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:
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
68
backend/app/routes/upload_routes.py
Normal file
68
backend/app/routes/upload_routes.py
Normal 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)
|
||||
17
backend/sql/update_appeal_fields.sql
Normal file
17
backend/sql/update_appeal_fields.sql
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user