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