feat(admin): 新增后台公告管理与发布归档

This commit is contained in:
yml2213
2026-10-30 13:25:24 +08:00
parent 4239bec519
commit d46636c3d2
8 changed files with 661 additions and 4 deletions
+2 -2
View File
@@ -194,9 +194,9 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
## 5. 已知限制
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板,其余页面仍为统一占位。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理,其余页面仍为统一占位。
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`;其余页面仍需核对。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`;其余页面仍需核对。
5. SVG 中文尚未转曲。
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
+66
View File
@@ -0,0 +1,66 @@
# Phase 2 验收报告(后台公告管理)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:`/console-jy8k3m/announcements`
> 结论:**本切片通过,公告列表、编辑、发布、归档与删除已接真实接口**
---
## 1. 接口与权限
| 能力 | 接口 | 权限码 |
| --- | --- | --- |
| 公告列表 | `GET /api/admin/announcements` | `announcement:view` |
| 公告详情 | `GET /api/admin/announcements/:id` | `announcement:view` |
| 新建 | `POST /api/admin/announcements` | `announcement:manage` |
| 编辑 | `PUT /api/admin/announcements/:id` | `announcement:manage` |
| 发布 | `POST /api/admin/announcements/:id/publish` | `announcement:manage` |
| 归档 | `POST /api/admin/announcements/:id/archive` | `announcement:manage` |
| 取消归档 | `POST /api/admin/announcements/:id/unarchive` | `announcement:manage` |
| 删除 | `DELETE /api/admin/announcements/:id` | `announcement:manage` |
## 2. 页面
`frontend/src/features/admin/AdminAnnouncementsPage.tsx`
- 筛选:分类(通知/教程/规则/常见问题)、状态(草稿/已发布/已归档)
- 列:公告(标题/置顶/重要/弹窗/优先级)、分类、站点、状态、浏览量、发布时间、更新时间、操作
- 分页 10 / 20 / 50
- 操作按状态与权限显示:
- 草稿:编辑、发布、删除
- 已发布:编辑、归档、删除
- 已归档:编辑、取消归档、删除
- 发布/归档/取消归档/删除均二次确认;删除提示优先使用归档
## 3. 编辑弹窗
- 字段:标题、分类、优先级、归属站点(仅新建)、正文、置顶、重要、弹窗
- 新建默认保存为草稿,发布后前台展示
- 弹窗公告提示「每天只弹一次,需用户手动关闭」
- 正文按纯文本编辑,支持换行
## 4. 验证结果
```text
npx tsc --noEmit 通过
npm run build 通过(入口 496.93 kB,gzip 161.32 kB)
GET /api/admin/announcements?page=1&page_size=5 ok,返回 1 条种子公告
GET /console-jy8k3m/announcements 200
```
列表读取已实测;新建、编辑、发布、归档、删除未执行,避免改动种子公告与前台展示。
## 5. 已知限制
1. 归属分站使用数字 ID 输入,未接分站选择器。
2. 正文无 Markdown 预览,前台渲染效果需发布后核对。
3. 未做公告置顶排序拖拽或批量归档。
4. 未接分站代发公告的 `site_id` 筛选入口(接口已支持)。
## 6. 下一步建议
1. 后台系统配置与审计日志。
2. 分站公告代发入口。
3. 线下出款/运营支出登记。
4. 移动端下单、支付与交接/归还/结账。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉与财务看板;线下出款/运营支出、系统配置与审计日志待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板与公告管理;线下出款/运营支出、系统配置与审计日志待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6 -1
View File
@@ -98,6 +98,11 @@ const AdminFinancePage = lazy(() =>
default: module.AdminFinancePage,
}))
)
const AdminAnnouncementsPage = lazy(() =>
import('@/features/admin/AdminAnnouncementsPage').then(module => ({
default: module.AdminAnnouncementsPage,
}))
)
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
@@ -107,7 +112,6 @@ const AdminSectionPlaceholder = lazy(() =>
const adminSections: Array<{ path: string; title: string; description: string }> = [
{ path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
{ path: 'announcements', title: '公告管理', description: '维护主站与分站公告。' },
{ path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
{ path: 'audit-logs', title: '审计日志', description: '查看高风险操作与变更记录。' },
]
@@ -149,6 +153,7 @@ export function AppRouter() {
<Route path="withdrawals" element={<AdminWithdrawalsPage />} />
<Route path="disputes" element={<AdminDisputesPage />} />
<Route path="finance" element={<AdminFinancePage />} />
<Route path="announcements" element={<AdminAnnouncementsPage />} />
{adminSections.map(section => (
<Route
key={section.path}
@@ -0,0 +1,461 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import {
archiveAdminAnnouncement,
createAdminAnnouncement,
deleteAdminAnnouncement,
fetchAdminAnnouncements,
publishAdminAnnouncement,
unarchiveAdminAnnouncement,
updateAdminAnnouncement,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
announcementCategoryMap,
announcementCategoryOptions,
announcementStatusMap,
announcementStatusOptions,
readStatusMeta,
} from '@/features/admin/labels'
import type {
AdminAnnouncement,
AdminAnnouncementPayload,
AdminAnnouncementQuery,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime } from '@/shared/utils/format'
const emptyFilters: AdminAnnouncementQuery = {
category: '',
status: '',
}
export function AdminAnnouncementsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<AdminAnnouncementPayload>()
const [draft, setDraft] = useState<AdminAnnouncementQuery>(emptyFilters)
const [applied, setApplied] = useState<AdminAnnouncementQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminAnnouncement | null>(null)
const [submitting, setSubmitting] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canManage = Boolean(
permissions?.includes('*') || permissions?.includes('announcement:manage')
)
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-announcements', applied, page, pageSize],
queryFn: () => fetchAdminAnnouncements({ ...applied, page, page_size: pageSize }),
placeholderData: keepPreviousData,
})
const openCreate = () => {
setEditing(null)
form.setFieldsValue({
distribution_site_id: 0,
title: '',
content: '',
category: 'notice',
priority: 0,
is_pinned: false,
is_important: false,
is_popup: false,
})
setEditorOpen(true)
}
const openEdit = (item: AdminAnnouncement) => {
setEditing(item)
form.setFieldsValue({
distribution_site_id: item.distribution_site_id,
title: item.title,
content: item.content,
category: item.category,
priority: item.priority,
is_pinned: item.is_pinned,
is_important: item.is_important,
is_popup: item.is_popup,
})
setEditorOpen(true)
}
const save = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
const payload: AdminAnnouncementPayload = {
...values,
distribution_site_id: Number(values.distribution_site_id ?? 0),
}
if (editing) {
await updateAdminAnnouncement(editing.id, payload)
message.success('公告已更新')
} else {
await createAdminAnnouncement(payload)
message.success('公告已创建为草稿')
}
setEditorOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin-announcements'] })
} catch (err) {
const text =
err && typeof err === 'object' && 'response' in err
? ((err as { response?: { data?: { message?: string } } }).response?.data?.message ??
'保存失败')
: err instanceof Error
? err.message
: '保存失败'
message.error(text)
} finally {
setSubmitting(false)
}
}
const runAction = (title: string, content: string, action: () => Promise<void>) => {
modal.confirm({
title,
content,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await action()
message.success('操作成功')
await queryClient.invalidateQueries({ queryKey: ['admin-announcements'] })
} catch (err) {
message.error(err instanceof Error ? err.message : '操作失败')
}
},
})
}
const columns = useMemo<TableColumnsType<AdminAnnouncement>>(
() => [
{
title: '公告',
dataIndex: 'title',
width: 320,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.title}</strong>
<Space size={4} wrap>
{row.is_pinned ? <Tag color="gold">置顶</Tag> : null}
{row.is_important ? <Tag color="red">重要</Tag> : null}
{row.is_popup ? <Tag color="purple">弹窗</Tag> : null}
{row.priority !== 0 ? <Tag>优先级 {row.priority}</Tag> : null}
</Space>
<span className="admin-mono">#{row.id}</span>
</div>
),
},
{
title: '分类',
dataIndex: 'category',
width: 110,
render: value => announcementCategoryMap[value] ?? value,
},
{
title: '站点',
key: 'site',
width: 150,
render: (_, row) =>
row.distribution_site_id > 0
? row.distribution_site_name || `分站 #${row.distribution_site_id}`
: '主站',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(announcementStatusMap, value)} />,
},
{
title: '浏览',
dataIndex: 'view_count',
width: 90,
align: 'right',
},
{
title: '发布时间',
dataIndex: 'published_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 260,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
{canManage ? (
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
编辑
</Button>
) : null}
{row.status === 'draft' && canManage ? (
<Button
type="link"
size="small"
onClick={() =>
runAction('发布公告?', `发布后前台将立即展示「${row.title}」。`, () =>
publishAdminAnnouncement(row.id)
)
}
>
发布
</Button>
) : null}
{row.status === 'published' && canManage ? (
<Button
type="link"
size="small"
onClick={() =>
runAction('归档公告?', `归档后「${row.title}」不再在前台展示。`, () =>
archiveAdminAnnouncement(row.id)
)
}
>
归档
</Button>
) : null}
{row.status === 'archived' && canManage ? (
<Button
type="link"
size="small"
onClick={() =>
runAction('取消归档?', `「${row.title}」将恢复为已发布公告。`, () =>
unarchiveAdminAnnouncement(row.id)
)
}
>
取消归档
</Button>
) : null}
{canManage ? (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
runAction(
'删除公告?',
`删除后不可恢复:「${row.title}」。如果只是下架,建议使用归档。`,
() => deleteAdminAnnouncement(row.id)
)
}
>
删除
</Button>
) : null}
</Space>
),
},
],
[canManage]
)
const patchFilter = (patch: Partial<AdminAnnouncementQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({ ...draft })
setPage(1)
}
const resetFilters = () => {
setDraft(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}
const hasActiveFilters = Boolean(draft.category) || Boolean(draft.status)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 运营"
title="公告管理"
description="维护主站与分站公告,支持置顶、重要、弹窗与发布归档。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新
</Button>
{canManage ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建公告
</Button>
) : null}
</Space>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="公告分类" width={160}>
<Select
allowClear
value={draft.category || undefined}
placeholder="全部分类"
options={announcementCategoryOptions}
onChange={value => patchFilter({ category: value ?? '' })}
/>
</FilterField>
<FilterField label="公告状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={announcementStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="公告列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminAnnouncement>
title={<span>公告列表</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
emptyDescription="还没有公告"
/>
<Modal
open={editorOpen}
title={editing ? '编辑公告' : '新建公告'}
width={720}
okText={editing ? '保存' : '创建草稿'}
cancelText="取消"
confirmLoading={submitting}
onOk={() => void save()}
onCancel={() => {
if (!submitting) setEditorOpen(false)
}}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入公告标题' }, { max: 255 }]}
>
<Input placeholder="例如:国庆期间结算时间调整" />
</Form.Item>
<Space size={12} style={{ display: 'flex' }}>
<Form.Item
name="category"
label="分类"
rules={[{ required: true, message: '请选择分类' }]}
style={{ flex: 1 }}
>
<Select options={announcementCategoryOptions} />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ width: 160 }}>
<InputNumber style={{ width: '100%' }} precision={0} />
</Form.Item>
{!editing ? (
<Form.Item
name="distribution_site_id"
label="归属站点"
tooltip="0 表示主站;分站公告需填写分站 ID"
style={{ width: 160 }}
>
<InputNumber style={{ width: '100%' }} min={0} precision={0} />
</Form.Item>
) : null}
</Space>
<Form.Item
name="content"
label="正文"
rules={[{ required: true, message: '请输入公告正文' }]}
>
<Input.TextArea rows={8} placeholder="支持换行,前台按纯文本展示" />
</Form.Item>
<Space size={24} wrap>
<Form.Item name="is_pinned" label="置顶" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_important" label="重要" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_popup" label="弹窗公告" valuePropName="checked">
<Switch />
</Form.Item>
<Typography.Text type="secondary">
弹窗公告每天只弹一次,需用户手动关闭。
</Typography.Text>
</Space>
</Form>
</Modal>
</div>
)
}
+60
View File
@@ -1,6 +1,10 @@
import { adminClient } from '@/shared/api/adminClient'
import type { ApiResponse } from '@/shared/api/types'
import type {
AdminAnnouncement,
AdminAnnouncementPage,
AdminAnnouncementPayload,
AdminAnnouncementQuery,
AdminCaptcha,
AdminDispute,
AdminDisputePage,
@@ -345,3 +349,59 @@ export async function fetchFinanceDetails(
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function fetchAdminAnnouncements(query: AdminAnnouncementQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminAnnouncementPage>>>(
'/admin/announcements',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function createAdminAnnouncement(payload: AdminAnnouncementPayload) {
const { data } = await adminClient.post<ApiResponse<AdminAnnouncement>>(
'/admin/announcements',
payload
)
return data.data
}
export async function updateAdminAnnouncement(
id: number | string,
payload: AdminAnnouncementPayload
) {
const { data } = await adminClient.put<ApiResponse<AdminAnnouncement>>(
`/admin/announcements/${id}`,
{
title: payload.title,
content: payload.content,
category: payload.category,
priority: payload.priority,
is_pinned: payload.is_pinned,
is_important: payload.is_important,
is_popup: payload.is_popup,
}
)
return data.data
}
export async function publishAdminAnnouncement(id: number | string) {
await adminClient.post(`/admin/announcements/${id}/publish`)
}
export async function archiveAdminAnnouncement(id: number | string) {
await adminClient.post(`/admin/announcements/${id}/archive`)
}
export async function unarchiveAdminAnnouncement(id: number | string) {
await adminClient.post(`/admin/announcements/${id}/unarchive`)
}
export async function deleteAdminAnnouncement(id: number | string) {
await adminClient.delete(`/admin/announcements/${id}`)
}
+21
View File
@@ -197,3 +197,24 @@ export const financeStatusMap: Record<string, StatusMeta> = {
settlement_diff: { label: '结算差异', tone: 'orange' },
offline_settlement_pending: { label: '待线下结算', tone: 'gold' },
}
export const announcementStatusMap: Record<string, StatusMeta> = {
draft: { label: '草稿', tone: 'default' },
published: { label: '已发布', tone: 'success' },
archived: { label: '已归档', tone: 'default' },
}
export const announcementCategoryMap: Record<string, string> = {
notice: '通知',
tutorial: '教程',
rule: '规则',
faq: '常见问题',
}
export const announcementStatusOptions = Object.entries(announcementStatusMap).map(
([value, meta]) => ({ value, label: meta.label })
)
export const announcementCategoryOptions = Object.entries(announcementCategoryMap).map(
([value, label]) => ({ value, label })
)
+44
View File
@@ -533,3 +533,47 @@ export interface FinanceDetailPage {
page: number
page_size: number
}
export interface AdminAnnouncementQuery {
category?: string
status?: string
page?: number
page_size?: number
site_id?: number
}
export interface AdminAnnouncement {
id: number
distribution_site_id: number
distribution_site_name?: string
title: string
content: string
category: string
priority: number
is_pinned: boolean
is_important: boolean
is_popup: boolean
view_count: number
status: string
published_at?: string | null
created_at: string
updated_at: string
}
export interface AdminAnnouncementPage {
items: AdminAnnouncement[]
total: number
page: number
page_size: number
}
export interface AdminAnnouncementPayload {
distribution_site_id?: number
title: string
content: string
category: string
priority: number
is_pinned: boolean
is_important: boolean
is_popup: boolean
}