>(
+ () => [
+ {
+ title: '公告',
+ dataIndex: 'title',
+ width: 320,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {row.title}
+
+ {row.is_pinned ? 置顶 : null}
+ {row.is_important ? 重要 : null}
+ {row.is_popup ? 弹窗 : null}
+ {row.priority !== 0 ? 优先级 {row.priority} : null}
+
+ #{row.id}
+
+ ),
+ },
+ {
+ 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 => ,
+ },
+ {
+ 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) => (
+
+ {canManage ? (
+ } onClick={() => openEdit(row)}>
+ 编辑
+
+ ) : null}
+ {row.status === 'draft' && canManage ? (
+
+ ) : null}
+ {row.status === 'published' && canManage ? (
+
+ ) : null}
+ {row.status === 'archived' && canManage ? (
+
+ ) : null}
+ {canManage ? (
+ }
+ onClick={() =>
+ runAction(
+ '删除公告?',
+ `删除后不可恢复:「${row.title}」。如果只是下架,建议使用归档。`,
+ () => deleteAdminAnnouncement(row.id)
+ )
+ }
+ >
+ 删除
+
+ ) : null}
+
+ ),
+ },
+ ],
+ [canManage]
+ )
+
+ const patchFilter = (patch: Partial) => {
+ 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 (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index c884885..769709d 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -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>>(
+ '/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>(
+ '/admin/announcements',
+ payload
+ )
+ return data.data
+}
+
+export async function updateAdminAnnouncement(
+ id: number | string,
+ payload: AdminAnnouncementPayload
+) {
+ const { data } = await adminClient.put>(
+ `/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}`)
+}
diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts
index 3c148ef..ba79efd 100644
--- a/frontend/src/features/admin/labels.ts
+++ b/frontend/src/features/admin/labels.ts
@@ -197,3 +197,24 @@ export const financeStatusMap: Record = {
settlement_diff: { label: '结算差异', tone: 'orange' },
offline_settlement_pending: { label: '待线下结算', tone: 'gold' },
}
+
+export const announcementStatusMap: Record = {
+ draft: { label: '草稿', tone: 'default' },
+ published: { label: '已发布', tone: 'success' },
+ archived: { label: '已归档', tone: 'default' },
+}
+
+export const announcementCategoryMap: Record = {
+ 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 })
+)
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index d07054f..abfa4a7 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -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
+}