959 lines
34 KiB
Markdown
959 lines
34 KiB
Markdown
# 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:
|
||
|
||
```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` 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` 排序展示热点。
|
||
|
||
```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 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 初始化时手动注册:
|
||
|
||
```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 条空状态场景;新增模板变量结构参考(首页 + 内容详情页)。 |
|