1
This commit is contained in:
298
miniprogram/pages/admin-review/index.js
Normal file
298
miniprogram/pages/admin-review/index.js
Normal file
@@ -0,0 +1,298 @@
|
||||
const { request } = require('../../utils/request')
|
||||
|
||||
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: '已驳回'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
thresholdInput: '0.75',
|
||||
|
||||
interceptKeyword: '',
|
||||
interceptStatusOptions: INTERCEPT_STATUS_OPTIONS,
|
||||
interceptStatusIndex: 1,
|
||||
interceptReviewStatusOptions: INTERCEPT_REVIEW_STATUS_OPTIONS,
|
||||
interceptReviewStatusIndex: 1,
|
||||
intercepts: [],
|
||||
interceptPager: buildPager(0, 1, 10),
|
||||
|
||||
appealKeyword: '',
|
||||
appealStatusOptions: APPEAL_STATUS_OPTIONS,
|
||||
appealStatusIndex: 0,
|
||||
appeals: [],
|
||||
appealPager: buildPager(0, 1, 10),
|
||||
|
||||
reviewNoteMap: {},
|
||||
appealNoteMap: {}
|
||||
},
|
||||
|
||||
formatPercent(value, digits = 2) {
|
||||
const num = Number(value || 0)
|
||||
return `${(num * 100).toFixed(digits)}%`
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.bootstrap()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.bootstrap(true)
|
||||
},
|
||||
|
||||
async bootstrap(fromPullDown = false) {
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
await Promise.all([this.fetchThreshold(), this.fetchIntercepts(), this.fetchAppeals()])
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
if (fromPullDown) wx.stopPullDownRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
async fetchThreshold() {
|
||||
const data = await request({ url: '/admin/detection/threshold' })
|
||||
this.setData({ 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
|
||||
}))
|
||||
},
|
||||
|
||||
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
|
||||
}))
|
||||
},
|
||||
|
||||
async fetchIntercepts() {
|
||||
const {
|
||||
interceptPager,
|
||||
interceptKeyword,
|
||||
interceptStatusOptions,
|
||||
interceptStatusIndex,
|
||||
interceptReviewStatusOptions,
|
||||
interceptReviewStatusIndex
|
||||
} = this.data
|
||||
|
||||
const status = interceptStatusOptions[interceptStatusIndex].value
|
||||
const reviewStatus = interceptReviewStatusOptions[interceptReviewStatusIndex].value
|
||||
const query = [
|
||||
`keyword=${encodeURIComponent((interceptKeyword || '').trim())}`,
|
||||
`status=${encodeURIComponent(status)}`,
|
||||
`review_status=${encodeURIComponent(reviewStatus)}`,
|
||||
`page=${interceptPager.page}`,
|
||||
`page_size=${interceptPager.pageSize}`
|
||||
].join('&')
|
||||
|
||||
const data = await request({ url: `/admin/intercepts?${query}` })
|
||||
this.setData({
|
||||
intercepts: this.normalizeIntercepts(data.items || []),
|
||||
interceptPager: buildPager(data.total || 0, interceptPager.page, interceptPager.pageSize)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchAppeals() {
|
||||
const { appealPager, appealKeyword, appealStatusOptions, appealStatusIndex } = this.data
|
||||
const status = appealStatusOptions[appealStatusIndex].value
|
||||
const query = [
|
||||
`keyword=${encodeURIComponent((appealKeyword || '').trim())}`,
|
||||
`status=${encodeURIComponent(status)}`,
|
||||
`page=${appealPager.page}`,
|
||||
`page_size=${appealPager.pageSize}`
|
||||
].join('&')
|
||||
|
||||
const data = await request({ url: `/admin/appeals?${query}` })
|
||||
this.setData({
|
||||
appeals: this.normalizeAppeals(data.items || []),
|
||||
appealPager: buildPager(data.total || 0, appealPager.page, appealPager.pageSize)
|
||||
})
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
const field = e.currentTarget.dataset.field
|
||||
this.setData({ [field]: e.detail.value || '' })
|
||||
},
|
||||
|
||||
onInterceptStatusChange(e) {
|
||||
this.setData({ interceptStatusIndex: Number(e.detail.value || 0) })
|
||||
},
|
||||
|
||||
onInterceptReviewStatusChange(e) {
|
||||
this.setData({ interceptReviewStatusIndex: Number(e.detail.value || 0) })
|
||||
},
|
||||
|
||||
onAppealStatusChange(e) {
|
||||
this.setData({ appealStatusIndex: Number(e.detail.value || 0) })
|
||||
},
|
||||
|
||||
applyInterceptFilters() {
|
||||
this.setData({ interceptPager: { ...this.data.interceptPager, page: 1 } })
|
||||
this.fetchIntercepts()
|
||||
},
|
||||
|
||||
clearInterceptFilters() {
|
||||
this.setData({
|
||||
interceptKeyword: '',
|
||||
interceptStatusIndex: 1,
|
||||
interceptReviewStatusIndex: 1,
|
||||
interceptPager: { ...this.data.interceptPager, page: 1 }
|
||||
})
|
||||
this.fetchIntercepts()
|
||||
},
|
||||
|
||||
applyAppealFilters() {
|
||||
this.setData({ appealPager: { ...this.data.appealPager, page: 1 } })
|
||||
this.fetchAppeals()
|
||||
},
|
||||
|
||||
clearAppealFilters() {
|
||||
this.setData({
|
||||
appealKeyword: '',
|
||||
appealStatusIndex: 0,
|
||||
appealPager: { ...this.data.appealPager, page: 1 }
|
||||
})
|
||||
this.fetchAppeals()
|
||||
},
|
||||
|
||||
prevInterceptPage() {
|
||||
if (!this.data.interceptPager.hasPrev) return
|
||||
this.setData({ interceptPager: { ...this.data.interceptPager, page: this.data.interceptPager.page - 1 } })
|
||||
this.fetchIntercepts()
|
||||
},
|
||||
|
||||
nextInterceptPage() {
|
||||
if (!this.data.interceptPager.hasNext) return
|
||||
this.setData({ interceptPager: { ...this.data.interceptPager, page: this.data.interceptPager.page + 1 } })
|
||||
this.fetchIntercepts()
|
||||
},
|
||||
|
||||
prevAppealPage() {
|
||||
if (!this.data.appealPager.hasPrev) return
|
||||
this.setData({ appealPager: { ...this.data.appealPager, page: this.data.appealPager.page - 1 } })
|
||||
this.fetchAppeals()
|
||||
},
|
||||
|
||||
nextAppealPage() {
|
||||
if (!this.data.appealPager.hasNext) return
|
||||
this.setData({ appealPager: { ...this.data.appealPager, page: this.data.appealPager.page + 1 } })
|
||||
this.fetchAppeals()
|
||||
},
|
||||
|
||||
onReviewNoteInput(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
this.setData({ [`reviewNoteMap.${id}`]: e.detail.value || '' })
|
||||
},
|
||||
|
||||
onAppealNoteInput(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
this.setData({ [`appealNoteMap.${id}`]: e.detail.value || '' })
|
||||
},
|
||||
|
||||
async saveThreshold() {
|
||||
const value = Number(this.data.thresholdInput)
|
||||
if (Number.isNaN(value) || value <= 0 || value >= 1) {
|
||||
wx.showToast({ title: '阈值需在 0 到 1 之间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
await request({
|
||||
url: '/admin/detection/threshold',
|
||||
method: 'PUT',
|
||||
data: { spam_threshold: value }
|
||||
})
|
||||
|
||||
wx.showToast({ title: '阈值更新成功', icon: 'success' })
|
||||
this.fetchThreshold()
|
||||
},
|
||||
|
||||
async reviewIntercept(e) {
|
||||
const id = Number(e.currentTarget.dataset.id)
|
||||
const decision = e.currentTarget.dataset.decision
|
||||
const note = (this.data.reviewNoteMap[id] || '').trim()
|
||||
|
||||
await request({
|
||||
url: `/admin/intercepts/${id}/review`,
|
||||
method: 'PUT',
|
||||
data: {
|
||||
decision,
|
||||
note: note || (decision === 'spam' ? '人工复核确认为垃圾信息' : '人工复核后解除拦截')
|
||||
}
|
||||
})
|
||||
|
||||
wx.showToast({ title: '复核完成', icon: 'success' })
|
||||
this.setData({ [`reviewNoteMap.${id}`]: '' })
|
||||
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
|
||||
},
|
||||
|
||||
async processAppeal(e) {
|
||||
const id = Number(e.currentTarget.dataset.id)
|
||||
const action = e.currentTarget.dataset.action
|
||||
const note = (this.data.appealNoteMap[id] || '').trim()
|
||||
|
||||
await request({
|
||||
url: `/admin/appeals/${id}/process`,
|
||||
method: 'PUT',
|
||||
data: {
|
||||
action,
|
||||
note: note || (action === 'approve' ? '申诉通过,解除拦截' : '申诉驳回,维持拦截')
|
||||
}
|
||||
})
|
||||
|
||||
wx.showToast({ title: '申诉处理完成', icon: 'success' })
|
||||
this.setData({ [`appealNoteMap.${id}`]: '' })
|
||||
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/admin-review/index.json
Normal file
4
miniprogram/pages/admin-review/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "复核与申诉",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
122
miniprogram/pages/admin-review/index.wxml
Normal file
122
miniprogram/pages/admin-review/index.wxml
Normal file
@@ -0,0 +1,122 @@
|
||||
<view class="container">
|
||||
<view class="hero fade-up">
|
||||
<view class="hero-badge">REVIEW CENTER</view>
|
||||
<view class="hero-title">复核与申诉后台</view>
|
||||
<view class="hero-sub">支持阈值调节、拦截复核、申诉处理与分页筛选,满足商用审核流程。</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-1">
|
||||
<view class="card-title">检测阈值调节</view>
|
||||
<view class="card-desc">阈值越低,拦截越严格。推荐范围:0.65 - 0.85。</view>
|
||||
<view class="row">
|
||||
<text class="label">垃圾阈值(0-1)</text>
|
||||
<input class="input threshold-input" type="digit" value="{{thresholdInput}}" data-field="thresholdInput" bindinput="onInput" />
|
||||
</view>
|
||||
<button class="btn btn-primary" bindtap="saveThreshold">更新阈值</button>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-1">
|
||||
<view class="card-title">拦截记录筛选</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">关键词</text>
|
||||
<input class="input" placeholder="搜索拦截文本关键词" value="{{interceptKeyword}}" data-field="interceptKeyword" bindinput="onInput" />
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">发布状态</text>
|
||||
<picker mode="selector" range="{{interceptStatusOptions}}" range-key="label" value="{{interceptStatusIndex}}" bindchange="onInterceptStatusChange">
|
||||
<view class="picker-value">{{interceptStatusOptions[interceptStatusIndex].label}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">复核状态</text>
|
||||
<picker mode="selector" range="{{interceptReviewStatusOptions}}" range-key="label" value="{{interceptReviewStatusIndex}}" bindchange="onInterceptReviewStatusChange">
|
||||
<view class="picker-value">{{interceptReviewStatusOptions[interceptReviewStatusIndex].label}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-primary" bindtap="applyInterceptFilters">查询</button>
|
||||
<button class="btn btn-ghost" bindtap="clearInterceptFilters">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-2">
|
||||
<view class="card-title">信息拦截人工复核</view>
|
||||
<view class="muted">共 {{interceptPager.total}} 条,第 {{interceptPager.page}} / {{interceptPager.totalPages}} 页</view>
|
||||
|
||||
<view wx:if="{{intercepts.length}}">
|
||||
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
||||
<view class="item-title">{{item.text}}</view>
|
||||
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
|
||||
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
|
||||
<view class="item-sub">发布时间:{{item.created_text}}</view>
|
||||
|
||||
<textarea class="textarea note-textarea" placeholder="可填写复核备注(将写入处理记录)" value="{{reviewNoteMap[item.id] || ''}}" data-id="{{item.id}}" bindinput="onReviewNoteInput" />
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-accent" data-id="{{item.id}}" data-decision="spam" bindtap="reviewIntercept">确认垃圾</button>
|
||||
<button class="btn btn-ghost" data-id="{{item.id}}" data-decision="ham" bindtap="reviewIntercept">误判放行</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pager-row">
|
||||
<button class="btn btn-ghost pager-btn" disabled="{{!interceptPager.hasPrev}}" bindtap="prevInterceptPage">上一页</button>
|
||||
<button class="btn btn-ghost pager-btn" disabled="{{!interceptPager.hasNext}}" bindtap="nextInterceptPage">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty">没有匹配的拦截记录。</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-2">
|
||||
<view class="card-title">申诉记录筛选</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">关键词</text>
|
||||
<input class="input" placeholder="搜索申诉文本或申诉理由" value="{{appealKeyword}}" data-field="appealKeyword" bindinput="onInput" />
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">申诉状态</text>
|
||||
<picker mode="selector" range="{{appealStatusOptions}}" range-key="label" value="{{appealStatusIndex}}" bindchange="onAppealStatusChange">
|
||||
<view class="picker-value">{{appealStatusOptions[appealStatusIndex].label}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-primary" bindtap="applyAppealFilters">查询</button>
|
||||
<button class="btn btn-ghost" bindtap="clearAppealFilters">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-3">
|
||||
<view class="card-title">拦截信息申诉处理</view>
|
||||
<view class="muted">共 {{appealPager.total}} 条,第 {{appealPager.page}} / {{appealPager.totalPages}} 页</view>
|
||||
|
||||
<view wx:if="{{appeals.length}}">
|
||||
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
|
||||
<view class="item-title">{{item.text}}</view>
|
||||
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
|
||||
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
||||
<view class="item-sub">时间:{{item.created_text}}</view>
|
||||
|
||||
<textarea class="textarea note-textarea" placeholder="可填写申诉处理备注" value="{{appealNoteMap[item.id] || ''}}" data-id="{{item.id}}" bindinput="onAppealNoteInput" />
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-primary" data-id="{{item.id}}" data-action="approve" bindtap="processAppeal">通过申诉</button>
|
||||
<button class="btn btn-ghost" data-id="{{item.id}}" data-action="reject" bindtap="processAppeal">驳回申诉</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pager-row">
|
||||
<button class="btn btn-ghost pager-btn" disabled="{{!appealPager.hasPrev}}" bindtap="prevAppealPage">上一页</button>
|
||||
<button class="btn btn-ghost pager-btn" disabled="{{!appealPager.hasNext}}" bindtap="nextAppealPage">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="empty">没有匹配的申诉记录。</view>
|
||||
</view>
|
||||
</view>
|
||||
23
miniprogram/pages/admin-review/index.wxss
Normal file
23
miniprogram/pages/admin-review/index.wxss
Normal file
@@ -0,0 +1,23 @@
|
||||
/* admin-review styles use global theme */
|
||||
.threshold-input {
|
||||
width: 240rpx;
|
||||
margin-top: 0;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.note-textarea {
|
||||
min-height: 120rpx;
|
||||
background: rgba(12, 23, 38, 0.82);
|
||||
}
|
||||
|
||||
.pager-row {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.pager-btn {
|
||||
flex: 1;
|
||||
}
|
||||
Reference in New Issue
Block a user