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

@@ -6,6 +6,7 @@ from app.routes.admin_routes import admin_bp
from app.routes.auth_routes import auth_bp
from app.routes.content_routes import content_bp
from app.routes.spam_routes import spam_bp
from app.routes.upload_routes import upload_bp
from app.routes.user_routes import user_bp
@@ -22,6 +23,7 @@ def create_app() -> Flask:
app.register_blueprint(spam_bp, url_prefix="/api/spam")
app.register_blueprint(content_bp, url_prefix="/api/content")
app.register_blueprint(admin_bp, url_prefix="/api/admin")
app.register_blueprint(upload_bp, url_prefix="/api/upload")
@app.get("/api/health")
def health_check():

View File

@@ -35,3 +35,4 @@ class Config:
SPAM_DATASET_PATH = str(BASE_DIR / "seed" / "spam_samples_seed.json")
NB_MODEL_PATH = str(BASE_DIR / "models" / "spam_nb_model.joblib")
UPLOAD_FOLDER = str(BASE_DIR / "uploads")

View File

@@ -141,7 +141,9 @@ class ContentPost(db.Model):
manual_review_at = db.Column(db.DateTime, nullable=True)
appeal_status = db.Column(db.String(16), nullable=False, default="none") # none | pending | approved | rejected
appeal_reason_type = db.Column(db.String(32), default="") # 快捷理由类型
appeal_reason = db.Column(db.String(255), default="")
appeal_evidence_urls = db.Column(db.JSON, default=list) # 证据图片 URL 列表
appeal_admin_note = db.Column(db.String(255), default="")
appeal_submitted_at = db.Column(db.DateTime, nullable=True)
appeal_processed_at = db.Column(db.DateTime, nullable=True)
@@ -170,7 +172,9 @@ class ContentPost(db.Model):
"manual_review_note": self.manual_review_note,
"manual_review_at": self.manual_review_at.isoformat() if self.manual_review_at else None,
"appeal_status": self.appeal_status,
"appeal_reason_type": self.appeal_reason_type,
"appeal_reason": self.appeal_reason,
"appeal_evidence_urls": self.appeal_evidence_urls or [],
"appeal_admin_note": self.appeal_admin_note,
"appeal_submitted_at": self.appeal_submitted_at.isoformat() if self.appeal_submitted_at else None,
"appeal_processed_at": self.appeal_processed_at.isoformat() if self.appeal_processed_at else None,

View File

@@ -295,14 +295,20 @@ def submit_appeal(post_id: int):
return fail("仅被拦截的信息可申诉", 400)
payload = request.get_json(silent=True) or {}
reason_type = (payload.get("reason_type") or "").strip()
reason = (payload.get("reason") or "").strip()
if len(reason) < 2:
evidence_urls = payload.get("evidence_urls") or []
# 如果选择了快捷理由reason 可为空;否则至少 2 字符
if not reason_type and len(reason) < 2:
return fail("申诉理由至少2个字符", 400)
if post.appeal_status == "pending":
return fail("该记录已在申诉处理中", 400)
post.appeal_status = "pending"
post.appeal_reason_type = reason_type
post.appeal_reason = reason
post.appeal_evidence_urls = evidence_urls
post.appeal_submitted_at = datetime.utcnow()
post.appeal_admin_note = ""
post.appeal_processed_at = None

View File

@@ -0,0 +1,68 @@
import os
import uuid
from datetime import datetime
from flask import Blueprint, current_app, request, send_from_directory
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from app.utils.auth import current_user
from app.utils.response import fail, ok
upload_bp = Blueprint("upload", __name__)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webg"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def allowed_file(filename: str) -> bool:
ext = (filename.rsplit(".", 1)[-1] if "." in filename else "").lower()
return ext in ALLOWED_EXTENSIONS
def generate_filename(original: str) -> str:
ext = original.rsplit(".", 1)[-1] if "." in original else "jpg"
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
unique = uuid.uuid4().hex[:8]
return f"{timestamp}_{unique}.{ext.lower()}"
@upload_bp.post("/image")
@jwt_required()
def upload_image():
user = current_user()
if not user:
return fail("用户不存在", 404)
if "file" not in request.files:
return fail("未上传文件", 400)
file = request.files["file"]
if not file.filename:
return fail("文件名无效", 400)
if not allowed_file(file.filename):
return fail("仅支持 png/jpg/jpeg/gif/webg 格式", 400)
upload_folder = current_app.config.get("UPLOAD_FOLDER")
if not upload_folder:
return fail("上传目录未配置", 500)
os.makedirs(upload_folder, exist_ok=True)
filename = generate_filename(secure_filename(file.filename))
filepath = os.path.join(upload_folder, filename)
file.save(filepath)
url = f"/api/upload/images/{filename}"
return ok({"url": url, "filename": filename}, "上传成功")
@upload_bp.get("/images/<filename>")
def get_image(filename: str):
upload_folder = current_app.config.get("UPLOAD_FOLDER")
if not upload_folder:
return fail("上传目录未配置", 500)
return send_from_directory(upload_folder, filename)

View File

@@ -0,0 +1,17 @@
-- 申诉功能增强:新增理由类型和证据图片字段
-- 执行方式mysql -u root -p database_name < update_appeal_fields.sql
-- 新增申诉理由类型字段
ALTER TABLE content_posts
ADD COLUMN appeal_reason_type VARCHAR(32) DEFAULT '' COMMENT '快捷申诉理由类型'
AFTER appeal_status;
-- 新增证据图片URL列表字段
ALTER TABLE content_posts
ADD COLUMN appeal_evidence_urls JSON DEFAULT NULL COMMENT '证据图片URL列表'
AFTER appeal_reason;
-- 如果 MySQL 版本不支持 JSON 类型,使用 TEXT 替代
-- ALTER TABLE content_posts
-- ADD COLUMN appeal_evidence_urls TEXT DEFAULT NULL COMMENT '证据图片URL列表(JSON)'
-- AFTER appeal_reason;

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
}