# 健康装修大家谈论坛 + 环保建材 + 导航改版 实现计划 > **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 已强调)。