Files
piano-plan/docs/superpowers/plans/2026-04-28-export-preview-plan.md
T

431 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 导出预览功能实现计划
> **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"
```