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

47 KiB
Raw Blame History

健康装修大家谈论坛 + 环保建材 + 导航改版 实现计划

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 页改导航与新增区块。登录复用现有手机验证码 PhoneAuthModalWEB_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,字段含 idusername)。
  • 查询参数经 queryTypeParser 解析后在 ctx.state.query(已按类型转换);请求体在 ctx.request.body
  • 前端 APIrequest<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 — 注册 forumRoutesTask 2

前端 源码/用户端/iapip-web

  • src/services/api/forum.ts新建论坛接口封装Task 6
  • src/services/api/material.ts — 新增 getEcoMaterialsTask 6
  • src/services/api/index.ts — 聚合 forumTask 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 schemaGenerated Prisma Client 成功。

  • Step 4: 验证表已创建

Run:

docker exec iapips-mysql mysql -uroot -pharvey0425 -N -e "SHOW TABLES LIKE 'forum_%'" iapips

Expected: 三行 —— forum_replyforum_threadforum_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.tsimport + app.use

Interfaces:

  • Consumes: dbFORUM_BOARDSfailERR_CODE

  • Produces: export const forumRoutesHTTP 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

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 个板块,每个 count0,例如: [{"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 422body 含 "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: authnCERT_TYPEUserCert(新增 import

  • Produces: POST /api/forum/threads → 新建 ForumThreadPOST /api/forum/threads/:id/replies → 新建 ForumReplyfloor 递增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 = 2replies 两条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}toggleGET /api/forum/threads/:id/like{liked,like_count}(登录,初始状态);DELETE /api/forum/threads/:idDELETE /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.tsmaterialRouter.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 顶部已 import db;若 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 接口

在文件末尾追加(requestAnyObj 已在该文件 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/createThreadPhoneAuthModalLOC_STOR_KEY

  • Produces: 路由 /forum(组件 ./forum)。

  • Step 1: 新增路由

.umirc.tsroutes 数组中,落地页 路由项之后追加:

        {
            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/deleteReplyuseParamsPhoneAuthModal

  • 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.getEcoMaterialshistory

  • Step 1: 顶部 import 取数依赖

landing/index.tsx 顶部已 import useEffect/useStatehistory。追加:

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 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 均含完整代码;验证步骤均有具体命令与预期输出。
  • 风险PublicMaterialdeleted/created_at 字段以 schema 为准Task5 已注明降级方案:无 deleted 则去掉条件、排序用 material_id)。/eco 必须先于 /:type 注册Task5 Step1 已强调)。