diff --git a/admin/dashboard/blueprints/fun_command_rules.py b/admin/dashboard/blueprints/fun_command_rules.py
index 7fa80eb..9cfa0be 100644
--- a/admin/dashboard/blueprints/fun_command_rules.py
+++ b/admin/dashboard/blueprints/fun_command_rules.py
@@ -1,16 +1,27 @@
# -*- coding: utf-8 -*-
"""趣味指令规则后台蓝图。"""
+import os
+import uuid
from datetime import datetime
from typing import Any, Dict
from flask import Blueprint, current_app, jsonify, render_template, request
+from werkzeug.utils import secure_filename
from .auth import login_required
fun_command_rules_bp = Blueprint("fun_command_rules", __name__, url_prefix="/fun_command_rules")
+# 媒体上传白名单:
+# 这里仅允许趣味指令管理功能需要的媒体类型,避免上传任意可执行文件带来风险。
+ALLOWED_EXTENSIONS = {
+ "image": {"png", "jpg", "jpeg", "gif", "webp"},
+ "voice": {"mp3", "wav", "amr"},
+ "video": {"mp4", "mov", "m4v"},
+}
+
def _normalize_datetime_text(value):
"""统一时间字段展示格式。"""
@@ -70,12 +81,63 @@ def _validate_payload(payload: Dict[str, Any], service) -> str:
return ""
+def _allowed_file(filename: str, media_type: str) -> bool:
+ """判断上传文件扩展名是否允许。"""
+ if "." not in filename:
+ return False
+ ext = filename.rsplit(".", 1)[1].lower()
+ return ext in ALLOWED_EXTENSIONS.get(media_type, set())
+
+
@fun_command_rules_bp.route("/")
@login_required
def page_fun_command_rules():
return render_template("fun_command_rules.html")
+@fun_command_rules_bp.route("/api/upload", methods=["POST"])
+@login_required
+def api_upload_media():
+ """上传媒体文件并返回服务端绝对路径。
+
+ 设计说明:
+ 1. 使用独立目录 static/uploads/fun_command_rules,与其它业务上传隔离。
+ 2. 返回绝对路径,直接可用于插件发送媒体消息。
+ """
+ if "file" not in request.files:
+ return jsonify({"success": False, "message": "未检测到上传文件"}), 400
+
+ media_type = str(request.form.get("media_type", "") or "").strip().lower()
+ if media_type not in ALLOWED_EXTENSIONS:
+ return jsonify({"success": False, "message": "media_type 非法,仅支持 image/voice/video"}), 400
+
+ upload_file = request.files.get("file")
+ if not upload_file or not upload_file.filename:
+ return jsonify({"success": False, "message": "文件名为空"}), 400
+
+ filename = secure_filename(upload_file.filename)
+ if not _allowed_file(filename, media_type):
+ return jsonify({"success": False, "message": f"不支持的文件类型: {filename}"}), 400
+
+ # 按媒体类型分目录,便于后续清理和排查。
+ upload_root = os.path.join(current_app.root_path, "static", "uploads", "fun_command_rules", media_type)
+ os.makedirs(upload_root, exist_ok=True)
+
+ unique_filename = f"{uuid.uuid4().hex}_{filename}"
+ abs_path = os.path.abspath(os.path.join(upload_root, unique_filename))
+ upload_file.save(abs_path)
+
+ return jsonify({
+ "success": True,
+ "message": "上传成功",
+ "data": {
+ "path": abs_path,
+ "filename": unique_filename,
+ "media_type": media_type,
+ }
+ })
+
+
@fun_command_rules_bp.route("/api/list", methods=["GET"])
@login_required
def api_list_rules():
diff --git a/admin/dashboard/templates/fun_command_rules.html b/admin/dashboard/templates/fun_command_rules.html
index c958a4e..ae72521 100644
--- a/admin/dashboard/templates/fun_command_rules.html
+++ b/admin/dashboard/templates/fun_command_rules.html
@@ -200,6 +200,14 @@
+
+
+ 上传图片并回填
+
+
@@ -209,11 +217,27 @@
+
+
+ 上传语音并回填
+
+
+
+
+ 上传视频并回填
+
+
@@ -429,6 +453,38 @@ new Vue({
return row
})
},
+ async uploadActionFile(uploadOptions, row, mediaType) {
+ // 统一处理媒体上传:
+ // 1. 使用后端专用接口保存到固定目录;
+ // 2. 上传成功后自动把绝对路径回填到当前动作;
+ // 3. 这样配置者不需要手动复制路径,维护成本更低。
+ const file = uploadOptions && uploadOptions.file
+ if (!file) {
+ this.$message.error('未获取到上传文件')
+ return
+ }
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('media_type', mediaType)
+
+ try {
+ const resp = await axios.post('/fun_command_rules/api/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ })
+ if (resp.data && resp.data.success) {
+ const path = (((resp.data || {}).data || {}).path) || ''
+ if (path) {
+ row.path = path
+ }
+ this.$message.success(resp.data.message || '上传成功')
+ return
+ }
+ this.$message.error((resp.data && resp.data.message) || '上传失败')
+ } catch (error) {
+ const msg = (error.response && error.response.data && error.response.data.message) || '上传失败'
+ this.$message.error(msg)
+ }
+ },
async loadGroups() {
const resp = await axios.get('/contacts/api/groups')
const groups = (resp.data && resp.data.data && resp.data.data.groups) || {}
@@ -592,6 +648,7 @@ new Vue({
.trigger-text{font-size:13px;color:#334155;word-break:break-all}
.action-toolbar{display:flex;gap:8px;margin-bottom:8px}
.action-config{display:flex;flex-direction:column;gap:0}
+.upload-row{display:flex;align-items:center}
.payload-tip{margin-top:8px;color:#64748b;font-size:12px;line-height:1.7}
.payload-tip code{background:#f1f5f9;border:1px solid #dbe3ee;padding:1px 6px;border-radius:6px;margin-right:6px}