34 KiB
UIDesign.md:小红书 / 抖音热榜评论抓取 + AI 分析报告工具
版本:v1.1 状态:MVP 设计稿(已审阅) 最后更新:2025-07-10 关联文档:DevelopmentPlan.md / FeatureSummary.md
1. 文档信息
- 文档阶段:UIDesign(界面与交互设计文档)
- 需求与技术依据:
PRD.md、FeatureSummary.md、DevelopmentPlan.md - 视觉风格:工程化、简洁、信息密度高,以数据看板、表格和轻量报告为主
- 技术实现前提:FastAPI Jinja2 模板 + Bootstrap 5 CDN + 简单 CSS + 原生 JS;HTMX 作为 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 Fragment(task_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">同步提交,改用 JavaScriptfetch()异步提交。 - 点击「开始抓取」按钮后的流程:
// 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 不支持移动端且不适合长文本):
- 任务列表中,仅在状态 Badge 下方以灰色小字直接展示
<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 Badge(normal 时不展示) |
第三行右 |
| 内容条目进度 | 成功 {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 = running 且 hotspots 列表为空(抓取尚未返回任何热点),展示:
<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_badgeMacro。 - 方向标签:将 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-errorAlert。
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 5(CSS + 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>
<!-- ④ HTMX(P1 轮询方案,可选) -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<!-- ⑤ 自定义 JS(defer 确保 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 引入顺序和 ` |