diff --git a/空气质量预测/源码/docs/superpowers/plans/2026-07-07-forum-and-eco-materials.md b/空气质量预测/源码/docs/superpowers/plans/2026-07-07-forum-and-eco-materials.md new file mode 100644 index 0000000..8d6f630 --- /dev/null +++ b/空气质量预测/源码/docs/superpowers/plans/2026-07-07-forum-and-eco-materials.md @@ -0,0 +1,1210 @@ +# 健康装修大家谈论坛 + 环保建材 + 导航改版 实现计划 + +> **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:** 给用户端首页换成 4 项导航,新增「环保建材」真实材料展示区块,并实现「健康装修大家谈」进阶版论坛(板块 / 楼层回帖 / 点赞,公开浏览、手机登录后发帖)。 + +**Architecture:** 后端 Koa + Prisma 新增 3 张论坛表与 1 个 `/api/forum` controller,公共材料库新增 1 个免认证 `/api/mtrl/eco` 接口;前端 Umi 新增 2 个公开路由页面与对应 API 封装,landing 页改导航与新增区块。登录复用现有手机验证码 `PhoneAuthModal` 与 `WEB_AUTH_TOKEN`。 + +**Tech Stack:** 后端 Koa 2 + koa-router + Prisma 5 + MySQL 5.7 + AJV(validate 中间件);前端 UmiJS 4 + React 18 + Antd 5。无单测框架 —— **后端任务用 curl 打运行中的 dev 服务做红/绿验证,前端任务用浏览器验证**。 + +## Global Constraints + +- 后端 controller 一律:`Router` + `.prefix('/api/xxx')`,成功用 `ctx.body = 数据`(前端 `request` 配置 `dataField:''`,**不包裹**,直接返回该数据体),失败用 `ctx.throw(422, fail(ERR_CODE.X, '中文提示'))`。 +- 登录鉴权用 `authn(CERT_TYPE.ACCOUNT)`;当前身份取 `ctx.state.acct_cert`(类型 `UserCert`,字段含 `id`、`username`)。 +- 查询参数经 `queryTypeParser` 解析后在 `ctx.state.query`(已按类型转换);请求体在 `ctx.request.body`。 +- 前端 API:`request(url,{ method, headers:{ [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }, data, params })`;`getLocalToken()` 返回 `Bearer `;token 存 `localStorage[LOC_STOR_KEY.AUTH_TOKEN]`(key `WEB_AUTH_TOKEN`)。 +- 论坛 ID 用自增 `Int`(与 `Material` 一致),**不用** id-kit 前缀。 +- 板块固定为常量 `FORUM_BOARDS = ['甲醛治理','异味TVOC','环保材料','装修避坑','求助问答']`,帖子 `board` 必须属于该列表。 +- 后端 dev 服务端口 6060,前端 8001。curl 验证统一加 `--noproxy '*'` 且用 `127.0.0.1`(本机代理/MSYS 路径坑)。 +- 编辑新建的 `.sh`/脚本无关;所有源码改动完成后各自任务末尾 `git commit`(仓库根为 `C:/code`,提交路径含中文目录,正常)。 + +--- + +## 文件结构 + +### 后端 `源码/服务端/iapip-svr` +- `prisma/schema.prisma` — 新增 `ForumThread` / `ForumReply` / `ForumThreadLike` 三表(Task 1) +- `src/common/constants.ts` — 新增 `FORUM_BOARDS` 常量(Task 1) +- `src/controllers/forum.ts` — **新建**,论坛全部路由(Task 2–4) +- `src/controllers/material.ts` — 新增 `GET /eco` 免认证接口(Task 5) +- `src/index.ts` — 注册 `forumRoutes`(Task 2) + +### 前端 `源码/用户端/iapip-web` +- `src/services/api/forum.ts` — **新建**,论坛接口封装(Task 6) +- `src/services/api/material.ts` — 新增 `getEcoMaterials`(Task 6) +- `src/services/api/index.ts` — 聚合 `forum`(Task 6) +- `src/pages/forum/index.tsx` — **新建**,论坛首页(Task 7) +- `src/pages/forum/thread.tsx` — **新建**,帖子详情(Task 8) +- `.umirc.ts` — 新增 `/forum`、`/forum/thread/:id` 公开路由(Task 7) +- `src/pages/landing/index.tsx` — 导航 4 项 + 区块改名 + 新增 `#eco` 区块(Task 9) + +--- + +## Task 1: Prisma 论坛表 + 板块常量 + 迁移 + +**Files:** +- Modify: `源码/服务端/iapip-svr/prisma/schema.prisma`(文件末尾追加 3 个 model) +- Modify: `源码/服务端/iapip-svr/src/common/constants.ts`(追加常量) + +**Interfaces:** +- Produces: Prisma 模型 `ForumThread`(id,board,title,content,author_id,author_name,like_count,reply_count,created_at,updated_at,deleted)、`ForumReply`(id,thread_id,floor,content,author_id,author_name,created_at,deleted)、`ForumThreadLike`(thread_id,user_id 联合主键);常量 `FORUM_BOARDS: string[]`。 + +- [ ] **Step 1: 追加 Prisma 模型** + +在 `prisma/schema.prisma` **文件末尾**追加: + +```prisma +// ------ 论坛 ------ + +model ForumThread { + id Int @id @default(autoincrement()) + board String // 板块(取值属于 FORUM_BOARDS) + title String + content String @db.Text + author_id String // User.id + author_name String // 冗余 username + like_count Int @default(0) + reply_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + deleted Int @default(0) + replies ForumReply[] + likes ForumThreadLike[] + + @@map("forum_thread") +} + +model ForumReply { + id Int @id @default(autoincrement()) + thread_id Int + thread ForumThread @relation(fields: [thread_id], references: [id], onDelete: Cascade) + floor Int // 楼层 + content String @db.Text + author_id String + author_name String + created_at DateTime @default(now()) + deleted Int @default(0) + + @@map("forum_reply") +} + +model ForumThreadLike { + thread_id Int + thread ForumThread @relation(fields: [thread_id], references: [id], onDelete: Cascade) + user_id String + created_at DateTime @default(now()) + + @@id([thread_id, user_id]) + @@map("forum_thread_like") +} +``` + +- [ ] **Step 2: 追加板块常量** + +在 `src/common/constants.ts` **文件末尾**追加: + +```ts +// 论坛板块(固定,不建表) +export const FORUM_BOARDS = ['甲醛治理', '异味TVOC', '环保材料', '装修避坑', '求助问答'] +``` + +- [ ] **Step 3: 执行迁移(生成 Prisma Client + 建表)** + +Run: +```bash +cd "C:/code/空气质量预测/源码/服务端/iapip-svr" && npm run dbpush:dev +``` +Expected: 输出包含 `Your database is now in sync with your Prisma schema` 且 `Generated Prisma Client` 成功。 + +- [ ] **Step 4: 验证表已创建** + +Run: +```bash +docker exec iapips-mysql mysql -uroot -pharvey0425 -N -e "SHOW TABLES LIKE 'forum_%'" iapips +``` +Expected: 三行 —— `forum_reply`、`forum_thread`、`forum_thread_like`。 + +- [ ] **Step 5: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/服务端/iapip-svr/prisma/schema.prisma" "空气质量预测/源码/服务端/iapip-svr/src/common/constants.ts" && git commit -m "feat(forum): add forum prisma models and board constant" +``` + +--- + +## Task 2: 论坛 controller — 公开读接口 + 路由注册 + +**Files:** +- Create: `源码/服务端/iapip-svr/src/controllers/forum.ts` +- Modify: `源码/服务端/iapip-svr/src/index.ts`(import + `app.use`) + +**Interfaces:** +- Consumes: `db`、`FORUM_BOARDS`、`fail`、`ERR_CODE`。 +- Produces: `export const forumRoutes`;HTTP `GET /api/forum/boards` → `{board,count}[]`;`GET /api/forum/threads?board&page&size` → `{list,total}`;`GET /api/forum/threads/:id` → `{thread,replies}`。 + +- [ ] **Step 1: 新建 controller(先只放公开读接口)** + +创建 `src/controllers/forum.ts`: + +```ts +import Router from 'koa-router' +import validate from '../middlewares/validator' +import { db } from '..' +import { fail } from '../common/utils' +import { ERR_CODE, FORUM_BOARDS } from '../common/constants' + +const forumRouter = new Router() +forumRouter.prefix('/api/forum') + +// 板块列表 + 帖数(公开) +forumRouter.get('/boards', async ctx => { + const grouped = await db.forumThread.groupBy({ + by: ['board'], + where: { deleted: 0 }, + _count: { _all: true } + }) + const countMap: Record = {} + grouped.forEach(g => { countMap[g.board] = g._count._all }) + ctx.body = FORUM_BOARDS.map(board => ({ board, count: countMap[board] ?? 0 })) +}) + +// 帖子列表(公开,分页,按最新) +forumRouter.get( + '/threads', + validate({ + query: { + type: 'object', + properties: { + board: { type: 'string' }, + page: { type: 'number' }, + size: { type: 'number' } + } + } + }), + async ctx => { + const q = ctx.state.query as { board?: string, page?: number, size?: number } + const page = q.page && q.page > 0 ? q.page : 1 + const size = q.size && q.size > 0 ? q.size : 20 + const where: { deleted: number, board?: string } = { deleted: 0 } + if (q.board && FORUM_BOARDS.includes(q.board)) where.board = q.board + const [list, total] = await Promise.all([ + db.forumThread.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip: (page - 1) * size, + take: size, + select: { + id: true, board: true, title: true, author_name: true, + like_count: true, reply_count: true, created_at: true + } + }), + db.forumThread.count({ where }) + ]) + ctx.body = { list, total } + } +) + +// 帖子详情 + 回帖(公开) +forumRouter.get('/threads/:id', async ctx => { + const id = Number(ctx.params.id) + if (!Number.isInteger(id)) { + ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '参数错误')) + return + } + const thread = await db.forumThread.findFirst({ where: { id, deleted: 0 } }) + if (!thread) { + ctx.throw(422, fail(ERR_CODE.UNKNOWN_ERR, '帖子不存在')) + return + } + const replies = await db.forumReply.findMany({ + where: { thread_id: id, deleted: 0 }, + orderBy: { floor: 'asc' } + }) + ctx.body = { thread, replies } +}) + +export const forumRoutes = forumRouter.routes() +``` + +- [ ] **Step 2: 注册路由** + +在 `src/index.ts` 中,仿照现有 `import { auditRoutes } from './controllers/audit'` 追加 import: + +```ts +import { forumRoutes } from './controllers/forum' +``` + +并在 `app.use(auditRoutes)` 之后追加: + +```ts +app.use(forumRoutes) +``` + +- [ ] **Step 3: 确保后端 dev 服务运行(红/绿验证前提)** + +若未运行: +```bash +cd "C:/code/空气质量预测/源码/服务端/iapip-svr" && npm run start:dev +``` +(后台运行,等待日志 `Server started at 6060.`) + +- [ ] **Step 4: 验证 boards 接口(绿)** + +Run: +```bash +curl -s --noproxy '*' http://127.0.0.1:6060/api/forum/boards +``` +Expected: JSON 数组,5 个板块,每个 `count` 为 `0`,例如: +`[{"board":"甲醛治理","count":0},{"board":"异味TVOC","count":0},...]` + +- [ ] **Step 5: 验证 threads 空列表** + +Run: +```bash +curl -s --noproxy '*' "http://127.0.0.1:6060/api/forum/threads" +``` +Expected: `{"list":[],"total":0}` + +- [ ] **Step 6: 验证不存在的帖子详情报错** + +Run: +```bash +curl -s --noproxy '*' http://127.0.0.1:6060/api/forum/threads/999999 +``` +Expected: HTTP 422,body 含 `"code":"4000"`(帖子不存在)。 + +- [ ] **Step 7: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/forum.ts" "空气质量预测/源码/服务端/iapip-svr/src/index.ts" && git commit -m "feat(forum): public read endpoints (boards/threads/detail)" +``` + +--- + +## Task 3: 论坛 controller — 发帖 + 回帖(登录) + +**Files:** +- Modify: `源码/服务端/iapip-svr/src/controllers/forum.ts` + +**Interfaces:** +- Consumes: `authn`、`CERT_TYPE`、`UserCert`(新增 import)。 +- Produces: `POST /api/forum/threads` → 新建 `ForumThread`;`POST /api/forum/threads/:id/replies` → 新建 `ForumReply`(floor 递增,thread.reply_count 同步)。 + +- [ ] **Step 1: 补充 import** + +`forum.ts` 顶部 import 段追加: + +```ts +import authn from '../middlewares/authn' +import { CERT_TYPE, UserCert } from '../common/types' +``` + +- [ ] **Step 2: 新增发帖与回帖路由** + +在 `export const forumRoutes = ...` **之前**追加: + +```ts +// 发帖(登录) +forumRouter.post( + '/threads', + authn(CERT_TYPE.ACCOUNT), + validate({ + body: { + type: 'object', + properties: { + board: { type: 'string' }, + title: { type: 'string', minLength: 1, maxLength: 60 }, + content: { type: 'string', minLength: 1, maxLength: 5000 } + }, + required: ['board', 'title', 'content'], + additionalProperties: false + } + }), + async ctx => { + const { board, title, content } = ctx.request.body as { board: string, title: string, content: string } + if (!FORUM_BOARDS.includes(board)) { + ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '非法板块')) + return + } + const cert = ctx.state.acct_cert as UserCert + const thread = await db.forumThread.create({ + data: { board, title, content, author_id: cert.id, author_name: cert.username } + }) + ctx.body = thread + } +) + +// 回帖(登录) +forumRouter.post( + '/threads/:id/replies', + authn(CERT_TYPE.ACCOUNT), + validate({ + body: { + type: 'object', + properties: { + content: { type: 'string', minLength: 1, maxLength: 5000 } + }, + required: ['content'], + additionalProperties: false + } + }), + async ctx => { + const id = Number(ctx.params.id) + const cert = ctx.state.acct_cert as UserCert + const { content } = ctx.request.body as { content: string } + const thread = await db.forumThread.findFirst({ where: { id, deleted: 0 } }) + if (!thread) { + ctx.throw(422, fail(ERR_CODE.UNKNOWN_ERR, '帖子不存在')) + return + } + const reply = await db.$transaction(async tx => { + const updated = await tx.forumThread.update({ + where: { id }, + data: { reply_count: { increment: 1 } } + }) + return tx.forumReply.create({ + data: { + thread_id: id, + floor: updated.reply_count, + content, + author_id: cert.id, + author_name: cert.username + } + }) + }) + ctx.body = reply + } +) +``` + +- [ ] **Step 3: 取得测试 token(开发短信登录)** + +Run(发验证码,dev 模式直接返回 code): +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/user/auth/sms/send -H "Content-Type: application/json" -d '{"phone":"13800138000"}' +``` +Expected: body 形如 `{"code":"123456"}`(记下该 code)。 + +Run(用该 code 登录,得到 token): +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/user/auth/sms/login -H "Content-Type: application/json" -d '{"phone":"13800138000","code":"上一步的code"}' +``` +Expected: body 含 `"token":"..."`。把 token 存入变量: +```bash +TOKEN=$(curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/user/auth/sms/login -H "Content-Type: application/json" -d '{"phone":"13800138000","code":"上一步的code"}' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') +echo "$TOKEN" +``` + +- [ ] **Step 4: 验证未登录发帖被拒(红)** + +Run: +```bash +curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:6060/api/forum/threads -H "Content-Type: application/json" -d '{"board":"甲醛治理","title":"t","content":"c"}' +``` +Expected: `401`(未授权)。 + +- [ ] **Step 5: 验证登录发帖成功(绿)** + +Run: +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/forum/threads -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"board":"甲醛治理","title":"新家除醛求助","content":"主卧衣柜味道大,怎么办"}' +``` +Expected: 返回创建的帖子 JSON,含 `"id"`、`"reply_count":0`、`"author_name"`。记下 `id`(记为 `TID`)。 + +- [ ] **Step 6: 验证回帖楼层递增(绿)** + +Run(连发两条): +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/forum/threads/$TID/replies -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"content":"先通风三个月"}' +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/forum/threads/$TID/replies -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"content":"建议换 E0 板材"}' +``` +Expected: 第一条 `"floor":1`,第二条 `"floor":2`。 + +Run(详情确认 reply_count 与列表): +```bash +curl -s --noproxy '*' http://127.0.0.1:6060/api/forum/threads/$TID +``` +Expected: `thread.reply_count` = `2`,`replies` 两条,floor 分别 1、2。 + +- [ ] **Step 7: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/forum.ts" && git commit -m "feat(forum): create thread and reply with floor increment" +``` + +--- + +## Task 4: 论坛 controller — 点赞 toggle + 点赞状态 + 作者删除 + +**Files:** +- Modify: `源码/服务端/iapip-svr/src/controllers/forum.ts` + +**Interfaces:** +- Produces: `POST /api/forum/threads/:id/like` → `{liked,like_count}`(toggle);`GET /api/forum/threads/:id/like` → `{liked,like_count}`(登录,初始状态);`DELETE /api/forum/threads/:id`、`DELETE /api/forum/replies/:id` → `{ok:true}`(仅作者)。 + +- [ ] **Step 1: 新增点赞与删除路由** + +在 `export const forumRoutes = ...` **之前**追加: + +```ts +// 点赞状态(登录,用于详情页初始化) +forumRouter.get('/threads/:id/like', authn(CERT_TYPE.ACCOUNT), async ctx => { + const id = Number(ctx.params.id) + const cert = ctx.state.acct_cert as UserCert + const existing = await db.forumThreadLike.findUnique({ + where: { thread_id_user_id: { thread_id: id, user_id: cert.id } } + }) + const thread = await db.forumThread.findFirst({ where: { id }, select: { like_count: true } }) + ctx.body = { liked: !!existing, like_count: thread?.like_count ?? 0 } +}) + +// 点赞 / 取消(登录,toggle) +forumRouter.post('/threads/:id/like', authn(CERT_TYPE.ACCOUNT), async ctx => { + const id = Number(ctx.params.id) + const cert = ctx.state.acct_cert as UserCert + const thread = await db.forumThread.findFirst({ where: { id, deleted: 0 } }) + if (!thread) { + ctx.throw(422, fail(ERR_CODE.UNKNOWN_ERR, '帖子不存在')) + return + } + const existing = await db.forumThreadLike.findUnique({ + where: { thread_id_user_id: { thread_id: id, user_id: cert.id } } + }) + let liked: boolean + const updated = await db.$transaction(async tx => { + if (existing) { + await tx.forumThreadLike.delete({ + where: { thread_id_user_id: { thread_id: id, user_id: cert.id } } + }) + liked = false + return tx.forumThread.update({ where: { id }, data: { like_count: { decrement: 1 } } }) + } else { + await tx.forumThreadLike.create({ data: { thread_id: id, user_id: cert.id } }) + liked = true + return tx.forumThread.update({ where: { id }, data: { like_count: { increment: 1 } } }) + } + }) + ctx.body = { liked, like_count: updated.like_count } +}) + +// 删除自己的帖(登录,软删) +forumRouter.delete('/threads/:id', authn(CERT_TYPE.ACCOUNT), async ctx => { + const id = Number(ctx.params.id) + const cert = ctx.state.acct_cert as UserCert + const thread = await db.forumThread.findFirst({ where: { id, deleted: 0 } }) + if (!thread) { + ctx.throw(422, fail(ERR_CODE.UNKNOWN_ERR, '帖子不存在')) + return + } + if (thread.author_id !== cert.id) { + ctx.throw(422, fail(ERR_CODE.FORBIDDEN, '只能删除自己的帖子')) + return + } + await db.forumThread.update({ where: { id }, data: { deleted: 1 } }) + ctx.body = { ok: true } +}) + +// 删除自己的回帖(登录,软删;不回退 floor) +forumRouter.delete('/replies/:id', authn(CERT_TYPE.ACCOUNT), async ctx => { + const id = Number(ctx.params.id) + const cert = ctx.state.acct_cert as UserCert + const reply = await db.forumReply.findFirst({ where: { id, deleted: 0 } }) + if (!reply) { + ctx.throw(422, fail(ERR_CODE.UNKNOWN_ERR, '回帖不存在')) + return + } + if (reply.author_id !== cert.id) { + ctx.throw(422, fail(ERR_CODE.FORBIDDEN, '只能删除自己的回帖')) + return + } + await db.forumReply.update({ where: { id }, data: { deleted: 1 } }) + ctx.body = { ok: true } +}) +``` + +- [ ] **Step 2: 验证点赞 toggle(绿)** + +(沿用 Task 3 的 `$TOKEN` 与 `$TID`;若已过期则重取 token。) + +Run(第一次点赞): +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/forum/threads/$TID/like -H "Authorization: Bearer $TOKEN" +``` +Expected: `{"liked":true,"like_count":1}` + +Run(再次点击取消): +```bash +curl -s --noproxy '*' -X POST http://127.0.0.1:6060/api/forum/threads/$TID/like -H "Authorization: Bearer $TOKEN" +``` +Expected: `{"liked":false,"like_count":0}` + +- [ ] **Step 3: 验证越权删除被拒 + 作者删除成功** + +Run(无 token 删除 → 未授权): +```bash +curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:6060/api/forum/threads/$TID +``` +Expected: `401` + +Run(作者删除自己的帖): +```bash +curl -s --noproxy '*' -X DELETE http://127.0.0.1:6060/api/forum/threads/$TID -H "Authorization: Bearer $TOKEN" +``` +Expected: `{"ok":true}` + +Run(删除后详情不可见): +```bash +curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" http://127.0.0.1:6060/api/forum/threads/$TID +``` +Expected: `422`(帖子不存在,已软删)。 + +- [ ] **Step 4: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/forum.ts" && git commit -m "feat(forum): like toggle, like status, author soft-delete" +``` + +--- + +## Task 5: 环保建材免认证接口 `GET /api/mtrl/eco` + +**Files:** +- Modify: `源码/服务端/iapip-svr/src/controllers/material.ts` + +**Interfaces:** +- Produces: `GET /api/mtrl/eco`(公开)→ `PublicMaterial` 精简数组(≤8 条,E0 优先,不足补 E1)。 + +- [ ] **Step 1: 新增 /eco 路由(务必在 `/:type` 之前)** + +在 `material.ts` 中 `materialRouter.prefix('/api/mtrl')` 行之后、**第一个 GET 路由之前**插入(确保字面量路由 `/eco` 先于任何 `/:参数` 路由被匹配): + +```ts +// 环保建材展示(公开,免认证):E0 优先,不足补 E1,最多 8 条 +materialRouter.get('/eco', async ctx => { + const take = 8 + const selectFields = { + material_id: true, name: true, category: true, + brand: true, factory: true, eco_level: true, methanal: true + } + const e0 = await db.publicMaterial.findMany({ + where: { deleted: 0, display: 1, eco_level: 'E0' }, + orderBy: { material_id: 'desc' }, + take, + select: selectFields + }) + let list = e0 + if (list.length < take) { + const e1 = await db.publicMaterial.findMany({ + where: { deleted: 0, display: 1, eco_level: 'E1' }, + orderBy: { material_id: 'desc' }, + take: take - list.length, + select: selectFields + }) + list = list.concat(e1) + } + ctx.body = list +}) +``` + +> 注:`material.ts` 顶部已 import `db`;若 `deleted` 字段在 `PublicMaterial` 上不存在(以 schema 为准),去掉该 where 条件即可。`display` 为「是否展示」,默认 0,只展示已上架材料。 + +- [ ] **Step 2: 验证接口返回数组(绿)** + +Run: +```bash +curl -s --noproxy '*' http://127.0.0.1:6060/api/mtrl/eco +``` +Expected: 返回 JSON **数组**(`[...]`,非报错对象)。若库中无 E0/E1 上架材料则为 `[]`;若有则每项含 `material_id/name/category/brand/eco_level` 等字段,且**不含**释放参数三件套(`methanal_min_be` 等)。 + +- [ ] **Step 3: 确认未破坏原 `/:type` 路由** + +Run(原公共材料查询仍需登录,用 Task 3 的 `$TOKEN`): +```bash +curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" http://127.0.0.1:6060/api/mtrl/pub -H "Authorization: Bearer $TOKEN" +``` +Expected: `200`(`/eco` 未吃掉 `/:type` 匹配)。 + +- [ ] **Step 4: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts" && git commit -m "feat(material): public GET /eco endpoint for eco-friendly materials" +``` + +--- + +## Task 6: 前端 API 封装(forum + eco + 聚合) + +**Files:** +- Create: `源码/用户端/iapip-web/src/services/api/forum.ts` +- Modify: `源码/用户端/iapip-web/src/services/api/material.ts`(追加 `getEcoMaterials`) +- Modify: `源码/用户端/iapip-web/src/services/api/index.ts`(聚合 `forum`) + +**Interfaces:** +- Produces: `api.forum.{getBoards,getThreads,getThread,getLikeStatus,createThread,createReply,toggleLike,deleteThread,deleteReply}`;`api.material.getEcoMaterials`。类型 `ForumThreadListItem/ForumThread/ForumReply/EcoMaterial`。 + +- [ ] **Step 1: 新建 `services/api/forum.ts`** + +```ts +import { HTTP_HEADER } from '@/common/constants' +import { AnyObj } from '@/common/types' +import { getLocalToken } from '@/common/utils' +import { request } from '@umijs/max' + +export interface ForumThreadListItem { + id: number + board: string + title: string + author_name: string + like_count: number + reply_count: number + created_at: string +} +export interface ForumThread extends ForumThreadListItem { + content: string + author_id: string + updated_at: string +} +export interface ForumReply { + id: number + thread_id: number + floor: number + content: string + author_id: string + author_name: string + created_at: string +} + +const auth = () => ({ [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }) + +export async function getBoards(options?: AnyObj) { + return request<{ board: string, count: number }[]>('/api/forum/boards', { method: 'GET', ...(options || {}) }) +} +export async function getThreads(params: { board?: string, page?: number, size?: number }, options?: AnyObj) { + return request<{ list: ForumThreadListItem[], total: number }>('/api/forum/threads', { method: 'GET', params, ...(options || {}) }) +} +export async function getThread(id: number, options?: AnyObj) { + return request<{ thread: ForumThread, replies: ForumReply[] }>(`/api/forum/threads/${id}`, { method: 'GET', ...(options || {}) }) +} +export async function getLikeStatus(id: number, options?: AnyObj) { + return request<{ liked: boolean, like_count: number }>(`/api/forum/threads/${id}/like`, { method: 'GET', headers: auth(), ...(options || {}) }) +} +export async function createThread(body: { board: string, title: string, content: string }, options?: AnyObj) { + return request('/api/forum/threads', { method: 'POST', headers: auth(), data: body, ...(options || {}) }) +} +export async function createReply(id: number, body: { content: string }, options?: AnyObj) { + return request(`/api/forum/threads/${id}/replies`, { method: 'POST', headers: auth(), data: body, ...(options || {}) }) +} +export async function toggleLike(id: number, options?: AnyObj) { + return request<{ liked: boolean, like_count: number }>(`/api/forum/threads/${id}/like`, { method: 'POST', headers: auth(), ...(options || {}) }) +} +export async function deleteThread(id: number, options?: AnyObj) { + return request<{ ok: boolean }>(`/api/forum/threads/${id}`, { method: 'DELETE', headers: auth(), ...(options || {}) }) +} +export async function deleteReply(id: number, options?: AnyObj) { + return request<{ ok: boolean }>(`/api/forum/replies/${id}`, { method: 'DELETE', headers: auth(), ...(options || {}) }) +} +``` + +- [ ] **Step 2: `services/api/material.ts` 追加 eco 接口** + +在文件末尾追加(`request`、`AnyObj` 已在该文件 import;若未 import 则补 `import { request } from '@umijs/max'`): + +```ts +export interface EcoMaterial { + material_id: string + name: string + category: string + brand: string + factory: string + eco_level: string + methanal: number +} +export async function getEcoMaterials(options?: AnyObj) { + return request('/api/mtrl/eco', { method: 'GET', ...(options || {}) }) +} +``` + +- [ ] **Step 3: `services/api/index.ts` 聚合 forum** + +改为: + +```ts +import * as admin from './admin' +import * as config from './config' +import * as material from './material' +import * as predict from './predict' +import * as project from './project' +import * as template from './template' +import * as user from './user' +import * as forum from './forum' + +export default { + admin, + user, + project, + template, + material, + config, + predict, + forum +} +``` + +- [ ] **Step 4: 验证前端编译无 TS 报错** + +确保前端 dev 运行(`cd 源码/用户端/iapip-web && PORT=8001 pnpm dev`)。保存后查看 dev 日志尾部: +Run: +```bash +tail -n 15 <前端 dev 输出日志路径> +``` +Expected: 出现 `[Webpack] Compiled in ...`,**无** `TS` 报错、无 `Failed to compile`。 + +- [ ] **Step 5: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/services/api/forum.ts" "空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts" "空气质量预测/源码/用户端/iapip-web/src/services/api/index.ts" && git commit -m "feat(web): forum and eco-materials api clients" +``` + +--- + +## Task 7: 前端论坛首页 `/forum` + 路由 + +**Files:** +- Create: `源码/用户端/iapip-web/src/pages/forum/index.tsx` +- Modify: `源码/用户端/iapip-web/.umirc.ts`(新增两条公开路由) + +**Interfaces:** +- Consumes: `api.forum.getBoards/getThreads/createThread`;`PhoneAuthModal`;`LOC_STOR_KEY`。 +- Produces: 路由 `/forum`(组件 `./forum`)。 + +- [ ] **Step 1: 新增路由** + +`.umirc.ts` 的 `routes` 数组中,`落地页` 路由项之后追加: + +```ts + { + name: '健康装修大家谈', + path: '/forum', + component: './forum', + layout: false + }, + { + name: '帖子详情', + path: '/forum/thread/:id', + component: './forum/thread', + layout: false + }, +``` + +- [ ] **Step 2: 新建论坛首页组件** + +创建 `src/pages/forum/index.tsx`: + +```tsx +import { useEffect, useState, useCallback } from 'react' +import { history } from '@umijs/max' +import { Tabs, List, Button, Modal, Form, Input, Select, Pagination, message, Empty } from 'antd' +import api from '@/services/api' +import type { ForumThreadListItem } from '@/services/api/forum' +import { LOC_STOR_KEY } from '@/common/constants' +import PhoneAuthModal from '@/components/phone-auth-modal' + +const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN) +const PAGE_SIZE = 10 + +export default function ForumHome() { + const [boards, setBoards] = useState<{ board: string, count: number }[]>([]) + const [active, setActive] = useState('all') + const [list, setList] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + + const [authOpen, setAuthOpen] = useState(false) + const [postOpen, setPostOpen] = useState(false) + const [form] = Form.useForm() + + const loadBoards = useCallback(async () => { + try { setBoards(await api.forum.getBoards()) } catch { /* 全局提示 */ } + }, []) + + const loadThreads = useCallback(async (p: number, board: string) => { + setLoading(true) + try { + const res = await api.forum.getThreads({ + board: board === 'all' ? undefined : board, + page: p, + size: PAGE_SIZE + }) + setList(res.list) + setTotal(res.total) + } catch { /* 全局提示 */ } finally { setLoading(false) } + }, []) + + useEffect(() => { loadBoards() }, [loadBoards]) + useEffect(() => { loadThreads(page, active) }, [page, active, loadThreads]) + + const onTab = (key: string) => { setActive(key); setPage(1) } + + const openPost = () => { + if (!hasToken()) { setAuthOpen(true); return } + form.resetFields() + setPostOpen(true) + } + + const submitPost = async () => { + const v = await form.validateFields() + try { + await api.forum.createThread(v) + message.success('发布成功') + setPostOpen(false) + setActive(v.board); setPage(1) + loadThreads(1, v.board) + loadBoards() + } catch { /* 全局提示 */ } + } + + const boardOptions = boards.map(b => ({ label: `${b.board} (${b.count})`, value: b.board })) + + return ( +
+ + + ({ key: b.board, label: `${b.board} (${b.count})` }))]} + /> + + }} + dataSource={list} + renderItem={t => ( + history.push(`/forum/thread/${t.id}`)} + actions={[💬 {t.reply_count}, 👍 {t.like_count}]} + > + [{t.board}] {t.title}} + description={`${t.author_name} · ${new Date(t.created_at).toLocaleString()}`} + /> + + )} + /> + + {total > PAGE_SIZE && ( +
+ +
+ )} + + setPostOpen(false)} okText="发布" destroyOnClose> +
+ + + + + + +
+
+ + { setAuthOpen(false); openPost() }} + onCancel={() => setAuthOpen(false)} + /> +
+ ) +} +``` + +- [ ] **Step 3: 浏览器验证** + +前端 dev 运行中,浏览器打开 `http://localhost:8001/iapip-web/#/forum`。 +Expected: +- 顶部标题「健康装修大家谈」+「发帖」按钮;板块 Tab 含「全部」+5 板块(带计数)。 +- 若已跑过后端 Task 3 未删的帖子则列表可见;否则空态「还没有帖子」。 +- 未登录点「发帖」→ 弹手机验证码登录框;登录后自动打开发帖表单;填写并发布 → 成功提示、列表刷新出现新帖。 + +- [ ] **Step 4: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/forum/index.tsx" "空气质量预测/源码/用户端/iapip-web/.umirc.ts" && git commit -m "feat(web): forum home page with boards, list, post" +``` + +--- + +## Task 8: 前端帖子详情 `/forum/thread/:id` + +**Files:** +- Create: `源码/用户端/iapip-web/src/pages/forum/thread.tsx` + +**Interfaces:** +- Consumes: `api.forum.getThread/getLikeStatus/toggleLike/createReply/deleteThread/deleteReply`;`useParams`;`PhoneAuthModal`。 + +- [ ] **Step 1: 新建详情组件** + +创建 `src/pages/forum/thread.tsx`: + +```tsx +import { useEffect, useState, useCallback } from 'react' +import { history, useParams } from '@umijs/max' +import { Button, Input, List, message, Popconfirm, Spin } from 'antd' +import api from '@/services/api' +import type { ForumThread, ForumReply } from '@/services/api/forum' +import { LOC_STOR_KEY } from '@/common/constants' +import PhoneAuthModal from '@/components/phone-auth-modal' + +const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN) + +export default function ThreadDetail() { + const params = useParams<{ id: string }>() + const id = Number(params.id) + + const [thread, setThread] = useState(null) + const [replies, setReplies] = useState([]) + const [liked, setLiked] = useState(false) + const [likeCount, setLikeCount] = useState(0) + const [loading, setLoading] = useState(true) + const [replyText, setReplyText] = useState('') + const [authOpen, setAuthOpen] = useState(false) + const [pendingAfterLogin, setPendingAfterLogin] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await api.forum.getThread(id) + setThread(res.thread) + setReplies(res.replies) + setLikeCount(res.thread.like_count) + if (hasToken()) { + try { const ls = await api.forum.getLikeStatus(id); setLiked(ls.liked); setLikeCount(ls.like_count) } catch { /* ignore */ } + } + } catch { /* 全局提示 */ } finally { setLoading(false) } + }, [id]) + + useEffect(() => { load() }, [load]) + + const doLike = async () => { + if (!hasToken()) { setPendingAfterLogin('like'); setAuthOpen(true); return } + try { + const res = await api.forum.toggleLike(id) + setLiked(res.liked); setLikeCount(res.like_count) + } catch { /* 全局提示 */ } + } + + const doReply = async () => { + if (!replyText.trim()) { message.warning('请输入回帖内容'); return } + if (!hasToken()) { setPendingAfterLogin('reply'); setAuthOpen(true); return } + try { + await api.forum.createReply(id, { content: replyText.trim() }) + setReplyText('') + message.success('回帖成功') + load() + } catch { /* 全局提示 */ } + } + + const onAuthed = () => { + setAuthOpen(false) + const act = pendingAfterLogin + setPendingAfterLogin(null) + load().then(() => { + if (act === 'like') doLike() + else if (act === 'reply') doReply() + }) + } + + const delThread = async () => { + try { await api.forum.deleteThread(id); message.success('已删除'); history.push('/forum') } catch { /* 全局提示 */ } + } + const delReply = async (rid: number) => { + try { await api.forum.deleteReply(rid); message.success('已删除'); load() } catch { /* 全局提示 */ } + } + + if (loading) return
+ if (!thread) return
帖子不存在或已删除。 history.push('/forum')}>返回论坛
+ + return ( +
+ history.push('/forum')} style={{ color: '#888' }}>← 返回论坛 + +
+
{thread.board}
+

{thread.title}

+
{thread.author_name} · {new Date(thread.created_at).toLocaleString()}
+

{thread.content}

+
+ + 💬 {replies.length} + + 删除 + +
+
+ +

全部回帖({replies.length})

+ ( + delReply(r.id)}>删除]}> + #{r.floor} · {r.author_name} · {new Date(r.created_at).toLocaleString()}} + description={{r.content}} + /> + + )} + /> + +
+ setReplyText(e.target.value)} placeholder="写下你的回帖…" maxLength={5000} /> +
+ +
+
+ + { setAuthOpen(false); setPendingAfterLogin(null) }} /> +
+ ) +} +``` + +> 注:删除入口对所有登录者可见,但后端仅允许作者删除(非作者点删除会收到 403 全局提示)。这是最小实现;如需仅作者可见删除入口,可后续用 cert.id 比对 author_id 隐藏。 + +- [ ] **Step 2: 浏览器验证** + +从 `/forum` 点一条帖子进入详情(或直接 `#/forum/thread/`)。 +Expected: +- 显示标题、板块、作者、正文、回帖列表(含楼层 #1/#2)。 +- 未登录点「👍」或「回帖」→ 弹登录框;登录后自动完成点赞/回帖。 +- 点赞后按钮高亮且计数 +1,再点取消 -1。 +- 回帖成功后列表新增一条、楼层递增。 +- 删除自己的帖 → 跳回 `/forum`。 + +- [ ] **Step 3: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/forum/thread.tsx" && git commit -m "feat(web): forum thread detail with like and reply" +``` + +--- + +## Task 9: landing 导航改版 + 区块改名 + 环保建材区块 + +**Files:** +- Modify: `源码/用户端/iapip-web/src/pages/landing/index.tsx` + +**Interfaces:** +- Consumes: `api.material.getEcoMaterials`;`history`。 + +- [ ] **Step 1: 顶部 import 取数依赖** + +`landing/index.tsx` 顶部已 import `useEffect/useState`、`history`。追加: + +```ts +import api from '@/services/api' +import type { EcoMaterial } from '@/services/api/material' +``` + +- [ ] **Step 2: 组件内加环保建材取数** + +在 `export default function Landing()` 内,`const [authOpen, setAuthOpen] = useState(false)` 之后追加: + +```tsx + const [ecoList, setEcoList] = useState([]) + useEffect(() => { + api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([])) + }, []) +``` + +- [ ] **Step 3: 替换导航为 4 项** + +把现有 ``(5 个 ``)整体替换为: + +```tsx + +``` + +- [ ] **Step 4: 两处区块标签改名** + +- 把 `#news` 区块内 `
资讯 · 科普
` 改为 `
健康装修科普
`。 +- 把 `#cases` 区块内 `
治理案例
` 改为 `
预测案例
`。 + +(区块其余内容与静态数据不变。) + +- [ ] **Step 5: 在 #cases 与 #how 之间插入环保建材区块** + +在 `#cases` 的 `` 之后、`#how` 的 `
` 之前插入: + +```tsx + {ecoList.length > 0 && ( +
+
+
+
+
环保建材
+

优选低释放材料,从源头减少污染

+

以下为材料库中环保等级 E0/E1 的低释放建材,选材阶段即可降低甲醛与 TVOC 风险。

+
+
+
+ {ecoList.map(m => ( +
+
+
{m.category} · {m.brand}
+

{m.name}

+
{m.factory || '—'}
+
+ 环保 {m.eco_level} + {typeof m.methanal === 'number' && 甲醛 {m.methanal}} +
+
+
+ ))} +
+
+
+ )} +``` + +> 复用现有 `.cases-grid` / `.case` / `.chip-good` 样式,无需新增 CSS。空数据时该区块整体不渲染(导航「环保建材」仍存在,点击滚动无目标时页面不跳动,可接受)。 + +- [ ] **Step 6: 浏览器验证** + +浏览器打开 `http://localhost:8001/iapip-web/#/landing`(硬刷新)。 +Expected: +- 顶部导航恰为 4 项:健康装修科普 / 预测案例 / 环保建材 / 健康装修大家谈。 +- 点前 3 项平滑滚动到对应区块;`#news` 标签显示「健康装修科普」、`#cases` 标签显示「预测案例」,内容不变。 +- 点「健康装修大家谈」跳转 `/forum`。 +- 若材料库有 E0/E1 上架材料,`#eco` 区块出现材料卡片;否则该区块不显示、页面其余正常。 + +- [ ] **Step 7: Commit** + +```bash +cd "C:/code" && git add "空气质量预测/源码/用户端/iapip-web/src/pages/landing/index.tsx" && git commit -m "feat(web): landing 4-item nav, section relabels, eco-materials section" +``` + +--- + +## Self-Review 记录 + +- **Spec 覆盖**:A 导航/改名→Task 9;B 环保建材(后端)→Task 5、(前端)→Task 6+9;C 数据模型→Task 1;D 后端接口→Task 2/3/4;E 前端页面→Task 7/8、API→Task 6。全部有对应任务。 +- **类型一致**:`author_name`/`reply_count`/`like_count`/`floor` 在 Prisma(Task1)、后端(Task2-4)、前端类型(Task6)、页面(Task7-8)中命名一致;`getEcoMaterials` 返回类型 `EcoMaterial` 在 Task6 定义、Task9 消费一致;`FORUM_BOARDS` 在 Task1 定义、Task2/3 消费。 +- **占位符**:无 TODO/TBD;每个 code step 均含完整代码;验证步骤均有具体命令与预期输出。 +- **风险**:`PublicMaterial` 的 `deleted`/`created_at` 字段以 schema 为准(Task5 已注明降级方案:无 `deleted` 则去掉条件、排序用 `material_id`)。`/eco` 必须先于 `/:type` 注册(Task5 Step1 已强调)。