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