diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..58567a0
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,34 @@
+# Smart Project repository instructions
+
+## Commands
+
+- There is currently **no repo-defined build, test, or lint command**. The repository has no `package.json`, `Makefile`, or CI workflow, so there is also no defined single-test command yet.
+
+## High-level architecture
+
+- Treat this repository as **two layers**:
+ - **Design source of truth** in `SPEC.md` and `docs/`, which describes the intended production system.
+ - **Current implementation** in `h5/`, which is a framework-free H5 prototype built from static HTML/CSS/JS files.
+- The intended production system in the docs is:
+ - **Backend API:** Node.js / Express + TypeScript
+ - **Database:** MySQL 8.0
+ - **Storage:** Aliyun OSS for uploaded images and files
+ - **Device ingestion:** Henan Sannong devices push data into `/input/post/call`
+ - **Client:** mobile H5 pages under the same domain as the API
+- The most important cross-cutting backend rule from the docs is **project-level tenant isolation**: business tables carry `project_id`, JWT resolves the current user's `project_id`, and middleware must inject that filter into every query.
+- The `h5/` prototype mirrors the planned product modules from the docs: dashboard, devices, alerts, hazards/reports, construction logs, AI analyses, and profile pages. Page routes are file-based (`devices.html`, `device.html?id=...`, `report.html`, `reports.html`, etc.).
+
+## Key conventions
+
+- **Use the docs to resolve product behavior.** When implementation details in `h5/` are incomplete or inconsistent, prefer `SPEC.md`, `docs/architecture.md`, `docs/api.md`, `docs/database.md`, `docs/h5.md`, and `docs/offline.md` as the canonical intent.
+- **Assume the current codebase is a prototype, not a finished full-stack app.** The backend described in the docs does not exist in this repository yet.
+- **Preserve the static-page architecture in `h5/`.** Each feature is a standalone HTML file with page-specific inline script at the bottom and shared browser-global helpers from `js/mock.js`, `js/api.js`, and `js/app.js`.
+- **Do not introduce module tooling assumptions** when editing `h5/`. The prototype uses browser globals, not imports/exports or a bundler.
+- **Shared state and helpers are global.** `mock.js` exposes mock datasets and lookup helpers on `window`; `api.js` provides async wrapper functions like `apiGetDevices()` and `apiGetAlerts()`; `app.js` owns auth and UI helpers such as `getToken()`, `setToken()`, `requireAuth()`, `showToast()`, `getQueryParam()`, and `initTabBar()`.
+- **Auth in the prototype is localStorage-based.** Protected pages call `requireAuth()` and login stores a `token` in `localStorage`. Keep that flow consistent unless you are explicitly replacing the prototype auth model.
+- **API response shape in the prototype is uniform.** Frontend code expects objects like `{ code: 0, data: ... }`, and mutating operations update the in-memory mock arrays directly.
+- **Keep terminology aligned with the domain docs.** Use the existing Chinese product language and status names where they already exist in the repo, especially for hazards, alerts, device types, and AI analysis modules.
+- **Keep tenant isolation visible in any future backend work.** If you add server code later, carry `project_id` through schema, auth context, and every data query rather than treating it as an optional filter.
+- **Styling is token-driven and CDN-based.** `h5/css/variables.css` defines theme variables; `h5/css/style.css` contains shared component styles; pages load WeUI and Remix Icon from CDNs. Reuse existing classes like `tab-bar`, `tab-item`, `stat-card`, `alert-item`, `log-item`, and related token variables before adding new patterns.
+- **Bottom navigation is a shared product convention.** Main app pages use the same four-tab structure: home, devices, reports, and logs. Keep those links and active-state patterns consistent.
+- **Prototype pages often combine list/create/detail flows across separate files inconsistently.** Check both the docs and the existing HTML before renaming or merging pages; for example, the docs describe `report.html?id=...` and `log.html?id=...`, while the prototype also includes `report-detail.html` and `log-detail.html`.
diff --git a/.gitignore b/.gitignore
index e458ed5..4474be6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.worktrees/
+.gstack/
diff --git a/.superpowers/brainstorm/3293659-1777006577/content/core-pages-v1.html b/.superpowers/brainstorm/3293659-1777006577/content/core-pages-v1.html
new file mode 100644
index 0000000..8892790
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/content/core-pages-v1.html
@@ -0,0 +1,140 @@
+
Section 3 - 核心业务页结构
+沿用你选的更亮、更轻的企业产品气质,但骨架仍然是任务驱动,不做传统 dashboard。
+
+
+
+
+
+
+
+ 全部
+ 待分派
+ 整改中
+
+
+
+
+
+
隐患列表
+
从“模块页”改成“任务页”:默认先给我看待处理事项,再允许切换全部/状态筛选。
+
+
+
+
+
+
+
上报页
+
把现在偏表单式的原型,改成更强引导的移动端提交面板,先拍照,再补关键信息。
+
+
+
+
+
+
+
+
+
今日天气 / 班组 / 人数
+
晴 · 土建二组 · 26人
+
+
+
今日施工内容
+
用更短的结构化方式填写,而不是整屏长表单
+
+
+
+
+
+
日志页
+
不是把日志当普通表单,而是当成“今日必须完成的例行动作”,突出补填和连续性。
+
+
+
+
+
+
这一节的核心判断
+
如果你认同这一节,后面的 written spec 就会把产品重心定成:首页给任务、列表给队列、详情给动作、上报给引导、日志给今日完成感。
+
+
+
+
+
+
B
+
+
列表还要更密一点
+
可以更像任务清单,单屏容纳更多条目。
+
+
+
+
C
+
+
上报页还要更简单
+
希望再减少字段暴露,先做最短路径提交。
+
+
+
diff --git a/.superpowers/brainstorm/3293659-1777006577/content/home-ia-v1.html b/.superpowers/brainstorm/3293659-1777006577/content/home-ia-v1.html
new file mode 100644
index 0000000..fdf14a9
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/content/home-ia-v1.html
@@ -0,0 +1,102 @@
+Section 1 - Product posture and navigation skeleton
+Recommended direction: lightweight enterprise feel, task-driven home, fast actions for field users.
+
+
+
Core idea
+
The app should feel like a practical field workbench, not a dashboard-first admin system. The first screen answers: what needs my attention right now, and what can I do in one tap?
+
+
+
+
+
+
+
+
Project A / Site Operations
+
3 items need attention today
+
+ 2 hazards pending
+ 1 log missing
+
+
+
+
+
Quick action
+
Report hazard
+
+
+
Quick action
+
Write log
+
+
+
+
My queue
+
Scaffold issue / Zone BPending
+
Assigned 10 min ago
+
+
+
Recent updates
+
Resolved power box issue
+
Today 14:30
+
+
+ Home
+ Hazards
+ Logs
+ Me
+
+
+
+
+
+
+
+
+
+
Hazard #HZ-023
+
Temporary cable exposed near lift
+
+
Pending
+
+
+
+
+
Location
+
Material entrance, east side
+
+
+
Current next step
+
Assign responsible person
+
+
+
+
Timeline stays below the main action area instead of competing with it.
+
+
+
+
+
+
+
A
+
+
Structure looks right
+
Keep the task-driven home and action-first detail layout.
+
+
+
+
B
+
+
Need more dashboard feeling
+
Add stronger overview and summary signals on the home screen.
+
+
+
+
C
+
+
Need even lighter mobile feel
+
Reduce blocks and make the pages feel simpler and faster.
+
+
+
diff --git a/.superpowers/brainstorm/3293659-1777006577/content/visual-system-v1.html b/.superpowers/brainstorm/3293659-1777006577/content/visual-system-v1.html
new file mode 100644
index 0000000..0002b8f
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/content/visual-system-v1.html
@@ -0,0 +1,87 @@
+Section 2 - 视觉系统方向
+轻量企业感,但不是普通 OA。要更干净、更可信,同时保留现场业务的专业和状态感。
+
+
+
+
+
+
方案 A(推荐):轻企业 + 稳定安全色
+
浅灰蓝底、深墨文字、琥珀/青蓝做状态点缀。像专业移动产品,不像老旧系统。
+
+
+
+
+
+
+
方案 B:更亮的 SaaS 感
+
更蓝、更轻、更互联网企业工具,但现场业务的辨识度会弱一点。
+
+
+
+
+
+
+
方案 C:更深色、更专业
+
更有“专业工具”味道,但会偏硬,和你刚才选的轻量企业感有点拉扯。
+
+
+
+
+
+
推荐理由
+
我推荐方案 A:整体更像正式交付的企业移动产品,同时保留安全业务需要的状态力度。它比 B 更有业务辨识度,比 C 更适合高频日常使用。
+
diff --git a/.superpowers/brainstorm/3293659-1777006577/content/waiting-1.html b/.superpowers/brainstorm/3293659-1777006577/content/waiting-1.html
new file mode 100644
index 0000000..d982cc3
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/content/waiting-1.html
@@ -0,0 +1,3 @@
+
diff --git a/.superpowers/brainstorm/3293659-1777006577/state/server-stopped b/.superpowers/brainstorm/3293659-1777006577/state/server-stopped
new file mode 100644
index 0000000..ef36dec
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1777008677977}
diff --git a/.superpowers/brainstorm/3293659-1777006577/state/server.log b/.superpowers/brainstorm/3293659-1777006577/state/server.log
new file mode 100644
index 0000000..1d90d70
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/state/server.log
@@ -0,0 +1,7 @@
+{"type":"server-started","port":54345,"host":"0.0.0.0","url_host":"192.168.5.5","url":"http://192.168.5.5:54345","screen_dir":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/content","state_dir":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/state"}
+{"type":"screen-added","file":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/content/home-ia-v1.html"}
+{"type":"screen-added","file":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/content/visual-system-v1.html"}
+{"type":"screen-added","file":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/content/core-pages-v1.html"}
+{"source":"user-event","type":"click","text":"A\n \n 结构对了\n 按这个方向写最终设计说明。","choice":"approve-structure","id":null,"timestamp":1777006805499}
+{"type":"screen-added","file":"/home/jesxion/projects/smart-project/.superpowers/brainstorm/3293659-1777006577/content/waiting-1.html"}
+{"type":"server-stopped","reason":"idle timeout"}
diff --git a/.superpowers/brainstorm/3293659-1777006577/state/server.pid b/.superpowers/brainstorm/3293659-1777006577/state/server.pid
new file mode 100644
index 0000000..1aee3e8
--- /dev/null
+++ b/.superpowers/brainstorm/3293659-1777006577/state/server.pid
@@ -0,0 +1 @@
+3293667
diff --git a/docs/superpowers/plans/2026-04-24-hazards-mvp-implementation.md b/docs/superpowers/plans/2026-04-24-hazards-mvp-implementation.md
new file mode 100644
index 0000000..6f536bb
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-24-hazards-mvp-implementation.md
@@ -0,0 +1,1275 @@
+# Hazards MVP Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the first real end-to-end MVP slice for this repository by adding a minimal Node.js/TypeScript backend plus a working hazards reporting workflow that the existing H5 pages can call.
+
+**Architecture:** Create a new `server/` workspace beside the existing `h5/` prototype. Keep the backend intentionally small: config, database access, auth, project isolation middleware, hazards routes, and file upload token support. Convert only the login and hazards H5 pages from mock APIs to real HTTP calls; leave devices, alerts, AI, and construction logs for follow-up plans.
+
+**Tech Stack:** Node.js, Express, TypeScript, MySQL 8, Zod, jsonwebtoken, bcryptjs, mysql2, Vitest, Supertest, existing static H5 pages in `h5/`
+
+---
+
+## Planned file structure
+
+### New backend workspace
+
+- Create: `server/package.json`
+- Create: `server/tsconfig.json`
+- Create: `server/vitest.config.ts`
+- Create: `server/.env.example`
+- Create: `server/schema.sql`
+- Create: `server/src/app.ts`
+- Create: `server/src/index.ts`
+- Create: `server/src/config/env.ts`
+- Create: `server/src/lib/db.ts`
+- Create: `server/src/lib/jwt.ts`
+- Create: `server/src/lib/password.ts`
+- Create: `server/src/middleware/auth.ts`
+- Create: `server/src/middleware/project.ts`
+- Create: `server/src/routes/health.ts`
+- Create: `server/src/routes/auth.ts`
+- Create: `server/src/routes/files.ts`
+- Create: `server/src/routes/hazards.ts`
+- Create: `server/src/repositories/user-repository.ts`
+- Create: `server/src/repositories/file-repository.ts`
+- Create: `server/src/repositories/hazard-repository.ts`
+- Create: `server/src/services/file-service.ts`
+- Create: `server/src/services/hazard-service.ts`
+- Create: `server/src/types/auth.ts`
+- Create: `server/tests/health.test.ts`
+- Create: `server/tests/auth.test.ts`
+- Create: `server/tests/hazards.test.ts`
+- Create: `server/tests/files.test.ts`
+
+### Existing H5 files to modify
+
+- Modify: `h5/js/api.js`
+- Modify: `h5/js/app.js`
+- Modify: `h5/login.html`
+- Modify: `h5/report.html`
+- Modify: `h5/reports.html`
+- Modify: `h5/report-detail.html`
+
+### Deferred to later plan
+
+- `construction_logs` backend and H5 integration
+- device ingestion / alerts / AI
+- offline mode
+
+---
+
+### Task 1: Bootstrap the backend workspace
+
+**Files:**
+- Create: `server/package.json`
+- Create: `server/tsconfig.json`
+- Create: `server/vitest.config.ts`
+- Create: `server/src/app.ts`
+- Create: `server/src/index.ts`
+- Create: `server/src/routes/health.ts`
+- Test: `server/tests/health.test.ts`
+
+- [ ] **Step 1: Write the failing health test**
+
+```ts
+// server/tests/health.test.ts
+import request from 'supertest';
+import { describe, expect, it } from 'vitest';
+import { createApp } from '../src/app';
+
+describe('GET /health', () => {
+ it('returns ok status', async () => {
+ const app = createApp();
+ const res = await request(app).get('/health');
+
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ ok: true });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/health.test.ts`
+Expected: FAIL because `package.json`, `vitest`, or `createApp` does not exist yet
+
+- [ ] **Step 3: Create the minimal backend workspace**
+
+```json
+// server/package.json
+{
+ "name": "smart-project-server",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "test": "vitest run",
+ "build": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "express": "^5.1.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.1",
+ "@types/supertest": "^6.0.3",
+ "supertest": "^7.1.1",
+ "tsx": "^4.19.4",
+ "typescript": "^5.8.3",
+ "vitest": "^3.2.4"
+ }
+}
+```
+
+```json
+// server/tsconfig.json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "."
+ },
+ "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
+}
+```
+
+```ts
+// server/vitest.config.ts
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node'
+ }
+});
+```
+
+```ts
+// server/src/routes/health.ts
+import { Router } from 'express';
+
+export function createHealthRouter() {
+ const router = Router();
+
+ router.get('/health', (_req, res) => {
+ res.json({ ok: true });
+ });
+
+ return router;
+}
+```
+
+```ts
+// server/src/app.ts
+import express from 'express';
+import { createHealthRouter } from './routes/health';
+
+export function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(createHealthRouter());
+ return app;
+}
+```
+
+```ts
+// server/src/index.ts
+import { createApp } from './app';
+
+const app = createApp();
+const port = Number(process.env.PORT ?? 3000);
+
+app.listen(port, () => {
+ console.log(`server listening on ${port}`);
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd server && npm install && npm run test -- tests/health.test.ts`
+Expected: PASS with `1 passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/package.json server/tsconfig.json server/vitest.config.ts server/src/app.ts server/src/index.ts server/src/routes/health.ts server/tests/health.test.ts
+git commit -m "feat: bootstrap backend workspace"
+```
+
+---
+
+### Task 2: Add config, database access, and schema bootstrap
+
+**Files:**
+- Create: `server/.env.example`
+- Create: `server/schema.sql`
+- Create: `server/src/config/env.ts`
+- Create: `server/src/lib/db.ts`
+- Modify: `server/src/app.ts`
+- Test: `server/tests/health.test.ts`
+
+- [ ] **Step 1: Add a failing config test expectation inside health**
+
+```ts
+// append to server/tests/health.test.ts
+it('returns service metadata', async () => {
+ const app = createApp();
+ const res = await request(app).get('/health');
+
+ expect(res.body.service).toBe('smart-project-server');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/health.test.ts`
+Expected: FAIL because `service` is missing
+
+- [ ] **Step 3: Add env loading and schema bootstrap artifacts**
+
+```env
+# server/.env.example
+PORT=3000
+MYSQL_HOST=127.0.0.1
+MYSQL_PORT=3306
+MYSQL_USER=root
+MYSQL_PASSWORD=change_me
+MYSQL_DATABASE=smart_project
+JWT_SECRET_KEY=change_me
+OSS_BUCKET=jesxion-ai-studio
+OSS_REGION=oss-cn-beijing
+```
+
+```sql
+-- server/schema.sql
+CREATE TABLE IF NOT EXISTS projects (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(128) NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ project_id INT NOT NULL,
+ username VARCHAR(64) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(32) NOT NULL,
+ real_name VARCHAR(128),
+ FOREIGN KEY (project_id) REFERENCES projects(id)
+);
+
+CREATE TABLE IF NOT EXISTS oss_files (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ project_id INT NOT NULL,
+ object_key VARCHAR(512) NOT NULL UNIQUE,
+ filename VARCHAR(255) NOT NULL,
+ content_type VARCHAR(128),
+ size BIGINT,
+ category VARCHAR(64) NOT NULL,
+ uploaded_by INT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS hazards (
+ id VARCHAR(64) PRIMARY KEY,
+ project_id INT NOT NULL,
+ category VARCHAR(32) NOT NULL,
+ severity VARCHAR(32) NOT NULL,
+ description TEXT NOT NULL,
+ photos JSON,
+ status VARCHAR(32) NOT NULL DEFAULT 'pending',
+ reporter_id INT NOT NULL,
+ reporter_role VARCHAR(32) NOT NULL,
+ reported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ assignee_id INT NULL,
+ assignee_role VARCHAR(32) NULL,
+ assigned_at TIMESTAMP NULL,
+ resolver_id INT NULL,
+ resolver_role VARCHAR(32) NULL,
+ resolve_note TEXT NULL,
+ resolved_at TIMESTAMP NULL
+);
+```
+
+```ts
+// server/src/config/env.ts
+import { z } from 'zod';
+
+const envSchema = z.object({
+ PORT: z.string().default('3000'),
+ MYSQL_HOST: z.string().default('127.0.0.1'),
+ MYSQL_PORT: z.string().default('3306'),
+ MYSQL_USER: z.string(),
+ MYSQL_PASSWORD: z.string(),
+ MYSQL_DATABASE: z.string(),
+ JWT_SECRET_KEY: z.string(),
+ OSS_BUCKET: z.string(),
+ OSS_REGION: z.string()
+});
+
+export const env = envSchema.parse(process.env);
+```
+
+```ts
+// server/src/lib/db.ts
+import mysql from 'mysql2/promise';
+import { env } from '../config/env';
+
+export const db = mysql.createPool({
+ host: env.MYSQL_HOST,
+ port: Number(env.MYSQL_PORT),
+ user: env.MYSQL_USER,
+ password: env.MYSQL_PASSWORD,
+ database: env.MYSQL_DATABASE,
+ connectionLimit: 10
+});
+```
+
+```ts
+// server/src/app.ts
+import express from 'express';
+import { createHealthRouter } from './routes/health';
+
+export function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(createHealthRouter());
+ return app;
+}
+```
+
+```ts
+// server/src/routes/health.ts
+import { Router } from 'express';
+
+export function createHealthRouter() {
+ const router = Router();
+
+ router.get('/health', (_req, res) => {
+ res.json({ ok: true, service: 'smart-project-server' });
+ });
+
+ return router;
+}
+```
+
+- [ ] **Step 4: Install dependencies and run test**
+
+Run: `cd server && npm install mysql2 zod && npm run test -- tests/health.test.ts`
+Expected: PASS with `2 passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/.env.example server/schema.sql server/src/config/env.ts server/src/lib/db.ts server/src/routes/health.ts server/tests/health.test.ts
+git commit -m "feat: add config and database bootstrap"
+```
+
+---
+
+### Task 3: Add login, JWT, and project context middleware
+
+**Files:**
+- Create: `server/src/lib/jwt.ts`
+- Create: `server/src/lib/password.ts`
+- Create: `server/src/types/auth.ts`
+- Create: `server/src/repositories/user-repository.ts`
+- Create: `server/src/middleware/auth.ts`
+- Create: `server/src/middleware/project.ts`
+- Create: `server/src/routes/auth.ts`
+- Modify: `server/src/app.ts`
+- Test: `server/tests/auth.test.ts`
+
+- [ ] **Step 1: Write the failing auth test**
+
+```ts
+// server/tests/auth.test.ts
+import request from 'supertest';
+import { describe, expect, it } from 'vitest';
+import { createApp } from '../src/app';
+
+describe('POST /v1/auth/login', () => {
+ it('returns token and user payload', async () => {
+ const app = createApp();
+ const res = await request(app).post('/v1/auth/login').send({
+ username: 'admin',
+ password: '123456'
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.token).toBeTypeOf('string');
+ expect(res.body.user.username).toBe('admin');
+ expect(res.body.user.project_id).toBeTypeOf('number');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/auth.test.ts`
+Expected: FAIL with 404 or route missing
+
+- [ ] **Step 3: Implement minimal auth plumbing**
+
+```ts
+// server/src/types/auth.ts
+export type AuthUser = {
+ id: number;
+ username: string;
+ role: string;
+ project_id: number;
+};
+```
+
+```ts
+// server/src/lib/jwt.ts
+import jwt from 'jsonwebtoken';
+import { env } from '../config/env';
+import type { AuthUser } from '../types/auth';
+
+export function signAuthToken(user: AuthUser) {
+ return jwt.sign(user, env.JWT_SECRET_KEY, { expiresIn: '7d' });
+}
+
+export function verifyAuthToken(token: string) {
+ return jwt.verify(token, env.JWT_SECRET_KEY) as AuthUser;
+}
+```
+
+```ts
+// server/src/lib/password.ts
+import bcrypt from 'bcryptjs';
+
+export function comparePassword(raw: string, hash: string) {
+ return bcrypt.compare(raw, hash);
+}
+```
+
+```ts
+// server/src/repositories/user-repository.ts
+import { db } from '../lib/db';
+
+export async function findUserByUsername(username: string) {
+ const [rows] = await db.query(
+ 'SELECT id, username, password_hash, role, project_id FROM users WHERE username = ? LIMIT 1',
+ [username]
+ );
+
+ return Array.isArray(rows) ? (rows[0] as any) ?? null : null;
+}
+```
+
+```ts
+// server/src/middleware/auth.ts
+import type { NextFunction, Request, Response } from 'express';
+import { verifyAuthToken } from '../lib/jwt';
+import type { AuthUser } from '../types/auth';
+
+declare global {
+ namespace Express {
+ interface Request {
+ auth?: AuthUser;
+ }
+ }
+}
+
+export function requireAuth(req: Request, res: Response, next: NextFunction) {
+ const header = req.header('Authorization');
+ const token = header?.startsWith('Bearer ') ? header.slice(7) : null;
+
+ if (!token) {
+ return res.status(401).json({ message: 'missing bearer token' });
+ }
+
+ try {
+ req.auth = verifyAuthToken(token);
+ next();
+ } catch {
+ res.status(401).json({ message: 'invalid token' });
+ }
+}
+```
+
+```ts
+// server/src/middleware/project.ts
+import type { Request } from 'express';
+
+export function getProjectId(req: Request) {
+ if (!req.auth) {
+ throw new Error('auth context missing');
+ }
+
+ return req.auth.project_id;
+}
+```
+
+```ts
+// server/src/routes/auth.ts
+import { Router } from 'express';
+import { z } from 'zod';
+import { signAuthToken } from '../lib/jwt';
+import { comparePassword } from '../lib/password';
+import { findUserByUsername } from '../repositories/user-repository';
+
+const loginSchema = z.object({
+ username: z.string().min(1),
+ password: z.string().min(1)
+});
+
+export function createAuthRouter() {
+ const router = Router();
+
+ router.post('/v1/auth/login', async (req, res, next) => {
+ try {
+ const { username, password } = loginSchema.parse(req.body);
+ const user = await findUserByUsername(username);
+
+ if (!user || !(await comparePassword(password, user.password_hash))) {
+ return res.status(401).json({ message: 'invalid credentials' });
+ }
+
+ const authUser = {
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ project_id: user.project_id
+ };
+
+ res.json({
+ token: signAuthToken(authUser),
+ user: authUser
+ });
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ return router;
+}
+```
+
+```ts
+// server/src/app.ts
+import express from 'express';
+import { createAuthRouter } from './routes/auth';
+import { createHealthRouter } from './routes/health';
+
+export function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(createHealthRouter());
+ app.use(createAuthRouter());
+ return app;
+}
+```
+
+- [ ] **Step 4: Run the auth test**
+
+Run: `cd server && npm install jsonwebtoken bcryptjs @types/jsonwebtoken && npm run test -- tests/auth.test.ts`
+Expected: PASS once a seeded `admin` user exists in the dev database
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/lib/jwt.ts server/src/lib/password.ts server/src/types/auth.ts server/src/repositories/user-repository.ts server/src/middleware/auth.ts server/src/middleware/project.ts server/src/routes/auth.ts server/src/app.ts server/tests/auth.test.ts
+git commit -m "feat: add login and auth token flow"
+```
+
+---
+
+### Task 4: Add file upload token endpoint and file indexing
+
+**Files:**
+- Create: `server/src/repositories/file-repository.ts`
+- Create: `server/src/services/file-service.ts`
+- Create: `server/src/routes/files.ts`
+- Modify: `server/src/app.ts`
+- Test: `server/tests/files.test.ts`
+
+- [ ] **Step 1: Write the failing files test**
+
+```ts
+// server/tests/files.test.ts
+import request from 'supertest';
+import { describe, expect, it } from 'vitest';
+import { createApp } from '../src/app';
+import { signAuthToken } from '../src/lib/jwt';
+
+describe('POST /v1/files/upload-token', () => {
+ it('returns object key and upload metadata', async () => {
+ const app = createApp();
+ const token = signAuthToken({
+ id: 1,
+ username: 'admin',
+ role: '安全员',
+ project_id: 1
+ });
+ const res = await request(app)
+ .post('/v1/files/upload-token')
+ .set('Authorization', 'Bearer ' + token)
+ .send({
+ filename: 'hazard.jpg',
+ contentType: 'image/jpeg',
+ category: 'hazard'
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.objectKey).toContain('hazard');
+ expect(res.body.expiresIn).toBe(3600);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/files.test.ts`
+Expected: FAIL with 404 or unauthorized
+
+- [ ] **Step 3: Implement token generation and file record creation**
+
+```ts
+// server/src/services/file-service.ts
+import { env } from '../config/env';
+
+export function buildUploadToken(input: {
+ category: string;
+ projectId: number;
+ filename: string;
+}) {
+ const objectKey = `${input.category}/${input.projectId}/${Date.now()}-${input.filename}`;
+
+ return {
+ objectKey,
+ uploadUrl: `https://${env.OSS_BUCKET}.${env.OSS_REGION}.aliyuncs.com/${objectKey}`,
+ expiresIn: 3600
+ };
+}
+```
+
+```ts
+// server/src/repositories/file-repository.ts
+import { db } from '../lib/db';
+
+export async function createFileIndexRecord(input: {
+ projectId: number;
+ objectKey: string;
+ filename: string;
+ contentType: string;
+ category: string;
+ uploadedBy: number;
+}) {
+ await db.query(
+ 'INSERT INTO oss_files (project_id, object_key, filename, content_type, category, uploaded_by) VALUES (?, ?, ?, ?, ?, ?)',
+ [input.projectId, input.objectKey, input.filename, input.contentType, input.category, input.uploadedBy]
+ );
+}
+```
+
+```ts
+// server/src/routes/files.ts
+import { Router } from 'express';
+import { z } from 'zod';
+import { requireAuth } from '../middleware/auth';
+import { getProjectId } from '../middleware/project';
+import { createFileIndexRecord } from '../repositories/file-repository';
+import { buildUploadToken } from '../services/file-service';
+
+const schema = z.object({
+ filename: z.string().min(1),
+ contentType: z.string().min(1),
+ category: z.literal('hazard')
+});
+
+export function createFilesRouter() {
+ const router = Router();
+ router.use(requireAuth);
+
+ router.post('/v1/files/upload-token', async (req, res, next) => {
+ try {
+ const input = schema.parse(req.body);
+ const token = buildUploadToken({
+ category: input.category,
+ projectId: getProjectId(req),
+ filename: input.filename
+ });
+
+ await createFileIndexRecord({
+ projectId: getProjectId(req),
+ objectKey: token.objectKey,
+ filename: input.filename,
+ contentType: input.contentType,
+ category: input.category,
+ uploadedBy: req.auth!.id
+ });
+
+ res.json(token);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ return router;
+}
+```
+
+```ts
+// server/src/app.ts
+import express from 'express';
+import { createAuthRouter } from './routes/auth';
+import { createFilesRouter } from './routes/files';
+import { createHealthRouter } from './routes/health';
+
+export function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(createHealthRouter());
+ app.use(createAuthRouter());
+ app.use(createFilesRouter());
+ return app;
+}
+```
+
+- [ ] **Step 4: Run files test**
+
+Run: `cd server && npm run test -- tests/files.test.ts`
+Expected: PASS with `1 passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/repositories/file-repository.ts server/src/services/file-service.ts server/src/routes/files.ts server/src/app.ts server/tests/files.test.ts
+git commit -m "feat: add hazard file upload token endpoint"
+```
+
+---
+
+### Task 5: Add hazards create, list, and detail APIs
+
+**Files:**
+- Create: `server/src/repositories/hazard-repository.ts`
+- Create: `server/src/services/hazard-service.ts`
+- Create: `server/src/routes/hazards.ts`
+- Modify: `server/src/app.ts`
+- Test: `server/tests/hazards.test.ts`
+
+- [ ] **Step 1: Write the failing hazards test**
+
+```ts
+// server/tests/hazards.test.ts
+import request from 'supertest';
+import { describe, expect, it } from 'vitest';
+import { createApp } from '../src/app';
+import { signAuthToken } from '../src/lib/jwt';
+
+describe('hazards api', () => {
+ it('creates a hazard and returns it in the list', async () => {
+ const app = createApp();
+ const token = signAuthToken({
+ id: 1,
+ username: 'admin',
+ role: '安全员',
+ project_id: 1
+ });
+
+ const createRes = await request(app)
+ .post('/v1/hazards')
+ .set('Authorization', 'Bearer ' + token)
+ .send({
+ category: 'fall',
+ severity: 'general',
+ description: 'A区临边防护缺失',
+ photos: ['hazard/1/test.jpg']
+ });
+
+ expect(createRes.status).toBe(201);
+ expect(createRes.body.status).toBe('pending');
+
+ const listRes = await request(app)
+ .get('/v1/hazards')
+ .set('Authorization', 'Bearer ' + token);
+ expect(listRes.status).toBe(200);
+ expect(Array.isArray(listRes.body.items)).toBe(true);
+ expect(listRes.body.items[0].description).toContain('临边防护缺失');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/hazards.test.ts`
+Expected: FAIL with 404
+
+- [ ] **Step 3: Implement minimal hazards persistence**
+
+```ts
+// server/src/repositories/hazard-repository.ts
+import { randomUUID } from 'node:crypto';
+import { db } from '../lib/db';
+
+export async function createHazard(input: {
+ projectId: number;
+ reporterId: number;
+ reporterRole: string;
+ category: string;
+ severity: string;
+ description: string;
+ photos: string[];
+}) {
+ const id = randomUUID();
+ await db.query(
+ 'INSERT INTO hazards (id, project_id, category, severity, description, photos, reporter_id, reporter_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, input.projectId, input.category, input.severity, input.description, JSON.stringify(input.photos), input.reporterId, input.reporterRole]
+ );
+
+ return { id, ...input, status: 'pending' as const };
+}
+
+export async function listHazards(projectId: number) {
+ const [rows] = await db.query(
+ 'SELECT id, category, severity, description, photos, status, reported_at FROM hazards WHERE project_id = ? ORDER BY reported_at DESC',
+ [projectId]
+ );
+
+ return rows as any[];
+}
+
+export async function getHazardById(projectId: number, id: string) {
+ const [rows] = await db.query(
+ 'SELECT * FROM hazards WHERE project_id = ? AND id = ? LIMIT 1',
+ [projectId, id]
+ );
+
+ return Array.isArray(rows) ? (rows[0] as any) ?? null : null;
+}
+```
+
+```ts
+// server/src/services/hazard-service.ts
+import { createHazard, getHazardById, listHazards } from '../repositories/hazard-repository';
+
+export const hazardService = {
+ create: createHazard,
+ list: listHazards,
+ getById: getHazardById
+};
+```
+
+```ts
+// server/src/routes/hazards.ts
+import { Router } from 'express';
+import { z } from 'zod';
+import { requireAuth } from '../middleware/auth';
+import { getProjectId } from '../middleware/project';
+import { hazardService } from '../services/hazard-service';
+
+const createSchema = z.object({
+ category: z.string().min(1),
+ severity: z.enum(['general', 'serious', 'major']),
+ description: z.string().min(1),
+ photos: z.array(z.string()).default([])
+});
+
+export function createHazardsRouter() {
+ const router = Router();
+ router.use(requireAuth);
+
+ router.post('/v1/hazards', async (req, res, next) => {
+ try {
+ const input = createSchema.parse(req.body);
+ const hazard = await hazardService.create({
+ projectId: getProjectId(req),
+ reporterId: req.auth!.id,
+ reporterRole: req.auth!.role,
+ ...input
+ });
+
+ res.status(201).json(hazard);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ router.get('/v1/hazards', async (_req, res, next) => {
+ try {
+ const items = await hazardService.list(_req.auth!.project_id);
+ res.json({ items });
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ router.get('/v1/hazards/:id', async (req, res, next) => {
+ try {
+ const item = await hazardService.getById(getProjectId(req), req.params.id);
+ if (!item) return res.status(404).json({ message: 'hazard not found' });
+ res.json(item);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ return router;
+}
+```
+
+```ts
+// server/src/app.ts
+import express from 'express';
+import { createAuthRouter } from './routes/auth';
+import { createFilesRouter } from './routes/files';
+import { createHazardsRouter } from './routes/hazards';
+import { createHealthRouter } from './routes/health';
+
+export function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(createHealthRouter());
+ app.use(createAuthRouter());
+ app.use(createFilesRouter());
+ app.use(createHazardsRouter());
+ return app;
+}
+```
+
+- [ ] **Step 4: Run the hazards test**
+
+Run: `cd server && npm run test -- tests/hazards.test.ts`
+Expected: PASS with `1 passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/repositories/hazard-repository.ts server/src/services/hazard-service.ts server/src/routes/hazards.ts server/src/app.ts server/tests/hazards.test.ts
+git commit -m "feat: add hazards create list and detail endpoints"
+```
+
+---
+
+### Task 6: Add hazards assign and resolve workflow
+
+**Files:**
+- Modify: `server/src/repositories/hazard-repository.ts`
+- Modify: `server/src/services/hazard-service.ts`
+- Modify: `server/src/routes/hazards.ts`
+- Modify: `server/tests/hazards.test.ts`
+
+- [ ] **Step 1: Add failing workflow test**
+
+```ts
+// append to server/tests/hazards.test.ts
+it('assigns and resolves a hazard', async () => {
+ const app = createApp();
+ const token = signAuthToken({
+ id: 2,
+ username: 'manager',
+ role: '安全负责人',
+ project_id: 1
+ });
+
+ const createRes = await request(app)
+ .post('/v1/hazards')
+ .set('Authorization', 'Bearer ' + token)
+ .send({
+ category: 'electric',
+ severity: 'serious',
+ description: '配电箱裸露',
+ photos: []
+ });
+
+ const id = createRes.body.id;
+
+ const assignRes = await request(app)
+ .post(`/v1/hazards/${id}/assign`)
+ .set('Authorization', 'Bearer ' + token);
+ expect(assignRes.status).toBe(200);
+ expect(assignRes.body.status).toBe('assigned');
+
+ const resolveRes = await request(app)
+ .post(`/v1/hazards/${id}/resolve`)
+ .set('Authorization', 'Bearer ' + token)
+ .send({
+ resolveNote: '已加装防护'
+ });
+ expect(resolveRes.status).toBe(200);
+ expect(resolveRes.body.status).toBe('resolved');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd server && npm run test -- tests/hazards.test.ts`
+Expected: FAIL because assign/resolve routes are missing
+
+- [ ] **Step 3: Implement status transitions**
+
+```ts
+// add to server/src/repositories/hazard-repository.ts
+export async function assignHazard(projectId: number, id: string, assigneeId: number, assigneeRole: string) {
+ await db.query(
+ 'UPDATE hazards SET status = ?, assignee_id = ?, assignee_role = ?, assigned_at = CURRENT_TIMESTAMP WHERE project_id = ? AND id = ? AND status = ?',
+ ['assigned', assigneeId, assigneeRole, projectId, id, 'pending']
+ );
+
+ return getHazardById(projectId, id);
+}
+
+export async function resolveHazard(projectId: number, id: string, resolverId: number, resolverRole: string, resolveNote: string) {
+ await db.query(
+ 'UPDATE hazards SET status = ?, resolver_id = ?, resolver_role = ?, resolve_note = ?, resolved_at = CURRENT_TIMESTAMP WHERE project_id = ? AND id = ? AND status IN (?, ?)',
+ ['resolved', resolverId, resolverRole, resolveNote, projectId, id, 'pending', 'assigned']
+ );
+
+ return getHazardById(projectId, id);
+}
+```
+
+```ts
+// add to server/src/services/hazard-service.ts
+import { assignHazard, createHazard, getHazardById, listHazards, resolveHazard } from '../repositories/hazard-repository';
+
+export const hazardService = {
+ create: createHazard,
+ list: listHazards,
+ getById: getHazardById,
+ assign: assignHazard,
+ resolve: resolveHazard
+};
+```
+
+```ts
+// add to server/src/routes/hazards.ts
+const resolveSchema = z.object({
+ resolveNote: z.string().default('')
+});
+
+router.post('/v1/hazards/:id/assign', async (req, res, next) => {
+ try {
+ const item = await hazardService.assign(
+ getProjectId(req),
+ req.params.id,
+ req.auth!.id,
+ req.auth!.role
+ );
+ res.json(item);
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.post('/v1/hazards/:id/resolve', async (req, res, next) => {
+ try {
+ const body = resolveSchema.parse(req.body);
+ const item = await hazardService.resolve(
+ getProjectId(req),
+ req.params.id,
+ req.auth!.id,
+ req.auth!.role,
+ body.resolveNote
+ );
+ res.json(item);
+ } catch (error) {
+ next(error);
+ }
+});
+```
+
+- [ ] **Step 4: Run hazards workflow test**
+
+Run: `cd server && npm run test -- tests/hazards.test.ts`
+Expected: PASS with both hazard tests green
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/repositories/hazard-repository.ts server/src/services/hazard-service.ts server/src/routes/hazards.ts server/tests/hazards.test.ts
+git commit -m "feat: add hazards assign and resolve flow"
+```
+
+---
+
+### Task 7: Replace mock login and hazards calls in H5 with real API calls
+
+**Files:**
+- Modify: `h5/js/api.js`
+- Modify: `h5/js/app.js`
+- Modify: `h5/login.html`
+- Modify: `h5/report.html`
+- Modify: `h5/reports.html`
+- Modify: `h5/report-detail.html`
+
+- [ ] **Step 1: Replace the mock API transport**
+
+```js
+// h5/js/api.js
+const API_BASE = '/v1';
+
+async function request(path, options) {
+ const token = localStorage.getItem('token');
+ const headers = Object.assign(
+ { 'Content-Type': 'application/json' },
+ options && options.headers ? options.headers : {}
+ );
+
+ if (token) {
+ headers.Authorization = 'Bearer ' + token;
+ }
+
+ const res = await fetch(API_BASE + path, {
+ method: options && options.method ? options.method : 'GET',
+ headers,
+ body: options && options.body ? JSON.stringify(options.body) : undefined
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(function() { return {}; });
+ throw new Error(errorBody.message || '请求失败');
+ }
+
+ return res.json();
+}
+
+function apiLogin(username, password) {
+ return request('/auth/login', {
+ method: 'POST',
+ body: { username, password }
+ });
+}
+
+function apiSubmitReport(formData) {
+ return request('/hazards', {
+ method: 'POST',
+ body: formData
+ });
+}
+
+function apiGetReports() {
+ return request('/hazards');
+}
+
+function apiGetReportDetail(id) {
+ return request('/hazards/' + id);
+}
+
+function apiAssignReport(id) {
+ return request('/hazards/' + id + '/assign', { method: 'POST' });
+}
+
+function apiResolveReport(id, resolveNote) {
+ return request('/hazards/' + id + '/resolve', {
+ method: 'POST',
+ body: { resolveNote: resolveNote || '' }
+ });
+}
+```
+
+- [ ] **Step 2: Normalize auth state helpers**
+
+```js
+// h5/js/app.js
+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;
+}
+```
+
+- [ ] **Step 3: Update page usage**
+
+```js
+// login.html inline script change
+var res = await apiLogin(username, password);
+setToken(res.token);
+```
+
+```js
+// report.html inline submit change
+await apiSubmitReport({
+ category: category,
+ severity: severity,
+ description: description,
+ photos: uploadedPhotoKeys
+});
+```
+
+```js
+// reports.html inline list change
+var res = await apiGetReports();
+var items = res.items;
+```
+
+```js
+// report-detail.html inline action change
+await apiAssignReport(reportId);
+await apiResolveReport(reportId, noteValue);
+```
+
+- [ ] **Step 4: Smoke test the vertical slice**
+
+Run:
+
+```bash
+cd server && npm run dev
+```
+
+Then open `h5/login.html` through the same domain/proxy as the backend and verify:
+
+- login succeeds
+- create hazard succeeds
+- hazards list shows new item
+- assign succeeds
+- resolve succeeds
+
+Expected: the whole hazard workflow works without using `MOCK_REPORTS`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add h5/js/api.js h5/js/app.js h5/login.html h5/report.html h5/reports.html h5/report-detail.html
+git commit -m "feat: connect H5 hazards flow to real backend"
+```
+
+---
+
+## Self-review checklist
+
+### Spec coverage
+
+- MVP backend skeleton: covered by Tasks 1-3
+- file upload / OSS seam: covered by Task 4
+- hazards closed loop: covered by Tasks 5-7
+- project isolation foundation: covered by Task 3 and carried forward into hazard tasks
+- H5 integration: covered by Task 7
+- construction logs: intentionally excluded from this plan and should be a follow-up plan
+
+### Placeholder scan
+
+- No `TODO`, `TBD`, or “similar to above” placeholders remain in task steps
+- Commands include exact paths
+- Code steps contain concrete file content or concrete snippets to add
+
+### Type consistency
+
+- Hazard statuses are consistently `pending`, `assigned`, `resolved`
+- H5 uses `/v1/hazards` and `/v1/auth/login` consistently
+- Shared `project_id` context is introduced before files and hazard routes depend on it