diff --git a/docs/plans/2026-04-14-h5-prototype-implementation.md b/docs/plans/2026-04-14-h5-prototype-implementation.md new file mode 100644 index 0000000..3405b16 --- /dev/null +++ b/docs/plans/2026-04-14-h5-prototype-implementation.md @@ -0,0 +1,2911 @@ +# 郑州智慧工地 H5 原型实施计划 + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 构建可运行的 H5 原型,包含 10 个页面、Mock 数据、智慧工地风 UI,运行在浏览器中直接打开。 + +**Architecture:** 纯前端 HTML/CSS/JS,无构建步骤。Mock 数据内嵌于 JS,所有 API 调用替换为本地 mock + setTimeout 模拟异步延迟。CSS 变量统一管理主题色。 + +**Tech Stack:** HTML5 / CSS3 / Vanilla JS / ECharts(CDN) / iconfont(CDN) + +--- + +## 前置准备 + +### Task 1: 创建项目目录结构 + +**Objective:** 创建 `h5/` 目录及子目录 + +**Files:** +- Create: `h5/css/` +- Create: `h5/js/` + +**Step 1: 创建目录** + +```bash +mkdir -p h5/css h5/js +``` + +**Step 2: 验证** + +```bash +ls -la h5/ +``` + +Expected output: +``` +h5/ +├── css/ +└── js/ +``` + +--- + +### Task 2: 创建 CSS 变量定义文件 + +**Objective:** 建立主题色变量和全局重置样式 + +**Files:** +- Create: `h5/css/variables.css` + +**Step 1: 创建 variables.css** + +```css +/* 智慧工地主题色 */ +:root { + /* 主色 */ + --color-primary: #1e3a5f; + --color-primary-light: #2d5a8a; + --color-secondary: #3b82f6; + + /* 警示色 */ + --color-danger: #f97316; + --color-warning: #eab308; + --color-success: #22c55e; + + /* 中性色 */ + --color-bg: #f8fafc; + --color-card: #ffffff; + --color-text: #1f2937; + --color-text-secondary: #6b7280; + --color-border: #e5e7eb; + + /* 状态色 */ + --color-online: #22c55e; + --color-offline: #9ca3af; + + /* 字体 */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', Consolas, monospace; +} + +/* 全局重置 */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + font-family: var(--font-family); + background-color: var(--color-bg); + color: var(--color-text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiwebkit; + -webkit-tap-highlight-color: transparent; +} + +a { + color: var(--color-secondary); + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; +} + +input, textarea, select { + font-family: inherit; + font-size: inherit; +} + +/* 工具类 */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.p-3 { padding: 12px; } +.p-4 { padding: 16px; } +.mt-2 { margin-top: 8px; } +.mt-3 { margin-top: 12px; } +.mb-3 { margin-bottom: 12px; } +.text-sm { font-size: 12px; } +.text-secondary { color: var(--color-text-secondary); } +.font-bold { font-weight: 700; } +.text-center { text-align: center; } +.w-full { width: 100%; } +.hidden { display: none; } +``` + +**Step 3: 验证文件存在** + +```bash +cat h5/css/variables.css | head -20 +``` + +Expected: 包含 `--color-primary: #1e3a5f` 等变量定义 + +--- + +### Task 3: 创建全局样式文件 + +**Objective:** 复用样式组件:按钮、卡片、Tab栏、顶栏 + +**Files:** +- Create: `h5/css/style.css` +- Modify: `h5/css/variables.css` (已创建) + +**Step 1: 创建 style.css** + +```css +@import 'variables.css'; + +/* ===== 页面容器 ===== */ +.page { + min-height: 100vh; + padding-bottom: 60px; /* Tab 栏高度 */ +} + +/* ===== 顶栏 ===== */ +.header { + position: sticky; + top: 0; + z-index: 100; + background: var(--color-primary); + color: #fff; + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 16px; + font-weight: 600; +} + +.header-back { + color: #fff; + font-size: 20px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.15); + border-radius: 6px; +} + +.header-title { + flex: 1; + text-align: center; +} + +.header-right { + width: 32px; +} + +/* ===== 卡片 ===== */ +.card { + background: var(--color-card); + border-radius: 12px; + padding: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 12px; +} + +/* ===== 统计卡片 ===== */ +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px; +} + +.stat-card { + background: var(--color-card); + border-radius: 12px; + padding: 16px; + text-align: center; +} + +.stat-label { + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 4px; +} + +.stat-value { + font-size: 24px; + font-weight: 700; + font-family: var(--font-mono); +} + +.stat-sub { + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 2px; +} + +/* ===== 按钮 ===== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + transition: opacity 0.2s; +} + +.btn:active { opacity: 0.85; } + +.btn-primary { + background: var(--color-primary); + color: #fff; +} + +.btn-secondary { + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-danger { + background: var(--color-danger); + color: #fff; +} + +.btn-block { width: 100%; } + +/* ===== 底部 Tab 栏 ===== */ +.tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 60px; + background: var(--color-card); + border-top: 1px solid var(--color-border); + display: flex; + z-index: 200; +} + +.tab-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + color: var(--color-text-secondary); + font-size: 10px; + transition: color 0.2s; +} + +.tab-item.active { + color: var(--color-primary); +} + +.tab-item svg, .tab-item span { + display: block; +} + +/* ===== 子 Tab ===== */ +.sub-tabs { + display: flex; + background: var(--color-card); + border-bottom: 1px solid var(--color-border); + padding: 0 12px; +} + +.sub-tab { + padding: 10px 16px; + font-size: 13px; + color: var(--color-text-secondary); + border-bottom: 2px solid transparent; +} + +.sub-tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* ===== 列表项 ===== */ +.list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--color-card); + border-bottom: 1px solid var(--color-border); +} + +.list-item:active { background: var(--color-bg); } + +.list-item-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--color-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.list-item-content { flex: 1; min-width: 0; } + +.list-item-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-item-desc { + font-size: 12px; + color: var(--color-text-secondary); +} + +.list-item-arrow { + color: var(--color-text-secondary); + font-size: 18px; +} + +/* ===== 设备卡片网格 ===== */ +.device-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px; +} + +.device-card { + background: var(--color-card); + border-radius: 12px; + padding: 14px; + cursor: pointer; +} + +.device-card:active { opacity: 0.85; } + +.device-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.device-card-icon { + font-size: 24px; +} + +.device-card-status { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.device-card-status.online { background: var(--color-online); } +.device-card-status.offline { background: var(--color-offline); } + +.device-card-name { + font-weight: 600; + font-size: 14px; + margin-bottom: 4px; +} + +.device-card-model { + font-size: 12px; + color: var(--color-text-secondary); +} + +.device-card-location { + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 4px; +} + +/* ===== 预警列表项 ===== */ +.alert-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + background: var(--color-card); + border-bottom: 1px solid var(--color-border); + cursor: pointer; +} + +.alert-item:active { background: var(--color-bg); } + +.alert-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.alert-icon.danger { background: #fff3e6; color: var(--color-danger); } +.alert-icon.warning { background: #fef9c3; color: var(--color-warning); } +.alert-icon.handled { background: #f3f4f6; color: var(--color-text-secondary); } + +.alert-content { flex: 1; min-width: 0; } + +.alert-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 2px; +} + +.alert-meta { + font-size: 12px; + color: var(--color-text-secondary); +} + +/* ===== 表单 ===== */ +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 6px; +} + +.form-label .required { color: var(--color-danger); } + +.form-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 14px; + background: var(--color-card); + transition: border-color 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--color-secondary); +} + +textarea.form-input { + resize: vertical; + min-height: 80px; +} + +.form-select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 14px; + background: var(--color-card); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} + +/* ===== 图片上传 ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.photo-item { + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + background: var(--color-bg); + display: flex; + align-items: center; + justify-content: center; +} + +.photo-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.photo-add { + border: 1px dashed var(--color-border); + color: var(--color-text-secondary); + font-size: 28px; + cursor: pointer; +} + +/* ===== Toast 提示 ===== */ +.toast { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,0.8); + color: #fff; + padding: 10px 20px; + border-radius: 24px; + font-size: 13px; + z-index: 9999; + animation: toast-in 0.3s ease; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(-10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ===== 页面内容区 ===== */ +.page-content { + padding: 12px; +} + +/* ===== 空状态 ===== */ +.empty-state { + padding: 40px 20px; + text-align: center; + color: var(--color-text-secondary); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.5; +} + +.empty-state-text { + font-size: 14px; +} +``` + +**Step 2: 验证** + +```bash +wc -l h5/css/style.css +``` + +Expected: `> 200` 行 + +--- + +## Mock 数据层 + +### Task 4: 创建 Mock 数据文件 + +**Objective:** 提供全量 Mock 数据供所有页面使用 + +**Files:** +- Create: `h5/js/mock.js` + +**Step 1: 创建 mock.js** + +```javascript +// ============================ +// Mock 数据 — 郑州智慧工地 H5 +// ============================ + +// 设备 Mock 数据 +const MOCK_DEVICES = [ + { + id: 'tc001', + name: '1号塔吊', + type: 'tower_crane', + model: 'QTZ500', + location: 'A区施工现场', + status: 'online', + lastSeen: '2026-04-14 10:30:00', + }, + { + id: 'tc002', + name: '2号塔吊', + type: 'tower_crane', + model: 'QTZ630', + location: 'B区施工现场', + status: 'online', + lastSeen: '2026-04-14 10:29:00', + }, + { + id: 'tc003', + name: '3号塔吊', + type: 'tower_crane', + model: 'QTZ500', + location: 'C区施工现场', + status: 'offline', + lastSeen: '2026-04-13 18:00:00', + }, + { + id: 'el001', + name: '1号升降机', + type: 'elevator', + model: 'SC200/200', + location: 'A区主楼', + status: 'online', + lastSeen: '2026-04-14 10:30:00', + }, + { + id: 'el002', + name: '2号升降机', + type: 'elevator', + model: 'SC200/200', + location: 'B区主楼', + status: 'online', + lastSeen: '2026-04-14 10:30:00', + }, +]; + +// 设备实时数据 Mock +const MOCK_REALTIME = { + tc001: { + weight: 45.2, windSpeed: 5.2, windLevel: 3, + range: 30.5, height: 85.0, angle: 120.5, + moment: 320.5, momentPercent: 85.3, + alert: 'danger', alertMsg: '载重超限', + }, + tc002: { + weight: 32.1, windSpeed: 4.8, windLevel: 2, + range: 25.0, height: 92.3, angle: 45.2, + moment: 210.5, momentPercent: 55.8, + alert: null, alertMsg: null, + }, + tc003: { + weight: 0, windSpeed: 0, windLevel: 0, + range: 0, height: 0, angle: 0, + moment: 0, momentPercent: 0, + alert: null, alertMsg: null, + }, + el001: { + realtimeWeight: 1.2, realtimeSpeed: 1.0, + realtimeHeight: 45.5, realtimeDipX: 0.5, + realtimeDipY: 0.3, outDoorStatus: '0', + alert: 'warning', alertMsg: '风速过大', + }, + el002: { + realtimeWeight: 0.8, realtimeSpeed: 0.8, + realtimeHeight: 28.0, realtimeDipX: 0.3, + realtimeDipY: 0.2, outDoorStatus: '0', + alert: null, alertMsg: null, + }, +}; + +// 预警 Mock 数据 +const MOCK_ALERTS = [ + { + id: 'al001', + deviceId: 'tc001', + deviceName: '1号塔吊', + deviceType: 'tower_crane', + level: 'danger', + message: '载重超限:当前45.2kN,超过额定值40kN', + metric: 'weight', + value: 45.2, + createdAt: '2026-04-14 10:25:00', + status: 'unread', + }, + { + id: 'al002', + deviceId: 'el001', + deviceName: '1号升降机', + deviceType: 'elevator', + level: 'warning', + message: '风速过大:当前8.5m/s,超过阈值6m/s', + metric: 'windSpeed', + value: 8.5, + createdAt: '2026-04-14 10:20:00', + status: 'unread', + }, + { + id: 'al003', + deviceId: 'tc002', + deviceName: '2号塔吊', + deviceType: 'tower_crane', + level: 'warning', + message: '力矩超限:当前210.5kN·m,超过额定值180kN·m', + metric: 'moment', + value: 210.5, + createdAt: '2026-04-14 09:15:00', + status: 'handled', + handleNote: '已现场确认,正常吊装作业', + }, + { + id: 'al004', + deviceId: 'tc001', + deviceName: '1号塔吊', + deviceType: 'tower_crane', + level: 'danger', + message: '回转角度异常:回转速度过快', + metric: 'angle', + value: 180.0, + createdAt: '2026-04-14 08:30:00', + status: 'handled', + handleNote: '已通知操作员减速', + }, + { + id: 'al005', + deviceId: 'el001', + deviceName: '1号升降机', + deviceType: 'elevator', + level: 'warning', + message: '高度接近上限:当前高度45.5m,接近限高50m', + metric: 'height', + value: 45.5, + createdAt: '2026-04-13 16:45:00', + status: 'ignored', + }, + { + id: 'al006', + deviceId: 'tc003', + deviceName: '3号塔吊', + deviceType: 'tower_crane', + level: 'warning', + message: '设备离线:超过2小时无数据上报', + metric: 'offline', + value: 0, + createdAt: '2026-04-13 20:00:00', + status: 'unread', + }, +]; + +// 隐患随手拍 Mock +const MOCK_REPORTS = [ + { + id: 'rep001', + desc: 'A区基坑临边防护栏缺失,存在高处坠落风险', + category: '高空坠落', + severity: '较大', + gps: '34.7567,113.6234', + address: '郑州市中原区A区基坑现场', + photos: [], + createdAt: '2026-04-14 09:30:00', + }, + { + id: 'rep002', + desc: 'B区钢筋加工区配电箱门未关闭', + category: '触电', + severity: '一般', + gps: '34.7570,113.6240', + address: '郑州市中原区B区钢筋棚', + photos: [], + createdAt: '2026-04-13 15:20:00', + }, + { + id: 'rep003', + desc: 'C区脚手架扣件松动', + category: '物体打击', + severity: '重大', + gps: '34.7565,113.6228', + address: '郑州市中原区C区脚手架', + photos: [], + createdAt: '2026-04-12 11:10:00', + }, +]; + +// 施工日志 Mock +const MOCK_LOGS = [ + { + id: 'log001', + date: '2026-04-14', + part: 'A区主楼', + content: '完成3层混凝土浇筑,钢筋绑扎至4层', + workers: 28, + equipment: ['tower_crane', 'elevator'], + safety: '无安全问题', + note: '混凝土浇筑顺利', + attachments: [], + createdAt: '2026-04-14 18:00:00', + }, + { + id: 'log002', + date: '2026-04-13', + part: 'B区地下室', + content: '地下室西侧土方开挖,外运50车', + workers: 18, + equipment: ['elevator'], + safety: '注意坑边防护', + note: '夜间出土暂停', + attachments: [], + createdAt: '2026-04-13 18:30:00', + }, + { + id: 'log003', + date: '2026-04-12', + part: 'C区基坑', + content: '基坑支护施工,锚索张拉', + workers: 12, + equipment: [], + safety: '锚索张拉时无关人员远离', + note: '', + attachments: [], + createdAt: '2026-04-12 17:45:00', + }, +]; + +// 用户 Mock +const MOCK_USER = { + id: 1, + username: 'admin', + realName: '张工地', + role: '管理员', + phone: '138****1234', +}; + +// 辅助函数 +function getDeviceById(id) { + return MOCK_DEVICES.find(d => d.id === id); +} + +function getAlertById(id) { + return MOCK_ALERTS.find(a => a.id === id); +} + +function getRealtimeById(id) { + return MOCK_REALTIME[id] || null; +} +``` + +**Step 2: 验证** + +```bash +grep -c "MOCK_DEVICES\|MOCK_ALERTS\|MOCK_REPORTS\|MOCK_LOGS" h5/js/mock.js +``` + +Expected: `4` + +--- + +## JS 基础设施 + +### Task 5: 创建 API 封装文件 + +**Objective:** 提供统一的 API 调用封装,所有请求走 Mock + +**Files:** +- Create: `h5/js/api.js` + +**Step 1: 创建 api.js** + +```javascript +// ============================ +// API 基础封装 — Mock 模式 +// ============================ + +const API_BASE = '/v1'; + +// 登录 +function apiLogin(username, password) { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + code: 0, + data: { + token: 'mock-token-' + Date.now(), + expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + user: MOCK_USER, + } + }); + }, 500); + }); +} + +// 获取设备列表 +function apiGetDevices(params = {}) { + return new Promise((resolve) => { + setTimeout(() => { + let items = [...MOCK_DEVICES]; + if (params.type === 'tower_crane') { + items = items.filter(d => d.type === 'tower_crane'); + } else if (params.type === 'elevator') { + items = items.filter(d => d.type === 'elevator'); + } + resolve({ code: 0, data: { total: items.length, items } }); + }, 300); + }); +} + +// 获取设备实时数据 +function apiGetDeviceRealtime(deviceId) { + return new Promise((resolve) => { + setTimeout(() => { + const data = getRealtimeById(deviceId); + resolve({ code: 0, data }); + }, 200); + }); +} + +// 获取预警列表 +function apiGetAlerts(params = {}) { + return new Promise((resolve) => { + setTimeout(() => { + let items = [...MOCK_ALERTS]; + if (params.level === 'danger') { + items = items.filter(a => a.level === 'danger'); + } else if (params.level === 'warning') { + items = items.filter(a => a.level === 'warning'); + } else if (params.status === 'handled') { + items = items.filter(a => a.status === 'handled' || a.status === 'ignored'); + } else if (params.status === 'unread') { + items = items.filter(a => a.status === 'unread'); + } + const unreadCount = MOCK_ALERTS.filter(a => a.status === 'unread').length; + resolve({ code: 0, data: { total: items.length, unreadCount, items } }); + }, 300); + }); +} + +// 获取预警详情 +function apiGetAlertDetail(alertId) { + return new Promise((resolve) => { + setTimeout(() => { + const alert = getAlertById(alertId); + resolve({ code: 0, data: alert }); + }, 200); + }); +} + +// 处理预警 +function apiHandleAlert(alertId, action, note) { + return new Promise((resolve) => { + setTimeout(() => { + const alert = MOCK_ALERTS.find(a => a.id === alertId); + if (alert) { + alert.status = action === 'handled' ? 'handled' : 'ignored'; + if (note) alert.handleNote = note; + } + resolve({ code: 0, message: '操作成功' }); + }, 500); + }); +} + +// 提交隐患随手拍 +function apiSubmitReport(formData) { + return new Promise((resolve) => { + setTimeout(() => { + const newReport = { + id: 'rep' + Date.now(), + ...formData, + createdAt: new Date().toLocaleString('zh-CN'), + }; + MOCK_REPORTS.unshift(newReport); + resolve({ code: 0, data: newReport }); + }, 800); + }); +} + +// 获取随手拍记录 +function apiGetReports() { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ code: 0, data: { total: MOCK_REPORTS.length, items: MOCK_REPORTS } }); + }, 300); + }); +} + +// 获取施工日志列表 +function apiGetLogs() { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ code: 0, data: { total: MOCK_LOGS.length, items: MOCK_LOGS } }); + }, 300); + }); +} + +// 提交施工日志 +function apiSubmitLog(formData) { + return new Promise((resolve) => { + setTimeout(() => { + const newLog = { + id: 'log' + Date.now(), + ...formData, + createdAt: new Date().toLocaleString('zh-CN'), + }; + MOCK_LOGS.unshift(newLog); + resolve({ code: 0, data: newLog }); + }, 800); + }); +} + +// OSS 预签名 URL(Mock) +function apiGetUploadToken(filename, contentType) { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + code: 0, + data: { + uploadUrl: 'https://jesxion-ai-studio.oss-cn-beijing.aliyuncs.com/mock/' + filename, + objectKey: 'mock/' + filename, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + } + }); + }, 300); + }); +} +``` + +**Step 2: 验证** + +```bash +grep -c "^function api" h5/js/api.js +``` + +Expected: `10` + +--- + +### Task 6: 创建全局 App 逻辑文件 + +**Objective:** 全局逻辑:登录状态检查、Tab 栏激活、页面跳转、Toast 工具 + +**Files:** +- Create: `h5/js/app.js` + +**Step 1: 创建 app.js** + +```javascript +// ============================ +// 全局 App 逻辑 +// ============================ + +// ============ 登录状态 ============ + +function getToken() { + return localStorage.getItem('token'); +} + +function setToken(token) { + localStorage.setItem('token', token); +} + +function clearToken() { + localStorage.removeItem('token'); +} + +function isLoggedIn() { + return !!getToken(); +} + +// 检查登录,未登录跳转登录页 +function requireAuth() { + if (!isLoggedIn()) { + location.href = 'login.html'; + return false; + } + return true; +} + +// ============ Toast ============ + +function showToast(message, duration = 2000) { + const existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => toast.remove(), duration); +} + +// ============ Tab 栏激活 ============ + +function initTabBar() { + const path = location.pathname; + const page = path.split('/').pop() || 'index.html'; + + document.querySelectorAll('.tab-item').forEach(tab => { + const href = tab.getAttribute('href'); + if (href === './' + page || href === page) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); +} + +// ============ URL 参数解析 ============ + +function getQueryParam(key) { + const params = new URLSearchParams(location.search); + return params.get(key); +} + +// ============ 格式化 ============ + +function formatTime(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return dateStr; + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return `${month}-${day} ${h}:${m}`; +} + +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return dateStr; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// ============ 设备类型图标 ============ + +function getDeviceIcon(type) { + return type === 'tower_crane' ? '🏗️' : '🛗'; +} + +// ============ 预警图标 ============ + +function getAlertIcon(level) { + if (level === 'danger') return '🔴'; + if (level === 'warning') return '🟡'; + return '⚫'; +} + +// ============ 设备在线状态 ============ + +function getStatusClass(status) { + return status === 'online' ? 'online' : 'offline'; +} + +function getStatusText(status) { + return status === 'online' ? '在线' : '离线'; +} +``` + +**Step 2: 验证** + +```bash +grep -c "^function " h5/js/app.js +``` + +Expected: `> 8` + +--- + +## 页面实现 + +### Task 7: 创建登录页 + +**Objective:** 账号密码登录,Mock 任意账号密码可登录 + +**Files:** +- Create: `h5/login.html` + +**Step 1: 创建 login.html** + +```html + + +
+ + +