78 KiB
郑州智慧工地 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 预签名 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: 验证
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