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:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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('、') : '全部平台'
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
Reference in New Issue
Block a user