feat: monitoring page refactor + competitor analysis page (U1, U2)

- Refactor monitoring page: dual-tab (records + alerts), connect monitoringApi
- Replace sidebar '数据监测' nav with '品牌监测' → /dashboard/monitoring
- Add '竞品分析' nav item → /dashboard/competitors
- Create competitor API module with 8 endpoints
- Create competitor analysis page with radar chart, gap scores, recommendations
This commit is contained in:
chiguyong 2026-06-02 07:41:04 +08:00
parent 45e151fc31
commit 37ecd39a60
7 changed files with 1427 additions and 213 deletions

View File

@ -0,0 +1,146 @@
---
date: 2026-06-01
topic: geo-frontend-visualization
---
## Summary
为 GEO 平台已有的完整后端 API 补建前端可视化页面分三批交付P0 修复监测页面 + 新建竞品分析页面P1 引用统计可视化 + 健康评分页面 + 检测任务管理 UIP2 报告导出入口 + 公开评分落地页 + Agent 配置 UI + 趋势/Schema 页面。可视化优先——先让用户看到数据价值,再补操作闭环。
## Problem Frame
GEO 平台后端 API 已基本完整8 个 Agent、5 大服务模块、30+ 端点但前端存在多处功能断裂监测页面展示的是告警而非监测记录5 个监测 API 端点完全未被使用竞品分析后端完整5 种分析类型 + LLM 推荐 + 差距评分)却无前端页面,引用统计、健康评分、检测任务等 API 也缺乏前端对接。用户在 onboarding 流程中看到健康评分,进入 Dashboard 后却找不到对应页面,核心价值无法被感知。
## Key Decisions
**可视化优先于操作** — 先让用户看到品牌监测数据、竞品对比图表、引用统计趋势,证明数据价值;触发检测、创建任务等操作能力后续补全。这降低了首批交付的复杂度,同时验证用户是否真的需要这些数据。
**监测页面修复而非新建** — 当前 monitoring 页面展示告警,但告警功能本身有独立价值。将监测记录作为 monitoring 页面的主 Tab告警作为子 Tab避免功能割裂。
**复用现有图表库** — 前端已有 recharts 依赖Dashboard 页面使用),竞品雷达图、引用趋势图等复用 recharts 而非引入新库。
## Requirements
### P0: 核心可视化闭环
R1. 监测页面展示品牌监测记录列表,调用 `/api/v1/monitoring/brand/{brand_id}` 获取数据每条记录显示监测平台、上次检测时间、变化类型positive/negative/neutral、变化摘要
R2. 监测记录详情展示变化报告,调用 `/{record_id}/report` 获取,包含变化指标对比(引用量、情感、排名)和 AI 建议
R3. 监测页面支持手动触发检测,调用 `/{record_id}/check`,触发后显示检测进度状态
R4. 监测状态管理active/paused/completed调用 `/{record_id}/status` 更新
R5. 告警功能保留为 monitoring 页面的子 Tab不删除现有告警逻辑
R6. 竞品分析独立页面,展示品牌竞品列表,调用 `/{brand_id}/competitors/` 获取,支持添加(上限 5 个)和删除
R7. 竞品对比可视化,调用 `/competitor/analyze` 执行分析,以雷达图展示品牌与竞品在 5 个维度的差距
R8. 竞品差距评分汇总,调用 `/competitor/brand/{brand_id}/gap-summary` 获取,以评分卡片形式展示
R9. 竞品推荐功能,调用 `/{brand_id}/competitors/recommendations/` 获取 LLM 推荐的竞品列表
### P1: 数据深度可视化
R10. 引用统计可视化,调用 `/api/v1/citations/stats` 获取数据展示引用率、平均位置、平台分布饼图、30 天趋势折线图
R11. 健康评分独立页面,调用 `/{brand_id}/score/` 获取 V2 五维度评分,以仪表盘 + 维度卡片形式展示
R12. 品牌与竞品对比,调用 `/{brand_id}/compare/` 获取雷达图数据,在健康评分页面中展示
R13. 评分历史趋势,调用 `/{brand_id}/score/history/` 获取,以折线图展示评分变化
R14. 检测任务管理 UI调用 `/api/v1/detection/tasks` CRUD展示任务列表、创建/编辑/删除任务、手动触发检测
R15. Dashboard 主页 Agent 活动区域替换占位内容,展示最近 Agent 执行记录摘要
### P2: 体验补全
R16. 引用页面添加 CSV/PDF 导出按钮,调用后端已有导出端点
R17. 报告页面导出按钮,调用 `/api/v1/reports/export/csv``/pdf`
R18. 公开健康评分落地页,调用 `/api/v1/public/health-score`,作为营销获客入口,无需登录即可查看品牌 3 维度评分
R19. Agent 配置管理 UI调用 `/{agent_name}/config` GET/PUT展示和修改 Agent 参数
R20. 趋势洞察页面,调用 `/api/v1/trends` 获取趋势分析和洞察列表
R21. Schema 建议页面,调用 `/api/v1/schema` 获取和管理 Schema 建议
## Key Flows
### F1. 监测记录查看与检测
**Trigger:** 用户进入监测页面
1. 页面加载品牌监测记录列表R1
2. 用户点击某条记录 → 展开变化报告详情R2
3. 用户点击"立即检测" → 触发检测R3→ 按钮变为 loading → 检测完成后刷新记录
4. 用户切换监测状态 active/pausedR4
**Covers R1, R2, R3, R4.**
### F2. 竞品分析
**Trigger:** 用户进入竞品分析页面
1. 页面加载品牌竞品列表R6
2. 用户点击"添加竞品" → 搜索或从推荐列表选择R9→ 添加成功
3. 用户点击"开始分析" → 选择分析类型 → 执行分析R7→ 展示雷达图 + 差距评分R8
4. 用户点击"重新分析" → 刷新分析结果
**Covers R6, R7, R8, R9.**
### F3. 健康评分查看
**Trigger:** 用户进入健康评分页面
1. 页面加载 V2 五维度评分仪表盘R11
2. 用户点击"竞品对比" → 展示雷达图R12
3. 用户点击"历史趋势" → 展示评分折线图R13
**Covers R11, R12, R13.**
## Scope Boundaries
**Deferred for later:**
- 监测任务创建 UI当前仅展示已有记录和手动触发创建新监测任务的操作能力后续补全
- Agent 手动创建任务 UI当前仅展示执行记录和配置手动创建任务的操作能力后续补全
- 内容编辑器深度优化和版本对比
- 国际化i18n改造
**Outside this product's identity:**
- 后端 API 新增或修改(后端已完整,本次仅做前端对接)
- 实时数据推送WebSocket——当前使用轮询刷新即可
- 移动端适配——当前仅考虑桌面端
## Dependencies / Assumptions
- 后端 API 端点已完整且可用(基于代码扫描确认,未做运行时验证)
- 前端 recharts 库已安装,可复用于所有图表
- 前端 `fetchWithAuth` 统一 API 客户端已就绪,所有新页面使用该客户端
- Docker 服务已部署且数据库已迁移Plan 005 已完成)
- 各 API 返回的数据结构与前端类型定义一致
## Outstanding Questions
**Resolve Before Planning:**
- 监测记录的"创建监测任务"入口放在哪里Onboarding Step3 已创建品牌并选择平台,是否在品牌创建后自动创建监测任务?
**Deferred to Planning:**
- 竞品雷达图的具体维度映射(后端返回的 5 种分析类型如何映射到雷达图轴)
- 公开健康评分落地页的 URL 路由和 SEO 策略
- Dashboard Agent 活动区域的数据刷新策略
## Sources / Research
- `backend/app/api/v1/monitoring.py` — 5 个监测端点
- `backend/app/api/v1/competitors.py` + `backend/app/api/v1/competitor.py` — 竞品管理 + 分析端点
- `backend/app/api/v1/citations.py` — 引用记录 + 统计端点
- `backend/app/api/v1/scoring.py` — V2 五维度评分 + 竞品对比 + 历史趋势
- `backend/app/api/v1/detection.py` — 检测任务 CRUD + 触发
- `backend/app/api/v1/agents.py` — Agent 配置 + 任务管理
- `backend/app/api/v1/trends.py` — 趋势洞察
- `backend/app/api/v1/schema_suggestions.py` — Schema 建议
- `frontend/app/(dashboard)/dashboard/monitoring/page.tsx` — 当前告警页面(需改造)
- `frontend/app/(dashboard)/dashboard/citations/page.tsx` — 引用列表页面(需增加统计)
- `frontend/lib/api/client.ts` — 统一 API 客户端

View File

@ -0,0 +1,323 @@
---
date: 2026-06-01
status: active
origin: docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md
---
## Summary
为 GEO 平台已有后端 API 补建前端可视化页面分三批交付P0 监测页面改造 + 竞品分析新建P1 引用统计 + 健康评分 + 检测任务 + Dashboard Agent 区域P2 导出入口 + 公开评分落地页 + Agent 配置 + 趋势/Schema 页面。可视化优先,复用 recharts + 现有 CompetitorRadarChart 组件。
## Problem Frame
后端 API 完整但前端多处断裂:监测页面展示告警而非监测记录,竞品分析无前端,引用统计/健康评分/检测任务等 API 未对接。用户在 Onboarding 看到健康评分后进入 Dashboard 却找不到对应页面。
## Requirements
**P0 核心可视化闭环:** R1R9
**P1 数据深度可视化:** R10R15
**P2 体验补全:** R16R21
Full requirements in origin document.
## Key Technical Decisions
**KTD1. 监测页面改造为主 Tab 模式** — 监测记录为主 Tab告警为子 Tab。替换侧边栏"数据监测"入口(原 `/dashboard/analytics`)指向 `/dashboard/monitoring`。保留现有告警逻辑不删除。
**KTD2. 竞品分析独立页面** — 新建 `/dashboard/competitors` 页面,复用现有 `CompetitorRadarChart` 组件(`components/charts/CompetitorRadarChart.tsx`)。侧边栏新增"竞品分析"导航项。
**KTD3. 复用 recharts + 现有图表组件** — 不引入新图表库。引用趋势用 `trend-chart.tsx`,平台分布用 `platform-chart.tsx`,竞品雷达图用 `CompetitorRadarChart.tsx`。健康评分仪表盘用 recharts `PieChart` 半圆实现。
**KTD4. 监测任务由 Onboarding 自动创建** — 在 Onboarding Step3 创建品牌后自动调用 `monitoringApi.createTask`,不在监测页面提供手动创建 UI。
**KTD5. API 模块复用现有模式** — 新增 `lib/api/competitor.ts`、`lib/api/scoring.ts`、`lib/api/detection.ts`、`lib/api/trends.ts`、`lib/api/schema-suggestions.ts`,遵循 `fetchWithAuth` + typed interfaces + const object export 模式。已有 `monitoring.ts``alerts.ts` 直接复用。
## Implementation Units
### U1. 监测页面改造
**Goal:** 将 monitoring 页面从纯告警改为监测记录 + 告警双 Tab监测记录为主 Tab对接 `/api/v1/monitoring` 的 5 个端点。
**Requirements:** R1, R2, R3, R4, R5
**Dependencies:** none
**Files:**
- `frontend/app/(dashboard)/dashboard/monitoring/page.tsx` — 重写
- `frontend/app/(dashboard)/layout.tsx` — 替换"数据监测"导航项 href
**Approach:**
- 页面顶部使用 shadcn `Tabs` 组件Tab1"监测记录"Tab2"告警通知"
- Tab1 调用 `monitoringApi.getBrandRecords(token, brandId)` 获取记录列表
- 每条记录展示:平台、上次检测时间、变化类型 Badgepositive=绿/negative=红/neutral=灰)、变化摘要
- 点击记录展开详情:调用 `monitoringApi.getReport(token, recordId)` 获取变化报告,展示指标对比和建议
- "立即检测"按钮调用 `monitoringApi.triggerCheck(token, recordId)`loading 状态后刷新列表
- 状态切换active/paused调用 `monitoringApi.updateStatus(token, recordId, status)`
- Tab2 保留现有告警逻辑,提取为 `AlertsTab` 子组件
- 侧边栏 `NAV_GROUPS``analytics` 项的 href 从 `/dashboard/analytics` 改为 `/dashboard/monitoring`label 改为"品牌监测"
**Patterns to follow:** `citations/page.tsx` 的 useApi + useState 模式;`api-states.tsx` 的 LoadingState/ErrorState/EmptyState
**Test scenarios:**
- 监测记录列表加载并展示变化类型 Badge
- 点击记录展开变化报告详情
- 点击"立即检测"触发检测并刷新列表
- 切换监测状态 active/paused
- 告警 Tab 保留现有功能不受影响
**Verification:** 页面加载显示监测记录 Tab 为默认,告警 Tab 可切换,监测 API 被正确调用
---
### U2. 竞品分析页面
**Goal:** 新建竞品分析页面展示竞品列表、雷达图对比、差距评分、LLM 推荐。
**Requirements:** R6, R7, R8, R9
**Dependencies:** none
**Files:**
- `frontend/app/(dashboard)/dashboard/competitors/page.tsx` — 新建
- `frontend/lib/api/competitor.ts` — 新建
- `frontend/app/(dashboard)/layout.tsx` — 新增"竞品分析"导航项
- `frontend/lib/api/index.ts` — 添加 competitor 导出
**Approach:**
- 新建 `lib/api/competitor.ts`,封装 6 个端点:竞品 CRUD、推荐、分析、差距评分、洞察详情
- 页面布局:顶部竞品列表卡片 + 添加竞品按钮,中部雷达图对比区,底部差距评分卡片
- 竞品列表调用 `GET /{brand_id}/competitors/`,添加调用 `POST /{brand_id}/competitors/`(上限 5 个)
- 添加竞品时展示推荐列表:调用 `GET /{brand_id}/competitors/recommendations/`
- 雷达图复用 `CompetitorRadarChart` 组件,数据来自 `POST /competitor/analyze`
- 差距评分调用 `GET /competitor/brand/{brand_id}/gap-summary`
- 侧边栏新增导航项:`{ id: "competitors", label: "竞品分析", href: "/dashboard/competitors", icon: <Swords /> }`
**Patterns to follow:** `CompetitorRadarChart.tsx` 已有雷达图实现;`citations/page.tsx` 的列表 + 详情模式
**Test scenarios:**
- 竞品列表加载并展示
- 添加竞品(从推荐列表选择 + 手动输入)
- 删除竞品(上限 5 个校验)
- 执行竞品分析并展示雷达图
- 差距评分卡片展示
**Verification:** 页面加载显示竞品列表,雷达图可交互,差距评分正确展示
---
### U3. Onboarding 自动创建监测任务
**Goal:** 在 Onboarding Step3 创建品牌后自动调用 `monitoringApi.createTask` 创建监测任务。
**Requirements:** KTD4
**Dependencies:** U1
**Files:**
- `frontend/app/(dashboard)/onboarding/page.tsx` — 修改 handleStep3Next/handleStep3Skip
- `frontend/lib/hooks/use-onboarding-data.ts` — 添加 createMonitoringTask 方法
**Approach:**
- 在 `useOnboardingData` hook 中添加 `createMonitoringTask` 方法,调用 `monitoringApi.createTask`
- 在 `handleStep3Next``handleStep3Skip` 中,品牌创建成功后立即调用 `createMonitoringTask`
- 传入品牌 ID 和选择的平台列表,频率使用用户选择的 frequency
- 监测任务创建失败不阻塞 onboarding 流程静默失败console.warn 级别)
**Patterns to follow:** `useOnboardingData` hook 的 `createBrand` 模式
**Test scenarios:**
- Onboarding Step3 完成后自动创建监测任务
- 监测任务创建失败不阻塞 onboarding
**Verification:** 完成 onboarding 后在监测页面能看到自动创建的监测记录
---
### U4. 引用统计可视化
**Goal:** 在引用页面添加统计图表区域展示引用率、平台分布、30 天趋势。
**Requirements:** R10
**Dependencies:** none
**Files:**
- `frontend/app/(dashboard)/dashboard/citations/page.tsx` — 修改
- `frontend/lib/api/citations.ts` — 新建或扩展
**Approach:**
- 新建 `lib/api/citations.ts`,封装 `getCitationStats` 端点
- 在引用页面顶部添加统计区域3 个统计卡片 + 2 个图表)
- 统计卡片:引用率、平均位置、总引用数
- 图表 1平台分布饼图recharts PieChart复用 analytics 页面的饼图模式
- 图表 230 天趋势折线图,复用 `trend-chart.tsx` 组件
- 统计数据调用 `GET /api/v1/citations/stats`
**Patterns to follow:** `trend-chart.tsx` 折线图;`usage/page.tsx` 饼图模式;`api-states.tsx` 加载状态
**Test scenarios:**
- 统计卡片正确展示引用率、平均位置、总数
- 平台分布饼图按平台着色
- 30 天趋势折线图展示变化曲线
- 无数据时展示空状态
**Verification:** 引用页面顶部展示统计图表,数据与后端 API 一致
---
### U5. 健康评分页面
**Goal:** 新建健康评分页面,展示 V2 五维度评分仪表盘、竞品对比雷达图、评分历史趋势。
**Requirements:** R11, R12, R13
**Dependencies:** U2复用 CompetitorRadarChart
**Files:**
- `frontend/app/(dashboard)/dashboard/health-score/page.tsx` — 新建
- `frontend/lib/api/scoring.ts` — 新建
- `frontend/app/(dashboard)/layout.tsx` — 新增"健康评分"导航项
- `frontend/lib/api/index.ts` — 添加 scoring 导出
**Approach:**
- 新建 `lib/api/scoring.ts`,封装 4 个端点V2 评分、V1 兼容、历史趋势、竞品对比
- 页面布局顶部总评分仪表盘recharts PieChart 半圆),中部 5 维度评分卡片,底部 Tab 切换(竞品对比 / 历史趋势)
- 总评分仪表盘recharts `PieChart` 两个弧形(已得分 + 剩余),中心显示分数
- 5 维度卡片:提及率、推荐排名、情感倾向、引用质量、竞品对比,每项显示分数 + 进度条
- 竞品对比 Tab复用 `CompetitorRadarChart`,数据来自 `GET /{brand_id}/compare/`
- 历史趋势 Tab复用 `trend-chart.tsx`,数据来自 `GET /{brand_id}/score/history/`
- 侧边栏新增导航项:`{ id: "health-score", label: "健康评分", href: "/dashboard/health-score", icon: <Heart /> }`
**Patterns to follow:** `CompetitorRadarChart.tsx``trend-chart.tsx``onboarding/Step0HealthScore.tsx` 的评分展示模式
**Test scenarios:**
- 总评分仪表盘正确显示分数和弧形
- 5 维度卡片展示各维度分数和进度条
- 竞品对比雷达图展示品牌与竞品对比
- 历史趋势折线图展示评分变化
- 无品牌时展示空状态引导
**Verification:** 页面加载展示完整评分仪表盘,图表交互正常
---
### U6. 检测任务管理 + Dashboard Agent 区域
**Goal:** 新建检测任务管理页面,替换 Dashboard 主页 Agent 活动占位内容。
**Requirements:** R14, R15
**Dependencies:** none
**Files:**
- `frontend/app/(dashboard)/dashboard/detection/page.tsx` — 新建
- `frontend/lib/api/detection.ts` — 新建
- `frontend/app/(dashboard)/dashboard/page.tsx` — 修改 Agent 活动区域
- `frontend/app/(dashboard)/layout.tsx` — 新增"检测任务"导航项
- `frontend/lib/api/index.ts` — 添加 detection 导出
**Approach:**
- 新建 `lib/api/detection.ts`,封装检测任务 CRUD + 触发端点
- 检测任务页面任务列表Table+ 创建任务 Dialog + 手动触发按钮
- 任务列表展示:查询词、平台、频率、状态、上次运行时间、下次运行时间
- 创建任务 Dialog选择查询词、平台、频率
- 手动触发调用 `POST /tasks/{task_id}/trigger`
- Dashboard 主页 Agent 活动区域:调用 `agentsApi.listTasks(token, {limit: 5})` 获取最近 5 条执行记录,展示为紧凑列表(任务类型 + 状态 Badge + 耗时 + 时间)
- 侧边栏新增导航项:`{ id: "detection", label: "检测任务", href: "/dashboard/detection", icon: <ScanSearch /> }`
**Patterns to follow:** `queries/page.tsx` 的 CRUD 模式;`agents/page.tsx` 的任务列表模式
**Test scenarios:**
- 检测任务列表加载并展示
- 创建新检测任务
- 手动触发检测
- 删除检测任务
- Dashboard Agent 区域展示最近执行记录
**Verification:** 检测任务页面 CRUD 正常Dashboard Agent 区域不再显示"功能开发中"
---
### U7. P2 体验补全
**Goal:** 添加导出入口、公开评分落地页、Agent 配置 UI、趋势/Schema 页面。
**Requirements:** R16, R17, R18, R19, R20, R21
**Dependencies:** U4, U5
**Files:**
- `frontend/app/(dashboard)/dashboard/citations/page.tsx` — 添加导出按钮
- `frontend/app/(dashboard)/dashboard/reports/page.tsx` — 添加导出按钮
- `frontend/app/(public)/health-score/page.tsx` — 新建
- `frontend/app/(dashboard)/dashboard/agents/page.tsx` — 添加配置面板
- `frontend/app/(dashboard)/dashboard/trends/page.tsx` — 新建
- `frontend/app/(dashboard)/dashboard/schema/page.tsx` — 新建
- `frontend/lib/api/trends.ts` — 新建
- `frontend/lib/api/schema-suggestions.ts` — 新建
- `frontend/app/(dashboard)/layout.tsx` — 添加新导航项
- `frontend/lib/api/index.ts` — 添加新模块导出
**Approach:**
- 引用页面添加"导出 CSV"和"导出 PDF"按钮,调用 `fetchWithAuth` blob 模式
- 报告页面添加导出按钮(已有逻辑,确认按钮存在且可用)
- 公开健康评分落地页:无需登录,输入品牌名调用 `/api/v1/public/health-score`,展示 3 维度评分 + "注册查看完整报告" CTA
- Agent 配置面板:在 agents 页面添加配置 Dialog调用 `GET/PUT /{agent_name}/config`
- 趋势洞察页面:调用 `/api/v1/trends` 获取洞察列表和趋势摘要
- Schema 建议页面:调用 `/api/v1/schema` 获取和管理建议
**Patterns to follow:** `reports.ts` 的 blob 导出模式;`Step0HealthScore.tsx` 的公开评分展示
**Test scenarios:**
- 引用页面 CSV/PDF 导出按钮可用
- 公开评分页面无需登录即可查看
- Agent 配置 Dialog 展示和修改配置
- 趋势洞察页面展示洞察列表
- Schema 建议页面展示建议列表
**Verification:** 所有 P2 功能页面可访问且 API 对接正确
---
## Scope Boundaries
**Deferred for later:**
- 监测任务手动创建 UIOnboarding 自动创建已覆盖)
- Agent 手动创建任务 UI
- 内容编辑器深度优化和版本对比
- 国际化i18n改造
- 实时数据推送WebSocket
**Outside this product's identity:**
- 后端 API 新增或修改
- 移动端适配
## Open Questions
**Deferred to implementation:**
- 竞品雷达图的具体维度映射(后端 5 种分析类型如何映射到雷达图轴)
- 公开健康评分落地页的 URL 路由和 SEO 策略
- Dashboard Agent 区域的数据刷新策略
## Risks & Dependencies
- **后端 API 数据格式未验证** — 计划基于代码扫描,未做运行时验证。实现时可能需要适配实际响应格式
- **品牌 ID 获取** — 多个页面需要当前用户的品牌 ID需确认获取方式useApi 调用 brands 端点 or useUserStore
- **recharts 雷达图数据格式** — CompetitorRadarChart 已有实现,但竞品分析 API 返回格式可能需要适配
## Sources & Research
- `frontend/app/(dashboard)/dashboard/citations/page.tsx` — 页面模式参考
- `frontend/lib/api/client.ts` — fetchWithAuth 统一客户端
- `frontend/lib/api/monitoring.ts` — 已有监测 API 模块(完全未使用)
- `frontend/lib/api/alerts.ts` — 告警 API 模块
- `frontend/components/charts/CompetitorRadarChart.tsx` — 已有雷达图组件
- `frontend/components/charts/trend-chart.tsx` — 已有趋势图组件
- `frontend/components/charts/platform-chart.tsx` — 已有平台分布图组件
- `frontend/components/ui/api-states.tsx` — 共享加载/错误/空状态组件
- `frontend/app/(dashboard)/layout.tsx` — 侧边栏导航配置
- `backend/app/api/v1/monitoring.py` — 监测端点
- `backend/app/api/v1/competitors.py` + `backend/app/api/v1/competitor.py` — 竞品端点
- `backend/app/api/v1/citations.py` — 引用统计端点
- `backend/app/api/v1/scoring.py` — 评分端点
- `backend/app/api/v1/detection.py` — 检测任务端点

View File

@ -0,0 +1,493 @@
"use client";
import { useState, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api";
import { competitorApi, type Competitor, type CompetitorRecommendation, type CompetitorGapSummary, type CompetitorInsight } from "@/lib/api/competitor";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart";
import {
Users,
Plus,
Trash2,
Loader2,
Lightbulb,
BarChart3,
Play,
AlertTriangle,
} from "lucide-react";
const ANALYSIS_TYPES = [
{ value: "visibility", label: "可见度分析" },
{ value: "sentiment", label: "情感分析" },
{ value: "citation", label: "引用对比" },
{ value: "content_gap", label: "内容差距" },
];
const RADAR_COLORS = [
"hsl(346.8 77.2% 49.8%)",
"hsl(24.6 95% 53.1%)",
"hsl(142.1 76.2% 36.3%)",
"hsl(262.1 83.3% 57.8%)",
"hsl(45 93.5% 47.6%)",
];
export default function CompetitorsPage() {
const { data: session } = useSession();
const token = session?.accessToken ?? "";
const { data: brandsData, isLoading: brandsLoading } = useApi<{ items: { id: string; name: string }[] }>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const brandName = brandsData?.items?.[0]?.name ?? "";
const competitorsUrl = brandId ? `/api/v1/brands/${brandId}/competitors/` : null;
const { data: competitorsData, isLoading: competitorsLoading, error: competitorsError, refresh: refreshCompetitors } = useApi<{ items: Competitor[]; total: number }>(competitorsUrl);
const recommendationsUrl = brandId ? `/api/v1/brands/${brandId}/competitors/recommendations/` : null;
const { data: recommendationsData, isLoading: recommendationsLoading, error: recommendationsError, refresh: refreshRecommendations } = useApi<CompetitorRecommendation[]>(recommendationsUrl);
const gapSummaryUrl = brandId ? `/api/v1/competitor/brand/${brandId}/gap-summary` : null;
const { data: gapSummaryData, isLoading: gapSummaryLoading, error: gapSummaryError } = useApi<CompetitorGapSummary[]>(gapSummaryUrl);
const insightsUrl = brandId ? `/api/v1/competitor/brand/${brandId}` : null;
const { data: insightsData, isLoading: insightsLoading, refresh: refreshInsights } = useApi<{ items: CompetitorInsight[]; total: number }>(insightsUrl);
const competitors = competitorsData?.items ?? [];
const recommendations = recommendationsData ?? [];
const gapSummaries = gapSummaryData ?? [];
const insights = insightsData?.items ?? [];
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [manualName, setManualName] = useState("");
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [analysisCompetitorId, setAnalysisCompetitorId] = useState<string>("");
const [analysisType, setAnalysisType] = useState<string>("");
const [analyzing, setAnalyzing] = useState(false);
const [analysisError, setAnalysisError] = useState<string | null>(null);
const handleAddFromRecommendation = useCallback(async (name: string) => {
if (!token || !brandId) return;
setAdding(true);
try {
await competitorApi.add(token, brandId, { name });
refreshCompetitors();
} catch {
} finally {
setAdding(false);
}
}, [token, brandId, refreshCompetitors]);
const handleAddManual = useCallback(async () => {
const trimmed = manualName.trim();
if (!trimmed || !token || !brandId) return;
setAdding(true);
try {
await competitorApi.add(token, brandId, { name: trimmed });
setManualName("");
refreshCompetitors();
setAddDialogOpen(false);
} catch {
} finally {
setAdding(false);
}
}, [manualName, token, brandId, refreshCompetitors]);
const handleDelete = useCallback(async (competitorId: string) => {
if (!token || !brandId) return;
setDeletingId(competitorId);
try {
await competitorApi.delete(token, brandId, competitorId);
refreshCompetitors();
} catch {
} finally {
setDeletingId(null);
}
}, [token, brandId, refreshCompetitors]);
const handleAnalyze = useCallback(async () => {
if (!token || !brandId || !analysisCompetitorId || !analysisType) return;
setAnalyzing(true);
setAnalysisError(null);
try {
await competitorApi.analyze(token, {
brand_id: brandId,
competitor_id: analysisCompetitorId,
analysis_type: analysisType,
});
refreshInsights();
} catch (err) {
setAnalysisError(err instanceof Error ? err.message : "分析失败");
} finally {
setAnalyzing(false);
}
}, [token, brandId, analysisCompetitorId, analysisType, refreshInsights]);
const radarData = (() => {
if (!gapSummaries.length) return [];
const dimensions = Object.keys(gapSummaries[0]?.dimensions || {});
return dimensions.map((dim) => {
const item: { label: string; [key: string]: string | number | undefined } = { label: dim, [brandName]: 50 };
gapSummaries.forEach((gs) => {
const val = gs.dimensions[dim];
item[gs.competitor_name] = typeof val === "number" ? val : 0;
});
return item;
});
})();
const radarCompetitors = gapSummaries.map((gs, i) => ({
name: gs.competitor_name,
color: RADAR_COLORS[i % RADAR_COLORS.length],
}));
if (brandsLoading) {
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<LoadingState rows={4} rowHeight="h-24" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogTrigger asChild>
<Button disabled={competitors.length >= 5}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Tabs defaultValue="recommend">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="recommend"></TabsTrigger>
<TabsTrigger value="manual"></TabsTrigger>
</TabsList>
<TabsContent value="recommend" className="mt-4 space-y-3">
{recommendationsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">...</span>
</div>
) : recommendationsError ? (
<ErrorState error={recommendationsError} onRetry={refreshRecommendations} />
) : recommendations.length === 0 ? (
<EmptyState message="暂无推荐竞品" description="请尝试手动输入竞品名称" />
) : (
<div className="grid gap-2">
{recommendations.map((rec) => {
const alreadyAdded = competitors.some((c) => c.name === rec.name);
return (
<div
key={rec.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{rec.name}</p>
<p className="text-xs text-muted-foreground truncate">{rec.reason}</p>
</div>
<Button
size="sm"
variant={alreadyAdded ? "ghost" : "outline"}
disabled={alreadyAdded || adding}
onClick={() => handleAddFromRecommendation(rec.name)}
>
{alreadyAdded ? "已添加" : <Plus className="h-4 w-4" />}
</Button>
</div>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="competitor-name"></Label>
<div className="flex gap-2">
<Input
id="competitor-name"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
placeholder="输入竞品名称"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddManual();
}
}}
/>
<Button onClick={handleAddManual} disabled={!manualName.trim() || adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : "添加"}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
<Badge variant="secondary" className="ml-auto">
{competitors.length}/5
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{competitorsLoading ? (
<LoadingState rows={3} rowHeight="h-16" />
) : competitorsError ? (
<ErrorState error={competitorsError} onRetry={refreshCompetitors} />
) : competitors.length === 0 ? (
<EmptyState
message="暂无竞品"
description="点击上方「添加竞品」按钮开始"
icon={<Users className="h-6 w-6 text-gray-400" />}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{competitors.map((comp) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{comp.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(comp.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={deletingId === comp.id}
onClick={() => handleDelete(comp.id)}
>
{deletingId === comp.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<Select value={analysisCompetitorId} onValueChange={setAnalysisCompetitorId}>
<SelectTrigger>
<SelectValue placeholder="选择竞品" />
</SelectTrigger>
<SelectContent>
{competitors.map((comp) => (
<SelectItem key={comp.id} value={comp.id}>
{comp.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={analysisType} onValueChange={setAnalysisType}>
<SelectTrigger>
<SelectValue placeholder="选择分析类型" />
</SelectTrigger>
<SelectContent>
{ANALYSIS_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
className="w-full"
disabled={!analysisCompetitorId || !analysisType || analyzing}
onClick={handleAnalyze}
>
{analyzing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</div>
{analysisError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
{analysisError}
</div>
)}
{insightsLoading ? (
<LoadingState rows={2} rowHeight="h-20" />
) : insights.length > 0 ? (
<div className="space-y-3">
{insights.map((insight) => (
<div key={insight.id} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline">{insight.insight_type}</Badge>
{insight.competitor_name && (
<Badge variant="secondary">{insight.competitor_name}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{new Date(insight.created_at).toLocaleString("zh-CN")}
</span>
</div>
{insight.recommendations && insight.recommendations.length > 0 && (
<div className="space-y-1">
{insight.recommendations.map((rec, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<Lightbulb className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{rec}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : null}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
{gapSummaryLoading ? (
<LoadingState rows={1} rowHeight="h-80" />
) : gapSummaryError ? (
<ErrorState error={gapSummaryError} />
) : radarData.length > 0 ? (
<CompetitorRadarChart
data={radarData}
brandName={brandName}
competitors={radarCompetitors}
/>
) : (
<EmptyState
message="暂无对比数据"
description="添加竞品并执行分析后将显示雷达图"
icon={<BarChart3 className="h-6 w-6 text-gray-400" />}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
{gapSummaryLoading ? (
<LoadingState rows={3} rowHeight="h-20" grid cols={2} />
) : gapSummaryError ? (
<ErrorState error={gapSummaryError} />
) : gapSummaries.length === 0 ? (
<EmptyState
message="暂无差距评分"
description="执行分析后将显示差距评分"
icon={<AlertTriangle className="h-6 w-6 text-gray-400" />}
/>
) : (
<div className="space-y-3">
{gapSummaries.map((gs) => (
<div key={gs.competitor_name} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{gs.competitor_name}</span>
<Badge
variant={gs.gap_score > 50 ? "destructive" : gs.gap_score > 20 ? "secondary" : "default"}
>
{gs.gap_score.toFixed(1)}
</Badge>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, gs.gap_score)}%` }}
/>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(gs.dimensions).map(([key, val]) => (
<div key={key} className="text-xs text-muted-foreground">
{key}: <span className="font-medium text-foreground">{typeof val === "number" ? val.toFixed(1) : String(val)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -2,12 +2,14 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api"; import { useApi } from "@/lib/hooks/use-api";
import { monitoringApi, MonitoringRecord, MonitoringChangeReport } from "@/lib/api/monitoring";
import { alertsApi } from "@/lib/api/alerts"; import { alertsApi } from "@/lib/api/alerts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -20,16 +22,20 @@ import {
Bell, Bell,
BellRing, BellRing,
AlertTriangle, AlertTriangle,
Plus,
CheckCheck, CheckCheck,
Settings, Settings,
Clock, Clock,
Filter, Filter,
Activity,
Play,
Pause,
RefreshCw,
ChevronDown,
ChevronUp,
Eye,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/* ─── Types ───────────────────────────────────────────────────────────────────*/
interface Alert { interface Alert {
id: string; id: string;
title: string; title: string;
@ -50,7 +56,270 @@ interface UnreadCountResponse {
unread_count: number; unread_count: number;
} }
/* ─── Stat Card Component ────────────────────────────────────────────────────*/ interface BrandsResponse {
items: { id: string; name: string }[];
}
function formatTimeAgo(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "刚刚";
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString("zh-CN");
}
function ChangeTypeBadge({ changeType }: { changeType: string | null }) {
if (!changeType) return null;
const config: Record<string, { variant: "default" | "destructive" | "secondary" | "outline"; label: string }> = {
positive: { variant: "default", label: "正向" },
negative: { variant: "destructive", label: "负向" },
neutral: { variant: "secondary", label: "中性" },
};
const c = config[changeType] || { variant: "outline" as const, label: changeType };
return <Badge variant={c.variant} className="text-xs">{c.label}</Badge>;
}
function RecordCard({
record,
token,
onRefresh,
}: {
record: MonitoringRecord;
token: string;
onRefresh: () => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const [report, setReport] = React.useState<MonitoringChangeReport | null>(null);
const [reportLoading, setReportLoading] = React.useState(false);
const [reportError, setReportError] = React.useState<string | null>(null);
const [checking, setChecking] = React.useState(false);
const [togglingStatus, setTogglingStatus] = React.useState(false);
const handleExpand = async () => {
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
if (!report) {
setReportLoading(true);
setReportError(null);
try {
const result = await monitoringApi.getReport(token, record.id);
setReport(result);
} catch (err) {
setReportError(err instanceof Error ? err.message : "加载报告失败");
} finally {
setReportLoading(false);
}
}
};
const handleTriggerCheck = async () => {
setChecking(true);
try {
await monitoringApi.triggerCheck(token, record.id);
onRefresh();
} catch {
} finally {
setChecking(false);
}
};
const handleToggleStatus = async () => {
setTogglingStatus(true);
try {
const newStatus = record.status === "active" ? "paused" : "active";
await monitoringApi.updateStatus(token, record.id, { status: newStatus });
onRefresh();
} catch {
} finally {
setTogglingStatus(false);
}
};
return (
<Card>
<CardContent className="p-0">
<div
className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={handleExpand}
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 shrink-0">
<Activity className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground truncate">
{record.platform || "全平台"}
</p>
<ChangeTypeBadge changeType={record.change_type} />
{record.status === "paused" && (
<Badge variant="outline" className="text-xs"></Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{record.last_checked_at ? formatTimeAgo(record.last_checked_at) : "未检测"}
</span>
<span>: {record.query_keywords}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleTriggerCheck(); }}
disabled={checking}
>
<RefreshCw className={cn("mr-1 h-3.5 w-3.5", checking && "animate-spin")} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleToggleStatus(); }}
disabled={togglingStatus}
>
{record.status === "active" ? (
<><Pause className="mr-1 h-3.5 w-3.5" /></>
) : (
<><Play className="mr-1 h-3.5 w-3.5" /></>
)}
</Button>
{expanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
{expanded && (
<div className="border-t px-6 py-4 space-y-4">
{reportLoading && <LoadingState rows={2} rowHeight="h-16" />}
{reportError && <ErrorState error={reportError} onRetry={handleExpand} />}
{report && !reportLoading && !reportError && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">线</p>
<div className="rounded-lg border p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{String(report.baseline.citation_count ?? "—")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.baseline.sentiment != null ? String(report.baseline.sentiment) : "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.baseline.rank != null ? String(report.baseline.rank) : "—"}</span>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="rounded-lg border p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{String(report.current.citation_count ?? "—")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.current.sentiment != null ? String(report.current.sentiment) : "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.current.rank != null ? String(report.current.rank) : "—"}</span>
</div>
</div>
</div>
</div>
{report.recommendations && report.recommendations.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<ul className="space-y-1">
{report.recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
function MonitoringRecordsTab() {
const { data: session } = useSession();
const token = session?.accessToken || "";
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const {
data: recordsData,
isLoading,
error,
refresh,
} = useApi<{ records: MonitoringRecord[]; total: number }>(
token && brandId ? `/api/v1/monitoring/brand/${brandId}` : null
);
const records = recordsData?.records ?? [];
if (isLoading) {
return <LoadingState rows={4} rowHeight="h-24" />;
}
if (error) {
return <ErrorState error={error} onRetry={refresh} />;
}
if (records.length === 0) {
return (
<EmptyState
icon={<Activity className="h-6 w-6 text-muted-foreground" />}
message="暂无监测记录"
description="创建监测任务后将在此显示监测结果"
/>
);
}
return (
<div className="space-y-4">
{records.map((record) => (
<RecordCard
key={record.id}
record={record}
token={token}
onRefresh={refresh}
/>
))}
</div>
);
}
interface StatCardProps { interface StatCardProps {
title: string; title: string;
@ -62,28 +331,11 @@ interface StatCardProps {
function StatCard({ title, value, icon, trend, color }: StatCardProps) { function StatCard({ title, value, icon, trend, color }: StatCardProps) {
const colorMap = { const colorMap = {
emerald: { emerald: { bg: "bg-emerald-50", icon: "text-emerald-600", border: "border-emerald-100" },
bg: "bg-emerald-50", amber: { bg: "bg-amber-50", icon: "text-amber-600", border: "border-amber-100" },
icon: "text-emerald-600", red: { bg: "bg-red-50", icon: "text-red-600", border: "border-red-100" },
border: "border-emerald-100", blue: { bg: "bg-blue-50", icon: "text-blue-600", border: "border-blue-100" },
},
amber: {
bg: "bg-amber-50",
icon: "text-amber-600",
border: "border-amber-100",
},
red: {
bg: "bg-red-50",
icon: "text-red-600",
border: "border-red-100",
},
blue: {
bg: "bg-blue-50",
icon: "text-blue-600",
border: "border-blue-100",
},
}; };
const colors = colorMap[color]; const colors = colorMap[color];
return ( return (
@ -93,9 +345,7 @@ function StatCard({ title, value, icon, trend, color }: StatCardProps) {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-2xl font-bold tracking-tight">{value}</p> <p className="text-2xl font-bold tracking-tight">{value}</p>
{trend && ( {trend && <p className="text-xs text-muted-foreground">{trend}</p>}
<p className="text-xs text-muted-foreground">{trend}</p>
)}
</div> </div>
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", colors.bg)}> <div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", colors.bg)}>
<div className={colors.icon}>{icon}</div> <div className={colors.icon}>{icon}</div>
@ -106,8 +356,6 @@ function StatCard({ title, value, icon, trend, color }: StatCardProps) {
); );
} }
/* ─── Alert Row Component ────────────────────────────────────────────────────*/
interface AlertRowProps { interface AlertRowProps {
alert: Alert; alert: Alert;
onMarkRead: (id: string) => void; onMarkRead: (id: string) => void;
@ -116,21 +364,9 @@ interface AlertRowProps {
function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) { function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) {
const severityConfig = { const severityConfig = {
critical: { critical: { badge: "destructive", label: "严重", icon: <AlertTriangle className="h-4 w-4" /> },
badge: "destructive", warning: { badge: "default", label: "警告", icon: <BellRing className="h-4 w-4" /> },
label: "严重", info: { badge: "secondary", label: "信息", icon: <Bell className="h-4 w-4" /> },
icon: <AlertTriangle className="h-4 w-4" />,
},
warning: {
badge: "default",
label: "警告",
icon: <BellRing className="h-4 w-4" />,
},
info: {
badge: "secondary",
label: "信息",
icon: <Bell className="h-4 w-4" />,
},
}; };
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
@ -174,24 +410,16 @@ function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) {
<span className="flex h-2 w-2 rounded-full bg-red-500" /> <span className="flex h-2 w-2 rounded-full bg-red-500" />
)} )}
</div> </div>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">{timeAgo}</span>
{timeAgo}
</span>
</div> </div>
{alert.description && ( {alert.description && (
<p className="text-xs text-muted-foreground leading-snug"> <p className="text-xs text-muted-foreground leading-snug">{alert.description}</p>
{alert.description}
</p>
)} )}
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<Badge variant={config.badge as any} className="text-xs"> <Badge variant={config.badge as "destructive" | "default" | "secondary"} className="text-xs">{config.label}</Badge>
{config.label} <Badge variant="outline" className="text-xs">{typeMap[alert.type] || alert.type}</Badge>
</Badge>
<Badge variant="outline" className="text-xs">
{typeMap[alert.type] || alert.type}
</Badge>
</div> </div>
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
@ -213,72 +441,7 @@ function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) {
); );
} }
/* ─── Skeleton Components ────────────────────────────────────────────────────*/ function AlertsTab() {
function StatCardSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
<div className="h-8 w-16 rounded bg-muted animate-pulse" />
</div>
<div className="h-12 w-12 rounded-lg bg-muted animate-pulse" />
</div>
</CardContent>
</Card>
);
}
function AlertListSkeleton() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-start gap-4">
<div className="h-9 w-9 rounded-lg bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-muted animate-pulse" />
<div className="h-3 w-1/2 rounded bg-muted animate-pulse" />
<div className="flex gap-2">
<div className="h-5 w-12 rounded bg-muted animate-pulse" />
<div className="h-5 w-16 rounded bg-muted animate-pulse" />
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
/* ─── Helpers ────────────────────────────────────────────────────────────────*/
function formatTimeAgo(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "刚刚";
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString("zh-CN");
}
/* ─── Main Page Component ────────────────────────────────────────────────────*/
export default function MonitoringPage() {
const router = useRouter();
const [filterType, setFilterType] = React.useState<string>("all"); const [filterType, setFilterType] = React.useState<string>("all");
const [filterSeverity, setFilterSeverity] = React.useState<string>("all"); const [filterSeverity, setFilterSeverity] = React.useState<string>("all");
const [filterRead, setFilterRead] = React.useState<string>("all"); const [filterRead, setFilterRead] = React.useState<string>("all");
@ -339,96 +502,25 @@ export default function MonitoringPage() {
return date.toDateString() === today.toDateString(); return date.toDateString() === today.toDateString();
}).length; }).length;
const processedCount = alerts.filter((a) => a.is_read).length; const processedCount = alerts.filter((a) => a.is_read).length;
const uniqueTypes = Array.from(new Set(alerts.map((a) => a.type))); const uniqueTypes = Array.from(new Set(alerts.map((a) => a.type)));
if (alertsLoading || unreadLoading) { if (alertsLoading || unreadLoading) {
return ( return <LoadingState rows={6} rowHeight="h-20" />;
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
AI可见性
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCardSkeleton key={i} />
))}
</div>
<AlertListSkeleton />
</div>
);
} }
if (alertsError) { if (alertsError) {
return ( return <ErrorState error={alertsError} onRetry={refreshAlerts} />;
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
AI可见性
</p>
</div>
<ErrorState error={alertsError} onRetry={refreshAlerts} />
</div>
);
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
AI可见性
</p>
</div>
<Button
variant="outline"
onClick={() => router.push("/dashboard/monitoring/settings")}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
{/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard title="未读告警" value={unreadCount} icon={<Bell className="h-6 w-6" />} trend="需要处理" color="blue" />
title="未读告警" <StatCard title="严重告警" value={criticalCount} icon={<AlertTriangle className="h-6 w-6" />} trend="高优先级" color="red" />
value={unreadCount} <StatCard title="今日新增" value={todayCount} icon={<Clock className="h-6 w-6" />} trend="今天" color="amber" />
icon={<Bell className="h-6 w-6" />} <StatCard title="已处理" value={processedCount} icon={<CheckCheck className="h-6 w-6" />} trend="已读" color="emerald" />
trend="需要处理"
color="blue"
/>
<StatCard
title="严重告警"
value={criticalCount}
icon={<AlertTriangle className="h-6 w-6" />}
trend="高优先级"
color="red"
/>
<StatCard
title="今日新增"
value={todayCount}
icon={<Clock className="h-6 w-6" />}
trend="今天"
color="amber"
/>
<StatCard
title="已处理"
value={processedCount}
icon={<CheckCheck className="h-6 w-6" />}
trend="已读"
color="emerald"
/>
</div> </div>
{/* Alert List */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@ -436,25 +528,17 @@ export default function MonitoringPage() {
<BellRing className="h-5 w-5" /> <BellRing className="h-5 w-5" />
{alerts.length > 0 && ( {alerts.length > 0 && (
<Badge variant="secondary" className="ml-2"> <Badge variant="secondary" className="ml-2">{alerts.length}</Badge>
{alerts.length}
</Badge>
)} )}
</CardTitle> </CardTitle>
{unreadCount > 0 && ( {unreadCount > 0 && (
<Button <Button variant="outline" size="sm" onClick={handleMarkAllRead} disabled={mutatingAll}>
variant="outline"
size="sm"
onClick={handleMarkAllRead}
disabled={mutatingAll}
>
<CheckCheck className="mr-1 h-4 w-4" /> <CheckCheck className="mr-1 h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 pt-2"> <div className="flex flex-wrap items-center gap-2 pt-2">
<Filter className="h-4 w-4 text-muted-foreground" /> <Filter className="h-4 w-4 text-muted-foreground" />
<Select value={filterRead} onValueChange={setFilterRead}> <Select value={filterRead} onValueChange={setFilterRead}>
@ -528,3 +612,48 @@ export default function MonitoringPage() {
</div> </div>
); );
} }
export default function MonitoringPage() {
const router = useRouter();
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
AI可见性
</p>
</div>
<Button
variant="outline"
onClick={() => router.push("/dashboard/monitoring/settings")}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
<Tabs defaultValue="records">
<TabsList>
<TabsTrigger value="records">
<Eye className="mr-1.5 h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="alerts">
<Bell className="mr-1.5 h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="records">
<MonitoringRecordsTab />
</TabsContent>
<TabsContent value="alerts">
<AlertsTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -12,6 +12,7 @@ import {
Sparkles, Sparkles,
BookOpen, BookOpen,
BarChart3, BarChart3,
Swords,
Share2, Share2,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
@ -43,10 +44,16 @@ const NAV_GROUPS: NavGroup[] = [
}, },
{ {
id: "analytics", id: "analytics",
label: "数据监测", label: "品牌监测",
href: "/dashboard/analytics", href: "/dashboard/monitoring",
icon: <BarChart3 className="h-5 w-5" />, icon: <BarChart3 className="h-5 w-5" />,
}, },
{
id: "competitors",
label: "竞品分析",
href: "/dashboard/competitors",
icon: <Swords className="h-5 w-5" />,
},
{ {
id: "distribution", id: "distribution",
label: "内容分发", label: "内容分发",

View File

@ -0,0 +1,106 @@
import { fetchWithAuth } from "./client";
function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const qs = Object.entries(params)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join("&");
return qs ? `?${qs}` : "";
}
export interface Competitor {
id: string;
brand_id: string;
name: string;
aliases?: string[];
created_at: string;
updated_at: string;
}
export interface CompetitorListResponse {
items: Competitor[];
total: number;
}
export interface AddCompetitorRequest {
name: string;
}
export interface CompetitorRecommendation {
id: string;
name: string;
reason: string;
}
export interface CompetitorAnalysisRequest {
brand_id: string;
competitor_id: string;
analysis_type: string;
}
export interface CompetitorInsight {
id: string;
brand_id: string;
insight_type: string;
competitor_name: string | null;
data: Record<string, unknown>;
recommendations: string[] | null;
period_start: string | null;
period_end: string | null;
created_at: string;
}
export interface CompetitorInsightList {
items: CompetitorInsight[];
total: number;
}
export interface CompetitorInsightDetail extends CompetitorInsight {}
export interface CompetitorGapSummary {
competitor_name: string;
gap_score: number;
dimensions: Record<string, unknown>;
}
export const competitorApi = {
list: (token: string, brandId: string, params?: { skip?: number; limit?: number }) =>
fetchWithAuth(
`/api/v1/brands/${brandId}/competitors/${buildQuery(params || {})}`,
{},
token
) as Promise<CompetitorListResponse>,
add: (token: string, brandId: string, data: AddCompetitorRequest) =>
fetchWithAuth(`/api/v1/brands/${brandId}/competitors/`, {
method: "POST",
body: JSON.stringify(data),
}, token) as Promise<Competitor>,
delete: (token: string, brandId: string, competitorId: string) =>
fetchWithAuth(`/api/v1/brands/${brandId}/competitors/${competitorId}/`, {
method: "DELETE",
}, token) as Promise<void>,
getRecommendations: (token: string, brandId: string) =>
fetchWithAuth(`/api/v1/brands/${brandId}/competitors/recommendations/`, {}, token) as Promise<CompetitorRecommendation[]>,
analyze: (token: string, data: CompetitorAnalysisRequest) =>
fetchWithAuth("/api/v1/competitor/analyze", {
method: "POST",
body: JSON.stringify(data),
}, token) as Promise<CompetitorInsightList>,
getBrandInsights: (token: string, brandId: string, params?: { skip?: number; limit?: number }) =>
fetchWithAuth(
`/api/v1/competitor/brand/${brandId}${buildQuery(params || {})}`,
{},
token
) as Promise<CompetitorInsightList>,
getInsight: (token: string, insightId: string) =>
fetchWithAuth(`/api/v1/competitor/${insightId}`, {}, token) as Promise<CompetitorInsightDetail>,
getGapSummary: (token: string, brandId: string) =>
fetchWithAuth(`/api/v1/competitor/brand/${brandId}/gap-summary`, {}, token) as Promise<CompetitorGapSummary[]>,
};

View File

@ -70,6 +70,14 @@ export type {
TrendInsightResponse, TrendInsightResponse,
TrendSummary, TrendSummary,
} from "./trends"; } from "./trends";
export { competitorApi } from "./competitor";
export type {
Competitor,
CompetitorListResponse,
AddCompetitorRequest,
CompetitorRecommendation,
CompetitorInsightDetail,
} from "./competitor";
export { usageApi } from "./usage"; export { usageApi } from "./usage";
export type { UsageQuota, UsageResponse } from "./usage"; export type { UsageQuota, UsageResponse } from "./usage";
@ -161,6 +169,7 @@ import { monitoringApi } from "./monitoring";
import { competitorAnalysisApi } from "./competitor-analysis"; import { competitorAnalysisApi } from "./competitor-analysis";
import { schemaAdvisorApi } from "./schema-advisor"; import { schemaAdvisorApi } from "./schema-advisor";
import { trendsApi } from "./trends"; import { trendsApi } from "./trends";
import { competitorApi } from "./competitor";
import { usageApi } from "./usage"; import { usageApi } from "./usage";
/** /**
@ -194,5 +203,6 @@ export const api = {
competitorAnalysis: competitorAnalysisApi, competitorAnalysis: competitorAnalysisApi,
schemaAdvisor: schemaAdvisorApi, schemaAdvisor: schemaAdvisorApi,
trends: trendsApi, trends: trendsApi,
competitor: competitorApi,
usage: usageApi, usage: usageApi,
}; };