feat(compliance): add content compliance detection across tenant, ops, and clients

Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web
content-safety pages, admin-web editor/publish gate integration, desktop publish
block surfacing, and supporting migrations / shared types / config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:48:14 +08:00
parent 81577b6154
commit 745cdd79cf
73 changed files with 12747 additions and 1892 deletions
@@ -0,0 +1,61 @@
<template>
<div class="platform-scope-picker">
<a-radio-group :value="scope" @change="onScopeChange">
<a-radio value="all">全部平台</a-radio>
<a-radio value="specific">指定平台</a-radio>
</a-radio-group>
<a-select
mode="multiple"
class="platform-scope-picker__select"
:value="platforms"
:disabled="scope === 'all'"
:options="publishPlatformOptions"
placeholder="选择适用平台"
@change="onPlatformsChange"
/>
</div>
</template>
<script setup lang="ts">
import type { RadioChangeEvent } from 'ant-design-vue'
import { publishPlatformOptions } from '@/lib/compliance-display'
import type { CompliancePlatformScope } from '@/lib/compliance'
defineProps<{
scope: CompliancePlatformScope
platforms: string[]
}>()
const emit = defineEmits<{
'update:scope': [value: CompliancePlatformScope]
'update:platforms': [value: string[]]
}>()
function onScopeChange(event: RadioChangeEvent) {
const value = event.target.value as CompliancePlatformScope
emit('update:scope', value)
if (value === 'all') {
emit('update:platforms', [])
}
}
function onPlatformsChange(value: string[]) {
emit('update:platforms', value)
if (value.length > 0) {
emit('update:scope', 'specific')
}
}
</script>
<style scoped>
.platform-scope-picker {
display: flex;
flex-direction: column;
gap: 12px;
}
.platform-scope-picker__select {
width: 100%;
}
</style>
+43 -1
View File
@@ -48,11 +48,16 @@
<script setup lang="ts">
import {
AuditOutlined,
BookOutlined,
ControlOutlined,
CrownOutlined,
FileSearchOutlined,
GlobalOutlined,
LineChartOutlined,
SafetyCertificateOutlined,
SettingOutlined,
TeamOutlined,
UserSwitchOutlined,
} from '@ant-design/icons-vue'
import type { ItemType } from 'ant-design-vue'
import { computed, h, onMounted, ref } from 'vue'
@@ -76,6 +81,11 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/admin-users', label: '用户管理', path: '/admin-users' },
{ key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' },
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
{ key: '/compliance/manual-reviews', label: '人工审阅', path: '/compliance/manual-reviews' },
{ key: '/compliance/records', label: '检测记录', path: '/compliance/records' },
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
{ key: '/audits', label: '审计日志', path: '/audits' },
]
@@ -96,6 +106,38 @@ const menuItems = computed<ItemType[]>(() => [
label: '站点映射',
icon: () => h(GlobalOutlined),
},
{
key: 'compliance',
label: '内容安全',
icon: () => h(SafetyCertificateOutlined),
children: [
{
key: '/compliance/policy',
label: '全局策略',
icon: () => h(ControlOutlined),
},
{
key: '/compliance/dictionaries',
label: '合规词库',
icon: () => h(BookOutlined),
},
{
key: '/compliance/manual-reviews',
label: '人工审阅',
icon: () => h(UserSwitchOutlined),
},
{
key: '/compliance/records',
label: '检测记录',
icon: () => h(FileSearchOutlined),
},
{
key: '/compliance/stats',
label: '合规统计',
icon: () => h(LineChartOutlined),
},
],
},
{
key: 'system',
label: '系统设置',
@@ -115,7 +157,7 @@ const menuItems = computed<ItemType[]>(() => [
},
])
const openKeys = ref<string[]>(['system'])
const openKeys = ref<string[]>(['compliance', 'system'])
function onOpenKeysChange(keys: string[]) {
openKeys.value = keys
+114
View File
@@ -0,0 +1,114 @@
import { getAIPlatformCatalogItem } from '@geo/shared-types'
export const publishPlatformCatalog: Record<string, { label: string; short: string }> = {
toutiaohao: { label: '头条号', short: '头' },
baijiahao: { label: '百家号', short: '百' },
sohuhao: { label: '搜狐号', short: '搜' },
qiehao: { label: '企鹅号', short: 'Q' },
zhihu: { label: '知乎', short: '知' },
wangyihao: { label: '网易号', short: '网' },
jianshu: { label: '简书', short: '简' },
bilibili: { label: 'bilibili', short: 'B' },
juejin: { label: '稀土掘金', short: '掘' },
smzdm: { label: '什么值得买', short: '值' },
weixin_gzh: { label: '微信公众号', short: '微' },
zol: { label: '中关村在线', short: 'Z' },
dongchedi: { label: '懂车帝', short: '懂' },
}
export const publishPlatformOptions = Object.entries(publishPlatformCatalog).map(
([value, item]) => ({
value,
label: item.label,
}),
)
export const complianceLevelOptions = [
{ label: '阻断', value: 'block' },
{ label: '高风险', value: 'high' },
{ label: '中风险', value: 'medium' },
{ label: '提示', value: 'info' },
]
export const complianceModeOptions = [
{ label: '强制阻断', value: 'mandatory' },
{ label: '仅提示确认', value: 'advisory' },
{ label: '关闭检测', value: 'disabled' },
]
export const complianceSourceOptions = [
{ label: '词库', value: 'dict' },
{ label: '正则', value: 'regex' },
{ label: 'LLM', value: 'llm' },
]
export function levelLabel(level?: string | null): string {
return complianceLevelOptions.find((item) => item.value === level)?.label ?? level ?? '--'
}
export function levelColor(level?: string | null): string {
if (level === 'block') return 'red'
if (level === 'high') return 'orange'
if (level === 'medium') return 'gold'
if (level === 'info') return 'blue'
return 'default'
}
export function modeLabel(mode?: string | null): string {
return complianceModeOptions.find((item) => item.value === mode)?.label ?? mode ?? '--'
}
export function modeColor(mode?: string | null): string {
if (mode === 'mandatory') return 'red'
if (mode === 'advisory') return 'gold'
if (mode === 'disabled') return 'default'
return 'blue'
}
export function decisionLabel(decision?: string | null): string {
if (decision === 'pass') return '通过'
if (decision === 'needs_ack') return '待确认'
if (decision === 'block') return '阻断'
return decision ?? '--'
}
export function decisionColor(decision?: string | null): string {
if (decision === 'pass') return 'green'
if (decision === 'needs_ack') return 'gold'
if (decision === 'block') return 'red'
return 'default'
}
export function reviewStatusLabel(status?: string | null): string {
if (status === 'pending') return '待审'
if (status === 'approved') return '已通过'
if (status === 'rejected') return '已驳回'
if (status === 'cancelled') return '已撤回'
if (status === 'none') return '无'
return status ?? '--'
}
export function reviewStatusColor(status?: string | null): string {
if (status === 'pending') return 'gold'
if (status === 'approved') return 'green'
if (status === 'rejected') return 'red'
if (status === 'cancelled') return 'default'
return 'blue'
}
export function sourceLabel(source?: string | null): string {
return complianceSourceOptions.find((item) => item.value === source)?.label ?? source ?? '--'
}
export function platformLabel(platform?: string | null): string {
const normalized = String(platform ?? '').trim()
if (!normalized) return '全部平台'
const ai = getAIPlatformCatalogItem(normalized)
if (ai) return ai.label
return publishPlatformCatalog[normalized]?.label ?? normalized
}
export function platformListLabel(platforms?: string[] | null): string {
const normalized = (platforms ?? []).map((item) => item.trim()).filter(Boolean)
return normalized.length ? normalized.map(platformLabel).join('、') : '全部平台'
}
+191
View File
@@ -0,0 +1,191 @@
import type { ComplianceCheckResult, ComplianceManualReview } from '@geo/shared-types'
import { http } from '@/lib/http'
export type ComplianceLevel = 'block' | 'high' | 'medium' | 'info'
export type ComplianceMode = 'mandatory' | 'advisory' | 'disabled'
export type CompliancePlatformScope = 'all' | 'specific'
export interface OpsComplianceDictionary {
id: number
code: string
name: string
platform_scope: CompliancePlatformScope
applicable_platforms: string[]
default_level: ComplianceLevel
enabled: boolean
version: number
description?: string | null
created_at: string
updated_at: string
term_count: number
}
export interface UpsertComplianceDictionaryRequest {
code?: string
name: string
platform_scope: CompliancePlatformScope
applicable_platforms: string[]
default_level: ComplianceLevel
enabled?: boolean
description?: string | null
}
export interface OpsComplianceTerm {
id: number
dictionary_id: number
match_type: 'exact' | 'regex'
pattern: string
level_override?: ComplianceLevel | null
hint?: string | null
reference_law?: string | null
enabled: boolean
created_at: string
updated_at: string
}
export interface CreateComplianceTermRequest {
match_type: 'exact' | 'regex'
pattern: string
level_override?: ComplianceLevel | null
hint?: string | null
reference_law?: string | null
enabled?: boolean
}
export interface CompliancePolicy {
id?: number
master_enabled?: boolean | null
enforcement_mode: ComplianceMode
enabled_dictionaries: number[]
llm_judge_enabled: boolean
llm_categories: string[]
revision: number
updated_at?: string
}
export interface UpdateCompliancePolicyRequest {
master_enabled?: boolean
enforcement_mode?: ComplianceMode
enabled_dictionaries?: number[]
llm_judge_enabled?: boolean
llm_categories?: string[]
}
export interface ComplianceStats {
check_count: number
blocked_count: number
needs_ack_count: number
passed_count: number
ack_count: number
manual_pending_count: number
manual_approved_count: number
manual_rejected_count: number
by_level: Record<string, number>
by_source: Record<string, number>
}
export interface PagedResult<T> {
items: T[]
total: number
page: number
page_size: number
}
export const complianceApi = {
dictionaries() {
return http.get<OpsComplianceDictionary[]>('/compliance/dictionaries')
},
createDictionary(payload: UpsertComplianceDictionaryRequest) {
return http.post<OpsComplianceDictionary, UpsertComplianceDictionaryRequest>(
'/compliance/dictionaries',
payload,
)
},
updateDictionary(id: number, payload: UpsertComplianceDictionaryRequest) {
return http.put<OpsComplianceDictionary, UpsertComplianceDictionaryRequest>(
`/compliance/dictionaries/${id}`,
payload,
)
},
deleteDictionary(id: number) {
return http.delete<{ deleted: boolean }>(`/compliance/dictionaries/${id}`)
},
terms(dictionaryId: number) {
return http.get<OpsComplianceTerm[]>(`/compliance/dictionaries/${dictionaryId}/terms`)
},
createTerm(dictionaryId: number, payload: CreateComplianceTermRequest) {
return http.post<OpsComplianceTerm, CreateComplianceTermRequest>(
`/compliance/dictionaries/${dictionaryId}/terms`,
payload,
)
},
batchImportTerms(dictionaryId: number, terms: CreateComplianceTermRequest[]) {
return http.post<OpsComplianceTerm[], { terms: CreateComplianceTermRequest[] }>(
`/compliance/dictionaries/${dictionaryId}/terms:batch-import`,
{ terms },
)
},
deleteTerm(dictionaryId: number, termId: number) {
return http.delete<{ deleted: boolean }>(`/compliance/dictionaries/${dictionaryId}/terms/${termId}`)
},
publishDictionary(id: number) {
return http.post<OpsComplianceDictionary>(`/compliance/dictionaries/${id}/publish`)
},
rollbackDictionary(id: number, version: number) {
return http.post<OpsComplianceDictionary, { version: number }>(
`/compliance/dictionaries/${id}/rollback`,
{ version },
)
},
globalPolicy() {
return http.get<CompliancePolicy>('/compliance/policy/global')
},
updateGlobalPolicy(payload: UpdateCompliancePolicyRequest) {
return http.put<CompliancePolicy, UpdateCompliancePolicyRequest>(
'/compliance/policy/global',
payload,
)
},
setMasterSwitch(enabled: boolean) {
return http.post<CompliancePolicy, { enabled: boolean }>(
'/compliance/policy/global/master-switch',
{ enabled },
)
},
manualReviews(params: {
status?: string
tenant_id?: number
page?: number
page_size?: number
}) {
return http.get<PagedResult<ComplianceManualReview>>('/compliance/manual-reviews', params)
},
manualReview(id: number) {
return http.get<ComplianceManualReview>(`/compliance/manual-reviews/${id}`)
},
approveManualReview(id: number, reason: string) {
return http.post<ComplianceManualReview, { reason: string }>(
`/compliance/manual-reviews/${id}/approve`,
{ reason },
)
},
rejectManualReview(id: number, reason: string) {
return http.post<ComplianceManualReview, { reason: string }>(
`/compliance/manual-reviews/${id}/reject`,
{ reason },
)
},
records(params: {
tenant_id?: number
article_id?: number
decision?: string
page?: number
page_size?: number
}) {
return http.get<PagedResult<ComplianceCheckResult>>('/compliance/records', params)
},
stats() {
return http.get<ComplianceStats>('/compliance/stats')
},
}
+1
View File
@@ -97,6 +97,7 @@ async function unwrap<T>(promise: Promise<{ data: ApiEnvelope<T> }>): Promise<T>
export const http = {
get: <T>(url: string, params?: Record<string, unknown>) => unwrap<T>(client.get(url, { params })),
post: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.post(url, body)),
put: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.put(url, body)),
patch: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.patch(url, body)),
delete: <T>(url: string) => unwrap<T>(client.delete(url)),
}
+10
View File
@@ -6,6 +6,7 @@ import {
Badge,
Button,
Card,
Checkbox,
ConfigProvider,
DatePicker,
Descriptions,
@@ -14,17 +15,21 @@ import {
Empty,
Form,
Input,
InputNumber,
Layout,
Menu,
Modal,
Pagination,
Popconfirm,
Radio,
Result,
Select,
Space,
Spin,
Statistic,
Switch,
Table,
Tabs,
Tag,
Tooltip,
message,
@@ -61,6 +66,7 @@ bindUnauthorizedHandler(() => {
Badge,
Button,
Card,
Checkbox,
ConfigProvider,
DatePicker,
Descriptions,
@@ -69,17 +75,21 @@ bindUnauthorizedHandler(() => {
Empty,
Form,
Input,
InputNumber,
Layout,
Menu,
Modal,
Pagination,
Popconfirm,
Radio,
Result,
Select,
Space,
Spin,
Statistic,
Switch,
Table,
Tabs,
Tag,
Tooltip,
].forEach((component) => {
+48
View File
@@ -48,6 +48,54 @@ export const router = createRouter({
component: () => import('@/views/SiteDomainMappingsView.vue'),
meta: { title: '站点映射' },
},
{
path: 'compliance/policy',
name: 'compliance-policy',
component: () => import('@/views/compliance/CompliancePolicyView.vue'),
meta: { title: '全局合规策略' },
},
{
path: 'compliance/dictionaries',
name: 'compliance-dictionaries',
component: () => import('@/views/compliance/ComplianceDictionariesView.vue'),
meta: { title: '合规词库' },
},
{
path: 'compliance/dictionaries/new',
name: 'compliance-dictionary-new',
component: () => import('@/views/compliance/ComplianceDictionaryEditView.vue'),
meta: { title: '新建合规词库' },
},
{
path: 'compliance/dictionaries/:id/edit',
name: 'compliance-dictionary-edit',
component: () => import('@/views/compliance/ComplianceDictionaryEditView.vue'),
meta: { title: '编辑合规词库' },
},
{
path: 'compliance/manual-reviews',
name: 'compliance-manual-reviews',
component: () => import('@/views/compliance/ComplianceManualReviewsView.vue'),
meta: { title: '人工审阅' },
},
{
path: 'compliance/manual-reviews/:id',
name: 'compliance-manual-review-detail',
component: () => import('@/views/compliance/ComplianceManualReviewDetailView.vue'),
meta: { title: '人工审阅详情' },
},
{
path: 'compliance/records',
name: 'compliance-records',
component: () => import('@/views/compliance/ComplianceRecordsView.vue'),
meta: { title: '检测记录' },
},
{
path: 'compliance/stats',
name: 'compliance-stats',
component: () => import('@/views/compliance/ComplianceStatsView.vue'),
meta: { title: '合规统计' },
},
{
path: 'profile',
name: 'profile',
@@ -0,0 +1,276 @@
<template>
<div class="compliance-page">
<div class="compliance-page__header">
<div>
<h2 class="ops-page-title">合规词库</h2>
<div class="compliance-page__summary">
<span>全部 {{ filteredRows.length }}</span>
<span>启用 {{ enabledCount }}</span>
<span>词条 {{ totalTerms }}</span>
</div>
</div>
<a-button type="primary" @click="router.push('/compliance/dictionaries/new')">
<template #icon><PlusOutlined /></template>
新建词库
</a-button>
</div>
<div class="ops-card">
<div class="ops-toolbar compliance-toolbar">
<a-input
v-model:value="filter.keyword"
class="compliance-toolbar__search"
placeholder="搜索词库名称 / Code"
allow-clear
/>
<a-select
v-model:value="filter.status"
class="compliance-toolbar__select"
:options="statusOptions"
placeholder="状态"
allow-clear
/>
<a-button @click="loadDictionaries">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<a-table
:columns="columns"
:data-source="filteredRows"
:loading="loading"
row-key="id"
:pagination="{ pageSize: 20, showSizeChanger: true }"
>
<template #emptyText>
<a-empty description="还没有合规词库" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="primary-cell">
<strong>{{ record.name }}</strong>
<span>{{ record.code }}</span>
</div>
</template>
<template v-else-if="column.key === 'scope'">
<a-tag v-if="record.platform_scope === 'all'" color="blue">全部平台</a-tag>
<a-space v-else wrap>
<a-tag v-for="platform in record.applicable_platforms" :key="platform">
{{ platformLabel(platform) }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'level'">
<a-tag :color="levelColor(record.default_level)">
{{ levelLabel(record.default_level) }}
</a-tag>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatDate(record.updated_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row table-actions-row--right">
<a-tooltip title="编辑">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
@click="router.push(`/compliance/dictionaries/${record.id}/edit`)"
>
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="发布版本">
<a-popconfirm
title="确认发布当前词库版本?"
:ok-button-props="{ loading: publishingId === record.id }"
@confirm="publishDictionary(record.id)"
>
<a-button type="text" shape="circle" size="small" class="action-btn action-play">
<RocketOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
<a-tooltip title="删除">
<a-popconfirm
title="确认删除该词库?词条会一并删除。"
:ok-button-props="{ danger: true, loading: deletingId === record.id }"
@confirm="deleteDictionary(record.id)"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-delete"
>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
levelColor,
levelLabel,
platformLabel,
} from '@/lib/compliance-display'
import { complianceApi, type OpsComplianceDictionary } from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const router = useRouter()
const rows = ref<OpsComplianceDictionary[]>([])
const loading = ref(false)
const deletingId = ref<number | null>(null)
const publishingId = ref<number | null>(null)
const filter = reactive({
keyword: '',
status: undefined as 'enabled' | 'disabled' | undefined,
})
const statusOptions = [
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]
const columns: TableColumnsType<OpsComplianceDictionary> = [
{ title: '词库', key: 'name', width: 260 },
{ title: '适用平台', key: 'scope' },
{ title: '默认等级', key: 'level', width: 120 },
{ title: '词条数', dataIndex: 'term_count', key: 'term_count', width: 100 },
{ title: '版本', dataIndex: 'version', key: 'version', width: 90 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '更新时间', key: 'updated_at', width: 170 },
{ title: '操作', key: 'actions', width: 140, align: 'right' },
]
const filteredRows = computed(() => {
const keyword = filter.keyword.trim().toLowerCase()
return rows.value.filter((row) => {
if (filter.status === 'enabled' && !row.enabled) return false
if (filter.status === 'disabled' && row.enabled) return false
if (!keyword) return true
return `${row.name} ${row.code}`.toLowerCase().includes(keyword)
})
})
const enabledCount = computed(() => rows.value.filter((row) => row.enabled).length)
const totalTerms = computed(() => rows.value.reduce((sum, row) => sum + row.term_count, 0))
async function loadDictionaries() {
loading.value = true
try {
rows.value = await complianceApi.dictionaries()
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
async function publishDictionary(id: number) {
publishingId.value = id
try {
await complianceApi.publishDictionary(id)
message.success('词库版本已发布')
await loadDictionaries()
} catch (error) {
showError(error)
} finally {
publishingId.value = null
}
}
async function deleteDictionary(id: number) {
deletingId.value = id
try {
await complianceApi.deleteDictionary(id)
message.success('词库已删除')
await loadDictionaries()
} catch (error) {
showError(error)
} finally {
deletingId.value = null
}
}
function formatDate(value: string): string {
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void loadDictionaries()
})
</script>
<style scoped>
.compliance-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.compliance-page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.compliance-page__summary {
display: flex;
gap: 14px;
color: #64748b;
font-size: 13px;
}
.compliance-toolbar__search {
width: 300px;
}
.compliance-toolbar__select {
width: 140px;
}
.primary-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.primary-cell span {
color: #94a3b8;
font-size: 12px;
}
.table-actions-row--right {
justify-content: flex-end;
}
</style>
@@ -0,0 +1,618 @@
<template>
<div class="dictionary-edit-page">
<div class="dictionary-edit-header">
<div>
<h2 class="ops-page-title">{{ isCreate ? '新建合规词库' : '编辑合规词库' }}</h2>
<div class="dictionary-edit-header__meta">
<span v-if="dictionary">版本 {{ dictionary.version }}</span>
<span v-if="dictionary">词条 {{ dictionary.term_count }}</span>
<span>词库用途由名称表达不使用固定分类</span>
</div>
</div>
<a-space>
<a-button @click="router.push('/compliance/dictionaries')">返回列表</a-button>
<a-button type="primary" :loading="saving" @click="saveDictionary">保存</a-button>
</a-space>
</div>
<div class="dictionary-edit-grid">
<div class="ops-card">
<h3 class="section-title">基础设置</h3>
<a-form layout="vertical" :model="form">
<a-form-item label="词库 Code" required>
<a-input
v-model:value="form.code"
:disabled="!isCreate"
placeholder="ad_law_extreme_words"
/>
</a-form-item>
<a-form-item label="词库名称" required>
<a-input v-model:value="form.name" placeholder="广告法极限词" />
</a-form-item>
<a-form-item label="适用平台" required>
<PlatformScopePicker
v-model:scope="form.platform_scope"
v-model:platforms="form.applicable_platforms"
/>
</a-form-item>
<a-form-item label="默认风险等级" required>
<a-select v-model:value="form.default_level" :options="complianceLevelOptions" />
</a-form-item>
<a-form-item label="状态">
<a-switch
v-model:checked="form.enabled"
checked-children="启用"
un-checked-children="停用"
/>
</a-form-item>
<a-form-item label="说明">
<a-textarea v-model:value="form.description" :rows="3" placeholder="可选" />
</a-form-item>
</a-form>
</div>
<div class="ops-card dictionary-edit-side">
<h3 class="section-title">版本操作</h3>
<a-alert
type="info"
show-icon
message="发布会写入版本快照;回滚会恢复快照中的词库设置和词条。"
/>
<a-space class="version-actions" direction="vertical">
<a-button
block
:disabled="isCreate"
:loading="publishing"
@click="publishCurrentDictionary"
>
<template #icon><RocketOutlined /></template>
发布当前版本
</a-button>
<div class="rollback-row">
<a-input-number
v-model:value="rollbackVersion"
:min="1"
class="rollback-row__input"
placeholder="版本号"
/>
<a-popconfirm
title="确认回滚到该版本?当前词条会被快照覆盖。"
:disabled="isCreate || !rollbackVersion"
:ok-button-props="{ loading: rollingBack }"
@confirm="rollbackDictionary"
>
<a-button :disabled="isCreate || !rollbackVersion">回滚</a-button>
</a-popconfirm>
</div>
</a-space>
</div>
</div>
<div class="ops-card">
<div class="terms-header">
<h3 class="section-title">词条管理</h3>
<a-space>
<a-button v-if="!isCreate" @click="termDrawerOpen = true">
<template #icon><PlusOutlined /></template>
新增词条
</a-button>
<a-button v-if="!isCreate" @click="batchDrawerOpen = true">批量导入</a-button>
<a-button v-if="!isCreate" :loading="termsLoading" @click="loadTerms">刷新</a-button>
</a-space>
</div>
<template v-if="isCreate">
<a-alert
class="create-terms-alert"
type="info"
show-icon
message="新建时可先粘贴敏感词,一行一个;保存词库后会自动导入为精确匹配词条。"
/>
<a-textarea
v-model:value="createTermsText"
:rows="10"
placeholder="一行一个词条,不要包含空白字符"
/>
</template>
<a-table
v-else
:columns="termColumns"
:data-source="terms"
:loading="termsLoading"
row-key="id"
:pagination="{ pageSize: 20, showSizeChanger: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'pattern'">
<div class="term-pattern">
<strong>{{ record.pattern }}</strong>
<span v-if="record.hint">{{ record.hint }}</span>
</div>
</template>
<template v-else-if="column.key === 'match_type'">
<a-tag>{{ matchTypeLabel(record.match_type) }}</a-tag>
</template>
<template v-else-if="column.key === 'level'">
<a-tag :color="levelColor(record.level_override || dictionary?.default_level)">
{{ levelLabel(record.level_override || dictionary?.default_level) }}
</a-tag>
</template>
<template v-else-if="column.key === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row table-actions-row--right">
<a-popconfirm
title="确认删除该词条?"
:ok-button-props="{ danger: true, loading: deletingTermId === record.id }"
@confirm="deleteTerm(record.id)"
>
<a-button type="text" shape="circle" size="small" class="action-btn action-delete">
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</template>
</template>
</a-table>
</div>
<a-drawer v-model:open="termDrawerOpen" title="新增词条" width="460" :destroy-on-close="true">
<a-form layout="vertical" :model="termForm">
<a-form-item label="匹配方式" required>
<a-select v-model:value="termForm.match_type" :options="matchTypeOptions" />
</a-form-item>
<a-form-item label="词条 / 正则" required>
<a-input v-model:value="termForm.pattern" placeholder="请输入词条" />
</a-form-item>
<a-form-item label="风险等级覆盖">
<a-select
v-model:value="termForm.level_override"
:options="[{ label: '继承词库默认', value: null }, ...complianceLevelOptions]"
/>
</a-form-item>
<a-form-item label="提示文案">
<a-textarea v-model:value="termForm.hint" :rows="3" />
</a-form-item>
<a-form-item label="法规依据">
<a-input v-model:value="termForm.reference_law" />
</a-form-item>
<a-form-item label="状态">
<a-switch
v-model:checked="termForm.enabled"
checked-children="启用"
un-checked-children="停用"
/>
</a-form-item>
</a-form>
<template #footer>
<div class="drawer-footer">
<a-button @click="termDrawerOpen = false">取消</a-button>
<a-button type="primary" :loading="termSaving" @click="createTerm">保存</a-button>
</div>
</template>
</a-drawer>
<a-drawer v-model:open="batchDrawerOpen" title="批量导入词条" width="520" :destroy-on-close="true">
<a-alert
class="create-terms-alert"
type="info"
show-icon
message="一行一个词条,批量导入会创建为精确匹配并继承词库默认等级。"
/>
<a-textarea v-model:value="batchTermsText" :rows="12" placeholder="一行一个词条" />
<template #footer>
<div class="drawer-footer">
<a-button @click="batchDrawerOpen = false">取消</a-button>
<a-button type="primary" :loading="batchSaving" @click="batchImportTerms">导入</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { DeleteOutlined, PlusOutlined, RocketOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PlatformScopePicker from '@/components/compliance/PlatformScopePicker.vue'
import {
complianceLevelOptions,
levelColor,
levelLabel,
} from '@/lib/compliance-display'
import {
complianceApi,
type ComplianceLevel,
type OpsComplianceDictionary,
type OpsComplianceTerm,
} from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
type MatchType = 'exact' | 'regex'
const route = useRoute()
const router = useRouter()
const dictionaryId = computed(() => {
const raw = route.params.id
const value = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(value) && value > 0 ? value : null
})
const isCreate = computed(() => route.name === 'compliance-dictionary-new')
const dictionary = ref<OpsComplianceDictionary | null>(null)
const terms = ref<OpsComplianceTerm[]>([])
const saving = ref(false)
const termsLoading = ref(false)
const publishing = ref(false)
const rollingBack = ref(false)
const rollbackVersion = ref<number | null>(null)
const deletingTermId = ref<number | null>(null)
const termDrawerOpen = ref(false)
const batchDrawerOpen = ref(false)
const termSaving = ref(false)
const batchSaving = ref(false)
const createTermsText = ref('')
const batchTermsText = ref('')
const form = reactive({
code: '',
name: '',
platform_scope: 'all' as 'all' | 'specific',
applicable_platforms: [] as string[],
default_level: 'block' as ComplianceLevel,
enabled: true,
description: '',
})
const termForm = reactive({
match_type: 'exact' as MatchType,
pattern: '',
level_override: null as ComplianceLevel | null,
hint: '',
reference_law: '',
enabled: true,
})
const matchTypeOptions = [
{ label: '精确匹配', value: 'exact' },
{ label: '正则', value: 'regex' },
]
const termColumns: TableColumnsType<OpsComplianceTerm> = [
{ title: '词条', key: 'pattern' },
{ title: '匹配方式', key: 'match_type', width: 120 },
{ title: '风险等级', key: 'level', width: 120 },
{ title: '法规依据', dataIndex: 'reference_law', key: 'reference_law', width: 180 },
{ title: '状态', key: 'enabled', width: 90 },
{ title: '操作', key: 'actions', width: 80, align: 'right' },
]
async function loadDictionary() {
if (isCreate.value) return
if (!dictionaryId.value) {
message.error('无效的词库 ID')
await router.replace('/compliance/dictionaries')
return
}
try {
const dictionaries = await complianceApi.dictionaries()
const item = dictionaries.find((row) => row.id === dictionaryId.value)
if (!item) {
message.error('词库不存在')
await router.replace('/compliance/dictionaries')
return
}
dictionary.value = item
form.code = item.code
form.name = item.name
form.platform_scope = item.platform_scope
form.applicable_platforms = [...item.applicable_platforms]
form.default_level = item.default_level
form.enabled = item.enabled
form.description = item.description ?? ''
await loadTerms()
} catch (error) {
showError(error)
}
}
async function loadTerms() {
if (!dictionaryId.value) return
termsLoading.value = true
try {
terms.value = await complianceApi.terms(dictionaryId.value)
} catch (error) {
showError(error)
} finally {
termsLoading.value = false
}
}
async function saveDictionary() {
if (!validateDictionaryForm()) return
saving.value = true
try {
const payload = {
code: form.code.trim(),
name: form.name.trim(),
platform_scope: form.platform_scope,
applicable_platforms: form.platform_scope === 'all' ? [] : [...form.applicable_platforms],
default_level: form.default_level,
enabled: form.enabled,
description: form.description.trim() || null,
}
if (isCreate.value) {
const created = await complianceApi.createDictionary(payload)
const initialTerms = parseTermLines(createTermsText.value)
if (initialTerms.length > 0) {
await complianceApi.batchImportTerms(
created.id,
initialTerms.map((pattern) => ({
match_type: 'exact',
pattern,
enabled: true,
})),
)
}
message.success('词库已创建')
await router.replace(`/compliance/dictionaries/${created.id}/edit`)
} else if (dictionaryId.value) {
dictionary.value = await complianceApi.updateDictionary(dictionaryId.value, payload)
message.success('词库已保存')
await loadDictionary()
}
} catch (error) {
showError(error)
} finally {
saving.value = false
}
}
async function createTerm() {
if (!dictionaryId.value) return
const pattern = termForm.pattern.trim()
if (!pattern) {
message.warning('请输入词条')
return
}
if (termForm.match_type === 'exact' && /\s/.test(pattern)) {
message.warning('精确匹配词条不能包含空白字符')
return
}
termSaving.value = true
try {
await complianceApi.createTerm(dictionaryId.value, {
match_type: termForm.match_type,
pattern,
level_override: termForm.level_override,
hint: termForm.hint.trim() || null,
reference_law: termForm.reference_law.trim() || null,
enabled: termForm.enabled,
})
message.success('词条已新增')
resetTermForm()
termDrawerOpen.value = false
await loadDictionary()
} catch (error) {
showError(error)
} finally {
termSaving.value = false
}
}
async function batchImportTerms() {
if (!dictionaryId.value) return
const lines = parseTermLines(batchTermsText.value)
if (lines.length === 0) {
message.warning('请输入至少一个词条')
return
}
batchSaving.value = true
try {
await complianceApi.batchImportTerms(
dictionaryId.value,
lines.map((pattern) => ({ match_type: 'exact', pattern, enabled: true })),
)
message.success(`已导入 ${lines.length} 个词条`)
batchTermsText.value = ''
batchDrawerOpen.value = false
await loadDictionary()
} catch (error) {
showError(error)
} finally {
batchSaving.value = false
}
}
async function deleteTerm(termId: number) {
if (!dictionaryId.value) return
deletingTermId.value = termId
try {
await complianceApi.deleteTerm(dictionaryId.value, termId)
message.success('词条已删除')
await loadDictionary()
} catch (error) {
showError(error)
} finally {
deletingTermId.value = null
}
}
async function publishCurrentDictionary() {
if (!dictionaryId.value) return
publishing.value = true
try {
dictionary.value = await complianceApi.publishDictionary(dictionaryId.value)
message.success('词库版本已发布')
} catch (error) {
showError(error)
} finally {
publishing.value = false
}
}
async function rollbackDictionary() {
if (!dictionaryId.value || !rollbackVersion.value) return
rollingBack.value = true
try {
dictionary.value = await complianceApi.rollbackDictionary(dictionaryId.value, rollbackVersion.value)
message.success('词库已回滚')
await loadDictionary()
} catch (error) {
showError(error)
} finally {
rollingBack.value = false
}
}
function validateDictionaryForm(): boolean {
if (!form.name.trim()) {
message.warning('请输入词库名称')
return false
}
if (isCreate.value && !form.code.trim()) {
message.warning('请输入词库 Code')
return false
}
if (form.platform_scope === 'specific' && form.applicable_platforms.length === 0) {
message.warning('请选择适用平台')
return false
}
return true
}
function parseTermLines(value: string): string[] {
return Array.from(
new Set(
value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !/\s/.test(line)),
),
)
}
function matchTypeLabel(value: string): string {
return matchTypeOptions.find((item) => item.value === value)?.label ?? value
}
function resetTermForm() {
termForm.match_type = 'exact'
termForm.pattern = ''
termForm.level_override = null
termForm.hint = ''
termForm.reference_law = ''
termForm.enabled = true
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void loadDictionary()
})
</script>
<style scoped>
.dictionary-edit-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.dictionary-edit-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.dictionary-edit-header__meta {
display: flex;
gap: 14px;
color: #64748b;
font-size: 13px;
}
.dictionary-edit-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 18px;
}
.dictionary-edit-side {
align-self: start;
}
.section-title {
margin: 0 0 16px;
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.version-actions {
width: 100%;
margin-top: 16px;
}
.rollback-row {
display: flex;
gap: 8px;
width: 100%;
}
.rollback-row__input {
flex: 1;
}
.terms-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.create-terms-alert {
margin-bottom: 12px;
}
.term-pattern {
display: flex;
flex-direction: column;
gap: 3px;
}
.term-pattern span {
color: #64748b;
font-size: 12px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-actions-row--right {
justify-content: flex-end;
}
@media (max-width: 960px) {
.dictionary-edit-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,279 @@
<template>
<div class="manual-review-detail-page">
<div class="manual-review-detail-header">
<div>
<h2 class="ops-page-title">审阅详情 #{{ reviewId }}</h2>
<div v-if="review" class="manual-review-detail-header__meta">
<a-tag :color="reviewStatusColor(review.status)">
{{ reviewStatusLabel(review.status) }}
</a-tag>
<span>租户 {{ review.tenant_name || `#${review.tenant_id}` }}</span>
<span>版本 #{{ review.article_version_id }}</span>
</div>
</div>
<a-space>
<a-button @click="router.push('/compliance/manual-reviews')">返回列表</a-button>
<a-button :loading="loading" @click="loadReview">刷新</a-button>
</a-space>
</div>
<a-spin :spinning="loading">
<div v-if="review" class="manual-review-detail-grid">
<div class="ops-card article-panel">
<h3 class="section-title">{{ review.article_title || `文章 #${review.article_id}` }}</h3>
<div class="article-meta">
<span>申请人 {{ review.applicant_name || `#${review.requested_by}` }}</span>
<span>申请时间 {{ formatDate(review.requested_at) }}</span>
<span>目标平台 {{ platformListLabel(review.original_target_platforms) }}</span>
</div>
<div class="plaintext-box">
{{ review.article_plaintext || '暂无正文快照' }}
</div>
</div>
<div class="ops-card decision-panel">
<h3 class="section-title">审阅决策</h3>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="申请说明">
{{ review.request_note || '—' }}
</a-descriptions-item>
<a-descriptions-item v-if="review.decision_reason" label="决策理由">
{{ review.decision_reason }}
</a-descriptions-item>
<a-descriptions-item v-if="review.cancel_reason" label="撤回理由">
{{ review.cancel_reason }}
</a-descriptions-item>
</a-descriptions>
<template v-if="review.status === 'pending'">
<a-form class="decision-form" layout="vertical">
<a-form-item label="决策理由" required>
<a-textarea v-model:value="decisionReason" :rows="5" placeholder="填写通过或驳回原因" />
</a-form-item>
<a-space class="decision-actions">
<a-popconfirm
title="确认通过该版本?通过后该 article_version 将永久绕过自动检测。"
:ok-button-props="{ loading: deciding, type: 'primary' }"
@confirm="decide(true)"
>
<a-button type="primary" :disabled="deciding">
<template #icon><CheckOutlined /></template>
通过
</a-button>
</a-popconfirm>
<a-popconfirm
title="确认驳回该版本?驳回后该 article_version 将不可发布。"
:ok-button-props="{ danger: true, loading: deciding }"
@confirm="decide(false)"
>
<a-button danger :disabled="deciding">
<template #icon><CloseOutlined /></template>
驳回
</a-button>
</a-popconfirm>
</a-space>
</a-form>
</template>
<a-alert
v-else
class="terminal-alert"
type="info"
show-icon
message="该审阅已进入终态,不能再次决策。"
/>
</div>
</div>
<div v-if="review" class="ops-card violations-card">
<h3 class="section-title">命中证据</h3>
<a-table
:columns="violationColumns"
:data-source="review.violation_summary"
row-key="id"
:pagination="{ pageSize: 20, showSizeChanger: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'matched_text'">
<strong>{{ record.matched_text }}</strong>
</template>
<template v-else-if="column.key === 'level'">
<a-tag :color="levelColor(record.level)">{{ levelLabel(record.level) }}</a-tag>
</template>
<template v-else-if="column.key === 'source'">
{{ sourceLabel(record.source) }}
</template>
<template v-else-if="column.key === 'platform'">
{{ platformLabel(record.platform_code) }}
</template>
<template v-else-if="column.key === 'dictionary'">
{{ record.dictionary_name || record.dictionary_code || '' }}
</template>
</template>
</a-table>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import type { ComplianceManualReview, ComplianceViolation } from '@geo/shared-types'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
levelColor,
levelLabel,
platformLabel,
platformListLabel,
reviewStatusColor,
reviewStatusLabel,
sourceLabel,
} from '@/lib/compliance-display'
import { complianceApi } from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const route = useRoute()
const router = useRouter()
const reviewId = computed(() => Number(route.params.id))
const review = ref<ComplianceManualReview | null>(null)
const loading = ref(false)
const deciding = ref(false)
const decisionReason = ref('')
const violationColumns: TableColumnsType<ComplianceViolation> = [
{ title: '命中文本', key: 'matched_text' },
{ title: '等级', key: 'level', width: 110 },
{ title: '来源', key: 'source', width: 110 },
{ title: '平台', key: 'platform', width: 140 },
{ title: '词库', key: 'dictionary', width: 180 },
{ title: '提示', dataIndex: 'hint', key: 'hint' },
]
async function loadReview() {
if (!Number.isFinite(reviewId.value) || reviewId.value <= 0) {
message.error('无效的审阅 ID')
await router.replace('/compliance/manual-reviews')
return
}
loading.value = true
try {
review.value = await complianceApi.manualReview(reviewId.value)
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
async function decide(approve: boolean) {
const reason = decisionReason.value.trim()
if (!reason) {
message.warning('请填写决策理由')
return
}
deciding.value = true
try {
review.value = approve
? await complianceApi.approveManualReview(reviewId.value, reason)
: await complianceApi.rejectManualReview(reviewId.value, reason)
message.success(approve ? '审阅已通过' : '审阅已驳回')
decisionReason.value = ''
} catch (error) {
showError(error)
} finally {
deciding.value = false
}
}
function formatDate(value: string): string {
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void loadReview()
})
</script>
<style scoped>
.manual-review-detail-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.manual-review-detail-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.manual-review-detail-header__meta,
.article-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
color: #64748b;
font-size: 13px;
}
.manual-review-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 18px;
}
.section-title {
margin: 0 0 14px;
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.plaintext-box {
min-height: 420px;
max-height: 640px;
overflow: auto;
margin-top: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #334155;
line-height: 1.8;
white-space: pre-wrap;
}
.decision-panel {
align-self: start;
}
.decision-form {
margin-top: 16px;
}
.decision-actions {
width: 100%;
}
.terminal-alert {
margin-top: 16px;
}
@media (max-width: 1100px) {
.manual-review-detail-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,223 @@
<template>
<div class="manual-review-page">
<div class="manual-review-header">
<div>
<h2 class="ops-page-title">人工审阅工作台</h2>
<div class="manual-review-header__meta">
<span>待审优先处理</span>
<span>当前筛选 {{ total }}</span>
</div>
</div>
<a-button :loading="loading" @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="ops-card">
<div class="ops-toolbar manual-review-toolbar">
<a-tabs v-model:active-key="filter.status" class="manual-review-tabs" @change="onStatusTab">
<a-tab-pane key="pending" tab="待审" />
<a-tab-pane key="approved" tab="已通过" />
<a-tab-pane key="rejected" tab="已驳回" />
<a-tab-pane key="" tab="全部" />
</a-tabs>
<a-input-number
v-model:value="tenantIdInput"
class="tenant-filter"
:min="1"
placeholder="租户 ID"
@press-enter="reload"
/>
<a-button @click="reload">查询</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'article'">
<div class="primary-cell">
<strong>{{ record.article_title || `文章 #${record.article_id}` }}</strong>
<span>
租户 {{ record.tenant_name || `#${record.tenant_id}` }} · 版本 #{{ record.article_version_id }}
</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="reviewStatusColor(record.status)">
{{ reviewStatusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'violations'">
<a-space wrap>
<a-tag
v-for="violation in record.violation_summary.slice(0, 3)"
:key="`${record.id}-${violation.id}-${violation.matched_text}`"
:color="levelColor(violation.level)"
>
{{ violation.matched_text }}
</a-tag>
<a-tag v-if="record.violation_summary.length > 3">
+{{ record.violation_summary.length - 3 }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'platforms'">
{{ platformListLabel(record.original_target_platforms) }}
</template>
<template v-else-if="column.key === 'requested_at'">
{{ formatDate(record.requested_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button type="link" @click="router.push(`/compliance/manual-reviews/${record.id}`)">
详情
</a-button>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script setup lang="ts">
import type { ComplianceManualReview } from '@geo/shared-types'
import { ReloadOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
levelColor,
platformListLabel,
reviewStatusColor,
reviewStatusLabel,
} from '@/lib/compliance-display'
import { complianceApi } from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const router = useRouter()
const rows = ref<ComplianceManualReview[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const tenantIdInput = ref<number | null>(null)
const filter = reactive({
status: 'pending',
})
const columns: TableColumnsType<ComplianceManualReview> = [
{ title: '文章', key: 'article', width: 300 },
{ title: '状态', key: 'status', width: 100 },
{ title: '命中摘要', key: 'violations' },
{ title: '目标平台', key: 'platforms', width: 180 },
{ title: '申请时间', key: 'requested_at', width: 170 },
{ title: '操作', key: 'actions', width: 90, align: 'right' },
]
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
}))
async function reload() {
loading.value = true
try {
const result = await complianceApi.manualReviews({
status: filter.status || undefined,
tenant_id: tenantIdInput.value ?? undefined,
page: page.value,
page_size: pageSize.value,
})
rows.value = result.items
total.value = result.total
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
function onStatusTab() {
page.value = 1
void reload()
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1
pageSize.value = pag.pageSize ?? 50
void reload()
}
function formatDate(value: string): string {
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void reload()
})
</script>
<style scoped>
.manual-review-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.manual-review-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.manual-review-header__meta {
display: flex;
gap: 14px;
color: #64748b;
font-size: 13px;
}
.manual-review-toolbar {
align-items: flex-start;
}
.manual-review-tabs {
flex: 1;
min-width: 360px;
}
.tenant-filter {
width: 140px;
}
.primary-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.primary-cell span {
color: #94a3b8;
font-size: 12px;
}
</style>
@@ -0,0 +1,324 @@
<template>
<div class="policy-page">
<div class="policy-header">
<div>
<h2 class="ops-page-title">全局合规策略</h2>
<div class="policy-header__meta">
<span>修订 {{ policy?.revision ?? 0 }}</span>
<span>更新 {{ policy?.updated_at ? formatDate(policy.updated_at) : '--' }}</span>
</div>
</div>
<a-space>
<a-button :loading="loading" @click="loadAll">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" :loading="saving" @click="savePolicy">保存策略</a-button>
</a-space>
</div>
<div class="policy-grid">
<div class="ops-card policy-master-card">
<div class="policy-master-card__main">
<div>
<span class="eyebrow">MASTER SWITCH</span>
<h3>内容合规检测总开关</h3>
<p>关闭后自动检测器短路但人工审阅 pending / rejected 仍会继续生效</p>
</div>
<a-popconfirm
:title="form.master_enabled ? '确认关闭全站自动检测?' : '确认开启全站自动检测?'"
:ok-button-props="{ danger: form.master_enabled, loading: masterSaving }"
@confirm="toggleMasterSwitch"
>
<a-switch
:checked="form.master_enabled"
:loading="masterSaving"
checked-children="开启"
un-checked-children="关闭"
/>
</a-popconfirm>
</div>
<a-alert
v-if="!form.master_enabled"
type="warning"
show-icon
message="全站自动检测已关闭,发布闸会跳过词库、正则与 LLM 检测。"
/>
</div>
<div class="ops-card">
<h3 class="section-title">判定模式</h3>
<a-radio-group v-model:value="form.enforcement_mode" class="mode-radio-group">
<a-radio-button value="mandatory">强制阻断</a-radio-button>
<a-radio-button value="advisory">仅提示确认</a-radio-button>
<a-radio-button value="disabled">关闭检测</a-radio-button>
</a-radio-group>
<div class="mode-hint">
<a-tag :color="modeColor(form.enforcement_mode)">
{{ modeLabel(form.enforcement_mode) }}
</a-tag>
<span>{{ modeHint }}</span>
</div>
</div>
</div>
<div class="ops-card">
<div class="section-header">
<h3 class="section-title">启用词库</h3>
<a-tag>{{ form.enabled_dictionaries.length }} / {{ dictionaries.length }}</a-tag>
</div>
<a-select
v-model:value="form.enabled_dictionaries"
mode="multiple"
class="dictionary-select"
:options="dictionaryOptions"
:loading="loading"
placeholder="选择参与自动检测的词库"
/>
<div class="dictionary-preview">
<a-tag
v-for="dictionary in selectedDictionaries"
:key="dictionary.id"
:color="dictionary.enabled ? 'blue' : 'default'"
>
{{ dictionary.name }} · v{{ dictionary.version }}
</a-tag>
</div>
</div>
<div class="ops-card">
<div class="section-header">
<h3 class="section-title">LLM 复核</h3>
<a-switch
v-model:checked="form.llm_judge_enabled"
checked-children="开启"
un-checked-children="关闭"
/>
</div>
<a-alert
type="info"
show-icon
message="发布硬闸不等待 LLM;开启后编辑器检测可同步等待,发布后会进入异步复核队列并计入 AI 点数。"
/>
<a-select
v-model:value="form.llm_categories"
mode="tags"
class="llm-category-select"
placeholder="输入复核类别,如 medical、finance"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { modeColor, modeLabel } from '@/lib/compliance-display'
import {
complianceApi,
type ComplianceMode,
type CompliancePolicy,
type OpsComplianceDictionary,
} from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const policy = ref<CompliancePolicy | null>(null)
const dictionaries = ref<OpsComplianceDictionary[]>([])
const loading = ref(false)
const saving = ref(false)
const masterSaving = ref(false)
const form = reactive({
master_enabled: true,
enforcement_mode: 'advisory' as ComplianceMode,
enabled_dictionaries: [] as number[],
llm_judge_enabled: false,
llm_categories: [] as string[],
})
const dictionaryOptions = computed(() =>
dictionaries.value.map((item) => ({
label: `${item.name} · ${item.code}`,
value: item.id,
disabled: !item.enabled,
})),
)
const selectedDictionaries = computed(() => {
const selected = new Set(form.enabled_dictionaries)
return dictionaries.value.filter((item) => selected.has(item.id))
})
const modeHint = computed(() => {
if (form.enforcement_mode === 'mandatory') return 'block 等级命中会直接阻断发布'
if (form.enforcement_mode === 'advisory') return '命中后作者确认风险即可继续发布'
return '自动检测短路,保留人工审阅裁决'
})
async function loadAll() {
loading.value = true
try {
const [policyResult, dictionariesResult] = await Promise.all([
complianceApi.globalPolicy(),
complianceApi.dictionaries(),
])
policy.value = policyResult
dictionaries.value = dictionariesResult
applyPolicy(policyResult)
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
async function savePolicy() {
saving.value = true
try {
policy.value = await complianceApi.updateGlobalPolicy({
master_enabled: form.master_enabled,
enforcement_mode: form.enforcement_mode,
enabled_dictionaries: form.enabled_dictionaries,
llm_judge_enabled: form.llm_judge_enabled,
llm_categories: form.llm_categories,
})
applyPolicy(policy.value)
message.success('全局策略已保存')
} catch (error) {
showError(error)
} finally {
saving.value = false
}
}
async function toggleMasterSwitch() {
masterSaving.value = true
try {
policy.value = await complianceApi.setMasterSwitch(!form.master_enabled)
applyPolicy(policy.value)
message.success(form.master_enabled ? '自动检测已开启' : '自动检测已关闭')
} catch (error) {
showError(error)
} finally {
masterSaving.value = false
}
}
function applyPolicy(next: CompliancePolicy) {
form.master_enabled = next.master_enabled ?? true
form.enforcement_mode = next.enforcement_mode
form.enabled_dictionaries = [...next.enabled_dictionaries]
form.llm_judge_enabled = next.llm_judge_enabled
form.llm_categories = [...next.llm_categories]
}
function formatDate(value: string): string {
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void loadAll()
})
</script>
<style scoped>
.policy-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.policy-header,
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.policy-header__meta {
display: flex;
gap: 14px;
color: #64748b;
font-size: 13px;
}
.policy-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 18px;
}
.policy-master-card {
display: flex;
flex-direction: column;
gap: 14px;
}
.policy-master-card__main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
}
.eyebrow {
color: #64748b;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
}
.policy-master-card h3,
.section-title {
margin: 0;
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.policy-master-card p {
margin: 8px 0 0;
color: #64748b;
}
.mode-radio-group {
width: 100%;
}
.mode-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
color: #64748b;
}
.dictionary-select,
.llm-category-select {
width: 100%;
margin-top: 14px;
}
.dictionary-preview {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
@media (max-width: 1040px) {
.policy-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,238 @@
<template>
<div class="records-page">
<div class="records-header">
<div>
<h2 class="ops-page-title">检测记录</h2>
<div class="records-header__meta">全局检测审计与命中证据搜索</div>
</div>
<a-button :loading="loading" @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="ops-card">
<div class="ops-toolbar records-toolbar">
<a-input-number
v-model:value="filter.tenant_id"
class="number-filter"
:min="1"
placeholder="租户 ID"
/>
<a-input-number
v-model:value="filter.article_id"
class="number-filter"
:min="1"
placeholder="文章 ID"
/>
<a-select
v-model:value="filter.decision"
class="decision-filter"
:options="decisionOptions"
allow-clear
placeholder="判定"
/>
<a-button @click="onFilter">查询</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="record_id"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'record'">
<div class="primary-cell">
<strong>记录 #{{ record.record_id }}</strong>
<span>文章 #{{ record.article_id || '--' }} · 版本 #{{ record.article_version_id || '--' }}</span>
</div>
</template>
<template v-else-if="column.key === 'decision'">
<a-tag :color="decisionColor(record.decision)">
{{ decisionLabel(record.decision) }}
</a-tag>
</template>
<template v-else-if="column.key === 'level'">
<a-tag :color="levelColor(record.highest_level)">
{{ levelLabel(record.highest_level) }}
</a-tag>
</template>
<template v-else-if="column.key === 'mode'">
<a-tag :color="modeColor(record.enforcement_mode)">
{{ modeLabel(record.enforcement_mode) }}
</a-tag>
</template>
<template v-else-if="column.key === 'violations'">
<a-space wrap>
<a-tag
v-for="violation in record.violations.slice(0, 3)"
:key="`${record.record_id}-${violation.id}-${violation.matched_text}`"
:color="levelColor(violation.level)"
>
{{ violation.matched_text }}
</a-tag>
<a-tag v-if="record.violations.length > 3">+{{ record.violations.length - 3 }}</a-tag>
<span v-if="record.violations.length === 0" class="muted-text">无命中</span>
</a-space>
</template>
<template v-else-if="column.key === 'manual'">
<a-tag :color="reviewStatusColor(record.manual_review_status)">
{{ reviewStatusLabel(record.manual_review_status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'checked_at'">
{{ formatDate(record.checked_at) }}
</template>
<template v-else-if="column.key === 'duration'">
{{ record.duration_ms }}ms
</template>
</template>
</a-table>
</div>
</div>
</template>
<script setup lang="ts">
import type { ComplianceCheckResult } from '@geo/shared-types'
import { ReloadOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import {
decisionColor,
decisionLabel,
levelColor,
levelLabel,
modeColor,
modeLabel,
reviewStatusColor,
reviewStatusLabel,
} from '@/lib/compliance-display'
import { complianceApi } from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const rows = ref<ComplianceCheckResult[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const filter = reactive({
tenant_id: null as number | null,
article_id: null as number | null,
decision: undefined as string | undefined,
})
const decisionOptions = [
{ label: '通过', value: 'pass' },
{ label: '待确认', value: 'needs_ack' },
{ label: '阻断', value: 'block' },
]
const columns: TableColumnsType<ComplianceCheckResult> = [
{ title: '记录', key: 'record', width: 230 },
{ title: '判定', key: 'decision', width: 100 },
{ title: '最高等级', key: 'level', width: 110 },
{ title: '模式', key: 'mode', width: 120 },
{ title: '命中', key: 'violations' },
{ title: '人工审阅', key: 'manual', width: 120 },
{ title: '耗时', key: 'duration', width: 90 },
{ title: '检测时间', key: 'checked_at', width: 170 },
]
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
}))
async function reload() {
loading.value = true
try {
const result = await complianceApi.records({
tenant_id: filter.tenant_id ?? undefined,
article_id: filter.article_id ?? undefined,
decision: filter.decision,
page: page.value,
page_size: pageSize.value,
})
rows.value = result.items
total.value = result.total
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
function onFilter() {
page.value = 1
void reload()
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1
pageSize.value = pag.pageSize ?? 50
void reload()
}
function formatDate(value: string): string {
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void reload()
})
</script>
<style scoped>
.records-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.records-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.records-header__meta {
color: #64748b;
font-size: 13px;
}
.number-filter {
width: 140px;
}
.decision-filter {
width: 140px;
}
.primary-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.primary-cell span,
.muted-text {
color: #94a3b8;
font-size: 12px;
}
</style>
@@ -0,0 +1,206 @@
<template>
<div class="stats-page">
<div class="stats-header">
<div>
<h2 class="ops-page-title">合规统计</h2>
<div class="stats-header__meta">检测量拦截确认与人工审阅分布</div>
</div>
<a-button :loading="loading" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="stats-grid">
<div class="ops-card stat-card">
<a-statistic title="检测总量" :value="stats?.check_count ?? 0" />
</div>
<div class="ops-card stat-card stat-card--red">
<a-statistic title="阻断" :value="stats?.blocked_count ?? 0" />
</div>
<div class="ops-card stat-card stat-card--gold">
<a-statistic title="待确认" :value="stats?.needs_ack_count ?? 0" />
</div>
<div class="ops-card stat-card stat-card--green">
<a-statistic title="通过" :value="stats?.passed_count ?? 0" />
</div>
</div>
<div class="stats-grid stats-grid--wide">
<div class="ops-card">
<h3 class="section-title">风险等级分布</h3>
<div class="metric-list">
<div v-for="item in levelRows" :key="item.key" class="metric-row">
<span>
<a-tag :color="levelColor(item.key)">{{ levelLabel(item.key) }}</a-tag>
</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="ops-card">
<h3 class="section-title">命中来源分布</h3>
<div class="metric-list">
<div v-for="item in sourceRows" :key="item.key" class="metric-row">
<span>{{ sourceLabel(item.key) }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="ops-card">
<h3 class="section-title">人工审阅</h3>
<div class="metric-list">
<div class="metric-row">
<span><a-tag color="gold">待审</a-tag></span>
<strong>{{ stats?.manual_pending_count ?? 0 }}</strong>
</div>
<div class="metric-row">
<span><a-tag color="green">已通过</a-tag></span>
<strong>{{ stats?.manual_approved_count ?? 0 }}</strong>
</div>
<div class="metric-row">
<span><a-tag color="red">已驳回</a-tag></span>
<strong>{{ stats?.manual_rejected_count ?? 0 }}</strong>
</div>
<div class="metric-row">
<span>确认记录</span>
<strong>{{ stats?.ack_count ?? 0 }}</strong>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { computed, onMounted, ref } from 'vue'
import { levelColor, levelLabel, sourceLabel } from '@/lib/compliance-display'
import { complianceApi, type ComplianceStats } from '@/lib/compliance'
import { OpsApiError } from '@/lib/http'
const stats = ref<ComplianceStats | null>(null)
const loading = ref(false)
const levelRows = computed(() => toRows(stats.value?.by_level))
const sourceRows = computed(() => toRows(stats.value?.by_source))
async function loadStats() {
loading.value = true
try {
stats.value = await complianceApi.stats()
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
function toRows(value?: Record<string, number>) {
return Object.entries(value ?? {})
.map(([key, count]) => ({ key, value: count }))
.sort((a, b) => b.value - a.value)
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
}
}
onMounted(() => {
void loadStats()
})
</script>
<style scoped>
.stats-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.stats-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.stats-header__meta {
color: #64748b;
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.stats-grid--wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-card {
border-left: 4px solid #1677ff;
}
.stat-card--red {
border-left-color: #ff4d4f;
}
.stat-card--gold {
border-left-color: #faad14;
}
.stat-card--green {
border-left-color: #52c41a;
}
.section-title {
margin: 0 0 16px;
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.metric-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.metric-row:last-child {
border-bottom: 0;
}
.metric-row strong {
color: #0f172a;
}
@media (max-width: 1100px) {
.stats-grid,
.stats-grid--wide {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.stats-grid,
.stats-grid--wide {
grid-template-columns: 1fr;
}
}
</style>