Files
geo/apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue
T
root aa96143754 style: format web apps with prettier and sort imports
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>
2026-06-08 11:56:18 +08:00

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>