merge: Plan 006 — GEO frontend visualization (U1-U7)
This commit is contained in:
commit
feb2bb2af1
|
|
@ -0,0 +1,146 @@
|
||||||
|
---
|
||||||
|
date: 2026-06-01
|
||||||
|
topic: geo-frontend-visualization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
为 GEO 平台已有的完整后端 API 补建前端可视化页面,分三批交付:P0 修复监测页面 + 新建竞品分析页面,P1 引用统计可视化 + 健康评分页面 + 检测任务管理 UI,P2 报告导出入口 + 公开评分落地页 + 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/paused(R4)
|
||||||
|
|
||||||
|
**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 客户端
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
---
|
||||||
|
date: 2026-06-01
|
||||||
|
status: completed
|
||||||
|
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 核心可视化闭环:** R1–R9
|
||||||
|
**P1 数据深度可视化:** R10–R15
|
||||||
|
**P2 体验补全:** R16–R21
|
||||||
|
|
||||||
|
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)` 获取记录列表
|
||||||
|
- 每条记录展示:平台、上次检测时间、变化类型 Badge(positive=绿/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 页面的饼图模式
|
||||||
|
- 图表 2:30 天趋势折线图,复用 `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:**
|
||||||
|
- 监测任务手动创建 UI(Onboarding 自动创建已覆盖)
|
||||||
|
- 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` — 检测任务端点
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { agentsApi, type AgentTask, type TaskLog } from "@/lib/api/agents";
|
import { agentsApi, type AgentTask, type TaskLog, type Agent } from "@/lib/api/agents";
|
||||||
import { MetricCard } from "@/components/business/metric-card";
|
import { MetricCard } from "@/components/business/metric-card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -21,8 +21,11 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle } from "lucide-react";
|
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle, Settings2, Save } from "lucide-react";
|
||||||
|
|
||||||
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
type FilterStatus = "all" | TaskStatus;
|
type FilterStatus = "all" | TaskStatus;
|
||||||
|
|
@ -76,6 +79,10 @@ export default function AgentsPage() {
|
||||||
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
|
||||||
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
|
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
|
||||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [configAgent, setConfigAgent] = useState<Agent | null>(null);
|
||||||
|
const [configJson, setConfigJson] = useState("");
|
||||||
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
|
|
||||||
// 获取执行记录
|
// 获取执行记录
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -98,6 +105,11 @@ export default function AgentsPage() {
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
}, [token, filterStatus]);
|
}, [token, filterStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
agentsApi.list(token).then(setAgents).catch(() => {});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
// 获取任务日志
|
// 获取任务日志
|
||||||
const fetchTaskLogs = async (taskId: string) => {
|
const fetchTaskLogs = async (taskId: string) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
@ -147,6 +159,27 @@ export default function AgentsPage() {
|
||||||
await fetchTaskLogs(task.id);
|
await fetchTaskLogs(task.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openConfig = (agent: Agent) => {
|
||||||
|
setConfigAgent(agent);
|
||||||
|
setConfigJson(JSON.stringify(agent.config ?? {}, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!token || !configAgent) return;
|
||||||
|
try {
|
||||||
|
setSavingConfig(true);
|
||||||
|
const parsed = JSON.parse(configJson);
|
||||||
|
await agentsApi.updateConfig(token, configAgent.id, parsed);
|
||||||
|
setConfigAgent(null);
|
||||||
|
const updated = await agentsApi.list(token);
|
||||||
|
setAgents(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("保存配置失败:", err);
|
||||||
|
} finally {
|
||||||
|
setSavingConfig(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filterButtons: { status: FilterStatus; label: string }[] = [
|
const filterButtons: { status: FilterStatus; label: string }[] = [
|
||||||
{ status: "all", label: "全部" },
|
{ status: "all", label: "全部" },
|
||||||
{ status: "running", label: "运行中" },
|
{ status: "running", label: "运行中" },
|
||||||
|
|
@ -187,6 +220,46 @@ export default function AgentsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
Agent 配置
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">暂无 Agent</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<div key={agent.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{agent.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{agent.type}</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"ml-2 shrink-0",
|
||||||
|
agent.status === "running" && "bg-emerald-100 text-emerald-700",
|
||||||
|
agent.status === "error" && "bg-red-100 text-red-700",
|
||||||
|
agent.status === "disabled" && "bg-gray-100 text-gray-600",
|
||||||
|
agent.status === "idle" && "bg-blue-100 text-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agent.status}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-2 shrink-0" onClick={() => openConfig(agent)}>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 状态筛选 */}
|
{/* 状态筛选 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{filterButtons.map(({ status, label }) => (
|
{filterButtons.map(({ status, label }) => (
|
||||||
|
|
@ -362,6 +435,42 @@ export default function AgentsPage() {
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!configAgent} onOpenChange={() => setConfigAgent(null)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
配置 — {configAgent?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{configAgent && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>配置 (JSON)</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full min-h-[200px] rounded-md border bg-gray-50 p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={configJson}
|
||||||
|
onChange={(e) => setConfigJson(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setConfigAgent(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveConfig} disabled={savingConfig}>
|
||||||
|
{savingConfig ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -22,9 +23,23 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PLATFORM_MAP } from "@/lib/platforms";
|
import { PLATFORM_MAP } from "@/lib/platforms";
|
||||||
import { Check, X, Quote, Filter } from "lucide-react";
|
import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash, FileDown, FileText, Loader2 } from "lucide-react";
|
||||||
import { useApi } from "@/lib/hooks/use-api";
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
import { LoadingState } from "@/components/ui/api-states";
|
import { LoadingState } from "@/components/ui/api-states";
|
||||||
|
import { type CitationStats } from "@/lib/api/citations";
|
||||||
|
import { reportsApi } from "@/lib/api/reports";
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
interface CitationItem {
|
interface CitationItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -42,15 +57,139 @@ interface QueryOption {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PIE_FALLBACK_COLORS = [
|
||||||
|
"#3b82f6",
|
||||||
|
"#10b981",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#8b5cf6",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getChartColor(index: number): string {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const cssVar = style.getPropertyValue(`--chart-${index + 1}`).trim();
|
||||||
|
if (cssVar) return `hsl(var(--chart-${index + 1}))`;
|
||||||
|
}
|
||||||
|
return PIE_FALLBACK_COLORS[index % PIE_FALLBACK_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ElementType;
|
||||||
|
suffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{value}
|
||||||
|
{suffix && <span className="text-sm font-normal text-muted-foreground ml-1">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformPieChart({ data }: { data: { platform: string; count: number }[] }) {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((d) => ({
|
||||||
|
name: PLATFORM_MAP[d.platform] || d.platform,
|
||||||
|
value: d.count,
|
||||||
|
})),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{chartData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getChartColor(index)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "var(--radius)",
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [value, "引用数"]}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendLineChart({ data }: { data: { date: string; count: number }[] }) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "var(--radius)",
|
||||||
|
}}
|
||||||
|
labelFormatter={(value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [`引用次数: ${value}`, ""]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CitationsPage() {
|
export default function CitationsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = (session as { accessToken?: string })?.accessToken;
|
||||||
const [selectedQuery, setSelectedQuery] = useState<string>("all");
|
const [selectedQuery, setSelectedQuery] = useState<string>("all");
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<string>("all");
|
const [selectedPlatform, setSelectedPlatform] = useState<string>("all");
|
||||||
const [startDate, setStartDate] = useState<string>("");
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
const [endDate, setEndDate] = useState<string>("");
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
// 用于手动触发筛选
|
|
||||||
const [filterKey, setFilterKey] = useState(0);
|
const [filterKey, setFilterKey] = useState(0);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
// 构建引用记录查询 URL
|
|
||||||
const citationsUrl = (() => {
|
const citationsUrl = (() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
||||||
|
|
@ -58,10 +197,18 @@ export default function CitationsPage() {
|
||||||
if (startDate) params.append("start_date", startDate);
|
if (startDate) params.append("start_date", startDate);
|
||||||
if (endDate) params.append("end_date", endDate);
|
if (endDate) params.append("end_date", endDate);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
// filterKey 作为虚拟参数,即使筛选条件不变也允许重新请求
|
|
||||||
return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const statsUrl = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
||||||
|
if (startDate) params.append("start_date", startDate);
|
||||||
|
if (endDate) params.append("end_date", endDate);
|
||||||
|
const qs = params.toString();
|
||||||
|
return `/api/v1/citations/stats${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
||||||
|
}, [selectedQuery, startDate, endDate, filterKey]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: citationsData,
|
data: citationsData,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -76,6 +223,12 @@ export default function CitationsPage() {
|
||||||
data: queriesData,
|
data: queriesData,
|
||||||
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: statsData,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
} = useApi<CitationStats>(statsUrl, { dedupingInterval: 0 });
|
||||||
|
|
||||||
const citations: CitationItem[] = citationsData?.items ?? [];
|
const citations: CitationItem[] = citationsData?.items ?? [];
|
||||||
const queries: QueryOption[] = queriesData?.items ?? [];
|
const queries: QueryOption[] = queriesData?.items ?? [];
|
||||||
|
|
||||||
|
|
@ -91,6 +244,35 @@ export default function CitationsPage() {
|
||||||
setFilterKey((k) => k + 1);
|
setFilterKey((k) => k + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExport(format: "csv" | "pdf") {
|
||||||
|
if (!token) return;
|
||||||
|
const queryId = selectedQuery !== "all" ? selectedQuery : undefined;
|
||||||
|
try {
|
||||||
|
setExporting(true);
|
||||||
|
let blob: Blob;
|
||||||
|
let filename: string;
|
||||||
|
if (format === "csv") {
|
||||||
|
blob = await reportsApi.exportCSV(token, queryId) as unknown as Blob;
|
||||||
|
filename = `citations_${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
} else {
|
||||||
|
blob = await reportsApi.exportPDF(token, queryId) as unknown as Blob;
|
||||||
|
filename = `citations_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||||
|
}
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("导出失败:", err);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading && citations.length === 0) {
|
if (isLoading && citations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -105,10 +287,91 @@ export default function CitationsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
||||||
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleExport("csv")}
|
||||||
|
disabled={exporting || !token}
|
||||||
|
>
|
||||||
|
{exporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileDown className="mr-2 h-4 w-4" />}
|
||||||
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleExport("pdf")}
|
||||||
|
disabled={exporting || !token}
|
||||||
|
>
|
||||||
|
{exporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
|
||||||
|
导出 PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!statsError && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{statsLoading ? (
|
||||||
|
<LoadingState rows={2} rowHeight="h-32" />
|
||||||
|
) : statsData ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="引用率"
|
||||||
|
value={statsData.citation_rate != null ? `${(statsData.citation_rate * 100).toFixed(1)}` : "—"}
|
||||||
|
icon={TrendingUp}
|
||||||
|
suffix={statsData.citation_rate != null ? "%" : undefined}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="平均位置"
|
||||||
|
value={statsData.avg_position != null ? statsData.avg_position.toFixed(1) : "—"}
|
||||||
|
icon={MapPin}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="总引用数"
|
||||||
|
value={statsData.total_citations ?? 0}
|
||||||
|
icon={Hash}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">平台分布</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{statsData.platform_distribution?.length > 0 ? (
|
||||||
|
<PlatformPieChart data={statsData.platform_distribution} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
|
暂无平台分布数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">30天趋势</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{statsData.trend?.length > 0 ? (
|
||||||
|
<TrendLineChart data={statsData.trend} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
|
暂无趋势数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,479 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { detectionApi, type DetectionTask } from "@/lib/api/detection";
|
||||||
|
import type { QueryListResponse, ApiQueryItem } from "@/lib/api/queries";
|
||||||
|
import { fetchWithAuth } from "@/lib/api/client";
|
||||||
|
import { PLATFORM_MAP, PLATFORMS } from "@/lib/platforms";
|
||||||
|
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Play,
|
||||||
|
Loader2,
|
||||||
|
ScanSearch,
|
||||||
|
CheckCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const FREQUENCY_MAP: Record<string, string> = {
|
||||||
|
daily: "每日",
|
||||||
|
weekly: "每周",
|
||||||
|
hourly: "每小时",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||||
|
active: { label: "运行中", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||||||
|
paused: { label: "已暂停", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
|
||||||
|
completed: { label: "已完成", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CreateFormData {
|
||||||
|
query_id: string;
|
||||||
|
platforms: string[];
|
||||||
|
frequency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: CreateFormData = {
|
||||||
|
query_id: "",
|
||||||
|
platforms: [],
|
||||||
|
frequency: "weekly",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DetectionPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = (session as { accessToken?: string })?.accessToken;
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<DetectionTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [queries, setQueries] = useState<ApiQueryItem[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateFormData>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await detectionApi.listTasks(token);
|
||||||
|
setTasks(result.items ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "获取检测任务失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueries() {
|
||||||
|
try {
|
||||||
|
const result = await fetchWithAuth("/api/v1/queries/") as QueryListResponse;
|
||||||
|
setQueries(result.items ?? []);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) loadTasks();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
function showSuccess(msg: string) {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
setFormData(emptyForm);
|
||||||
|
setFormErrors({});
|
||||||
|
setMutationError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
loadQueries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlatform(platform: string) {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const platforms = prev.platforms.includes(platform)
|
||||||
|
? prev.platforms.filter((p) => p !== platform)
|
||||||
|
: [...prev.platforms, platform];
|
||||||
|
return { ...prev, platforms };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!formData.query_id) errors.query_id = "请选择查询词";
|
||||||
|
if (formData.platforms.length === 0) errors.platforms = "请至少选择一个平台";
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!validateForm() || !token) return;
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setMutationError(null);
|
||||||
|
await detectionApi.createTask(token, {
|
||||||
|
query_id: formData.query_id,
|
||||||
|
platforms: formData.platforms,
|
||||||
|
frequency: formData.frequency,
|
||||||
|
});
|
||||||
|
setDialogOpen(false);
|
||||||
|
showSuccess("创建成功");
|
||||||
|
loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setMutationError(err instanceof Error ? err.message : "创建失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteDialog(id: string) {
|
||||||
|
setDeletingId(id);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingId || !token) return;
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
await detectionApi.deleteTask(token, deletingId);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeletingId(null);
|
||||||
|
showSuccess("删除成功");
|
||||||
|
loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setMutationError(err instanceof Error ? err.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTrigger(taskId: string) {
|
||||||
|
if (!token) return;
|
||||||
|
setActionLoading(taskId);
|
||||||
|
setMutationError(null);
|
||||||
|
try {
|
||||||
|
await detectionApi.triggerTask(token, taskId);
|
||||||
|
showSuccess("检测已触发");
|
||||||
|
loadTasks();
|
||||||
|
} catch (err) {
|
||||||
|
setMutationError(err instanceof Error ? err.message : "触发检测失败");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">检测任务</h2>
|
||||||
|
<p className="text-muted-foreground">管理AI搜索检测任务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoadingState rows={5} rowHeight="h-14" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">检测任务</h2>
|
||||||
|
<p className="text-muted-foreground">管理AI搜索检测任务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorState error={error} onRetry={loadTasks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">检测任务</h2>
|
||||||
|
<p className="text-muted-foreground">管理AI搜索检测任务</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openAddDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建检测任务</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
配置新的AI搜索检测任务
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
查询词 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.query_id}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, query_id: value }));
|
||||||
|
if (formErrors.query_id) {
|
||||||
|
setFormErrors((prev) => ({ ...prev, query_id: "" }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择查询词" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{queries.map((q) => (
|
||||||
|
<SelectItem key={q.id} value={q.id}>
|
||||||
|
{q.keyword} — {q.target_brand}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formErrors.query_id && (
|
||||||
|
<p className="text-xs text-destructive">{formErrors.query_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
检测平台 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{PLATFORMS.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.key}
|
||||||
|
className="flex cursor-pointer items-center space-x-2 rounded-md border p-2 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary"
|
||||||
|
checked={formData.platforms.includes(p.key)}
|
||||||
|
onChange={() => togglePlatform(p.key)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{p.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{formErrors.platforms && (
|
||||||
|
<p className="text-xs text-destructive">{formErrors.platforms}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="frequency">检测频率</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.frequency}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData((prev) => ({ ...prev, frequency: value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="frequency">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">每小时</SelectItem>
|
||||||
|
<SelectItem value="daily">每日</SelectItem>
|
||||||
|
<SelectItem value="weekly">每周</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{mutationError && (
|
||||||
|
<p className="text-xs text-destructive">{mutationError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMsg && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutationError && !dialogOpen && (
|
||||||
|
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{mutationError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ScanSearch className="h-6 w-6 text-gray-400" />}
|
||||||
|
message="暂无检测任务"
|
||||||
|
description="点击右上角按钮创建您的第一个检测任务"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">检测任务列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>查询词</TableHead>
|
||||||
|
<TableHead>平台</TableHead>
|
||||||
|
<TableHead>频率</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>上次运行</TableHead>
|
||||||
|
<TableHead>下次运行</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const matchedQuery = queries.find((q) => q.id === task.query_id);
|
||||||
|
const statusCfg = STATUS_CONFIG[task.status] ?? {
|
||||||
|
label: task.status,
|
||||||
|
className: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<TableRow key={task.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{matchedQuery?.keyword ?? task.query_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{task.platforms.map((p) => (
|
||||||
|
<Badge key={p} variant="secondary" className="text-xs">
|
||||||
|
{PLATFORM_MAP[p] || p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{FREQUENCY_MAP[task.frequency] || task.frequency}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className={statusCfg.className}>
|
||||||
|
{statusCfg.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{task.last_run_at
|
||||||
|
? new Date(task.last_run_at).toLocaleString("zh-CN")
|
||||||
|
: "从未"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{task.next_run_at
|
||||||
|
? new Date(task.next_run_at).toLocaleString("zh-CN")
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleTrigger(task.id)}
|
||||||
|
disabled={actionLoading === task.id}
|
||||||
|
>
|
||||||
|
{actionLoading === task.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => openDeleteDialog(task.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
删除后无法恢复,确定要删除这个检测任务吗?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
|
import { scoringApi, BrandScore, BrandCompare, ScoreHistory } from "@/lib/api/scoring";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||||
|
import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart";
|
||||||
|
import { round, getStatusColor, getProgressBg } from "@/lib/utils/health-score";
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
} from "recharts";
|
||||||
|
import { Heart } from "lucide-react";
|
||||||
|
|
||||||
|
interface BrandsResponse {
|
||||||
|
items: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIMENSION_LABELS: Record<string, string> = {
|
||||||
|
mention_rate: "提及率",
|
||||||
|
recommendation_rank: "推荐排名",
|
||||||
|
sentiment: "情感倾向",
|
||||||
|
citation_quality: "引用质量",
|
||||||
|
competitor_comparison: "竞品对比",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScoreGauge({ score }: { score: number }) {
|
||||||
|
const percentage = Math.min(Math.max(score, 0), 100);
|
||||||
|
const colorClass = getStatusColor(percentage);
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
"text-green-500": "#22c55e",
|
||||||
|
"text-yellow-500": "#eab308",
|
||||||
|
"text-red-500": "#ef4444",
|
||||||
|
};
|
||||||
|
const fillColor = colorMap[colorClass] || "#3b82f6";
|
||||||
|
const data = [
|
||||||
|
{ name: "score", value: percentage },
|
||||||
|
{ name: "remaining", value: 100 - percentage },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width={220} height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
innerRadius={80}
|
||||||
|
outerRadius={100}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
<Cell fill={fillColor} />
|
||||||
|
<Cell fill="hsl(var(--muted))" />
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-4xl font-bold">{round(percentage, 1)}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DimensionCards({ dimensions }: { dimensions: BrandScore["dimensions"] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{dimensions.map((dim) => {
|
||||||
|
const percentage = dim.max_score > 0 ? (dim.score / dim.max_score) * 100 : 0;
|
||||||
|
const label = DIMENSION_LABELS[dim.name] || dim.name;
|
||||||
|
return (
|
||||||
|
<Card key={dim.name}>
|
||||||
|
<CardContent className="pt-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<span className={`text-sm font-semibold ${getStatusColor(percentage)}`}>
|
||||||
|
{round(percentage, 1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${getProgressBg(percentage)}`}
|
||||||
|
style={{ width: `${Math.max(percentage, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{dim.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompetitorTab({ token, brandId }: { token: string; brandId: string }) {
|
||||||
|
const [data, setData] = React.useState<BrandCompare | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
scoringApi
|
||||||
|
.getCompare(token, brandId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setData(res);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, brandId]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingState rows={2} rowHeight="h-48" />;
|
||||||
|
if (error) return <ErrorState error={error} />;
|
||||||
|
if (!data) return <EmptyState message="暂无竞品对比数据" />;
|
||||||
|
|
||||||
|
const radarData = data.dimensions.map((dim) => {
|
||||||
|
const item: Record<string, string | number> = {
|
||||||
|
label: DIMENSION_LABELS[dim] || dim,
|
||||||
|
dimension: dim,
|
||||||
|
};
|
||||||
|
item[data.brand_name] = 0;
|
||||||
|
data.competitors.forEach((c) => {
|
||||||
|
item[c.name] = c.scores[dim] ?? 0;
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const brandScoreEntry = data.dimensions.reduce<Record<string, number>>(
|
||||||
|
(acc, dim) => {
|
||||||
|
acc[dim] = 0;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
radarData.forEach((item) => {
|
||||||
|
const dim = item.dimension as string;
|
||||||
|
brandScoreEntry[dim] = (item[data.brand_name] as number) || 0;
|
||||||
|
});
|
||||||
|
radarData.forEach((item) => {
|
||||||
|
const dim = item.dimension as string;
|
||||||
|
item[data.brand_name] = brandScoreEntry[dim];
|
||||||
|
});
|
||||||
|
|
||||||
|
const competitors = data.competitors.map((c, i) => ({
|
||||||
|
name: c.name,
|
||||||
|
color: [
|
||||||
|
"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%)",
|
||||||
|
][i % 4],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>竞品对比雷达图</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompetitorRadarChart
|
||||||
|
data={radarData as any}
|
||||||
|
brandName={data.brand_name}
|
||||||
|
competitors={competitors}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryTab({ token, brandId }: { token: string; brandId: string }) {
|
||||||
|
const [data, setData] = React.useState<ScoreHistory | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
scoringApi
|
||||||
|
.getHistory(token, brandId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setData(res);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, brandId]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingState rows={2} rowHeight="h-48" />;
|
||||||
|
if (error) return <ErrorState error={error} />;
|
||||||
|
if (!data || data.scores.length === 0)
|
||||||
|
return <EmptyState message="暂无历史趋势数据" />;
|
||||||
|
|
||||||
|
const chartData = data.scores.map((entry) => ({
|
||||||
|
date: entry.date,
|
||||||
|
score: entry.overall_score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>历史趋势</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "var(--radius)",
|
||||||
|
}}
|
||||||
|
labelFormatter={(value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [`评分: ${value}`, ""]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="score"
|
||||||
|
stroke="hsl(221.2 83.2% 53.3%)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthScorePage() {
|
||||||
|
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 [scoreData, setScoreData] = React.useState<BrandScore | null>(null);
|
||||||
|
const [scoreLoading, setScoreLoading] = React.useState(true);
|
||||||
|
const [scoreError, setScoreError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setScoreLoading(true);
|
||||||
|
setScoreError(null);
|
||||||
|
scoringApi
|
||||||
|
.getScore(token, brandId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setScoreData(res);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setScoreError(err instanceof Error ? err.message : "加载失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setScoreLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, brandId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">健康评分</h1>
|
||||||
|
<p className="text-muted-foreground">品牌在AI搜索中的综合健康表现</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!token || !brandId ? (
|
||||||
|
<LoadingState rows={3} rowHeight="h-32" />
|
||||||
|
) : scoreLoading ? (
|
||||||
|
<LoadingState rows={3} rowHeight="h-32" />
|
||||||
|
) : scoreError ? (
|
||||||
|
<ErrorState error={scoreError} />
|
||||||
|
) : !scoreData ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Heart className="h-6 w-6 text-muted-foreground" />}
|
||||||
|
message="暂无健康评分数据"
|
||||||
|
description="请先完成品牌评分检测"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 flex flex-col items-center">
|
||||||
|
<ScoreGauge score={scoreData.overall_score} />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
综合健康评分
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DimensionCards dimensions={scoreData.dimensions} />
|
||||||
|
|
||||||
|
<Tabs defaultValue="compare">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="compare">竞品对比</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">历史趋势</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="compare">
|
||||||
|
<CompetitorTab token={token} brandId={brandId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="history">
|
||||||
|
<HistoryTab token={token} brandId={brandId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MetricCard, StageProgress } from "@/components/business";
|
import { MetricCard, StageProgress } from "@/components/business";
|
||||||
|
|
@ -16,8 +17,14 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
Zap,
|
||||||
Lock,
|
Lock,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type GeoProject, type LifecycleStats } from "@/lib/api";
|
import { type GeoProject, type LifecycleStats } from "@/lib/api";
|
||||||
|
import { agentsApi, type AgentTask } from "@/lib/api/agents";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useApi } from "@/lib/hooks/use-api";
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
import {
|
import {
|
||||||
LoadingState,
|
LoadingState,
|
||||||
|
|
@ -87,6 +94,138 @@ function getRecommendation(stage: GeoProject["current_stage"]) {
|
||||||
return map[stage];
|
return map[stage];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Agent Activity Component ───────────────────────────────────────────────*/
|
||||||
|
|
||||||
|
const TASK_STATUS_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; icon: React.ReactNode; color: string }
|
||||||
|
> = {
|
||||||
|
pending: {
|
||||||
|
label: "等待中",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
color: "bg-gray-100 text-gray-600",
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
label: "运行中",
|
||||||
|
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||||||
|
color: "bg-blue-100 text-blue-600",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "已完成",
|
||||||
|
icon: <CheckCircle2 className="h-3 w-3" />,
|
||||||
|
color: "bg-emerald-100 text-emerald-600",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "失败",
|
||||||
|
icon: <XCircle className="h-3 w-3" />,
|
||||||
|
color: "bg-red-100 text-red-600",
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
label: "已取消",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
color: "bg-yellow-100 text-yellow-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(startedAt?: string, completedAt?: string): string {
|
||||||
|
if (!startedAt) return "-";
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||||
|
const seconds = Math.round((end - start) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return "刚刚";
|
||||||
|
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
if (diffHour < 24) return `${diffHour}小时前`;
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
return `${diffDay}天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentActivity() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = (session as { accessToken?: string })?.accessToken;
|
||||||
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
agentsApi
|
||||||
|
.listTasks(token, { limit: 5 })
|
||||||
|
.then((result) => setTasks(result.items ?? []))
|
||||||
|
.catch(() => setTasks([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Agent活动</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/agents"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Zap className="h-8 w-8 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
暂无执行记录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const cfg = TASK_STATUS_CONFIG[task.status] ?? {
|
||||||
|
label: task.status,
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
color: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-gray-700 truncate flex-1 min-w-0">
|
||||||
|
{task.task_type}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`text-xs shrink-0 ${cfg.color}`}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
<span className="ml-1">{cfg.label}</span>
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
|
{formatDuration(task.started_at, task.completed_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
|
{task.created_at ? formatRelativeTime(task.created_at) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Component ───────────────────────────────────────────────────────────────*/
|
/* ─── Component ───────────────────────────────────────────────────────────────*/
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
|
@ -391,26 +530,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Activity */}
|
{/* Agent Activity */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<AgentActivity />
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Agent活动</p>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/agents"
|
|
||||||
className="text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<Zap className="h-8 w-8 text-muted-foreground mb-3" />
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
功能开发中
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Agent状态监控即将上线
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { schemaAdvisorApi, type SchemaSuggestion } from "@/lib/api/schema-advisor";
|
||||||
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
|
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
FileJson,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const VALIDATION_BADGE: Record<string, { label: string; className: string }> = {
|
||||||
|
valid: { label: "有效", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||||||
|
invalid: { label: "无效", className: "bg-red-100 text-red-700 hover:bg-red-100" },
|
||||||
|
pending: { label: "待验证", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
|
||||||
|
pending: { label: "待处理", className: "bg-gray-100 text-gray-700 hover:bg-gray-100" },
|
||||||
|
applied: { label: "已应用", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||||||
|
dismissed: { label: "已忽略", className: "bg-red-100 text-red-700 hover:bg-red-100" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SchemaPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = (session as { accessToken?: string })?.accessToken;
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = useState<SchemaSuggestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [adviseDialogOpen, setAdviseDialogOpen] = useState(false);
|
||||||
|
const [targetUrl, setTargetUrl] = useState("");
|
||||||
|
const [advising, setAdvising] = useState(false);
|
||||||
|
const [adviseError, setAdviseError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
const [selectedSuggestion, setSelectedSuggestion] = useState<SchemaSuggestion | null>(null);
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState(false);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: brandsData } = useApi<{ items: { id: string; name: string }[] }>("/api/v1/brands/");
|
||||||
|
const brandId = brandsData?.items?.[0]?.id ?? "";
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await schemaAdvisorApi.getBrandSuggestions(token, brandId);
|
||||||
|
setSuggestions(result.suggestions ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "获取 Schema 建议失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && brandId) loadSuggestions();
|
||||||
|
}, [token, brandId]);
|
||||||
|
|
||||||
|
async function handleAdvise() {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
try {
|
||||||
|
setAdvising(true);
|
||||||
|
setAdviseError(null);
|
||||||
|
await schemaAdvisorApi.advise(token, {
|
||||||
|
brand_id: brandId,
|
||||||
|
target_url: targetUrl || undefined,
|
||||||
|
});
|
||||||
|
setAdviseDialogOpen(false);
|
||||||
|
setTargetUrl("");
|
||||||
|
loadSuggestions();
|
||||||
|
} catch (err) {
|
||||||
|
setAdviseError(err instanceof Error ? err.message : "生成建议失败");
|
||||||
|
} finally {
|
||||||
|
setAdvising(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(suggestion: SchemaSuggestion) {
|
||||||
|
setSelectedSuggestion(suggestion);
|
||||||
|
setStatusError(null);
|
||||||
|
setDetailDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateStatus(suggestionId: string, status: string) {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
setStatusUpdating(true);
|
||||||
|
setStatusError(null);
|
||||||
|
await schemaAdvisorApi.updateStatus(token, suggestionId, status);
|
||||||
|
setDetailDialogOpen(false);
|
||||||
|
setSelectedSuggestion(null);
|
||||||
|
loadSuggestions();
|
||||||
|
} catch (err) {
|
||||||
|
setStatusError(err instanceof Error ? err.message : "状态更新失败");
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||||||
|
<p className="text-muted-foreground">结构化数据优化建议</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LoadingState rows={5} rowHeight="h-14" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||||||
|
<p className="text-muted-foreground">结构化数据优化建议</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorState error={error} onRetry={loadSuggestions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||||||
|
<p className="text-muted-foreground">结构化数据优化建议,提升搜索引擎与 AI 平台的理解能力</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
|
||||||
|
<Code2 className="mr-2 h-4 w-4" />
|
||||||
|
生成建议
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<FileJson className="h-6 w-6 text-gray-400" />}
|
||||||
|
message="暂无 Schema 建议"
|
||||||
|
description="点击右上角按钮生成结构化数据优化建议"
|
||||||
|
action={
|
||||||
|
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
|
||||||
|
<Code2 className="mr-2 h-4 w-4" />
|
||||||
|
生成建议
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">建议列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Schema 类型</TableHead>
|
||||||
|
<TableHead>目标 URL</TableHead>
|
||||||
|
<TableHead>验证状态</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const vCfg = VALIDATION_BADGE[s.validation_status ?? "pending"] ?? {
|
||||||
|
label: s.validation_status ?? "未知",
|
||||||
|
className: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
const sCfg = STATUS_BADGE[s.status] ?? {
|
||||||
|
label: s.status,
|
||||||
|
className: "bg-gray-100 text-gray-600",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={s.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => openDetail(s)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileJson className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{s.schema_type}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||||
|
{s.target_url ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className={vCfg.className}>
|
||||||
|
{s.validation_status === "valid" && <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||||
|
{s.validation_status === "invalid" && <XCircle className="mr-1 h-3 w-3" />}
|
||||||
|
{s.validation_status === "pending" && <AlertTriangle className="mr-1 h-3 w-3" />}
|
||||||
|
{vCfg.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.priority !== null ? (
|
||||||
|
<Badge variant="outline">{s.priority}</Badge>
|
||||||
|
) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className={sCfg.className}>
|
||||||
|
{sCfg.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(s.created_at).toLocaleString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openDetail(s); }}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={adviseDialogOpen} onOpenChange={setAdviseDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>生成 Schema 建议</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="target-url">目标 URL(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="target-url"
|
||||||
|
placeholder="https://example.com/page"
|
||||||
|
value={targetUrl}
|
||||||
|
onChange={(e) => setTargetUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{adviseError && (
|
||||||
|
<p className="text-xs text-destructive">{adviseError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAdviseDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdvise} disabled={advising}>
|
||||||
|
{advising && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileJson className="h-5 w-5" />
|
||||||
|
{selectedSuggestion?.schema_type}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedSuggestion && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">目标 URL:</span>
|
||||||
|
<span className="font-medium">{selectedSuggestion.target_url ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">优先级:</span>
|
||||||
|
<span className="font-medium">{selectedSuggestion.priority ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">验证状态:</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]
|
||||||
|
?.className ?? "bg-gray-100 text-gray-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]?.label ?? "未知"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">当前状态:</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
STATUS_BADGE[selectedSuggestion.status]?.className ?? "bg-gray-100 text-gray-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{STATUS_BADGE[selectedSuggestion.status]?.label ?? selectedSuggestion.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Code2 className="h-4 w-4" />
|
||||||
|
JSON-LD
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||||
|
{JSON.stringify(selectedSuggestion.json_ld, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSuggestion.validation_errors && selectedSuggestion.validation_errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
验证错误
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
|
{selectedSuggestion.validation_errors.map((err, i) => (
|
||||||
|
<li key={i} className="text-xs text-red-700">
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{statusError && (
|
||||||
|
<p className="text-xs text-destructive">{statusError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSuggestion.status === "pending" && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateStatus(selectedSuggestion.id, "applied")}
|
||||||
|
disabled={statusUpdating}
|
||||||
|
>
|
||||||
|
{statusUpdating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleUpdateStatus(selectedSuggestion.id, "dismissed")}
|
||||||
|
disabled={statusUpdating}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
忽略
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,402 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
|
import { trendsApi, type TrendInsight, type TrendSummary } from "@/lib/api/trends";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||||
|
import { TrendingUp, TrendingDown, RefreshCw, Eye, Clock, BarChart3, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface BrandsResponse {
|
||||||
|
items: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendDirectionIcon({ direction }: { direction: string }) {
|
||||||
|
if (direction === "up" || direction === "rising") {
|
||||||
|
return <TrendingUp className="h-5 w-5 text-emerald-600" />;
|
||||||
|
}
|
||||||
|
if (direction === "down" || direction === "declining") {
|
||||||
|
return <TrendingDown className="h-5 w-5 text-red-500" />;
|
||||||
|
}
|
||||||
|
return <BarChart3 className="h-5 w-5 text-amber-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendDirectionBadge({ direction }: { direction: string }) {
|
||||||
|
const config: Record<string, { variant: "default" | "destructive" | "secondary" | "outline"; label: string }> = {
|
||||||
|
up: { variant: "default", label: "上升" },
|
||||||
|
rising: { variant: "default", label: "上升" },
|
||||||
|
down: { variant: "destructive", label: "下降" },
|
||||||
|
declining: { variant: "destructive", label: "下降" },
|
||||||
|
stable: { variant: "secondary", label: "平稳" },
|
||||||
|
flat: { variant: "secondary", label: "平稳" },
|
||||||
|
};
|
||||||
|
const c = config[direction] || { variant: "outline" as const, label: direction };
|
||||||
|
return <Badge variant={c.variant} className="text-xs">{c.label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightTypeBadge({ type }: { type: string }) {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
keyword_trend: "关键词趋势",
|
||||||
|
sentiment: "情感分析",
|
||||||
|
platform_comparison: "平台对比",
|
||||||
|
competitor_movement: "竞品动态",
|
||||||
|
content_gap: "内容缺口",
|
||||||
|
};
|
||||||
|
return <Badge variant="outline" className="text-xs">{typeMap[type] || type}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonViewer({ data }: { data: Record<string, unknown> }) {
|
||||||
|
return (
|
||||||
|
<pre className="rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed overflow-auto max-h-[400px]">
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = (session as { accessToken?: string })?.accessToken;
|
||||||
|
|
||||||
|
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/");
|
||||||
|
const brandId = brandsData?.items?.[0]?.id ?? null;
|
||||||
|
|
||||||
|
const [insights, setInsights] = useState<TrendInsight[]>([]);
|
||||||
|
const [insightsLoading, setInsightsLoading] = useState(true);
|
||||||
|
const [insightsError, setInsightsError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<TrendSummary | null>(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
|
const [summaryError, setSummaryError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedInsight, setSelectedInsight] = useState<TrendInsight | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [periodDays, setPeriodDays] = useState<string>("7");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
|
||||||
|
setInsightsLoading(true);
|
||||||
|
setInsightsError(null);
|
||||||
|
trendsApi
|
||||||
|
.getBrandInsights(token, brandId)
|
||||||
|
.then((res) => setInsights(res.items ?? []))
|
||||||
|
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
.finally(() => setInsightsLoading(false));
|
||||||
|
|
||||||
|
setSummaryLoading(true);
|
||||||
|
setSummaryError(null);
|
||||||
|
trendsApi
|
||||||
|
.getSummary(token, brandId)
|
||||||
|
.then((res) => setSummary(res))
|
||||||
|
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
.finally(() => setSummaryLoading(false));
|
||||||
|
}, [token, brandId]);
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
|
||||||
|
setInsightsLoading(true);
|
||||||
|
setInsightsError(null);
|
||||||
|
trendsApi
|
||||||
|
.getBrandInsights(token, brandId)
|
||||||
|
.then((res) => setInsights(res.items ?? []))
|
||||||
|
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
.finally(() => setInsightsLoading(false));
|
||||||
|
|
||||||
|
setSummaryLoading(true);
|
||||||
|
setSummaryError(null);
|
||||||
|
trendsApi
|
||||||
|
.getSummary(token, brandId)
|
||||||
|
.then((res) => setSummary(res))
|
||||||
|
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
|
||||||
|
.finally(() => setSummaryLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewDetail(insightId: string) {
|
||||||
|
if (!token) return;
|
||||||
|
setDetailLoading(true);
|
||||||
|
setSelectedInsight(null);
|
||||||
|
setDetailOpen(true);
|
||||||
|
try {
|
||||||
|
const result = await trendsApi.getInsight(token, insightId);
|
||||||
|
setSelectedInsight(result);
|
||||||
|
} catch {
|
||||||
|
setSelectedInsight(null);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateInsight() {
|
||||||
|
if (!token || !brandId) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await trendsApi.createInsight(token, {
|
||||||
|
brand_id: brandId,
|
||||||
|
period_days: Number(periodDays),
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
handleRefresh();
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !brandId) {
|
||||||
|
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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">趋势洞察</h2>
|
||||||
|
<p className="text-muted-foreground">分析品牌趋势变化与热点关键词</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={insightsLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${insightsLoading ? "animate-spin" : ""}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<TrendingUp className="mr-2 h-4 w-4" />
|
||||||
|
生成洞察
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summaryError ? (
|
||||||
|
<ErrorState error={summaryError} onRetry={handleRefresh} />
|
||||||
|
) : summaryLoading ? (
|
||||||
|
<LoadingState rows={1} rowHeight="h-32" />
|
||||||
|
) : summary ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
趋势概览
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
近 {summary.period_days} 天
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TrendDirectionIcon direction={summary.trend_direction} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">趋势方向</p>
|
||||||
|
<TrendDirectionBadge direction={summary.trend_direction} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">热点关键词</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{summary.hotspot_keywords?.length > 0 ? (
|
||||||
|
summary.hotspot_keywords.map((kw) => (
|
||||||
|
<Badge key={kw} variant="secondary" className="text-xs">
|
||||||
|
{kw}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">暂无热点关键词</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{insightsError ? (
|
||||||
|
<ErrorState error={insightsError} onRetry={handleRefresh} />
|
||||||
|
) : insightsLoading ? (
|
||||||
|
<LoadingState rows={5} rowHeight="h-12" />
|
||||||
|
) : insights.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<TrendingUp className="h-6 w-6 text-muted-foreground" />}
|
||||||
|
message="暂无洞察记录"
|
||||||
|
description="点击「生成洞察」按钮创建趋势洞察分析"
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<TrendingUp className="mr-2 h-4 w-4" />
|
||||||
|
生成洞察
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">洞察记录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>洞察类型</TableHead>
|
||||||
|
<TableHead>分析周期</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{insights.map((insight) => (
|
||||||
|
<TableRow key={insight.id}>
|
||||||
|
<TableCell>
|
||||||
|
<InsightTypeBadge type={insight.insight_type} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{insight.period_start && insight.period_end ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(insight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(insight.period_end).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(insight.created_at).toLocaleString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
onClick={() => handleViewDetail(insight.id)}
|
||||||
|
>
|
||||||
|
<Eye className="mr-1 h-3.5 w-3.5" />
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
洞察详情
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{detailLoading ? (
|
||||||
|
<LoadingState rows={3} rowHeight="h-16" />
|
||||||
|
) : selectedInsight ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InsightTypeBadge type={selectedInsight.insight_type} />
|
||||||
|
{selectedInsight.period_start && selectedInsight.period_end && (
|
||||||
|
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{new Date(selectedInsight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(selectedInsight.period_end).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">洞察数据</p>
|
||||||
|
<JsonViewer data={selectedInsight.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedInsight.recommendations && selectedInsight.recommendations.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">优化建议</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{selectedInsight.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>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">加载洞察详情失败</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
生成趋势洞察
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">分析周期</p>
|
||||||
|
<Select value={periodDays} onValueChange={setPeriodDays}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择分析周期" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">近 7 天</SelectItem>
|
||||||
|
<SelectItem value="14">近 14 天</SelectItem>
|
||||||
|
<SelectItem value="30">近 30 天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={creating}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateInsight} disabled={creating}>
|
||||||
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,12 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Swords,
|
||||||
Share2,
|
Share2,
|
||||||
|
Heart,
|
||||||
|
ScanSearch,
|
||||||
|
TrendingUp,
|
||||||
|
Code2,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -43,16 +48,46 @@ 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: "health-score",
|
||||||
|
label: "健康评分",
|
||||||
|
href: "/dashboard/health-score",
|
||||||
|
icon: <Heart className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "distribution",
|
id: "distribution",
|
||||||
label: "内容分发",
|
label: "内容分发",
|
||||||
href: "/dashboard/distribution",
|
href: "/dashboard/distribution",
|
||||||
icon: <Share2 className="h-5 w-5" />,
|
icon: <Share2 className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "detection",
|
||||||
|
label: "检测任务",
|
||||||
|
href: "/dashboard/detection",
|
||||||
|
icon: <ScanSearch className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trends",
|
||||||
|
label: "趋势洞察",
|
||||||
|
href: "/dashboard/trends",
|
||||||
|
icon: <TrendingUp className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "schema",
|
||||||
|
label: "Schema 建议",
|
||||||
|
href: "/dashboard/schema",
|
||||||
|
icon: <Code2 className="h-5 w-5" />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export default function OnboardingPage() {
|
||||||
createBrand: hookCreateBrand,
|
createBrand: hookCreateBrand,
|
||||||
isCreatingBrand,
|
isCreatingBrand,
|
||||||
mutationError,
|
mutationError,
|
||||||
|
createMonitoringTask,
|
||||||
} = useOnboardingData();
|
} = useOnboardingData();
|
||||||
|
|
||||||
const error = mutationError?.message ?? null;
|
const error = mutationError?.message ?? null;
|
||||||
|
|
@ -117,6 +118,7 @@ export default function OnboardingPage() {
|
||||||
) => {
|
) => {
|
||||||
const brandId = await createBrand();
|
const brandId = await createBrand();
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
|
createMonitoringTask(brandId, platforms, frequency);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
platforms,
|
platforms,
|
||||||
|
|
@ -146,6 +148,7 @@ export default function OnboardingPage() {
|
||||||
];
|
];
|
||||||
const brandId = await createBrand();
|
const brandId = await createBrand();
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
|
createMonitoringTask(brandId, defaultPlatforms, state.frequency);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
platforms: defaultPlatforms,
|
platforms: defaultPlatforms,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { fetchWithAuth } from "./client";
|
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 CitationRecord {
|
export interface CitationRecord {
|
||||||
id: string;
|
id: string;
|
||||||
query_id: string;
|
query_id: string;
|
||||||
|
|
@ -18,11 +26,23 @@ export interface CitationListResponse {
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlatformDistribution {
|
||||||
|
platform: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CitationStats {
|
export interface CitationStats {
|
||||||
total_queries: number;
|
total_queries: number;
|
||||||
total_citations: number;
|
total_citations: number;
|
||||||
citation_rate: number;
|
citation_rate: number;
|
||||||
avg_position: number | null;
|
avg_position: number | null;
|
||||||
|
platform_distribution: PlatformDistribution[];
|
||||||
|
trend: TrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const citationsApi = {
|
export const citationsApi = {
|
||||||
|
|
@ -32,6 +52,10 @@ export const citationsApi = {
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
) as Promise<CitationListResponse>,
|
) as Promise<CitationListResponse>,
|
||||||
getStats: (token: string) =>
|
getStats: (token: string, brandId?: string, queryId?: string) =>
|
||||||
fetchWithAuth("/api/v1/citations/stats/", {}, token) as Promise<CitationStats>,
|
fetchWithAuth(
|
||||||
|
`/api/v1/citations/stats${buildQuery({ brand_id: brandId, query_id: queryId })}`,
|
||||||
|
{},
|
||||||
|
token
|
||||||
|
) as Promise<CitationStats>,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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[]>,
|
||||||
|
};
|
||||||
|
|
@ -8,19 +8,47 @@ function buildQuery(params: Record<string, string | number | boolean | undefined
|
||||||
return qs ? `?${qs}` : "";
|
return qs ? `?${qs}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetectionTask {
|
||||||
|
id: string;
|
||||||
|
query_id: string;
|
||||||
|
platforms: string[];
|
||||||
|
frequency: string;
|
||||||
|
status: string;
|
||||||
|
last_run_at: string | null;
|
||||||
|
next_run_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionTaskList {
|
||||||
|
items: DetectionTask[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionTaskCreate {
|
||||||
|
query_id: string;
|
||||||
|
platforms: string[];
|
||||||
|
frequency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectionTaskUpdate {
|
||||||
|
platforms?: string[];
|
||||||
|
frequency?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const detectionApi = {
|
export const detectionApi = {
|
||||||
listTasks: (token?: string, params?: { skip?: number; limit?: number; status?: string }) =>
|
listTasks: (token: string, params?: { skip?: number; limit?: number; status?: string }) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token),
|
fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token) as Promise<DetectionTaskList>,
|
||||||
|
|
||||||
createTask: (data: Record<string, unknown>, token?: string) =>
|
createTask: (token: string, data: DetectionTaskCreate) =>
|
||||||
fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token),
|
fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token) as Promise<DetectionTask>,
|
||||||
|
|
||||||
updateTask: (taskId: string, data: Record<string, unknown>, token?: string) =>
|
updateTask: (token: string, taskId: string, data: DetectionTaskUpdate) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token) as Promise<DetectionTask>,
|
||||||
|
|
||||||
deleteTask: (taskId: string, token?: string) =>
|
deleteTask: (token: string, taskId: string) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token) as Promise<void>,
|
||||||
|
|
||||||
triggerTask: (taskId: string, token?: string) =>
|
triggerTask: (token: string, taskId: string) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token) as Promise<DetectionTask>,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export { authApi } from "./auth";
|
||||||
export { queriesApi } from "./queries";
|
export { queriesApi } from "./queries";
|
||||||
export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries";
|
export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries";
|
||||||
export { citationsApi } from "./citations";
|
export { citationsApi } from "./citations";
|
||||||
export type { CitationRecord, CitationListResponse, CitationStats } from "./citations";
|
export type { CitationRecord, CitationListResponse, CitationStats, PlatformDistribution, TrendPoint } from "./citations";
|
||||||
export { reportsApi } from "./reports";
|
export { reportsApi } from "./reports";
|
||||||
export { subscriptionsApi } from "./subscriptions";
|
export { subscriptionsApi } from "./subscriptions";
|
||||||
export type { SubscriptionInfo } from "./subscriptions";
|
export type { SubscriptionInfo } from "./subscriptions";
|
||||||
|
|
@ -37,6 +37,7 @@ export type {
|
||||||
UpdateMemberRolePayload,
|
UpdateMemberRolePayload,
|
||||||
} from "./organization";
|
} from "./organization";
|
||||||
export { detectionApi } from "./detection";
|
export { detectionApi } from "./detection";
|
||||||
|
export type { DetectionTask, DetectionTaskList, DetectionTaskCreate, DetectionTaskUpdate } from "./detection";
|
||||||
export { strategyApi } from "./strategy";
|
export { strategyApi } from "./strategy";
|
||||||
export { monitoringApi } from "./monitoring";
|
export { monitoringApi } from "./monitoring";
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -70,8 +71,18 @@ 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";
|
||||||
|
export { scoringApi } from "./scoring";
|
||||||
|
export type { BrandScore, BrandScoreDimension, BrandCompare, BrandCompareCompetitor, ScoreHistory, ScoreHistoryEntry } from "./scoring";
|
||||||
|
|
||||||
// ── 类型导出 ───────────────────────────────────────────────────────────────────
|
// ── 类型导出 ───────────────────────────────────────────────────────────────────
|
||||||
export type { Agent, AgentRunLog } from "./agents";
|
export type { Agent, AgentRunLog } from "./agents";
|
||||||
|
|
@ -161,7 +172,9 @@ 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";
|
||||||
|
import { scoringApi } from "./scoring";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。
|
* 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。
|
||||||
|
|
@ -194,5 +207,7 @@ export const api = {
|
||||||
competitorAnalysis: competitorAnalysisApi,
|
competitorAnalysis: competitorAnalysisApi,
|
||||||
schemaAdvisor: schemaAdvisorApi,
|
schemaAdvisor: schemaAdvisorApi,
|
||||||
trends: trendsApi,
|
trends: trendsApi,
|
||||||
|
competitor: competitorApi,
|
||||||
usage: usageApi,
|
usage: usageApi,
|
||||||
|
scoring: scoringApi,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { fetchWithAuth } from "./client";
|
||||||
|
|
||||||
|
export interface BrandScoreDimension {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
max_score: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandScore {
|
||||||
|
overall_score: number;
|
||||||
|
dimensions: BrandScoreDimension[];
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandCompareCompetitor {
|
||||||
|
name: string;
|
||||||
|
scores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandCompare {
|
||||||
|
brand_name: string;
|
||||||
|
competitors: BrandCompareCompetitor[];
|
||||||
|
dimensions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreHistoryEntry {
|
||||||
|
date: string;
|
||||||
|
overall_score: number;
|
||||||
|
dimension_scores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreHistory {
|
||||||
|
scores: ScoreHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scoringApi = {
|
||||||
|
getScore: (token: string, brandId: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/brands/${brandId}/score/`, {}, token) as Promise<BrandScore>,
|
||||||
|
|
||||||
|
getHistory: (token: string, brandId: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/brands/${brandId}/score/history/`, {}, token) as Promise<ScoreHistory>,
|
||||||
|
|
||||||
|
getCompare: (token: string, brandId: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/brands/${brandId}/compare/`, {}, token) as Promise<BrandCompare>,
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useApi, useApiMutation } from "./use-api";
|
import { useApi, useApiMutation } from "./use-api";
|
||||||
import type { SWRConfiguration } from "swr";
|
import type { SWRConfiguration } from "swr";
|
||||||
|
import { getSession } from "next-auth/react";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { monitoringApi } from "@/lib/api/monitoring";
|
||||||
|
|
||||||
interface OnboardingStatusResponse {
|
interface OnboardingStatusResponse {
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
|
@ -42,6 +45,12 @@ export interface UseOnboardingDataReturn {
|
||||||
isCreatingBrand: boolean;
|
isCreatingBrand: boolean;
|
||||||
/** 创建品牌错误 */
|
/** 创建品牌错误 */
|
||||||
mutationError: Error | undefined;
|
mutationError: Error | undefined;
|
||||||
|
/** 创建监控任务 */
|
||||||
|
createMonitoringTask: (
|
||||||
|
brandId: string,
|
||||||
|
platforms: string[],
|
||||||
|
frequency: string
|
||||||
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseOnboardingDataOptions {
|
export interface UseOnboardingDataOptions {
|
||||||
|
|
@ -82,6 +91,30 @@ export function useOnboardingData(
|
||||||
[createBrandTrigger]
|
[createBrandTrigger]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createMonitoringTask = useCallback(
|
||||||
|
async (
|
||||||
|
brandId: string,
|
||||||
|
platforms: string[],
|
||||||
|
frequency: string
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
const token = (session as Session)?.accessToken;
|
||||||
|
if (!token) return;
|
||||||
|
for (const platform of platforms) {
|
||||||
|
await monitoringApi.createTask(token, {
|
||||||
|
brand_id: brandId,
|
||||||
|
platform,
|
||||||
|
query_keywords: frequency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onboardingStatus,
|
onboardingStatus,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
|
|
@ -91,5 +124,6 @@ export function useOnboardingData(
|
||||||
createBrand,
|
createBrand,
|
||||||
isCreatingBrand,
|
isCreatingBrand,
|
||||||
mutationError,
|
mutationError,
|
||||||
|
createMonitoringTask,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue