feat: 小程序移除管理后台入口,新增admin-web前端项目
将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
353
admin-web/src/views/admin/Review.vue
Normal file
353
admin-web/src/views/admin/Review.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user