aa96143754
Apply repo-wide Prettier/lint normalization across admin-web, desktop-client and ops-web: single quotes, no semicolons, trailing commas, consistent line wrapping, and import ordering. Also drop an unused brand-logo import in DesktopShell.vue. No behavior changes — formatting only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
282 lines
8.4 KiB
Vue
282 lines
8.4 KiB
Vue
<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 { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
|
|
import type { ComplianceManualReview, ComplianceViolation } from '@geo/shared-types'
|
|
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 { complianceApi } from '@/lib/compliance'
|
|
import {
|
|
levelColor,
|
|
levelLabel,
|
|
platformLabel,
|
|
platformListLabel,
|
|
reviewStatusColor,
|
|
reviewStatusLabel,
|
|
sourceLabel,
|
|
} from '@/lib/compliance-display'
|
|
import { showOpsError } from '@/lib/errors'
|
|
|
|
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) {
|
|
showOpsError(error)
|
|
}
|
|
|
|
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>
|