Files

34 KiB
Raw Permalink Blame History

UIDesign.md:小红书 / 抖音热榜评论抓取 + AI 分析报告工具

版本:v1.1 状态:MVP 设计稿(已审阅) 最后更新:2025-07-10 关联文档:DevelopmentPlan.md / FeatureSummary.md

1. 文档信息

  • 文档阶段:UIDesign(界面与交互设计文档)
  • 需求与技术依据:PRD.mdFeatureSummary.mdDevelopmentPlan.md
  • 视觉风格:工程化、简洁、信息密度高,以数据看板、表格和轻量报告为主
  • 技术实现前提:FastAPI Jinja2 模板 + Bootstrap 5 CDN + 简单 CSS + 原生 JSHTMX 作为 P1 局部刷新增强方案
  • 目标用户:内部演示、开发联调、产品/数据分析同事查看抓取与分析结果

2. 设计原则与全局规范

2.1 设计原则

  • 信息优先:核心数据,包括进度、状态、评论样本数、情绪分布和导出入口,必须在第一视觉层级。
  • 状态透明:任务的等待、运行、成功、失败、AI 样本不足和分析失败必须通过统一 Badge、颜色和文案展示。
  • 极简交互:避免复杂弹窗和多步引导,抓取、查看、导出等操作入口扁平化呈现。
  • 调试友好:MVP 是工程演示工具,允许在详情页保留原始 JSON 调试入口,但不作为正式用户功能。
  • 移动端可读:不依赖 Hover 作为主信息通道,错误原因和状态说明应直接可见。

2.2 全局样式规范

推荐直接使用 Bootstrap 5 CDN,降低 4 天 MVP 开发复杂度。

主色调:

  • Primary(主要操作/高亮):#0d6efd
  • Success(成功/正向情绪):#198754
  • Danger(失败/负向情绪/错误):#dc3545
  • Warning(运行中/中性情绪/分析不足):#ffc107
  • Secondary(次要信息/未知状态):#6c757d

版式:

  • 顶部固定导航栏。
  • 主体内容区居中,最大宽度建议 1200px
  • 页面主体使用 Bootstrap .container
  • 模块使用 Card、Table、Accordion、Alert、Badge、Progress Bar 等基础组件。
  • 不做复杂营销式视觉设计,优先保证密集信息下的可读性和操作效率。

3. 路由与接口总表

页面路由(返回 HTML,由 Jinja2 渲染)

方法 路径 用途 对应模板
GET / 首页 / 任务列表页 index.html
GET /tasks/{task_id} 任务详情页(热点与内容条目列表) tasks/detail.html
GET /hotspots/{hotspot_id}/report 热点级汇总报告页 hotspots/report.html
GET /items/{item_id} 内容条目详情页 items/detail.html

API 接口(返回 JSON,供前端 JS 调用)

方法 路径 用途 返回格式
POST /api/tasks 创建抓取任务 JSON {task_id, status}
GET /api/tasks/{task_id} 查询任务最新状态 JSON

局部刷新接口(返回 HTML Fragment,供 HTMX 调用)

方法 路径 用途 返回格式
GET /partials/tasks 刷新任务列表表格行 HTML Fragmenttask_rows.html

导出接口(返回文件流)

方法 路径 用途 返回格式
GET /api/export/items/{item_id}/comments.csv 导出内容条目评论明细 CSV File
GET /api/export/items/{item_id}/report.md 导出内容条目分析报告 Markdown File
GET /api/export/hotspots/{hotspot_id}/report.md 导出热点汇总报告 Markdown File
GET /api/export/hotspots/{hotspot_id}/comments.csv 导出热点下全部评论汇总 CSV File

4. 状态枚举与 UI 映射规范

所有模板中的状态展示统一依据以下枚举值映射,禁止在模板中硬编码中文状态文案。

Task.status

后端值 中文展示 Badge 样式
pending 等待中 bg-secondary
running 运行中 bg-warning text-dark
success 已完成 bg-success
failed 失败 bg-danger

Task.analysis_status

后端值 中文展示 Badge 样式 说明
normal 不展示 正常,无需提示
insufficient ⚠️ AI 样本不足 bg-warning text-dark 展示 Alert 提示
failed ⚠️ AI 分析失败 bg-danger 展示 Alert 提示

Item.status

后端值 中文展示 样式
pending 等待抓取 text-secondary
crawling 抓取中 text-warning
analyzing 分析中 text-info
success 已分析 text-success
crawl_failed 抓取失败 text-danger
analysis_failed 分析失败 text-danger

Comment.analysis_status

后端值 中文展示 说明
success 正常,无需特殊标注
insufficient 样本不足 灰色斜体提示
failed 解析失败 红色文字
skipped 未分析 灰色文字

sentiment(情绪倾向)

后端值 中文展示 Badge 样式
positive 正向 bg-success
neutral 中性 bg-warning text-dark
negative 负向 bg-danger
unknown 未知 bg-secondary

5. 面包屑导航与页面标题规范

各页面面包屑路径

页面 面包屑层级
/ 首页
/tasks/{task_id} 首页 任务 #{task_id}
/hotspots/{hotspot_id}/report 首页 任务 #{task_id} 热点 #{rank}{title} 汇总报告
/items/{item_id} 首页 任务 #{task_id} 热点 #{rank}{title} {item_title}

面包屑使用 Bootstrap 的 <nav aria-label="breadcrumb"> 组件,放置于页面 <main> 容器顶部、页面标题之上。

浏览器标签页 <title> 格式

页面 <title> 格式
/ 任务列表 - 热榜评论分析工具
/tasks/{task_id} 任务 \#{task_id} - 热榜评论分析工具
/hotspots/{hotspot_id}/report {hotspot_title} 汇总报告 - 热榜评论分析工具
/items/{item_id} {item_title} 详情 - 热榜评论分析工具

base.html 中使用 Jinja2 block

<title>{% block title %}热榜评论分析工具{% endblock %}</title>

各子页面覆盖:

{% block title %}任务列表 - 热榜评论分析工具{% endblock %}

6. 全局布局(Global Layout

所有页面共享 base.html 布局。

+-------------------------------------------------------------+
| [Logo/Title] 抓取与分析工具      [首页/任务列表]              |
+-------------------------------------------------------------+
|                                                             |
|  Breadcrumb: 首页 > 任务 #1234 > 热点分析                    |
|                                                             |
|                    [ 主体内容区域 ]                          |
|                                                             |
+-------------------------------------------------------------+
|  Footer: 内部演示工具 | 仅供学习参考                         |
+-------------------------------------------------------------+

基础结构:

  • Navbar:左侧为产品名「热榜评论分析工具」,右侧保留「任务列表」入口。
  • Breadcrumb:每个页面放在标题上方。
  • Main:页面核心内容,使用 .container py-4
  • Footer:简短说明「内部演示工具 | 仅供学习参考」。

7. 核心页面设计

7.1 首页 / 任务列表页(/

页面目标:提供配置入口触发抓取;查看历史与当前任务进度。

区域 A:创建任务表单

使用 Bootstrap Card 承载表单,字段紧凑排列。

字段控件:

  • 平台选择:Radio Buttons 或 Select,选项为「小红书」「抖音」。
  • 热点数量上限:Number Input,默认 5,范围 1-10
  • 每热点内容上限:Number Input,默认 5,范围 1-10
  • 每内容评论上限:Number Input,默认 50,范围 10-100
  • 操作按钮:开始抓取Primary Button。

表单提交交互(异步 JS 模式)

  • 表单不使用 HTML <form action="..." method="POST"> 同步提交,改用 JavaScript fetch() 异步提交。
  • 点击「开始抓取」按钮后的流程:
// static/app.js
async function submitTask() {
  const btn = document.getElementById("submit-btn");
  const errorBox = document.getElementById("form-error");

  // 1. 读取表单值
  const payload = {
    platform: document.getElementById("platform").value,
    hotspot_limit: parseInt(document.getElementById("hotspot_limit").value),
    item_limit: parseInt(document.getElementById("item_limit").value),
    comment_limit: parseInt(document.getElementById("comment_limit").value),
  };

  // 2. 禁用按钮,显示 Loading
  btn.disabled = true;
  btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> 提交中...`;
  errorBox.classList.add("d-none");

  try {
    const resp = await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    if (resp.ok) {
      // 3. 成功:跳转至新任务详情页
      const data = await resp.json();
      window.location.href = `/tasks/${data.task_id}`;
    } else if (resp.status === 422) {
      // 4. 校验失败:在表单上方渲染错误 Alert
      const data = await resp.json();
      const messages = data.detail.map(e => e.msg).join("");
      errorBox.textContent = `参数错误:${messages}`;
      errorBox.classList.remove("d-none");
    } else {
      errorBox.textContent = "服务器错误,请稍后重试。";
      errorBox.classList.remove("d-none");
    }
  } catch (e) {
    errorBox.textContent = "网络异常,请检查连接后重试。";
    errorBox.classList.remove("d-none");
  } finally {
    btn.disabled = false;
    btn.innerHTML = "开始抓取";
  }
}
  • 表单顶部须预留错误展示区:
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
  • 表单提交成功后:直接跳转至 /tasks/{new_task_id},用户可立即在任务详情页看到初始 pending 状态。

规模预估提示(表单底部)

在三个配置输入框下方,实时展示预估抓取规模:

<div class="form-text text-muted mt-2" id="scale-hint">
  预计最多抓取:<strong id="scale-calc">1250</strong> 条评论
  (实际数量可能受平台返回数量、去重、失败、限流影响)
</div>
// 三个输入框 oninput 时实时更新
function updateScaleHint() {
  const h = parseInt(document.getElementById("hotspot_limit").value) || 0;
  const i = parseInt(document.getElementById("item_limit").value) || 0;
  const c = parseInt(document.getElementById("comment_limit").value) || 0;
  document.getElementById("scale-calc").textContent = (h * i * c).toLocaleString();
}

区域 B:任务列表

使用 Table 展示任务。

表头:

任务 ID / 平台 / 创建时间 / 配置规模 / 进度 / 状态 / 操作

进度展示:

  • 已成功内容条目数 / 总内容条目数。
  • 可同时展示 Bootstrap Progress Bar。
  • 如果任务刚创建且 started_at 为空,进度列显示「等待开始...」灰色文字。

任务列表状态列与错误信息展示

  • 状态列展示 status 对应的 Badge(见状态枚举规范章节)。
  • analysis_status != 'normal',在 Badge 后追加 ⚠️ 图标。
  • 错误信息展示层级
    • 任务列表中,仅在状态 Badge 下方以灰色小字直接展示 error_stage / error_type(不使用 Hover tooltip,Hover 不支持移动端且不适合长文本):
<span class="badge bg-danger">失败</span>
<br>
<small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
  • 完整 error_message 仅在 /tasks/{task_id} 任务详情页展示。
  • Tooltip 可作为 error_type 的辅助说明(限 50 字以内),不作为主信息通道。

配置规模列展示格式

<small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit }} / 评论 {{ task.comment_limit }}</small>

P1 方案:HTMX 任务列表自动轮询

HTMX 通过 /partials/tasks 接口获取 HTML Fragment(服务端渲染 task_rows.html),每 5 秒更新一次 <tbody> 内容,无需手写 DOM 操作。

<!-- index.html 任务列表区域 -->
<div class="d-flex justify-content-between align-items-center mb-2">
  <span class="text-muted small">
    <span id="refresh-spinner" class="htmx-indicator">⟳ 刷新中...</span>
  </span>
  <button
    class="btn btn-sm btn-outline-secondary"
    hx-get="/partials/tasks"
    hx-target="#task-table-body"
    hx-swap="innerHTML"
    hx-indicator="#refresh-spinner">
    ↻ 手动刷新
  </button>
</div>

<table class="table table-hover align-middle">
  <thead>
    <tr>
      <th>任务 ID</th>
      <th>平台</th>
      <th>创建时间</th>
      <th>配置规模</th>
      <th>进度</th>
      <th>状态</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody
    id="task-table-body"
    hx-get="/partials/tasks"
    hx-trigger="every 5s [document.querySelector('.badge.bg-warning') !== null]"
    hx-swap="innerHTML"
    hx-indicator="#refresh-spinner">
    {% include "partials/task_rows.html" %}
  </tbody>
</table>

说明:

  • hx-trigger="every 5s [condition]" 中的条件判断页面上是否存在 running 状态的任务(Badge 为 bg-warning)。所有任务完成后条件为 false,自动停止轮询,避免无效请求。
  • hx-indicator 在请求进行中显示「⟳ 刷新中...」提示。
  • /partials/tasks 返回纯 HTML Fragment<tr> 行集合),不是 JSON。

P0 方案:原生 JS 定时轮询(HTMX 不可用时降级)

// static/app.js
function startPolling() {
  const interval = setInterval(async () => {
    const resp = await fetch("/partials/tasks");
    const html = await resp.text();
    document.getElementById("task-table-body").innerHTML = html;
    // 若页面上已无 running 状态,停止轮询
    if (!document.querySelector(".badge.bg-warning")) {
      clearInterval(interval);
    }
  }, 5000);
}
if (document.querySelector(".badge.bg-warning")) startPolling();

7.2 热点与内容条目列表页(/tasks/{task_id}

页面目标:展示任务宏观执行结果,作为进入具体报告的路由中枢。

区域 A:任务概览看板

使用 Card + Bootstrap row + col 网格排列。

任务概览看板字段清单

字段 数据来源 展示位置
任务 ID task.id 左上
平台 task.platform(小红书 / 抖音) 左上并排
创建时间 task.created_at 第二行左
任务耗时 task.finished_at - task.started_at(运行中显示「进行中」) 第二行右
任务状态 task.status Badge 第三行左
AI 分析状态 task.analysis_status Badgenormal 时不展示) 第三行右
内容条目进度 成功 {success_count} / 共 {total_count} 条 第四行
失败条目数 task.failed_items_count(大于 0 时显示红色) 第四行并排
错误阶段 / 类型 task.error_stage / task.error_type(仅 status=failed 时展示) 第五行,红色 Alert
完整错误信息 task.error_message(仅 status=failed 时,折叠展示) 第五行,可展开

区域 B:热点手风琴列表

rank 排序展示热点。

▼ 热点 #1: [热点标题] (热度: 120w) -------------------- [ 查看热点级汇总报告 ↗ ]
   |
   |-- [视频/笔记] 标题摘要 1 | 状态: 已分析 | [ 查看详情 ↗ ]
   |-- [视频/笔记] 标题摘要 2 | 状态: 抓取失败 (API限流)
   |-- [视频/笔记] 标题摘要 3 | 状态: 已分析 | [ 查看详情 ↗ ]
▶ 热点 #2: [热点标题] (热度: 98w) --------------------- [ 查看热点级汇总报告 ↗ ]

手风琴默认展开状态

  • 默认展开 rank=1 的第一个热点,其余折叠。
  • 若热点下所有内容条目均为 crawl_failed,该热点的「查看汇总报告」按钮置灰,disabled,Tooltip 文本:「暂无报告(该热点内容全部抓取失败)」。

任务 running 时内容列表空状态

task.status = runninghotspots 列表为空(抓取尚未返回任何热点),展示:

<div class="text-center text-muted py-5">
  <div class="spinner-border text-warning mb-3" role="status"></div>
  <p>正在抓取热点数据,请稍候...</p>
  <small>页面将每 5 秒自动刷新</small>
</div>

7.3 热点级汇总报告页(/hotspots/{hotspot_id}/report

页面目标:展示跨内容条目的聚合分析结果。

热点报告页面包屑

<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">首页</a></li>
    <li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
    <li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">热点 #{{ hotspot.rank }}{{ hotspot.title }}</a></li>
    <li class="breadcrumb-item active">汇总报告</li>
  </ol>
</nav>

顶部操作栏

右上角提供:

  • 导出 Markdown 报告
  • 导出热点下全部评论 CSV

导出按钮可用状态遵循「导出交互规范」章节。

数据看板

基础统计卡片:

  • 关联内容条目数。
  • 评论样本总量。
  • 成功分析评论数。
  • AI 分析状态。

情绪分布展示规范

情绪分布不能只展示百分比,必须并列展示具体条数:

<!-- 正向情绪行示例 -->
<div class="d-flex justify-content-between mb-1">
  <span>🟩 正向</span>
  <span class="text-muted">{{ positive_count }} 条({{ positive_pct }}%</span>
</div>
<div class="progress mb-3" style="height: 12px;">
  <div class="progress-bar bg-success" style="width: {{ positive_pct }}%"></div>
</div>

三种情绪(正向 / 中性 / 负向)均按此格式展示,数据来源为 report.metrics_json 中的情绪统计字段。

标签与总结

  • Top 5 标签云:例如 [ 价格实惠 (15) ] [ 质量好 (12) ] [ 物流慢 (8) ]
  • AI 热点总结:使用浅蓝色 Callout 展示,不超过 300 字。
  • 如果 analysis_status = insufficient,展示样本不足 Alert。
  • 如果 analysis_status = failed,展示「总结生成失败,请查看上方统计数据。」灰色文本。

典型评论

按情绪分列展示:

  • 正向代表:1-2 条,显示点赞数。
  • 中性代表:1-2 条,显示点赞数。
  • 负向代表:1-2 条,显示点赞数。

7.4 内容条目详情页(/items/{item_id}

页面目标:单条内容的深度报告与评论明细展示。

顶部操作栏

  • 导出 Markdown 报告
  • 导出 CSV 评论明细

原始内容链接

在内容条目基础信息区域末尾展示:

{% if item.url %}
  <a href="{{ item.url }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary">
    🔗 查看原始内容
  </a>
{% else %}
  <button class="btn btn-sm btn-outline-secondary" disabled title="原始链接不可用">
    🔗 查看原始内容
  </button>
{% endif %}

区域 A:内容条目级分析报告

结构与热点级报告一致,但范围限定为单个内容条目:

  • 样本评论数量。
  • 情绪条数和占比。
  • Top 5 标签。
  • AI 总结,建议 200 字以内。
  • 典型评论。

如果报告尚未生成,展示 Spinner +「报告生成中,请稍候...」。

原始 JSON 调试入口(原生折叠,零 JS)

<details class="mt-3">
  <summary class="btn btn-sm btn-outline-secondary" style="display: inline-block; cursor: pointer;">
    🐞 查看原始 JSON(调试)
  </summary>
  <div class="mt-2 p-3 bg-light rounded border">
    <pre class="mb-0" style="max-height: 400px; overflow-y: auto; font-size: 0.8em;">{{ item.raw_data | tojson(indent=2) }}</pre>
  </div>
</details>

优点:

  • 零 JS 依赖,使用 HTML5 原生 <details>/<summary> 元素。
  • 自动支持长内容滚动,不存在 Modal 在小屏幕溢出的问题。
  • 点击展开 / 再点收起,体验如手风琴折叠。

区域 B:评论明细列表

评论明细展示策略

  • MVP 阶段评论明细一次性展示,不做后端分页
  • 最多展示 100 条评论,按点赞数降序排列;点赞数相同时按评论时间降序
  • 若评论总数超过 50 条,在表格上方展示数量提示:
<div class="d-flex justify-content-between align-items-center mb-2">
  <span class="text-muted small">共 {{ total_comment_count }} 条评论,展示前 {{ comments | length }} 条</span>
  <span class="text-muted small">如需查看全部,请导出 CSV</span>
</div>

表头:

评论 ID / 评论内容 / 情绪倾向 / 方向标签 / 点赞数 / 评论时间

展示逻辑:

  • 情绪倾向:使用 sentiment_badge Macro。
  • 方向标签:将 JSON Array 解析为多个独立的小 Tag 块;为空显示 -
  • 评论内容:长文本使用 CSS text-truncate,可在详情或 tooltip 中查看完整内容。
  • comment.analysis_status == 'failed',情绪列显示「解析失败」红色文字,不影响其他行。
  • 若评论为空,展示「暂无评论数据(该内容无评论或评论抓取为空)」。

8. 表单校验、交互状态与空状态

8.1 表单校验行为

前端拦截:

  • 热点数量范围:1-10
  • 每热点内容上限范围:1-10
  • 每内容评论上限范围:10-100
  • 输入非法值时,Input 边框变红,失去焦点及提交时显示提示文本,例如「最大值为 10」。
  • 非法输入阻止提交。

后端双重校验:

  • 若绕过前端提交,FastAPI 返回 HTTP 422。
  • 页面通过异步 JS 将错误信息渲染到 #form-error Alert。

8.2 空状态场景

场景 页面 展示内容
任务列表无任何任务 首页 「还没有任何任务,请在上方创建第一个任务 🚀
任务 running 但热点列表为空 任务详情页 Spinner + 「正在抓取热点数据,请稍候...」
热点下评论数为 0 内容详情页评论区 「暂无评论数据(该内容无评论或评论抓取为空)」
任务刚创建未开始(started_at 为空) 任务列表进度列 「等待开始...」灰色文字
AI 样本不足(analysis_status = insufficient 报告页总结区域 ⚠️ Alert:「当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。」
AI 总结生成失败(analysis_status = failed 报告页总结区域 「总结生成失败,请查看上方统计数据。」灰色文字
报告尚未生成(内容条目分析中) 内容详情页报告区 Spinner + 「报告生成中,请稍候...」
导出文件内容为空 导出按钮点击后 alert("当前暂无可导出评论数据")

8.3 单条目失败不阻塞

如果某个视频或笔记抓取失败:

  • 手风琴列表中该条目置灰。
  • 不可点击详情。
  • 状态列显示失败原因,例如「API 限流」。
  • 其他成功条目仍可查看报告和评论明细。

如果 AI 总结生成失败:

  • 页面不应 500。
  • 总结区域展示「总结生成失败,请查看上方统计数据。」。
  • 结构化统计和评论明细仍正常展示。

9. 导出交互规范

导出按钮可用/置灰前提条件

场景 按钮状态 Tooltip 文本
task.status = running disabled 任务运行中,请等待完成后导出
task.status = failed 且无成功内容条目 disabled 任务失败,无可导出数据
item.status = crawl_failed disabled 内容抓取失败,无评论数据
可导出(任务已完成且有数据) 可点击
评论数为 0 disabled 当前暂无可导出评论数据

导出交互实现(增强型 JS Blob 下载)

// static/app.js
async function downloadExport(url, defaultFilename) {
  const btn = event.currentTarget;
  const originalText = btn.innerHTML;

  // 1. 按钮置灰,显示 Loading
  btn.disabled = true;
  btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> 生成中...`;

  try {
    const resp = await fetch(url);

    if (!resp.ok) {
      alert("导出失败,请稍后重试。");
      return;
    }

    // 2. 从响应头获取文件名
    const disposition = resp.headers.get("Content-Disposition");
    const filenameMatch = disposition && disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
    const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : defaultFilename;

    // 3. 触发浏览器下载
    const blob = await resp.blob();
    const blobUrl = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = blobUrl;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(blobUrl);

  } catch (e) {
    alert("网络异常,请检查连接后重试。");
  } finally {
    // 4. 恢复按钮状态
    btn.disabled = false;
    btn.innerHTML = originalText;
  }
}

导出按钮 HTML 示例

<!-- 内容条目详情页导出区域 -->
<div class="d-flex gap-2 mt-3">
  <button
    class="btn btn-outline-primary"
    onclick="downloadExport('/api/export/items/{{ item.id }}/comments.csv', 'comments.csv')"
    {% if item.status != 'success' %}disabled title="内容抓取失败,无评论数据"{% endif %}>
    ⬇️ 导出评论 CSV
  </button>

  <button
    class="btn btn-outline-secondary"
    onclick="downloadExport('/api/export/items/{{ item.id }}/report.md', 'report.md')"
    {% if not report %}disabled title="报告尚未生成"{% endif %}>
    ⬇️ 导出 Markdown 报告
  </button>
</div>

CSV 导出安全处理(防 Excel 公式注入)

后端 export_service.py 导出 CSV 时,若评论内容首字符为 =+-@,须在该字符前添加单引号前缀:

def sanitize_csv_field(value: str) -> str:
    """防止 CSV 公式注入"""
    if value and value[0] in ('=', '+', '-', '@'):
        return f"'{value}"
    return value

此项为后端实现规范,在 UIDesign 中作为说明记录。

10. 实施建议

10.1 模板复用

模板结构建议使用 Jinja2 的 {% extends "base.html" %}{% block content %} 减少重复代码。

页面级模板仅负责布局和数据展示,状态 Badge、情绪 Badge、标签列表等重复结构应抽成 Macro。

10.2 样式框架

为了在 4 天 MVP 周期内完成,建议在 base.html 中直接引入 Bootstrap 5CSS + JS bundle)。Accordion、Badge、Progress Bar、Alert、Table、Button 等组件开箱即用。

10.3 Jinja2 自定义 Filter 注册(必须在 main.py 中完成)

原生 Jinja2 没有 from_json Filter,直接在模板中使用 {{ value | from_json }} 会抛出 TemplateAssertionError。须在 FastAPI 初始化时手动注册:

# app/main.py
import json
from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory="templates")

def from_json_filter(value):
    """将 JSON 字符串解析为 Python 对象,用于 Jinja2 模板"""
    try:
        return json.loads(value) if value else []
    except (json.JSONDecodeError, TypeError):
        return []

templates.env.filters["from_json"] = from_json_filter

注册后,模板中可安全使用:

{% for label in comment.labels | from_json %}
  <span class="badge bg-secondary">{{ label }}</span>
{% else %}
  <span class="text-muted">-</span>
{% endfor %}

10.4 base.html CDN 引入顺序

HTMX 必须在 Bootstrap JS Bundle 之后引入,避免事件冲突。推荐的 <head> 结构:

<!-- ① Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- ② 自定义样式 -->
<link href="/static/app.css" rel="stylesheet">

<!-- ③ Bootstrap 5 JS Bundle(含 Popper,须在 HTMX 之前) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- ④ HTMXP1 轮询方案,可选) -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<!-- ⑤ 自定义 JSdefer 确保 DOM 加载完成后执行) -->
<script src="/static/app.js" defer></script>

10.5 Jinja2 |safe Filter 使用约束

  • Jinja2 默认对所有变量启用 HTML 转义,禁止随意使用 |safe
  • 仅在以下场景允许使用 |safe:由后端程序生成、非用户输入的可信 HTML 片段(如报告 Markdown 渲染后的 HTML)。
  • 所有来源于外部平台的评论内容、热点标题、用户昵称等字段,严禁使用 |safe,必须经过 Jinja2 默认转义。
  • 外部链接须添加安全属性:<a href="..." target="_blank" rel="noopener noreferrer">

11. 模板目录结构与 Jinja2 组件规范

模板目录结构

templates/
├── base.html                  # 全局布局:导航栏、面包屑、CSS/JS 引入
├── index.html                 # 首页 / 任务列表页
├── tasks/
│   └── detail.html            # 任务详情页(热点手风琴)
├── hotspots/
│   └── report.html            # 热点级汇总报告页
├── items/
│   └── detail.html            # 内容条目详情页
└── partials/
    ├── task_rows.html         # 任务列表表格行(HTMX 局部刷新片段)
    ├── status_badge.html      # 任务状态 Badge 组件
    ├── sentiment_badge.html   # 情绪倾向 Badge 组件
    └── label_tags.html        # 标签 Badge 列表组件

Jinja2 Macro 组件规范

status_badge(任务状态 Badge

partials/status_badge.html 中定义:

{% macro status_badge(status) %}
  {% set config = {
    "pending":  ("等待中", "bg-secondary"),
    "running":  ("运行中", "bg-warning text-dark"),
    "success":  ("已完成", "bg-success"),
    "failed":   ("失败",   "bg-danger"),
  } %}
  {% set label, style = config.get(status, ("未知", "bg-secondary")) %}
  <span class="badge {{ style }}">{{ label }}</span>
{% endmacro %}

使用方式:

{% from "partials/status_badge.html" import status_badge %}
{{ status_badge(task.status) }}

sentiment_badge(情绪倾向 Badge

{% macro sentiment_badge(sentiment) %}
  {% set config = {
    "positive": ("正向", "bg-success"),
    "neutral":  ("中性", "bg-warning text-dark"),
    "negative": ("负向", "bg-danger"),
  } %}
  {% set label, style = config.get(sentiment, ("未知", "bg-secondary")) %}
  <span class="badge {{ style }}">{{ label }}</span>
{% endmacro %}

label_tags(标签列表)

{% macro label_tags(labels_json) %}
  {% for label in labels_json | from_json %}
    <span class="badge bg-secondary me-1">{{ label }}</span>
  {% else %}
    <span class="text-muted">-</span>
  {% endfor %}
{% endmacro %}

12. 模板变量结构参考

以下为各页面 Jinja2 模板的后端数据结构示例,供模板开发对照使用。

首页(index.html

{
    "tasks": [
        {
            "id": 123,
            "platform": "xhs",                    # "xhs" | "douyin"
            "created_at": "2025-01-01 10:00:00",
            "hotspot_limit": 5,
            "item_limit": 5,
            "comment_limit": 50,
            "total_items_count": 25,
            "successful_items_count": 18,
            "failed_items_count": 3,
            "status": "running",                  # 见状态枚举
            "analysis_status": "normal",          # 见状态枚举
            "error_stage": None,
            "error_type": None,
            "error_message": None,
        }
    ]
}

内容条目详情页(items/detail.html

{
    "task": { "id": 123, "platform": "douyin" },
    "hotspot": { "id": 456, "rank": 1, "title": "热点标题" },
    "item": {
        "id": 789,
        "title": "视频标题",
        "platform": "douyin",
        "url": "https://www.douyin.com/video/xxx",
        "status": "success",
        "raw_data": { ... },                      # 调试用,通过 | tojson 渲染
    },
    "report": {
        "comment_count": 100,
        "sentiment": {
            "positive": { "count": 60, "pct": 60.0 },
            "neutral":  { "count": 20, "pct": 20.0 },
            "negative": { "count": 20, "pct": 20.0 },
        },
        "top_labels": [
            { "name": "价格实惠", "count": 15 },
            { "name": "质量好",   "count": 12 },
        ],
        "summary": "该内容评论整体偏正向,用户对价格和质量满意度较高...",
        "analysis_status": "success",
    },
    "comments": [
        {
            "id": 1,
            "content": "这个产品不错",
            "sentiment": "positive",
            "labels": "[\"质量好\", \"价格实惠\"]",  # JSON 字符串,模板用 | from_json 解析
            "like_count": 20,
            "created_at": "2025-01-01 11:00:00",
            "analysis_status": "success",
        }
    ],
    "total_comment_count": 150,                   # 数据库实际总数,用于"展示前 N 条"提示
}

变更日志

日期 版本 变更内容
2025-07-10 v1.0 初始版本
2025-07-10 v1.1 基于双审阅报告合并修订(共 17 条指令):补充文档元数据;新增完整路由总表(页面路由 / API 接口 / HTMX 局部刷新 / 导出接口);新增状态枚举与 UI 映射规范(Task / Item / Comment / sentiment 四层);新增面包屑路径与 <title> 命名规范;表单提交改为异步 JS fetch 模式,422 错误表单内渲染;新增表单规模预估实时计算提示;任务列表错误信息从 Hover 改为直接展示 error_stage / error_type;修复 HTMX 代码片段截断问题,补全含条件触发的完整轮询示例;补充任务概览看板字段清单、手风琴默认展开规则和热点报告按钮置灰条件;情绪分布改为数值+百分比并列展示;评论明细限制 100 条 + 总数提示 + 按点赞数降序;新增原始内容 URL 跳转入口;JSON 调试入口从 Modal 改为原生 <details> 折叠;导出交互改为增强型 JS Blob 下载 + 补充按钮可用/置灰前提条件 + CSV 公式注入防护;补充 Jinja2 from_json filter 注册规范;补充 CDN 引入顺序和 `