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
@@ -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