feat(web): forum home page with boards, list, post

This commit is contained in:
zty 2026-07-07 03:34:48 -04:00
parent 899c4eb42e
commit fd84b0b0ff
2 changed files with 251 additions and 0 deletions

View File

@ -0,0 +1,126 @@
import { defineConfig } from '@umijs/max'
const COLOR_PRIMARY = '#B43533'
const COLOR_TEXT_PRIMARY = 'rgba(0, 0, 0, 0.88)'
const COLOR_TEXT_DISABLED = 'rgba(0, 0, 0, 0.25)'
export default defineConfig({
initialState: {},
access: {},
model: {},
antd: {
theme: {
token: {
colorPrimary: COLOR_PRIMARY,
colorLink: COLOR_PRIMARY,
colorTextDisabled: COLOR_TEXT_PRIMARY
},
components: {
Button: {
defaultBorderColor: COLOR_PRIMARY,
defaultColor: COLOR_PRIMARY,
colorTextDisabled: COLOR_TEXT_DISABLED
},
Cascader: {
dropdownHeight: 460
}
}
}
},
title: '装修室内空气质量预测系统',
layout: {
locale: false
},
tailwindcss: {},
routes: [
{
name: '登录',
path: '/login',
component: './login',
layout: false
},
{
name: '落地页',
path: '/landing',
component: './landing',
layout: false
},
{
name: '健康装修大家谈',
path: '/forum',
component: './forum',
layout: false
},
{
name: '帖子详情',
path: '/forum/thread/:id',
component: './forum/thread',
layout: false
},
{
path: '/',
redirect: '/landing'
},
{
name: '首页',
path: '/home',
component: './home'
},
{
name: '污染源识别',
path: '/source',
component: './source'
},
{
name: '模板库',
path: '/template',
component: './template'
},
{
name: '材料库',
path: '/material',
component: './material'
},
{
name: '材料类别管理',
path: '/material-category',
component: './material-category',
access: 'canMtrlCatMgmt'
},
{
name: '历史记录',
path: '/history-project',
component: './history-project'
},
{
name: '个人设置',
path: '/personal-settings',
component: './personal-settings',
hideInMenu: true
}
],
mock: false,
proxy: {
'/api': {
target: 'http://localhost:6060',
changeOrigin: true
},
'/socket.io': {
target: 'http://localhost:6060',
ws: true
}
},
history: {
type: 'hash'
},
publicPath: '/iapip-web/',
favicons: ['/iapip-web/favicon.ico'],
request: {
dataField: ''
},
mfsu: {
strategy: 'normal'
},
esbuildMinifyIIFE: true,
npmClient: 'pnpm'
})

View File

@ -0,0 +1,125 @@
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>
)
}