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
@@ -3,6 +3,8 @@ import type { ApiResponse } from '@/shared/types/types'
import type { Announcement, PaginatedResult } from '@/features/announcement/api/announcements'
export interface CreateAnnouncementRequest {
/** 归属站点:0(默认)=主站,>0=分站代发 */
distribution_site_id?: number
title: string
content: string
category: 'notice' | 'tutorial' | 'rule' | 'faq'
@@ -23,6 +25,8 @@ export interface UpdateAnnouncementRequest {
export async function fetchAdminAnnouncements(params: {
status?: string
category?: string
/** 站点筛选:不传=全部,0=主站,>0=指定分站 */
site_id?: number
page?: number
page_size?: number
}): Promise<PaginatedResult<Announcement>> {
@@ -15,6 +15,10 @@ import type {
CreateAnnouncementRequest,
UpdateAnnouncementRequest,
} from '@/features/admin/api/adminAnnouncements'
import {
fetchDistributionSites,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { uploadAdminFile } from '@/shared/api/files'
import { debugError } from '@/shared/utils/debug'
import { readError } from '@/shared/utils/error'
@@ -44,6 +48,7 @@ const imageSettingsForm = ref({
useCustomWidth: true,
})
const formData = ref<CreateAnnouncementRequest>({
distribution_site_id: 0,
title: '',
content: '',
category: 'notice',
@@ -52,6 +57,26 @@ const formData = ref<CreateAnnouncementRequest>({
is_important: false,
})
// 站点列表:0=主站(默认),其余为分站;公告归属站点只在新建时可选。
const distributionSites = ref<DistributionSite[]>([])
const currentSiteLabel = computed(() => {
if (props.mode === 'create') return ''
if (!props.announcement?.distribution_site_id) return '主站'
const matched = distributionSites.value.find(
item => item.id === props.announcement?.distribution_site_id
)
return matched ? `${matched.site_name}(${matched.site_code})` : '分站'
})
async function loadDistributionSites() {
if (distributionSites.value.length > 0) return
try {
distributionSites.value = await fetchDistributionSites()
} catch (error) {
debugError('加载分销站点失败', error)
}
}
const categories = [
{ value: 'notice', label: '通知公告', icon: Bell },
{ value: 'tutorial', label: '使用教程', icon: Document },
@@ -86,6 +111,7 @@ watch(
if (val) {
if (props.mode === 'edit' && props.announcement) {
formData.value = {
distribution_site_id: props.announcement.distribution_site_id,
title: props.announcement.title,
content: props.announcement.content,
category: props.announcement.category as any,
@@ -95,6 +121,7 @@ watch(
}
} else {
formData.value = {
distribution_site_id: 0,
title: '',
content: '',
category: 'notice',
@@ -103,6 +130,7 @@ watch(
is_important: false,
}
}
void loadDistributionSites()
formRef.value?.clearValidate()
}
}
@@ -278,6 +306,23 @@ function escapeMarkdownImageText(text: string) {
<template>
<el-dialog :model-value="visible" :title="dialogTitle" width="800px" @close="handleClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="归属站点">
<el-select
v-if="mode === 'create'"
v-model="formData.distribution_site_id"
placeholder="选择公告归属站点"
>
<el-option :value="0" label="主站" />
<el-option
v-for="site in distributionSites"
:key="site.id"
:value="site.id"
:label="`${site.site_name}(${site.site_code})`"
/>
</el-select>
<el-tag v-else effect="plain">{{ currentSiteLabel }}</el-tag>
</el-form-item>
<el-form-item label="公告标题" prop="title">
<el-input
v-model="formData.title"
@@ -23,6 +23,10 @@ import {
type CreateAnnouncementRequest,
type UpdateAnnouncementRequest,
} from '@/features/admin/api/adminAnnouncements'
import {
fetchDistributionSites,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import type { Announcement } from '@/features/announcement'
import { formatDateTime } from '@/shared/utils/time'
import AnnouncementDialog from '../components/AnnouncementDialog.vue'
@@ -35,6 +39,9 @@ const currentPageSize = ref(20)
const total = ref(0)
const statusFilter = ref('')
const categoryFilter = ref('')
// 站点筛选:'' = 全部,0 = 主站,>0 = 指定分站
const siteFilter = ref<number | ''>('')
const distributionSites = ref<DistributionSite[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
@@ -54,7 +61,23 @@ const statusOptions = [
{ value: 'archived', label: '已归档' },
]
onMounted(loadAnnouncements)
onMounted(() => {
void loadDistributionSites()
void loadAnnouncements()
})
async function loadDistributionSites() {
try {
distributionSites.value = await fetchDistributionSites()
} catch {
distributionSites.value = []
}
}
function siteLabel(row: Announcement): string {
if (!row.distribution_site_id) return '主站'
return row.distribution_site_name || `分站 #${row.distribution_site_id}`
}
async function loadAnnouncements() {
loading.value = true
@@ -62,6 +85,7 @@ async function loadAnnouncements() {
const result = await fetchAdminAnnouncements({
status: statusFilter.value || undefined,
category: categoryFilter.value || undefined,
site_id: siteFilter.value === '' ? undefined : Number(siteFilter.value),
page: currentPage.value,
page_size: currentPageSize.value,
})
@@ -222,6 +246,22 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</div>
<div class="filter-bar">
<el-select
v-model="siteFilter"
placeholder="筛选站点"
clearable
@change="handleFilterChange"
style="width: 170px"
>
<el-option label="全部站点" :value="''" />
<el-option label="主站" :value="0" />
<el-option
v-for="site in distributionSites"
:key="site.id"
:value="site.id"
:label="`${site.site_name}(${site.site_code})`"
/>
</el-select>
<el-select
v-model="statusFilter"
placeholder="筛选状态"
@@ -272,6 +312,14 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</div>
</template>
</el-table-column>
<el-table-column label="站点" width="150">
<template #default="{ row }">
<el-tag v-if="row.distribution_site_id" type="success" effect="plain" size="small">
{{ siteLabel(row) }}
</el-tag>
<el-tag v-else type="info" effect="plain" size="small">主站</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag
@@ -3,6 +3,10 @@ import type { ApiResponse } from '@/shared/types/types'
export interface Announcement {
id: number
/** 归属站点:0=主站,>0=分站 */
distribution_site_id: number
/** 管理端列表带出的站点名(前台不返回) */
distribution_site_name?: string
title: string
content: string
category: string