Files
hot_comment_radar/docs/UIDesign.md
T

959 lines
34 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.
# 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 + 原生 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 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
```jinja2
<title>{% block title %}热榜评论分析工具{% endblock %}</title>
```
各子页面覆盖:
```jinja2
{% block title %}任务列表 - 热榜评论分析工具{% endblock %}
```
## 6. 全局布局(Global Layout
所有页面共享 `base.html` 布局。
```text
+-------------------------------------------------------------+
| [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()` 异步提交。
- 点击「开始抓取」按钮后的流程:
```javascript
// 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 = "开始抓取";
}
}
```
- 表单顶部须预留错误展示区:
```html
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
```
- **表单提交成功后**:直接跳转至 `/tasks/{new_task_id}`,用户可立即在任务详情页看到初始 `pending` 状态。
#### 规模预估提示(表单底部)
在三个配置输入框下方,实时展示预估抓取规模:
```html
<div class="form-text text-muted mt-2" id="scale-hint">
预计最多抓取:<strong id="scale-calc">1250</strong> 条评论
(实际数量可能受平台返回数量、去重、失败、限流影响)
</div>
```
```javascript
// 三个输入框 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 展示任务。
表头:
```text
任务 ID / 平台 / 创建时间 / 配置规模 / 进度 / 状态 / 操作
```
进度展示:
- 已成功内容条目数 / 总内容条目数。
- 可同时展示 Bootstrap Progress Bar。
- 如果任务刚创建且 `started_at` 为空,进度列显示「等待开始...」灰色文字。
#### 任务列表状态列与错误信息展示
- 状态列展示 `status` 对应的 Badge(见状态枚举规范章节)。
-`analysis_status != 'normal'`,在 Badge 后追加 ⚠️ 图标。
- **错误信息展示层级**
- 任务列表中,仅在状态 Badge 下方以灰色小字直接展示 `error_stage` / `error_type`(不使用 Hover tooltip,Hover 不支持移动端且不适合长文本):
```html
<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 字以内),不作为主信息通道。
#### 配置规模列展示格式
```html
<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 操作。
```html
<!-- 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 不可用时降级)
```javascript
// 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` 排序展示热点。
```text
▼ 热点 #1: [热点标题] (热度: 120w) -------------------- [ 查看热点级汇总报告 ↗ ]
|
|-- [视频/笔记] 标题摘要 1 | 状态: 已分析 | [ 查看详情 ↗ ]
|-- [视频/笔记] 标题摘要 2 | 状态: 抓取失败 (API限流)
|-- [视频/笔记] 标题摘要 3 | 状态: 已分析 | [ 查看详情 ↗ ]
▶ 热点 #2: [热点标题] (热度: 98w) --------------------- [ 查看热点级汇总报告 ↗ ]
```
#### 手风琴默认展开状态
- 默认展开 `rank=1` 的第一个热点,其余折叠。
- 若热点下所有内容条目均为 `crawl_failed`,该热点的「查看汇总报告」按钮置灰,`disabled`,Tooltip 文本:「暂无报告(该热点内容全部抓取失败)」。
#### 任务 running 时内容列表空状态
`task.status = running``hotspots` 列表为空(抓取尚未返回任何热点),展示:
```html
<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`
页面目标:展示跨内容条目的聚合分析结果。
#### 热点报告页面包屑
```html
<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 分析状态。
#### 情绪分布展示规范
情绪分布不能只展示百分比,必须并列展示具体条数:
```html
<!-- 正向情绪行示例 -->
<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 评论明细`
#### 原始内容链接
在内容条目基础信息区域末尾展示:
```html
{% 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)
```html
<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 条,在表格上方展示数量提示:
```html
<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>
```
表头:
```text
评论 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 下载)
```javascript
// 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 示例
```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 时,若评论内容首字符为 `=``+``-``@`,须在该字符前添加单引号前缀:
```python
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 初始化时手动注册:
```python
# 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
```
注册后,模板中可安全使用:
```jinja2
{% 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>` 结构:
```html
<!-- ① 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 组件规范
### 模板目录结构
```text
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` 中定义:
```jinja2
{% 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 %}
```
使用方式:
```jinja2
{% from "partials/status_badge.html" import status_badge %}
{{ status_badge(task.status) }}
```
#### sentiment_badge(情绪倾向 Badge
```jinja2
{% 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(标签列表)
```jinja2
{% 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
```python
{
"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
```python
{
"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 引入顺序和 `|safe` 使用约束;新增模板目录结构与三个 Jinja2 Macro 组件规范(status_badge / sentiment_badge / label_tags);补充 8 条空状态场景;新增模板变量结构参考(首页 + 内容详情页)。 |