Files
smart-project/docs/plans/2026-04-14-h5-prototype-implementation.md

78 KiB
Raw Blame History

郑州智慧工地 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: 创建目录

mkdir -p h5/css h5/js

Step 2: 验证

ls -la h5/

Expected output:

h5/
├── css/
└── js/

Task 2: 创建 CSS 变量定义文件

Objective: 建立主题色变量和全局重置样式

Files:

  • Create: h5/css/variables.css

Step 1: 创建 variables.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: 验证文件存在

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

@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: 验证

wc -l h5/css/style.css

Expected: > 200


Mock 数据层

Task 4: 创建 Mock 数据文件

Objective: 提供全量 Mock 数据供所有页面使用

Files:

  • Create: h5/js/mock.js

Step 1: 创建 mock.js

// ============================
// 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: 验证

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

// ============================
// 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 预签名 URLMock
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: 验证

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

// ============================
// 全局 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: 验证

grep -c "^function " h5/js/app.js

Expected: > 8


页面实现

Task 7: 创建登录页

Objective: 账号密码登录Mock 任意账号密码可登录

Files:

  • Create: h5/login.html

Step 1: 创建 login.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>登录 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .login-page {
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      justify-content: center;
      padding: 20px;
      background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
    }
    .login-logo {
      text-align: center;
      margin-bottom: 40px;
    }
    .login-logo-icon {
      font-size: 64px;
      margin-bottom: 12px;
    }
    .login-logo-text {
      font-size: 20px;
      font-weight: 700;
      color: #fff;
    }
    .login-form {
      background: var(--color-card);
      border-radius: 16px;
      padding: 32px 24px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.15);
    }
    .login-title {
      font-size: 18px;
      font-weight: 700;
      text-align: center;
      margin-bottom: 24px;
      color: var(--color-text);
    }
  </style>
</head>
<body>
  <div class="login-page">
    <div class="login-logo">
      <div class="login-logo-icon">🏗️</div>
      <div class="login-logo-text">郑州智慧工地</div>
    </div>
    <div class="login-form">
      <div class="login-title">账号登录</div>
      <div class="form-group">
        <label class="form-label">账号</label>
        <input type="text" id="username" class="form-input" placeholder="请输入账号" value="admin">
      </div>
      <div class="form-group">
        <label class="form-label">密码</label>
        <input type="password" id="password" class="form-input" placeholder="请输入密码" value="123456">
      </div>
      <button class="btn btn-primary btn-block" id="loginBtn">登录</button>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script>
    // 如果已登录直接跳转首页
    if (isLoggedIn()) location.href = 'index.html';

    document.getElementById('loginBtn').addEventListener('click', async () => {
      const username = document.getElementById('username').value.trim();
      const password = document.getElementById('password').value;

      if (!username || !password) {
        showToast('请输入账号和密码');
        return;
      }

      const btn = document.getElementById('loginBtn');
      btn.disabled = true;
      btn.textContent = '登录中...';

      try {
        const res = await apiLogin(username, password);
        if (res.code === 0) {
          setToken(res.data.token);
          showToast('登录成功');
          setTimeout(() => location.href = 'index.html', 500);
        } else {
          showToast(res.message || '登录失败');
        }
      } catch (e) {
        showToast('网络异常');
      } finally {
        btn.disabled = false;
        btn.textContent = '登录';
      }
    });
  </script>
</body>
</html>

Step 2: 验证

grep -c "apiLogin\|isLoggedIn\|setToken" h5/login.html

Expected: 3


Task 8: 创建首页仪表盘

Objective: 设备统计卡片 + 预警统计卡片 + 快捷入口

Files:

  • Create: h5/index.html

Step 1: 创建 index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>首页 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <div style="width:32px"></div>
      <div class="header-title">郑州智慧工地</div>
      <a href="profile.html" class="header-right" style="color:#fff;font-size:20px;display:flex;align-items:center;">👤</a>
    </div>

    <!-- 内容 -->
    <div class="page-content">
      <!-- 设备统计 -->
      <div class="card mb-3">
        <div class="card-title">设备概况</div>
        <div class="stat-grid">
          <div class="stat-card">
            <div class="stat-label">塔吊</div>
            <div class="stat-value" id="tcCount">-</div>
            <div class="stat-sub" id="tcOnline">- 在线</div>
          </div>
          <div class="stat-card">
            <div class="stat-label">升降机</div>
            <div class="stat-value" id="elCount">-</div>
            <div class="stat-sub" id="elOnline">- 在线</div>
          </div>
        </div>
      </div>

      <!-- 预警统计 -->
      <div class="card mb-3">
        <div class="card-title">今日预警</div>
        <div class="stat-grid">
          <div class="stat-card" style="border-left:3px solid var(--color-danger)">
            <div class="stat-label" style="color:var(--color-danger)">危险</div>
            <div class="stat-value" style="color:var(--color-danger)" id="dangerCount">-</div>
          </div>
          <div class="stat-card" style="border-left:3px solid var(--color-warning)">
            <div class="stat-label" style="color:var(--color-warning)">警告</div>
            <div class="stat-value" style="color:var(--color-warning)" id="warningCount">-</div>
          </div>
        </div>
      </div>

      <!-- 最新预警列表 -->
      <div class="card">
        <div class="flex justify-between items-center mb-3">
          <div class="card-title" style="margin-bottom:0">最新预警</div>
          <a href="alerts.html" style="font-size:13px;color:var(--color-secondary)">查看全部 </a>
        </div>
        <div id="latestAlerts">
          <div class="empty-state-text text-center" style="padding:20px">加载中...</div>
        </div>
      </div>
    </div>

    <!-- 底部 Tab 栏 -->
    <div class="tab-bar">
      <a href="index.html" class="tab-item active">
        <span style="font-size:22px">🏠</span>
        <span>首页</span>
      </a>
      <a href="devices.html" class="tab-item">
        <span style="font-size:22px">🏗️</span>
        <span>设备</span>
      </a>
      <a href="report.html" class="tab-item">
        <span style="font-size:22px">📷</span>
        <span>随手拍</span>
      </a>
      <a href="logs.html" class="tab-item">
        <span style="font-size:22px">📋</span>
        <span>日志</span>
      </a>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    async function loadDashboard() {
      // 加载设备统计
      const [devRes, alertRes] = await Promise.all([
        apiGetDevices(),
        apiGetAlerts(),
      ]);

      const devices = devRes.data.items;
      const alerts = alertRes.data.items;

      const tcDevices = devices.filter(d => d.type === 'tower_crane');
      const elDevices = devices.filter(d => d.type === 'elevator');
      const dangerAlerts = alerts.filter(a => a.level === 'danger' && a.status === 'unread');
      const warningAlerts = alerts.filter(a => a.level === 'warning' && a.status === 'unread');

      document.getElementById('tcCount').textContent = tcDevices.length;
      document.getElementById('tcOnline').textContent = tcDevices.filter(d => d.status === 'online').length + ' 在线';
      document.getElementById('elCount').textContent = elDevices.length;
      document.getElementById('elOnline').textContent = elDevices.filter(d => d.status === 'online').length + ' 在线';
      document.getElementById('dangerCount').textContent = dangerAlerts.length;
      document.getElementById('warningCount').textContent = warningAlerts.length;

      // 最新预警前3条
      const latestAlerts = alerts.slice(0, 3);
      const alertsEl = document.getElementById('latestAlerts');

      if (latestAlerts.length === 0) {
        alertsEl.innerHTML = '<div class="empty-state-text text-center">暂无预警</div>';
      } else {
        alertsEl.innerHTML = latestAlerts.map(a => `
          <a href="alert.html?id=${a.id}" class="list-item" style="padding:12px 0;border-bottom:1px solid var(--color-border)">
            <div style="font-size:18px">${getAlertIcon(a.level)}</div>
            <div class="list-item-content">
              <div class="list-item-title">${a.message.substring(0, 20)}...</div>
              <div class="list-item-desc">${a.deviceName} · ${formatTime(a.createdAt)}</div>
            </div>
          </a>
        `).join('');
      }
    }

    loadDashboard();
  </script>
</body>
</html>

Step 2: 验证

grep -c "apiGetDevices\|apiGetAlerts\|requireAuth" h5/index.html

Expected: 3


Task 9: 创建设备列表页

Objective: 卡片网格展示Tab 子导航(全部/塔吊/升降机)

Files:

  • Create: h5/devices.html

Step 1: 创建 devices.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>设备列表 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="index.html" class="header-back"></a>
      <div class="header-title">设备列表</div>
      <div style="width:32px"></div>
    </div>

    <!-- 子 Tab -->
    <div class="sub-tabs" id="subTabs">
      <div class="sub-tab active" data-type="">全部</div>
      <div class="sub-tab" data-type="tower_crane">塔吊</div>
      <div class="sub-tab" data-type="elevator">升降机</div>
    </div>

    <!-- 设备网格 -->
    <div class="device-grid" id="deviceGrid">
      <div class="empty-state-text text-center" style="grid-column:1/-1;padding:40px">加载中...</div>
    </div>

    <!-- 底部 Tab 栏 -->
    <div class="tab-bar">
      <a href="index.html" class="tab-item">
        <span style="font-size:22px">🏠</span>
        <span>首页</span>
      </a>
      <a href="devices.html" class="tab-item active">
        <span style="font-size:22px">🏗️</span>
        <span>设备</span>
      </a>
      <a href="report.html" class="tab-item">
        <span style="font-size:22px">📷</span>
        <span>随手拍</span>
      </a>
      <a href="logs.html" class="tab-item">
        <span style="font-size:22px">📋</span>
        <span>日志</span>
      </a>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    let currentType = '';

    function renderDevices(devices) {
      const grid = document.getElementById('deviceGrid');
      if (devices.length === 0) {
        grid.innerHTML = '<div class="empty-state-text text-center" style="grid-column:1/-1;padding:40px">暂无设备</div>';
        return;
      }
      grid.innerHTML = devices.map(d => `
        <a href="device.html?id=${d.id}" class="device-card">
          <div class="device-card-header">
            <span class="device-card-icon">${getDeviceIcon(d.type)}</span>
            <span class="device-card-status ${getStatusClass(d.status)}" title="${getStatusText(d.status)}"></span>
          </div>
          <div class="device-card-name">${d.name}</div>
          <div class="device-card-model">${d.model}</div>
          <div class="device-card-location">📍 ${d.location}</div>
        </a>
      `).join('');
    }

    async function loadDevices(type = '') {
      currentType = type;
      const res = await apiGetDevices({ type });
      renderDevices(res.data.items);
    }

    // 子 Tab 切换
    document.getElementById('subTabs').addEventListener('click', e => {
      const tab = e.target.closest('.sub-tab');
      if (!tab) return;
      document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
      tab.classList.add('active');
      loadDevices(tab.dataset.type);
    });

    loadDevices();
  </script>
</body>
</html>

Step 2: 验证

grep -c "device-grid\|device-card\|apiGetDevices" h5/devices.html

Expected: 3


Task 10: 创建设备详情页

Objective: 实时数据面板,每 30 秒自动刷新

Files:

  • Create: h5/device.html

Step 1: 创建 device.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>设备详情 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .data-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1px;
      background: var(--color-border);
      border-radius: 8px;
      overflow: hidden;
    }
    .data-item {
      background: var(--color-card);
      padding: 14px;
      text-align: center;
    }
    .data-label {
      font-size: 12px;
      color: var(--color-text-secondary);
      margin-bottom: 4px;
    }
    .data-value {
      font-size: 18px;
      font-weight: 700;
      font-family: var(--font-mono);
    }
    .data-unit {
      font-size: 12px;
      font-weight: 400;
      color: var(--color-text-secondary);
    }
    .alert-banner {
      background: #fff3e6;
      border: 1px solid var(--color-danger);
      border-radius: 8px;
      padding: 10px 14px;
      margin-top: 12px;
      display: flex;
      align-items: center;
      gap: 8px;
      color: var(--color-danger);
      font-size: 13px;
    }
    .refresh-bar {
      padding: 8px 16px;
      text-align: center;
      font-size: 12px;
      color: var(--color-text-secondary);
    }
  </style>
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="devices.html" class="header-back"></a>
      <div class="header-title" id="pageTitle">设备详情</div>
      <div style="width:32px"></div>
    </div>

    <div class="page-content">
      <!-- 基本信息卡片 -->
      <div class="card mb-3">
        <div class="flex items-center gap-3 mb-3">
          <span style="font-size:36px" id="deviceIcon">🏗️</span>
          <div>
            <div style="font-size:16px;font-weight:700" id="deviceName">-</div>
            <div style="font-size:12px;color:var(--color-text-secondary)" id="deviceModel">-</div>
            <div style="font-size:12px;color:var(--color-text-secondary)" id="deviceLocation">-</div>
          </div>
          <div style="margin-left:auto;text-align:right">
            <span id="statusBadge" style="padding:4px 10px;border-radius:12px;font-size:12px;font-weight:600"></span>
            <div style="font-size:11px;color:var(--color-text-secondary);margin-top:4px" id="lastSeen">-</div>
          </div>
        </div>
      </div>

      <!-- 实时数据 -->
      <div class="card mb-3">
        <div class="card-title">实时数据</div>
        <div class="data-grid" id="dataGrid">
          <div class="empty-state-text text-center" style="grid-column:1/-1;padding:20px">加载中...</div>
        </div>
        <!-- 告警提示 -->
        <div class="alert-banner hidden" id="alertBanner">
          <span>⚠️</span>
          <span id="alertMsg"></span>
        </div>
      </div>

      <div class="refresh-bar">
        <span id="refreshTime">-</span> 自动刷新
        <button class="btn btn-secondary" style="padding:4px 12px;margin-left:8px;font-size:12px" onclick="loadRealtime()">刷新</button>
      </div>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    const deviceId = getQueryParam('id');
    if (!deviceId) location.href = 'devices.html';

    let deviceInfo = null;

    function renderRealtime(data, type) {
      const grid = document.getElementById('dataGrid');
      if (!data) {
        grid.innerHTML = '<div class="empty-state-text text-center" style="grid-column:1/-1;padding:20px">暂无数据</div>';
        return;
      }

      if (type === 'tower_crane') {
        grid.innerHTML = `
          <div class="data-item"><div class="data-label">载重</div><div class="data-value">${data.weight} <span class="data-unit">t</span></div></div>
          <div class="data-item"><div class="data-label">风速</div><div class="data-value">${data.windSpeed} <span class="data-unit">m/s</span></div></div>
          <div class="data-item"><div class="data-label">幅度</div><div class="data-value">${data.range} <span class="data-unit">m</span></div></div>
          <div class="data-item"><div class="data-label">起升高度</div><div class="data-value">${data.height} <span class="data-unit">m</span></div></div>
          <div class="data-item"><div class="data-label">回转角度</div><div class="data-value">${data.angle} <span class="data-unit">°</span></div></div>
          <div class="data-item"><div class="data-label">力矩百分比</div><div class="data-value">${data.momentPercent} <span class="data-unit">%</span></div></div>
        `;
      } else {
        const doorText = { '0': '全部关闭', '1': '全部打开', '2': '内笼门开', '3': '外笼门开' };
        grid.innerHTML = `
          <div class="data-item"><div class="data-label">载重</div><div class="data-value">${data.realtimeWeight} <span class="data-unit">t</span></div></div>
          <div class="data-item"><div class="data-label">运行速度</div><div class="data-value">${data.realtimeSpeed} <span class="data-unit">m/s</span></div></div>
          <div class="data-item"><div class="data-label">当前楼层</div><div class="data-value">${data.realtimeHeight} <span class="data-unit">m</span></div></div>
          <div class="data-item"><div class="data-label">X轴倾角</div><div class="data-value">${data.realtimeDipX} <span class="data-unit">°</span></div></div>
          <div class="data-item"><div class="data-label">Y轴倾角</div><div class="data-value">${data.realtimeDipY} <span class="data-unit">°</span></div></div>
          <div class="data-item"><div class="data-label">门状态</div><div class="data-value" style="font-size:14px">${doorText[data.outDoorStatus] || '-'}</div></div>
        `;
      }

      // 告警
      const alertBanner = document.getElementById('alertBanner');
      if (data.alert) {
        alertBanner.classList.remove('hidden');
        document.getElementById('alertMsg').textContent = data.alertMsg || '设备告警';
        alertBanner.style.background = data.alert === 'danger' ? '#fff3e6' : '#fef9c3';
        alertBanner.style.borderColor = data.alert === 'danger' ? 'var(--color-danger)' : 'var(--color-warning)';
        alertBanner.style.color = data.alert === 'danger' ? 'var(--color-danger)' : 'var(--color-warning)';
      } else {
        alertBanner.classList.add('hidden');
      }

      document.getElementById('refreshTime').textContent = new Date().toLocaleTimeString('zh-CN');
    }

    async function loadRealtime() {
      const [devRes, rtRes] = await Promise.all([
        apiGetDevices(),
        apiGetDeviceRealtime(deviceId),
      ]);

      if (!deviceInfo) {
        deviceInfo = devRes.data.items.find(d => d.id === deviceId);
        if (!deviceInfo) { location.href = 'devices.html'; return; }
        document.getElementById('pageTitle').textContent = deviceInfo.name;
        document.getElementById('deviceIcon').textContent = getDeviceIcon(deviceInfo.type);
        document.getElementById('deviceName').textContent = deviceInfo.name;
        document.getElementById('deviceModel').textContent = deviceInfo.model;
        document.getElementById('deviceLocation').textContent = '📍 ' + deviceInfo.location;
        document.getElementById('lastSeen').textContent = '最后在线: ' + deviceInfo.lastSeen;
        const badge = document.getElementById('statusBadge');
        badge.textContent = getStatusText(deviceInfo.status);
        badge.style.background = deviceInfo.status === 'online' ? '#dcfce7' : '#f3f4f6';
        badge.style.color = deviceInfo.status === 'online' ? 'var(--color-success)' : 'var(--color-text-secondary)';
      }

      renderRealtime(rtRes.data, deviceInfo.type);
    }

    loadRealtime();
    setInterval(loadRealtime, 30000); // 每30秒刷新
  </script>
</body>
</html>

Step 2: 验证

grep -c "deviceId\|apiGetDeviceRealtime\|setInterval" h5/device.html

Expected: 3


Task 11: 创建预警列表页

Objective: 最新优先Tab 筛选(全部/危险/警告/已处理)

Files:

  • Create: h5/alerts.html

Step 1: 创建 alerts.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>预警中心 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="index.html" class="header-back"></a>
      <div class="header-title">预警中心</div>
      <div style="width:32px"></div>
    </div>

    <!-- 子 Tab -->
    <div class="sub-tabs" id="subTabs">
      <div class="sub-tab active" data-filter="">全部</div>
      <div class="sub-tab" data-filter="danger">危险</div>
      <div class="sub-tab" data-filter="warning">警告</div>
      <div class="sub-tab" data-filter="handled">已处理</div>
    </div>

    <!-- 预警列表 -->
    <div id="alertList" style="background:var(--color-card);min-height:300px">
      <div class="empty-state-text text-center" style="padding:40px">加载中...</div>
    </div>

    <!-- 底部 Tab 栏 -->
    <div class="tab-bar">
      <a href="index.html" class="tab-item">
        <span style="font-size:22px">🏠</span>
        <span>首页</span>
      </a>
      <a href="devices.html" class="tab-item">
        <span style="font-size:22px">🏗️</span>
        <span>设备</span>
      </a>
      <a href="report.html" class="tab-item">
        <span style="font-size:22px">📷</span>
        <span>随手拍</span>
      </a>
      <a href="logs.html" class="tab-item">
        <span style="font-size:22px">📋</span>
        <span>日志</span>
      </a>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    let currentFilter = '';

    function renderAlerts(alerts) {
      const list = document.getElementById('alertList');
      if (alerts.length === 0) {
        list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🔔</div><div class="empty-state-text">暂无预警</div></div>';
        return;
      }
      list.innerHTML = alerts.map(a => `
        <a href="alert.html?id=${a.id}" class="alert-item">
          <div class="alert-icon ${a.level === 'danger' ? 'danger' : a.level === 'warning' ? 'warning' : 'handled'}">${getAlertIcon(a.level)}</div>
          <div class="alert-content">
            <div class="alert-title">${a.message.substring(0, 30)}${a.message.length > 30 ? '...' : ''}</div>
            <div class="alert-meta">${a.deviceName} · ${getDeviceIcon(a.deviceType)} · ${formatTime(a.createdAt)}</div>
          </div>
          <div class="list-item-arrow"></div>
        </a>
      `).join('');
    }

    async function loadAlerts(filter = '') {
      currentFilter = filter;
      const params = {};
      if (filter === 'danger') params.level = 'danger';
      else if (filter === 'warning') params.level = 'warning';
      else if (filter === 'handled') params.status = 'handled';
      else if (filter === 'unread') params.status = 'unread';

      const res = await apiGetAlerts(params);
      renderAlerts(res.data.items);
    }

    document.getElementById('subTabs').addEventListener('click', e => {
      const tab = e.target.closest('.sub-tab');
      if (!tab) return;
      document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
      tab.classList.add('active');
      loadAlerts(tab.dataset.filter);
    });

    loadAlerts();
  </script>
</body>
</html>

Step 2: 验证

grep -c "alert-item\|apiGetAlerts\|getAlertIcon" h5/alerts.html

Expected: 3


Task 12: 创建预警详情页

Objective: 预警详情 + 确认处理/忽略操作

Files:

  • Create: h5/alert.html

Step 1: 创建 alert.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>预警详情 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .alert-detail-header {
      padding: 20px 16px;
      text-align: center;
    }
    .alert-detail-icon {
      width: 60px;
      height: 60px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 28px;
      margin: 0 auto 12px;
    }
    .alert-detail-icon.danger { background: #fff3e6; }
    .alert-detail-icon.warning { background: #fef9c3; }
    .alert-detail-icon.handled { background: #f3f4f6; }
    .alert-detail-level {
      font-size: 16px;
      font-weight: 700;
      margin-bottom: 4px;
    }
    .alert-detail-level.danger { color: var(--color-danger); }
    .alert-detail-level.warning { color: var(--color-warning); }
    .info-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1px;
      background: var(--color-border);
      border-radius: 8px;
      overflow: hidden;
    }
    .info-item {
      background: var(--color-card);
      padding: 12px;
    }
    .info-label {
      font-size: 12px;
      color: var(--color-text-secondary);
      margin-bottom: 2px;
    }
    .info-value {
      font-size: 14px;
      font-weight: 600;
    }
    .action-area {
      padding: 16px;
      display: flex;
      gap: 12px;
    }
    .action-area .btn { flex: 1; }
  </style>
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="alerts.html" class="header-back"></a>
      <div class="header-title">预警详情</div>
      <div style="width:32px"></div>
    </div>

    <div id="alertContent">
      <div class="empty-state-text text-center" style="padding:40px">加载中...</div>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    const alertId = getQueryParam('id');
    if (!alertId) location.href = 'alerts.html';

    async function loadAlert() {
      const res = await apiGetAlertDetail(alertId);
      const a = res.data;
      if (!a) { showToast('预警不存在'); location.href = 'alerts.html'; return; }

      const levelText = { danger: '危险', warning: '警告', handled: '已处理', ignored: '已忽略' };
      const levelClass = { danger: 'danger', warning: 'warning' };

      const handledArea = a.status === 'unread' ? `
        <div class="action-area">
          <button class="btn btn-danger" onclick="handleAlert('handled')">确认处理</button>
          <button class="btn btn-secondary" onclick="handleAlert('ignored')">忽略</button>
        </div>
        <div style="padding:0 16px 16px">
          <div class="form-group mb-2">
            <label class="form-label">处理备注</label>
            <textarea class="form-input" id="handleNote" placeholder="选填,添加处理说明"></textarea>
          </div>
        </div>
      ` : `
        <div class="action-area">
          <div style="flex:1;text-align:center;color:var(--color-text-secondary);font-size:13px;padding:10px">
${a.status === 'handled' ? '处理' : '忽略'}${a.handleNote ? '' + a.handleNote : ''}
          </div>
        </div>
      `;

      document.getElementById('alertContent').innerHTML = `
        <div class="alert-detail-header">
          <div class="alert-detail-icon ${a.level === 'danger' ? 'danger' : a.level === 'warning' ? 'warning' : 'handled'}">${getAlertIcon(a.level)}</div>
          <div class="alert-detail-level ${a.level}">${levelText[a.level] || a.level}</div>
        </div>

        <div class="card mb-3" style="border-radius:0">
          <div style="font-size:15px;font-weight:600;margin-bottom:12px">${a.message}</div>
          <div class="info-grid">
            <div class="info-item">
              <div class="info-label">设备</div>
              <div class="info-value">${a.deviceName}</div>
            </div>
            <div class="info-item">
              <div class="info-label">发生时间</div>
              <div class="info-value">${a.createdAt}</div>
            </div>
            <div class="info-item">
              <div class="info-label">触发指标</div>
              <div class="info-value">${a.metric}</div>
            </div>
            <div class="info-item">
              <div class="info-label">当前值</div>
              <div class="info-value">${a.value || '-'}</div>
            </div>
          </div>
        </div>

        ${handledArea}
      `;
    }

    async function handleAlert(action) {
      const note = document.getElementById('handleNote')?.value || '';
      const res = await apiHandleAlert(alertId, action, note);
      if (res.code === 0) {
        showToast('操作成功');
        setTimeout(() => location.href = 'alerts.html', 500);
      } else {
        showToast(res.message || '操作失败');
      }
    }

    loadAlert();
  </script>
</body>
</html>

Step 2: 验证

grep -c "apiGetAlertDetail\|apiHandleAlert\|handleAlert" h5/alert.html

Expected: 3


Task 13: 创建隐患随手拍页面

Objective: 拍照 + 完整字段表单 + 直传 OSS

Files:

  • Create: h5/report.html

Step 1: 创建 report.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>隐患随手拍 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .gps-bar {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 10px 12px;
      background: var(--color-bg);
      border-radius: 8px;
      font-size: 12px;
      color: var(--color-text-secondary);
    }
    .gps-bar.active { color: var(--color-success); }
  </style>
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="index.html" class="header-back"></a>
      <div class="header-title">隐患随手拍</div>
      <a href="profile.html" style="color:#fff;font-size:18px">👤</a>
    </div>

    <div class="page-content">
      <!-- GPS 状态 -->
      <div class="gps-bar mb-3" id="gpsBar">
        <span>📍</span>
        <span id="gpsText">正在获取位置...</span>
      </div>

      <!-- 照片 -->
      <div class="card mb-3">
        <div class="card-title">现场照片 <span style="color:var(--color-danger)">*</span></div>
        <div class="photo-grid" id="photoGrid">
          <div class="photo-item photo-add" id="addPhotoBtn">+</div>
        </div>
        <input type="file" id="photoInput" accept="image/*" capture="environment" multiple style="display:none">
      </div>

      <!-- 表单 -->
      <div class="card mb-3">
        <div class="form-group">
          <label class="form-label">隐患描述 <span style="color:var(--color-danger)">*</span></label>
          <textarea class="form-input" id="descInput" placeholder="请描述隐患情况最多200字" maxlength="200"></textarea>
        </div>

        <div class="form-group">
          <label class="form-label">隐患类别 <span style="color:var(--color-danger)">*</span></label>
          <select class="form-select" id="categoryInput">
            <option value="">请选择类别</option>
            <option value="高空坠落">高空坠落</option>
            <option value="物体打击">物体打击</option>
            <option value="机械伤害">机械伤害</option>
            <option value="触电">触电</option>
            <option value="坍塌">坍塌</option>
            <option value="火灾">火灾</option>
            <option value="其他">其他</option>
          </select>
        </div>

        <div class="form-group">
          <label class="form-label">严重程度 <span style="color:var(--color-danger)">*</span></label>
          <div class="flex gap-3">
            <label class="flex items-center gap-2" style="cursor:pointer">
              <input type="radio" name="severity" value="一般"> 一般
            </label>
            <label class="flex items-center gap-2" style="cursor:pointer">
              <input type="radio" name="severity" value="较大"> 较大
            </label>
            <label class="flex items-center gap-2" style="cursor:pointer">
              <input type="radio" name="severity" value="重大"> 重大
            </label>
          </div>
        </div>
      </div>

      <button class="btn btn-primary btn-block" id="submitBtn" onclick="submitReport()">提交隐患</button>
    </div>

    <!-- 底部 Tab 栏 -->
    <div class="tab-bar">
      <a href="index.html" class="tab-item">
        <span style="font-size:22px">🏠</span>
        <span>首页</span>
      </a>
      <a href="devices.html" class="tab-item">
        <span style="font-size:22px">🏗️</span>
        <span>设备</span>
      </a>
      <a href="report.html" class="tab-item active">
        <span style="font-size:22px">📷</span>
        <span>随手拍</span>
      </a>
      <a href="logs.html" class="tab-item">
        <span style="font-size:22px">📋</span>
        <span>日志</span>
      </a>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    let photos = [];
    let gpsData = null;

    // GPS 获取
    function initGPS() {
      if (!navigator.geolocation) {
        document.getElementById('gpsText').textContent = '定位不可用';
        return;
      }
      navigator.geolocation.getCurrentPosition(
        pos => {
          gpsData = { lat: pos.coords.latitude, lng: pos.coords.longitude };
          document.getElementById('gpsBar').classList.add('active');
          document.getElementById('gpsText').textContent = `${gpsData.lat.toFixed(4)}, ${gpsData.lng.toFixed(4)}`;
        },
        () => {
          document.getElementById('gpsText').textContent = '定位失败,请检查权限';
        }
      );
    }

    // 照片选择
    document.getElementById('addPhotoBtn').addEventListener('click', () => {
      document.getElementById('photoInput').click();
    });

    document.getElementById('photoInput').addEventListener('change', e => {
      const files = Array.from(e.target.files);
      files.forEach(file => {
        const reader = new FileReader();
        reader.onload = ev => {
          photos.push({ file, dataUrl: ev.target.result });
          renderPhotos();
        };
        reader.readAsDataURL(file);
      });
      e.target.value = '';
    });

    function renderPhotos() {
      const grid = document.getElementById('photoGrid');
      const addBtn = '<div class="photo-item photo-add" id="addPhotoBtn">+</div>';
      grid.innerHTML = photos.map((p, i) => `
        <div class="photo-item">
          <img src="${p.dataUrl}" alt="照片${i+1}">
          <div onclick="removePhoto(${i})" style="position:absolute;top:0;right:0;background:rgba(0,0,0,0.5);color:#fff;width:18px;height:18px;border-radius:50%;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer">×</div>
        </div>
      `).join('') + (photos.length < 9 ? addBtn : '');
      document.getElementById('addPhotoBtn')?.addEventListener('click', () => document.getElementById('photoInput').click());
    }

    function removePhoto(index) {
      photos.splice(index, 1);
      renderPhotos();
    }

    async function submitReport() {
      const desc = document.getElementById('descInput').value.trim();
      const category = document.getElementById('categoryInput').value;
      const severity = document.querySelector('input[name="severity"]:checked')?.value;

      if (photos.length === 0) { showToast('请拍摄现场照片'); return; }
      if (!desc) { showToast('请填写隐患描述'); return; }
      if (!category) { showToast('请选择隐患类别'); return; }
      if (!severity) { showToast('请选择严重程度'); return; }

      const btn = document.getElementById('submitBtn');
      btn.disabled = true;
      btn.textContent = '提交中...';

      try {
        // Mock: 模拟上传图片到 OSS实际使用预签名 URL 直传)
        const formData = {
          desc,
          category,
          severity,
          gps: gpsData ? `${gpsData.lat},${gpsData.lng}` : '',
          photos: photos.map(p => p.dataUrl), // Mock 直接存 base64
        };

        const res = await apiSubmitReport(formData);
        if (res.code === 0) {
          showToast('提交成功');
          setTimeout(() => location.reload(), 800);
        } else {
          showToast(res.message || '提交失败');
        }
      } finally {
        btn.disabled = false;
        btn.textContent = '提交隐患';
      }
    }

    initGPS();
  </script>
</body>
</html>

Step 2: 验证

grep -c "navigator.geolocation\|apiSubmitReport\|photoInput" h5/report.html

Expected: 3


Task 14: 创建施工日志列表页

Objective: 日志列表 + 写日志入口 + 右上角个人中心入口

Files:

  • Create: h5/logs.html

Step 1: 创建 logs.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>施工日志 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .fab-btn {
      position: fixed;
      right: 20px;
      bottom: 80px;
      width: 52px;
      height: 52px;
      border-radius: 50%;
      background: var(--color-primary);
      color: #fff;
      font-size: 28px;
      display: flex;
      align-items: center;
      justify-content: center;
      box-shadow: 0 4px 12px rgba(30,58,95,0.35);
      z-index: 150;
      cursor: pointer;
    }
    .log-item {
      background: var(--color-card);
      border-bottom: 1px solid var(--color-border);
      padding: 14px 16px;
      cursor: pointer;
    }
    .log-item:active { background: var(--color-bg); }
    .log-date {
      font-size: 13px;
      font-weight: 600;
      color: var(--color-primary);
      margin-bottom: 4px;
    }
    .log-content {
      font-size: 13px;
      color: var(--color-text);
      margin-bottom: 6px;
    }
    .log-meta {
      font-size: 11px;
      color: var(--color-text-secondary);
    }
  </style>
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <div style="width:32px"></div>
      <div class="header-title">施工日志</div>
      <a href="profile.html" style="color:#fff;font-size:20px">👤</a>
    </div>

    <!-- 日志列表 -->
    <div id="logList">
      <div class="empty-state-text text-center" style="padding:40px">加载中...</div>
    </div>

    <!-- 写日志悬浮按钮 -->
    <a href="log.html" class="fab-btn">✏️</a>

    <!-- 底部 Tab 栏 -->
    <div class="tab-bar">
      <a href="index.html" class="tab-item">
        <span style="font-size:22px">🏠</span>
        <span>首页</span>
      </a>
      <a href="devices.html" class="tab-item">
        <span style="font-size:22px">🏗️</span>
        <span>设备</span>
      </a>
      <a href="report.html" class="tab-item">
        <span style="font-size:22px">📷</span>
        <span>随手拍</span>
      </a>
      <a href="logs.html" class="tab-item active">
        <span style="font-size:22px">📋</span>
        <span>日志</span>
      </a>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    function renderLogs(logs) {
      const list = document.getElementById('logList');
      if (logs.length === 0) {
        list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-text">暂无日志</div></div>';
        return;
      }
      const equipText = { tower_crane: '塔吊', elevator: '升降机' };
      list.innerHTML = logs.map(log => `
        <div class="log-item" onclick="location.href='log.html?id=${log.id}'">
          <div class="log-date">📅 ${log.date}</div>
          <div class="log-content">${log.content.substring(0, 50)}${log.content.length > 50 ? '...' : ''}</div>
          <div class="log-meta">
            ${log.part} · ${log.workers}人出勤
            ${log.equipment.length ? ' · ' + log.equipment.map(e => equipText[e] || e).join(', ') : ''}
          </div>
        </div>
      `).join('');
    }

    async function loadLogs() {
      const res = await apiGetLogs();
      renderLogs(res.data.items);
    }

    loadLogs();
  </script>
</body>
</html>

Step 2: 验证

grep -c "apiGetLogs\|fab-btn\|log-item" h5/logs.html

Expected: 3


Task 15: 创建施工日志填写页

Objective: 模板化表单 + 拍照附件 + 提交

Files:

  • Create: h5/log.html

Step 1: 创建 log.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>写日志 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .equipment-checkboxes {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }
    .equipment-checkboxes label {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 6px 12px;
      background: var(--color-bg);
      border-radius: 6px;
      font-size: 13px;
      cursor: pointer;
    }
    .equipment-checkboxes input:checked + span {
      color: var(--color-primary);
      font-weight: 600;
    }
  </style>
</head>
<body>
  <div class="page">
    <!-- 顶栏 -->
    <div class="header">
      <a href="logs.html" class="header-back"></a>
      <div class="header-title" id="pageTitle">写日志</div>
      <div style="width:32px"></div>
    </div>

    <div class="page-content">
      <!-- 照片附件 -->
      <div class="card mb-3">
        <div class="card-title">现场照片(选填)</div>
        <div class="photo-grid" id="photoGrid">
          <div class="photo-item photo-add" id="addPhotoBtn">+</div>
        </div>
        <input type="file" id="photoInput" accept="image/*" capture="environment" multiple style="display:none">
      </div>

      <!-- 日志表单 -->
      <div class="card mb-3">
        <div class="form-group">
          <label class="form-label">日期 <span style="color:var(--color-danger)">*</span></label>
          <input type="date" class="form-input" id="dateInput">
        </div>

        <div class="form-group">
          <label class="form-label">施工部位 <span style="color:var(--color-danger)">*</span></label>
          <input type="text" class="form-input" id="partInput" placeholder="如A区主楼">
        </div>

        <div class="form-group">
          <label class="form-label">作业内容 <span style="color:var(--color-danger)">*</span></label>
          <textarea class="form-input" id="contentInput" placeholder="描述当日施工作业内容" rows="3"></textarea>
        </div>

        <div class="form-group">
          <label class="form-label">人员出勤 <span style="color:var(--color-danger)">*</span></label>
          <input type="number" class="form-input" id="workersInput" placeholder="当日出勤人数" min="0">
        </div>

        <div class="form-group">
          <label class="form-label">设备运行</label>
          <div class="equipment-checkboxes">
            <label><input type="checkbox" name="equipment" value="tower_crane"><span>🏗️ 塔吊</span></label>
            <label><input type="checkbox" name="equipment" value="elevator"><span>🛗 升降机</span></label>
          </div>
        </div>

        <div class="form-group">
          <label class="form-label">安全问题</label>
          <textarea class="form-input" id="safetyInput" placeholder="有则填写,无则留空" rows="2"></textarea>
        </div>

        <div class="form-group">
          <label class="form-label">备注</label>
          <textarea class="form-input" id="noteInput" placeholder="选填" rows="2"></textarea>
        </div>
      </div>

      <button class="btn btn-primary btn-block" id="submitBtn" onclick="submitLog()">提交日志</button>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script src="js/api.js"></script>
  <script>
    if (!requireAuth()) {}

    // 默认日期当天
    document.getElementById('dateInput').value = formatDate(new Date().toISOString());

    let photos = [];

    // 照片
    document.getElementById('addPhotoBtn').addEventListener('click', () => document.getElementById('photoInput').click());
    document.getElementById('photoInput').addEventListener('change', e => {
      const files = Array.from(e.target.files);
      files.forEach(file => {
        const reader = new FileReader();
        reader.onload = ev => {
          photos.push({ file, dataUrl: ev.target.result });
          renderPhotos();
        };
        reader.readAsDataURL(file);
      });
      e.target.value = '';
    });

    function renderPhotos() {
      const grid = document.getElementById('photoGrid');
      const addBtn = '<div class="photo-item photo-add" id="addPhotoBtn">+</div>';
      grid.innerHTML = photos.map((p, i) => `
        <div class="photo-item" style="position:relative">
          <img src="${p.dataUrl}" alt="附件${i+1}">
          <div onclick="removePhoto(${i})" style="position:absolute;top:0;right:0;background:rgba(0,0,0,0.5);color:#fff;width:18px;height:18px;border-radius:50%;font-size:12px;display:flex;align-items:center;justify-content:center;cursor:pointer">×</div>
        </div>
      `).join('') + (photos.length < 9 ? addBtn : '');
      document.getElementById('addPhotoBtn')?.addEventListener('click', () => document.getElementById('photoInput').click());
    }

    function removePhoto(index) {
      photos.splice(index, 1);
      renderPhotos();
    }

    async function submitLog() {
      const date = document.getElementById('dateInput').value;
      const part = document.getElementById('partInput').value.trim();
      const content = document.getElementById('contentInput').value.trim();
      const workers = parseInt(document.getElementById('workersInput').value);
      const equipment = Array.from(document.querySelectorAll('input[name="equipment"]:checked')).map(el => el.value);
      const safety = document.getElementById('safetyInput').value.trim();
      const note = document.getElementById('noteInput').value.trim();

      if (!date) { showToast('请选择日期'); return; }
      if (!part) { showToast('请填写施工部位'); return; }
      if (!content) { showToast('请填写作业内容'); return; }
      if (!workers && workers !== 0) { showToast('请填写人员出勤'); return; }

      const btn = document.getElementById('submitBtn');
      btn.disabled = true;
      btn.textContent = '提交中...';

      try {
        const res = await apiSubmitLog({ date, part, content, workers, equipment, safety, note, attachments: photos });
        if (res.code === 0) {
          showToast('提交成功');
          setTimeout(() => location.href = 'logs.html', 800);
        } else {
          showToast(res.message || '提交失败');
        }
      } finally {
        btn.disabled = false;
        btn.textContent = '提交日志';
      }
    }
  </script>
</body>
</html>

Step 2: 验证

grep -c "apiSubmitLog\|photo-grid\|form-input" h5/log.html

Expected: 3


Task 16: 创建个人中心页面

Objective: 个人信息 + 退出登录

Files:

  • Create: h5/profile.html

Step 1: 创建 profile.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>个人中心 - 郑州智慧工地</title>
  <link rel="stylesheet" href="css/style.css">
  <style>
    .profile-header {
      text-align: center;
      padding: 32px 16px;
      background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
      color: #fff;
    }
    .profile-avatar {
      width: 72px;
      height: 72px;
      border-radius: 50%;
      background: rgba(255,255,255,0.2);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 36px;
      margin: 0 auto 12px;
    }
    .profile-name {
      font-size: 18px;
      font-weight: 700;
      margin-bottom: 4px;
    }
    .profile-role {
      font-size: 13px;
      opacity: 0.8;
    }
    .menu-item {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 16px;
      background: var(--color-card);
      border-bottom: 1px solid var(--color-border);
      font-size: 14px;
      cursor: pointer;
    }
    .menu-item:active { background: var(--color-bg); }
    .menu-icon {
      width: 32px;
      height: 32px;
      border-radius: 8px;
      background: var(--color-bg);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <div class="page" style="padding-bottom:0">
    <!-- 顶栏 -->
    <div class="header">
      <a href="index.html" class="header-back"></a>
      <div class="header-title">个人中心</div>
      <div style="width:32px"></div>
    </div>

    <!-- 个人信息 -->
    <div class="profile-header">
      <div class="profile-avatar">👷</div>
      <div class="profile-name" id="userName">-</div>
      <div class="profile-role" id="userRole">-</div>
    </div>

    <!-- 菜单 -->
    <div class="mt-3">
      <div class="menu-item">
        <div class="menu-icon">📱</div>
        <div>
          <div style="font-weight:600">手机号</div>
          <div style="font-size:12px;color:var(--color-text-secondary)" id="userPhone">-</div>
        </div>
      </div>
    </div>

    <div style="padding:0 16px">
      <button class="btn btn-secondary btn-block" style="margin-top:32px;color:var(--color-danger)" onclick="logout()">
        退出登录
      </button>
    </div>
  </div>

  <script src="js/mock.js"></script>
  <script src="js/app.js"></script>
  <script>
    if (!requireAuth()) {}

    document.getElementById('userName').textContent = MOCK_USER.realName;
    document.getElementById('userRole').textContent = MOCK_USER.role;
    document.getElementById('userPhone').textContent = MOCK_USER.phone;

    function logout() {
      if (!confirm('确定退出登录?')) return;
      clearToken();
      location.href = 'login.html';
    }
  </script>
</body>
</html>

Step 2: 验证

grep -c "clearToken\|logout\|MOCK_USER" h5/profile.html

Expected: 3


最终验证

Task 17: 完整文件检查与预览

Objective: 验证所有文件存在,整理文件结构

Step 1: 验证文件结构

find h5 -type f | sort

Expected:

h5/
├── css/
│   ├── style.css
│   └── variables.css
├── js/
│   ├── api.js
│   ├── app.js
│   └── mock.js
├── index.html
├── login.html
├── devices.html
├── device.html
├── alerts.html
├── alert.html
├── report.html
├── logs.html
├── log.html
└── profile.html

Step 2: 检查 CSS 变量是否包含主题色

grep "color-primary\|color-danger\|color-warning" h5/css/variables.css

Expected: 包含主色、危险色、警告色定义

Step 3: 检查所有 HTML 文件都引用了 mock.js + app.js

for f in h5/*.html; do
  echo -n "$f: "
  grep -c "mock.js\|app.js" "$f"
done

Expected: 每个 HTML 文件 >= 2


提交

Task 18: 提交所有 H5 原型文件

cd /tmp/smart-project && \
git add h5/ && \
git commit -m "feat: H5原型完整实现10个页面Mock数据智慧工地风UI

实现内容:
- login.html: 登录页Mock任意账号密码
- index.html: 首页仪表盘(设备/预警统计)
- devices.html: 设备列表(卡片网格+Tab筛选
- device.html: 设备详情(实时数据面板+30s刷新
- alerts.html: 预警列表Tab筛选
- alert.html: 预警详情(处理/忽略操作)
- report.html: 隐患随手拍(拍照+GPS+完整字段)
- logs.html: 施工日志列表
- log.html: 施工日志填写(模板化+拍照附件)
- profile.html: 个人中心(退出登录)
- css/style.css: 全局样式(智慧工地风配色)
- css/variables.css: 主题色变量
- js/mock.js: Mock数据5设备/6预警/3随手拍/3日志
- js/api.js: API封装全部Mock
- js/app.js: 全局逻辑(登录/Toast/URL工具" && \
git push 2>&1

Expected: git commit 成功,git push 成功


运行方式

直接在浏览器中打开任意 HTML 文件即可运行,无需服务器:

# macOS
open h5/index.html

# 或用 Python 简单 HTTP 服务器
cd h5 && python3 -m http.server 8080
# 访问 http://localhost:8080