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,279 @@
|
||||
<template>
|
||||
<div class="manual-review-detail-page">
|
||||
<div class="manual-review-detail-header">
|
||||
<div>
|
||||
<h2 class="ops-page-title">审阅详情 #{{ reviewId }}</h2>
|
||||
<div v-if="review" class="manual-review-detail-header__meta">
|
||||
<a-tag :color="reviewStatusColor(review.status)">
|
||||
{{ reviewStatusLabel(review.status) }}
|
||||
</a-tag>
|
||||
<span>租户 {{ review.tenant_name || `#${review.tenant_id}` }}</span>
|
||||
<span>版本 #{{ review.article_version_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="router.push('/compliance/manual-reviews')">返回列表</a-button>
|
||||
<a-button :loading="loading" @click="loadReview">刷新</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="review" class="manual-review-detail-grid">
|
||||
<div class="ops-card article-panel">
|
||||
<h3 class="section-title">{{ review.article_title || `文章 #${review.article_id}` }}</h3>
|
||||
<div class="article-meta">
|
||||
<span>申请人 {{ review.applicant_name || `#${review.requested_by}` }}</span>
|
||||
<span>申请时间 {{ formatDate(review.requested_at) }}</span>
|
||||
<span>目标平台 {{ platformListLabel(review.original_target_platforms) }}</span>
|
||||
</div>
|
||||
<div class="plaintext-box">
|
||||
{{ review.article_plaintext || '暂无正文快照' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-card decision-panel">
|
||||
<h3 class="section-title">审阅决策</h3>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="申请说明">
|
||||
{{ review.request_note || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="review.decision_reason" label="决策理由">
|
||||
{{ review.decision_reason }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="review.cancel_reason" label="撤回理由">
|
||||
{{ review.cancel_reason }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<template v-if="review.status === 'pending'">
|
||||
<a-form class="decision-form" layout="vertical">
|
||||
<a-form-item label="决策理由" required>
|
||||
<a-textarea v-model:value="decisionReason" :rows="5" placeholder="填写通过或驳回原因" />
|
||||
</a-form-item>
|
||||
<a-space class="decision-actions">
|
||||
<a-popconfirm
|
||||
title="确认通过该版本?通过后该 article_version 将永久绕过自动检测。"
|
||||
:ok-button-props="{ loading: deciding, type: 'primary' }"
|
||||
@confirm="decide(true)"
|
||||
>
|
||||
<a-button type="primary" :disabled="deciding">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
通过
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
title="确认驳回该版本?驳回后该 article_version 将不可发布。"
|
||||
:ok-button-props="{ danger: true, loading: deciding }"
|
||||
@confirm="decide(false)"
|
||||
>
|
||||
<a-button danger :disabled="deciding">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
驳回
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</template>
|
||||
<a-alert
|
||||
v-else
|
||||
class="terminal-alert"
|
||||
type="info"
|
||||
show-icon
|
||||
message="该审阅已进入终态,不能再次决策。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="review" class="ops-card violations-card">
|
||||
<h3 class="section-title">命中证据</h3>
|
||||
<a-table
|
||||
:columns="violationColumns"
|
||||
:data-source="review.violation_summary"
|
||||
row-key="id"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'matched_text'">
|
||||
<strong>{{ record.matched_text }}</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'level'">
|
||||
<a-tag :color="levelColor(record.level)">{{ levelLabel(record.level) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
{{ sourceLabel(record.source) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'platform'">
|
||||
{{ platformLabel(record.platform_code) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dictionary'">
|
||||
{{ record.dictionary_name || record.dictionary_code || '—' }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComplianceManualReview, ComplianceViolation } from '@geo/shared-types'
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
levelColor,
|
||||
levelLabel,
|
||||
platformLabel,
|
||||
platformListLabel,
|
||||
reviewStatusColor,
|
||||
reviewStatusLabel,
|
||||
sourceLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import { OpsApiError } from '@/lib/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const reviewId = computed(() => Number(route.params.id))
|
||||
const review = ref<ComplianceManualReview | null>(null)
|
||||
const loading = ref(false)
|
||||
const deciding = ref(false)
|
||||
const decisionReason = ref('')
|
||||
|
||||
const violationColumns: TableColumnsType<ComplianceViolation> = [
|
||||
{ title: '命中文本', key: 'matched_text' },
|
||||
{ title: '等级', key: 'level', width: 110 },
|
||||
{ title: '来源', key: 'source', width: 110 },
|
||||
{ title: '平台', key: 'platform', width: 140 },
|
||||
{ title: '词库', key: 'dictionary', width: 180 },
|
||||
{ title: '提示', dataIndex: 'hint', key: 'hint' },
|
||||
]
|
||||
|
||||
async function loadReview() {
|
||||
if (!Number.isFinite(reviewId.value) || reviewId.value <= 0) {
|
||||
message.error('无效的审阅 ID')
|
||||
await router.replace('/compliance/manual-reviews')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
review.value = await complianceApi.manualReview(reviewId.value)
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(approve: boolean) {
|
||||
const reason = decisionReason.value.trim()
|
||||
if (!reason) {
|
||||
message.warning('请填写决策理由')
|
||||
return
|
||||
}
|
||||
deciding.value = true
|
||||
try {
|
||||
review.value = approve
|
||||
? await complianceApi.approveManualReview(reviewId.value, reason)
|
||||
: await complianceApi.rejectManualReview(reviewId.value, reason)
|
||||
message.success(approve ? '审阅已通过' : '审阅已驳回')
|
||||
decisionReason.value = ''
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
} finally {
|
||||
deciding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 loadReview()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manual-review-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.manual-review-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.manual-review-detail-header__meta,
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-review-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plaintext-box {
|
||||
min-height: 420px;
|
||||
max-height: 640px;
|
||||
overflow: auto;
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.decision-panel {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.decision-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.decision-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.terminal-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.manual-review-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user