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

12 KiB
Raw Blame History

导出预览功能实现计划

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
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
venv\Scripts\pip install markdown
  • Step 3: 验证安装
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" 之前)添加:

@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: 验证路由语法正确

运行开发服务器检查是否有语法错误:

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) 之前添加:

<!-- 导出预览模态框 -->
<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> 中添加):

/* 导出预览样式 - 镜像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: 在导出按钮区添加「预览」按钮

找到导出按钮区(应该有 downloadPDFWithTemplatedownloadMDWithTemplate 按钮),在 PDF 按钮之后、MD 按钮之前添加预览按钮:

<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 函数之后添加:

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> 中添加:

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

(注意:marked 已经用于渲染 AI 报告区域,如果页面已有引入则跳过此步)


Task 6: 验证完整功能

  • Step 1: 启动开发服务器
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: 检查变更
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
git status
  • Step 2: 提交
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"