Files
c/admin-web/src/views/admin/Review.vue
刘正航 49c946dd55 feat: 小程序移除管理后台入口,新增admin-web前端项目
将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:49:07 +08:00

354 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">REVIEW CENTER</div>
<h1 class="hero-title">复核与申诉后台</h1>
<p class="hero-sub">支持阈值调节拦截复核申诉处理与分页筛选满足商用审核流程</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">检测阈值调节</div>
<div class="card-desc">阈值越低拦截越严格推荐范围0.65 - 0.85</div>
<div class="row">
<span class="label">垃圾阈值0-1</span>
<input class="input threshold-input" type="number" step="0.01" v-model="thresholdInput" />
</div>
<button class="btn btn-primary" @click="saveThreshold">更新阈值</button>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">拦截记录筛选</div>
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="interceptKeyword" placeholder="搜索拦截文本关键词" />
</div>
<div class="grid-2">
<div class="field">
<label class="field-label">发布状态</label>
<select class="select" v-model="interceptStatus">
<option v-for="opt in interceptStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="field">
<label class="field-label">复核状态</label>
<select class="select" v-model="interceptReviewStatus">
<option v-for="opt in interceptReviewStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="applyInterceptFilters">查询</button>
<button class="btn btn-ghost" @click="clearInterceptFilters">重置</button>
</div>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">信息拦截人工复核</div>
<div class="muted"> {{ interceptPager.total }} {{ interceptPager.page }} / {{ interceptPager.totalPages }} </div>
<div v-if="intercepts.length">
<div class="list-item" v-for="item in intercepts" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">用户{{ item.nickname || item.username }} · 垃圾概率{{ item.spam_probability_text }}</div>
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
<div class="item-sub">复核状态{{ item.review_status_text }} · 申诉状态{{ item.appeal_status_text }}</div>
<div class="item-sub">发布时间{{ item.created_text }}</div>
<textarea
class="textarea note-textarea"
v-model="reviewNoteMap[item.id]"
placeholder="可填写复核备注(将写入处理记录)"
></textarea>
<div class="btn-row">
<button class="btn btn-accent" @click="reviewIntercept(item.id, 'spam')">确认垃圾</button>
<button class="btn btn-ghost" @click="reviewIntercept(item.id, 'ham')">误判放行</button>
</div>
</div>
<div class="pager-row">
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasPrev" @click="changeInterceptPage(-1)">上一页</button>
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasNext" @click="changeInterceptPage(1)">下一页</button>
</div>
</div>
<div v-else class="empty">没有匹配的拦截记录</div>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">申诉记录筛选</div>
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="appealKeyword" placeholder="搜索申诉文本或申诉理由" />
</div>
<div class="field">
<label class="field-label">申诉状态</label>
<select class="select" v-model="appealStatus">
<option v-for="opt in appealStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="applyAppealFilters">查询</button>
<button class="btn btn-ghost" @click="clearAppealFilters">重置</button>
</div>
</section>
<section class="card fade-up fade-up-delay-3">
<div class="card-title">拦截信息申诉处理</div>
<div class="muted"> {{ appealPager.total }} {{ appealPager.page }} / {{ appealPager.totalPages }} </div>
<div v-if="appeals.length">
<div class="list-item" v-for="item in appeals" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">申诉人{{ item.nickname || item.username }} · 当前状态{{ item.appeal_status_text }}</div>
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
<div class="item-sub">申诉理由类型{{ item.appeal_reason_type || '未选择' }}</div>
<div class="item-sub">申诉理由{{ item.appeal_reason || '未填写' }}</div>
<div class="item-sub">时间{{ item.created_text }}</div>
<div class="field" v-if="item.appeal_evidence_urls && item.appeal_evidence_urls.length">
<span class="field-label">证据截图</span>
<div class="evidence-grid">
<div
class="evidence-item"
v-for="url in item.appeal_evidence_urls"
:key="url"
>
<img
class="evidence-thumb evidence-clickable"
:src="url"
@click="previewEvidence(item.appeal_evidence_urls, url)"
alt="evidence"
/>
</div>
</div>
</div>
<textarea
class="textarea note-textarea"
v-model="appealNoteMap[item.id]"
placeholder="可填写申诉处理备注"
></textarea>
<div class="btn-row">
<button class="btn btn-primary" @click="processAppeal(item.id, 'approve')">通过申诉</button>
<button class="btn btn-ghost" @click="processAppeal(item.id, 'reject')">驳回申诉</button>
</div>
</div>
<div class="pager-row">
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasPrev" @click="changeAppealPage(-1)">上一页</button>
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasNext" @click="changeAppealPage(1)">下一页</button>
</div>
</div>
<div v-else class="empty">没有匹配的申诉记录</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, previewImage } from '@/utils/feedback'
const INTERCEPT_STATUS_OPTIONS = [
{ value: '', label: '全部发布状态' },
{ value: 'blocked', label: '已拦截' },
{ value: 'published', label: '已发布' }
]
const INTERCEPT_REVIEW_STATUS_OPTIONS = [
{ value: '', label: '全部复核状态' },
{ value: 'pending', label: '待复核' },
{ value: 'confirmed_spam', label: '确认垃圾' },
{ value: 'approved_ham', label: '误判放行' },
{ value: 'none', label: '未进入复核' }
]
const APPEAL_STATUS_OPTIONS = [
{ value: 'pending', label: '待处理申诉' },
{ value: '', label: '全部申诉' },
{ value: 'approved', label: '已通过申诉' },
{ value: 'rejected', label: '已驳回申诉' }
]
const REVIEW_STATUS_TEXT = { none: '无', pending: '待复核', confirmed_spam: '确认垃圾', approved_ham: '误判放行' }
const APPEAL_STATUS_TEXT = { none: '无', pending: '待处理', approved: '已通过', rejected: '已驳回' }
const CATEGORY_LABELS = { fraud: '疑似诈骗', harassment: '疑似骚扰', advertisement: '疑似广告', spam: '疑似垃圾' }
function buildPager(total, page, pageSize) {
const totalValue = Number(total || 0)
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
return {
page,
pageSize,
total: totalValue,
totalPages,
hasPrev: page > 1,
hasNext: page < totalPages
}
}
export default {
name: 'AdminReview',
data() {
return {
thresholdInput: '0.75',
interceptKeyword: '',
interceptStatus: 'blocked',
interceptReviewStatus: 'pending',
interceptStatusOptions: INTERCEPT_STATUS_OPTIONS,
interceptReviewStatusOptions: INTERCEPT_REVIEW_STATUS_OPTIONS,
intercepts: [],
interceptPager: buildPager(0, 1, 10),
appealKeyword: '',
appealStatus: 'pending',
appealStatusOptions: APPEAL_STATUS_OPTIONS,
appeals: [],
appealPager: buildPager(0, 1, 10),
reviewNoteMap: {},
appealNoteMap: {}
}
},
mounted() {
this.bootstrap()
},
methods: {
formatPercent(value, digits = 2) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
},
async bootstrap() {
await Promise.all([this.fetchThreshold(), this.fetchIntercepts(), this.fetchAppeals()])
},
async fetchThreshold() {
const data = await request({ url: '/admin/detection/threshold' })
this.thresholdInput = String(data.spam_threshold || 0.75)
},
normalizeIntercepts(rows = []) {
return rows.map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
spam_probability_text: this.formatPercent(item.spam_probability, 2),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || ''
}))
},
normalizeAppeals(rows = []) {
return rows.map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || '',
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
url.startsWith('http') ? url : url
)
}))
},
async fetchIntercepts() {
const data = await request({
url: '/admin/intercepts',
params: {
keyword: (this.interceptKeyword || '').trim(),
status: this.interceptStatus,
review_status: this.interceptReviewStatus,
page: this.interceptPager.page,
page_size: this.interceptPager.pageSize
}
})
this.intercepts = this.normalizeIntercepts(data.items || [])
this.interceptPager = buildPager(data.total || 0, this.interceptPager.page, this.interceptPager.pageSize)
},
async fetchAppeals() {
const data = await request({
url: '/admin/appeals',
params: {
keyword: (this.appealKeyword || '').trim(),
status: this.appealStatus,
page: this.appealPager.page,
page_size: this.appealPager.pageSize
}
})
this.appeals = this.normalizeAppeals(data.items || [])
this.appealPager = buildPager(data.total || 0, this.appealPager.page, this.appealPager.pageSize)
},
applyInterceptFilters() {
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
this.fetchIntercepts()
},
clearInterceptFilters() {
this.interceptKeyword = ''
this.interceptStatus = 'blocked'
this.interceptReviewStatus = 'pending'
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
this.fetchIntercepts()
},
changeInterceptPage(delta) {
const next = this.interceptPager.page + delta
if (next < 1 || next > this.interceptPager.totalPages) return
this.interceptPager = { ...this.interceptPager, page: next }
this.fetchIntercepts()
},
applyAppealFilters() {
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
this.fetchAppeals()
},
clearAppealFilters() {
this.appealKeyword = ''
this.appealStatus = 'pending'
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
this.fetchAppeals()
},
changeAppealPage(delta) {
const next = this.appealPager.page + delta
if (next < 1 || next > this.appealPager.totalPages) return
this.appealPager = { ...this.appealPager, page: next }
this.fetchAppeals()
},
async saveThreshold() {
const value = Number(this.thresholdInput)
if (Number.isNaN(value) || value <= 0 || value >= 1) {
toast('阈值需在 0 到 1 之间', 'error')
return
}
await request({ url: '/admin/detection/threshold', method: 'PUT', data: { spam_threshold: value } })
toast('阈值更新成功', 'success')
this.fetchThreshold()
},
async reviewIntercept(id, decision) {
const note = (this.reviewNoteMap[id] || '').trim()
await request({
url: `/admin/intercepts/${id}/review`,
method: 'PUT',
data: {
decision,
note: note || (decision === 'spam' ? '人工复核确认为垃圾信息' : '人工复核后解除拦截')
}
})
toast('复核完成', 'success')
this.$set(this.reviewNoteMap, id, '')
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
async processAppeal(id, action) {
const note = (this.appealNoteMap[id] || '').trim()
await request({
url: `/admin/appeals/${id}/process`,
method: 'PUT',
data: {
action,
note: note || (action === 'approve' ? '申诉通过,解除拦截' : '申诉驳回,维持拦截')
}
})
toast('申诉处理完成', 'success')
this.$set(this.appealNoteMap, id, '')
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
previewEvidence(urls, current) {
previewImage(urls, current)
}
}
}
</script>