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