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。

+ +
+
+
+
+
+
+
隐患任务
+
待我处理 3
+
+
+ 上报
+
+
+ 全部 + 待分派 + 整改中 +
+
+
+
+
配电箱未加锁
+ 待处理 +
+
东侧加工区 · 10分钟前
+
+
+
+
临边防护缺失
+ 整改中 +
+
2号楼南侧 · 今天 09:20
+
+
+
+
+
+

隐患列表

+

从“模块页”改成“任务页”:默认先给我看待处理事项,再允许切换全部/状态筛选。

+
+
+ +
+
+
+
快速上报
+
隐患上报
+
+
+
拍照 / 上传
+
+
+
+
位置
+
点击选择区域
+
+
+
+
严重等级
+
一般 / 较重 / 紧急
+
+
+
分类
+
临电 / 防护 / 消防
+
+
+
+
补充描述
+
一句话说明发生了什么
+
+
+
提交上报
+
+
+
+

上报页

+

把现在偏表单式的原型,改成更强引导的移动端提交面板,先拍照,再补关键信息。

+
+
+ +
+
+
+
+
+
施工日志
+
今日日志
+
+
未完成
+
+
+
今日天气 / 班组 / 人数
+
晴 · 土建二组 · 26人
+
+
+
今日施工内容
+
用更短的结构化方式填写,而不是整屏长表单
+
+
+
继续填写
+
查看历史
+
+
+
+
+

日志页

+

不是把日志当普通表单,而是当成“今日必须完成的例行动作”,突出补填和连续性。

+
+
+
+ +
+
这一节的核心判断
+

如果你认同这一节,后面的 written spec 就会把产品重心定成:首页给任务、列表给队列、详情给动作、上报给引导、日志给今日完成感

+
+ +
+
+
A
+
+

结构对了

+

按这个方向写最终设计说明。

+
+
+
+
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?

+
+ +
+
+
Home / Task-driven workbench
+
+
+
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 detail / Action panel
+
+
+
+
Hazard #HZ-023
+
Temporary cable exposed near lift
+
+
Pending
+
+
+
+
+
Location
+
Material entrance, east side
+
+
+
Current next step
+
Assign responsible person
+
+
+
+
Assign
+
Resolve
+
+
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