feat(distribution): 公告中心支持分站自定义

公告中心(前台公告列表/详情)此前完全没有站点概念,分站访客只能看到主站公告。
本次按「默认继承主站、站长可切仅本站」的口径支持分站自定义。

数据与口径:
- 新增 announcements.distribution_site_id(0=主站),前台按站点隔离
- 新增 distribution_sites.announcement_mode:inherit(默认,主站+本站)/ own_only(仅本站)

后端:
- 公告模块新增 ResolvePublicSiteScope:主站 [0]、inherit [0, siteID]、own_only [siteID],
  站点编码无效/已停用回落主站;列表与详情都按该范围过滤,详情不再允许跨站按 ID 读取
- 管理端公告列表支持按站点筛选(site_id:不传=全部,0=主站,>0=分站)并带出站点名
- 分销模块新增本站公告 CRUD(distribution/announcement.go):强制 distribution_site_id=本站,
  先过 ensureSiteAccess,跨站更新/删除/上下架一律 ErrAnnouncementNotFound
- UpdateSiteBranding 支持 announcement_mode 切换(非法值报错,读侧非法历史值回落 inherit)
- 新增权限 distribution:announcement_view|manage 并授予 super_admin/ops/finance,
  站长开通时继承 ops 角色即自动获得
- 路由:站长侧 /api/distribution/announcements*,平台侧 /api/admin/distribution/announcements*

前端:
- 分销后台新增「公告中心」页:来源开关(继承主站+本站 / 仅本站)、本站公告列表与
  新建/编辑/发布/下架/归档/恢复/删除,空态按来源模式给出引导
- 平台后台公告页新增「站点」列与筛选,新建公告可选择归属站点(主站或指定分站代发)
- 前台 PC/移动无需改动:请求已带 X-Distribution-Site-Code,后端按站点隔离,页面已有空态

测试:
- 公告模块:主站/继承/仅本站三种可见范围、跨站详情拦截、无效站点回落、管理端站点筛选
- 分销模块:本站公告 CRUD 与跨站防护、公告来源开关校验与非法值容错
This commit is contained in:
yml2213
2026-10-01 10:02:31 +08:00
parent 329d0f75bb
commit 2a76d59a30
24 changed files with 1676 additions and 88 deletions
+4
View File
@@ -12,6 +12,7 @@ import {
OrderedListOutlined,
SettingOutlined,
ShopOutlined,
SoundOutlined,
SkinOutlined,
UserOutlined,
WalletOutlined,
@@ -26,6 +27,7 @@ import Accounts from './pages/Accounts'
import Settlements from './pages/Settlements'
import Orders from './pages/Orders'
import Wallet from './pages/Wallet'
import Announcements from './pages/Announcements'
import SiteSettings from './pages/SiteSettings'
import Login from './pages/Login'
import Storefront from './pages/Storefront'
@@ -44,6 +46,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号订单</Link> },
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号管理</Link> },
{ key: '/accounts', icon: <ShopOutlined />, label: <Link to="/accounts">商品货架与加价</Link> },
{ key: '/announcements', icon: <SoundOutlined />, label: <Link to="/announcements">公告中心</Link> },
{ key: '/wallet', icon: <WalletOutlined />, label: <Link to="/wallet">资金与提现</Link> },
{
key: '/settlements',
@@ -153,6 +156,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/wallet" element={<Wallet />} />
<Route path="/announcements" element={<Announcements />} />
<Route path="/settings" element={<SiteSettings />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
+89
View File
@@ -130,6 +130,7 @@ export interface Site {
aw_recycle_json?: string
cooperation_feedback_json?: string
storefront_text_json?: string
announcement_mode?: string
listing_qrcode_url?: string
receive_ratio_delta?: number
receive_cost_delta?: number
@@ -653,9 +654,97 @@ export interface SiteBrandingPayload {
aw_recycle_json?: string
cooperation_feedback_json?: string
storefront_text_json?: string
/** 公告中心来源:inherit=继承主站+本站,own_only=仅本站 */
announcement_mode?: string
listing_qrcode_url?: string
}
// ---------------- 分站公告中心(站长自助维护本站公告) ----------------
export interface SiteAnnouncement {
id: number
distribution_site_id: number
title: string
content: string
category: string
priority: number
is_pinned: boolean
is_important: boolean
view_count: number
status: string
published_at: string | null
created_at: string
updated_at: string
}
export interface SiteAnnouncementPage {
items: SiteAnnouncement[]
total: number
page: number
page_size: number
}
export interface SiteAnnouncementPayload {
title: string
content: string
category: string
priority: number
is_pinned: boolean
is_important: boolean
}
export async function listSiteAnnouncements(
siteID: number,
params: { status?: string; page?: number; page_size?: number } = {},
): Promise<SiteAnnouncementPage> {
const resp = await api.get<ApiResponse<SiteAnnouncementPage>>('/admin/distribution/announcements', {
params: { site_id: siteID, ...params },
})
return extract(resp)
}
export async function createSiteAnnouncement(
siteID: number,
payload: SiteAnnouncementPayload,
): Promise<SiteAnnouncement> {
const resp = await api.post<ApiResponse<SiteAnnouncement>>(
'/admin/distribution/announcements',
payload,
{ params: { site_id: siteID } },
)
return extract(resp)
}
export async function updateSiteAnnouncement(
siteID: number,
id: number,
payload: SiteAnnouncementPayload,
): Promise<SiteAnnouncement> {
const resp = await api.put<ApiResponse<SiteAnnouncement>>(
`/admin/distribution/announcements/${id}`,
payload,
{ params: { site_id: siteID } },
)
return extract(resp)
}
export async function updateSiteAnnouncementStatus(
siteID: number,
id: number,
action: 'publish' | 'archive' | 'unarchive',
): Promise<SiteAnnouncement> {
const resp = await api.post<ApiResponse<SiteAnnouncement>>(
`/admin/distribution/announcements/${id}/${action}`,
{},
{ params: { site_id: siteID } },
)
return extract(resp)
}
export async function deleteSiteAnnouncement(siteID: number, id: number): Promise<void> {
await api.delete(`/admin/distribution/announcements/${id}`, { params: { site_id: siteID } })
}
export async function uploadSiteImage(file: File, scene: string): Promise<string> {
const form = new FormData()
form.append('file', file)
@@ -0,0 +1,455 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Button,
Card,
Checkbox,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import {
BellOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
PushpinOutlined,
ReloadOutlined,
WarningOutlined,
} from '@ant-design/icons'
import {
createSiteAnnouncement,
deleteSiteAnnouncement,
updateSiteAnnouncement,
updateSiteAnnouncementStatus,
updateSiteBranding,
listSiteAnnouncements,
listSites,
getMe,
type Site,
type SiteAnnouncement,
type SiteAnnouncementPayload,
} from '../api'
import { useMember } from '../MemberContext'
const CATEGORY_OPTIONS = [
{ value: 'notice', label: '通知公告' },
{ value: 'tutorial', label: '使用教程' },
{ value: 'rule', label: '规则说明' },
{ value: 'faq', label: '常见问题' },
]
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '已发布' },
{ value: 'archived', label: '已归档' },
]
const STATUS_META: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
published: { label: '已发布', color: 'green' },
archived: { label: '已归档', color: 'orange' },
}
const EMPTY_FORM: SiteAnnouncementPayload = {
title: '',
content: '',
category: 'notice',
priority: 0,
is_pinned: false,
is_important: false,
}
function formatTime(value?: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 分站公告中心:站长自助维护本站公告。
*
* 展示来源由本站 announcement_mode 控制:
* inherit(默认)= 主站公告 + 本站公告;own_only = 仅本站公告。
*/
export default function Announcements() {
const member = useMember()
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [sites, setSites] = useState<Site[]>([])
const [announcementMode, setAnnouncementMode] = useState<'inherit' | 'own_only'>('inherit')
const [modeSaving, setModeSaving] = useState(false)
const [items, setItems] = useState<SiteAnnouncement[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [status, setStatus] = useState('')
const [loading, setLoading] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<SiteAnnouncement | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm<SiteAnnouncementPayload>()
const load = useCallback(
async (targetSiteID?: number, nextPage = page, nextPageSize = pageSize, nextStatus = status) => {
if (!targetSiteID) return
setLoading(true)
try {
const result = await listSiteAnnouncements(targetSiteID, {
status: nextStatus || undefined,
page: nextPage,
page_size: nextPageSize,
})
setItems(result.items)
setTotal(result.total)
} catch {
message.error('公告加载失败')
} finally {
setLoading(false)
}
},
[page, pageSize, status],
)
useEffect(() => {
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} catch {
// ignore
}
}
const activeSiteID = currentMember?.site_id
if (!activeSiteID) return
setSiteID(activeSiteID)
try {
const rows = await listSites()
setSites(rows)
const current = rows.find(row => row.id === activeSiteID)
setAnnouncementMode(current?.announcement_mode === 'own_only' ? 'own_only' : 'inherit')
} catch {
// 站点信息读取失败时保留默认口径
}
void load(activeSiteID, 1, pageSize, '')
}
void init()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [member])
async function handleModeChange(next: 'inherit' | 'own_only') {
if (!siteID || next === announcementMode) return
setModeSaving(true)
try {
await updateSiteBranding(siteID, { announcement_mode: next })
setAnnouncementMode(next)
message.success(next === 'own_only' ? '已切换为「仅本站公告」' : '已切换为「继承主站 + 本站公告」')
} catch {
message.error('公告来源切换失败')
} finally {
setModeSaving(false)
}
}
function openCreate() {
setEditing(null)
form.setFieldsValue({ ...EMPTY_FORM })
setModalOpen(true)
}
function openEdit(row: SiteAnnouncement) {
setEditing(row)
form.setFieldsValue({
title: row.title,
content: row.content,
category: row.category,
priority: row.priority,
is_pinned: row.is_pinned,
is_important: row.is_important,
})
setModalOpen(true)
}
async function handleSubmit() {
if (!siteID) return
const values = await form.validateFields()
setSaving(true)
try {
if (editing) {
await updateSiteAnnouncement(siteID, editing.id, values)
message.success('公告已保存')
} else {
await createSiteAnnouncement(siteID, values)
message.success('公告已创建为草稿,发布后才会在前台展示')
}
setModalOpen(false)
void load(siteID)
} catch {
message.error('公告保存失败')
} finally {
setSaving(false)
}
}
async function handleStatusAction(row: SiteAnnouncement, action: 'publish' | 'archive' | 'unarchive') {
if (!siteID) return
try {
await updateSiteAnnouncementStatus(siteID, row.id, action)
message.success(action === 'publish' ? '公告已发布' : action === 'archive' ? '公告已归档' : '公告已取消归档')
void load(siteID)
} catch {
message.error('操作失败')
}
}
async function handleDelete(row: SiteAnnouncement) {
if (!siteID) return
try {
await deleteSiteAnnouncement(siteID, row.id)
message.success('公告已删除')
void load(siteID)
} catch {
message.error('删除失败')
}
}
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space align="center" size={8}>
<BellOutlined style={{ color: '#1677ff' }} />
<Typography.Title level={4} style={{ margin: 0 }}>
公告中心
</Typography.Title>
</Space>
<Typography.Text type="secondary">
维护本站公告(列表 / 详情页展示)。公告发布后立即在本站前台生效。
</Typography.Text>
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
<Typography.Text strong>公告来源</Typography.Text>
<Radio.Group
value={announcementMode}
onChange={e => void handleModeChange(e.target.value)}
disabled={modeSaving}
>
<Radio.Button value="inherit">继承主站 + 本站公告</Radio.Button>
<Radio.Button value="own_only">仅本站公告</Radio.Button>
</Radio.Group>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
「继承」会同时展示主站公告与本站公告;「仅本站」只展示本站公告,主站公告不再下发(本站未发布公告时公告中心为空)。
</Typography.Text>
</Space>
</Space>
</Card>
<Card>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
<Select
value={status}
options={STATUS_OPTIONS}
style={{ width: 140 }}
onChange={value => {
setStatus(value)
setPage(1)
void load(siteID, 1, pageSize, value)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void load(siteID)}>
刷新
</Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建公告
</Button>
</Space>
<Table<SiteAnnouncement>
rowKey="id"
loading={loading}
dataSource={items}
locale={{
emptyText: (
<Empty
description={
announcementMode === 'own_only'
? '本站还没有公告。仅本站模式下公告中心会保持为空,先新建并发布一条公告。'
: '本站还没有自己的公告。当前继承主站公告,也可以新建本站专属公告。'
}
/>
),
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
void load(siteID, nextPage, nextPageSize, status)
},
}}
columns={[
{
title: '标题',
dataIndex: 'title',
render: (_: string, row) => (
<Space size={6} wrap>
<Typography.Text strong>{row.title}</Typography.Text>
{row.is_pinned && (
<Tag color="gold" icon={<PushpinOutlined />}>
置顶
</Tag>
)}
{row.is_important && (
<Tag color="red" icon={<WarningOutlined />}>
重要
</Tag>
)}
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 120,
render: (value: string) =>
CATEGORY_OPTIONS.find(item => item.value === value)?.label || value,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (value: string) => (
<Tag color={STATUS_META[value]?.color || 'default'}>
{STATUS_META[value]?.label || value}
</Tag>
),
},
{ title: '优先级', dataIndex: 'priority', width: 90, align: 'center' },
{ title: '阅读', dataIndex: 'view_count', width: 80, align: 'center' },
{
title: '发布时间',
dataIndex: 'published_at',
width: 160,
render: (value: string | null) => formatTime(value),
},
{
title: '操作',
key: 'actions',
width: 260,
render: (_: unknown, row) => (
<Space size={4} wrap>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
编辑
</Button>
{row.status === 'published' ? (
<Button
type="link"
size="small"
onClick={() => void handleStatusAction(row, 'archive')}
>
下架
</Button>
) : (
<Button
type="link"
size="small"
onClick={() =>
void handleStatusAction(row, row.status === 'archived' ? 'unarchive' : 'publish')
}
>
{row.status === 'archived' ? '恢复发布' : '发布'}
</Button>
)}
<Popconfirm
title="确认删除这条公告?"
okText="删除"
cancelText="取消"
onConfirm={() => void handleDelete(row)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</Space>
</Card>
<Modal
title={editing ? '编辑公告' : '新建公告'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void handleSubmit()}
confirmLoading={saving}
okText={editing ? '保存' : '创建草稿'}
width={680}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={EMPTY_FORM}>
<Form.Item
label="公告标题"
name="title"
rules={[{ required: true, message: '请输入公告标题' }, { max: 255, message: '标题不超过 255 字' }]}
>
<Input placeholder="例如:本站春节期间发货安排" maxLength={255} showCount />
</Form.Item>
<Form.Item
label="公告分类"
name="category"
rules={[{ required: true, message: '请选择公告分类' }]}
>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
<Form.Item
label="公告内容"
name="content"
rules={[{ required: true, message: '请输入公告内容' }]}
>
<Input.TextArea rows={8} placeholder="支持 Markdown 语法" />
</Form.Item>
<Space size="large" align="start" wrap>
<Form.Item label="优先级" name="priority" tooltip="数值越大越靠前">
<InputNumber min={0} max={9999} />
</Form.Item>
<Form.Item name="is_pinned" valuePropName="checked">
<Checkbox>置顶</Checkbox>
</Form.Item>
<Form.Item name="is_important" valuePropName="checked">
<Checkbox>标记重要</Checkbox>
</Form.Item>
</Space>
{!editing && (
<Alert
type="info"
showIcon
message="新建的公告是草稿,需要在列表中点击「发布」才会在本站前台展示。"
/>
)}
</Form>
</Modal>
</Space>
)
}