airpredict/空气质量预测/源码/docs/superpowers/plans/2026-07-07-forum-and-eco-ma...

1211 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 健康装修大家谈论坛 + 环保建材 + 导航改版 实现计划
> **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<T>(url,{ method, headers:{ [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }, data, params })``getLocalToken()` 返回 `Bearer <token>`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 24
- `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<string, number> = {}
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 422body 含 `"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<ForumThread>('/api/forum/threads', { method: 'POST', headers: auth(), data: body, ...(options || {}) })
}
export async function createReply(id: number, body: { content: string }, options?: AnyObj) {
return request<ForumReply>(`/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<EcoMaterial[]>('/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<string>('all')
const [list, setList] = useState<ForumThreadListItem[]>([])
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 (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}> 返回首页</a>
<h1 style={{ flex: 1, textAlign: 'center', margin: 0, fontSize: 22 }}>健康装修大家谈</h1>
<Button type="primary" onClick={openPost}>发帖</Button>
</div>
<Tabs
activeKey={active}
onChange={onTab}
items={[{ key: 'all', label: '全部' }, ...boards.map(b => ({ key: b.board, label: `${b.board} (${b.count})` }))]}
/>
<List
loading={loading}
locale={{ emptyText: <Empty description="还没有帖子,来发第一帖吧" /> }}
dataSource={list}
renderItem={t => (
<List.Item
style={{ cursor: 'pointer' }}
onClick={() => history.push(`/forum/thread/${t.id}`)}
actions={[<span key="r">💬 {t.reply_count}</span>, <span key="l">👍 {t.like_count}</span>]}
>
<List.Item.Meta
title={<span>[{t.board}] {t.title}</span>}
description={`${t.author_name} · ${new Date(t.created_at).toLocaleString()}`}
/>
</List.Item>
)}
/>
{total > PAGE_SIZE && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Pagination current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
<Modal title="发布新帖" open={postOpen} onOk={submitPost} onCancel={() => setPostOpen(false)} okText="发布" destroyOnClose>
<Form form={form} layout="vertical">
<Form.Item name="board" label="板块" rules={[{ required: true, message: '请选择板块' }]}>
<Select options={boardOptions} placeholder="选择板块" />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 60 }]}>
<Input placeholder="一句话说清问题" />
</Form.Item>
<Form.Item name="content" label="正文" rules={[{ required: true, message: '请输入正文' }, { max: 5000 }]}>
<Input.TextArea rows={6} placeholder="详细描述你的问题或经验" />
</Form.Item>
</Form>
</Modal>
<PhoneAuthModal
open={authOpen}
onSuccess={() => { setAuthOpen(false); openPost() }}
onCancel={() => setAuthOpen(false)}
/>
</div>
)
}
```
- [ ] **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<ForumThread | null>(null)
const [replies, setReplies] = useState<ForumReply[]>([])
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 | 'like' | 'reply'>(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 <div style={{ textAlign: 'center', padding: 80 }}><Spin /></div>
if (!thread) return <div style={{ textAlign: 'center', padding: 80 }}>帖子不存在或已删除。<a onClick={() => history.push('/forum')}>返回论坛</a></div>
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px' }}>
<a onClick={() => history.push('/forum')} style={{ color: '#888' }}>← 返回论坛</a>
<div style={{ marginTop: 16, padding: 20, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ color: '#B43533', fontSize: 12 }}>{thread.board}</div>
<h1 style={{ fontSize: 22, margin: '8px 0' }}>{thread.title}</h1>
<div style={{ color: '#999', fontSize: 13 }}>{thread.author_name} · {new Date(thread.created_at).toLocaleString()}</div>
<p style={{ marginTop: 16, whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>{thread.content}</p>
<div style={{ marginTop: 16, display: 'flex', gap: 12, alignItems: 'center' }}>
<Button type={liked ? 'primary' : 'default'} onClick={doLike}>👍 {likeCount}</Button>
<span style={{ color: '#999' }}>💬 {replies.length}</span>
<Popconfirm title="确定删除这篇帖子?" onConfirm={delThread}>
<a style={{ marginLeft: 'auto', color: '#999' }}>删除</a>
</Popconfirm>
</div>
</div>
<h3 style={{ marginTop: 24 }}>全部回帖({replies.length}</h3>
<List
dataSource={replies}
locale={{ emptyText: '还没有回帖,来抢沙发' }}
renderItem={r => (
<List.Item actions={[<Popconfirm key="d" title="删除这条回帖?" onConfirm={() => delReply(r.id)}><a style={{ color: '#bbb' }}>删除</a></Popconfirm>]}>
<List.Item.Meta
title={<span style={{ fontSize: 13, color: '#999' }}>#{r.floor} · {r.author_name} · {new Date(r.created_at).toLocaleString()}</span>}
description={<span style={{ color: '#333', whiteSpace: 'pre-wrap' }}>{r.content}</span>}
/>
</List.Item>
)}
/>
<div style={{ marginTop: 16 }}>
<Input.TextArea rows={3} value={replyText} onChange={e => setReplyText(e.target.value)} placeholder="写下你的回帖…" maxLength={5000} />
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" onClick={doReply}>回帖</Button>
</div>
</div>
<PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => { setAuthOpen(false); setPendingAfterLogin(null) }} />
</div>
)
}
```
> 注:删除入口对所有登录者可见,但后端仅允许作者删除(非作者点删除会收到 403 全局提示)。这是最小实现;如需仅作者可见删除入口,可后续用 cert.id 比对 author_id 隐藏。
- [ ] **Step 2: 浏览器验证**
`/forum` 点一条帖子进入详情(或直接 `#/forum/thread/<id>`)。
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<EcoMaterial[]>([])
useEffect(() => {
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
}, [])
```
- [ ] **Step 3: 替换导航为 4 项**
把现有 `<nav className="nav-links">…</nav>`5 个 `<a>`)整体替换为:
```tsx
<nav className="nav-links">
<a onClick={() => scrollToId('news')}>健康装修科普</a>
<a onClick={() => scrollToId('cases')}>预测案例</a>
<a onClick={() => scrollToId('eco')}>环保建材</a>
<a onClick={() => history.push('/forum')}>健康装修大家谈</a>
</nav>
```
- [ ] **Step 4: 两处区块标签改名**
-`#news` 区块内 `<div className="sec-tag">资讯 · 科普</div>` 改为 `<div className="sec-tag">健康装修科普</div>`
-`#cases` 区块内 `<div className="sec-tag">治理案例</div>` 改为 `<div className="sec-tag">预测案例</div>`
(区块其余内容与静态数据不变。)
- [ ] **Step 5: 在 #cases 与 #how 之间插入环保建材区块**
`#cases``</section>` 之后、`#how` 的 `<section className="sec" id="how">` 之前插入:
```tsx
{ecoList.length > 0 && (
<section className="sec" id="eco">
<div className="wrap">
<div className="sec-head">
<div>
<div className="sec-tag">环保建材</div>
<h2 className="sec-h">优选低释放材料,从源头减少污染</h2>
<p className="sec-sub">以下为材料库中环保等级 E0/E1 的低释放建材,选材阶段即可降低甲醛与 TVOC 风险。</p>
</div>
</div>
<div className="cases-grid">
{ecoList.map(m => (
<article className="case" key={m.material_id}>
<div className="case-b">
<div className="case-type">{m.category} · {m.brand}</div>
<h4>{m.name}</h4>
<div className="case-meta">{m.factory || '—'}</div>
<div className="case-foot">
<span className="chip chip-good">环保 {m.eco_level}</span>
{typeof m.methanal === 'number' && <span className="lk">甲醛 {m.methanal}</span>}
</div>
</div>
</article>
))}
</div>
</div>
</section>
)}
```
> 复用现有 `.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 9B 环保建材(后端)→Task 5、(前端)→Task 6+9C 数据模型→Task 1D 后端接口→Task 2/3/4E 前端页面→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 已强调)。