From 37ecd39a60083e51903a6b4deb862e65665bf831 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 2 Jun 2026 07:41:04 +0800 Subject: [PATCH 1/4] feat: monitoring page refactor + competitor analysis page (U1, U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor monitoring page: dual-tab (records + alerts), connect monitoringApi - Replace sidebar '数据监测' nav with '品牌监测' → /dashboard/monitoring - Add '竞品分析' nav item → /dashboard/competitors - Create competitor API module with 8 endpoints - Create competitor analysis page with radar chart, gap scores, recommendations --- ...geo-frontend-visualization-requirements.md | 146 +++++ ...06-feat-geo-frontend-visualization-plan.md | 323 ++++++++++ .../dashboard/competitors/page.tsx | 493 ++++++++++++++++ .../(dashboard)/dashboard/monitoring/page.tsx | 551 +++++++++++------- frontend/app/(dashboard)/layout.tsx | 11 +- frontend/lib/api/competitor.ts | 106 ++++ frontend/lib/api/index.ts | 10 + 7 files changed, 1427 insertions(+), 213 deletions(-) create mode 100644 docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md create mode 100644 docs/plans/2026-06-01-006-feat-geo-frontend-visualization-plan.md create mode 100644 frontend/app/(dashboard)/dashboard/competitors/page.tsx create mode 100644 frontend/lib/api/competitor.ts diff --git a/docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md b/docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md new file mode 100644 index 0000000..ffc74bb --- /dev/null +++ b/docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md @@ -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 客户端 diff --git a/docs/plans/2026-06-01-006-feat-geo-frontend-visualization-plan.md b/docs/plans/2026-06-01-006-feat-geo-frontend-visualization-plan.md new file mode 100644 index 0000000..f13b30a --- /dev/null +++ b/docs/plans/2026-06-01-006-feat-geo-frontend-visualization-plan.md @@ -0,0 +1,323 @@ +--- +date: 2026-06-01 +status: active +origin: docs/brainstorms/2026-06-01-geo-frontend-visualization-requirements.md +--- + +## Summary + +为 GEO 平台已有后端 API 补建前端可视化页面,分三批交付:P0 监测页面改造 + 竞品分析新建,P1 引用统计 + 健康评分 + 检测任务 + Dashboard Agent 区域,P2 导出入口 + 公开评分落地页 + Agent 配置 + 趋势/Schema 页面。可视化优先,复用 recharts + 现有 CompetitorRadarChart 组件。 + +## Problem Frame + +后端 API 完整但前端多处断裂:监测页面展示告警而非监测记录,竞品分析无前端,引用统计/健康评分/检测任务等 API 未对接。用户在 Onboarding 看到健康评分后进入 Dashboard 却找不到对应页面。 + +## Requirements + +**P0 核心可视化闭环:** 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: }` + +**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: }` + +**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: }` + +**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` — 检测任务端点 diff --git a/frontend/app/(dashboard)/dashboard/competitors/page.tsx b/frontend/app/(dashboard)/dashboard/competitors/page.tsx new file mode 100644 index 0000000..7df94bb --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/competitors/page.tsx @@ -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(recommendationsUrl); + + const gapSummaryUrl = brandId ? `/api/v1/competitor/brand/${brandId}/gap-summary` : null; + const { data: gapSummaryData, isLoading: gapSummaryLoading, error: gapSummaryError } = useApi(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(null); + + const [analysisCompetitorId, setAnalysisCompetitorId] = useState(""); + const [analysisType, setAnalysisType] = useState(""); + const [analyzing, setAnalyzing] = useState(false); + const [analysisError, setAnalysisError] = useState(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 ( +
+
+

竞品分析

+

分析竞品表现,发现差距与机会

+
+ +
+ ); + } + + return ( +
+
+
+

竞品分析

+

分析竞品表现,发现差距与机会

+
+ + + + + + + 添加竞品 + + + + 从推荐选择 + 手动输入 + + + {recommendationsLoading ? ( +
+ + 加载推荐中... +
+ ) : recommendationsError ? ( + + ) : recommendations.length === 0 ? ( + + ) : ( +
+ {recommendations.map((rec) => { + const alreadyAdded = competitors.some((c) => c.name === rec.name); + return ( +
+
+

{rec.name}

+

{rec.reason}

+
+ +
+ ); + })} +
+ )} +
+ +
+ +
+ setManualName(e.target.value)} + placeholder="输入竞品名称" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddManual(); + } + }} + /> + +
+
+
+
+
+
+
+ + + + + + 竞品列表 + + {competitors.length}/5 + + + + + {competitorsLoading ? ( + + ) : competitorsError ? ( + + ) : competitors.length === 0 ? ( + } + /> + ) : ( +
+ {competitors.map((comp) => ( +
+
+

{comp.name}

+

+ 添加于 {new Date(comp.created_at).toLocaleDateString("zh-CN")} +

+
+ +
+ ))} +
+ )} +
+
+ + + + + + 竞品分析 + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + {analysisError && ( +
+ + {analysisError} +
+ )} + + {insightsLoading ? ( + + ) : insights.length > 0 ? ( +
+ {insights.map((insight) => ( +
+
+ {insight.insight_type} + {insight.competitor_name && ( + {insight.competitor_name} + )} + + {new Date(insight.created_at).toLocaleString("zh-CN")} + +
+ {insight.recommendations && insight.recommendations.length > 0 && ( +
+ {insight.recommendations.map((rec, i) => ( +
+ + {rec} +
+ ))} +
+ )} +
+ ))} +
+ ) : null} +
+
+ +
+ + + + + 竞品雷达图 + + + + {gapSummaryLoading ? ( + + ) : gapSummaryError ? ( + + ) : radarData.length > 0 ? ( + + ) : ( + } + /> + )} + + + + + + + + 差距评分 + + + + {gapSummaryLoading ? ( + + ) : gapSummaryError ? ( + + ) : gapSummaries.length === 0 ? ( + } + /> + ) : ( +
+ {gapSummaries.map((gs) => ( +
+
+ {gs.competitor_name} + 50 ? "destructive" : gs.gap_score > 20 ? "secondary" : "default"} + > + 差距 {gs.gap_score.toFixed(1)} + +
+
+
+
+
+ {Object.entries(gs.dimensions).map(([key, val]) => ( +
+ {key}: {typeof val === "number" ? val.toFixed(1) : String(val)} +
+ ))} +
+
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/monitoring/page.tsx b/frontend/app/(dashboard)/dashboard/monitoring/page.tsx index d3cd222..c4364e8 100644 --- a/frontend/app/(dashboard)/dashboard/monitoring/page.tsx +++ b/frontend/app/(dashboard)/dashboard/monitoring/page.tsx @@ -2,12 +2,14 @@ import * as React from "react"; import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; import { useApi } from "@/lib/hooks/use-api"; +import { monitoringApi, MonitoringRecord, MonitoringChangeReport } from "@/lib/api/monitoring"; import { alertsApi } from "@/lib/api/alerts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Select, SelectContent, @@ -20,16 +22,20 @@ import { Bell, BellRing, AlertTriangle, - Plus, CheckCheck, Settings, Clock, Filter, + Activity, + Play, + Pause, + RefreshCw, + ChevronDown, + ChevronUp, + Eye, } from "lucide-react"; import { cn } from "@/lib/utils"; -/* ─── Types ───────────────────────────────────────────────────────────────────*/ - interface Alert { id: string; title: string; @@ -50,7 +56,270 @@ interface UnreadCountResponse { 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 = { + positive: { variant: "default", label: "正向" }, + negative: { variant: "destructive", label: "负向" }, + neutral: { variant: "secondary", label: "中性" }, + }; + const c = config[changeType] || { variant: "outline" as const, label: changeType }; + return {c.label}; +} + +function RecordCard({ + record, + token, + onRefresh, +}: { + record: MonitoringRecord; + token: string; + onRefresh: () => void; +}) { + const [expanded, setExpanded] = React.useState(false); + const [report, setReport] = React.useState(null); + const [reportLoading, setReportLoading] = React.useState(false); + const [reportError, setReportError] = React.useState(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 ( + + +
+
+ +
+ +
+
+

+ {record.platform || "全平台"} +

+ + {record.status === "paused" && ( + 已暂停 + )} +
+
+ + + {record.last_checked_at ? formatTimeAgo(record.last_checked_at) : "未检测"} + + 关键词: {record.query_keywords} +
+
+ +
+ + + {expanded ? ( + + ) : ( + + )} +
+
+ + {expanded && ( +
+ {reportLoading && } + {reportError && } + {report && !reportLoading && !reportError && ( + <> +
+
+

基线指标

+
+
+ 引用次数 + {String(report.baseline.citation_count ?? "—")} +
+
+ 情感分数 + {report.baseline.sentiment != null ? String(report.baseline.sentiment) : "—"} +
+
+ 排名 + {report.baseline.rank != null ? String(report.baseline.rank) : "—"} +
+
+
+
+

当前指标

+
+
+ 引用次数 + {String(report.current.citation_count ?? "—")} +
+
+ 情感分数 + {report.current.sentiment != null ? String(report.current.sentiment) : "—"} +
+
+ 排名 + {report.current.rank != null ? String(report.current.rank) : "—"} +
+
+
+
+ {report.recommendations && report.recommendations.length > 0 && ( +
+

优化建议

+
    + {report.recommendations.map((rec, i) => ( +
  • + + {rec} +
  • + ))} +
+
+ )} + + )} +
+ )} +
+
+ ); +} + +function MonitoringRecordsTab() { + const { data: session } = useSession(); + const token = session?.accessToken || ""; + + const { data: brandsData } = useApi("/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 ; + } + + if (error) { + return ; + } + + if (records.length === 0) { + return ( + } + message="暂无监测记录" + description="创建监测任务后将在此显示监测结果" + /> + ); + } + + return ( +
+ {records.map((record) => ( + + ))} +
+ ); +} interface StatCardProps { title: string; @@ -62,28 +331,11 @@ interface StatCardProps { function StatCard({ title, value, icon, trend, color }: StatCardProps) { const colorMap = { - emerald: { - bg: "bg-emerald-50", - icon: "text-emerald-600", - border: "border-emerald-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", - }, + emerald: { bg: "bg-emerald-50", icon: "text-emerald-600", border: "border-emerald-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]; return ( @@ -93,9 +345,7 @@ function StatCard({ title, value, icon, trend, color }: StatCardProps) {

{title}

{value}

- {trend && ( -

{trend}

- )} + {trend &&

{trend}

}
{icon}
@@ -106,8 +356,6 @@ function StatCard({ title, value, icon, trend, color }: StatCardProps) { ); } -/* ─── Alert Row Component ────────────────────────────────────────────────────*/ - interface AlertRowProps { alert: Alert; onMarkRead: (id: string) => void; @@ -116,21 +364,9 @@ interface AlertRowProps { function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) { const severityConfig = { - critical: { - badge: "destructive", - label: "严重", - icon: , - }, - warning: { - badge: "default", - label: "警告", - icon: , - }, - info: { - badge: "secondary", - label: "信息", - icon: , - }, + critical: { badge: "destructive", label: "严重", icon: }, + warning: { badge: "default", label: "警告", icon: }, + info: { badge: "secondary", label: "信息", icon: }, }; const typeMap: Record = { @@ -174,24 +410,16 @@ function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) { )}
- - {timeAgo} - + {timeAgo}
{alert.description && ( -

- {alert.description} -

+

{alert.description}

)}
- - {config.label} - - - {typeMap[alert.type] || alert.type} - + {config.label} + {typeMap[alert.type] || alert.type}
@@ -213,72 +441,7 @@ function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) { ); } -/* ─── Skeleton Components ────────────────────────────────────────────────────*/ - -function StatCardSkeleton() { - return ( - - -
-
-
-
-
-
-
- - - ); -} - -function AlertListSkeleton() { - return ( - - - 告警列表 - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
- - - ); -} - -/* ─── 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(); +function AlertsTab() { const [filterType, setFilterType] = React.useState("all"); const [filterSeverity, setFilterSeverity] = React.useState("all"); const [filterRead, setFilterRead] = React.useState("all"); @@ -339,96 +502,25 @@ export default function MonitoringPage() { return date.toDateString() === today.toDateString(); }).length; const processedCount = alerts.filter((a) => a.is_read).length; - const uniqueTypes = Array.from(new Set(alerts.map((a) => a.type))); if (alertsLoading || unreadLoading) { - return ( -
-
-

监测优化

-

- 实时监控品牌AI可见性,及时响应告警通知 -

-
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- - -
- ); + return ; } if (alertsError) { - return ( -
-
-

监测优化

-

- 实时监控品牌AI可见性,及时响应告警通知 -

-
- -
- ); + return ; } return (
- {/* Page Header */} -
-
-

监测优化

-

- 实时监控品牌AI可见性,及时响应告警通知 -

-
- -
- - {/* Stat Cards */}
- } - trend="需要处理" - color="blue" - /> - } - trend="高优先级" - color="red" - /> - } - trend="今天" - color="amber" - /> - } - trend="已读" - color="emerald" - /> + } trend="需要处理" color="blue" /> + } trend="高优先级" color="red" /> + } trend="今天" color="amber" /> + } trend="已读" color="emerald" />
- {/* Alert List */}
@@ -436,25 +528,17 @@ export default function MonitoringPage() { 告警列表 {alerts.length > 0 && ( - - {alerts.length} - + {alerts.length} )} {unreadCount > 0 && ( - )}
- {/* Filters */}
{ + setFormData((prev) => ({ ...prev, query_id: value })); + if (formErrors.query_id) { + setFormErrors((prev) => ({ ...prev, query_id: "" })); + } + }} + > + + + + + {queries.map((q) => ( + + {q.keyword} — {q.target_brand} + + ))} + + + {formErrors.query_id && ( +

{formErrors.query_id}

+ )} +
+
+ +
+ {PLATFORMS.map((p) => ( + + ))} +
+ {formErrors.platforms && ( +

{formErrors.platforms}

+ )} +
+
+ + +
+ {mutationError && ( +

{mutationError}

+ )} +
+ + + + + + +
+ + {successMsg && ( +
+ + {successMsg} +
+ )} + + {mutationError && !dialogOpen && ( +
+ {mutationError} +
+ )} + + {tasks.length === 0 ? ( + } + message="暂无检测任务" + description="点击右上角按钮创建您的第一个检测任务" + /> + ) : ( + + + 检测任务列表 + + +
+ + + + 查询词 + 平台 + 频率 + 状态 + 上次运行 + 下次运行 + 操作 + + + + {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 ( + + + {matchedQuery?.keyword ?? task.query_id} + + +
+ {task.platforms.map((p) => ( + + {PLATFORM_MAP[p] || p} + + ))} +
+
+ + {FREQUENCY_MAP[task.frequency] || task.frequency} + + + + {statusCfg.label} + + + + {task.last_run_at + ? new Date(task.last_run_at).toLocaleString("zh-CN") + : "从未"} + + + {task.next_run_at + ? new Date(task.next_run_at).toLocaleString("zh-CN") + : "—"} + + +
+ + +
+
+
+ ); + })} +
+
+
+
+
+ )} + + + + + 确认删除 + + 删除后无法恢复,确定要删除这个检测任务吗? + + + + + + + + +
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/health-score/page.tsx b/frontend/app/(dashboard)/dashboard/health-score/page.tsx new file mode 100644 index 0000000..ce57303 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/health-score/page.tsx @@ -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 = { + 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 = { + "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 ( +
+ + + + + + + + +
+ {round(percentage, 1)} + /100 +
+
+ ); +} + +function DimensionCards({ dimensions }: { dimensions: BrandScore["dimensions"] }) { + return ( +
+ {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 ( + + +
+ {label} + + {round(percentage, 1)}% + +
+
+
+
+

+ {dim.description} +

+ + + ); + })} +
+ ); +} + +function CompetitorTab({ token, brandId }: { token: string; brandId: string }) { + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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 ; + if (error) return ; + if (!data) return ; + + const radarData = data.dimensions.map((dim) => { + const item: Record = { + 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>( + (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 ( + + + 竞品对比雷达图 + + + + + + ); +} + +function HistoryTab({ token, brandId }: { token: string; brandId: string }) { + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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 ; + if (error) return ; + if (!data || data.scores.length === 0) + return ; + + const chartData = data.scores.map((entry) => ({ + date: entry.date, + score: entry.overall_score, + })); + + return ( + + + 历史趋势 + + + + + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }} + /> + + { + 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}`, ""]} + /> + + + + + + ); +} + +export default function HealthScorePage() { + const { data: session } = useSession(); + const token = session?.accessToken || ""; + + const { data: brandsData } = useApi("/api/v1/brands/?limit=1"); + const brandId = brandsData?.items?.[0]?.id ?? null; + + const [scoreData, setScoreData] = React.useState(null); + const [scoreLoading, setScoreLoading] = React.useState(true); + const [scoreError, setScoreError] = React.useState(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 ( +
+
+

健康评分

+

品牌在AI搜索中的综合健康表现

+
+ + {!token || !brandId ? ( + + ) : scoreLoading ? ( + + ) : scoreError ? ( + + ) : !scoreData ? ( + } + message="暂无健康评分数据" + description="请先完成品牌评分检测" + /> + ) : ( + <> + + + +

+ 综合健康评分 +

+
+
+ + + + + + 竞品对比 + 历史趋势 + + + + + + + + + + )} +
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx index b4e3c03..6ae4d7c 100644 --- a/frontend/app/(dashboard)/dashboard/page.tsx +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MetricCard, StageProgress } from "@/components/business"; @@ -16,8 +17,14 @@ import { ArrowRight, Zap, Lock, + Loader2, + CheckCircle2, + XCircle, + Clock, } from "lucide-react"; 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 { LoadingState, @@ -87,6 +94,138 @@ function getRecommendation(stage: GeoProject["current_stage"]) { return map[stage]; } +/* ─── Agent Activity Component ───────────────────────────────────────────────*/ + +const TASK_STATUS_CONFIG: Record< + string, + { label: string; icon: React.ReactNode; color: string } +> = { + pending: { + label: "等待中", + icon: , + color: "bg-gray-100 text-gray-600", + }, + running: { + label: "运行中", + icon: , + color: "bg-blue-100 text-blue-600", + }, + completed: { + label: "已完成", + icon: , + color: "bg-emerald-100 text-emerald-600", + }, + failed: { + label: "失败", + icon: , + color: "bg-red-100 text-red-600", + }, + cancelled: { + label: "已取消", + icon: , + 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([]); + 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 ( +
+
+

Agent活动

+ + 查看全部 + +
+ {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( +
+ +

+ 暂无执行记录 +

+
+ ) : ( +
+ {tasks.map((task) => { + const cfg = TASK_STATUS_CONFIG[task.status] ?? { + label: task.status, + icon: , + color: "bg-gray-100 text-gray-600", + }; + return ( +
+ + {task.task_type} + + + {cfg.icon} + {cfg.label} + + + {formatDuration(task.started_at, task.completed_at)} + + + {task.created_at ? formatRelativeTime(task.created_at) : ""} + +
+ ); + })} +
+ )} +
+ ); +} + /* ─── Component ───────────────────────────────────────────────────────────────*/ export default function DashboardPage() { @@ -391,26 +530,7 @@ export default function DashboardPage() {
{/* Agent Activity */} -
-
-

Agent活动

- - 查看全部 - -
-
- -

- 功能开发中 -

-

- Agent状态监控即将上线 -

-
-
+
); diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index 77e14c9..fbe03a3 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -14,6 +14,8 @@ import { BarChart3, Swords, Share2, + Heart, + ScanSearch, Settings, } from "lucide-react"; @@ -54,12 +56,24 @@ const NAV_GROUPS: NavGroup[] = [ href: "/dashboard/competitors", icon: , }, + { + id: "health-score", + label: "健康评分", + href: "/dashboard/health-score", + icon: , + }, { id: "distribution", label: "内容分发", href: "/dashboard/distribution", icon: , }, + { + id: "detection", + label: "检测任务", + href: "/dashboard/detection", + icon: , + }, ], }, { diff --git a/frontend/app/(dashboard)/onboarding/page.tsx b/frontend/app/(dashboard)/onboarding/page.tsx index c66631e..3ea265d 100644 --- a/frontend/app/(dashboard)/onboarding/page.tsx +++ b/frontend/app/(dashboard)/onboarding/page.tsx @@ -37,6 +37,7 @@ export default function OnboardingPage() { createBrand: hookCreateBrand, isCreatingBrand, mutationError, + createMonitoringTask, } = useOnboardingData(); const error = mutationError?.message ?? null; @@ -117,6 +118,7 @@ export default function OnboardingPage() { ) => { const brandId = await createBrand(); if (brandId) { + createMonitoringTask(brandId, platforms, frequency); setState((prev) => ({ ...prev, platforms, @@ -146,6 +148,7 @@ export default function OnboardingPage() { ]; const brandId = await createBrand(); if (brandId) { + createMonitoringTask(brandId, defaultPlatforms, state.frequency); setState((prev) => ({ ...prev, platforms: defaultPlatforms, diff --git a/frontend/lib/api/citations.ts b/frontend/lib/api/citations.ts index e7b7c3f..d5bb9ba 100644 --- a/frontend/lib/api/citations.ts +++ b/frontend/lib/api/citations.ts @@ -1,5 +1,13 @@ import { fetchWithAuth } from "./client"; +function buildQuery(params: Record): 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 { id: string; query_id: string; @@ -18,11 +26,23 @@ export interface CitationListResponse { total: number; } +export interface PlatformDistribution { + platform: string; + count: number; +} + +export interface TrendPoint { + date: string; + count: number; +} + export interface CitationStats { total_queries: number; total_citations: number; citation_rate: number; avg_position: number | null; + platform_distribution: PlatformDistribution[]; + trend: TrendPoint[]; } export const citationsApi = { @@ -32,6 +52,10 @@ export const citationsApi = { {}, token ) as Promise, - getStats: (token: string) => - fetchWithAuth("/api/v1/citations/stats/", {}, token) as Promise, + getStats: (token: string, brandId?: string, queryId?: string) => + fetchWithAuth( + `/api/v1/citations/stats${buildQuery({ brand_id: brandId, query_id: queryId })}`, + {}, + token + ) as Promise, }; diff --git a/frontend/lib/api/detection.ts b/frontend/lib/api/detection.ts index dc88941..b2e022b 100644 --- a/frontend/lib/api/detection.ts +++ b/frontend/lib/api/detection.ts @@ -8,19 +8,47 @@ function buildQuery(params: Record - fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token), + listTasks: (token: string, params?: { skip?: number; limit?: number; status?: string }) => + fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token) as Promise, - createTask: (data: Record, token?: string) => - fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token), + createTask: (token: string, data: DetectionTaskCreate) => + fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token) as Promise, - updateTask: (taskId: string, data: Record, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token), + updateTask: (token: string, taskId: string, data: DetectionTaskUpdate) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token) as Promise, - deleteTask: (taskId: string, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token), + deleteTask: (token: string, taskId: string) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token) as Promise, - triggerTask: (taskId: string, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token), + triggerTask: (token: string, taskId: string) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token) as Promise, }; diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts index e0d9ec8..d67ef27 100644 --- a/frontend/lib/api/index.ts +++ b/frontend/lib/api/index.ts @@ -6,7 +6,7 @@ export { authApi } from "./auth"; export { queriesApi } from "./queries"; export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries"; 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 { subscriptionsApi } from "./subscriptions"; export type { SubscriptionInfo } from "./subscriptions"; @@ -37,6 +37,7 @@ export type { UpdateMemberRolePayload, } from "./organization"; export { detectionApi } from "./detection"; +export type { DetectionTask, DetectionTaskList, DetectionTaskCreate, DetectionTaskUpdate } from "./detection"; export { strategyApi } from "./strategy"; export { monitoringApi } from "./monitoring"; export type { @@ -80,6 +81,8 @@ export type { } from "./competitor"; export { usageApi } 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"; @@ -171,6 +174,7 @@ import { schemaAdvisorApi } from "./schema-advisor"; import { trendsApi } from "./trends"; import { competitorApi } from "./competitor"; import { usageApi } from "./usage"; +import { scoringApi } from "./scoring"; /** * 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。 @@ -205,4 +209,5 @@ export const api = { trends: trendsApi, competitor: competitorApi, usage: usageApi, + scoring: scoringApi, }; diff --git a/frontend/lib/api/scoring.ts b/frontend/lib/api/scoring.ts new file mode 100644 index 0000000..f47e0ca --- /dev/null +++ b/frontend/lib/api/scoring.ts @@ -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; +} + +export interface BrandCompare { + brand_name: string; + competitors: BrandCompareCompetitor[]; + dimensions: string[]; +} + +export interface ScoreHistoryEntry { + date: string; + overall_score: number; + dimension_scores: Record; +} + +export interface ScoreHistory { + scores: ScoreHistoryEntry[]; +} + +export const scoringApi = { + getScore: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/score/`, {}, token) as Promise, + + getHistory: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/score/history/`, {}, token) as Promise, + + getCompare: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/compare/`, {}, token) as Promise, +}; diff --git a/frontend/lib/hooks/use-onboarding-data.ts b/frontend/lib/hooks/use-onboarding-data.ts index 7975c48..3cbfdbb 100644 --- a/frontend/lib/hooks/use-onboarding-data.ts +++ b/frontend/lib/hooks/use-onboarding-data.ts @@ -9,6 +9,9 @@ import { useCallback } from "react"; import { useApi, useApiMutation } from "./use-api"; 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 { completed: boolean; @@ -42,6 +45,12 @@ export interface UseOnboardingDataReturn { isCreatingBrand: boolean; /** 创建品牌错误 */ mutationError: Error | undefined; + /** 创建监控任务 */ + createMonitoringTask: ( + brandId: string, + platforms: string[], + frequency: string + ) => Promise; } export interface UseOnboardingDataOptions { @@ -82,6 +91,30 @@ export function useOnboardingData( [createBrandTrigger] ); + const createMonitoringTask = useCallback( + async ( + brandId: string, + platforms: string[], + frequency: string + ): Promise => { + 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 { onboardingStatus, isCompleted, @@ -91,5 +124,6 @@ export function useOnboardingData( createBrand, isCreatingBrand, mutationError, + createMonitoringTask, }; } From f182e166dcf719ed783b0a04dbd1ad4bcaa3122b Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 2 Jun 2026 08:11:43 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20U7=20=E2=80=94=20citation=20export,?= =?UTF-8?q?=20agent=20config=20panel,=20trends=20insight=20page,=20schema?= =?UTF-8?q?=20suggestion=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(dashboard)/dashboard/agents/page.tsx | 113 ++++- .../(dashboard)/dashboard/citations/page.tsx | 64 ++- .../app/(dashboard)/dashboard/schema/page.tsx | 400 +++++++++++++++++ .../app/(dashboard)/dashboard/trends/page.tsx | 402 ++++++++++++++++++ frontend/app/(dashboard)/layout.tsx | 14 + 5 files changed, 987 insertions(+), 6 deletions(-) create mode 100644 frontend/app/(dashboard)/dashboard/schema/page.tsx create mode 100644 frontend/app/(dashboard)/dashboard/trends/page.tsx diff --git a/frontend/app/(dashboard)/dashboard/agents/page.tsx b/frontend/app/(dashboard)/dashboard/agents/page.tsx index 5091bbe..5565f37 100644 --- a/frontend/app/(dashboard)/dashboard/agents/page.tsx +++ b/frontend/app/(dashboard)/dashboard/agents/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "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 { Table, @@ -21,8 +21,11 @@ import { } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; 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 { 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 FilterStatus = "all" | TaskStatus; @@ -76,6 +79,10 @@ export default function AgentsPage() { const [selectedTask, setSelectedTask] = useState(null); const [taskLogs, setTaskLogs] = useState([]); const [loadingLogs, setLoadingLogs] = useState(false); + const [agents, setAgents] = useState([]); + const [configAgent, setConfigAgent] = useState(null); + const [configJson, setConfigJson] = useState(""); + const [savingConfig, setSavingConfig] = useState(false); // 获取执行记录 useEffect(() => { @@ -98,6 +105,11 @@ export default function AgentsPage() { fetchTasks(); }, [token, filterStatus]); + useEffect(() => { + if (!token) return; + agentsApi.list(token).then(setAgents).catch(() => {}); + }, [token]); + // 获取任务日志 const fetchTaskLogs = async (taskId: string) => { if (!token) return; @@ -147,6 +159,27 @@ export default function AgentsPage() { 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 }[] = [ { status: "all", label: "全部" }, { status: "running", label: "运行中" }, @@ -187,6 +220,46 @@ export default function AgentsPage() { />
+ + + + + Agent 配置 + + + + {agents.length === 0 ? ( +

暂无 Agent

+ ) : ( +
+ {agents.map((agent) => ( +
+
+

{agent.name}

+

{agent.type}

+
+ + {agent.status} + + +
+ ))} +
+ )} +
+
+ {/* 状态筛选 */}
{filterButtons.map(({ status, label }) => ( @@ -362,6 +435,42 @@ export default function AgentsPage() { )} + + setConfigAgent(null)}> + + + + + 配置 — {configAgent?.name} + + + {configAgent && ( +
+
+ +