feat: 初始化项目,添加文档

This commit is contained in:
meijiali
2026-07-01 16:59:59 +08:00
commit 289d7e2c82
9 changed files with 4447 additions and 0 deletions
+958
View File
@@ -0,0 +1,958 @@
# 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>
<!-- ④ 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 组件规范
### 模板目录结构
```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 条空状态场景;新增模板变量结构参考(首页 + 内容详情页)。 |