feat: add export preview with template rendering and watermark support

This commit is contained in:
hmo
2026-04-28 12:37:08 +08:00
parent c6f7d9f7e3
commit 08302ab82b
3 changed files with 230 additions and 100 deletions
+27 -67
View File
@@ -763,22 +763,21 @@ def export_md(plan_id):
) )
@main_bp.route("/api/plans/<int:plan_id>/docx", methods=["GET"]) @main_bp.route("/api/plans/<int:plan_id>/preview", methods=["GET"])
@login_required_json @login_required_json
def export_docx(plan_id): def preview_report(plan_id):
"""导出DOCX - 使用pandoc将markdown转为docx""" """预览报告模板渲染结果 - 返回HTML片段"""
import subprocess, tempfile, os, shutil import markdown
from urllib.parse import quote from app.models import Template
plan = PracticePlan.query.get_or_404(plan_id) plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content) content = json.loads(plan.content)
student_name = plan.student.name if plan.student else "未知学员" student_name = plan.student.name if plan.student else "未知学员"
template_id = request.args.get('template_id', type=int)
# 获取报告模板(复用export_md的逻辑) # 获取选中的报告模板
template_id = request.args.get('template_id', type=int)
report_template = None report_template = None
try: try:
from app.models import Template
if template_id: if template_id:
tmpl = Template.query.get(template_id) tmpl = Template.query.get(template_id)
if tmpl and tmpl.type == "report": if tmpl and tmpl.type == "report":
@@ -790,12 +789,16 @@ def export_docx(plan_id):
except: except:
pass pass
if report_template: if not report_template:
return "<div class='text-muted'>无可用模板</div>", 200
# 占位符替换(复用 export_pdf 逻辑)
rendered = report_template rendered = report_template
rendered = rendered.replace("{student_name}", student_name) rendered = rendered.replace("{student_name}", student_name)
rendered = rendered.replace("{practice_time}", content.get('practice_time', 'N/A')) 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("{total_minutes}", str(content.get('total_daily_minutes', 0)))
rendered = rendered.replace("{generated_at}", content.get('generated_at', '')) rendered = rendered.replace("{generated_at}", content.get('generated_at', ''))
from app.models import User from app.models import User
user_id = session.get('user_id') user_id = session.get('user_id')
user_name = '未知' user_name = '未知'
@@ -804,83 +807,40 @@ def export_docx(plan_id):
if user and user.name: if user and user.name:
user_name = user.name user_name = user.name
rendered = rendered.replace("{generated_by}", user_name) rendered = rendered.replace("{generated_by}", user_name)
if content.get('ai_report'): if content.get('ai_report'):
rendered = rendered.replace("{ai_report}", content['ai_report']) rendered = rendered.replace("{ai_report}", content['ai_report'])
else: else:
rendered = rendered.replace("{ai_report}", "(未生成AI报告)") rendered = rendered.replace("{ai_report}", "(未生成AI报告)")
problem_tags = "" problem_tags = ""
for problem in content.get('problems', []): for problem in content.get('problems', []):
problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n" problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n"
rendered = rendered.replace("{problem_tags}", problem_tags or "(无)") rendered = rendered.replace("{problem_tags}", problem_tags or "(无)")
# 学员目标 # 学员目标
from app.models import StudentGoal from app.models import StudentGoal
student_goals_list = StudentGoal.query.filter_by(student_id=plan.student_id).all() if plan.student_id else [] student_goals_list = StudentGoal.query.filter_by(student_id=plan.student_id).all() if plan.student_id else []
goals_text_parts = [] goals_text_parts = []
for g in student_goals_list: for g in student_goals_list:
if g.status != "已完成": if g.status != "已完成":
goals_text_parts.append(f"- **{g.goal.name}**\n {g.goal.content if g.goal else '未提供具体内容'}") goal_content = (g.goal.content if g.goal else '未提供具体内容').replace('\n', '<br>')
goals_text = "\n".join(goals_text_parts) if goals_text_parts else "(无)" goals_text_parts.append(f"- **{g.goal.name}**<br>{goal_content}")
goals_text = "<br>".join(goals_text_parts) if goals_text_parts else "(无)"
rendered = rendered.replace("{student_goals}", goals_text) 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 # Markdown 转 HTML
tmp_dir = tempfile.mkdtemp() html_content = markdown.markdown(rendered, extensions=['tables', 'fenced_code'])
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: # 支持 ReportLab <para alignment="center"> 语法,转为 HTML
f.write(md_content) import re
html_content = re.sub(
try: r'<para\s+alignment="center">(.+?)</para>',
subprocess.run( r'<div style="text-align:center">\1</div>',
["pandoc", md_path, "-o", docx_path], html_content
check=True, capture_output=True
) )
# 添加水印 return html_content, 200, {'Content-Type': 'text/html; charset=utf-8'}
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"])
+174 -5
View File
@@ -2,6 +2,124 @@
{% block title %}方案详情 - 钢琴练习方案系统{% endblock %} {% block title %}方案详情 - 钢琴练习方案系统{% endblock %}
{% block extra_css %}
<style>
/* 导出预览样式 - 镜像PDF效果 */
.preview-content {
font-family: "Microsoft YaHei", "微软雅黑", sans-serif;
font-size: 12px;
line-height: 1.6;
color: #333;
padding: 20px;
background: white;
}
.preview-content h1 {
font-size: 24px;
font-weight: bold;
margin: 0 0 16px 0;
text-align: center;
}
.preview-content h2 {
font-size: 20px;
font-weight: bold;
margin: 16px 0 12px 0;
border-bottom: 1px solid #ddd;
padding-bottom: 4px;
}
.preview-content h3 {
font-size: 16px;
font-weight: bold;
margin: 12px 0 8px 0;
}
.preview-content p {
margin: 8px 0;
}
.preview-content ul, .preview-content ol {
margin: 8px 0;
padding-left: 24px;
}
.preview-content li {
margin: 4px 0;
}
.preview-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 12px;
}
.preview-content table th,
.preview-content table td {
border: 1px solid #ddd;
padding: 4px 8px;
text-align: left;
}
.preview-content table th {
background: #f5f5f5;
font-weight: bold;
}
.preview-content strong {
font-weight: bold;
}
.preview-content em {
font-style: italic;
}
.preview-content code {
font-family: Consolas, monospace;
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
}
.preview-content blockquote {
border-left: 3px solid #ddd;
margin: 8px 0;
padding-left: 12px;
color: #666;
}
.preview-watermark-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 10;
overflow: hidden;
}
.preview-watermark-overlay::before {
content: attr(data-watermark);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 48px;
font-weight: bold;
color: rgba(200, 200, 200, 0.3);
white-space: nowrap;
z-index: 11;
display: none;
}
.preview-watermark-overlay.watermark-active::before {
display: block;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-file-text"></i> 方案详情</h4> <h4><i class="bi bi-file-text"></i> 方案详情</h4>
@@ -81,10 +199,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="downloadDOCXWithTemplate()" class="btn btn-sm btn-outline-primary"> <button onclick="showPreview()" class="btn btn-sm btn-outline-success">
<i class="bi bi-file-word"></i> 下载DOCX <i class="bi bi-eye"></i> 预览
</button> </button>
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-secondary"> <button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-primary" style="display:none">
<i class="bi bi-file-markdown"></i> 下载MD <i class="bi bi-file-markdown"></i> 下载MD
</button> </button>
</div> </div>
@@ -141,10 +259,45 @@ function downloadMDWithTemplate() {
window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank'); window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank');
} }
function downloadDOCXWithTemplate() { async function showPreview() {
const templateId = document.getElementById('reportTemplateSelect')?.value; const templateId = document.getElementById('reportTemplateSelect')?.value;
const planId = currentPlanId;
const suffix = templateId ? `?template_id=${templateId}` : ''; const suffix = templateId ? `?template_id=${templateId}` : '';
window.open(`/api/plans/${currentPlanId}/docx${suffix}`, '_blank');
try {
const resp = await fetch(`/api/plans/${planId}/preview${suffix}`);
if (!resp.ok) {
alert('预览加载失败');
return;
}
const html = await resp.text();
document.getElementById('previewContent').innerHTML = html;
// 加载水印配置
try {
const cfgResp = await fetch('/api/config');
if (cfgResp.ok) {
const cfg = await cfgResp.json();
const wmText = cfg.watermark_text || '';
const wmEl = document.getElementById('previewWatermark');
if (wmText) {
wmEl.setAttribute('data-watermark', wmText);
wmEl.classList.add('watermark-active');
} else {
wmEl.classList.remove('watermark-active');
}
}
} catch (e) {
console.log('水印配置加载失败', e);
}
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
} catch (err) {
console.error('预览错误:', err);
alert('预览加载失败');
}
} }
async function loadTemplates() { async function loadTemplates() {
@@ -195,4 +348,20 @@ window.currentStudentId = null;
loadPlan(); loadPlan();
</script> </script>
<!-- 导出预览模态框 -->
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">导出预览</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>
</div>
<div class="modal-body" style="position: relative; overflow: hidden;">
<div id="previewWatermark" class="preview-watermark-overlay"></div>
<div id="previewContent" class="preview-content"></div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+1
View File
@@ -4,3 +4,4 @@ reportlab==4.0.7
Jinja2==3.1.3 Jinja2==3.1.3
requests==2.31.0 requests==2.31.0
gunicorn==23.0.0 gunicorn==23.0.0
markdown>=3.4