Files
geo/apps/ops-web/src/views/compliance/CompliancePolicyView.vue
T
root 745cdd79cf 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>
2026-05-05 20:48:14 +08:00

325 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>