diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b0f476f..439d60d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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(): diff --git a/backend/app/config.py b/backend/app/config.py index 968fca5..de64240 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index ed69a0f..4b453cc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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, diff --git a/backend/app/routes/content_routes.py b/backend/app/routes/content_routes.py index 01633de..0ceb525 100644 --- a/backend/app/routes/content_routes.py +++ b/backend/app/routes/content_routes.py @@ -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 diff --git a/backend/app/routes/upload_routes.py b/backend/app/routes/upload_routes.py new file mode 100644 index 0000000..9b2db27 --- /dev/null +++ b/backend/app/routes/upload_routes.py @@ -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/") +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) \ No newline at end of file diff --git a/backend/sql/update_appeal_fields.sql b/backend/sql/update_appeal_fields.sql new file mode 100644 index 0000000..f227f78 --- /dev/null +++ b/backend/sql/update_appeal_fields.sql @@ -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; \ No newline at end of file diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index b36da0a..f03214d 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -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; diff --git a/miniprogram/pages/admin-review/index.js b/miniprogram/pages/admin-review/index.js index 0c7784d..4a15119 100644 --- a/miniprogram/pages/admin-review/index.js +++ b/miniprogram/pages/admin-review/index.js @@ -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 + }) } }) diff --git a/miniprogram/pages/admin-review/index.wxml b/miniprogram/pages/admin-review/index.wxml index e0667c7..81c5053 100644 --- a/miniprogram/pages/admin-review/index.wxml +++ b/miniprogram/pages/admin-review/index.wxml @@ -100,9 +100,19 @@ {{item.text}} 申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}} + 申诉理由类型:{{item.appeal_reason_type || '未选择'}} 申诉理由:{{item.appeal_reason || '未填写'}} 时间:{{item.created_text}} + + 证据截图 + + + + + + +