响应指令管理支持媒体上传并自动回填路径
1. 新增响应指令管理专用媒体上传接口,按图片语音视频白名单校验并分目录存储。 2. 在动作配置UI中为图片语音视频增加上传按钮,上传成功后自动回填本地绝对路径。 3. 保留结构化动作表单,进一步减少手工维护路径和JSON的场景。
This commit is contained in:
@@ -1,16 +1,27 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""趣味指令规则后台蓝图。"""
|
"""趣味指令规则后台蓝图。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from .auth import login_required
|
from .auth import login_required
|
||||||
|
|
||||||
|
|
||||||
fun_command_rules_bp = Blueprint("fun_command_rules", __name__, url_prefix="/fun_command_rules")
|
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):
|
def _normalize_datetime_text(value):
|
||||||
"""统一时间字段展示格式。"""
|
"""统一时间字段展示格式。"""
|
||||||
@@ -70,12 +81,63 @@ def _validate_payload(payload: Dict[str, Any], service) -> str:
|
|||||||
return ""
|
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("/")
|
@fun_command_rules_bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
def page_fun_command_rules():
|
def page_fun_command_rules():
|
||||||
return render_template("fun_command_rules.html")
|
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"])
|
@fun_command_rules_bp.route("/api/list", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_list_rules():
|
def api_list_rules():
|
||||||
|
|||||||
@@ -200,6 +200,14 @@
|
|||||||
|
|
||||||
<template v-else-if="scope.row.type === 'image'">
|
<template v-else-if="scope.row.type === 'image'">
|
||||||
<el-input v-model="scope.row.path" placeholder="图片文件路径,例如 D:/learn/abot/static/uploads/a.jpg"></el-input>
|
<el-input v-model="scope.row.path" placeholder="图片文件路径,例如 D:/learn/abot/static/uploads/a.jpg"></el-input>
|
||||||
|
<div class="upload-row">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="(options) => uploadActionFile(options, scope.row, 'image')"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.webp">
|
||||||
|
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传图片并回填</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="scope.row.type === 'voice'">
|
<template v-else-if="scope.row.type === 'voice'">
|
||||||
@@ -209,11 +217,27 @@
|
|||||||
<el-option label="wav" value="wav"></el-option>
|
<el-option label="wav" value="wav"></el-option>
|
||||||
<el-option label="amr" value="amr"></el-option>
|
<el-option label="amr" value="amr"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div class="upload-row">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="(options) => uploadActionFile(options, scope.row, 'voice')"
|
||||||
|
accept=".mp3,.wav,.amr">
|
||||||
|
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传语音并回填</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="scope.row.type === 'video'">
|
<template v-else-if="scope.row.type === 'video'">
|
||||||
<el-input v-model="scope.row.path" placeholder="视频文件路径"></el-input>
|
<el-input v-model="scope.row.path" placeholder="视频文件路径"></el-input>
|
||||||
<el-input v-model="scope.row.cover_path" style="margin-top:6px;" placeholder="封面路径(可选)"></el-input>
|
<el-input v-model="scope.row.cover_path" style="margin-top:6px;" placeholder="封面路径(可选)"></el-input>
|
||||||
|
<div class="upload-row">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="(options) => uploadActionFile(options, scope.row, 'video')"
|
||||||
|
accept=".mp4,.mov,.m4v">
|
||||||
|
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传视频并回填</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="scope.row.type === 'link'">
|
<template v-else-if="scope.row.type === 'link'">
|
||||||
@@ -429,6 +453,38 @@ new Vue({
|
|||||||
return row
|
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() {
|
async loadGroups() {
|
||||||
const resp = await axios.get('/contacts/api/groups')
|
const resp = await axios.get('/contacts/api/groups')
|
||||||
const groups = (resp.data && resp.data.data && resp.data.data.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}
|
.trigger-text{font-size:13px;color:#334155;word-break:break-all}
|
||||||
.action-toolbar{display:flex;gap:8px;margin-bottom:8px}
|
.action-toolbar{display:flex;gap:8px;margin-bottom:8px}
|
||||||
.action-config{display:flex;flex-direction:column;gap:0}
|
.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{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}
|
.payload-tip code{background:#f1f5f9;border:1px solid #dbe3ee;padding:1px 6px;border-radius:6px;margin-right:6px}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user