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,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>
|
||||
Reference in New Issue
Block a user