Compare commits
4 Commits
f3233e2374
...
f14dd3435e
| Author | SHA1 | Date | |
|---|---|---|---|
| f14dd3435e | |||
| 08302ab82b | |||
| c6f7d9f7e3 | |||
| e3aeee0af2 |
+27
-67
@@ -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"])
|
||||||
|
|||||||
@@ -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: auto;">
|
||||||
|
<div id="previewWatermark" class="preview-watermark-overlay"></div>
|
||||||
|
<div id="previewContent" class="preview-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+29
-2
@@ -1,11 +1,35 @@
|
|||||||
# 钢琴练习方案系统 - 部署 SOP
|
# 钢琴练习方案系统 - 部署 SOP
|
||||||
|
|
||||||
> 版本:v1.5.1
|
> 版本:v1.5.2
|
||||||
> 日期:2026-04-28
|
> 日期:2026-04-28
|
||||||
> 核心原则:**不删除,只备份后新增/替换**
|
> 核心原则:**不删除,只备份后新增/替换**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 重要更新(v1.5.2)
|
||||||
|
|
||||||
|
### ✨ 导出预览功能
|
||||||
|
|
||||||
|
在方案详情页新增「预览」按钮,点击弹出模态框展示套用模板导出后的最终效果(所见即所得)。
|
||||||
|
|
||||||
|
**功能特点:**
|
||||||
|
- 预览内容与 PDF 导出效果一致(字体、标题层级、表格样式)
|
||||||
|
- 支持水印预览(如已配置)
|
||||||
|
- 内容超出时可滚动查看
|
||||||
|
- 支持 `<para alignment="center">` 居中语法(预览和 PDF 导出均支持)
|
||||||
|
|
||||||
|
**模板居中写法:**
|
||||||
|
```html
|
||||||
|
<para alignment="center">【{generated_by} 撰写于 {generated_at}】</para>
|
||||||
|
```
|
||||||
|
|
||||||
|
**技术实现:**
|
||||||
|
- 后端:`GET /api/plans/<id>/preview` 返回渲染后的 HTML
|
||||||
|
- 前端:Bootstrap 模态框 + CSS 镜像 PDF 样式
|
||||||
|
- 新增依赖:`markdown` Python 包(Markdown → HTML 转换)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 重要更新(v1.5.1)
|
## 重要更新(v1.5.1)
|
||||||
|
|
||||||
### ⚠️ 问题文件已迁移到数据库
|
### ⚠️ 问题文件已迁移到数据库
|
||||||
@@ -409,6 +433,8 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
|
|||||||
[ ] PDF 水印功能正常(配置后导出可见)
|
[ ] PDF 水印功能正常(配置后导出可见)
|
||||||
[ ] 数据统计页面正常显示
|
[ ] 数据统计页面正常显示
|
||||||
[ ] 导出PDF时 {student_goals} 正常显示学员目标
|
[ ] 导出PDF时 {student_goals} 正常显示学员目标
|
||||||
|
[ ] 导出预览功能正常:预览按钮、模态框、水印显示
|
||||||
|
[ ] 模板支持 <para alignment="center"> 居中语法(预览和PDF均有效)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -417,6 +443,7 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更 |
|
| 版本 | 日期 | 变更 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| v1.5.2 | 2026-04-28 | 导出预览功能(预览按钮+模态框+水印);目标内容换行修复;支持<para alignment="center">居中语法;隐藏MD下载按钮 |
|
||||||
| v1.5.1 | 2026-04-28 | PDF水印配置保存修复(3处漏改);{student_goals}占位符修复;移除目标导出时的"内容:"标签 |
|
| v1.5.1 | 2026-04-28 | PDF水印配置保存修复(3处漏改);{student_goals}占位符修复;移除目标导出时的"内容:"标签 |
|
||||||
| v1.5.0 | 2026-04-27 | 数据统计页面(问题/级别分布可视化);PDF水印配置(可自定义文本);编辑页按钮吸底;侧边栏顺序调整;Linux中文字体路径修复 |
|
| v1.5.0 | 2026-04-27 | 数据统计页面(问题/级别分布可视化);PDF水印配置(可自定义文本);编辑页按钮吸底;侧边栏顺序调整;Linux中文字体路径修复 |
|
||||||
| v1.4.0 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化(bfcache处理);审计字段完善(created_by/updated_by/updated_at);方案列表支持删除;学员列表"暂无方案/问题"样式统一 |
|
| v1.4.0 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化(bfcache处理);审计字段完善(created_by/updated_by/updated_at);方案列表支持删除;学员列表"暂无方案/问题"样式统一 |
|
||||||
@@ -434,4 +461,4 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
|
|||||||
---
|
---
|
||||||
|
|
||||||
> **最后更新**:2026-04-28
|
> **最后更新**:2026-04-28
|
||||||
> **更新原因**:v1.5.1 补丁;PDF水印保存漏改;{student_goals}占位符修复;清理重复检查清单
|
> **更新原因**:v1.5.2 - 导出预览功能;目标换行修复;居中语法支持
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
# 导出预览功能实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在方案详情页新增「预览」功能,用户选择模板后可即时看到套用模板导出后的最终效果(所见即所得,接近 PDF 视觉效果)
|
||||||
|
|
||||||
|
**Architecture:** 后端新增 preview 路由,复用 export_pdf 的模板渲染逻辑,将 Markdown 转 HTML 后返回;前端用模态框 + CSS 镜像 PDF 样式,包括水印效果
|
||||||
|
|
||||||
|
**Tech Stack:** Flask, markdown (Python), marked.js, Bootstrap 5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更总览
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `requirements.txt` | 添加 `markdown` 包 |
|
||||||
|
| `app/routes/plans.py` | 新增 `/api/plans/<id>/preview` 路由 |
|
||||||
|
| `app/templates/plan_detail.html` | 新增预览按钮 + 模态框 + CSS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 安装 markdown 包
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `requirements.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 markdown 到 requirements.txt**
|
||||||
|
|
||||||
|
在 requirements.txt 末尾添加:
|
||||||
|
```
|
||||||
|
markdown>=3.4
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 安装到 venv**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
venv\Scripts\pip install markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证安装**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv\Scripts\python -c "import markdown; print(markdown.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出类似:`3.4.3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 新增后端 preview 路由
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/routes/plans.py` (在 `export_md` 函数之后添加新路由)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 找到 export_md 函数的位置**
|
||||||
|
|
||||||
|
在 plans.py 中找到 `def export_md(plan_id):` 和它结束的位置(下一个 `@main_bp.route` 之前)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 export_md 结束后添加 preview 路由**
|
||||||
|
|
||||||
|
在 `export_md` 路由结束处(`@main_bp.route("/plans/<int:plan_id>/wechat"` 之前)添加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@main_bp.route("/api/plans/<int:plan_id>/preview", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def preview_report(plan_id):
|
||||||
|
"""预览报告模板渲染结果 - 返回HTML片段"""
|
||||||
|
import markdown
|
||||||
|
from app.models import Template
|
||||||
|
|
||||||
|
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)
|
||||||
|
report_template = None
|
||||||
|
try:
|
||||||
|
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 not report_template:
|
||||||
|
return "<div class='text-muted'>无可用模板</div>", 200
|
||||||
|
|
||||||
|
# 占位符替换(复用 export_pdf 逻辑)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Markdown 转 HTML
|
||||||
|
html_content = markdown.markdown(rendered, extensions=['tables', 'fenced_code'])
|
||||||
|
|
||||||
|
return html_content, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证路由语法正确**
|
||||||
|
|
||||||
|
运行开发服务器检查是否有语法错误:
|
||||||
|
```bash
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
venv\Scripts\python -c "from app import create_app; app = create_app(); print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:`OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 前端添加预览模态框
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/plan_detail.html` (在文件末尾 `</div>` 之前添加模态框)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 plan_detail.html 末尾添加预览模态框**
|
||||||
|
|
||||||
|
在 `</div>` (最后一个 closing div) 之前添加:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 导出预览模态框 -->
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加预览区域的 CSS 样式**
|
||||||
|
|
||||||
|
在 `plan_detail.html` 的 `<style>` 区块中添加(如果页面没有 `<style>` 区块,需要在 `<head>` 中添加):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 导出预览样式 - 镜像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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 添加预览按钮
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/plan_detail.html` (在导出按钮区)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在导出按钮区添加「预览」按钮**
|
||||||
|
|
||||||
|
找到导出按钮区(应该有 `downloadPDFWithTemplate` 和 `downloadMDWithTemplate` 按钮),在 PDF 按钮之后、MD 按钮之前添加预览按钮:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button onclick="downloadPDFWithTemplate()" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-download"></i> 下载PDF
|
||||||
|
</button>
|
||||||
|
<button onclick="showPreview()" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-eye"></i> 预览
|
||||||
|
</button>
|
||||||
|
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-file-markdown"></i> 下载MD
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 添加预览 JS 逻辑
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/plan_detail.html` (在 `<script>` 区块中添加 JS 函数)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 showPreview 函数**
|
||||||
|
|
||||||
|
在 `downloadMDWithTemplate` 函数之后添加:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function showPreview() {
|
||||||
|
const templateId = document.getElementById('reportTemplateSelect')?.value;
|
||||||
|
const planId = currentPlanId;
|
||||||
|
const suffix = templateId ? `?template_id=${templateId}` : '';
|
||||||
|
|
||||||
|
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('预览加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 确保 marked.js 可用**
|
||||||
|
|
||||||
|
检查 `plan_detail.html` 是否已引入 marked.js。如果没有,在 `<head>` 中添加:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
(注意:marked 已经用于渲染 AI 报告区域,如果页面已有引入则跳过此步)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 验证完整功能
|
||||||
|
|
||||||
|
- [ ] **Step 1: 启动开发服务器**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
venv\Scripts\python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 打开方案详情页测试**
|
||||||
|
|
||||||
|
访问 http://127.0.0.1:5001/plan/26(选择一个有内容的方案)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 测试预览功能**
|
||||||
|
|
||||||
|
1. 选择一个报告模板
|
||||||
|
2. 点击「预览」按钮
|
||||||
|
3. 确认模态框显示的内容与 PDF 导出效果一致
|
||||||
|
4. 确认水印显示(如已配置)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证 Markdown 渲染正确**
|
||||||
|
|
||||||
|
检查预览中的:
|
||||||
|
- 标题层级(# ## ###)
|
||||||
|
- 粗体(**text**)
|
||||||
|
- 列表(- item)
|
||||||
|
- 表格(| col |)
|
||||||
|
- 水印(如已配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 提交代码
|
||||||
|
|
||||||
|
- [ ] **Step 1: 检查变更**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add requirements.txt app/routes/plans.py app/templates/plan_detail.html
|
||||||
|
git commit -m "feat: add export preview with template rendering and watermark support"
|
||||||
|
```
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 导出预览功能设计
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
在方案详情页提供「预览」功能,让用户在选择模板后能立即看到套用模板导出后的最终效果(所见即所得),无需实际下载文件。
|
||||||
|
|
||||||
|
## 核心思路
|
||||||
|
|
||||||
|
复用现有模板渲染逻辑,在后端完成 `{placeholder}` 替换后返回 HTML,前端用 CSS 镜像 PDF 视觉效果。
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
**新增 API:**
|
||||||
|
```
|
||||||
|
GET /api/plans/<plan_id>/preview?template_id=<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:** 返回 HTML 片段
|
||||||
|
```html
|
||||||
|
<div class="preview-content">
|
||||||
|
<!-- 渲染后的模板内容 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现步骤:**
|
||||||
|
1. 复用 `export_pdf` 的模板渲染逻辑(`report_template` + 所有占位符替换)
|
||||||
|
2. 模板内容中 `{ai_report}` 已经是 Markdown 格式,替换后得到完整 Markdown
|
||||||
|
3. 后端调用 `markdown.markdown()` 把 Markdown 转成 HTML(安装 markdown 包)
|
||||||
|
4. 返回 HTML 片段
|
||||||
|
|
||||||
|
**注意:** 不在水印层面处理——水印效果由前端 CSS 实现。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
**模态框:**
|
||||||
|
```html
|
||||||
|
<!-- 预览模态框 -->
|
||||||
|
<div class="modal fade" id="previewModal" ...>
|
||||||
|
<div class="modal-dialog modal-xl"> <!-- 超大模态框保证PDF效果可见 -->
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">导出预览</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="preview-watermark-overlay" id="previewWatermark"></div>
|
||||||
|
<div class="preview-content" id="previewContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**预览区域 CSS(镜像 PDF 样式):**
|
||||||
|
- 字体:Microsoft YaHei, sans-serif
|
||||||
|
- h1: 24px, bold
|
||||||
|
- h2: 20px, bold
|
||||||
|
- h3: 16px, bold
|
||||||
|
- body: 12px, line-height 1.6
|
||||||
|
- 表格:1px solid border, collapse, td padding 4px 8px
|
||||||
|
- 列表:left padding, list-style disc
|
||||||
|
- **水印:** CSS fixed overlay,45度旋转,半透明灰色大字 "机构名称"
|
||||||
|
|
||||||
|
**水印配置:**
|
||||||
|
- 从 `/api/config` 获取 `watermark_text` 配置
|
||||||
|
- 前端在模态框 body 内叠加水印层(`position: relative` + fixed watermark div)
|
||||||
|
|
||||||
|
**交互流程:**
|
||||||
|
1. 点击「预览」按钮
|
||||||
|
2. 读取当前选中的 `reportTemplateSelect` 值
|
||||||
|
3. `fetch('/api/plans/{id}/preview?template_id={tid}')`
|
||||||
|
4. 填充到 `#previewContent`,显示模态框
|
||||||
|
5. 如果配置了水印,在模态框内叠加水印效果
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- `markdown` Python 包:后端 Markdown→HTML 转换(pip install markdown)
|
||||||
|
- `marked` JS 库:前端已有(plan_detail.html 已引入 CDN)
|
||||||
|
- Bootstrap 5 模态框:前端已有
|
||||||
|
|
||||||
|
### 页面改动
|
||||||
|
|
||||||
|
**plan_detail.html 导出按钮区:**
|
||||||
|
```html
|
||||||
|
[模板选择] [下载PDF] [预览] [下载MD]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件变更
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `app/routes/plans.py` | 新增 `/api/plans/<id>/preview` 路由 |
|
||||||
|
| `app/templates/plan_detail.html` | 新增预览按钮 + 模态框 + CSS |
|
||||||
|
| `requirements.txt` | 添加 `markdown` 包 |
|
||||||
|
|
||||||
|
## 实现顺序
|
||||||
|
|
||||||
|
1. 安装 markdown 包到 venv
|
||||||
|
2. 后端:新增 preview 路由,复用模板渲染逻辑
|
||||||
|
3. 前端:添加模态框 HTML 和 CSS
|
||||||
|
4. 前端:添加预览按钮和 JS 逻辑
|
||||||
|
5. 测试:选择不同模板,预览效果与 PDF 一致
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user