feat: 小程序移除管理后台入口,新增admin-web前端项目

将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
刘正航
2026-05-14 13:49:07 +08:00
parent f342fdc9b4
commit 49c946dd55
39 changed files with 10760 additions and 257 deletions

View 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>