745cdd79cf
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>
325 lines
8.4 KiB
Vue
325 lines
8.4 KiB
Vue
<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>
|