feat: 申诉常见理由快捷选择 + 证据截图上传

- 后端: 新增 appeal_reason_type, appeal_evidence_urls 字段
- 后端: 新建 upload_routes.py 图片上传接口
- 前端: history 页面添加快捷理由选择器 + 截图上传
- 前端: admin-review 页面展示证据图片 + 点击预览
- 新增 SQL 更新脚本 update_appeal_fields.sql

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
刘正航
2026-04-21 23:26:25 +08:00
parent 50440e84fb
commit f7d0601c4e
12 changed files with 344 additions and 13 deletions

View File

@@ -399,6 +399,67 @@ button.btn::after {
color: #ffdce1;
}
.evidence-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 12rpx;
}
.evidence-item {
position: relative;
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
overflow: hidden;
border: 1rpx solid rgba(139, 177, 223, 0.3);
}
.evidence-thumb {
width: 100%;
height: 100%;
display: block;
}
.evidence-clickable {
cursor: pointer;
transition: opacity 0.2s;
}
.evidence-clickable:active {
opacity: 0.8;
}
.evidence-remove {
position: absolute;
top: -4rpx;
right: -4rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: rgba(255, 91, 111, 0.9);
color: #fff;
font-size: 24rpx;
text-align: center;
line-height: 32rpx;
}
.evidence-add {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
border: 1rpx dashed rgba(139, 177, 223, 0.5);
background: rgba(17, 35, 58, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.evidence-add-icon {
font-size: 48rpx;
color: var(--sub);
}
.chip-group {
display: flex;
flex-wrap: wrap;

View File

@@ -110,10 +110,15 @@ Page({
},
normalizeAppeals(rows = []) {
const baseURL = getApp().globalData.baseURL || 'http://127.0.0.1:5000/api'
const serverBase = baseURL.replace('/api', '')
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
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
url.startsWith('http') ? url : `${serverBase}${url}`
)
}))
},
@@ -155,8 +160,10 @@ Page({
].join('&')
const data = await request({ url: `/admin/appeals?${query}` })
const normalizedAppeals = this.normalizeAppeals(data.items || [])
console.log('normalized appeals:', normalizedAppeals)
this.setData({
appeals: this.normalizeAppeals(data.items || []),
appeals: normalizedAppeals,
appealPager: buildPager(data.total || 0, appealPager.page, appealPager.pageSize)
})
},
@@ -294,5 +301,15 @@ Page({
wx.showToast({ title: '申诉处理完成', icon: 'success' })
this.setData({ [`appealNoteMap.${id}`]: '' })
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
previewEvidence(e) {
const url = e.currentTarget.dataset.url
const item = this.data.appeals.find((a) => a.appeal_evidence_urls && a.appeal_evidence_urls.includes(url))
const urls = item ? item.appeal_evidence_urls : [url]
wx.previewImage({
current: url,
urls
})
}
})

View File

@@ -100,9 +100,19 @@
<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_type || '未选择'}}</view>
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
<view class="item-sub">时间:{{item.created_text}}</view>
<view class="field" wx:if="{{item.appeal_evidence_urls && item.appeal_evidence_urls.length}}">
<text class="field-label">证据截图</text>
<view class="evidence-grid">
<view class="evidence-item" wx:for="{{item.appeal_evidence_urls}}" wx:for-item="evidenceUrl" wx:key="*this">
<image class="evidence-thumb evidence-clickable" src="{{evidenceUrl}}" mode="aspectFill" data-url="{{evidenceUrl}}" bindtap="previewEvidence" />
</view>
</view>
</view>
<textarea class="textarea note-textarea" placeholder="可填写申诉处理备注" value="{{appealNoteMap[item.id] || ''}}" data-id="{{item.id}}" bindinput="onAppealNoteInput" />
<view class="btn-row">

View File

@@ -1,4 +1,4 @@
const { request } = require('../../utils/request')
const { request, uploadFile } = require('../../utils/request')
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
@@ -26,6 +26,14 @@ const APPEAL_STATUS_TEXT = {
rejected: '已驳回'
}
const REASON_TYPE_OPTIONS = [
{ value: '', label: '请选择申诉理由类型' },
{ value: '正常活动文案', label: '正常活动文案' },
{ value: '正常社区通知', label: '正常社区通知' },
{ value: '私信沟通内容', label: '私信沟通内容' },
{ value: '其他', label: '其他(需手动填写)' }
]
Page({
data: {
loading: false,
@@ -35,7 +43,12 @@ Page({
visibilityOptions: VISIBILITY_OPTIONS,
visibilityIndex: 0,
appealPostId: null,
appealReason: ''
appealReasonType: '',
appealReasonTypeOptions: REASON_TYPE_OPTIONS,
appealReasonTypeIndex: 0,
appealReason: '',
appealEvidenceUrls: [],
appealEvidenceFiles: []
},
formatPercent(value, digits = 2) {
@@ -84,33 +97,112 @@ Page({
startAppeal(e) {
const postId = Number(e.currentTarget.dataset.id)
this.setData({ appealPostId: postId, appealReason: '' })
this.setData({
appealPostId: postId,
appealReasonType: '',
appealReasonTypeIndex: 0,
appealReason: '',
appealEvidenceUrls: [],
appealEvidenceFiles: []
})
},
onReasonTypeChange(e) {
const idx = Number(e.detail.value || 0)
const reasonType = this.data.appealReasonTypeOptions[idx].value
this.setData({ appealReasonTypeIndex: idx, appealReasonType: reasonType })
// 如果选择"其他",清空快捷理由,让用户手动输入
if (reasonType === '其他') {
this.setData({ appealReasonType: '' })
}
},
onAppealInput(e) {
this.setData({ appealReason: e.detail.value || '' })
},
chooseEvidence() {
wx.chooseMedia({
count: 3,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const files = res.tempFiles.map((f) => f.tempFilePath)
this.setData({
appealEvidenceFiles: [...this.data.appealEvidenceFiles, ...files].slice(0, 3)
})
}
})
},
removeEvidence(e) {
const idx = Number(e.currentTarget.dataset.index || 0)
const files = this.data.appealEvidenceFiles.filter((_, i) => i !== idx)
const urls = this.data.appealEvidenceUrls.filter((_, i) => i !== idx)
this.setData({ appealEvidenceFiles: files, appealEvidenceUrls: urls })
},
async uploadAllEvidence() {
const files = this.data.appealEvidenceFiles
if (!files.length) return []
const urls = []
for (const filePath of files) {
try {
const result = await uploadFile(filePath)
urls.push(result.url)
} catch (err) {
console.error('上传失败', filePath, err)
}
}
return urls
},
cancelAppeal() {
this.setData({ appealPostId: null, appealReason: '' })
this.setData({
appealPostId: null,
appealReasonType: '',
appealReasonTypeIndex: 0,
appealReason: '',
appealEvidenceUrls: [],
appealEvidenceFiles: []
})
},
async submitAppeal() {
const postId = this.data.appealPostId
if (!postId) return
const reasonType = (this.data.appealReasonType || '').trim()
const reason = (this.data.appealReason || '').trim()
if (reason.length < 2) {
// 如果没有选择快捷理由类型,必须手动填写理由
if (!reasonType && reason.length < 2) {
wx.showToast({ title: '申诉理由至少 2 个字符', icon: 'none' })
return
}
// 上传证据图片
const evidenceUrls = await this.uploadAllEvidence()
await request({
url: `/content/posts/${postId}/appeal`,
method: 'POST',
data: { reason }
data: {
reason_type: reasonType,
reason,
evidence_urls: evidenceUrls
}
})
wx.showToast({ title: '申诉提交成功', icon: 'success' })
this.setData({ appealPostId: null, appealReason: '' })
this.setData({
appealPostId: null,
appealReasonType: '',
appealReasonTypeIndex: 0,
appealReason: '',
appealEvidenceUrls: [],
appealEvidenceFiles: []
})
this.fetchList()
},

View File

@@ -61,7 +61,25 @@
</view>
<view wx:if="{{appealPostId === item.id}}">
<textarea class="textarea" placeholder="请输入申诉理由(至少 2 个字符)" value="{{appealReason}}" bindinput="onAppealInput" />
<view class="field">
<text class="field-label">申诉理由类型</text>
<picker mode="selector" range="{{appealReasonTypeOptions}}" range-key="label" value="{{appealReasonTypeIndex}}" bindchange="onReasonTypeChange">
<view class="picker-value">{{appealReasonTypeOptions[appealReasonTypeIndex].label}}</view>
</picker>
</view>
<textarea class="textarea" placeholder="可补充申诉理由(选择快捷理由后可省略)" value="{{appealReason}}" bindinput="onAppealInput" />
<view class="field">
<text class="field-label">证据截图最多3张</text>
<view class="evidence-grid">
<view class="evidence-item" wx:for="{{appealEvidenceFiles}}" wx:key="index">
<image class="evidence-thumb" src="{{item}}" mode="aspectFill" />
<view class="evidence-remove" data-index="{{index}}" bindtap="removeEvidence">×</view>
</view>
<view class="evidence-add" wx:if="{{appealEvidenceFiles.length < 3}}" bindtap="chooseEvidence">
<text class="evidence-add-icon">+</text>
</view>
</view>
</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="submitAppeal">提交申诉</button>
<button class="btn btn-ghost" bindtap="cancelAppeal">取消</button>

View File

@@ -4,6 +4,11 @@ function getBaseURL() {
return app.globalData.baseURL || 'http://127.0.0.1:5000/api'
}
function getServerBase() {
const baseURL = getBaseURL()
return baseURL.replace('/api', '')
}
function getToken() {
return app.globalData.token || wx.getStorageSync('token') || ''
}
@@ -53,6 +58,36 @@ function request({ url, method = 'GET', data = {}, header = {} }) {
})
}
module.exports = {
request
function uploadFile(filePath) {
return new Promise((resolve, reject) => {
const token = getToken()
wx.uploadFile({
url: `${getServerBase()}/api/upload/image`,
filePath,
name: 'file',
header: {
Authorization: token ? `Bearer ${token}` : ''
},
success: (res) => {
const data = JSON.parse(res.data || '{}')
if (data.code === 0) {
resolve(data.data)
return
}
const message = data.message || '上传失败'
wx.showToast({ title: message, icon: 'none' })
reject(new Error(message))
},
fail: (err) => {
const msg = (err && err.errMsg) || '上传失败'
wx.showToast({ title: msg, icon: 'none' })
reject(err)
}
})
})
}
module.exports = {
request,
uploadFile
}