47 KiB
健康装修大家谈论坛 + 环保建材 + 导航改版 实现计划
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](keyWEB_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 文件末尾追加:
// ------ 论坛 ------
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 文件末尾追加:
// 论坛板块(固定,不建表)
export const FORUM_BOARDS = ['甲醛治理', '异味TVOC', '环保材料', '装修避坑', '求助问答']
- Step 3: 执行迁移(生成 Prisma Client + 建表)
Run:
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:
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
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;HTTPGET /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:
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:
import { forumRoutes } from './controllers/forum'
并在 app.use(auditRoutes) 之后追加:
app.use(forumRoutes)
- Step 3: 确保后端 dev 服务运行(红/绿验证前提)
若未运行:
cd "C:/code/空气质量预测/源码/服务端/iapip-svr" && npm run start:dev
(后台运行,等待日志 Server started at 6060.)
- Step 4: 验证 boards 接口(绿)
Run:
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:
curl -s --noproxy '*' "http://127.0.0.1:6060/api/forum/threads"
Expected: {"list":[],"total":0}
- Step 6: 验证不存在的帖子详情报错
Run:
curl -s --noproxy '*' http://127.0.0.1:6060/api/forum/threads/999999
Expected: HTTP 422,body 含 "code":"4000"(帖子不存在)。
- Step 7: Commit
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 段追加:
import authn from '../middlewares/authn'
import { CERT_TYPE, UserCert } from '../common/types'
- Step 2: 新增发帖与回帖路由
在 export const forumRoutes = ... 之前追加:
// 发帖(登录)
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):
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):
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 存入变量:
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:
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:
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(连发两条):
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 与列表):
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
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 = ... 之前追加:
// 点赞状态(登录,用于详情页初始化)
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(第一次点赞):
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(再次点击取消):
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 删除 → 未授权):
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(作者删除自己的帖):
curl -s --noproxy '*' -X DELETE http://127.0.0.1:6060/api/forum/threads/$TID -H "Authorization: Bearer $TOKEN"
Expected: {"ok":true}
Run(删除后详情不可见):
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
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 先于任何 /:参数 路由被匹配):
// 环保建材展示(公开,免认证):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顶部已 importdb;若deleted字段在PublicMaterial上不存在(以 schema 为准),去掉该 where 条件即可。display为「是否展示」,默认 0,只展示已上架材料。
- Step 2: 验证接口返回数组(绿)
Run:
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):
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
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
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'):
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
改为:
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:
tail -n 15 <前端 dev 输出日志路径>
Expected: 出现 [Webpack] Compiled in ...,无 TS 报错、无 Failed to compile。
- Step 5: Commit
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 数组中,落地页 路由项之后追加:
{
name: '健康装修大家谈',
path: '/forum',
component: './forum',
layout: false
},
{
name: '帖子详情',
path: '/forum/thread/:id',
component: './forum/thread',
layout: false
},
- Step 2: 新建论坛首页组件
创建 src/pages/forum/index.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
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:
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
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。追加:
import api from '@/services/api'
import type { EcoMaterial } from '@/services/api/material'
- Step 2: 组件内加环保建材取数
在 export default function Landing() 内,const [authOpen, setAuthOpen] = useState(false) 之后追加:
const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
useEffect(() => {
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
}, [])
- Step 3: 替换导航为 4 项
把现有 <nav className="nav-links">…</nav>(5 个 <a>)整体替换为:
<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"> 之前插入:
{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
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 已强调)。