Compare commits
6 Commits
2198c6b30a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 30fbe92b9a | |||
| ea29c77fe3 | |||
| ed28c99e81 | |||
| 90e93bb2b0 | |||
| 2a8d8a87d7 | |||
| c9e818e1ac |
@@ -164,8 +164,8 @@ piano-plan/
|
||||
|
||||
---
|
||||
|
||||
> **版本**:v1.5.1
|
||||
> **版本**:v1.5.7
|
||||
> **创建时间**:2026-04-17
|
||||
> **最后更新**:2026-04-28
|
||||
> **最后更新**:2026-05-05
|
||||
>
|
||||
> **重要更新**:v1.5.1 - PDF水印配置保存修复;{student_goals}占位符修复
|
||||
> **重要更新**:v1.5.7 - 动态API提供商管理(新增/编辑/删除);OpenCode Go集成(deepseek-v4-pro/qwen3.6-plus);AI生成调试增强
|
||||
|
||||
+96
-37
@@ -5,25 +5,52 @@ import pathlib
|
||||
# 检测是否在Docker容器中
|
||||
IS_DOCKER = os.environ.get("FLASK_ENV") == "production"
|
||||
|
||||
# 默认提供商列表
|
||||
DEFAULT_PROVIDERS = {
|
||||
"minimax": {
|
||||
"name": "MiniMax",
|
||||
"endpoint": "https://api.minimaxi.com/anthropic/v1",
|
||||
"models": ["MiniMax-M2.7-highspeed"],
|
||||
},
|
||||
"volcengine": {
|
||||
"name": "火山引擎",
|
||||
"endpoint": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"models": ["doubao-seed-2.0-pro", "doubao-seed-code", "doubao-seed-2.0-lite"],
|
||||
},
|
||||
"deepseek": {
|
||||
"name": "DeepSeek",
|
||||
"endpoint": "https://api.deepseek.com/v1",
|
||||
"models": ["deepseek-chat"],
|
||||
},
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"endpoint": "https://api.openai.com/v1",
|
||||
"models": ["gpt-4o-mini", "gpt-4o"],
|
||||
},
|
||||
"openrouter": {
|
||||
"name": "OpenRouter",
|
||||
"endpoint": "https://openrouter.ai/api/v1",
|
||||
"models": ["anthropic/claude-3-haiku"],
|
||||
},
|
||||
"opencodego": {
|
||||
"name": "OpenCode Go",
|
||||
"endpoint": "https://opencode.ai/zen/go/v1",
|
||||
"models": ["deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus"],
|
||||
},
|
||||
}
|
||||
|
||||
def load_api_config(app_config=None):
|
||||
"""加载API配置"""
|
||||
import json
|
||||
|
||||
# 优先从 app_config 获取路径,否则使用基于 __file__ 的绝对路径
|
||||
if app_config and app_config.get("API_CONFIG_FILE"):
|
||||
config_file = pathlib.Path(app_config["API_CONFIG_FILE"])
|
||||
# 兼容旧配置:从 default_model 迁移到 models
|
||||
def _migrate_provider_models(providers):
|
||||
"""确保每个 provider 都有 models 数组"""
|
||||
for pid, pdata in list(providers.items()):
|
||||
if "models" not in pdata:
|
||||
if "default_model" in pdata and pdata["default_model"]:
|
||||
pdata["models"] = [pdata.pop("default_model")]
|
||||
else:
|
||||
config_file = pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json"
|
||||
pdata["models"] = []
|
||||
return providers
|
||||
|
||||
default_config = {
|
||||
"provider": "volcengine",
|
||||
"api_key": "",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"model": "doubao-seed-2.0-pro",
|
||||
"temperature": 0.7,
|
||||
"watermark_text": "",
|
||||
"prompt_template": """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。
|
||||
DEFAULT_PROMPT_TEMPLATE = """你是一位专业的钢琴教师,请为学员生成一份简洁的个性化练习方案报告。
|
||||
|
||||
## 学员信息
|
||||
- 姓名:{student_name}
|
||||
@@ -39,22 +66,56 @@ def load_api_config(app_config=None):
|
||||
3. 针对每个问题的核心练习建议
|
||||
4. 重点注意事项
|
||||
|
||||
语言要专业、简洁、有鼓励性。使用Markdown格式。""",
|
||||
语言要专业、简洁、有鼓励性。使用Markdown格式。"""
|
||||
|
||||
|
||||
def _get_config_path(app_config=None):
|
||||
if app_config and app_config.get("API_CONFIG_FILE"):
|
||||
return pathlib.Path(app_config["API_CONFIG_FILE"])
|
||||
return pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json"
|
||||
|
||||
|
||||
def _build_default_config():
|
||||
return {
|
||||
"provider": "volcengine",
|
||||
"providers": dict(DEFAULT_PROVIDERS),
|
||||
"api_key": "",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"model": "doubao-seed-2.0-pro",
|
||||
"temperature": 0.7,
|
||||
"watermark_text": "",
|
||||
"prompt_template": DEFAULT_PROMPT_TEMPLATE,
|
||||
}
|
||||
|
||||
|
||||
def load_api_config(app_config=None):
|
||||
"""加载API配置"""
|
||||
import json
|
||||
|
||||
config_file = _get_config_path(app_config)
|
||||
default_config = _build_default_config()
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
loaded_config = json.load(f)
|
||||
# 确保 providers 字段存在(兼容旧配置)
|
||||
if "providers" not in loaded_config:
|
||||
loaded_config["providers"] = dict(DEFAULT_PROVIDERS)
|
||||
# 迁移旧 default_model → models
|
||||
loaded_config["providers"] = _migrate_provider_models(loaded_config["providers"])
|
||||
# 合并缺失的默认 provider
|
||||
for pid, pdata in DEFAULT_PROVIDERS.items():
|
||||
if pid not in loaded_config["providers"]:
|
||||
loaded_config["providers"][pid] = pdata
|
||||
# 如果 api_keys 映射存在,根据当前 provider 自动设置 api_key
|
||||
if "api_keys" in loaded_config:
|
||||
provider = loaded_config.get("provider", "volcengine")
|
||||
loaded_config["api_key"] = loaded_config["api_keys"].get(provider, "")
|
||||
return loaded_config
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 返回默认配置
|
||||
return default_config
|
||||
|
||||
|
||||
@@ -62,40 +123,38 @@ def save_api_config(config, app_config=None):
|
||||
"""保存API配置"""
|
||||
import json
|
||||
|
||||
# 优先从 app_config 获取路径,否则使用基于 __file__ 的绝对路径
|
||||
if app_config and app_config.get("API_CONFIG_FILE"):
|
||||
config_file = pathlib.Path(app_config["API_CONFIG_FILE"])
|
||||
else:
|
||||
config_file = pathlib.Path(__file__).resolve().parent.parent / "config" / "api_config.json"
|
||||
|
||||
config_file = pathlib.Path(config_file)
|
||||
config_file = _get_config_path(app_config)
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 先读取现有配置,保留 api_keys 映射
|
||||
existing_config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
existing_config = json.load(f)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果 config 中有 api_key,保存到 api_keys 映射(保留其他 provider 的 key)
|
||||
# 确保 providers 存在
|
||||
if "providers" not in existing_config:
|
||||
existing_config["providers"] = dict(DEFAULT_PROVIDERS)
|
||||
existing_config["providers"] = _migrate_provider_models(existing_config["providers"])
|
||||
|
||||
# 如果 config 中有 providers,完整替换
|
||||
if "providers" in config:
|
||||
existing_config["providers"] = config["providers"]
|
||||
|
||||
# 保存 api_key 到 api_keys 映射
|
||||
if "api_key" in config and config["api_key"]:
|
||||
if "api_keys" not in existing_config:
|
||||
existing_config["api_keys"] = {}
|
||||
provider = config.get("provider", "volcengine")
|
||||
existing_config["api_keys"][provider] = config["api_key"]
|
||||
|
||||
# 合并配置:保留 api_keys,更新其他字段
|
||||
existing_config["provider"] = config.get("provider", existing_config.get("provider", "volcengine"))
|
||||
existing_config["base_url"] = config.get("base_url", existing_config.get("base_url", ""))
|
||||
existing_config["model"] = config.get("model", existing_config.get("model", ""))
|
||||
existing_config["temperature"] = config.get("temperature", existing_config.get("temperature", 0.7))
|
||||
existing_config["prompt_template"] = config.get("prompt_template", existing_config.get("prompt_template", ""))
|
||||
existing_config["watermark_text"] = config.get("watermark_text", existing_config.get("watermark_text", ""))
|
||||
# 更新当前选中的配置
|
||||
for key in ["provider", "base_url", "model", "temperature", "prompt_template", "watermark_text"]:
|
||||
if key in config:
|
||||
existing_config[key] = config[key]
|
||||
|
||||
# 当前选中的 provider 的 key 直接存储(用于兼容旧逻辑)
|
||||
if config.get("api_key"):
|
||||
existing_config["api_key"] = config["api_key"]
|
||||
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ class Student(db.Model):
|
||||
key=lambda p: (severity_order.get(p.severity, 1), p.created_at)
|
||||
)
|
||||
# 通过关联获取问题名称
|
||||
problem_names = [p.problem.name if p.problem else p.problem_name for p in problems_list]
|
||||
problem_names = [p.problem.name if p.problem else "未知问题" for p in problems_list]
|
||||
|
||||
# 获取目标统计
|
||||
goal_count = len(self.goal_records) if self.goal_records else 0
|
||||
|
||||
+51
-5
@@ -467,7 +467,7 @@ def generate_plan():
|
||||
)
|
||||
|
||||
# 真正调用API生成报告
|
||||
_, ai_report, error, _ = generate_ai_report(
|
||||
_, ai_report, error, extra = generate_ai_report(
|
||||
student_name=student.name,
|
||||
wechat_nickname=student.wechat_nickname or "",
|
||||
problems=problem_data,
|
||||
@@ -479,24 +479,46 @@ def generate_plan():
|
||||
)
|
||||
|
||||
if error:
|
||||
# 拼接详细的错误信息用于显示
|
||||
display_error = f"AI生成失败: {error}"
|
||||
if extra:
|
||||
debug_info = []
|
||||
if extra.get("prompt_tokens"):
|
||||
debug_info.append(f"输入token={extra['prompt_tokens']}")
|
||||
if extra.get("completion_tokens"):
|
||||
debug_info.append(f"输出token={extra['completion_tokens']}")
|
||||
if extra.get("finish_reason"):
|
||||
debug_info.append(f"finish={extra['finish_reason']}")
|
||||
if extra.get("raw_keys"):
|
||||
debug_info.append(f"keys={extra['raw_keys']}")
|
||||
if debug_info:
|
||||
display_error += " [" + ", ".join(debug_info) + "]"
|
||||
yield sse_format(
|
||||
{
|
||||
"step": "ai_error",
|
||||
"message": f"AI生成失败: {error}",
|
||||
"message": display_error,
|
||||
"progress": 80,
|
||||
"error": error,
|
||||
"error": display_error,
|
||||
}
|
||||
)
|
||||
plan_content["ai_report_error"] = error
|
||||
plan_content["ai_report_error"] = display_error
|
||||
else:
|
||||
# 显示AI返回的报告长度
|
||||
report_lines = len(ai_report.split("\n")) if ai_report else 0
|
||||
detail_parts = [f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines} 行"]
|
||||
if extra:
|
||||
if extra.get("prompt_tokens"):
|
||||
detail_parts.append(f"输入token: {extra['prompt_tokens']}")
|
||||
if extra.get("completion_tokens"):
|
||||
detail_parts.append(f"输出token: {extra['completion_tokens']}")
|
||||
if extra.get("finish_reason"):
|
||||
detail_parts.append(f"finish_reason: {extra['finish_reason']}")
|
||||
yield sse_format(
|
||||
{
|
||||
"step": "ai_response",
|
||||
"message": f"AI报告已生成",
|
||||
"progress": 75,
|
||||
"detail": f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines} 行",
|
||||
"detail": " | ".join(detail_parts),
|
||||
}
|
||||
)
|
||||
yield sse_format(
|
||||
@@ -558,6 +580,7 @@ def generate_plan():
|
||||
"ai_report": ai_report,
|
||||
"prompt_length": prompt_length,
|
||||
"ai_report_length": len(ai_report) if ai_report else 0,
|
||||
"ai_error": plan_content.get("ai_report_error", ""),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -832,6 +855,29 @@ def preview_report(plan_id):
|
||||
# Markdown 转 HTML
|
||||
html_content = markdown.markdown(rendered, extensions=['tables', 'fenced_code'])
|
||||
|
||||
# URL 转二维码图片(预览用)
|
||||
import re
|
||||
import qrcode
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
def make_qr_base64(url):
|
||||
qr = qrcode.make(url, box_size=5)
|
||||
buf = BytesIO()
|
||||
qr.save(buf, format='PNG')
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
def replace_url_with_qr(match):
|
||||
url = match.group(0)
|
||||
try:
|
||||
qr_b64 = make_qr_base64(url)
|
||||
return f'<br><img src="data:image/png;base64,{qr_b64}" alt="QR" style="width:80px;display:block;margin:8px auto;">'
|
||||
except:
|
||||
return url
|
||||
|
||||
url_pattern = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+')
|
||||
html_content = url_pattern.sub(replace_url_with_qr, html_content)
|
||||
|
||||
# 支持 ReportLab <para alignment="center"> 语法,转为 HTML
|
||||
import re
|
||||
html_content = re.sub(
|
||||
|
||||
+129
-18
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from flask import request, jsonify, render_template, current_app, session, redirect
|
||||
from app.routes import main_bp
|
||||
from app.models import db, Problem, StudentProblem
|
||||
from app.config import load_api_config, save_api_config
|
||||
from app.config import load_api_config, save_api_config, DEFAULT_PROVIDERS
|
||||
from app.routes.auth import login_required_json, admin_required
|
||||
|
||||
|
||||
@@ -42,8 +42,113 @@ def get_api_config():
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@main_bp.route("/api/config/providers", methods=["GET"])
|
||||
@admin_required
|
||||
def list_providers():
|
||||
"""列出所有API提供商"""
|
||||
config = load_api_config(current_app.config)
|
||||
providers = config.get("providers", {})
|
||||
result = {}
|
||||
for pid, pdata in providers.items():
|
||||
result[pid] = {
|
||||
"name": pdata.get("name", pid),
|
||||
"endpoint": pdata.get("endpoint", ""),
|
||||
"models": pdata.get("models", []),
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@main_bp.route("/api/config/providers", methods=["POST"])
|
||||
@admin_required
|
||||
def add_provider():
|
||||
"""新增API提供商"""
|
||||
data = request.get_json()
|
||||
provider_id = data.get("id", "").strip().lower()
|
||||
name = data.get("name", "").strip()
|
||||
endpoint = data.get("endpoint", "").strip()
|
||||
models = data.get("models", [])
|
||||
|
||||
if not isinstance(models, list):
|
||||
models = [m.strip() for m in str(models).split(",") if m.strip()]
|
||||
|
||||
if not provider_id or not name:
|
||||
return jsonify({"error": "提供商ID和名称不能为空"}), 400
|
||||
if not endpoint:
|
||||
return jsonify({"error": "Endpoint不能为空"}), 400
|
||||
|
||||
config = load_api_config(current_app.config)
|
||||
providers = config.get("providers", {})
|
||||
|
||||
if provider_id in providers:
|
||||
return jsonify({"error": f"提供商 '{provider_id}' 已存在"}), 400
|
||||
|
||||
providers[provider_id] = {
|
||||
"name": name,
|
||||
"endpoint": endpoint,
|
||||
"models": models,
|
||||
}
|
||||
config["providers"] = providers
|
||||
save_api_config(config, current_app.config)
|
||||
return jsonify({"message": "添加成功", "id": provider_id})
|
||||
|
||||
|
||||
@main_bp.route("/api/config/providers/<provider_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_provider(provider_id):
|
||||
"""删除API提供商"""
|
||||
provider_id = provider_id.strip().lower()
|
||||
config = load_api_config(current_app.config)
|
||||
providers = config.get("providers", {})
|
||||
|
||||
if provider_id not in providers:
|
||||
return jsonify({"error": f"提供商 '{provider_id}' 不存在"}), 404
|
||||
|
||||
# 不允许删除当前选中的 provider
|
||||
if provider_id == config.get("provider"):
|
||||
return jsonify({"error": "不能删除当前正在使用的提供商,请先切换到其他提供商"}), 400
|
||||
|
||||
# 不允许删除到只剩 0 个
|
||||
if len(providers) <= 1:
|
||||
return jsonify({"error": "至少保留一个提供商"}), 400
|
||||
|
||||
del providers[provider_id]
|
||||
|
||||
# 同时删除 api_keys 中对应的 key
|
||||
if "api_keys" in config and provider_id in config["api_keys"]:
|
||||
del config["api_keys"][provider_id]
|
||||
|
||||
config["providers"] = providers
|
||||
save_api_config(config, current_app.config)
|
||||
return jsonify({"message": "删除成功"})
|
||||
|
||||
|
||||
@main_bp.route("/api/config/providers/<provider_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
def update_provider(provider_id):
|
||||
"""更新API提供商(名称、endpoint、模型列表)"""
|
||||
provider_id = provider_id.strip().lower()
|
||||
data = request.get_json()
|
||||
config = load_api_config(current_app.config)
|
||||
providers = config.get("providers", {})
|
||||
|
||||
if provider_id not in providers:
|
||||
return jsonify({"error": f"提供商 '{provider_id}' 不存在"}), 404
|
||||
|
||||
pdata = providers[provider_id]
|
||||
if "name" in data:
|
||||
pdata["name"] = data["name"].strip()
|
||||
if "endpoint" in data:
|
||||
pdata["endpoint"] = data["endpoint"].strip()
|
||||
if "models" in data:
|
||||
if isinstance(data["models"], list):
|
||||
pdata["models"] = [m.strip() for m in data["models"] if m.strip()]
|
||||
else:
|
||||
pdata["models"] = [m.strip() for m in str(data["models"]).split(",") if m.strip()]
|
||||
|
||||
config["providers"] = providers
|
||||
save_api_config(config, current_app.config)
|
||||
return jsonify({"message": "更新成功"})
|
||||
|
||||
|
||||
@main_bp.route("/api/config", methods=["POST"])
|
||||
@admin_required
|
||||
@@ -51,32 +156,38 @@ def update_api_config():
|
||||
"""更新API配置"""
|
||||
data = request.get_json()
|
||||
|
||||
# 验证必填字段
|
||||
if not data.get("api_key"):
|
||||
config = load_api_config(current_app.config)
|
||||
provider = data.get("provider", config.get("provider", "volcengine"))
|
||||
|
||||
# api_key 留空时,尝试使用已有 key(当前 provider 的 key)
|
||||
api_key = data.get("api_key", "")
|
||||
if not api_key:
|
||||
existing_key = config.get("api_keys", {}).get(provider, "")
|
||||
if not existing_key:
|
||||
return jsonify({"error": "API Key不能为空"}), 400
|
||||
api_key = existing_key
|
||||
providers = config.get("providers", DEFAULT_PROVIDERS)
|
||||
|
||||
# 根据provider设置默认endpoint
|
||||
provider = data.get("provider", "volcengine")
|
||||
default_endpoints = {
|
||||
"minimax": "https://api.minimaxi.com/anthropic/v1",
|
||||
"volcengine": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"deepseek": "https://api.deepseek.com/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
default_endpoint = default_endpoints.get(provider, "https://ark.cn-beijing.volces.com/api/coding/v3")
|
||||
# 从 providers 列表获取默认 endpoint 和 model
|
||||
if provider in providers:
|
||||
default_endpoint = providers[provider].get("endpoint", "")
|
||||
models = providers[provider].get("models", [])
|
||||
default_model = models[0] if models else ""
|
||||
else:
|
||||
default_endpoint = ""
|
||||
default_model = ""
|
||||
|
||||
# 保存配置
|
||||
config = {
|
||||
new_config = {
|
||||
"provider": provider,
|
||||
"api_key": data.get("api_key", ""),
|
||||
"api_key": api_key,
|
||||
"base_url": data.get("base_url", default_endpoint),
|
||||
"model": data.get("model", "doubao-seed-2.0-pro"),
|
||||
"model": data.get("model", default_model),
|
||||
"temperature": float(data.get("temperature", 0.7)),
|
||||
"prompt_template": data.get("prompt_template", ""),
|
||||
"watermark_text": data.get("watermark_text", ""),
|
||||
}
|
||||
|
||||
save_api_config(config, current_app.config)
|
||||
save_api_config(new_config, current_app.config)
|
||||
return jsonify({"message": "配置保存成功"})
|
||||
|
||||
|
||||
@@ -171,7 +282,7 @@ def delete_problem(problem_id):
|
||||
|
||||
# 检查是否有关联数据
|
||||
from app.models import StudentProblem
|
||||
if StudentProblem.query.filter_by(problem_db_id=problem_id).first():
|
||||
if StudentProblem.query.filter_by(problem_id=problem_id).first():
|
||||
return jsonify({"error": "该问题已被学员使用,无法删除"}), 400
|
||||
|
||||
db.session.delete(problem)
|
||||
|
||||
@@ -53,6 +53,26 @@ try:
|
||||
except Exception as e:
|
||||
CHINESE_FONT_OK = False
|
||||
|
||||
import re
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
from reportlab.platypus import Image as RLImage
|
||||
|
||||
# URL 正则表达式
|
||||
URL_PATTERN = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+')
|
||||
|
||||
def generate_qr_image(url, size=50*mm):
|
||||
"""生成二维码图片,返回 BytesIO 对象"""
|
||||
qr = qrcode.make(url, box_size=10)
|
||||
buf = BytesIO()
|
||||
qr.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
def contains_url(text):
|
||||
"""检测文本是否包含 URL"""
|
||||
return bool(URL_PATTERN.search(text))
|
||||
|
||||
def md_to_xml(text):
|
||||
"""将markdown转换为reportlab XML markup"""
|
||||
if not text:
|
||||
@@ -169,7 +189,25 @@ class PianoPDF:
|
||||
self.elements.append(Paragraph(md_to_xml(text), self.heading_style))
|
||||
|
||||
def add_paragraph(self, text):
|
||||
if text:
|
||||
if not text:
|
||||
return
|
||||
|
||||
# 检测是否是纯 URL
|
||||
url_match = URL_PATTERN.match(text.strip())
|
||||
if url_match and url_match.group() == text.strip():
|
||||
# 纯 URL,生成二维码
|
||||
url = url_match.group()
|
||||
try:
|
||||
buf = generate_qr_image(url, 50*mm)
|
||||
img = RLImage(buf, width=50*mm, height=50*mm)
|
||||
img.hAlign = 'CENTER'
|
||||
self.elements.append(img)
|
||||
self.elements.append(Spacer(1, 2*mm))
|
||||
return
|
||||
except Exception as e:
|
||||
# QR生成失败,回退到文字
|
||||
pass
|
||||
|
||||
self.elements.append(Paragraph(md_to_xml(text), self.body_style))
|
||||
self.elements.append(Spacer(1, 1*mm))
|
||||
|
||||
@@ -249,7 +287,7 @@ def generate_pdf(plan_id, student_name, content, output_dir, rendered_report=Non
|
||||
pdf.add_table(table_data)
|
||||
else:
|
||||
# 使用结构化内容
|
||||
pdf.add_title(f"钢琴练习方案 - {student_name}")
|
||||
pdf.add_title(f"有音个性化教学 - {student_name}")
|
||||
pdf.add_heading("学员信息")
|
||||
pdf.add_paragraph(f"学员姓名:{student_name}")
|
||||
pdf.add_paragraph(f"每日练习时间:{content.get('practice_time', 'N/A')}")
|
||||
|
||||
@@ -321,7 +321,7 @@ def generate_ai_report(
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": temperature,
|
||||
"max_tokens": 2000,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
# 确定 endpoint
|
||||
@@ -330,40 +330,71 @@ def generate_ai_report(
|
||||
else:
|
||||
endpoint = f"{base_url}/chat/completions"
|
||||
|
||||
# 调试信息
|
||||
req_info = {"endpoint": endpoint, "model": model, "provider": provider}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=60,
|
||||
timeout=180,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception as je:
|
||||
raw_text = response.text[:300] if response.text else "(empty body)"
|
||||
return prompt, None, f"API返回非JSON [{endpoint}]: {raw_text}", None
|
||||
# MiniMax (Anthropic格式) 返回结构不同
|
||||
if provider == "minimax":
|
||||
# MiniMax 返回的 content 可能是 [{"type": "thinking", ...}, {"type": "text", "text": "..."}]
|
||||
content_list = result.get("content", [])
|
||||
content = ""
|
||||
for item in content_list:
|
||||
if item.get("type") == "text":
|
||||
content = item.get("text", "")
|
||||
break
|
||||
extra = {"finish_reason": "n/a"}
|
||||
else:
|
||||
content = (
|
||||
result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
)
|
||||
return prompt, content, None, None
|
||||
choice = result.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
finish = choice.get("finish_reason", "missing")
|
||||
usage = result.get("usage", {})
|
||||
# 调试信息
|
||||
extra = {
|
||||
"finish_reason": finish,
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"raw_keys": list(result.keys()),
|
||||
}
|
||||
if not content or not content.strip():
|
||||
err_detail = "API返回空内容"
|
||||
if extra.get("finish_reason", "") not in ("", "n/a"):
|
||||
err_detail += f" (finish_reason={extra['finish_reason']})"
|
||||
if extra.get("completion_tokens", 0) > 0:
|
||||
err_detail += f", 有{extra['completion_tokens']}个输出token但content为空"
|
||||
if extra.get("raw_keys"):
|
||||
err_detail += f", 响应keys={extra['raw_keys']}"
|
||||
return prompt, None, err_detail, extra
|
||||
return prompt, content, None, extra
|
||||
else:
|
||||
error_msg = f"API错误: {response.status_code}"
|
||||
error_msg = f"API返回HTTP {response.status_code}"
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
loc = response.headers.get("Location", "unknown")
|
||||
error_msg += f" 重定向到: {loc}"
|
||||
try:
|
||||
error_detail = response.json()
|
||||
error_msg = error_detail.get("error", {}).get("message", error_msg)
|
||||
error_body = response.json()
|
||||
error_msg += ": " + error_body.get("error", {}).get("message", str(error_body)[:200])
|
||||
except:
|
||||
pass
|
||||
raw = response.text[:200] if response.text else "(empty)"
|
||||
error_msg += ": " + raw
|
||||
return prompt, None, error_msg, None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return prompt, None, "API请求超时,请稍后重试", None
|
||||
return prompt, None, "AI响应超时(等待180秒),网络较慢或模型负载高,请重试", None
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return prompt, None, f"无法连接到API服务器,请检查网络或Endpoint配置", None
|
||||
except Exception as e:
|
||||
return prompt, None, f"调用API失败: {str(e)}", None
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+237
-146
@@ -1,11 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API设置 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}API设置 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-key"></i> AI API 配置</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showAddProviderModal()">
|
||||
<i class="bi bi-plus-lg"></i> 新增提供商
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
@@ -14,24 +17,21 @@
|
||||
|
||||
<form id="apiConfigForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">提供商</label>
|
||||
<select class="form-select" id="apiProvider" onchange="onProviderChange()">
|
||||
<option value="minimax">MiniMax (Token Plan)</option>
|
||||
<option value="volcengine">火山引擎 (Volcengine)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="apiProvider" onchange="onProviderChange()"></select>
|
||||
<button class="btn btn-outline-danger" type="button" onclick="deleteCurrentProvider()" title="删除当前提供商">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">模型</label>
|
||||
<select class="form-select" id="apiModel">
|
||||
<option value="MiniMax-M2.7-highspeed">MiniMax-M2.7-highspeed</option>
|
||||
<option value="doubao-seed-2.0-pro">doubao-seed-2.0-pro</option>
|
||||
<option value="doubao-seed-code">doubao-seed-code</option>
|
||||
<option value="doubao-seed-2.0-lite">doubao-seed-2.0-lite</option>
|
||||
</select>
|
||||
<div id="modelInputArea">
|
||||
<select class="form-select" id="apiModelSelect" onchange="onModelSelectChange()" style="display:none"></select>
|
||||
<input type="text" class="form-control" id="apiModelText" placeholder="如 gpt-4o-mini">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,41 +77,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增提供商 Modal -->
|
||||
<div class="modal fade" id="addProviderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="providerModalTitle">新增API提供商</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">提供商ID <small class="text-muted">(英文标识)</small></label>
|
||||
<input type="text" class="form-control" id="newProviderId" placeholder="如 opencodego">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">显示名称</label>
|
||||
<input type="text" class="form-control" id="newProviderName" placeholder="如 OpenCode Go">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Endpoint</label>
|
||||
<input type="text" class="form-control" id="newProviderEndpoint" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模型列表</label>
|
||||
<div id="newModelsContainer">
|
||||
<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" placeholder="模型名称,如 gpt-4o">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addModelInput()">
|
||||
<i class="bi bi-plus"></i> 添加模型
|
||||
</button>
|
||||
<small class="text-muted d-block">一个提供商可以有多个模型</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="providerModalSaveBtn" onclick="saveProvider()">确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 推荐配置</h6>
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 已注册提供商</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm" id="providersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>提供商</th>
|
||||
<th>推荐模型</th>
|
||||
<th>Endpoint</th>
|
||||
</tr>
|
||||
<tr><th>ID</th><th>名称</th><th>Endpoint</th><th>模型</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>MiniMax</td>
|
||||
<td>MiniMax-M2.7-highspeed</td>
|
||||
<td><code>https://api.minimaxi.com/anthropic/v1</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>火山方舟</td>
|
||||
<td>doubao-seed-2.0-pro</td>
|
||||
<td><code>https://ark.cn-beijing.volces.com/api/coding/v3</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DeepSeek</td>
|
||||
<td>deepseek-chat</td>
|
||||
<td><code>https://api.deepseek.com</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OpenRouter</td>
|
||||
<td>deepseek/deepseek-r1</td>
|
||||
<td><code>https://openrouter.ai/api/v1</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,126 +138,199 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.pageInit = function(data) {
|
||||
if (data.role !== 'admin') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (data.role !== 'admin') { window.location.href = '/'; return; }
|
||||
loadApiConfig();
|
||||
};
|
||||
|
||||
const providerDefaults = {
|
||||
'minimax': {
|
||||
endpoint: 'https://api.minimaxi.com/anthropic/v1',
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'volcengine': {
|
||||
endpoint: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
model: 'doubao-seed-2.0-pro',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'deepseek': {
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openai': {
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openrouter': {
|
||||
endpoint: 'https://openrouter.ai/api/v1',
|
||||
model: 'anthropic/claude-3-haiku',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
}
|
||||
};
|
||||
let allProviders = {};
|
||||
let currentModel = '';
|
||||
|
||||
async function onProviderChange() {
|
||||
const provider = document.getElementById('apiProvider').value;
|
||||
const defaults = providerDefaults[provider] || providerDefaults['volcengine'];
|
||||
|
||||
document.getElementById('apiModel').value = defaults.model;
|
||||
document.getElementById('apiEndpoint').value = defaults.endpoint;
|
||||
document.getElementById('apiTemperature').value = defaults.temperature;
|
||||
document.getElementById('apiKey').value = defaults.api_key || '';
|
||||
async function loadProviders() {
|
||||
const r = await fetch('/api/config/providers');
|
||||
allProviders = await r.json();
|
||||
renderProviderDropdown();
|
||||
renderProvidersTable();
|
||||
}
|
||||
|
||||
async function saveApiConfig(silent = false, overrideProvider = null) {
|
||||
const provider = overrideProvider || document.getElementById('apiProvider').value;
|
||||
function renderProviderDropdown() {
|
||||
const sel = document.getElementById('apiProvider');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '';
|
||||
for (const [id, p] of Object.entries(allProviders)) {
|
||||
sel.innerHTML += `<option value="${id}">${p.name}</option>`;
|
||||
}
|
||||
if (cur && allProviders[cur]) sel.value = cur;
|
||||
}
|
||||
|
||||
function renderProvidersTable() {
|
||||
const tbody = document.getElementById('providersTable').querySelector('tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const [id, p] of Object.entries(allProviders)) {
|
||||
const ml = (p.models || []).join(', ');
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><code>${id}</code></td><td>${p.name}</td>
|
||||
<td><code style="font-size:11px">${p.endpoint}</code></td>
|
||||
<td>${ml || '<span class="text-muted">—</span>'}</td>
|
||||
<td><button class="btn btn-sm btn-outline-secondary" onclick="editProvider('${id}')" title="编辑"><i class="bi bi-pencil"></i></button></td>
|
||||
</tr>`;
|
||||
}
|
||||
if (!Object.keys(allProviders).length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center">暂无提供商</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelInput(models) {
|
||||
const sel = document.getElementById('apiModelSelect');
|
||||
const txt = document.getElementById('apiModelText');
|
||||
if (models && models.length > 1) {
|
||||
sel.style.display = ''; txt.style.display = 'none';
|
||||
sel.innerHTML = '';
|
||||
models.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); });
|
||||
if (models.includes(currentModel)) sel.value = currentModel; else sel.value = models[0];
|
||||
} else {
|
||||
sel.style.display = 'none'; txt.style.display = '';
|
||||
txt.value = (models && models.length === 1) ? models[0] : (currentModel || '');
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedModel() {
|
||||
const sel = document.getElementById('apiModelSelect');
|
||||
return sel.style.display !== 'none' ? sel.value : document.getElementById('apiModelText').value;
|
||||
}
|
||||
|
||||
function onModelSelectChange() {
|
||||
currentModel = document.getElementById('apiModelSelect').value;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
const id = document.getElementById('apiProvider').value;
|
||||
const p = allProviders[id];
|
||||
if (p) {
|
||||
document.getElementById('apiEndpoint').value = p.endpoint || '';
|
||||
renderModelInput(p.models || []);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiConfig() {
|
||||
await loadProviders();
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
const c = await r.json();
|
||||
document.getElementById('apiProvider').value = c.provider || '';
|
||||
document.getElementById('apiEndpoint').value = c.base_url || '';
|
||||
document.getElementById('apiTemperature').value = c.temperature || 0.7;
|
||||
document.getElementById('watermarkText').value = c.watermark_text || '';
|
||||
currentModel = c.model || '';
|
||||
const p = allProviders[c.provider];
|
||||
renderModelInput(p ? p.models : []);
|
||||
if (c.api_key_preview) document.getElementById('apiKeyPreview').textContent = '当前: ' + c.api_key_preview;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function saveApiConfig() {
|
||||
const m = getSelectedModel();
|
||||
if (!m) { alert('请选择或输入模型'); return; }
|
||||
const config = {
|
||||
provider: provider,
|
||||
model: document.getElementById('apiModel').value,
|
||||
provider: document.getElementById('apiProvider').value,
|
||||
model: m,
|
||||
api_key: document.getElementById('apiKey').value,
|
||||
base_url: document.getElementById('apiEndpoint').value,
|
||||
temperature: parseFloat(document.getElementById('apiTemperature').value),
|
||||
watermark_text: document.getElementById('watermarkText').value.trim()
|
||||
};
|
||||
|
||||
const r = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!silent) {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert('保存成功');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadApiConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
const config = await r.json();
|
||||
document.getElementById('apiProvider').value = config.provider || 'volcengine';
|
||||
document.getElementById('apiModel').value = config.model || 'doubao-seed-2.0-pro';
|
||||
document.getElementById('apiKey').value = config.api_key || '';
|
||||
document.getElementById('apiEndpoint').value = config.base_url || '';
|
||||
document.getElementById('apiTemperature').value = config.temperature || 0.7;
|
||||
document.getElementById('watermarkText').value = config.watermark_text || '';
|
||||
|
||||
if (config.api_key_preview) {
|
||||
document.getElementById('apiKeyPreview').textContent = '当前: ' + config.api_key_preview;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const r = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) });
|
||||
const d = await r.json();
|
||||
if (d.error) alert(d.error); else { alert('保存成功'); loadApiConfig(); }
|
||||
}
|
||||
|
||||
async function testApiConnection() {
|
||||
const resultDiv = document.getElementById('apiTestResult');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">测试中...</div>';
|
||||
|
||||
const div = document.getElementById('apiTestResult');
|
||||
div.innerHTML = '<div class="alert alert-info">测试中...</div>';
|
||||
const r = await fetch('/api/config/test', { method: 'POST' });
|
||||
const data = await r.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (data.error || '未知错误') + '</div>';
|
||||
const d = await r.json();
|
||||
div.innerHTML = d.success ? '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>'
|
||||
: '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (d.error || '未知错误') + '</div>';
|
||||
}
|
||||
|
||||
// --- 新增/编辑提供商 ---
|
||||
let editingProviderId = null;
|
||||
|
||||
function showAddProviderModal() {
|
||||
editingProviderId = null;
|
||||
document.getElementById('providerModalTitle').textContent = '新增API提供商';
|
||||
document.getElementById('providerModalSaveBtn').textContent = '确认添加';
|
||||
document.getElementById('newProviderId').value = '';
|
||||
document.getElementById('newProviderId').disabled = false;
|
||||
document.getElementById('newProviderName').value = '';
|
||||
document.getElementById('newProviderEndpoint').value = '';
|
||||
document.getElementById('newModelsContainer').innerHTML = `<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" placeholder="模型名称">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>`;
|
||||
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
|
||||
}
|
||||
|
||||
function editProvider(id) {
|
||||
editingProviderId = id;
|
||||
const p = allProviders[id];
|
||||
document.getElementById('providerModalTitle').textContent = '编辑提供商 - ' + p.name;
|
||||
document.getElementById('providerModalSaveBtn').textContent = '保存修改';
|
||||
document.getElementById('newProviderId').value = id;
|
||||
document.getElementById('newProviderId').disabled = true;
|
||||
document.getElementById('newProviderName').value = p.name || '';
|
||||
document.getElementById('newProviderEndpoint').value = p.endpoint || '';
|
||||
const container = document.getElementById('newModelsContainer');
|
||||
const models = p.models || [];
|
||||
if (!models.length) models.push('');
|
||||
container.innerHTML = models.map(m => `<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" value="${m}" placeholder="模型名称">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>`).join('');
|
||||
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
const id = document.getElementById('newProviderId').value.trim().toLowerCase();
|
||||
const name = document.getElementById('newProviderName').value.trim();
|
||||
const endpoint = document.getElementById('newProviderEndpoint').value.trim();
|
||||
const models = []; document.querySelectorAll('.newModelInput').forEach(inp => { const v = inp.value.trim(); if (v) models.push(v); });
|
||||
|
||||
if (!name || !endpoint) { alert('请填写名称和Endpoint'); return; }
|
||||
if (!models.length) { alert('请至少添加一个模型'); return; }
|
||||
|
||||
const isEdit = !!editingProviderId;
|
||||
const url = isEdit ? '/api/config/providers/' + editingProviderId : '/api/config/providers';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
const body = isEdit ? { name, endpoint, models } : { id, name, endpoint, models };
|
||||
|
||||
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
bootstrap.Modal.getInstance(document.getElementById('addProviderModal')).hide();
|
||||
await loadProviders();
|
||||
document.getElementById('apiProvider').value = editingProviderId || id;
|
||||
onProviderChange();
|
||||
alert(isEdit ? '修改成功' : '添加成功');
|
||||
}
|
||||
|
||||
// --- 删除提供商 ---
|
||||
|
||||
async function deleteCurrentProvider() {
|
||||
const id = document.getElementById('apiProvider').value;
|
||||
if (Object.keys(allProviders).length <= 1) { alert('至少保留一个提供商'); return; }
|
||||
if (!confirm('确定要删除提供商 "' + (allProviders[id]?.name || id) + '" 吗?')) return;
|
||||
const r = await fetch('/api/config/providers/' + id, { method: 'DELETE' });
|
||||
const d = await r.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
alert('删除成功');
|
||||
loadApiConfig();
|
||||
}
|
||||
|
||||
function toggleApiKey() {
|
||||
const input = document.getElementById('apiKey');
|
||||
const icon = document.getElementById('apiKeyToggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
const inp = document.getElementById('apiKey');
|
||||
const ico = document.getElementById('apiKeyToggleIcon');
|
||||
if (inp.type === 'password') { inp.type = 'text'; ico.className = 'bi bi-eye-slash'; }
|
||||
else { inp.type = 'password'; ico.className = 'bi bi-eye'; }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{% block title %}钢琴练习方案管理系统{% endblock %}</title>
|
||||
<title>{% block title %}有音个性化教学系统{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/jpeg" href="{{ url_for('static', filename='images/logo-YoIn-200.jpg') }}">
|
||||
|
||||
<!-- 公共CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
@@ -70,7 +73,7 @@
|
||||
<nav class="mobile-nav-toggle navbar navbar-dark bg-dark d-flex d-md-none" style="position:fixed;top:0;left:0;right:0;z-index:1050;padding:10px;">
|
||||
<div class="container-fluid d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="navbar-brand mb-0"><i class="bi bi-music-note-beamed"></i> 钢琴方案</span>
|
||||
<a href="/" class="navbar-brand mb-0 text-white text-decoration-none"><i class="bi bi-music-note-beamed"></i> 有音个性化教学系统</a>
|
||||
<small id="mobileUserDisplay" class="d-block text-white-50" style="font-size:10px;"></small>
|
||||
</div>
|
||||
<button class="btn btn-outline-light btn-sm" onclick="toggleMobileNav()">
|
||||
@@ -84,7 +87,7 @@
|
||||
<!-- 侧边栏 -->
|
||||
<div class="col-md-2 sidebar p-0 collapsed" id="sidebar">
|
||||
<div class="p-3 text-center border-bottom border-secondary d-none d-md-block">
|
||||
<h5><i class="bi bi-music-note-beamed"></i> 钢琴方案</h5>
|
||||
<h5><a href="/" class="text-white text-decoration-none"><i class="bi bi-music-note-beamed"></i> 有音个性化教学系统</a></h5>
|
||||
<small id="currentUserDisplay" class="text-light"></small>
|
||||
</div>
|
||||
<nav class="nav flex-column">
|
||||
@@ -303,5 +306,6 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% include 'fragments/copyright_footer.html' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}班级管理 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}班级管理 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- 版权声明 footer -->
|
||||
<footer class="bg-light text-center py-4 mt-auto border-top">
|
||||
<div class="container">
|
||||
<!-- 两家合作企业logo -->
|
||||
<div class="d-flex justify-content-center align-items-center gap-4 mb-3">
|
||||
<img src="{{ url_for('static', filename='images/有音-logo和字.png') }}"
|
||||
alt="有音教育"
|
||||
style="height: 40px; width: auto;"
|
||||
loading="lazy">
|
||||
<img src="{{ url_for('static', filename='images/艺超-logo和字.png') }}"
|
||||
alt="艺超音乐"
|
||||
style="height: 40px; width: auto;"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<!-- 版权文字 -->
|
||||
<p class="text-muted small mb-0">
|
||||
© 2024-2026 有音教育 & 艺超音乐 联合开发 | 个性化钢琴教学系统
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}首页 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}首页 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-4 mb-4">
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-music-note-beamed text-muted" style="font-size: 48px;"></i>
|
||||
<h4 class="mt-3 text-muted">欢迎使用钢琴练习方案管理系统</h4>
|
||||
<h4 class="mt-3 text-muted">欢迎使用有音个性化教学系统</h4>
|
||||
<p class="text-muted">从左侧菜单选择功能</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}钢琴练习方案管理系统{% endblock %}
|
||||
{% block title %}有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
@@ -1158,7 +1158,11 @@ async function generatePlan() {
|
||||
if (data.step === 'complete') {
|
||||
setTimeout(() => {
|
||||
progressModal.hide();
|
||||
if (data.ai_error) {
|
||||
alert('基础方案已生成,但AI报告失败: ' + data.ai_error);
|
||||
} else {
|
||||
alert('方案生成成功!');
|
||||
}
|
||||
showStudentDetail(currentStudentId);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 钢琴练习方案系统</title>
|
||||
<title>登录 - 有音个性化教学系统</title>
|
||||
<link rel="icon" type="image/jpeg" href="/static/images/logo-YoIn-200.jpg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
@@ -18,7 +19,7 @@
|
||||
<div class="login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-music-note-beamed" style="font-size: 48px; color: #667eea;"></i>
|
||||
<h4 class="mt-3">钢琴练习方案系统</h4>
|
||||
<h4 class="mt-3">有音个性化教学系统</h4>
|
||||
<p class="text-muted">请登录继续</p>
|
||||
</div>
|
||||
|
||||
@@ -90,5 +91,14 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.login-footer { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.9); padding: 10px; text-align: center; }
|
||||
.login-footer img { height: 30px; width: auto; }
|
||||
</style>
|
||||
<div class="login-footer">
|
||||
<img src="/static/images/有音-logo和字.png" alt="有音教育" style="height:25px;margin-right:20px;">
|
||||
<img src="/static/images/艺超-logo和字.png" alt="艺超音乐" style="height:25px;">
|
||||
<p class="small text-muted mb-0 mt-1">© 2024-2026 有音教育 & 艺超音乐 联合开发</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}方案详情 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}方案详情 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}编辑方案 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}编辑方案 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}方案管理 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}方案管理 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}问题配置 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}问题配置 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>确定要删除问题 <strong id="deleteProblemName"></strong> 吗?</p>
|
||||
<p class="text-muted small">删除后可在 bk 文件夹中找到备份</p>
|
||||
<p class="text-muted small">已被学员使用的问题无法删除</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>初始设置 - 钢琴练习方案系统</title>
|
||||
<title>初始设置 - 有音个性化教学系统</title>
|
||||
<link rel="icon" type="image/jpeg" href="/static/images/logo-YoIn-200.jpg">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
@@ -144,5 +145,14 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.setup-footer { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.9); padding: 10px; text-align: center; }
|
||||
.setup-footer img { height: 25px; width: auto; }
|
||||
</style>
|
||||
<div class="setup-footer">
|
||||
<img src="/static/images/有音-logo和字.png" alt="有音教育" style="height:20px;margin-right:15px;">
|
||||
<img src="/static/images/艺超-logo和字.png" alt="艺超音乐" style="height:20px;">
|
||||
<p class="small text-muted mb-0 mt-1">© 2024-2026 有音教育 & 艺超音乐 联合开发</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}数据统计 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}数据统计 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ student.name }} - 学员详情 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}{{ student.name }} - 学员详情 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@@ -943,7 +943,11 @@ async function startGeneratePlan() {
|
||||
generateModal.hide();
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '开始生成';
|
||||
if (data.ai_error) {
|
||||
alert('基础方案已生成,但AI报告失败: ' + data.ai_error);
|
||||
} else {
|
||||
alert('方案生成成功!');
|
||||
}
|
||||
loadPlans();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}模板管理 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}模板管理 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}用户管理 - 钢琴练习方案系统{% endblock %}
|
||||
{% block title %}用户管理 - 有音个性化教学系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from app import create_app, db
|
||||
from app.models import PracticePlan
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
plans = PracticePlan.query.order_by(db.desc("created_at")).limit(3).all()
|
||||
for p in plans:
|
||||
print(f"ID:{p.id} | created_at:{p.created_at} | student:{p.student.name if p.student else 'N/A'}")
|
||||
@@ -0,0 +1,9 @@
|
||||
from app import create_app, db
|
||||
from app.models import PracticePlan
|
||||
|
||||
# Query local dev database (same data as production)
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
plans = PracticePlan.query.order_by(db.desc("created_at")).limit(5).all()
|
||||
for p in plans:
|
||||
print(f"ID:{p.id} | created_at:{p.created_at} | student:{p.student.name if p.student else 'N/A'}")
|
||||
+302
-4
@@ -1,11 +1,258 @@
|
||||
# 钢琴练习方案系统 - 部署 SOP
|
||||
|
||||
> 版本:v1.5.2
|
||||
> 日期:2026-04-28
|
||||
> 版本:v1.5.7
|
||||
> 日期:2026-05-05
|
||||
> 核心原则:**不删除,只备份后新增/替换**
|
||||
|
||||
---
|
||||
|
||||
## 重要更新(v1.5.7)
|
||||
|
||||
### 🔧 动态API提供商管理
|
||||
|
||||
新增 API 提供商的新增、编辑、删除功能,替代原有的硬编码列表:
|
||||
|
||||
**功能特点:**
|
||||
- 新增提供商:填写ID/名称/Endpoint/模型列表
|
||||
- 编辑提供商:修改名称/Endpoint/模型列表(ID不可改)
|
||||
- 删除提供商:移除不再使用的提供商
|
||||
- 多模型支持:每个提供商可配置多个模型(>1个显示下拉框)
|
||||
|
||||
**OpenCode Go 预置配置:**
|
||||
- Endpoint:`https://opencode.ai/zen/go/v1`
|
||||
- 模型:`deepseek-v4-pro`、`deepseek-v4-flash`、`qwen3.6-plus`
|
||||
|
||||
**API 接口:**
|
||||
- `GET /api/config/providers` - 列出所有提供商
|
||||
- `POST /api/config/providers` - 新增提供商
|
||||
- `PUT /api/config/providers/<id>` - 编辑提供商
|
||||
- `DELETE /api/config/providers/<id>` - 删除提供商
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- 问题删除字段名错误(`problem_db_id` → `problem_id`)
|
||||
- 学员模型回退属性不存在(`problem_name` → "未知问题")
|
||||
- 删除问题提示文案过时
|
||||
- API Key 留空时保存报错(已有 key 则复用)
|
||||
- AI 失败时仍弹"方案生成成功"
|
||||
- API 超时 60s → 180s,max_tokens 2000 → 4096
|
||||
|
||||
### 📊 调试增强
|
||||
|
||||
- AI 生成日志显示 finish_reason、输入/输出 token 数
|
||||
- API 返回非 JSON 时显示原始内容和请求 URL
|
||||
- API 返回空内容时显示详细诊断信息
|
||||
|
||||
---
|
||||
|
||||
## 重要更新(v1.5.6)
|
||||
|
||||
### 🔒 安全防护:Nginx 限流配置
|
||||
|
||||
新增 Nginx 限流规则,防止爬虫和恶意请求:
|
||||
|
||||
**限流规则:**
|
||||
|
||||
| 路由 | 限流规则 | 说明 |
|
||||
|------|----------|------|
|
||||
| `/` (全站) | 30请求/秒, burst=50 | 正常用户够用 |
|
||||
| `/api/generate-plan` | 10请求/秒, burst=20 | 重点防护(AI生成接口) |
|
||||
|
||||
**超过限制返回:** `429 Too Many Requests`
|
||||
|
||||
**配置文件:**
|
||||
- 主配置:`/etc/nginx/nginx.conf`(限流zone定义)
|
||||
- 站点配置:`/etc/nginx/conf.d/piano.yoin.fun.conf`(限流规则应用)
|
||||
|
||||
**完整的 nginx.conf 限流部分(http{} 块中添加):**
|
||||
```nginx
|
||||
# 限流配置 - 防止爬虫和恶意请求
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=general_limit:10m rate=30r/s;
|
||||
limit_req_log_level warn;
|
||||
limit_req_status 429;
|
||||
```
|
||||
|
||||
**完整的站点配置(piano.yoin.fun.conf):**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name piano.yoin.fun;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
server_name piano.yoin.fun;
|
||||
location / {
|
||||
limit_req zone=general_limit burst=50 nodelay;
|
||||
proxy_pass http://172.17.0.1:5001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
tcp_nodelay on;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
}
|
||||
location /api/generate-plan {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
proxy_pass http://172.17.0.1:5001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
tcp_nodelay on;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffers 8 32k;
|
||||
proxy_buffer_size 32k;
|
||||
proxy_max_temp_file_size 1024m;
|
||||
}
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/piano.yoin.fun/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/piano.yoin.fun/privkey.pem;
|
||||
}
|
||||
```
|
||||
|
||||
**部署/修改方法:**
|
||||
```bash
|
||||
# 1. 备份当前配置
|
||||
docker exec nginx_server cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
|
||||
docker exec nginx_server cp /etc/nginx/conf.d/piano.yoin.fun.conf /etc/nginx/conf.d/piano.yoin.fun.conf.bak
|
||||
|
||||
# 2. 使用 sed 或直接编辑修改配置
|
||||
# 主配置:在 http{} 块中插入限流zone定义
|
||||
# 站点配置:在 location{} 块中添加 limit_req 指令
|
||||
|
||||
# 3. 测试配置
|
||||
docker exec nginx_server nginx -t -c /etc/nginx/nginx.conf
|
||||
|
||||
# 4. 重载nginx
|
||||
docker exec nginx_server nginx -s reload
|
||||
```
|
||||
|
||||
**常用命令:**
|
||||
```bash
|
||||
# 查看限流配置
|
||||
docker exec nginx_server cat /etc/nginx/conf.d/piano.yoin.fun.conf | grep limit_req
|
||||
|
||||
# 检查nginx状态
|
||||
docker exec nginx_server nginx -t
|
||||
|
||||
# 重载配置
|
||||
docker exec nginx_server nginx -s reload
|
||||
|
||||
# 查看nginx日志(限流日志)
|
||||
docker exec nginx_server tail -50 /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💾 数据备份:每日自动备份
|
||||
|
||||
新增服务器本地每日自动备份机制:
|
||||
|
||||
**备份配置:**
|
||||
- 备份路径:`/opt/backups/piano-db/`
|
||||
- 执行时间:每天凌晨 3:00
|
||||
- 保留期限:30 天
|
||||
- 备份方式:SQLite `.backup` 命令(保证一致性)
|
||||
- 服务状态:crond 运行中
|
||||
|
||||
**备份脚本位置:** `/opt/backups/backup_piano_db.sh`(宿主机,不受容器重建影响)
|
||||
|
||||
**完整脚本内容:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Piano Plan 数据库每日备份脚本
|
||||
# 保留30天
|
||||
# 自包含:部署后依然有效,不依赖容器内预存文件
|
||||
|
||||
BACKUP_DIR="/opt/backups/piano-db"
|
||||
RETENTION_DAYS=30
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="piano_plans_${TIMESTAMP}.db"
|
||||
SCRIPT_FILE="/tmp/backup_piano_$$.py"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 创建临时Python脚本(写在宿主机,然后复制到容器)
|
||||
cat > "$SCRIPT_FILE" << 'PYEOF'
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/app/data/piano_plans.db')
|
||||
backup = sqlite3.connect('/tmp/piano_backup.db')
|
||||
conn.backup(backup)
|
||||
backup.close()
|
||||
conn.close()
|
||||
PYEOF
|
||||
|
||||
# 复制到容器并执行
|
||||
docker cp "$SCRIPT_FILE" piano-plan:/tmp/backup_piano.py
|
||||
rm -f "$SCRIPT_FILE"
|
||||
docker exec piano-plan python3 /tmp/backup_piano.py
|
||||
|
||||
# 复制备份到宿主机
|
||||
docker cp piano-plan:/tmp/piano_backup.db "$BACKUP_DIR/$BACKUP_FILE"
|
||||
|
||||
# 清理容器内文件
|
||||
docker exec piano-plan rm -f /tmp/backup_piano.py /tmp/piano_backup.db
|
||||
|
||||
# 验证
|
||||
if [ -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
|
||||
echo "[$(date)] Backup OK: $BACKUP_FILE ($SIZE)"
|
||||
|
||||
# 删除30天前的备份
|
||||
find "$BACKUP_DIR" -name "piano_plans_*.db" -mtime +${RETENTION_DAYS} -delete
|
||||
echo "[$(date)] Cleanup done (retained $RETENTION_DAYS days)"
|
||||
else
|
||||
echo "[$(date)] Backup FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls -lh "$BACKUP_DIR"
|
||||
```
|
||||
|
||||
**设置 cron 任务:**
|
||||
```bash
|
||||
# 添加到 crontab
|
||||
(crontab -l 2>/dev/null; echo '0 3 * * * /opt/backups/backup_piano_db.sh >> /opt/backups/backup.log 2>&1') | crontab -
|
||||
|
||||
# 验证
|
||||
crontab -l
|
||||
```
|
||||
|
||||
**常用命令:**
|
||||
```bash
|
||||
# 查看备份
|
||||
ls -lh /opt/backups/piano-db/
|
||||
|
||||
# 手动执行备份
|
||||
/opt/backups/backup_piano_db.sh
|
||||
|
||||
# 查看 cron 配置
|
||||
crontab -l
|
||||
|
||||
# 删除旧的 cron 任务(如果需要)
|
||||
crontab -e # 编辑模式删除对应行
|
||||
```
|
||||
|
||||
**备份恢复:**
|
||||
```bash
|
||||
# 停止容器
|
||||
docker stop piano-plan
|
||||
|
||||
# 复制备份文件到容器
|
||||
docker cp /opt/backups/piano-db/piano_plans_XXXXXXXX_XXXXXX.db piano-plan:/app/data/piano_plans.db
|
||||
|
||||
# 启动容器
|
||||
docker start piano-plan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重要更新(v1.5.2)
|
||||
|
||||
### ✨ 导出预览功能
|
||||
@@ -447,6 +694,8 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|------|------|------|
|
||||
| v1.5.7 | 2026-05-05 | 动态API提供商管理(新增/编辑/删除/多模型);OpenCode Go集成;Bug修复(问题删除、空key保存、AI失败弹窗);调试增强 |
|
||||
| v1.5.6 | 2026-04-30 | Nginx限流配置(防爬虫/恶意请求);每日自动备份(30天保留) |
|
||||
| v1.5.5 | 2026-04-28 | 修复容器时区(TZ=Asia/Shanghai);学员列表"x个方案"可点击跳转最新方案详情 |
|
||||
| v1.5.4 | 2026-04-28 | PDF正文字体12pt、表格11pt |
|
||||
| v1.5.3 | 2026-04-28 | PDF行间距修复(7mm→2mm) |
|
||||
@@ -467,5 +716,54 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
|
||||
|
||||
---
|
||||
|
||||
> **最后更新**:2026-04-28
|
||||
> **更新原因**:v1.5.2 - 导出预览功能;目标换行修复;居中语法支持
|
||||
## 十二、Ops 运维脚本
|
||||
|
||||
### 12.1 备份脚本 (scripts/ops/backup_piano_db.sh)
|
||||
|
||||
**位置**:`scripts/ops/backup_piano_db.sh`(项目目录)
|
||||
|
||||
**上传到服务器**:
|
||||
```bash
|
||||
scp -i ~/.ssh/id_rsa scripts/ops/backup_piano_db.sh root@47.115.32.206:/opt/backups/backup_piano_db.sh
|
||||
chmod +x /opt/backups/backup_piano_db.sh
|
||||
```
|
||||
|
||||
**设置定时任务**:
|
||||
```bash
|
||||
ssh -i ~/.ssh/id_rsa root@47.115.32.206
|
||||
(crontab -l 2>/dev/null; echo '0 3 * * * /opt/backups/backup_piano_db.sh >> /opt/backups/backup.log 2>&1') | crontab -
|
||||
```
|
||||
|
||||
**验证设置**:
|
||||
```bash
|
||||
crontab -l
|
||||
ls -lh /opt/backups/piano-db/
|
||||
```
|
||||
|
||||
### 12.2 脚本特性
|
||||
|
||||
- **自包含**:不依赖容器内预存文件
|
||||
- **幂等性**:可重复执行
|
||||
- **验证**:备份后自动验证文件存在
|
||||
- **清理**:自动删除 30 天前的备份
|
||||
|
||||
### 12.3 恢复备份
|
||||
|
||||
```bash
|
||||
# 1. 停止容器
|
||||
docker stop piano-plan
|
||||
|
||||
# 2. 列出可用备份
|
||||
ls -lh /opt/backups/piano-db/
|
||||
|
||||
# 3. 复制备份到容器
|
||||
docker cp /opt/backups/piano-db/piano_plans_XXXXXXXX_XXXXXX.db piano-plan:/app/data/piano_plans.db
|
||||
|
||||
# 4. 启动容器
|
||||
docker start piano-plan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **最后更新**:2026-05-05
|
||||
> **更新原因**:v1.5.7 - 动态API提供商管理;OpenCode Go集成;Bug修复
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# PDF URL→二维码功能设计
|
||||
|
||||
## 需求
|
||||
|
||||
导出 PDF 时,如果发现文本内容中有 URL,自动将 URL 转换为二维码,扫描可跳转。
|
||||
|
||||
## 规则
|
||||
|
||||
- **检测位置**:任意位置(AI报告内容、标题等所有文本)
|
||||
- **替代方式**:二维码替代原 URL 文字
|
||||
- **二维码大小**:60x60pt(约2.5cm)
|
||||
- **放置位置**:URL 原文字位置
|
||||
|
||||
## 技术方案
|
||||
|
||||
**新增依赖:**
|
||||
- `qrcode` Python 包
|
||||
|
||||
**修改文件:**
|
||||
- `app/services/pdf_generator.py`
|
||||
|
||||
**实现步骤:**
|
||||
1. 安装 qrcode 包
|
||||
2. 在 `generate_pdf()` 中,正则检测 URL(`http://` 或 `https://` 开头)
|
||||
3. 检测到 URL 时,生成二维码 PNG 图片
|
||||
4. 用 ReportLab 的 `Image` flowable 嵌入 PDF,替代原 URL 文字
|
||||
|
||||
**二维码生成方式:**
|
||||
```python
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
|
||||
def generate_qr_image(url, size=60):
|
||||
qr = qrcode.make(url)
|
||||
buf = BytesIO()
|
||||
qr.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return buf
|
||||
```
|
||||
|
||||
**URL 正则:** `https?://[^\s<>"{}|\\^`\[\]]+`
|
||||
@@ -5,3 +5,5 @@ Jinja2==3.1.3
|
||||
requests==2.31.0
|
||||
gunicorn==23.0.0
|
||||
markdown>=3.4
|
||||
qrcode>=7.4
|
||||
Pillow>=10.0
|
||||
Reference in New Issue
Block a user