feat: add DOCX export endpoint and button with watermark support

This commit is contained in:
hmo
2026-04-28 11:13:04 +08:00
parent 8067e0587e
commit f3233e2374
2 changed files with 130 additions and 1 deletions
+120
View File
@@ -763,6 +763,126 @@ def export_md(plan_id):
) )
@main_bp.route("/api/plans/<int:plan_id>/docx", methods=["GET"])
@login_required_json
def export_docx(plan_id):
"""导出DOCX - 使用pandoc将markdown转为docx"""
import subprocess, tempfile, os, shutil
from urllib.parse import quote
plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content)
student_name = plan.student.name if plan.student else "未知学员"
template_id = request.args.get('template_id', type=int)
# 获取报告模板(复用export_md的逻辑)
report_template = None
try:
from app.models import Template
if template_id:
tmpl = Template.query.get(template_id)
if tmpl and tmpl.type == "report":
report_template = tmpl.content
else:
tmpl = Template.query.filter_by(type="report").order_by(Template.sort_order.asc()).first()
if tmpl:
report_template = tmpl.content
except:
pass
if report_template:
rendered = report_template
rendered = rendered.replace("{student_name}", student_name)
rendered = rendered.replace("{practice_time}", content.get('practice_time', 'N/A'))
rendered = rendered.replace("{total_minutes}", str(content.get('total_daily_minutes', 0)))
rendered = rendered.replace("{generated_at}", content.get('generated_at', ''))
from app.models import User
user_id = session.get('user_id')
user_name = '未知'
if user_id:
user = User.query.get(user_id)
if user and user.name:
user_name = user.name
rendered = rendered.replace("{generated_by}", user_name)
if content.get('ai_report'):
rendered = rendered.replace("{ai_report}", content['ai_report'])
else:
rendered = rendered.replace("{ai_report}", "(未生成AI报告)")
problem_tags = ""
for problem in content.get('problems', []):
problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n"
rendered = rendered.replace("{problem_tags}", problem_tags or "(无)")
# 学员目标
from app.models import StudentGoal
student_goals_list = StudentGoal.query.filter_by(student_id=plan.student_id).all() if plan.student_id else []
goals_text_parts = []
for g in student_goals_list:
if g.status != "已完成":
goals_text_parts.append(f"- **{g.goal.name}**\n {g.goal.content if g.goal else '未提供具体内容'}")
goals_text = "\n".join(goals_text_parts) if goals_text_parts else "(无)"
rendered = rendered.replace("{student_goals}", goals_text)
md_content = rendered
else:
md_lines = [
f"# {student_name} - 个性化练习方案\n",
f"**练习时间**: {content.get('practice_time', 'N/A')} (共{content.get('total_daily_minutes', 0)}分钟)\n",
f"**生成时间**: {content.get('generated_at', '')}\n",
"\n---\n",
]
if content.get('ai_report'):
md_lines.append(content['ai_report'])
md_content = ''.join(md_lines)
# 转换markdown为docx
tmp_dir = tempfile.mkdtemp()
md_path = os.path.join(tmp_dir, f"plan_{plan_id}.md")
docx_path = os.path.join(tmp_dir, f"{student_name}_练习方案.docx")
with open(md_path, "w", encoding="utf-8") as f:
f.write(md_content)
try:
subprocess.run(
["pandoc", md_path, "-o", docx_path],
check=True, capture_output=True
)
# 添加水印
from app.config import load_api_config
api_config = load_api_config(current_app.config)
wm_text = api_config.get("watermark_text", "")
if wm_text:
from docx import Document
doc = Document(docx_path)
section = doc.sections[0]
header = section.header
# 清除现有header内容
for para in header.paragraphs:
for run in para.runs:
run.text = ""
# 添加水印paragraph
header_para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
header_para.text = wm_text
header_para.alignment = 1 # CENTER (WD_ALIGN_PARAGRAPH.CENTER = 1)
for run in header_para.runs:
run.font.size = Pt(48)
run.font.color.rgb = RGBColor(180, 180, 180)
run.font.name = "Arial"
doc.save(docx_path)
with open(docx_path, "rb") as f:
docx_data = f.read()
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
filename = quote(f"{student_name}_练习方案.docx")
return Response(
docx_data,
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers={'Content-Disposition': f"attachment; filename*=UTF-8''{filename}"}
)
@main_bp.route("/plans/<int:plan_id>/wechat", methods=["GET"]) @main_bp.route("/plans/<int:plan_id>/wechat", methods=["GET"])
@login_required_json @login_required_json
def wechat_card(plan_id): def wechat_card(plan_id):
+10 -1
View File
@@ -81,7 +81,10 @@ async function loadPlan() {
<button onclick="downloadPDFWithTemplate()" class="btn btn-sm btn-primary"> <button onclick="downloadPDFWithTemplate()" class="btn btn-sm btn-primary">
<i class="bi bi-download"></i> 下载PDF <i class="bi bi-download"></i> 下载PDF
</button> </button>
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-primary"> <button onclick="downloadDOCXWithTemplate()" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-word"></i> 下载DOCX
</button>
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-markdown"></i> 下载MD <i class="bi bi-file-markdown"></i> 下载MD
</button> </button>
</div> </div>
@@ -138,6 +141,12 @@ function downloadMDWithTemplate() {
window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank'); window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank');
} }
function downloadDOCXWithTemplate() {
const templateId = document.getElementById('reportTemplateSelect')?.value;
const suffix = templateId ? `?template_id=${templateId}` : '';
window.open(`/api/plans/${currentPlanId}/docx${suffix}`, '_blank');
}
async function loadTemplates() { async function loadTemplates() {
try { try {
const resp = await fetch('/templates/templates?type=report'); const resp = await fetch('/templates/templates?type=report');