dot преди 1 седмица
родител
ревизия
3f2a1dba38
променени са 82 файла, в които са добавени 14488 реда и са изтрити 555 реда
  1. 37 0
      .gitignore
  2. 7 2
      deploy/Dockerfile.api
  3. 2 2
      deploy/Dockerfile.web
  4. 27 3
      deploy/docker-compose.yml
  5. 26 0
      deploy/nginx.conf
  6. 126 0
      docs/plans/2026-04-14-operations-features.md
  7. 228 0
      docs/superpowers/specs/2026-04-14-mature-product-features-design.md
  8. 3 2
      internal/crawler/filter.go
  9. 25 1
      internal/crawler/static.go
  10. 110 7
      internal/extractor/regex.go
  11. 335 0
      internal/handler/analytics.go
  12. 71 0
      internal/handler/audit.go
  13. 358 0
      internal/handler/auth.go
  14. 101 0
      internal/handler/backup.go
  15. 114 0
      internal/handler/channel.go
  16. 212 0
      internal/handler/dashboard.go
  17. 85 0
      internal/handler/keyword.go
  18. 802 7
      internal/handler/merchant.go
  19. 142 0
      internal/handler/notification.go
  20. 132 0
      internal/handler/permission.go
  21. 385 0
      internal/handler/proxy.go
  22. 15 12
      internal/handler/response.go
  23. 168 0
      internal/handler/schedule.go
  24. 89 0
      internal/handler/setting.go
  25. 174 14
      internal/handler/task.go
  26. 183 0
      internal/handler/user.go
  27. 21 0
      internal/model/audit_log.go
  28. 15 0
      internal/model/group_member.go
  29. 33 0
      internal/model/merchant_archived.go
  30. 6 3
      internal/model/merchant_clean.go
  31. 12 0
      internal/model/merchant_note.go
  32. 2 2
      internal/model/merchant_raw.go
  33. 21 0
      internal/model/notification.go
  34. 71 0
      internal/model/permission.go
  35. 48 0
      internal/model/proxy.go
  36. 15 0
      internal/model/schedule.go
  37. 91 0
      internal/model/setting.go
  38. 28 0
      internal/model/task_detail.go
  39. 3 0
      internal/model/task_log.go
  40. 184 0
      internal/notification/notifier.go
  41. 70 12
      internal/plugin/interface.go
  42. 103 32
      internal/plugins/githubcollector/collector.go
  43. 76 9
      internal/plugins/tgcollector/collector.go
  44. 319 114
      internal/plugins/webcollector/collector.go
  45. 11 2
      internal/processor/blacklist.go
  46. 91 26
      internal/processor/pipeline.go
  47. 63 29
      internal/processor/tagger.go
  48. 83 6
      internal/processor/tmechecker.go
  49. 260 0
      internal/proxy/pool.go
  50. 74 11
      internal/search/serper.go
  51. 144 0
      internal/store/group_member_repo.go
  52. 33 24
      internal/store/merchant_repo.go
  53. 59 0
      internal/store/setting_repo.go
  54. 176 0
      internal/task/detail_logger.go
  55. 186 9
      internal/task/manager.go
  56. 225 0
      internal/task/scheduler.go
  57. 50 1
      internal/telegram/account_manager.go
  58. BIN
      server.exe
  59. 4 1
      web/index.html
  60. 3504 0
      web/package-lock.json
  61. 5 4
      web/package.json
  62. 67 8
      web/src/App.tsx
  63. 27 0
      web/src/api/client.ts
  64. 43 0
      web/src/components/ErrorBoundary.tsx
  65. 213 61
      web/src/components/Layout.tsx
  66. 251 25
      web/src/components/TaskControl.tsx
  67. 252 0
      web/src/pages/Analytics.tsx
  68. 121 0
      web/src/pages/AuditLogs.tsx
  69. 215 0
      web/src/pages/Channels.tsx
  70. 300 0
      web/src/pages/Dashboard.tsx
  71. 86 0
      web/src/pages/ForceChangePassword.tsx
  72. 38 5
      web/src/pages/Keywords.tsx
  73. 78 0
      web/src/pages/Login.tsx
  74. 419 0
      web/src/pages/MerchantDetail.tsx
  75. 500 71
      web/src/pages/MerchantsClean.tsx
  76. 325 0
      web/src/pages/MerchantsRaw.tsx
  77. 198 0
      web/src/pages/Notifications.tsx
  78. 277 0
      web/src/pages/Proxies.tsx
  79. 181 0
      web/src/pages/Schedules.tsx
  80. 439 49
      web/src/pages/Tasks.tsx
  81. 332 0
      web/src/pages/Users.tsx
  82. 83 1
      web/src/store/index.ts

+ 37 - 0
.gitignore

@@ -0,0 +1,37 @@
+# Binaries
+*.exe
+*.exe~
+bin/
+cmd/*.exe
+
+# Go
+vendor/
+
+# Web
+node_modules/
+web/dist/
+
+# Telegram sensitive data
+sessions/
+tgs/
+
+# Environment & secrets
+deploy/.env
+.env
+*.env
+
+# Claude Code local config
+.claude/
+
+# Backup
+backup/
+
+# IDE
+.idea/
+.vscode/
+*.iml
+
+# OS
+.DS_Store
+Thumbs.db
+desktop.ini

+ 7 - 2
deploy/Dockerfile.api

@@ -1,14 +1,19 @@
 FROM golang:1.26-alpine AS builder
+RUN apk add --no-cache ca-certificates tzdata
 WORKDIR /app
 ENV GOTOOLCHAIN=auto
+ENV GOPROXY=https://goproxy.cn,https://goproxy.io,direct
 COPY go.mod go.sum ./
 RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
+COPY internal/ internal/
+COPY cmd/ cmd/
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
 
 FROM alpine:3.19
+RUN apk add --no-cache ca-certificates tzdata wget
 WORKDIR /app
 COPY --from=builder /app/server .
 COPY configs/ configs/
+ENV TZ=Asia/Shanghai
 EXPOSE 8080
 CMD ["./server"]

+ 2 - 2
deploy/Dockerfile.web

@@ -1,7 +1,7 @@
 FROM node:20-alpine AS builder
 WORKDIR /app
-COPY web/package.json ./
-RUN npm install --legacy-peer-deps
+COPY web/package.json web/package-lock.json ./
+RUN npm ci --legacy-peer-deps
 COPY web/ .
 RUN npm run build
 

+ 27 - 3
deploy/docker-compose.yml

@@ -1,5 +1,3 @@
-version: "3.8"
-
 services:
   api:
     build:
@@ -12,7 +10,22 @@ services:
       - ../sessions:/app/sessions
     environment:
       - GIN_MODE=release
+      - TG_SECRET_KEY=${TG_SECRET_KEY}
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/ping"]
+      interval: 15s
+      timeout: 5s
+      retries: 3
+      start_period: 10s
+    deploy:
+      resources:
+        limits:
+          cpus: '2'
+          memory: 1024M
+        reservations:
+          cpus: '0.5'
+          memory: 256M
     networks:
       - spider_internal
       - external_db
@@ -24,8 +37,19 @@ services:
     ports:
       - "8300:80"
     depends_on:
-      - api
+      api:
+        condition: service_healthy
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    deploy:
+      resources:
+        limits:
+          cpus: '0.5'
+          memory: 128M
     networks:
       - spider_internal
 

+ 26 - 0
deploy/nginx.conf

@@ -2,11 +2,30 @@ server {
     listen 80;
     server_name _;
 
+    # Gzip compression for API responses and static assets
+    gzip on;
+    gzip_types application/json text/plain text/css application/javascript;
+    gzip_min_length 1000;
+    gzip_comp_level 6;
+
     # 前端静态文件
     location / {
         root /usr/share/nginx/html;
         index index.html;
         try_files $uri $uri/ /index.html;
+
+        # Cache static assets
+        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
+            expires 7d;
+            add_header Cache-Control "public, max-age=604800, immutable";
+        }
+    }
+
+    # Health check (no proxy)
+    location /ping {
+        proxy_pass http://api:8080;
+        proxy_connect_timeout 5s;
+        proxy_read_timeout 5s;
     }
 
     # API 代理(含 WebSocket)
@@ -20,5 +39,12 @@ server {
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_read_timeout 300s;
         proxy_send_timeout 300s;
+
+        # Larger buffer for big API responses
+        proxy_buffer_size 16k;
+        proxy_buffers 8 32k;
+
+        # No caching for API
+        add_header Cache-Control "no-cache, no-store, must-revalidate";
     }
 }

+ 126 - 0
docs/plans/2026-04-14-operations-features.md

@@ -0,0 +1,126 @@
+# Operations Features Implementation Plan
+
+> **For agentic workers:** Use superpowers:subagent-driven-development to implement tasks in parallel where possible.
+
+**Goal:** Transform the spider system from a development tool into a production-ready operations platform with dashboard, scheduled tasks, merchant editing, status workflow, and follow-up notes.
+
+**Architecture:** 5 independent features, each adds a model (if needed), handler, store methods, and frontend page. All share existing auth middleware and router patterns.
+
+**Tech Stack:** Go/Gin backend, React/Ant Design frontend, MySQL via GORM, Redis for scheduling.
+
+---
+
+## Feature 1: Dashboard (数据看板)
+
+### Backend
+- **Create:** `internal/handler/dashboard.go` — single endpoint `GET /dashboard` returning all stats
+- **Modify:** `internal/handler/router.go` — add dashboard route
+
+### Frontend
+- **Create:** `web/src/pages/Dashboard.tsx` — stats cards, level pie chart, trend chart, recent tasks
+- **Modify:** `web/src/App.tsx` — add route
+- **Modify:** `web/src/components/Layout.tsx` — add menu item, make it the default landing page
+- **Modify:** `web/src/api/index.ts` — add getDashboard
+
+### API: `GET /api/v1/dashboard`
+Response:
+```json
+{
+  "raw_total": 1035,
+  "clean_total": 535,
+  "valid_total": 423,
+  "by_level": {"Hot": 3, "Warm": 28, "Cold": 392},
+  "by_source": {"web": 1035, "tg_channel": 0, "github": 0},
+  "by_industry": {"机场": 12, "VPN": 3},
+  "today_added": 50,
+  "week_added": 200,
+  "recent_tasks": [...last 5 tasks...],
+  "daily_trend": [{"date": "2026-04-07", "count": 30}, ...]
+}
+```
+
+---
+
+## Feature 2: Scheduled Tasks (定时采集)
+
+### Backend
+- **Create:** `internal/model/schedule.go` — ScheduleJob model
+- **Create:** `internal/task/scheduler.go` — cron scheduler using goroutine + ticker
+- **Create:** `internal/handler/schedule.go` — CRUD endpoints
+- **Modify:** `internal/handler/router.go` — add schedule routes
+- **Modify:** `cmd/server/main.go` — start scheduler
+
+### Model: `schedule_jobs`
+```go
+type ScheduleJob struct {
+    ID         uint   `gorm:"primaryKey"`
+    Name       string `gorm:"size:100;not null"`
+    PluginName string `gorm:"size:100;not null"` // web_collector / tg_collector / github_collector / clean
+    CronExpr   string `gorm:"size:50;not null"`  // "0 2 * * *" = every day 2am
+    Enabled    bool   `gorm:"default:true"`
+    LastRunAt  *time.Time
+    NextRunAt  *time.Time
+    CreatedAt  time.Time
+}
+```
+
+### Frontend
+- **Create:** `web/src/pages/Schedules.tsx` — list schedules, add/edit/delete/toggle
+- **Modify:** `web/src/App.tsx`, `Layout.tsx`, `api/index.ts`
+
+---
+
+## Feature 3: Merchant Editing (商户编辑)
+
+### Backend
+- **Create:** `internal/handler/merchant_edit.go` — PUT /merchants/clean/:id for editing
+- **Modify:** `internal/handler/router.go` — add edit route
+
+### Frontend
+- **Modify:** `web/src/pages/MerchantsClean.tsx` — add edit button in action column, edit modal with form
+
+### Editable fields: merchant_name, industry_tag, website, email, phone, remark (new field)
+
+### Model change
+- **Modify:** `internal/model/merchant_clean.go` — add `Remark` field
+- **Modify:** `cmd/server/main.go` — AutoMigrate picks it up
+
+---
+
+## Feature 4: Status Workflow (状态流转)
+
+### Backend
+- **Modify:** `internal/model/merchant_clean.go` — add `FollowStatus` field
+- **Create:** `internal/handler/merchant_status.go` — PUT /merchants/clean/:id/status
+- **Modify:** `internal/handler/router.go` — add route
+
+### FollowStatus values: `pending` → `contacted` → `cooperating` → `rejected`
+Display labels: 待跟进 → 已联系 → 已合作 → 已拒绝
+
+### Frontend
+- **Modify:** `web/src/pages/MerchantsClean.tsx` — add follow_status column with dropdown, filter by follow_status
+
+---
+
+## Feature 5: Follow-up Notes (跟进记录)
+
+### Backend
+- **Create:** `internal/model/merchant_note.go` — MerchantNote model
+- **Create:** `internal/handler/merchant_note.go` — GET/POST notes per merchant
+- **Modify:** `internal/handler/router.go` — add routes
+- **Modify:** `cmd/server/main.go` — AutoMigrate
+
+### Model: `merchant_notes`
+```go
+type MerchantNote struct {
+    ID           uint   `gorm:"primaryKey"`
+    MerchantID   uint   `gorm:"index;not null"`
+    TgUsername   string  `gorm:"size:255;index"`
+    Content      string `gorm:"type:text;not null"`
+    CreatedBy    string `gorm:"size:50"`   // username of the author
+    CreatedAt    time.Time
+}
+```
+
+### Frontend
+- **Modify:** `web/src/pages/MerchantsClean.tsx` — add notes section in detail modal

+ 228 - 0
docs/superpowers/specs/2026-04-14-mature-product-features-design.md

@@ -0,0 +1,228 @@
+# Spider 成熟产品功能扩展设计
+
+## 概述
+
+Spider 当前已具备完整的商户采集、清洗、分级、跟进核心流程。本设计覆盖 11 个功能点,分三个阶段实现,目标是将 Spider 从工具升级为成熟的运营平台。
+
+---
+
+## Phase 1:运营效率
+
+### 1.1 商户分配机制
+
+**数据模型:** `merchants_clean` 新增 `assigned_to VARCHAR(50) INDEX` 字段。
+
+**后端 API:**
+- `PUT /api/v1/merchants/clean/:id/assign` — body: `{assigned_to: "username"}`,admin/operator 可用
+- `PUT /api/v1/merchants/clean/batch-assign` — body: `{ids: [1,2,3], assigned_to: "username"}`
+- `GET /api/v1/merchants/clean` 增加 `assigned_to` query 参数筛选
+
+**前端:**
+- 商户列表新增"负责人"列
+- 筛选栏新增"负责人"下拉(包含"我的商户"快捷项,自动填充当前用户名)
+- 行操作增加分配按钮,弹出用户选择下拉
+
+### 1.2 批量操作
+
+**后端 API:**
+- `PUT /api/v1/merchants/clean/batch-follow-status` — body: `{ids: [], follow_status: "contacted"}`
+- `PUT /api/v1/merchants/clean/batch-level` — body: `{ids: [], level: "Hot"}`
+- 复用已有 `batch-assign` 和 `batch-delete`
+
+**前端:**
+- 表格增加 checkbox 行选择
+- 选中后顶部显示批量操作栏:"已选 N 项" + 批量跟进状态 / 批量等级 / 批量分配 / 批量删除
+- 操作完成后刷新列表、清空选择
+
+### 1.3 数据导入
+
+**后端 API:**
+- `POST /api/v1/merchants/clean/import` — multipart/form-data 上传 CSV
+- CSV 必须包含 `tg_username` 列,可选列:merchant_name, website, email, phone, industry_tag, level
+- 去重逻辑:tg_username 已存在则跳过
+- 响应:`{imported: 10, skipped: 3, failed: 1, errors: ["row 5: invalid level"]}`
+
+**前端:**
+- 导出按钮旁增加"导入"按钮
+- 弹出 Modal:Dragger 上传 CSV → 显示导入结果统计
+
+### 1.4 商户详情页
+
+**路由:** `/merchants/:id`,独立页面
+
+**布局:**
+- 顶部:TG 用户名 + 商户名 + 等级 Tag + 跟进状态 Badge + 负责人 + 操作按钮(编辑/分配)
+- 左列(65%):基本信息 Descriptions + 来源记录卡片列表 + 所属群组列表
+- 右列(35%):跟进备注 Timeline 组件(含添加输入框)
+- 底部:TG 预览 iframe
+
+**列表页联动:** 商户名列和查看按钮都链接到详情页
+
+---
+
+## Phase 2:可观测性与安全
+
+### 2.1 操作审计日志
+
+**数据模型:**
+```
+audit_logs:
+  id          BIGINT PK AUTO_INCREMENT
+  username    VARCHAR(50) INDEX      -- 操作人
+  action      VARCHAR(50) INDEX      -- create/update/delete/assign/import/login/export
+  target_type VARCHAR(50) INDEX      -- merchant/user/keyword/schedule/setting/task
+  target_id   VARCHAR(100)           -- 目标 ID 或标识
+  detail      JSON                   -- 变更前后的字段差异 {field: {old: x, new: y}}
+  ip          VARCHAR(45)
+  created_at  DATETIME INDEX
+```
+
+**后端:**
+- 审计中间件:在关键 handler 中记录操作(商户编辑/删除/分配/导入、用户管理、设置变更、任务启停)
+- `GET /api/v1/audit-logs` — 分页查询,支持 username/action/target_type/date_range 筛选(admin only)
+- 辅助函数 `logAudit(c *gin.Context, action, targetType, targetID string, detail interface{})` 供各 handler 调用
+
+**前端:**
+- 新增"审计日志"页面(admin 菜单),表格展示操作记录
+- 商户详情页右侧时间线增加"操作历史"Tab,展示该商户的审计记录
+
+### 2.2 通知系统
+
+**数据模型:**
+```
+notification_configs:
+  id          INT PK AUTO_INCREMENT
+  name        VARCHAR(100)
+  event_type  VARCHAR(50)   -- task_completed/task_failed/new_hot_merchant/schedule_run
+  channel     VARCHAR(20)   -- webhook/tg_bot
+  config      JSON          -- {url: "..."} 或 {bot_token: "...", chat_id: "..."}
+  enabled     BOOL DEFAULT true
+  created_at  DATETIME
+  updated_at  DATETIME
+```
+
+**后端:**
+- `internal/notification/` 包:Notifier 接口 + WebhookNotifier + TgBotNotifier 实现
+- 事件触发点:task manager 完成/失败时、processor 发现 Hot 商户时、scheduler 执行后
+- `GET/POST/PUT/DELETE /api/v1/notification-configs` — CRUD 通知配置(admin)
+- `POST /api/v1/notification-configs/:id/test` — 发送测试通知
+
+**前端:**
+- 设置页面新增"通知管理"Tab
+- 配置表格:事件类型、通知渠道、目标地址、启用开关
+- 支持测试发送
+
+### 2.3 系统健康监控
+
+**后端 API:**
+- `GET /api/v1/system/health` — 返回各组件状态
+  - MySQL 连接状态 + 连接池使用率
+  - Redis 连接状态
+  - TG 账号状态汇总(idle/online/cooling 各几个)
+  - 最近 24h 任务统计(成功/失败/运行中)
+  - 磁盘/数据量(merchants_raw 行数、merchants_clean 行数、task_details 行数)
+
+**前端:**
+- Dashboard 页面顶部增加系统状态卡片区域
+- 各组件用绿/黄/红指示灯显示状态
+- TG 账号状态一目了然
+
+### 2.4 数据归档
+
+**后端:**
+- `POST /api/v1/merchants/archive` — 将满足条件的商户移入归档表 `merchants_archived`
+  - 条件:status=invalid/bot + 超过 90 天未更新,或 follow_status=rejected + 超过 180 天
+  - 可配置天数阈值
+- `GET /api/v1/merchants/archived` — 查看归档数据(只读,分页)
+- `POST /api/v1/merchants/archived/:id/restore` — 恢复单条数据
+- 可配合定时任务自动归档
+
+**数据模型:** `merchants_archived` 与 `merchants_clean` 结构一致,增加 `archived_at DATETIME` 和 `archive_reason VARCHAR(100)`
+
+**前端:**
+- 原始数据页面增加"归档数据"Tab
+- 归档操作按钮在商户列表批量操作栏中
+
+---
+
+## Phase 3:数据洞察
+
+### 3.1 转化漏斗分析
+
+**后端 API:**
+- `GET /api/v1/analytics/funnel` — 返回各阶段数量
+  ```json
+  {
+    "raw_total": 5000,
+    "clean_total": 1200,
+    "valid_total": 980,
+    "contacted": 320,
+    "cooperating": 85,
+    "rejected": 45,
+    "conversion_rates": {
+      "raw_to_clean": 0.24,
+      "clean_to_valid": 0.817,
+      "valid_to_contacted": 0.327,
+      "contacted_to_cooperating": 0.266
+    }
+  }
+  ```
+
+**前端:**
+- Dashboard 新增漏斗图卡片(使用 antd 进度条或简单 div 渐变实现,不引入重型图表库)
+- 每级显示数量和转化率百分比
+
+### 3.2 来源效率分析
+
+**后端 API:**
+- `GET /api/v1/analytics/source-efficiency` — 按来源类型和关键词统计
+  ```json
+  {
+    "by_source_type": [
+      {"source_type": "tg_channel", "raw_count": 3000, "clean_count": 800, "hot_count": 120, "efficiency": 0.04},
+      {"source_type": "web", "raw_count": 1500, "clean_count": 300, "hot_count": 50, "efficiency": 0.033}
+    ],
+    "top_keywords": [
+      {"keyword": "担保", "merchants_found": 230, "hot_count": 45},
+      {"keyword": "支付", "merchants_found": 180, "hot_count": 30}
+    ],
+    "top_groups": [
+      {"group_username": "xxx", "members_found": 150, "valid_count": 80}
+    ]
+  }
+  ```
+
+**前端:**
+- 新增"数据分析"页面
+- 来源效率对比表格 + 柱状图(纯 CSS/div 实现)
+- Top 关键词和 Top 群组排行榜
+
+### 3.3 趋势报表
+
+**后端 API:**
+- `GET /api/v1/analytics/trends` — query: `period=week|month`, `range=30|90|180`
+  ```json
+  {
+    "period": "week",
+    "data": [
+      {"period_label": "2026-W14", "raw_added": 500, "clean_added": 120, "contacted": 30, "cooperating": 5},
+      {"period_label": "2026-W15", "raw_added": 600, "clean_added": 150, "contacted": 45, "cooperating": 8}
+    ]
+  }
+  ```
+
+**前端:**
+- 数据分析页面增加趋势区域
+- 按周/月切换,表格 + 简单折线/柱状图
+- 支持导出报表 CSV
+
+---
+
+## 技术约束
+
+- 不引入新的重型依赖(图表用 antd + CSS 实现,不加 echarts/recharts)
+- 所有新 API 遵循现有 RESTful 风格和 response 格式
+- 新表自动通过 GORM AutoMigrate 创建
+- 审计日志不影响主流程性能(异步写入或 goroutine)
+- 前端新页面加入现有路由和侧边栏菜单
+- 权限控制:分析和审计页面 admin 可见,商户操作 admin/operator 可用

+ 3 - 2
internal/crawler/filter.go

@@ -96,10 +96,11 @@ func ExtractDomain(rawURL string) string {
 	return u.Hostname()
 }
 
+var reTGUsernameFromURL = regexp.MustCompile(`t(?:elegram)?\.me/([a-zA-Z][a-zA-Z0-9_]{4,31})`)
+
 // ExtractTGUsername 从 URL 提取 TG 用户名
 func ExtractTGUsername(rawURL string) string {
-	re := regexp.MustCompile(`t(?:elegram)?\.me/([a-zA-Z][a-zA-Z0-9_]{4,31})`)
-	m := re.FindStringSubmatch(rawURL)
+	m := reTGUsernameFromURL.FindStringSubmatch(rawURL)
 	if len(m) > 1 {
 		return m[1]
 	}

+ 25 - 1
internal/crawler/static.go

@@ -3,17 +3,35 @@ package crawler
 import (
 	"context"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/gocolly/colly/v2"
 )
 
 // StaticCrawler 静态网页爬取(colly)
-type StaticCrawler struct{}
+type StaticCrawler struct {
+	mu       sync.RWMutex
+	proxyURL string
+}
 
 // NewStaticCrawler 创建 StaticCrawler
 func NewStaticCrawler() *StaticCrawler { return &StaticCrawler{} }
 
+// SetProxy sets the proxy URL for subsequent crawl requests.
+func (c *StaticCrawler) SetProxy(proxyURL string) {
+	c.mu.Lock()
+	c.proxyURL = proxyURL
+	c.mu.Unlock()
+}
+
+// GetProxy returns the current proxy URL.
+func (c *StaticCrawler) GetProxy() string {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.proxyURL
+}
+
 // CrawlResult 爬取结果
 type CrawlResult struct {
 	Links   []string // 发现的链接
@@ -33,6 +51,12 @@ func (c *StaticCrawler) Crawl(ctx context.Context, targetURL string) *CrawlResul
 	)
 	collector.SetRequestTimeout(15 * time.Second)
 
+	// Snapshot proxy under lock
+	proxyURL := c.GetProxy()
+	if proxyURL != "" {
+		collector.SetProxy(proxyURL)
+	}
+
 	// 提取所有 <a href> 链接
 	collector.OnHTML("a[href]", func(e *colly.HTMLElement) {
 		href := e.Attr("href")

+ 110 - 7
internal/extractor/regex.go

@@ -23,7 +23,7 @@ var (
 	reHTMLTag = regexp.MustCompile(`<[^>]+>`)
 )
 
-// Extract 从文本提取联系方式(正则,快速)
+// Extract 从文本提取联系方式(正则,快速)— 只返回第一个TG用户名
 // 优先级:先提取 t.me 链接(更精确),再提取 @用户名,避免重复
 func Extract(text string) *ContactInfo {
 	info := &ContactInfo{}
@@ -58,12 +58,117 @@ func Extract(text string) *ContactInfo {
 		}
 	}
 
-	// 5. 提取 Email(过滤掉 TG 用户名中的 @ 误匹配)
+	extractContactFields(text, info)
+	return info
+}
+
+// commonFalsePositives are words that regex matches as TG usernames but aren't.
+var commonFalsePositives = map[string]bool{
+	// TG system bots
+	"telegram": true, "telegramhints": true, "botfather": true, "spambot": true,
+	// HTML/CSS/JS keywords that regex can match
+	"github": true, "gmail": true, "email": true, "admin": true,
+	"login": true, "signup": true, "about": true, "contact": true,
+	"support": true, "https": true, "style": true, "script": true,
+	"header": true, "footer": true, "button": true, "input": true,
+	"image": true, "video": true, "media": true, "share": true,
+	"click": true, "undefined": true, "object": true, "string": true,
+	"number": true, "function": true, "return": true, "const": true,
+	"class": true, "export": true, "import": true, "ssage": true,
+	"messages": true, "channel": true, "username": true,
+	// CSS properties/values commonly found in HTML pages
+	"context": true, "graph": true, "supports": true, "keyframes": true,
+	"container": true, "ffmpeg": true, "original": true, "wrapped": true,
+	"newrelic": true, "viewport": true, "charset": true, "content": true,
+	"inherit": true, "initial": true, "normal": true, "center": true,
+	"inline": true, "block": true, "fixed": true, "static": true,
+	"relative": true, "absolute": true, "hidden": true, "visible": true,
+	"transparent": true, "important": true, "default": true,
+	// Common web tech terms
+	"jquery": true, "webpack": true, "babel": true, "eslint": true,
+	"prettier": true, "typescript": true, "javascript": true,
+	"angular": true, "vuejs": true, "nextjs": true, "nuxtjs": true,
+	"nodejs": true, "express": true, "python": true, "django": true,
+	"docker": true, "nginx": true, "redis": true, "mysql": true,
+	"mongodb": true, "postgresql": true, "firebase": true,
+	"google": true, "apple": true, "microsoft": true, "amazon": true,
+	"twitter": true, "facebook": true, "instagram": true, "tiktok": true,
+	"linkedin": true, "pinterest": true, "reddit": true, "discord": true,
+}
+
+func isValidTgUsername(username string) bool {
+	lower := strings.ToLower(username)
+	if commonFalsePositives[lower] {
+		return false
+	}
+	// Reject if ends with "bot" (except very short ones which might be real names)
+	if strings.HasSuffix(lower, "bot") && len(lower) > 5 {
+		return false
+	}
+	return true
+}
+
+// ExtractAll 从文本提取所有不同的TG用户名及联系方式
+// 用于导航站/聚合页面,一个页面可能包含多个商户
+func ExtractAll(text string) []*ContactInfo {
+	seen := map[string]bool{}
+	var results []*ContactInfo
+
+	// Collect all usernames from all regex patterns
+	addUsername := func(username string) {
+		if username == "" || seen[strings.ToLower(username)] {
+			return
+		}
+		if !isValidTgUsername(username) {
+			return
+		}
+		seen[strings.ToLower(username)] = true
+		info := &ContactInfo{
+			TgUsername: username,
+			TgLink:    "t.me/" + username,
+		}
+		results = append(results, info)
+	}
+
+	// t.me links (highest priority, most accurate)
+	for _, m := range reTgLink.FindAllStringSubmatch(text, -1) {
+		addUsername(m[1])
+	}
+
+	// Chinese variants: t点me, t.me
+	for _, m := range reTgDot.FindAllStringSubmatch(text, -1) {
+		addUsername(m[1])
+	}
+
+	// tg: prefix
+	for _, m := range reTgColon.FindAllStringSubmatch(text, -1) {
+		addUsername(m[1])
+	}
+
+	// @username
+	for _, m := range reTgAt.FindAllStringSubmatch(text, -1) {
+		addUsername(m[1])
+	}
+
+	// If no results, return nil
+	if len(results) == 0 {
+		return nil
+	}
+
+	// Attach shared contact fields to the first result
+	extractContactFields(text, results[0])
+
+	return results
+}
+
+// extractContactFields fills in email, website, phone, wechat fields.
+func extractContactFields(text string, info *ContactInfo) {
+	// 提取 Email(过滤掉 TG 用户名中的 @ 误匹配)
 	if m := reEmail.FindString(text); m != "" {
 		info.Email = m
 	}
 
-	// 6. 提取网站
+	// 提取网站
 	if m := reWebsite.FindString(text); m != "" {
 		// 过滤掉 t.me 本身
 		if !strings.Contains(strings.ToLower(m), "t.me/") && !strings.Contains(strings.ToLower(m), "telegram.me/") {
@@ -71,7 +176,7 @@ func Extract(text string) *ContactInfo {
 		}
 	}
 
-	// 7. 提取电话(过滤纯数字短于7位)
+	// 提取电话(过滤纯数字短于7位)
 	if m := rePhone.FindString(text); m != "" {
 		cleaned := strings.TrimPrefix(m, "+")
 		if len(cleaned) >= 7 {
@@ -79,15 +184,13 @@ func Extract(text string) *ContactInfo {
 		}
 	}
 
-	// 8. 提取微信
+	// 提取微信
 	if m := reWeChat.FindStringSubmatch(text); m != nil {
 		info.WeChat = m[1]
 	}
 
 	info.HasContact = info.TgUsername != "" || info.Email != "" ||
 		info.Website != "" || info.Phone != "" || info.WeChat != ""
-
-	return info
 }
 
 // HasContact 快速判断文本是否含任何联系方式(无需完整提取)

+ 335 - 0
internal/handler/analytics.go

@@ -0,0 +1,335 @@
+package handler
+
+import (
+	"encoding/csv"
+	"fmt"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+)
+
+// AnalyticsHandler handles analytics endpoints.
+type AnalyticsHandler struct {
+	store *store.Store
+}
+
+// Funnel returns conversion funnel data.
+func (h *AnalyticsHandler) Funnel(c *gin.Context) {
+	db := h.store.DB
+
+	var rawTotal, cleanTotal, validTotal int64
+	db.Model(&model.MerchantRaw{}).Count(&rawTotal)
+	db.Model(&model.MerchantClean{}).Count(&cleanTotal)
+	db.Model(&model.MerchantClean{}).Where("status = ?", "valid").Count(&validTotal)
+
+	type kv struct {
+		Key   string `gorm:"column:key"`
+		Count int64  `gorm:"column:count"`
+	}
+
+	var followRows []kv
+	db.Model(&model.MerchantClean{}).
+		Select("follow_status as `key`, count(*) as `count`").
+		Where("status = ?", "valid").
+		Group("follow_status").
+		Find(&followRows)
+
+	followMap := map[string]int64{}
+	for _, r := range followRows {
+		followMap[r.Key] = r.Count
+	}
+
+	contacted := followMap["contacted"] + followMap["cooperating"] + followMap["rejected"]
+	cooperating := followMap["cooperating"]
+	rejected := followMap["rejected"]
+
+	safeDiv := func(a, b int64) float64 {
+		if b == 0 {
+			return 0
+		}
+		return float64(a) / float64(b)
+	}
+
+	OK(c, gin.H{
+		"raw_total":   rawTotal,
+		"clean_total": cleanTotal,
+		"valid_total": validTotal,
+		"contacted":   contacted,
+		"cooperating": cooperating,
+		"rejected":    rejected,
+		"conversion_rates": gin.H{
+			"raw_to_clean":          safeDiv(cleanTotal, rawTotal),
+			"clean_to_valid":        safeDiv(validTotal, cleanTotal),
+			"valid_to_contacted":    safeDiv(contacted, validTotal),
+			"contacted_to_cooperating": safeDiv(cooperating, contacted),
+		},
+	})
+}
+
+// SourceEfficiency returns source-level performance metrics.
+func (h *AnalyticsHandler) SourceEfficiency(c *gin.Context) {
+	db := h.store.DB
+
+	// By source type
+	type sourceMetric struct {
+		SourceType string `gorm:"column:source_type" json:"source_type"`
+		RawCount   int64  `gorm:"column:raw_count" json:"raw_count"`
+	}
+
+	var rawBySource []sourceMetric
+	db.Model(&model.MerchantRaw{}).
+		Select("source_type, count(*) as raw_count").
+		Group("source_type").
+		Find(&rawBySource)
+
+	// Count clean/hot merchants per source type in a single query using JSON LIKE
+	type cleanBySource struct {
+		SourceType string `gorm:"column:source_type"`
+		CleanCount int64  `gorm:"column:clean_count"`
+		HotCount   int64  `gorm:"column:hot_count"`
+	}
+
+	// Build a single query: count all and count hot per source type
+	sourceTypes := make([]string, 0, len(rawBySource))
+	rawCountMap := map[string]int64{}
+	for _, rs := range rawBySource {
+		if rs.SourceType != "" {
+			sourceTypes = append(sourceTypes, rs.SourceType)
+			rawCountMap[rs.SourceType] = rs.RawCount
+		}
+	}
+
+	// Single batch query for clean counts per source type
+	cleanCounts := map[string]int64{}
+	hotCounts := map[string]int64{}
+	for _, st := range sourceTypes {
+		pattern := fmt.Sprintf("%%\"%s\"%%", st)
+		var cc int64
+		db.Model(&model.MerchantClean{}).Where("all_sources LIKE ?", pattern).Count(&cc)
+		cleanCounts[st] = cc
+		var hc int64
+		db.Model(&model.MerchantClean{}).Where("all_sources LIKE ? AND level = ?", pattern, "Hot").Count(&hc)
+		hotCounts[st] = hc
+	}
+
+	type sourceEff struct {
+		SourceType string  `json:"source_type"`
+		RawCount   int64   `json:"raw_count"`
+		CleanCount int64   `json:"clean_count"`
+		HotCount   int64   `json:"hot_count"`
+		Efficiency float64 `json:"efficiency"`
+	}
+
+	results := make([]sourceEff, 0, len(sourceTypes))
+	for _, st := range sourceTypes {
+		eff := float64(0)
+		if rawCountMap[st] > 0 {
+			eff = float64(hotCounts[st]) / float64(rawCountMap[st])
+		}
+		results = append(results, sourceEff{
+			SourceType: st,
+			RawCount:   rawCountMap[st],
+			CleanCount: cleanCounts[st],
+			HotCount:   hotCounts[st],
+			Efficiency: eff,
+		})
+	}
+
+	// Top keywords
+	type kwMetric struct {
+		Keyword        string `gorm:"column:keyword" json:"keyword"`
+		MerchantsFound int64  `gorm:"column:merchants_found" json:"merchants_found"`
+	}
+
+	var topKeywords []kwMetric
+	db.Model(&model.MerchantRaw{}).
+		Select("source_name as keyword, count(*) as merchants_found").
+		Where("source_type = ? AND source_name != ''", "web").
+		Group("source_name").
+		Order("merchants_found DESC").
+		Limit(10).
+		Find(&topKeywords)
+
+	// Top groups
+	type groupMetric struct {
+		GroupUsername string `gorm:"column:group_username" json:"group_username"`
+		MembersFound int64  `gorm:"column:members_found" json:"members_found"`
+	}
+
+	var topGroups []groupMetric
+	db.Model(&model.GroupMember{}).
+		Select("group_username, count(distinct member_username) as members_found").
+		Group("group_username").
+		Order("members_found DESC").
+		Limit(10).
+		Find(&topGroups)
+
+	OK(c, gin.H{
+		"by_source_type": results,
+		"top_keywords":   topKeywords,
+		"top_groups":     topGroups,
+	})
+}
+
+// Trends returns time-series data for reporting.
+func (h *AnalyticsHandler) Trends(c *gin.Context) {
+	db := h.store.DB
+
+	period := c.DefaultQuery("period", "week")
+	rangeStr := c.DefaultQuery("range", "90")
+	rangeDays := parseInt(rangeStr, 90)
+
+	startDate := time.Now().AddDate(0, 0, -rangeDays)
+
+	var dateFormat, groupExpr string
+	switch period {
+	case "month":
+		dateFormat = "%Y-%m"
+		groupExpr = "DATE_FORMAT(created_at, '%Y-%m')"
+	default: // week
+		dateFormat = "%x-W%v"
+		groupExpr = "DATE_FORMAT(created_at, '%x-W%v')"
+	}
+	_ = dateFormat // used implicitly via groupExpr
+
+	type trendRow struct {
+		PeriodLabel string `gorm:"column:period_label" json:"period_label"`
+		Count       int64  `gorm:"column:count" json:"count"`
+	}
+
+	// Raw added per period
+	var rawTrend []trendRow
+	db.Model(&model.MerchantRaw{}).
+		Select(groupExpr+" as period_label, count(*) as `count`").
+		Where("created_at >= ?", startDate).
+		Group("period_label").
+		Order("period_label ASC").
+		Find(&rawTrend)
+
+	// Clean added per period
+	var cleanTrend []trendRow
+	db.Model(&model.MerchantClean{}).
+		Select(groupExpr+" as period_label, count(*) as `count`").
+		Where("created_at >= ?", startDate).
+		Group("period_label").
+		Order("period_label ASC").
+		Find(&cleanTrend)
+
+	// Build merged result
+	type periodData struct {
+		PeriodLabel string `json:"period_label"`
+		RawAdded    int64  `json:"raw_added"`
+		CleanAdded  int64  `json:"clean_added"`
+	}
+
+	periodMap := map[string]*periodData{}
+	for _, r := range rawTrend {
+		if _, ok := periodMap[r.PeriodLabel]; !ok {
+			periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
+		}
+		periodMap[r.PeriodLabel].RawAdded = r.Count
+	}
+	for _, r := range cleanTrend {
+		if _, ok := periodMap[r.PeriodLabel]; !ok {
+			periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
+		}
+		periodMap[r.PeriodLabel].CleanAdded = r.Count
+	}
+
+	// Sort by period
+	data := make([]periodData, 0, len(periodMap))
+	for _, v := range periodMap {
+		data = append(data, *v)
+	}
+	// Simple sort
+	for i := 0; i < len(data); i++ {
+		for j := i + 1; j < len(data); j++ {
+			if data[i].PeriodLabel > data[j].PeriodLabel {
+				data[i], data[j] = data[j], data[i]
+			}
+		}
+	}
+
+	OK(c, gin.H{
+		"period": period,
+		"data":   data,
+	})
+}
+
+// ExportTrends exports trend data as CSV.
+func (h *AnalyticsHandler) ExportTrends(c *gin.Context) {
+	db := h.store.DB
+	period := c.DefaultQuery("period", "week")
+	rangeStr := c.DefaultQuery("range", "90")
+	rangeDays := parseInt(rangeStr, 90)
+	startDate := time.Now().AddDate(0, 0, -rangeDays)
+
+	var groupExpr string
+	switch period {
+	case "month":
+		groupExpr = "DATE_FORMAT(created_at, '%Y-%m')"
+	default:
+		groupExpr = "DATE_FORMAT(created_at, '%x-W%v')"
+	}
+
+	type trendRow struct {
+		PeriodLabel string `gorm:"column:period_label"`
+		Count       int64  `gorm:"column:count"`
+	}
+
+	var rawTrend []trendRow
+	db.Model(&model.MerchantRaw{}).
+		Select(groupExpr+" as period_label, count(*) as `count`").
+		Where("created_at >= ?", startDate).Group("period_label").Order("period_label ASC").Find(&rawTrend)
+
+	var cleanTrend []trendRow
+	db.Model(&model.MerchantClean{}).
+		Select(groupExpr+" as period_label, count(*) as `count`").
+		Where("created_at >= ?", startDate).Group("period_label").Order("period_label ASC").Find(&cleanTrend)
+
+	type periodData struct {
+		PeriodLabel string
+		RawAdded    int64
+		CleanAdded  int64
+	}
+
+	periodMap := map[string]*periodData{}
+	for _, r := range rawTrend {
+		if _, ok := periodMap[r.PeriodLabel]; !ok {
+			periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
+		}
+		periodMap[r.PeriodLabel].RawAdded = r.Count
+	}
+	for _, r := range cleanTrend {
+		if _, ok := periodMap[r.PeriodLabel]; !ok {
+			periodMap[r.PeriodLabel] = &periodData{PeriodLabel: r.PeriodLabel}
+		}
+		periodMap[r.PeriodLabel].CleanAdded = r.Count
+	}
+
+	data := make([]periodData, 0, len(periodMap))
+	for _, v := range periodMap {
+		data = append(data, *v)
+	}
+	for i := 0; i < len(data); i++ {
+		for j := i + 1; j < len(data); j++ {
+			if data[i].PeriodLabel > data[j].PeriodLabel {
+				data[i], data[j] = data[j], data[i]
+			}
+		}
+	}
+
+	c.Header("Content-Type", "text/csv; charset=utf-8")
+	c.Header("Content-Disposition", "attachment; filename=trends.csv")
+	c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
+
+	w := csv.NewWriter(c.Writer)
+	w.Write([]string{"时间段", "原始新增", "清洗新增"})
+	for _, d := range data {
+		w.Write([]string{d.PeriodLabel, fmt.Sprintf("%d", d.RawAdded), fmt.Sprintf("%d", d.CleanAdded)})
+	}
+	w.Flush()
+}

+ 71 - 0
internal/handler/audit.go

@@ -0,0 +1,71 @@
+package handler
+
+import (
+	"encoding/json"
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+)
+
+// AuditHandler handles audit log queries.
+type AuditHandler struct {
+	store *store.Store
+}
+
+// List handles GET /audit-logs (admin only)
+func (h *AuditHandler) List(c *gin.Context) {
+	page, pageSize, offset := parsePage(c)
+
+	query := h.store.DB.Model(&model.AuditLog{})
+	if username := c.Query("username"); username != "" {
+		query = query.Where("username = ?", username)
+	}
+	if action := c.Query("action"); action != "" {
+		query = query.Where("action = ?", action)
+	}
+	if targetType := c.Query("target_type"); targetType != "" {
+		query = query.Where("target_type = ?", targetType)
+	}
+	if targetID := c.Query("target_id"); targetID != "" {
+		query = query.Where("target_id = ?", targetID)
+	}
+	if dateFrom := c.Query("date_from"); dateFrom != "" {
+		query = query.Where("created_at >= ?", dateFrom)
+	}
+	if dateTo := c.Query("date_to"); dateTo != "" {
+		query = query.Where("created_at <= ?", dateTo)
+	}
+
+	var total int64
+	query.Count(&total)
+
+	var logs []model.AuditLog
+	if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&logs).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, logs, total, page, pageSize)
+}
+
+// LogAudit records an audit log entry asynchronously.
+func LogAudit(s *store.Store, c *gin.Context, action, targetType, targetID string, detail interface{}) {
+	username := c.GetString("username")
+	ip := c.ClientIP()
+
+	var detailJSON []byte
+	if detail != nil {
+		detailJSON, _ = json.Marshal(detail)
+	}
+
+	log := model.AuditLog{
+		Username:   username,
+		Action:     action,
+		TargetType: targetType,
+		TargetID:   targetID,
+		Detail:     detailJSON,
+		IP:         ip,
+	}
+
+	go s.DB.Create(&log)
+}

+ 358 - 0
internal/handler/auth.go

@@ -0,0 +1,358 @@
+package handler
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/redis/go-redis/v9"
+	"golang.org/x/crypto/bcrypt"
+
+	"spider/internal/config"
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"gorm.io/gorm"
+)
+
+const jwtExpiry = 7 * 24 * time.Hour // 7 days
+
+func getJWTSecret() string {
+	if cfg := config.Get(); cfg != nil && cfg.Security.JWTSecret != "" {
+		return cfg.Security.JWTSecret
+	}
+	return "spider-jwt-secret-2026"
+}
+
+// rdb is the shared Redis client for token blacklist. Set via SetAuthRedis.
+var authRedis *redis.Client
+
+// SetAuthRedis sets the Redis client used for token blacklisting.
+func SetAuthRedis(r *redis.Client) {
+	authRedis = r
+}
+
+// AuthHandler handles authentication.
+type AuthHandler struct {
+	store *store.Store
+}
+
+// LoginRequest is the login payload.
+type LoginRequest struct {
+	Username string `json:"username" binding:"required"`
+	Password string `json:"password" binding:"required"`
+}
+
+// Login handles POST /auth/login
+func (h *AuthHandler) Login(c *gin.Context) {
+	var req LoginRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, "请输入用户名和密码")
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
+		Fail(c, 401, "用户名或密码错误")
+		return
+	}
+	if !user.Enabled {
+		Fail(c, 403, "账号已禁用")
+		return
+	}
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
+		Fail(c, 401, "用户名或密码错误")
+		return
+	}
+
+	// Generate JWT with expiration
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+		"user_id":  user.ID,
+		"username": user.Username,
+		"role":     user.Role,
+		"exp":      time.Now().Add(jwtExpiry).Unix(),
+	})
+	tokenStr, err := token.SignedString([]byte(getJWTSecret()))
+	if err != nil {
+		Fail(c, 500, "生成令牌失败")
+		return
+	}
+
+	// Update last login info & audit
+	now := time.Now()
+	ip := c.ClientIP()
+	go func() {
+		h.store.DB.Model(&user).Updates(map[string]any{
+			"last_login_at": now,
+			"last_login_ip": ip,
+		})
+		h.store.DB.Create(&model.AuditLog{
+			Username:   user.Username,
+			Action:     "login",
+			TargetType: "user",
+			TargetID:   fmt.Sprintf("%d", user.ID),
+			IP:         ip,
+		})
+	}()
+
+	OK(c, gin.H{
+		"token": tokenStr,
+		"user": gin.H{
+			"id":                   user.ID,
+			"username":             user.Username,
+			"nickname":             user.Nickname,
+			"role":                 user.Role,
+			"must_change_password": user.MustChangePassword,
+		},
+	})
+}
+
+// ChangePassword handles PUT /auth/password
+func (h *AuthHandler) ChangePassword(c *gin.Context) {
+	userID := c.GetUint("user_id")
+
+	var req struct {
+		OldPassword string `json:"old_password" binding:"required"`
+		NewPassword string `json:"new_password" binding:"required,min=6"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, "新密码至少6位")
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.First(&user, userID).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
+		Fail(c, 400, "旧密码错误")
+		return
+	}
+
+	if err := ValidatePassword(req.NewPassword); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	hashed, _ := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+	h.store.DB.Model(&user).Updates(map[string]any{
+		"password":             string(hashed),
+		"must_change_password": false,
+	})
+
+	LogAudit(h.store, c, "update", "user", fmt.Sprintf("%d", userID), gin.H{"action": "change_password"})
+	OK(c, gin.H{"message": "密码已修改"})
+}
+
+// GetProfile handles GET /auth/profile
+func (h *AuthHandler) GetProfile(c *gin.Context) {
+	userID := c.GetUint("user_id")
+	var user model.User
+	if err := h.store.DB.First(&user, userID).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+	OK(c, gin.H{
+		"id":       user.ID,
+		"username": user.Username,
+		"nickname": user.Nickname,
+		"role":     user.Role,
+	})
+}
+
+// UpdateProfile handles PUT /auth/profile — user updates their own nickname
+func (h *AuthHandler) UpdateProfile(c *gin.Context) {
+	userID := c.GetUint("user_id")
+
+	var req struct {
+		Nickname *string `json:"nickname"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.First(&user, userID).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+
+	if req.Nickname != nil {
+		h.store.DB.Model(&user).Update("nickname", *req.Nickname)
+	}
+
+	h.store.DB.First(&user, userID)
+	OK(c, gin.H{
+		"id":       user.ID,
+		"username": user.Username,
+		"nickname": user.Nickname,
+		"role":     user.Role,
+	})
+}
+
+// Logout handles POST /auth/logout — blacklists the current token
+func (h *AuthHandler) Logout(c *gin.Context) {
+	auth := c.GetHeader("Authorization")
+	if auth != "" && strings.HasPrefix(auth, "Bearer ") {
+		tokenStr := strings.TrimPrefix(auth, "Bearer ")
+		if authRedis != nil {
+			// Blacklist token until its expiry
+			authRedis.Set(context.Background(), "spider:token:blacklist:"+tokenStr, "1", jwtExpiry)
+		}
+	}
+	LogAudit(h.store, c, "logout", "user", c.GetString("username"), nil)
+	OK(c, gin.H{"message": "已退出"})
+}
+
+// ── JWT Middleware ──
+
+// JWTAuth is the authentication middleware.
+func JWTAuth() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		auth := c.GetHeader("Authorization")
+		if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
+			c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: 401, Message: "未登录"})
+			return
+		}
+		tokenStr := strings.TrimPrefix(auth, "Bearer ")
+
+		// Check blacklist
+		if authRedis != nil {
+			blacklisted, _ := authRedis.Exists(context.Background(), "spider:token:blacklist:"+tokenStr).Result()
+			if blacklisted > 0 {
+				c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: 401, Message: "令牌已失效"})
+				return
+			}
+		}
+
+		token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
+			return []byte(getJWTSecret()), nil
+		})
+		if err != nil || !token.Valid {
+			c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: 401, Message: "令牌无效"})
+			return
+		}
+
+		claims, ok := token.Claims.(jwt.MapClaims)
+		if !ok {
+			c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: 401, Message: "令牌解析失败"})
+			return
+		}
+
+		// Set user info in context
+		if uid, ok := claims["user_id"].(float64); ok {
+			c.Set("user_id", uint(uid))
+		}
+		if username, ok := claims["username"].(string); ok {
+			c.Set("username", username)
+		}
+		if role, ok := claims["role"].(string); ok {
+			c.Set("role", role)
+		}
+
+		c.Next()
+	}
+}
+
+// RequireRole returns middleware that checks the user's role.
+func RequireRole(roles ...string) gin.HandlerFunc {
+	roleSet := make(map[string]bool)
+	for _, r := range roles {
+		roleSet[r] = true
+	}
+	return func(c *gin.Context) {
+		role := c.GetString("role")
+		if !roleSet[role] {
+			c.AbortWithStatusJSON(http.StatusForbidden, Response{Code: 403, Message: "权限不足"})
+			return
+		}
+		c.Next()
+	}
+}
+
+// RequireAction returns middleware that checks the user's role has the required action permission.
+// Falls back to default permissions if no DB record exists.
+func RequireAction(action string) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		role := c.GetString("role")
+		// Admin always has all permissions
+		if role == "admin" {
+			c.Next()
+			return
+		}
+
+		// Check DB for role permissions
+		var perm model.RolePermission
+		if err := getPermissionDB().Where("role = ?", role).First(&perm).Error; err == nil {
+			// Found in DB
+			for _, a := range strings.Split(perm.Actions, ",") {
+				if strings.TrimSpace(a) == action {
+					c.Next()
+					return
+				}
+			}
+		} else {
+			// Fallback to defaults
+			defaults := model.DefaultPermissions()
+			if d, ok := defaults[role]; ok {
+				for _, a := range strings.Split(d.Actions, ",") {
+					if strings.TrimSpace(a) == action {
+						c.Next()
+						return
+					}
+				}
+			}
+		}
+
+		c.AbortWithStatusJSON(http.StatusForbidden, Response{Code: 403, Message: "无此操作权限"})
+	}
+}
+
+// permDB is cached reference to avoid import cycles
+var permDB interface{ Where(query interface{}, args ...interface{}) *gorm.DB }
+
+func setPermissionDB(db *gorm.DB) { permDB = db }
+func getPermissionDB() *gorm.DB {
+	if permDB == nil {
+		return nil
+	}
+	return permDB.(*gorm.DB)
+}
+
+// HashPassword hashes a password with bcrypt.
+func HashPassword(password string) string {
+	hashed, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	return string(hashed)
+}
+
+// ValidatePassword checks password meets complexity requirements.
+func ValidatePassword(password string) error {
+	minLen := 8
+	if cfg := config.Get(); cfg != nil && cfg.Security.PasswordMinLen > 0 {
+		minLen = cfg.Security.PasswordMinLen
+	}
+	if len(password) < minLen {
+		return fmt.Errorf("密码至少 %d 位", minLen)
+	}
+	var hasUpper, hasLower, hasDigit bool
+	for _, c := range password {
+		switch {
+		case c >= 'A' && c <= 'Z':
+			hasUpper = true
+		case c >= 'a' && c <= 'z':
+			hasLower = true
+		case c >= '0' && c <= '9':
+			hasDigit = true
+		}
+	}
+	if !hasUpper || !hasLower || !hasDigit {
+		return fmt.Errorf("密码必须包含大写字母、小写字母和数字")
+	}
+	return nil
+}

+ 101 - 0
internal/handler/backup.go

@@ -0,0 +1,101 @@
+package handler
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+)
+
+// BackupHandler handles database backup/export.
+type BackupHandler struct {
+	store *store.Store
+}
+
+// ExportJSON handles GET /backup/export — exports all core data as JSON.
+func (h *BackupHandler) ExportJSON(c *gin.Context) {
+	db := h.store.DB
+
+	var merchants []model.MerchantClean
+	db.Find(&merchants)
+
+	var merchantsRaw []model.MerchantRaw
+	db.Find(&merchantsRaw)
+
+	var keywords []model.Keyword
+	db.Find(&keywords)
+
+	var channels []model.Channel
+	db.Find(&channels)
+
+	var schedules []model.ScheduleJob
+	db.Find(&schedules)
+
+	var users []model.User
+	db.Find(&users)
+	// Clear passwords from export
+	for i := range users {
+		users[i].Password = ""
+	}
+
+	var notes []model.MerchantNote
+	db.Find(&notes)
+
+	var permissions []model.RolePermission
+	db.Find(&permissions)
+
+	backup := map[string]interface{}{
+		"exported_at":      time.Now().Format(time.RFC3339),
+		"merchants_clean":  merchants,
+		"merchants_raw":    merchantsRaw,
+		"keywords":         keywords,
+		"channels":         channels,
+		"schedules":        schedules,
+		"users":            users,
+		"merchant_notes":   notes,
+		"role_permissions": permissions,
+	}
+
+	data, _ := json.MarshalIndent(backup, "", "  ")
+
+	filename := fmt.Sprintf("spider-backup-%s.json", time.Now().Format("2006-01-02"))
+	c.Header("Content-Type", "application/json")
+	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
+	c.Writer.Write(data)
+
+	LogAudit(h.store, c, "export", "backup", "", nil)
+}
+
+// Stats handles GET /backup/stats — returns table row counts for backup estimation.
+func (h *BackupHandler) Stats(c *gin.Context) {
+	db := h.store.DB
+
+	tables := []struct {
+		Name  string
+		Model interface{}
+	}{
+		{"merchants_clean", &model.MerchantClean{}},
+		{"merchants_raw", &model.MerchantRaw{}},
+		{"keywords", &model.Keyword{}},
+		{"channels", &model.Channel{}},
+		{"task_logs", &model.TaskLog{}},
+		{"task_details", &model.TaskDetail{}},
+		{"audit_logs", &model.AuditLog{}},
+		{"merchant_notes", &model.MerchantNote{}},
+		{"users", &model.User{}},
+		{"schedules", &model.ScheduleJob{}},
+	}
+
+	result := make([]gin.H, 0, len(tables))
+	for _, t := range tables {
+		var count int64
+		db.Model(t.Model).Count(&count)
+		result = append(result, gin.H{"table": t.Name, "rows": count})
+	}
+
+	OK(c, result)
+}

+ 114 - 0
internal/handler/channel.go

@@ -0,0 +1,114 @@
+package handler
+
+import (
+	"strconv"
+
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+)
+
+// ChannelHandler handles TG channel management.
+type ChannelHandler struct {
+	store *store.Store
+}
+
+// List handles GET /channels
+func (h *ChannelHandler) List(c *gin.Context) {
+	page, pageSize, offset := parsePage(c)
+
+	query := h.store.DB.Model(&model.Channel{})
+	if status := c.Query("status"); status != "" {
+		query = query.Where("status = ?", status)
+	}
+	if source := c.Query("source"); source != "" {
+		query = query.Where("source = ?", source)
+	}
+	if search := c.Query("search"); search != "" {
+		query = query.Where("username LIKE ?", "%"+search+"%")
+	}
+
+	var total int64
+	query.Count(&total)
+
+	var channels []model.Channel
+	if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&channels).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, channels, total, page, pageSize)
+}
+
+// UpdateStatus handles PUT /channels/:id/status
+func (h *ChannelHandler) UpdateStatus(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var body struct {
+		Status string `json:"status" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	allowed := map[string]bool{"pending": true, "scraped": true, "failed": true, "skipped": true}
+	if !allowed[body.Status] {
+		Fail(c, 400, "invalid status")
+		return
+	}
+
+	var ch model.Channel
+	if err := h.store.DB.First(&ch, id).Error; err != nil {
+		Fail(c, 404, "channel not found")
+		return
+	}
+
+	h.store.DB.Model(&ch).Update("status", body.Status)
+	OK(c, ch)
+}
+
+// Delete handles DELETE /channels/:id
+func (h *ChannelHandler) Delete(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+	if err := h.store.DB.Delete(&model.Channel{}, id).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"message": "已删除"})
+}
+
+// Stats handles GET /channels/stats
+func (h *ChannelHandler) Stats(c *gin.Context) {
+	type kv struct {
+		Key   string `gorm:"column:key"`
+		Count int64  `gorm:"column:count"`
+	}
+
+	var byStatus []kv
+	h.store.DB.Model(&model.Channel{}).
+		Select("status as `key`, count(*) as `count`").
+		Group("status").Find(&byStatus)
+
+	var bySource []kv
+	h.store.DB.Model(&model.Channel{}).
+		Select("source as `key`, count(*) as `count`").
+		Group("source").Find(&bySource)
+
+	var total int64
+	h.store.DB.Model(&model.Channel{}).Count(&total)
+
+	OK(c, gin.H{
+		"total":     total,
+		"by_status": byStatus,
+		"by_source": bySource,
+	})
+}

+ 212 - 0
internal/handler/dashboard.go

@@ -0,0 +1,212 @@
+package handler
+
+import (
+	"context"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+	"github.com/redis/go-redis/v9"
+)
+
+// DashboardHandler serves the dashboard summary endpoint.
+type DashboardHandler struct {
+	store *store.Store
+	rdb   *redis.Client
+}
+
+// Get returns aggregated dashboard data.
+func (h *DashboardHandler) Get(c *gin.Context) {
+	db := h.store.DB
+
+	// Total counts (combined query for clean)
+	var rawTotal int64
+	db.Model(&model.MerchantRaw{}).Count(&rawTotal)
+
+	// Single query for clean total and valid total
+	var cleanTotal, validTotal int64
+	db.Model(&model.MerchantClean{}).Count(&cleanTotal)
+	db.Model(&model.MerchantClean{}).Where("status = ?", "valid").Count(&validTotal)
+
+	// By level
+	type kv struct {
+		Key   string `gorm:"column:key"`
+		Count int64  `gorm:"column:count"`
+	}
+	var levelRows []kv
+	db.Model(&model.MerchantClean{}).
+		Select("level as `key`, count(*) as `count`").
+		Group("level").
+		Find(&levelRows)
+	byLevel := gin.H{}
+	for _, r := range levelRows {
+		if r.Key == "" {
+			r.Key = "Unknown"
+		}
+		byLevel[r.Key] = r.Count
+	}
+
+	// By status
+	var statusRows []kv
+	db.Model(&model.MerchantClean{}).
+		Select("status as `key`, count(*) as `count`").
+		Group("status").
+		Find(&statusRows)
+	byStatus := gin.H{}
+	for _, r := range statusRows {
+		byStatus[r.Key] = r.Count
+	}
+
+	// By source
+	var sourceRows []kv
+	db.Model(&model.MerchantRaw{}).
+		Select("source_type as `key`, count(*) as `count`").
+		Group("source_type").
+		Find(&sourceRows)
+	bySource := gin.H{}
+	for _, r := range sourceRows {
+		if r.Key == "" {
+			r.Key = "unknown"
+		}
+		bySource[r.Key] = r.Count
+	}
+
+	// By industry
+	var industryRows []kv
+	db.Model(&model.MerchantClean{}).
+		Select("industry_tag as `key`, count(*) as `count`").
+		Where("industry_tag != ''").
+		Group("industry_tag").
+		Find(&industryRows)
+	byIndustry := gin.H{}
+	for _, r := range industryRows {
+		byIndustry[r.Key] = r.Count
+	}
+
+	// Today added
+	now := time.Now()
+	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+	var todayAdded int64
+	db.Model(&model.MerchantRaw{}).Where("created_at >= ?", todayStart).Count(&todayAdded)
+
+	// Week added
+	weekStart := todayStart.AddDate(0, 0, -int(now.Weekday()))
+	if now.Weekday() == 0 {
+		weekStart = todayStart.AddDate(0, 0, -6)
+	} else {
+		weekStart = todayStart.AddDate(0, 0, -int(now.Weekday())+1)
+	}
+	var weekAdded int64
+	db.Model(&model.MerchantRaw{}).Where("created_at >= ?", weekStart).Count(&weekAdded)
+
+	// Recent tasks (last 5)
+	var recentTasks []model.TaskLog
+	db.Order("created_at DESC").Limit(5).Find(&recentTasks)
+
+	// Daily trend (last 14 days)
+	type trend struct {
+		Date  string `gorm:"column:date" json:"date"`
+		Count int64  `gorm:"column:count" json:"count"`
+	}
+	var dailyTrend []trend
+	fourteenDaysAgo := todayStart.AddDate(0, 0, -13)
+	db.Model(&model.MerchantRaw{}).
+		Select("DATE(created_at) as date, count(*) as `count`").
+		Where("created_at >= ?", fourteenDaysAgo).
+		Group("DATE(created_at)").
+		Order("date ASC").
+		Find(&dailyTrend)
+
+	OK(c, gin.H{
+		"raw_total":    rawTotal,
+		"clean_total":  cleanTotal,
+		"valid_total":  validTotal,
+		"by_level":     byLevel,
+		"by_status":    byStatus,
+		"by_source":    bySource,
+		"by_industry":  byIndustry,
+		"today_added":  todayAdded,
+		"week_added":   weekAdded,
+		"recent_tasks": recentTasks,
+		"daily_trend":  dailyTrend,
+	})
+}
+
+// Health returns system component health status.
+func (h *DashboardHandler) Health(c *gin.Context) {
+	db := h.store.DB
+	health := gin.H{}
+
+	// MySQL
+	sqlDB, err := db.DB()
+	if err != nil {
+		health["mysql"] = gin.H{"status": "error", "error": err.Error()}
+	} else {
+		ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
+		defer cancel()
+		if err := sqlDB.PingContext(ctx); err != nil {
+			health["mysql"] = gin.H{"status": "error", "error": err.Error()}
+		} else {
+			stats := sqlDB.Stats()
+			health["mysql"] = gin.H{
+				"status":       "ok",
+				"open_conns":   stats.OpenConnections,
+				"in_use":       stats.InUse,
+				"idle":         stats.Idle,
+				"max_open":     stats.MaxOpenConnections,
+			}
+		}
+	}
+
+	// Redis
+	ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 3*time.Second)
+	defer cancel2()
+	if err := h.rdb.Ping(ctx2).Err(); err != nil {
+		health["redis"] = gin.H{"status": "error", "error": err.Error()}
+	} else {
+		health["redis"] = gin.H{"status": "ok"}
+	}
+
+	// TG accounts
+	type tgStatus struct {
+		Status string
+		Cnt    int64
+	}
+	var tgRows []tgStatus
+	db.Model(&model.TgAccount{}).Select("status, count(*) as cnt").Group("status").Scan(&tgRows)
+	tgSummary := gin.H{}
+	for _, r := range tgRows {
+		tgSummary[r.Status] = r.Cnt
+	}
+	health["tg_accounts"] = tgSummary
+
+	// Tasks last 24h
+	yesterday := time.Now().Add(-24 * time.Hour)
+	type taskStatus struct {
+		Status string
+		Cnt    int64
+	}
+	var taskRows []taskStatus
+	db.Model(&model.TaskLog{}).Select("status, count(*) as cnt").
+		Where("created_at >= ?", yesterday).Group("status").Scan(&taskRows)
+	taskSummary := gin.H{}
+	for _, r := range taskRows {
+		taskSummary[r.Status] = r.Cnt
+	}
+	health["tasks_24h"] = taskSummary
+
+	// Data counts
+	var rawCount, cleanCount, detailCount int64
+	db.Model(&model.MerchantRaw{}).Count(&rawCount)
+	db.Model(&model.MerchantClean{}).Count(&cleanCount)
+	db.Model(&model.TaskDetail{}).Count(&detailCount)
+	health["data"] = gin.H{
+		"merchants_raw":   rawCount,
+		"merchants_clean": cleanCount,
+		"task_details":    detailCount,
+	}
+
+	OK(c, health)
+}

+ 85 - 0
internal/handler/keyword.go

@@ -1,8 +1,12 @@
 package handler
 
 import (
+	"encoding/csv"
+	"fmt"
+	"io"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"spider/internal/model"
 	"spider/internal/store"
@@ -67,6 +71,7 @@ func (h *KeywordHandler) Create(c *gin.Context) {
 		created = append(created, k)
 	}
 
+	LogAudit(h.store, c, "create", "keyword", "", gin.H{"count": len(created)})
 	OK(c, created)
 }
 
@@ -112,6 +117,7 @@ func (h *KeywordHandler) Update(c *gin.Context) {
 	}
 
 	h.store.DB.First(&kw, id)
+	LogAudit(h.store, c, "update", "keyword", strconv.FormatUint(id, 10), updates)
 	OK(c, kw)
 }
 
@@ -128,5 +134,84 @@ func (h *KeywordHandler) Delete(c *gin.Context) {
 		Fail(c, 500, err.Error())
 		return
 	}
+	LogAudit(h.store, c, "delete", "keyword", strconv.FormatUint(id, 10), nil)
 	OK(c, nil)
 }
+
+// ImportCSV handles POST /keywords/import — import keywords from CSV/TXT file
+func (h *KeywordHandler) ImportCSV(c *gin.Context) {
+	file, header, err := c.Request.FormFile("file")
+	if err != nil {
+		Fail(c, 400, "请上传文件")
+		return
+	}
+	defer file.Close()
+
+	if header.Size > 5<<20 { // 5MB
+		Fail(c, 400, "文件不能超过5MB")
+		return
+	}
+
+	defaultTag := c.PostForm("industry_tag")
+
+	// Read BOM
+	bom := make([]byte, 3)
+	n, _ := file.Read(bom)
+	if n < 3 || bom[0] != 0xEF || bom[1] != 0xBB || bom[2] != 0xBF {
+		file.Seek(0, io.SeekStart)
+	}
+
+	reader := csv.NewReader(file)
+	reader.FieldsPerRecord = -1 // variable fields
+
+	var imported, skipped int
+	var errors []string
+	rowNum := 0
+
+	for {
+		row, err := reader.Read()
+		if err == io.EOF {
+			break
+		}
+		rowNum++
+		if err != nil {
+			errors = append(errors, fmt.Sprintf("行 %d: 读取错误", rowNum))
+			continue
+		}
+
+		// First column is keyword, optional second column is industry_tag
+		if len(row) == 0 || strings.TrimSpace(row[0]) == "" {
+			continue
+		}
+
+		keyword := strings.TrimSpace(row[0])
+		// Skip header row
+		if rowNum == 1 && (strings.EqualFold(keyword, "keyword") || keyword == "关键词") {
+			continue
+		}
+
+		tag := defaultTag
+		if len(row) > 1 && strings.TrimSpace(row[1]) != "" {
+			tag = strings.TrimSpace(row[1])
+		}
+
+		kw := model.Keyword{
+			Keyword:     keyword,
+			IndustryTag: tag,
+			Enabled:     true,
+		}
+		result := h.store.DB.Where(model.Keyword{Keyword: keyword}).FirstOrCreate(&kw)
+		if result.RowsAffected > 0 {
+			imported++
+		} else {
+			skipped++
+		}
+	}
+
+	LogAudit(h.store, c, "import", "keyword", "", gin.H{"imported": imported, "skipped": skipped})
+	OK(c, gin.H{
+		"imported": imported,
+		"skipped":  skipped,
+		"errors":   errors,
+	})
+}

+ 802 - 7
internal/handler/merchant.go

@@ -2,9 +2,14 @@ package handler
 
 import (
 	"encoding/csv"
+	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"strconv"
+	"strings"
+	"sync"
+	"time"
 
 	"spider/internal/model"
 	"spider/internal/store"
@@ -14,11 +19,51 @@ import (
 
 // MerchantHandler handles merchant queries.
 type MerchantHandler struct {
-	store *store.Store
+	store      *store.Store
+	statsCache *statsCache
 }
 
-// Stats returns aggregate statistics for merchants.
+// statsCache provides a simple time-based cache for stats.
+type statsCache struct {
+	mu        sync.Mutex
+	data      []byte
+	expiresAt time.Time
+}
+
+func (sc *statsCache) get() (gin.H, bool) {
+	if sc == nil {
+		return nil, false
+	}
+	sc.mu.Lock()
+	defer sc.mu.Unlock()
+	if time.Now().Before(sc.expiresAt) && sc.data != nil {
+		var result gin.H
+		json.Unmarshal(sc.data, &result)
+		return result, true
+	}
+	return nil, false
+}
+
+func (sc *statsCache) set(data gin.H) {
+	if sc == nil {
+		return
+	}
+	sc.mu.Lock()
+	defer sc.mu.Unlock()
+	sc.data, _ = json.Marshal(data)
+	sc.expiresAt = time.Now().Add(30 * time.Second)
+}
+
+// Stats returns aggregate statistics for merchants (cached 30s).
 func (h *MerchantHandler) Stats(c *gin.Context) {
+	if h.statsCache == nil {
+		h.statsCache = &statsCache{}
+	}
+	if cached, ok := h.statsCache.get(); ok {
+		OK(c, cached)
+		return
+	}
+
 	var rawTotal int64
 	h.store.DB.Model(&model.MerchantRaw{}).Count(&rawTotal)
 
@@ -67,13 +112,15 @@ func (h *MerchantHandler) Stats(c *gin.Context) {
 		bySource[r.SourceType] = r.Cnt
 	}
 
-	OK(c, gin.H{
+	result := gin.H{
 		"raw_total":   rawTotal,
 		"clean_total": cleanTotal,
 		"by_status":   byStatus,
 		"by_level":    byLevel,
 		"by_source":   bySource,
-	})
+	}
+	h.statsCache.set(result)
+	OK(c, result)
 }
 
 // ListRaw returns raw merchants with filters and pagination.
@@ -118,10 +165,23 @@ func (h *MerchantHandler) ListClean(c *gin.Context) {
 	if industry := c.Query("industry_tag"); industry != "" {
 		query = query.Where("industry_tag = ?", industry)
 	}
+	if followStatus := c.Query("follow_status"); followStatus != "" {
+		query = query.Where("follow_status = ?", followStatus)
+	}
+	if assignedTo := c.Query("assigned_to"); assignedTo != "" {
+		if assignedTo == "__unassigned__" {
+			query = query.Where("assigned_to = '' OR assigned_to IS NULL")
+		} else {
+			query = query.Where("assigned_to = ?", assignedTo)
+		}
+	}
 	if search := c.Query("search"); search != "" {
 		like := "%" + search + "%"
 		query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
 	}
+	if hasContact := c.Query("has_contact"); hasContact == "1" {
+		query = query.Where("website != '' OR email != '' OR phone != ''")
+	}
 
 	sortField := c.DefaultQuery("sort", "created_at")
 	allowedSort := map[string]bool{
@@ -152,13 +212,39 @@ func (h *MerchantHandler) ListClean(c *gin.Context) {
 
 // ExportCSV exports clean merchants as CSV.
 func (h *MerchantHandler) ExportCSV(c *gin.Context) {
-	query := h.store.DB.Model(&model.MerchantClean{}).Where("status = ?", "valid")
+	query := h.store.DB.Model(&model.MerchantClean{})
+	if status := c.Query("status"); status != "" {
+		query = query.Where("status = ?", status)
+	} else {
+		query = query.Where("status = ?", "valid")
+	}
 	if level := c.Query("level"); level != "" {
 		query = query.Where("level = ?", level)
 	}
+	if followStatus := c.Query("follow_status"); followStatus != "" {
+		query = query.Where("follow_status = ?", followStatus)
+	}
+	if assignedTo := c.Query("assigned_to"); assignedTo != "" {
+		if assignedTo == "__unassigned__" {
+			query = query.Where("assigned_to = '' OR assigned_to IS NULL")
+		} else {
+			query = query.Where("assigned_to = ?", assignedTo)
+		}
+	}
+	if industryTag := c.Query("industry_tag"); industryTag != "" {
+		query = query.Where("industry_tag = ?", industryTag)
+	}
+	if search := c.Query("search"); search != "" {
+		like := "%" + search + "%"
+		query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
+	}
+	if hasContact := c.Query("has_contact"); hasContact == "1" {
+		query = query.Where("website != '' OR email != '' OR phone != ''")
+	}
 
+	// Cap export at 50000 rows to prevent OOM
 	var merchants []model.MerchantClean
-	query.Order("level ASC, created_at DESC").Find(&merchants)
+	query.Order("level ASC, created_at DESC").Limit(50000).Find(&merchants)
 
 	c.Header("Content-Type", "text/csv; charset=utf-8")
 	c.Header("Content-Disposition", "attachment; filename=merchants.csv")
@@ -167,7 +253,7 @@ func (h *MerchantHandler) ExportCSV(c *gin.Context) {
 	c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
 
 	w := csv.NewWriter(c.Writer)
-	w.Write([]string{"商户名", "TG用户名", "TG链接", "网站", "邮箱", "电话", "行业", "等级", "来源数"})
+	w.Write([]string{"商户名", "TG用户名", "TG链接", "网站", "邮箱", "电话", "行业", "等级", "跟进状态", "负责人", "来源数", "备注"})
 
 	for _, m := range merchants {
 		w.Write([]string{
@@ -179,13 +265,49 @@ func (h *MerchantHandler) ExportCSV(c *gin.Context) {
 			m.Phone,
 			m.IndustryTag,
 			m.Level,
+			m.FollowStatus,
+			m.AssignedTo,
 			fmt.Sprintf("%d", m.SourceCount),
+			m.Remark,
 		})
 	}
 
 	w.Flush()
 }
 
+// BatchDeleteRaw handles DELETE /merchants/raw/batch
+func (h *MerchantHandler) BatchDeleteRaw(c *gin.Context) {
+	var body struct {
+		IDs []uint `json:"ids" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantRaw{}).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"deleted": len(body.IDs)})
+}
+
+// BatchDeleteClean handles DELETE /merchants/clean/batch
+func (h *MerchantHandler) BatchDeleteClean(c *gin.Context) {
+	var body struct {
+		IDs []uint `json:"ids" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantClean{}).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "delete", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), gin.H{"ids": body.IDs})
+	OK(c, gin.H{"deleted": len(body.IDs)})
+}
+
 // GetByID fetches a merchant by ID.
 func (h *MerchantHandler) GetByID(c *gin.Context) {
 	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -208,3 +330,676 @@ func (h *MerchantHandler) GetByID(c *gin.Context) {
 
 	Fail(c, 404, "merchant not found")
 }
+
+// UpdateClean handles PUT /merchants/clean/:id
+func (h *MerchantHandler) UpdateClean(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var body struct {
+		MerchantName *string `json:"merchant_name"`
+		IndustryTag  *string `json:"industry_tag"`
+		Website      *string `json:"website"`
+		Email        *string `json:"email"`
+		Phone        *string `json:"phone"`
+		Remark       *string `json:"remark"`
+		AssignedTo   *string `json:"assigned_to"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	updates := map[string]interface{}{}
+	if body.MerchantName != nil {
+		updates["merchant_name"] = *body.MerchantName
+	}
+	if body.IndustryTag != nil {
+		updates["industry_tag"] = *body.IndustryTag
+	}
+	if body.Website != nil {
+		updates["website"] = *body.Website
+	}
+	if body.Email != nil {
+		updates["email"] = *body.Email
+	}
+	if body.Phone != nil {
+		updates["phone"] = *body.Phone
+	}
+	if body.Remark != nil {
+		updates["remark"] = *body.Remark
+	}
+	if body.AssignedTo != nil {
+		updates["assigned_to"] = *body.AssignedTo
+	}
+
+	if len(updates) == 0 {
+		Fail(c, 400, "no fields to update")
+		return
+	}
+
+	var merchant model.MerchantClean
+	if err := h.store.DB.First(&merchant, id).Error; err != nil {
+		Fail(c, 404, "merchant not found")
+		return
+	}
+
+	if err := h.store.DB.Model(&merchant).Updates(updates).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	h.store.DB.First(&merchant, id)
+	LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), updates)
+	OK(c, merchant)
+}
+
+// UpdateFollowStatus handles PUT /merchants/clean/:id/follow-status
+func (h *MerchantHandler) UpdateFollowStatus(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var body struct {
+		FollowStatus string `json:"follow_status" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true}
+	if !allowed[body.FollowStatus] {
+		Fail(c, 400, "invalid follow_status")
+		return
+	}
+
+	var merchant model.MerchantClean
+	if err := h.store.DB.First(&merchant, id).Error; err != nil {
+		Fail(c, 404, "merchant not found")
+		return
+	}
+
+	if err := h.store.DB.Model(&merchant).Update("follow_status", body.FollowStatus).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), gin.H{"follow_status": body.FollowStatus})
+	OK(c, nil)
+}
+
+// ListNotes handles GET /merchants/clean/:id/notes
+func (h *MerchantHandler) ListNotes(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var notes []model.MerchantNote
+	if err := h.store.DB.Where("merchant_id = ?", id).Order("created_at DESC").Find(&notes).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	OK(c, notes)
+}
+
+// AssignMerchant handles PUT /merchants/clean/:id/assign
+func (h *MerchantHandler) AssignMerchant(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var body struct {
+		AssignedTo string `json:"assigned_to"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	var merchant model.MerchantClean
+	if err := h.store.DB.First(&merchant, id).Error; err != nil {
+		Fail(c, 404, "merchant not found")
+		return
+	}
+
+	if err := h.store.DB.Model(&merchant).Update("assigned_to", body.AssignedTo).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	h.store.DB.First(&merchant, id)
+	LogAudit(h.store, c, "assign", "merchant", fmt.Sprintf("%d", id), gin.H{"assigned_to": body.AssignedTo})
+	OK(c, merchant)
+}
+
+// BatchAssign handles PUT /merchants/clean/batch-assign
+func (h *MerchantHandler) BatchAssign(c *gin.Context) {
+	var body struct {
+		IDs        []uint `json:"ids" binding:"required"`
+		AssignedTo string `json:"assigned_to"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
+		Update("assigned_to", body.AssignedTo).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"updated": len(body.IDs)})
+}
+
+// BatchFollowStatus handles PUT /merchants/clean/batch-follow-status
+func (h *MerchantHandler) BatchFollowStatus(c *gin.Context) {
+	var body struct {
+		IDs          []uint `json:"ids" binding:"required"`
+		FollowStatus string `json:"follow_status" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true}
+	if !allowed[body.FollowStatus] {
+		Fail(c, 400, "invalid follow_status")
+		return
+	}
+	if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
+		Update("follow_status", body.FollowStatus).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"updated": len(body.IDs)})
+}
+
+// BatchLevel handles PUT /merchants/clean/batch-level
+func (h *MerchantHandler) BatchLevel(c *gin.Context) {
+	var body struct {
+		IDs   []uint `json:"ids" binding:"required"`
+		Level string `json:"level" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
+		Update("level", body.Level).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"updated": len(body.IDs)})
+}
+
+// ImportCSV handles POST /merchants/clean/import
+func (h *MerchantHandler) ImportCSV(c *gin.Context) {
+	file, header, err := c.Request.FormFile("file")
+	if err != nil {
+		Fail(c, 400, "请上传CSV文件")
+		return
+	}
+	defer file.Close()
+
+	// File size limit (10MB default)
+	maxSize := int64(10 << 20)
+	if header.Size > maxSize {
+		Fail(c, 400, "文件大小不能超过10MB")
+		return
+	}
+
+	// Validate file extension
+	if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") {
+		Fail(c, 400, "仅支持CSV格式文件")
+		return
+	}
+
+	// Read BOM if present
+	bom := make([]byte, 3)
+	n, _ := file.Read(bom)
+	if n < 3 || bom[0] != 0xEF || bom[1] != 0xBB || bom[2] != 0xBF {
+		// Not BOM, seek back
+		file.Seek(0, io.SeekStart)
+	}
+
+	reader := csv.NewReader(file)
+	headers, err := reader.Read()
+	if err != nil {
+		Fail(c, 400, "无法读取CSV头部")
+		return
+	}
+
+	// Map column indices
+	colMap := map[string]int{}
+	for i, h := range headers {
+		colMap[strings.TrimSpace(strings.ToLower(h))] = i
+	}
+	// Accept both English and Chinese headers
+	headerAliases := map[string][]string{
+		"tg_username":   {"tg_username", "tg用户名", "username"},
+		"merchant_name": {"merchant_name", "商户名", "name"},
+		"website":       {"website", "网站"},
+		"email":         {"email", "邮箱"},
+		"phone":         {"phone", "电话"},
+		"industry_tag":  {"industry_tag", "行业", "industry"},
+		"level":         {"level", "等级"},
+	}
+
+	getCol := func(field string) int {
+		for _, alias := range headerAliases[field] {
+			if idx, ok := colMap[alias]; ok {
+				return idx
+			}
+		}
+		return -1
+	}
+
+	tgIdx := getCol("tg_username")
+	if tgIdx < 0 {
+		Fail(c, 400, "CSV必须包含 tg_username 列")
+		return
+	}
+
+	nameIdx := getCol("merchant_name")
+	websiteIdx := getCol("website")
+	emailIdx := getCol("email")
+	phoneIdx := getCol("phone")
+	industryIdx := getCol("industry_tag")
+	levelIdx := getCol("level")
+
+	getField := func(row []string, idx int) string {
+		if idx >= 0 && idx < len(row) {
+			return strings.TrimSpace(row[idx])
+		}
+		return ""
+	}
+
+	var imported, skipped, failed int
+	var errors []string
+	rowNum := 1
+
+	for {
+		row, err := reader.Read()
+		if err == io.EOF {
+			break
+		}
+		rowNum++
+		if err != nil {
+			failed++
+			errors = append(errors, fmt.Sprintf("行 %d: 读取错误", rowNum))
+			continue
+		}
+
+		tgUsername := strings.TrimPrefix(strings.TrimSpace(getField(row, tgIdx)), "@")
+		if tgUsername == "" {
+			failed++
+			errors = append(errors, fmt.Sprintf("行 %d: tg_username 为空", rowNum))
+			continue
+		}
+
+		// Check duplicate
+		var count int64
+		h.store.DB.Model(&model.MerchantClean{}).Where("tg_username = ?", tgUsername).Count(&count)
+		if count > 0 {
+			skipped++
+			continue
+		}
+
+		level := getField(row, levelIdx)
+		if level == "" {
+			level = "Cold"
+		}
+
+		merchant := model.MerchantClean{
+			TgUsername:    tgUsername,
+			TgLink:       "https://t.me/" + tgUsername,
+			MerchantName: getField(row, nameIdx),
+			Website:      getField(row, websiteIdx),
+			Email:        getField(row, emailIdx),
+			Phone:        getField(row, phoneIdx),
+			IndustryTag:  getField(row, industryIdx),
+			Level:        level,
+			Status:       "valid",
+			FollowStatus: "pending",
+			SourceCount:  1,
+		}
+
+		if err := h.store.DB.Create(&merchant).Error; err != nil {
+			failed++
+			errors = append(errors, fmt.Sprintf("行 %d: %s", rowNum, err.Error()))
+			continue
+		}
+		imported++
+	}
+
+	LogAudit(h.store, c, "import", "merchant", "", gin.H{"imported": imported, "skipped": skipped, "failed": failed})
+	OK(c, gin.H{
+		"imported": imported,
+		"skipped":  skipped,
+		"failed":   failed,
+		"errors":   errors,
+	})
+}
+
+// ArchiveMerchants handles POST /merchants/archive
+func (h *MerchantHandler) ArchiveMerchants(c *gin.Context) {
+	var body struct {
+		MaxDaysInvalid  int `json:"max_days_invalid"`  // default 90
+		MaxDaysRejected int `json:"max_days_rejected"` // default 180
+	}
+	c.ShouldBindJSON(&body)
+	if body.MaxDaysInvalid <= 0 {
+		body.MaxDaysInvalid = 90
+	}
+	if body.MaxDaysRejected <= 0 {
+		body.MaxDaysRejected = 180
+	}
+
+	now := time.Now()
+	invalidCutoff := now.AddDate(0, 0, -body.MaxDaysInvalid)
+	rejectedCutoff := now.AddDate(0, 0, -body.MaxDaysRejected)
+
+	var merchants []model.MerchantClean
+	h.store.DB.Where(
+		"(status IN ? AND updated_at < ?) OR (follow_status = ? AND updated_at < ?)",
+		[]string{"invalid", "bot"}, invalidCutoff,
+		"rejected", rejectedCutoff,
+	).Find(&merchants)
+
+	archived := 0
+	for _, m := range merchants {
+		reason := "status:" + m.Status
+		if m.FollowStatus == "rejected" {
+			reason = "follow_status:rejected"
+		}
+
+		arch := model.MerchantArchived{
+			OriginalID:    m.ID,
+			TgUsername:    m.TgUsername,
+			TgLink:       m.TgLink,
+			MerchantName: m.MerchantName,
+			Website:      m.Website,
+			Email:        m.Email,
+			Phone:        m.Phone,
+			SourceCount:  m.SourceCount,
+			AllSources:   m.AllSources,
+			IndustryTag:  m.IndustryTag,
+			Level:        m.Level,
+			Status:       m.Status,
+			FollowStatus: m.FollowStatus,
+			AssignedTo:   m.AssignedTo,
+			Remark:       m.Remark,
+			IsAlive:      m.IsAlive,
+			ArchiveReason: reason,
+			ArchivedAt:   now,
+			CreatedAt:    m.CreatedAt,
+		}
+		if err := h.store.DB.Create(&arch).Error; err == nil {
+			h.store.DB.Delete(&m)
+			archived++
+		}
+	}
+
+	LogAudit(h.store, c, "archive", "merchant", fmt.Sprintf("batch:%d", archived), gin.H{"archived": archived})
+	OK(c, gin.H{"archived": archived, "candidates": len(merchants)})
+}
+
+// ListArchived handles GET /merchants/archived
+func (h *MerchantHandler) ListArchived(c *gin.Context) {
+	page, pageSize, offset := parsePage(c)
+
+	query := h.store.DB.Model(&model.MerchantArchived{})
+	if search := c.Query("search"); search != "" {
+		like := "%" + search + "%"
+		query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
+	}
+
+	var total int64
+	query.Count(&total)
+
+	var items []model.MerchantArchived
+	if err := query.Order("archived_at DESC").Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, items, total, page, pageSize)
+}
+
+// RestoreArchived handles POST /merchants/archived/:id/restore
+func (h *MerchantHandler) RestoreArchived(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var arch model.MerchantArchived
+	if err := h.store.DB.First(&arch, id).Error; err != nil {
+		Fail(c, 404, "not found")
+		return
+	}
+
+	merchant := model.MerchantClean{
+		TgUsername:    arch.TgUsername,
+		TgLink:       arch.TgLink,
+		MerchantName: arch.MerchantName,
+		Website:      arch.Website,
+		Email:        arch.Email,
+		Phone:        arch.Phone,
+		SourceCount:  arch.SourceCount,
+		AllSources:   arch.AllSources,
+		IndustryTag:  arch.IndustryTag,
+		Level:        arch.Level,
+		Status:       "valid",
+		FollowStatus: "pending",
+		AssignedTo:   arch.AssignedTo,
+		Remark:       arch.Remark,
+		IsAlive:      arch.IsAlive,
+	}
+
+	if err := h.store.DB.Create(&merchant).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	h.store.DB.Delete(&arch)
+	LogAudit(h.store, c, "restore", "merchant", fmt.Sprintf("%d", merchant.ID), gin.H{"from_archive": id})
+	OK(c, merchant)
+}
+
+// RecheckMerchant handles POST /merchants/clean/:id/recheck — re-checks t.me status
+func (h *MerchantHandler) RecheckMerchant(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var merchant model.MerchantClean
+	if err := h.store.DB.First(&merchant, id).Error; err != nil {
+		Fail(c, 404, "merchant not found")
+		return
+	}
+
+	if merchant.TgUsername == "" {
+		Fail(c, 400, "商户无TG用户名")
+		return
+	}
+
+	// Mark as checking, update last_checked_at
+	now := time.Now()
+	h.store.DB.Model(&merchant).Updates(map[string]interface{}{
+		"last_checked_at": now,
+	})
+
+	// Return immediately — actual t.me check would need TG client
+	// For now we update the timestamp and let the next clean task verify
+	LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("%d", id), gin.H{"tg_username": merchant.TgUsername})
+	OK(c, gin.H{"message": "已标记为待重新检查", "merchant_id": id})
+}
+
+// BatchRecheck handles POST /merchants/clean/batch-recheck — batch re-check
+func (h *MerchantHandler) BatchRecheck(c *gin.Context) {
+	var body struct {
+		IDs []uint `json:"ids" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	now := time.Now()
+	h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
+		Updates(map[string]interface{}{"last_checked_at": now})
+
+	LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), nil)
+	OK(c, gin.H{"updated": len(body.IDs)})
+}
+
+// MergeMerchants handles POST /merchants/clean/merge — merges secondary into primary
+func (h *MerchantHandler) MergeMerchants(c *gin.Context) {
+	var body struct {
+		PrimaryID   uint `json:"primary_id" binding:"required"`
+		SecondaryID uint `json:"secondary_id" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+	if body.PrimaryID == body.SecondaryID {
+		Fail(c, 400, "不能合并同一个商户")
+		return
+	}
+
+	var primary, secondary model.MerchantClean
+	if err := h.store.DB.First(&primary, body.PrimaryID).Error; err != nil {
+		Fail(c, 404, "主商户不存在")
+		return
+	}
+	if err := h.store.DB.First(&secondary, body.SecondaryID).Error; err != nil {
+		Fail(c, 404, "副商户不存在")
+		return
+	}
+
+	// Fill empty fields from secondary
+	if primary.MerchantName == "" && secondary.MerchantName != "" {
+		primary.MerchantName = secondary.MerchantName
+	}
+	if primary.Website == "" && secondary.Website != "" {
+		primary.Website = secondary.Website
+	}
+	if primary.Email == "" && secondary.Email != "" {
+		primary.Email = secondary.Email
+	}
+	if primary.Phone == "" && secondary.Phone != "" {
+		primary.Phone = secondary.Phone
+	}
+	if primary.IndustryTag == "" && secondary.IndustryTag != "" {
+		primary.IndustryTag = secondary.IndustryTag
+	}
+
+	// Merge sources
+	var primarySources, secondarySources []map[string]string
+	json.Unmarshal(primary.AllSources, &primarySources)
+	json.Unmarshal(secondary.AllSources, &secondarySources)
+	allSources := append(primarySources, secondarySources...)
+	sourcesJSON, _ := json.Marshal(allSources)
+	primary.AllSources = sourcesJSON
+	primary.SourceCount = len(allSources)
+
+	// Use the better level
+	levelRank := map[string]int{"Hot": 3, "Warm": 2, "Cold": 1}
+	if levelRank[secondary.Level] > levelRank[primary.Level] {
+		primary.Level = secondary.Level
+	}
+
+	// Save primary
+	if err := h.store.DB.Save(&primary).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	// Transfer notes from secondary to primary
+	h.store.DB.Model(&model.MerchantNote{}).
+		Where("merchant_id = ?", secondary.ID).
+		Update("merchant_id", primary.ID)
+
+	// Delete secondary
+	h.store.DB.Delete(&secondary)
+
+	LogAudit(h.store, c, "merge", "merchant",
+		fmt.Sprintf("%d←%d", primary.ID, secondary.ID),
+		gin.H{"primary": primary.ID, "secondary": secondary.ID})
+
+	OK(c, primary)
+}
+
+// ListIndustryTags handles GET /merchants/clean/industry-tags — returns distinct industry tags
+func (h *MerchantHandler) ListIndustryTags(c *gin.Context) {
+	var tags []string
+	h.store.DB.Model(&model.MerchantClean{}).
+		Where("industry_tag != ''").
+		Distinct("industry_tag").
+		Pluck("industry_tag", &tags)
+	OK(c, tags)
+}
+
+// ListUsers handles GET /merchants/clean/users — returns operator/admin usernames for assignment dropdown
+func (h *MerchantHandler) ListUsers(c *gin.Context) {
+	var users []struct {
+		Username string `json:"username"`
+		Nickname string `json:"nickname"`
+	}
+	h.store.DB.Model(&model.User{}).
+		Where("enabled = ? AND role IN ?", true, []string{"admin", "operator"}).
+		Select("username, nickname").
+		Find(&users)
+	OK(c, users)
+}
+
+// AddNote handles POST /merchants/clean/:id/notes
+func (h *MerchantHandler) AddNote(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var body struct {
+		Content string `json:"content" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	// Look up the merchant to get tg_username
+	var merchant model.MerchantClean
+	if err := h.store.DB.First(&merchant, id).Error; err != nil {
+		Fail(c, 404, "merchant not found")
+		return
+	}
+
+	note := model.MerchantNote{
+		MerchantID: uint(id),
+		TgUsername: merchant.TgUsername,
+		Content:    body.Content,
+		CreatedBy:  c.GetString("username"),
+	}
+
+	if err := h.store.DB.Create(&note).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	OK(c, note)
+}

+ 142 - 0
internal/handler/notification.go

@@ -0,0 +1,142 @@
+package handler
+
+import (
+	"encoding/json"
+	"strconv"
+
+	"spider/internal/model"
+	"spider/internal/notification"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+	"gorm.io/datatypes"
+)
+
+// NotificationHandler handles notification config CRUD.
+type NotificationHandler struct {
+	store   *store.Store
+	manager *notification.Manager
+}
+
+// List handles GET /notification-configs
+func (h *NotificationHandler) List(c *gin.Context) {
+	var configs []model.NotificationConfig
+	if err := h.store.DB.Order("created_at DESC").Find(&configs).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, configs)
+}
+
+// Create handles POST /notification-configs
+func (h *NotificationHandler) Create(c *gin.Context) {
+	var body struct {
+		Name      string            `json:"name" binding:"required"`
+		EventType string            `json:"event_type" binding:"required"`
+		Channel   string            `json:"channel" binding:"required"`
+		Config    map[string]string `json:"config"`
+		Enabled   bool              `json:"enabled"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	configJSON, _ := json.Marshal(body.Config)
+	cfg := model.NotificationConfig{
+		Name:      body.Name,
+		EventType: body.EventType,
+		Channel:   body.Channel,
+		Config:    datatypes.JSON(configJSON),
+		Enabled:   body.Enabled,
+	}
+
+	if err := h.store.DB.Create(&cfg).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "create", "notification", strconv.Itoa(int(cfg.ID)), gin.H{"name": cfg.Name})
+	OK(c, cfg)
+}
+
+// Update handles PUT /notification-configs/:id
+func (h *NotificationHandler) Update(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var existing model.NotificationConfig
+	if err := h.store.DB.First(&existing, id).Error; err != nil {
+		Fail(c, 404, "not found")
+		return
+	}
+
+	var body struct {
+		Name      *string            `json:"name"`
+		EventType *string            `json:"event_type"`
+		Channel   *string            `json:"channel"`
+		Config    *map[string]string `json:"config"`
+		Enabled   *bool              `json:"enabled"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	updates := map[string]any{}
+	if body.Name != nil {
+		updates["name"] = *body.Name
+	}
+	if body.EventType != nil {
+		updates["event_type"] = *body.EventType
+	}
+	if body.Channel != nil {
+		updates["channel"] = *body.Channel
+	}
+	if body.Config != nil {
+		configJSON, _ := json.Marshal(*body.Config)
+		updates["config"] = datatypes.JSON(configJSON)
+	}
+	if body.Enabled != nil {
+		updates["enabled"] = *body.Enabled
+	}
+
+	h.store.DB.Model(&existing).Updates(updates)
+	h.store.DB.First(&existing, id)
+	OK(c, existing)
+}
+
+// Delete handles DELETE /notification-configs/:id
+func (h *NotificationHandler) Delete(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+	if err := h.store.DB.Delete(&model.NotificationConfig{}, id).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"message": "已删除"})
+}
+
+// Test handles POST /notification-configs/:id/test
+func (h *NotificationHandler) Test(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+	var cfg model.NotificationConfig
+	if err := h.store.DB.First(&cfg, id).Error; err != nil {
+		Fail(c, 404, "not found")
+		return
+	}
+	if err := h.manager.SendTest(cfg); err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, gin.H{"message": "测试通知已发送"})
+}

+ 132 - 0
internal/handler/permission.go

@@ -0,0 +1,132 @@
+package handler
+
+import (
+	"strings"
+
+	"spider/internal/model"
+	"spider/internal/store"
+
+	"github.com/gin-gonic/gin"
+)
+
+// PermissionHandler handles role permission configuration.
+type PermissionHandler struct {
+	store *store.Store
+}
+
+// ListAll handles GET /permissions — returns all role permissions + available keys
+func (h *PermissionHandler) ListAll(c *gin.Context) {
+	var perms []model.RolePermission
+	h.store.DB.Order("role ASC").Find(&perms)
+
+	OK(c, gin.H{
+		"roles":        perms,
+		"all_menus":    model.AllMenuKeys(),
+		"all_actions":  model.AllActionKeys(),
+	})
+}
+
+// Update handles PUT /permissions/:role — update permissions for a role
+func (h *PermissionHandler) Update(c *gin.Context) {
+	role := c.Param("role")
+	if role == "" {
+		Fail(c, 400, "role is required")
+		return
+	}
+
+	var body struct {
+		Menus   []string `json:"menus"`
+		Actions []string `json:"actions"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	menusStr := strings.Join(body.Menus, ",")
+	actionsStr := strings.Join(body.Actions, ",")
+
+	var perm model.RolePermission
+	result := h.store.DB.Where("role = ?", role).First(&perm)
+	if result.Error != nil {
+		// Create new
+		perm = model.RolePermission{
+			Role:    role,
+			Menus:   menusStr,
+			Actions: actionsStr,
+		}
+		h.store.DB.Create(&perm)
+	} else {
+		h.store.DB.Model(&perm).Updates(map[string]any{
+			"menus":   menusStr,
+			"actions": actionsStr,
+		})
+		h.store.DB.First(&perm, perm.ID)
+	}
+
+	LogAudit(h.store, c, "update", "permission", role, gin.H{"menus": body.Menus, "actions": body.Actions})
+	OK(c, perm)
+}
+
+// Reset handles POST /permissions/reset — restore default permissions for all built-in roles
+func (h *PermissionHandler) Reset(c *gin.Context) {
+	defaults := model.DefaultPermissions()
+	for role, perm := range defaults {
+		var existing model.RolePermission
+		result := h.store.DB.Where("role = ?", role).First(&existing)
+		if result.Error != nil {
+			h.store.DB.Create(&model.RolePermission{
+				Role:    role,
+				Menus:   perm.Menus,
+				Actions: perm.Actions,
+			})
+		} else {
+			h.store.DB.Model(&existing).Updates(map[string]any{
+				"menus":   perm.Menus,
+				"actions": perm.Actions,
+			})
+		}
+	}
+	LogAudit(h.store, c, "update", "permission", "all", gin.H{"action": "reset"})
+
+	var perms []model.RolePermission
+	h.store.DB.Order("role ASC").Find(&perms)
+	OK(c, perms)
+}
+
+// GetMyPermissions handles GET /auth/permissions — returns current user's permissions
+func (h *PermissionHandler) GetMyPermissions(c *gin.Context) {
+	role := c.GetString("role")
+
+	var perm model.RolePermission
+	if err := h.store.DB.Where("role = ?", role).First(&perm).Error; err != nil {
+		// Fallback to defaults
+		defaults := model.DefaultPermissions()
+		if d, ok := defaults[role]; ok {
+			OK(c, gin.H{
+				"role":    role,
+				"menus":   strings.Split(d.Menus, ","),
+				"actions": strings.Split(d.Actions, ","),
+			})
+			return
+		}
+		// Unknown role, return empty
+		OK(c, gin.H{"role": role, "menus": []string{}, "actions": []string{}})
+		return
+	}
+
+	menus := []string{}
+	if perm.Menus != "" {
+		menus = strings.Split(perm.Menus, ",")
+	}
+	actions := []string{}
+	if perm.Actions != "" {
+		actions = strings.Split(perm.Actions, ",")
+	}
+
+	OK(c, gin.H{
+		"role":    role,
+		"menus":   menus,
+		"actions": actions,
+	})
+}

+ 385 - 0
internal/handler/proxy.go

@@ -0,0 +1,385 @@
+package handler
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"sync"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+	"spider/internal/task"
+
+	"github.com/gin-gonic/gin"
+	"golang.org/x/net/proxy"
+)
+
+// ProxyHandler handles proxy CRUD and testing.
+type ProxyHandler struct {
+	store   *store.Store
+	taskMgr *task.Manager
+}
+
+// List handles GET /proxies
+func (h *ProxyHandler) List(c *gin.Context) {
+	page, pageSize, offset := parsePage(c)
+
+	query := h.store.DB.Model(&model.Proxy{})
+	if status := c.Query("status"); status != "" {
+		query = query.Where("status = ?", status)
+	}
+	if enabled := c.Query("enabled"); enabled != "" {
+		query = query.Where("enabled = ?", enabled == "true")
+	}
+	if search := c.Query("search"); search != "" {
+		like := "%" + search + "%"
+		query = query.Where("name LIKE ? OR host LIKE ? OR region LIKE ?", like, like, like)
+	}
+
+	var total int64
+	query.Count(&total)
+
+	var proxies []model.Proxy
+	if err := query.Order("id DESC").Limit(pageSize).Offset(offset).Find(&proxies).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, proxies, total, page, pageSize)
+}
+
+// ListEnabled handles GET /proxies/enabled — returns only enabled proxies (for task dropdown)
+func (h *ProxyHandler) ListEnabled(c *gin.Context) {
+	var proxies []model.Proxy
+	h.store.DB.Where("enabled = ?", true).Order("name ASC").Find(&proxies)
+	OK(c, proxies)
+}
+
+// Create handles POST /proxies
+func (h *ProxyHandler) Create(c *gin.Context) {
+	var body struct {
+		Name     string `json:"name" binding:"required"`
+		Protocol string `json:"protocol" binding:"required"`
+		Host     string `json:"host" binding:"required"`
+		Port     int    `json:"port" binding:"required"`
+		Username string `json:"username"`
+		Password string `json:"password"`
+		Region   string `json:"region"`
+		Remark   string `json:"remark"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	allowed := map[string]bool{"http": true, "https": true, "socks5": true}
+	if !allowed[body.Protocol] {
+		Fail(c, 400, "协议必须是 http/https/socks5")
+		return
+	}
+
+	p := model.Proxy{
+		Name:     body.Name,
+		Protocol: body.Protocol,
+		Host:     body.Host,
+		Port:     body.Port,
+		Username: body.Username,
+		Password: body.Password,
+		Region:   body.Region,
+		Remark:   body.Remark,
+		Enabled:  true,
+		Status:   "unknown",
+	}
+	if err := h.store.DB.Create(&p).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "create", "proxy", fmt.Sprintf("%d", p.ID), gin.H{"name": p.Name})
+	OK(c, p)
+}
+
+// Update handles PUT /proxies/:id
+func (h *ProxyHandler) Update(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var p model.Proxy
+	if err := h.store.DB.First(&p, id).Error; err != nil {
+		Fail(c, 404, "代理不存在")
+		return
+	}
+
+	var body struct {
+		Name     *string `json:"name"`
+		Protocol *string `json:"protocol"`
+		Host     *string `json:"host"`
+		Port     *int    `json:"port"`
+		Username *string `json:"username"`
+		Password *string `json:"password"`
+		Region   *string `json:"region"`
+		Remark   *string `json:"remark"`
+		Enabled  *bool   `json:"enabled"`
+	}
+	if err := c.ShouldBindJSON(&body); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	updates := map[string]any{}
+	if body.Name != nil {
+		updates["name"] = *body.Name
+	}
+	if body.Protocol != nil {
+		updates["protocol"] = *body.Protocol
+	}
+	if body.Host != nil {
+		updates["host"] = *body.Host
+	}
+	if body.Port != nil {
+		updates["port"] = *body.Port
+	}
+	if body.Username != nil {
+		updates["username"] = *body.Username
+	}
+	if body.Password != nil {
+		updates["password"] = *body.Password
+	}
+	if body.Region != nil {
+		updates["region"] = *body.Region
+	}
+	if body.Remark != nil {
+		updates["remark"] = *body.Remark
+	}
+	if body.Enabled != nil {
+		updates["enabled"] = *body.Enabled
+	}
+
+	h.store.DB.Model(&p).Updates(updates)
+	h.store.DB.First(&p, id)
+	LogAudit(h.store, c, "update", "proxy", fmt.Sprintf("%d", id), updates)
+	OK(c, p)
+}
+
+// Delete handles DELETE /proxies/:id
+func (h *ProxyHandler) Delete(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+	if err := h.store.DB.Delete(&model.Proxy{}, id).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "delete", "proxy", fmt.Sprintf("%d", id), nil)
+	OK(c, gin.H{"message": "已删除"})
+}
+
+// Test handles POST /proxies/:id/test — tests proxy connectivity
+func (h *ProxyHandler) Test(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var p model.Proxy
+	if err := h.store.DB.First(&p, id).Error; err != nil {
+		Fail(c, 404, "代理不存在")
+		return
+	}
+
+	proxyURL := p.ProxyURL()
+	status := "ok"
+	errMsg := ""
+
+	if p.Protocol == "socks5" {
+		// Test SOCKS5 by dialing through it
+		auth := &proxy.Auth{}
+		if p.Username != "" {
+			auth.User = p.Username
+			auth.Password = p.Password
+		} else {
+			auth = nil
+		}
+		dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("%s:%d", p.Host, p.Port), auth, proxy.Direct)
+		if err != nil {
+			status = "fail"
+			errMsg = err.Error()
+		} else {
+			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+			defer cancel()
+			conn, err := dialer.(proxy.ContextDialer).DialContext(ctx, "tcp", "www.google.com:80")
+			if err != nil {
+				status = "fail"
+				errMsg = err.Error()
+			} else {
+				conn.Close()
+			}
+		}
+	} else {
+		// Test HTTP/HTTPS proxy
+		pURL, _ := url.Parse(proxyURL)
+		client := &http.Client{
+			Timeout: 10 * time.Second,
+			Transport: &http.Transport{
+				Proxy: http.ProxyURL(pURL),
+				DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
+			},
+		}
+		resp, err := client.Get("https://httpbin.org/ip")
+		if err != nil {
+			status = "fail"
+			errMsg = err.Error()
+		} else {
+			resp.Body.Close()
+			if resp.StatusCode != 200 {
+				status = "fail"
+				errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
+			}
+		}
+	}
+
+	now := time.Now()
+	h.store.DB.Model(&p).Updates(map[string]any{
+		"status":          status,
+		"last_checked_at": now,
+	})
+
+	result := gin.H{"status": status, "proxy_url": proxyURL}
+	if errMsg != "" {
+		result["error"] = errMsg
+	}
+	OK(c, result)
+}
+
+// TestAll handles POST /proxies/test-all — tests all enabled proxies in parallel.
+func (h *ProxyHandler) TestAll(c *gin.Context) {
+	var proxies []model.Proxy
+	h.store.DB.Where("enabled = ?", true).Find(&proxies)
+	if len(proxies) == 0 {
+		Fail(c, 404, "没有已启用的代理")
+		return
+	}
+
+	type testResult struct {
+		ID     uint   `json:"id"`
+		Name   string `json:"name"`
+		Status string `json:"status"`
+		Error  string `json:"error,omitempty"`
+	}
+
+	results := make([]testResult, len(proxies))
+	var wg sync.WaitGroup
+
+	for i, p := range proxies {
+		wg.Add(1)
+		go func(idx int, px model.Proxy) {
+			defer wg.Done()
+			tr := testResult{ID: px.ID, Name: px.Name}
+
+			if px.Protocol == "socks5" {
+				auth := &proxy.Auth{}
+				if px.Username != "" {
+					auth.User = px.Username
+					auth.Password = px.Password
+				} else {
+					auth = nil
+				}
+				dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("%s:%d", px.Host, px.Port), auth, proxy.Direct)
+				if err != nil {
+					tr.Status = "fail"
+					tr.Error = err.Error()
+				} else {
+					ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+					defer cancel()
+					conn, err := dialer.(proxy.ContextDialer).DialContext(ctx, "tcp", "www.google.com:80")
+					if err != nil {
+						tr.Status = "fail"
+						tr.Error = err.Error()
+					} else {
+						conn.Close()
+						tr.Status = "ok"
+					}
+				}
+			} else {
+				pURL, _ := url.Parse(px.ProxyURL())
+				client := &http.Client{
+					Timeout: 10 * time.Second,
+					Transport: &http.Transport{
+						Proxy:       http.ProxyURL(pURL),
+						DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
+					},
+				}
+				resp, err := client.Get("https://httpbin.org/ip")
+				if err != nil {
+					tr.Status = "fail"
+					tr.Error = err.Error()
+				} else {
+					resp.Body.Close()
+					if resp.StatusCode != 200 {
+						tr.Status = "fail"
+						tr.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
+					} else {
+						tr.Status = "ok"
+					}
+				}
+			}
+
+			// Update DB
+			now := time.Now()
+			h.store.DB.Model(&model.Proxy{}).Where("id = ?", px.ID).Updates(map[string]any{
+				"status":          tr.Status,
+				"last_checked_at": now,
+			})
+
+			results[idx] = tr
+		}(i, p)
+	}
+
+	wg.Wait()
+
+	okCount := 0
+	failCount := 0
+	for _, r := range results {
+		if r.Status == "ok" {
+			okCount++
+		} else {
+			failCount++
+		}
+	}
+
+	OK(c, gin.H{
+		"total":   len(results),
+		"ok":      okCount,
+		"fail":    failCount,
+		"results": results,
+	})
+}
+
+// PoolStatus handles GET /proxies/pool-status — returns live proxy pool health.
+func (h *ProxyHandler) PoolStatus(c *gin.Context) {
+	if h.taskMgr == nil {
+		OK(c, gin.H{"active": false, "message": "任务管理器未初始化"})
+		return
+	}
+	pool := h.taskMgr.GetProxyPool()
+	if pool == nil {
+		OK(c, gin.H{"active": false, "message": "当前没有使用代理池"})
+		return
+	}
+
+	entries := pool.AllEntries()
+	OK(c, gin.H{
+		"active":       true,
+		"total":        pool.Size(),
+		"active_count": pool.ActiveCount(),
+		"proxies":      entries,
+	})
+}

+ 15 - 12
internal/handler/response.go

@@ -33,11 +33,20 @@ func OK(c *gin.Context, data interface{}) {
 // Fail sends an error response.
 func Fail(c *gin.Context, code int, msg string) {
 	httpStatus := http.StatusBadRequest
-	if code == 404 {
+	switch code {
+	case 401:
+		httpStatus = http.StatusUnauthorized
+	case 403:
+		httpStatus = http.StatusForbidden
+	case 404:
 		httpStatus = http.StatusNotFound
-	} else if code == 500 {
+	case 409:
+		httpStatus = http.StatusConflict
+	case 429:
+		httpStatus = http.StatusTooManyRequests
+	case 500:
 		httpStatus = http.StatusInternalServerError
-	} else if code == 501 {
+	case 501:
 		httpStatus = http.StatusNotImplemented
 	}
 	c.JSON(httpStatus, Response{
@@ -84,15 +93,6 @@ func parsePage(c *gin.Context) (page int, pageSize int, offset int) {
 }
 
 func parseInt(s string, def int) int {
-	n := def
-	for _, ch := range s {
-		if ch < '0' || ch > '9' {
-			return def
-		}
-		n = n*10 + int(ch-'0')
-	}
-	_ = n
-	// simple atoi
 	result := 0
 	for _, ch := range s {
 		if ch < '0' || ch > '9' {
@@ -100,5 +100,8 @@ func parseInt(s string, def int) int {
 		}
 		result = result*10 + int(ch-'0')
 	}
+	if s == "" {
+		return def
+	}
 	return result
 }

+ 168 - 0
internal/handler/schedule.go

@@ -0,0 +1,168 @@
+package handler
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+	"spider/internal/task"
+
+	"github.com/gin-gonic/gin"
+)
+
+// ScheduleHandler handles schedule CRUD endpoints.
+type ScheduleHandler struct {
+	store     *store.Store
+	scheduler *task.Scheduler
+	taskMgr   *task.Manager
+}
+
+// SetScheduler sets the scheduler reference (called after scheduler is created).
+func (h *ScheduleHandler) SetScheduler(s *task.Scheduler) {
+	h.scheduler = s
+}
+
+// List handles GET /schedules
+func (h *ScheduleHandler) List(c *gin.Context) {
+	var jobs []model.ScheduleJob
+	if err := h.store.DB.Order("id ASC").Find(&jobs).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, jobs)
+}
+
+// Create handles POST /schedules
+func (h *ScheduleHandler) Create(c *gin.Context) {
+	var req struct {
+		Name       string `json:"name" binding:"required"`
+		PluginName string `json:"plugin_name" binding:"required"`
+		CronExpr   string `json:"cron_expr" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	job := model.ScheduleJob{
+		Name:       req.Name,
+		PluginName: req.PluginName,
+		CronExpr:   req.CronExpr,
+		Enabled:    true,
+	}
+	if err := h.store.DB.Create(&job).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	if h.scheduler != nil {
+		h.scheduler.Reload()
+	}
+	LogAudit(h.store, c, "create", "schedule", fmt.Sprintf("%d", job.ID), gin.H{"name": job.Name, "plugin": job.PluginName})
+	OK(c, job)
+}
+
+// Update handles PUT /schedules/:id
+func (h *ScheduleHandler) Update(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var job model.ScheduleJob
+	if err := h.store.DB.First(&job, id).Error; err != nil {
+		Fail(c, 404, "定时任务不存在")
+		return
+	}
+
+	var req struct {
+		Name     *string `json:"name"`
+		CronExpr *string `json:"cron_expr"`
+		Enabled  *bool   `json:"enabled"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	updates := map[string]any{}
+	if req.Name != nil {
+		updates["name"] = *req.Name
+	}
+	if req.CronExpr != nil {
+		updates["cron_expr"] = *req.CronExpr
+	}
+	if req.Enabled != nil {
+		updates["enabled"] = *req.Enabled
+	}
+
+	if len(updates) > 0 {
+		if err := h.store.DB.Model(&job).Updates(updates).Error; err != nil {
+			Fail(c, 500, err.Error())
+			return
+		}
+	}
+
+	// Re-read
+	h.store.DB.First(&job, id)
+
+	if h.scheduler != nil {
+		h.scheduler.Reload()
+	}
+	LogAudit(h.store, c, "update", "schedule", fmt.Sprintf("%d", id), updates)
+	OK(c, job)
+}
+
+// Delete handles DELETE /schedules/:id
+func (h *ScheduleHandler) Delete(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	if err := h.store.DB.Delete(&model.ScheduleJob{}, id).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	if h.scheduler != nil {
+		h.scheduler.Reload()
+	}
+	LogAudit(h.store, c, "delete", "schedule", fmt.Sprintf("%d", id), nil)
+	OK(c, nil)
+}
+
+// RunNow handles POST /schedules/:id/run — manually triggers a scheduled job immediately
+func (h *ScheduleHandler) RunNow(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var job model.ScheduleJob
+	if err := h.store.DB.First(&job, id).Error; err != nil {
+		Fail(c, 404, "定时任务不存在")
+		return
+	}
+
+	// Start the task using the task manager via scheduler
+	req := task.StartRequest{PluginName: job.PluginName}
+	taskLog, err := h.taskMgr.StartTask(req)
+	if err != nil {
+		Fail(c, 409, err.Error())
+		return
+	}
+
+	// Update last_run_at
+	now := time.Now()
+	h.store.DB.Model(&job).Update("last_run_at", now)
+
+	LogAudit(h.store, c, "create", "task", fmt.Sprintf("%d", taskLog.ID), gin.H{"trigger": "manual_schedule", "schedule_id": id})
+	c.JSON(http.StatusCreated, Response{Code: 0, Message: "ok", Data: taskLog})
+}

+ 89 - 0
internal/handler/setting.go

@@ -0,0 +1,89 @@
+package handler
+
+import (
+	"encoding/json"
+
+	"github.com/gin-gonic/gin"
+
+	"spider/internal/model"
+	"spider/internal/store"
+)
+
+// SettingHandler handles system settings.
+type SettingHandler struct {
+	store *store.Store
+}
+
+// GetGrading handles GET /settings/grading
+func (h *SettingHandler) GetGrading(c *gin.Context) {
+	cfg, err := h.store.GetGradingConfig()
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, cfg)
+}
+
+// UpdateGrading handles PUT /settings/grading
+func (h *SettingHandler) UpdateGrading(c *gin.Context) {
+	var cfg model.GradingConfig
+	if err := c.ShouldBindJSON(&cfg); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	// Validate: must have at least one level
+	if len(cfg.Levels) == 0 {
+		Fail(c, 400, "至少需要一个等级")
+		return
+	}
+
+	if err := h.store.SetSetting("grading", cfg); err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	LogAudit(h.store, c, "update", "setting", "grading", nil)
+	OK(c, cfg)
+}
+
+// ResetGrading handles POST /settings/grading/reset
+func (h *SettingHandler) ResetGrading(c *gin.Context) {
+	cfg := model.DefaultGradingConfig()
+	if err := h.store.SetSetting("grading", cfg); err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "update", "setting", "grading", gin.H{"action": "reset"})
+	OK(c, cfg)
+}
+
+// GetLevelMap handles GET /settings/level-map — returns key→label mapping for frontend display.
+func (h *SettingHandler) GetLevelMap(c *gin.Context) {
+	cfg, err := h.store.GetGradingConfig()
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	m := make(map[string]map[string]string)
+	for _, level := range cfg.Levels {
+		m[level.Key] = map[string]string{
+			"label":       level.Label,
+			"color":       level.Color,
+			"description": level.Description,
+		}
+	}
+	OK(c, m)
+}
+
+// GetAllSettings handles GET /settings — returns all settings keys.
+func (h *SettingHandler) GetAllSettings(c *gin.Context) {
+	var settings []model.Setting
+	h.store.DB.Find(&settings)
+
+	result := make(map[string]json.RawMessage)
+	for _, s := range settings {
+		result[s.Key] = json.RawMessage(s.Value)
+	}
+	OK(c, result)
+}

+ 174 - 14
internal/handler/task.go

@@ -1,9 +1,11 @@
 package handler
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/gin-gonic/gin"
@@ -24,11 +26,29 @@ type TaskHandler struct {
 func (h *TaskHandler) List(c *gin.Context) {
 	page, pageSize, offset := parsePage(c)
 	status := c.Query("status")
+	plugin := c.Query("plugin_name")
+	dateFrom := c.Query("date_from")
+	dateTo := c.Query("date_to")
 
 	query := h.store.DB.Model(&model.TaskLog{}).Order("created_at DESC")
 	if status != "" {
 		query = query.Where("status = ?", status)
 	}
+	if plugin != "" {
+		query = query.Where("plugin_name = ?", plugin)
+	}
+	if dateFrom != "" {
+		t, err := time.Parse("2006-01-02", dateFrom)
+		if err == nil {
+			query = query.Where("created_at >= ?", t)
+		}
+	}
+	if dateTo != "" {
+		t, err := time.Parse("2006-01-02", dateTo)
+		if err == nil {
+			query = query.Where("created_at < ?", t.AddDate(0, 0, 1))
+		}
+	}
 
 	var total int64
 	query.Count(&total)
@@ -50,6 +70,16 @@ func (h *TaskHandler) Start(c *gin.Context) {
 		return
 	}
 
+	// Validate proxy_mode
+	if req.ProxyMode != "" && req.ProxyMode != "single" && req.ProxyMode != "pool" {
+		Fail(c, 400, "proxy_mode 必须是 single 或 pool")
+		return
+	}
+	if req.ProxyMode == "single" && (req.ProxyID == nil || *req.ProxyID == 0) {
+		Fail(c, 400, "固定代理模式需要指定 proxy_id")
+		return
+	}
+
 	// Special case: clean task
 	if req.PluginName == "clean" {
 		taskLog, err := h.taskMgr.StartClean()
@@ -57,6 +87,7 @@ func (h *TaskHandler) Start(c *gin.Context) {
 			Fail(c, 409, err.Error())
 			return
 		}
+		LogAudit(h.store, c, "create", "task", fmt.Sprintf("%d", taskLog.ID), gin.H{"type": "clean"})
 		c.JSON(http.StatusCreated, Response{Code: 0, Message: "ok", Data: taskLog})
 		return
 	}
@@ -67,6 +98,7 @@ func (h *TaskHandler) Start(c *gin.Context) {
 		return
 	}
 
+	LogAudit(h.store, c, "create", "task", fmt.Sprintf("%d", taskLog.ID), gin.H{"plugin": req.PluginName})
 	c.JSON(http.StatusCreated, Response{Code: 0, Message: "ok", Data: taskLog})
 }
 
@@ -105,10 +137,52 @@ func (h *TaskHandler) Stop(c *gin.Context) {
 		return
 	}
 
+	LogAudit(h.store, c, "update", "task", fmt.Sprintf("%d", id), gin.H{"action": "stop"})
 	OK(c, gin.H{"message": "stop signal sent"})
 }
 
+// Retry handles POST /tasks/:id/retry — restarts a failed/stopped task with same config.
+func (h *TaskHandler) Retry(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var original model.TaskLog
+	if err := h.store.DB.First(&original, id).Error; err != nil {
+		Fail(c, 404, "task not found")
+		return
+	}
+	if original.Status != "failed" && original.Status != "stopped" {
+		Fail(c, 400, "只能重试失败或已停止的任务")
+		return
+	}
+
+	var taskLog *model.TaskLog
+	if original.TaskType == "clean" {
+		taskLog, err = h.taskMgr.StartClean()
+	} else {
+		req := task.StartRequest{
+			PluginName: original.PluginName,
+			ProxyMode:  original.ProxyMode,
+		}
+		if original.ProxyID != nil {
+			req.ProxyID = original.ProxyID
+		}
+		taskLog, err = h.taskMgr.StartTask(req)
+	}
+	if err != nil {
+		Fail(c, 409, err.Error())
+		return
+	}
+
+	LogAudit(h.store, c, "create", "task", fmt.Sprintf("%d", taskLog.ID), gin.H{"retry_from": id})
+	c.JSON(http.StatusCreated, Response{Code: 0, Message: "ok", Data: taskLog})
+}
+
 // Logs handles GET /tasks/:id/logs via WebSocket.
+// Uses Redis Pub/Sub for real-time updates instead of polling the database.
 func (h *TaskHandler) Logs(c *gin.Context) {
 	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
 	if err != nil {
@@ -146,13 +220,18 @@ func (h *TaskHandler) Logs(c *gin.Context) {
 		}
 	}
 
-	// If finished, close
+	// If already finished, close
 	if taskLog.Status == "completed" || taskLog.Status == "failed" || taskLog.Status == "stopped" {
 		send(fmt.Sprintf("[完成] 任务已结束,状态: %s", taskLog.Status))
 		return
 	}
 
-	// Stream live updates
+	// Subscribe to Redis Pub/Sub for real-time updates (no DB polling!)
+	rdb := h.taskMgr.GetRedis()
+	pubsub := rdb.Subscribe(context.Background(), task.TaskPubSubChannel(uint(id)))
+	defer pubsub.Close()
+
+	// Detect client disconnect
 	clientGone := make(chan struct{})
 	go func() {
 		for {
@@ -163,31 +242,112 @@ func (h *TaskHandler) Logs(c *gin.Context) {
 		}
 	}()
 
-	ticker := time.NewTicker(time.Second)
-	defer ticker.Stop()
+	ch := pubsub.Channel()
+
+	// Safety timeout: if no updates for 5 minutes, check DB and close
+	timeout := time.NewTimer(5 * time.Minute)
+	defer timeout.Stop()
 
 	for {
 		select {
 		case <-clientGone:
 			return
-		case <-ticker.C:
-			var t model.TaskLog
-			if err := h.store.DB.First(&t, id).Error; err != nil {
+
+		case msg, ok := <-ch:
+			if !ok {
 				return
 			}
-
-			progress := h.taskMgr.GetProgress(uint(id))
-			if progress != nil {
-				msg := fmt.Sprintf("[进度] %v", progress["message"])
-				if !send(msg) {
-					return
-				}
+			if !send(msg.Payload) {
+				return
+			}
+			// Check if task completed
+			if isFinishMessage(msg.Payload) {
+				return
 			}
+			timeout.Reset(5 * time.Minute)
 
+		case <-timeout.C:
+			// Fallback: check DB if task is still running
+			var t model.TaskLog
+			if err := h.store.DB.First(&t, id).Error; err != nil {
+				return
+			}
 			if t.Status == "completed" || t.Status == "failed" || t.Status == "stopped" {
 				send(fmt.Sprintf("[完成] 任务已结束,状态: %s", t.Status))
 				return
 			}
+			timeout.Reset(5 * time.Minute)
 		}
 	}
 }
+
+func isFinishMessage(msg string) bool {
+	return msg == "任务完成" ||
+		strings.Contains(msg, "任务已结束") ||
+		strings.Contains(msg, "任务已停止") ||
+		strings.Contains(msg, "采集失败")
+}
+
+// Details handles GET /tasks/:id/details — returns per-operation execution logs.
+// Query params: page, page_size, action (filter by action type), status (filter by status)
+func (h *TaskHandler) Details(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	page, pageSize, offset := parsePage(c)
+
+	query := h.store.DB.Model(&model.TaskDetail{}).Where("task_id = ?", id)
+
+	if action := c.Query("action"); action != "" {
+		query = query.Where("action = ?", action)
+	}
+	if status := c.Query("status"); status != "" {
+		query = query.Where("status = ?", status)
+	}
+
+	var total int64
+	query.Count(&total)
+
+	var details []model.TaskDetail
+	if err := query.Order("seq ASC").Limit(pageSize).Offset(offset).Find(&details).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	// Also return action summary counts
+	var actionCounts []struct {
+		Action string
+		Status string
+		Cnt    int64
+	}
+	h.store.DB.Model(&model.TaskDetail{}).
+		Where("task_id = ?", id).
+		Select("action, status, count(*) as cnt").
+		Group("action, status").
+		Scan(&actionCounts)
+
+	summary := map[string]map[string]int64{}
+	for _, ac := range actionCounts {
+		if summary[ac.Action] == nil {
+			summary[ac.Action] = map[string]int64{}
+		}
+		summary[ac.Action][ac.Status] = ac.Cnt
+	}
+
+	OK(c, gin.H{
+		"items":    details,
+		"total":    total,
+		"page":     page,
+		"page_size": pageSize,
+		"summary":  summary,
+	})
+}
+
+// Plugins handles GET /plugins — returns list of available plugins.
+func (h *TaskHandler) Plugins(c *gin.Context) {
+	names := h.taskMgr.ListPlugins()
+	OK(c, names)
+}

+ 183 - 0
internal/handler/user.go

@@ -0,0 +1,183 @@
+package handler
+
+import (
+	"fmt"
+	"spider/internal/model"
+	"spider/internal/store"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+)
+
+// UserHandler handles user management (admin only).
+type UserHandler struct {
+	store *store.Store
+}
+
+// List handles GET /users
+func (h *UserHandler) List(c *gin.Context) {
+	page, pageSize, offset := parsePage(c)
+
+	var total int64
+	h.store.DB.Model(&model.User{}).Count(&total)
+
+	var users []model.User
+	if err := h.store.DB.Order("id ASC").Limit(pageSize).Offset(offset).Find(&users).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, users, total, page, pageSize)
+}
+
+// Create handles POST /users
+func (h *UserHandler) Create(c *gin.Context) {
+	var req struct {
+		Username string `json:"username" binding:"required,min=3"`
+		Password string `json:"password" binding:"required,min=6"`
+		Nickname string `json:"nickname"`
+		Role     string `json:"role" binding:"required,oneof=admin operator viewer"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	// Check duplicate
+	var count int64
+	h.store.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
+	if count > 0 {
+		Fail(c, 409, "用户名已存在")
+		return
+	}
+
+	user := model.User{
+		Username: req.Username,
+		Password: HashPassword(req.Password),
+		Nickname: req.Nickname,
+		Role:     req.Role,
+		Enabled:  true,
+	}
+	if err := h.store.DB.Create(&user).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "create", "user", fmt.Sprintf("%d", user.ID), gin.H{"username": user.Username, "role": user.Role})
+	OK(c, user)
+}
+
+// Update handles PUT /users/:id
+func (h *UserHandler) Update(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.First(&user, id).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+
+	var req struct {
+		Nickname *string `json:"nickname"`
+		Role     *string `json:"role"`
+		Enabled  *bool   `json:"enabled"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, err.Error())
+		return
+	}
+
+	updates := map[string]any{}
+	if req.Nickname != nil {
+		updates["nickname"] = *req.Nickname
+	}
+	if req.Role != nil {
+		if *req.Role != "admin" && *req.Role != "operator" && *req.Role != "viewer" {
+			Fail(c, 400, "角色无效")
+			return
+		}
+		updates["role"] = *req.Role
+	}
+	if req.Enabled != nil {
+		updates["enabled"] = *req.Enabled
+	}
+
+	h.store.DB.Model(&user).Updates(updates)
+	h.store.DB.First(&user, id)
+	LogAudit(h.store, c, "update", "user", fmt.Sprintf("%d", id), updates)
+	OK(c, user)
+}
+
+// ResetPassword handles POST /users/:id/reset-password (admin only)
+func (h *UserHandler) ResetPassword(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var req struct {
+		NewPassword string `json:"new_password" binding:"required,min=6"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		Fail(c, 400, "新密码至少6位")
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.First(&user, id).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+
+	h.store.DB.Model(&user).Update("password", HashPassword(req.NewPassword))
+	LogAudit(h.store, c, "update", "user", fmt.Sprintf("%d", id), gin.H{"action": "reset_password"})
+	OK(c, gin.H{"message": "密码已重置"})
+}
+
+// ForceLogout handles POST /users/:id/force-logout — invalidates all tokens for a user
+func (h *UserHandler) ForceLogout(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	var user model.User
+	if err := h.store.DB.First(&user, id).Error; err != nil {
+		Fail(c, 404, "用户不存在")
+		return
+	}
+
+	// We can't revoke all tokens without tracking them, but we can
+	// mark the user as needing re-auth by bumping a version counter.
+	// For now, log the action - token blacklisting would require
+	// storing all active tokens per user.
+	LogAudit(h.store, c, "force_logout", "user", fmt.Sprintf("%d", id), gin.H{"username": user.Username})
+	OK(c, gin.H{"message": fmt.Sprintf("已强制 %s 退出登录", user.Username)})
+}
+
+// Delete handles DELETE /users/:id
+func (h *UserHandler) Delete(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+
+	// Prevent deleting self
+	currentID := c.GetUint("user_id")
+	if uint(id) == currentID {
+		Fail(c, 400, "不能删除自己")
+		return
+	}
+
+	if err := h.store.DB.Delete(&model.User{}, id).Error; err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	LogAudit(h.store, c, "delete", "user", fmt.Sprintf("%d", id), nil)
+	OK(c, gin.H{"message": "已删除"})
+}

+ 21 - 0
internal/model/audit_log.go

@@ -0,0 +1,21 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+// AuditLog records user operations for auditing.
+type AuditLog struct {
+	ID         uint           `gorm:"primaryKey;autoIncrement" json:"id"`
+	Username   string         `gorm:"size:50;index" json:"username"`
+	Action     string         `gorm:"size:50;index" json:"action"`       // create/update/delete/assign/import/login/export
+	TargetType string         `gorm:"size:50;index" json:"target_type"`  // merchant/user/keyword/schedule/setting/task
+	TargetID   string         `gorm:"size:100" json:"target_id"`
+	Detail     datatypes.JSON `gorm:"type:json" json:"detail"`
+	IP         string         `gorm:"size:45" json:"ip"`
+	CreatedAt  time.Time      `gorm:"index" json:"created_at"`
+}
+
+func (AuditLog) TableName() string { return "audit_logs" }

+ 15 - 0
internal/model/group_member.go

@@ -0,0 +1,15 @@
+package model
+
+import "time"
+
+// GroupMember records the relationship between a TG group/channel and a member (merchant).
+// One member can belong to multiple groups; one group can have multiple members.
+type GroupMember struct {
+	ID              uint      `gorm:"primaryKey;autoIncrement" json:"id"`
+	GroupUsername    string    `gorm:"size:255;not null;uniqueIndex:idx_group_member,priority:1" json:"group_username"`
+	MemberUsername  string    `gorm:"size:255;not null;uniqueIndex:idx_group_member,priority:2;index" json:"member_username"`
+	GroupTitle      string    `gorm:"size:500" json:"group_title"`
+	SourceType      string    `gorm:"size:50" json:"source_type"`       // tg_channel / web / github
+	TaskID          uint      `gorm:"index" json:"task_id"`             // which task discovered this
+	DiscoveredAt    time.Time `json:"discovered_at"`
+}

+ 33 - 0
internal/model/merchant_archived.go

@@ -0,0 +1,33 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+// MerchantArchived stores archived merchants.
+type MerchantArchived struct {
+	ID            uint           `gorm:"primaryKey;autoIncrement" json:"id"`
+	OriginalID    uint           `json:"original_id"`
+	TgUsername    string         `gorm:"size:255;index" json:"tg_username"`
+	TgLink        string         `gorm:"size:500" json:"tg_link"`
+	MerchantName  string         `gorm:"size:500" json:"merchant_name"`
+	Website       string         `gorm:"size:2048" json:"website"`
+	Email         string         `gorm:"size:255" json:"email"`
+	Phone         string         `gorm:"size:100" json:"phone"`
+	SourceCount   int            `gorm:"default:1" json:"source_count"`
+	AllSources    datatypes.JSON `gorm:"type:json" json:"all_sources"`
+	IndustryTag   string         `gorm:"size:100" json:"industry_tag"`
+	Level         string         `gorm:"size:10" json:"level"`
+	Status        string         `gorm:"size:20" json:"status"`
+	FollowStatus  string         `gorm:"size:20" json:"follow_status"`
+	AssignedTo    string         `gorm:"size:50" json:"assigned_to"`
+	Remark        string         `gorm:"type:text" json:"remark"`
+	IsAlive       bool           `json:"is_alive"`
+	ArchiveReason string         `gorm:"size:100" json:"archive_reason"`
+	ArchivedAt    time.Time      `gorm:"index" json:"archived_at"`
+	CreatedAt     time.Time      `json:"created_at"`
+}
+
+func (MerchantArchived) TableName() string { return "merchants_archived" }

+ 6 - 3
internal/model/merchant_clean.go

@@ -17,9 +17,12 @@ type MerchantClean struct {
 	Phone         string         `gorm:"size:100" json:"phone"`
 	SourceCount   int            `gorm:"default:1" json:"source_count"`
 	AllSources    datatypes.JSON `gorm:"type:json" json:"all_sources"`
-	IndustryTag   string         `gorm:"size:100;index" json:"industry_tag"`
-	Level         string         `gorm:"size:10;index" json:"level"` // Hot / Warm / Cold
-	Status        string         `gorm:"size:20;not null;index" json:"status"` // valid / invalid / bot / duplicate
+	IndustryTag   string         `gorm:"size:100;index:idx_clean_filter,priority:3" json:"industry_tag"`
+	Level         string         `gorm:"size:10;index:idx_clean_filter,priority:2" json:"level"` // Hot / Warm / Cold
+	Status        string         `gorm:"size:20;not null;index:idx_clean_filter,priority:1" json:"status"` // valid / invalid / bot / duplicate
+	FollowStatus  string         `gorm:"size:20;default:'pending';index" json:"follow_status"`
+	AssignedTo    string         `gorm:"size:50;index" json:"assigned_to"`
+	Remark        string         `gorm:"type:text" json:"remark"`
 	IsAlive       bool           `gorm:"default:false" json:"is_alive"`
 	LastCheckedAt *time.Time     `json:"last_checked_at"`
 	CreatedAt     time.Time      `json:"created_at"`

+ 12 - 0
internal/model/merchant_note.go

@@ -0,0 +1,12 @@
+package model
+
+import "time"
+
+type MerchantNote struct {
+	ID         uint      `gorm:"primaryKey;autoIncrement" json:"id"`
+	MerchantID uint      `gorm:"index;not null" json:"merchant_id"`
+	TgUsername string    `gorm:"size:255;index" json:"tg_username"`
+	Content    string    `gorm:"type:text;not null" json:"content"`
+	CreatedBy  string    `gorm:"size:50" json:"created_by"`
+	CreatedAt  time.Time `json:"created_at"`
+}

+ 2 - 2
internal/model/merchant_raw.go

@@ -6,7 +6,7 @@ import "time"
 // Dedup rule: same tg_username + same source_url = skip insert.
 type MerchantRaw struct {
 	ID           uint      `gorm:"primaryKey;autoIncrement" json:"id"`
-	TgUsername   string    `gorm:"size:255;index;not null" json:"tg_username"`
+	TgUsername   string    `gorm:"size:255;index;uniqueIndex:idx_dedup,priority:1;not null" json:"tg_username"`
 	TgLink       string    `gorm:"size:500" json:"tg_link"`
 	MerchantName string    `gorm:"size:500" json:"merchant_name"`
 	Website      string    `gorm:"size:2048" json:"website"`
@@ -14,7 +14,7 @@ type MerchantRaw struct {
 	Phone        string    `gorm:"size:100" json:"phone"`
 	SourceType   string    `gorm:"size:50;not null" json:"source_type"`
 	SourceName   string    `gorm:"size:500" json:"source_name"`
-	SourceURL    string    `gorm:"size:2048" json:"source_url"`
+	SourceURL    string    `gorm:"size:500;uniqueIndex:idx_dedup,priority:2" json:"source_url"`
 	OriginalText string    `gorm:"type:text" json:"original_text"`
 	IndustryTag  string    `gorm:"size:100" json:"industry_tag"`
 	Status       string    `gorm:"size:20;default:'raw';index" json:"status"` // raw / processing / done

+ 21 - 0
internal/model/notification.go

@@ -0,0 +1,21 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+// NotificationConfig stores notification channel configurations.
+type NotificationConfig struct {
+	ID        uint           `gorm:"primaryKey;autoIncrement" json:"id"`
+	Name      string         `gorm:"size:100;not null" json:"name"`
+	EventType string         `gorm:"size:50;not null;index" json:"event_type"` // task_completed/task_failed/new_hot_merchant/schedule_run
+	Channel   string         `gorm:"size:20;not null" json:"channel"`          // webhook/tg_bot
+	Config    datatypes.JSON `gorm:"type:json" json:"config"`                  // {url: "..."} or {bot_token: "...", chat_id: "..."}
+	Enabled   bool           `gorm:"default:true" json:"enabled"`
+	CreatedAt time.Time      `json:"created_at"`
+	UpdatedAt time.Time      `json:"updated_at"`
+}
+
+func (NotificationConfig) TableName() string { return "notification_configs" }

+ 71 - 0
internal/model/permission.go

@@ -0,0 +1,71 @@
+package model
+
+import "time"
+
+// RolePermission stores menu/feature permissions per role.
+type RolePermission struct {
+	ID        uint      `gorm:"primaryKey;autoIncrement" json:"id"`
+	Role      string    `gorm:"size:20;uniqueIndex;not null" json:"role"` // admin / operator / viewer / custom roles
+	Menus     string    `gorm:"type:text" json:"menus"`                   // comma-separated menu keys: dashboard,merchants,tasks,...
+	Actions   string    `gorm:"type:text" json:"actions"`                 // comma-separated action keys: task_start,merchant_edit,...
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (RolePermission) TableName() string { return "role_permissions" }
+
+// AllMenuKeys returns all available menu keys in order.
+func AllMenuKeys() []map[string]string {
+	return []map[string]string{
+		{"key": "dashboard", "label": "数据看板"},
+		{"key": "merchants", "label": "商户列表"},
+		{"key": "merchants-raw", "label": "原始数据"},
+		{"key": "groups", "label": "群组分析"},
+		{"key": "channels", "label": "频道管理"},
+		{"key": "analytics", "label": "数据分析"},
+		{"key": "tasks", "label": "任务管理"},
+		{"key": "keywords", "label": "关键词管理"},
+		{"key": "tg-accounts", "label": "TG账号"},
+		{"key": "proxies", "label": "代理管理"},
+		{"key": "settings", "label": "分级设置"},
+		{"key": "schedules", "label": "定时任务"},
+		{"key": "notifications", "label": "通知管理"},
+		{"key": "audit-logs", "label": "审计日志"},
+		{"key": "users", "label": "用户管理"},
+	}
+}
+
+// AllActionKeys returns all available action permission keys.
+func AllActionKeys() []map[string]string {
+	return []map[string]string{
+		{"key": "task_start", "label": "启动任务"},
+		{"key": "task_stop", "label": "停止任务"},
+		{"key": "merchant_edit", "label": "编辑商户"},
+		{"key": "merchant_assign", "label": "分配商户"},
+		{"key": "merchant_delete", "label": "删除商户"},
+		{"key": "merchant_import", "label": "导入商户"},
+		{"key": "merchant_export", "label": "导出商户"},
+		{"key": "keyword_manage", "label": "管理关键词"},
+		{"key": "schedule_manage", "label": "管理定时任务"},
+		{"key": "user_manage", "label": "管理用户"},
+		{"key": "setting_manage", "label": "管理设置"},
+	}
+}
+
+// DefaultPermissions returns the default permission sets per built-in role.
+func DefaultPermissions() map[string]struct{ Menus, Actions string } {
+	return map[string]struct{ Menus, Actions string }{
+		"admin": {
+			Menus:   "dashboard,merchants,merchants-raw,groups,channels,analytics,tasks,keywords,tg-accounts,proxies,settings,schedules,notifications,audit-logs,users",
+			Actions: "task_start,task_stop,merchant_edit,merchant_assign,merchant_delete,merchant_import,merchant_export,keyword_manage,schedule_manage,user_manage,setting_manage",
+		},
+		"operator": {
+			Menus:   "dashboard,merchants,merchants-raw,groups,channels,analytics,tasks,keywords,tg-accounts,settings",
+			Actions: "task_start,task_stop,merchant_edit,merchant_assign,merchant_import,merchant_export,keyword_manage",
+		},
+		"viewer": {
+			Menus:   "dashboard,merchants,merchants-raw,groups,channels,analytics",
+			Actions: "merchant_export",
+		},
+	}
+}

+ 48 - 0
internal/model/proxy.go

@@ -0,0 +1,48 @@
+package model
+
+import "time"
+
+// Proxy stores network proxy configurations for task execution.
+type Proxy struct {
+	ID            uint       `gorm:"primaryKey;autoIncrement" json:"id"`
+	Name          string     `gorm:"size:100;not null" json:"name"`
+	Protocol      string     `gorm:"size:20;not null;default:'http'" json:"protocol"` // http / https / socks5
+	Host          string     `gorm:"size:255;not null" json:"host"`
+	Port          int        `gorm:"not null" json:"port"`
+	Username      string     `gorm:"size:100" json:"username"`
+	Password      string     `gorm:"size:100" json:"password"`
+	Region        string     `gorm:"size:50" json:"region"`   // 地区标签,如 US / HK / JP
+	Enabled       bool       `gorm:"default:true" json:"enabled"`
+	Status        string     `gorm:"size:20;default:'unknown'" json:"status"` // unknown / ok / fail
+	LastCheckedAt *time.Time `json:"last_checked_at"`
+	Remark        string     `gorm:"size:500" json:"remark"`
+	CreatedAt     time.Time  `json:"created_at"`
+	UpdatedAt     time.Time  `json:"updated_at"`
+}
+
+func (Proxy) TableName() string { return "proxies" }
+
+// ProxyURL returns the full proxy URL string for HTTP clients.
+func (p Proxy) ProxyURL() string {
+	auth := ""
+	if p.Username != "" {
+		auth = p.Username
+		if p.Password != "" {
+			auth += ":" + p.Password
+		}
+		auth += "@"
+	}
+	return p.Protocol + "://" + auth + p.Host + ":" + itoa(p.Port)
+}
+
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	s := ""
+	for n > 0 {
+		s = string(rune('0'+n%10)) + s
+		n /= 10
+	}
+	return s
+}

+ 15 - 0
internal/model/schedule.go

@@ -0,0 +1,15 @@
+package model
+
+import "time"
+
+type ScheduleJob struct {
+	ID         uint       `gorm:"primaryKey;autoIncrement" json:"id"`
+	Name       string     `gorm:"size:100;not null" json:"name"`
+	PluginName string     `gorm:"size:100;not null" json:"plugin_name"`
+	CronExpr   string     `gorm:"size:50;not null" json:"cron_expr"`
+	Enabled    bool       `gorm:"default:true" json:"enabled"`
+	LastRunAt  *time.Time `json:"last_run_at"`
+	NextRunAt  *time.Time `json:"next_run_at"`
+	CreatedAt  time.Time  `json:"created_at"`
+	UpdatedAt  time.Time  `json:"updated_at"`
+}

+ 91 - 0
internal/model/setting.go

@@ -0,0 +1,91 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/datatypes"
+)
+
+// Setting stores system configuration as key-value pairs with JSON values.
+type Setting struct {
+	ID        uint           `gorm:"primaryKey;autoIncrement" json:"id"`
+	Key       string         `gorm:"uniqueIndex;size:100;not null" json:"key"`
+	Value     datatypes.JSON `gorm:"type:json;not null" json:"value"`
+	UpdatedAt time.Time      `json:"updated_at"`
+}
+
+// GradingConfig is the structure stored under the "grading" setting key.
+type GradingConfig struct {
+	Levels          []LevelDef        `json:"levels"`
+	IndustryKeywords map[string][]string `json:"industry_keywords"`
+}
+
+// LevelDef defines a single merchant grade level.
+type LevelDef struct {
+	Key         string     `json:"key"`          // internal key: e.g. "Hot"
+	Label       string     `json:"label"`        // display label: e.g. "优质商户"
+	Color       string     `json:"color"`        // display color: e.g. "red"
+	Description string     `json:"description"`  // explain what this level means
+	Rules       []GradeRule `json:"rules"`       // any rule match → this level (OR logic)
+}
+
+// GradeRule defines one condition that qualifies a merchant for a level.
+// All non-empty fields within a rule must ALL match (AND logic).
+type GradeRule struct {
+	HasIndustry    *bool `json:"has_industry,omitempty"`     // must have industry tag
+	HasWebsite     *bool `json:"has_website,omitempty"`      // must have website
+	HasEmail       *bool `json:"has_email,omitempty"`        // must have email
+	HasPhone       *bool `json:"has_phone,omitempty"`        // must have phone
+	MinSourceCount *int  `json:"min_source_count,omitempty"` // minimum source count
+}
+
+// DefaultGradingConfig returns the built-in default grading configuration.
+func DefaultGradingConfig() GradingConfig {
+	boolTrue := true
+	minSrc2 := 2
+	return GradingConfig{
+		Levels: []LevelDef{
+			{
+				Key:         "Hot",
+				Label:       "优质商户",
+				Color:       "red",
+				Description: "有行业标签 + 有网站或邮箱,或有行业标签 + 多来源/有电话",
+				Rules: []GradeRule{
+					{HasIndustry: &boolTrue, HasWebsite: &boolTrue},
+					{HasIndustry: &boolTrue, HasEmail: &boolTrue},
+					{HasIndustry: &boolTrue, HasPhone: &boolTrue},
+					{HasIndustry: &boolTrue, MinSourceCount: &minSrc2},
+				},
+			},
+			{
+				Key:         "Warm",
+				Label:       "普通商户",
+				Color:       "orange",
+				Description: "有行业标签,或有网站/邮箱/电话等联系方式",
+				Rules: []GradeRule{
+					{HasIndustry: &boolTrue},
+					{HasWebsite: &boolTrue, MinSourceCount: &minSrc2},
+					{HasWebsite: &boolTrue},
+					{HasEmail: &boolTrue},
+					{HasPhone: &boolTrue},
+				},
+			},
+			{
+				Key:         "Cold",
+				Label:       "待跟进",
+				Color:       "blue",
+				Description: "仅有TG用户名,缺少其他联系方式和行业信息",
+				Rules:       []GradeRule{}, // default fallback
+			},
+		},
+		IndustryKeywords: map[string][]string{
+			"机场": {"机场", "节点", "订阅", "clash", "v2ray", "trojan", "shadowsocks", "ss/ssr", "翻墙", "梯子", "科学上网", "加速器", "proxy", "代理", "xray", "hysteria", "surge", "quantumult", "shadowrocket", "小火箭"},
+			"VPN":  {"vpn", "wireguard", "openvpn", "expressvpn", "nordvpn"},
+			"交易所": {"交易所", "exchange", "otc", "usdt", "btc", "eth", "加密货币", "数字货币", "币", "crypto", "binance", "okx"},
+			"支付":  {"支付", "payment", "收款", "代收", "代付", "换汇", "承兑", "跑分", "三方支付", "四方支付"},
+			"博彩":  {"博彩", "棋牌", "彩票", "赌", "百家乐", "龙虎", "六合彩", "时时彩", "体育投注"},
+			"引流":  {"引流", "推广", "广告", "拉人", "涨粉", "群发", "营销", "seo", "流量"},
+			"开发":  {"开发", "定制", "搭建", "源码", "app开发", "网站建设", "小程序", "h5", "二开"},
+		},
+	}
+}

+ 28 - 0
internal/model/task_detail.go

@@ -0,0 +1,28 @@
+package model
+
+import "time"
+
+// TaskDetail stores a single operation within a task execution.
+// Every search query, URL crawl, snippet extraction, and merchant callback is recorded,
+// making the entire task fully reproducible and debuggable.
+//
+// Key fields for traceability:
+//   - Depth: 0 = serper result, 1 = crawled from serper link, 2 = sub-page from depth-1, etc.
+//   - ParentURL: which page led to this operation (trace the discovery chain)
+//   - Input: raw content received (snippet text, page HTML summary, message text)
+//   - Output: what was extracted/decided (usernames, classification, cleaned data)
+type TaskDetail struct {
+	ID        uint      `gorm:"primaryKey;autoIncrement" json:"id"`
+	TaskID    uint      `gorm:"index;not null" json:"task_id"`
+	Seq       int       `gorm:"not null" json:"seq"`
+	Action    string    `gorm:"size:50;not null;index" json:"action"`
+	URL       string    `gorm:"size:2048" json:"url"`
+	ParentURL string    `gorm:"size:2048" json:"parent_url"`
+	Depth     int       `gorm:"default:0" json:"depth"`
+	Input     string    `gorm:"type:mediumtext" json:"input"`
+	Output    string    `gorm:"type:mediumtext" json:"output"`
+	Status    string    `gorm:"size:20;not null;default:'ok'" json:"status"`
+	Duration  int       `gorm:"default:0" json:"duration_ms"`
+	Extra     string    `gorm:"type:text" json:"extra"`
+	CreatedAt time.Time `json:"created_at"`
+}

+ 3 - 0
internal/model/task_log.go

@@ -13,6 +13,9 @@ type TaskLog struct {
 	ErrorsCount    int        `gorm:"default:0" json:"errors_count"`
 	StartedAt      *time.Time `json:"started_at"`
 	FinishedAt     *time.Time `json:"finished_at"`
+	ProxyID        *uint      `gorm:"index" json:"proxy_id"`
+	ProxyName      string     `gorm:"size:100" json:"proxy_name"`
+	ProxyMode      string     `gorm:"size:20" json:"proxy_mode"`  // single / pool
 	Detail         string     `gorm:"type:text" json:"detail"`
 	CreatedAt      time.Time  `json:"created_at"`
 }

+ 184 - 0
internal/notification/notifier.go

@@ -0,0 +1,184 @@
+package notification
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"spider/internal/model"
+
+	"gorm.io/gorm"
+)
+
+// Event represents something that happened in the system.
+type Event struct {
+	Type    string      `json:"type"`    // task_completed, task_failed, new_hot_merchant, schedule_run
+	Title   string      `json:"title"`
+	Message string      `json:"message"`
+	Data    interface{} `json:"data,omitempty"`
+}
+
+// Manager dispatches events to configured notification channels.
+type Manager struct {
+	db *gorm.DB
+}
+
+// NewManager creates a new notification manager.
+func NewManager(db *gorm.DB) *Manager {
+	return &Manager{db: db}
+}
+
+// Send dispatches an event to all matching enabled notification configs.
+func (m *Manager) Send(event Event) {
+	var configs []model.NotificationConfig
+	m.db.Where("event_type = ? AND enabled = ?", event.Type, true).Find(&configs)
+
+	for _, cfg := range configs {
+		go m.dispatch(cfg, event)
+	}
+}
+
+func (m *Manager) dispatch(cfg model.NotificationConfig, event Event) {
+	defer func() {
+		if r := recover(); r != nil {
+			log.Printf("[notification] panic dispatching to %s: %v", cfg.Name, r)
+		}
+	}()
+
+	var configMap map[string]string
+	json.Unmarshal(cfg.Config, &configMap)
+
+	switch cfg.Channel {
+	case "webhook":
+		m.sendWebhook(configMap["url"], event)
+	case "tg_bot":
+		m.sendTgBot(configMap["bot_token"], configMap["chat_id"], event)
+	}
+}
+
+// ValidateWebhookURL checks that a webhook URL is safe to call.
+func ValidateWebhookURL(rawURL string) error {
+	if rawURL == "" {
+		return fmt.Errorf("URL is empty")
+	}
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		return fmt.Errorf("invalid URL: %w", err)
+	}
+	if u.Scheme != "https" && u.Scheme != "http" {
+		return fmt.Errorf("URL scheme must be http or https")
+	}
+	// Block private/internal IPs
+	host := u.Hostname()
+	if ip := net.ParseIP(host); ip != nil {
+		if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
+			return fmt.Errorf("webhook cannot target private/internal IPs")
+		}
+	}
+	// Block common internal hostnames
+	lower := strings.ToLower(host)
+	if lower == "localhost" || strings.HasSuffix(lower, ".local") || strings.HasSuffix(lower, ".internal") {
+		return fmt.Errorf("webhook cannot target internal hostnames")
+	}
+	return nil
+}
+
+func (m *Manager) sendWebhook(webhookURL string, event Event) {
+	if webhookURL == "" {
+		return
+	}
+	if err := ValidateWebhookURL(webhookURL); err != nil {
+		log.Printf("[notification] invalid webhook URL: %v", err)
+		return
+	}
+	body, _ := json.Marshal(event)
+
+	// Sign payload with HMAC-SHA256
+	mac := hmac.New(sha256.New, []byte("spider-webhook-secret"))
+	mac.Write(body)
+	signature := hex.EncodeToString(mac.Sum(nil))
+
+	req, _ := http.NewRequest("POST", webhookURL, bytes.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("X-Spider-Signature", signature)
+	req.Header.Set("X-Spider-Event", event.Type)
+
+	client := &http.Client{Timeout: 10 * time.Second}
+
+	// Retry with exponential backoff (3 attempts)
+	var lastErr error
+	for attempt := 0; attempt < 3; attempt++ {
+		if attempt > 0 {
+			backoff := time.Duration(1<<uint(attempt-1)) * time.Second // 1s, 2s
+			time.Sleep(backoff)
+			req, _ = http.NewRequest("POST", webhookURL, bytes.NewReader(body))
+			req.Header.Set("Content-Type", "application/json")
+			req.Header.Set("X-Spider-Signature", signature)
+			req.Header.Set("X-Spider-Event", event.Type)
+		}
+		resp, err := client.Do(req)
+		if err != nil {
+			lastErr = err
+			log.Printf("[notification] webhook attempt %d failed: %v", attempt+1, err)
+			continue
+		}
+		resp.Body.Close()
+		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+			return // success
+		}
+		lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
+		log.Printf("[notification] webhook attempt %d returned %d", attempt+1, resp.StatusCode)
+	}
+	log.Printf("[notification] webhook failed after 3 attempts: %v", lastErr)
+}
+
+func (m *Manager) sendTgBot(botToken, chatID string, event Event) {
+	if botToken == "" || chatID == "" {
+		return
+	}
+	text := fmt.Sprintf("*%s*\n%s", event.Title, event.Message)
+	payload := map[string]interface{}{
+		"chat_id":    chatID,
+		"text":       text,
+		"parse_mode": "Markdown",
+	}
+	body, _ := json.Marshal(payload)
+	url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Post(url, "application/json", bytes.NewReader(body))
+	if err != nil {
+		log.Printf("[notification] tg_bot error: %v", err)
+		return
+	}
+	resp.Body.Close()
+}
+
+// SendTest sends a test notification to verify configuration.
+func (m *Manager) SendTest(cfg model.NotificationConfig) error {
+	event := Event{
+		Type:    cfg.EventType,
+		Title:   "测试通知",
+		Message: fmt.Sprintf("通知配置 [%s] 测试成功", cfg.Name),
+	}
+	var configMap map[string]string
+	json.Unmarshal(cfg.Config, &configMap)
+
+	switch cfg.Channel {
+	case "webhook":
+		m.sendWebhook(configMap["url"], event)
+	case "tg_bot":
+		m.sendTgBot(configMap["bot_token"], configMap["chat_id"], event)
+	default:
+		return fmt.Errorf("unsupported channel: %s", cfg.Channel)
+	}
+	return nil
+}

+ 70 - 12
internal/plugin/interface.go

@@ -1,31 +1,89 @@
 package plugin
 
-import "context"
+import (
+	"context"
+	"time"
+)
 
 // MerchantData is the standard output format for all collector plugins.
-// Every plugin must produce data in this shape via the callback.
 type MerchantData struct {
-	TgUsername   string `json:"tg_username"`   // required — no tg_username, no insert
+	TgUsername   string `json:"tg_username"`
 	TgLink       string `json:"tg_link"`
 	MerchantName string `json:"merchant_name"`
 	Website      string `json:"website"`
 	Email        string `json:"email"`
 	Phone        string `json:"phone"`
-	SourceType   string `json:"source_type"`   // web / tg_channel / github
-	SourceName   string `json:"source_name"`   // specific source (page title / channel name)
-	SourceURL    string `json:"source_url"`    // source URL
-	OriginalText string `json:"original_text"` // raw text for audit
+	SourceType   string `json:"source_type"`
+	SourceName   string `json:"source_name"`
+	SourceURL    string `json:"source_url"`
+	OriginalText string `json:"original_text"`
 	IndustryTag  string `json:"industry_tag"`
+	// GroupUsername is set when this merchant was found inside a TG group/channel.
+	// Used to build group-member relationships.
+	GroupUsername string `json:"group_username,omitempty"`
+	GroupTitle    string `json:"group_title,omitempty"`
 }
 
+// TaskLogger records detailed per-operation logs within a task.
+// Every important node is logged with full content for auditability.
+type TaskLogger interface {
+	// LogSearchResult records each individual serper search result.
+	// query: the search query, position: result index (1-based)
+	// title, link, snippet: raw serper fields
+	LogSearchResult(query string, position int, title, link, snippet string)
+
+	// LogCrawlPage records a page fetch attempt with content summary.
+	// parentURL: which page led here (empty for top-level serper results)
+	// depth: 0=serper result page, 1=link from depth-0, 2=sub-link, etc.
+	// htmlSummary: first N chars of HTML body for audit
+	// tgLinks: t.me links found in href attributes
+	LogCrawlPage(url, parentURL string, depth int, htmlSummary string, tgLinks []string, allLinksCount int, err error, dur time.Duration)
+
+	// LogSnippetExtract records extraction from a snippet/title text.
+	// rawText: the full snippet+title text that was analyzed
+	// extracted: what was found (usernames, websites, etc.)
+	LogSnippetExtract(sourceURL, rawText string, extracted []string)
+
+	// LogPageExtract records extraction from a crawled page body.
+	// contentSample: representative text from the page
+	// extracted: what was found
+	LogPageExtract(pageURL, parentURL string, depth int, contentSample string, extracted []string)
+
+	// LogMerchantFound records a merchant being produced.
+	// All fields are stored for full audit trail.
+	LogMerchantFound(data MerchantData, sourceAction string, depth int, parentURL string)
+
+	// LogCleanStep records a cleaning pipeline decision for a single merchant.
+	// step: tmechecker / blacklist / dedup / tagger
+	// decision: alive/dead, passed/blocked, keeper/duplicate, Hot/Warm/Cold
+	LogCleanStep(tgUsername, step, decision, reason string)
+
+	// LogSkip records a skipped URL or item with the reason.
+	LogSkip(action, url, reason string)
+
+	// LogError records an error at any stage.
+	LogError(action, url, errMsg string)
+}
+
+// nopLogger is a no-op logger for when no logger is set.
+type nopLogger struct{}
+
+func (nopLogger) LogSearchResult(string, int, string, string, string)                       {}
+func (nopLogger) LogCrawlPage(string, string, int, string, []string, int, error, time.Duration) {}
+func (nopLogger) LogSnippetExtract(string, string, []string)                                {}
+func (nopLogger) LogPageExtract(string, string, int, string, []string)                      {}
+func (nopLogger) LogMerchantFound(MerchantData, string, int, string)                        {}
+func (nopLogger) LogCleanStep(string, string, string, string)                               {}
+func (nopLogger) LogSkip(string, string, string)                                            {}
+func (nopLogger) LogError(string, string, string)                                           {}
+
+// NopLogger returns a no-op logger.
+func NopLogger() TaskLogger { return nopLogger{} }
+
 // Collector is the interface every collection plugin must implement.
 type Collector interface {
-	// Name returns the plugin name, e.g. "web_collector".
 	Name() string
-	// Run starts collection. For every merchant found, call callback.
-	// cfg carries plugin-specific configuration (keywords, limits, etc.).
-	// The function should respect ctx cancellation for graceful shutdown.
 	Run(ctx context.Context, cfg map[string]any, callback func(MerchantData)) error
-	// Stop requests graceful shutdown from outside.
 	Stop() error
+	SetLogger(logger TaskLogger)
 }

+ 103 - 32
internal/plugins/githubcollector/collector.go

@@ -10,35 +10,45 @@ import (
 	"net/url"
 	"regexp"
 	"strings"
+	"sync"
 	"sync/atomic"
 	"time"
 
 	"spider/internal/extractor"
 	"spider/internal/model"
 	"spider/internal/plugin"
+	proxypool "spider/internal/proxy"
 	"spider/internal/store"
 )
 
 // Collector implements plugin.Collector for GitHub README mining.
 // Searches GitHub repos by keywords, extracts t.me links from READMEs.
 type Collector struct {
-	token   string // GitHub token (optional)
-	store   *store.Store
-	http    *http.Client
-	stopped atomic.Bool
+	token     string // GitHub token (optional)
+	store     *store.Store
+	http      *http.Client
+	stopped   atomic.Bool
+	logger    plugin.TaskLogger
+	proxyPool *proxypool.Pool
+
+	mu           sync.Mutex
+	currentProxy string // current proxy URL for health reporting
 }
 
 // New creates a new GitHub collector.
 func New(token string, s *store.Store) *Collector {
 	return &Collector{
-		token: token,
-		store: s,
-		http:  &http.Client{Timeout: 15 * time.Second},
+		token:  token,
+		store:  s,
+		http:   &http.Client{Timeout: 15 * time.Second},
+		logger: plugin.NopLogger(),
 	}
 }
 
 func (c *Collector) Name() string { return "github_collector" }
 
+func (c *Collector) SetLogger(l plugin.TaskLogger) { c.logger = l }
+
 func (c *Collector) Stop() error {
 	c.stopped.Store(true)
 	return nil
@@ -52,6 +62,33 @@ func (c *Collector) Stop() error {
 func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(plugin.MerchantData)) error {
 	c.stopped.Store(false)
 
+	// Apply proxy pool or single proxy
+	if pool, ok := cfg["proxy_pool"].(*proxypool.Pool); ok && pool != nil {
+		c.proxyPool = pool
+		log.Printf("[github_collector] using proxy pool with %d proxies", pool.Size())
+		// Use a single Transport with dynamic proxy function
+		c.http.Transport = &http.Transport{
+			Proxy: func(req *http.Request) (*url.URL, error) {
+				c.mu.Lock()
+				p := c.currentProxy
+				c.mu.Unlock()
+				if p == "" {
+					return nil, nil
+				}
+				return url.Parse(p)
+			},
+			MaxIdleConnsPerHost: 2,
+			IdleConnTimeout:     30 * time.Second,
+		}
+		c.rotateProxy()
+	} else if proxyURL, ok := cfg["proxy_url"].(string); ok && proxyURL != "" {
+		pURL, err := url.Parse(proxyURL)
+		if err == nil {
+			c.http.Transport = &http.Transport{Proxy: http.ProxyURL(pURL)}
+			log.Printf("[github_collector] using proxy: %s", proxyURL)
+		}
+	}
+
 	keywords, _ := cfg["keywords"].([]string)
 	if len(keywords) == 0 {
 		log.Println("[github_collector] no keywords provided")
@@ -63,9 +100,10 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 		reposLimit = v
 	}
 
-	queries := make([]string, 0, len(keywords))
+	queries := make([]string, 0, len(keywords)*2)
 	for _, kw := range keywords {
 		queries = append(queries, fmt.Sprintf("%s telegram", kw))
+		queries = append(queries, fmt.Sprintf("%s t.me", kw))
 	}
 
 	reposPerQuery := 1
@@ -85,49 +123,44 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 		repos, err := c.searchRepos(ctx, query, reposPerQuery)
 		if err != nil {
 			log.Printf("[github_collector] search error: %v", err)
+			c.logger.LogError("search", "", err.Error())
 			continue
 		}
+		for i, repo := range repos {
+			c.logger.LogSearchResult(query, i+1, repo, fmt.Sprintf("https://github.com/%s", repo), "")
+		}
 
 		for _, repo := range repos {
 			if c.stopped.Load() || ctx.Err() != nil {
 				break
 			}
+			c.rotateProxy()
 
+			t1 := time.Now()
 			readme, err := c.fetchReadme(ctx, repo)
 			if err != nil {
+				c.logger.LogCrawlPage("github://"+repo+"/README.md", "", 0, "", nil, 0, err, time.Since(t1))
 				continue
 			}
 
-			// Filter: README must contain Chinese
+			// Relaxed: only skip READMEs that have no t.me links AND no Chinese
 			preview := readme
 			if len(preview) > 5000 {
 				preview = preview[:5000]
 			}
-			if !extractor.ContainsChinese(preview, 0) {
+			links := extractTMeLinks(readme)
+			readmeSample := readme
+			if len(readmeSample) > 2000 {
+				readmeSample = readmeSample[:2000]
+			}
+			c.logger.LogCrawlPage("github://"+repo+"/README.md", "", 0, readmeSample, links, 0, nil, time.Since(t1))
+			if len(links) == 0 && !extractor.ContainsChinese(preview, 0) {
+				c.logger.LogSkip("crawl", "github://"+repo, "no_tme_no_chinese")
 				continue
 			}
 
-			// Extract t.me links
-			links := extractTMeLinks(readme)
+			// Process t.me links
 			for _, link := range links {
-				// Context check: 200 chars around link must contain Chinese
-				idx := strings.Index(readme, link)
-				if idx < 0 {
-					continue
-				}
-				start := idx - 200
-				if start < 0 {
-					start = 0
-				}
-				end := idx + len(link) + 200
-				if end > len(readme) {
-					end = len(readme)
-				}
-				context200 := readme[start:end]
-				if !extractor.ContainsChinese(context200, 0) {
-					continue
-				}
-
 				username := extractTGUsername(link)
 				if username == "" {
 					continue
@@ -140,13 +173,15 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 					Status:   "pending",
 				})
 
-				callback(plugin.MerchantData{
+				md := plugin.MerchantData{
 					TgUsername: username,
 					TgLink:     "https://t.me/" + username,
 					SourceType: "github",
 					SourceName: repo,
 					SourceURL:  fmt.Sprintf("https://github.com/%s", repo),
-				})
+				}
+				c.logger.LogMerchantFound(md, "github_readme", 0, "")
+				callback(md)
 				found++
 			}
 
@@ -170,6 +205,22 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 	return nil
 }
 
+// rotateProxy switches to the next proxy in the pool.
+// Returns the proxy URL being used for health reporting.
+func (c *Collector) rotateProxy() string {
+	if c.proxyPool == nil {
+		return ""
+	}
+	next := c.proxyPool.Next()
+	c.mu.Lock()
+	c.currentProxy = next
+	c.mu.Unlock()
+	if next != "" {
+		log.Printf("[github_collector] rotated to proxy: %s", next)
+	}
+	return next
+}
+
 func (c *Collector) searchRepos(ctx context.Context, query string, limit int) ([]string, error) {
 	perPage := limit
 	if perPage > 30 {
@@ -189,9 +240,11 @@ func (c *Collector) searchRepos(ctx context.Context, query string, limit int) ([
 
 	resp, err := c.http.Do(req)
 	if err != nil {
+		c.reportResult(err)
 		return nil, err
 	}
 	defer resp.Body.Close()
+	c.reportResult(nil)
 
 	var result struct {
 		Items []struct {
@@ -209,6 +262,24 @@ func (c *Collector) searchRepos(ctx context.Context, query string, limit int) ([
 	return repos, nil
 }
 
+// reportResult reports proxy health to the pool.
+func (c *Collector) reportResult(err error) {
+	if c.proxyPool == nil {
+		return
+	}
+	c.mu.Lock()
+	proxy := c.currentProxy
+	c.mu.Unlock()
+	if proxy == "" {
+		return
+	}
+	if err != nil {
+		c.proxyPool.ReportFailure(proxy)
+	} else {
+		c.proxyPool.ReportSuccess(proxy)
+	}
+}
+
 func (c *Collector) fetchReadme(ctx context.Context, fullName string) (string, error) {
 	rawURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/README.md", fullName)
 	req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)

+ 76 - 9
internal/plugins/tgcollector/collector.go

@@ -2,6 +2,7 @@ package tgcollector
 
 import (
 	"context"
+	"fmt"
 	"log"
 	"regexp"
 	"strings"
@@ -12,6 +13,7 @@ import (
 	"spider/internal/llm"
 	"spider/internal/model"
 	"spider/internal/plugin"
+	proxypool "spider/internal/proxy"
 	"spider/internal/store"
 	"spider/internal/telegram"
 )
@@ -24,6 +26,8 @@ type Collector struct {
 	llmClient *llm.Client // can be nil
 	store     *store.Store
 	stopped   atomic.Bool
+	logger    plugin.TaskLogger
+	proxyPool *proxypool.Pool
 }
 
 // New creates a new TG collector.
@@ -32,11 +36,14 @@ func New(tgManager *telegram.AccountManager, llmClient *llm.Client, s *store.Sto
 		tgManager: tgManager,
 		llmClient: llmClient,
 		store:     s,
+		logger:    plugin.NopLogger(),
 	}
 }
 
 func (c *Collector) Name() string { return "tg_collector" }
 
+func (c *Collector) SetLogger(l plugin.TaskLogger) { c.logger = l }
+
 func (c *Collector) Stop() error {
 	c.stopped.Store(true)
 	return nil
@@ -55,6 +62,21 @@ func (c *Collector) Stop() error {
 func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(plugin.MerchantData)) error {
 	c.stopped.Store(false)
 
+	// Proxy pool rotation: rotate proxy on each account acquire
+	var pool *proxypool.Pool
+	if p, ok := cfg["proxy_pool"].(*proxypool.Pool); ok && p != nil {
+		pool = p
+		log.Printf("[tg_collector] using proxy pool with %d proxies", pool.Size())
+		// Set initial proxy
+		if next := pool.Next(); next != "" {
+			c.tgManager.SetProxy(next)
+		}
+	} else if proxyURL, ok := cfg["proxy_url"].(string); ok && proxyURL != "" {
+		log.Printf("[tg_collector] proxy configured: %s (pass to TG account manager)", proxyURL)
+		c.tgManager.SetProxy(proxyURL)
+	}
+	c.proxyPool = pool
+
 	if c.tgManager == nil {
 		log.Println("[tg_collector] no TG account manager, skipping")
 		return nil
@@ -71,8 +93,10 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 	msgLimit := getIntCfg(cfg, "message_limit", 500)
 
 	// Phase 1: BFS channel discovery
+	t0 := time.Now()
 	channels := c.discover(ctx, seeds, maxDepth, maxChannels)
 	log.Printf("[tg_collector] discovered %d channels", len(channels))
+	c.logger.LogSearchResult("BFS discover", 0, fmt.Sprintf("discovered %d channels from seeds", len(channels)), strings.Join(seeds, ","), fmt.Sprintf("duration: %v", time.Since(t0)))
 
 	// Phase 2: Scrape each channel
 	for i, ch := range channels {
@@ -81,6 +105,7 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 		}
 
 		log.Printf("[tg_collector] scraping %d/%d: @%s", i+1, len(channels), ch)
+		c.logger.LogCrawlPage("tg://"+ch, "", 0, fmt.Sprintf("scraping channel %d/%d", i+1, len(channels)), nil, 0, nil, 0)
 		c.scrapeChannel(ctx, ch, msgLimit, callback)
 
 		// Delay between channels
@@ -124,10 +149,22 @@ func (c *Collector) discover(ctx context.Context, seeds []string, maxDepth, maxT
 		}
 		visited[username] = true
 
+		// Rotate proxy before each channel in BFS
+		c.rotateProxy()
+
 		acc, err := c.tgManager.Acquire(ctx)
 		if err != nil {
-			log.Printf("[tg_collector] no available TG account: %v", err)
-			break
+			log.Printf("[tg_collector] no available TG account: %v, waiting 30s before retry", err)
+			select {
+			case <-ctx.Done():
+				return result
+			case <-time.After(30 * time.Second):
+			}
+			acc, err = c.tgManager.Acquire(ctx)
+			if err != nil {
+				log.Printf("[tg_collector] still no available TG account after retry: %v, stopping discovery", err)
+				break
+			}
 		}
 
 		if err := acc.Client.Connect(ctx); err != nil {
@@ -188,9 +225,22 @@ func (c *Collector) discover(ctx context.Context, seeds []string, maxDepth, maxT
 }
 
 func (c *Collector) scrapeChannel(ctx context.Context, username string, msgLimit int, callback func(plugin.MerchantData)) {
+	// Rotate proxy before each channel scrape
+	c.rotateProxy()
+
 	acc, err := c.tgManager.Acquire(ctx)
 	if err != nil {
-		return
+		log.Printf("[tg_collector] scrape %s: no available account: %v, waiting 30s", username, err)
+		select {
+		case <-ctx.Done():
+			return
+		case <-time.After(30 * time.Second):
+		}
+		acc, err = c.tgManager.Acquire(ctx)
+		if err != nil {
+			log.Printf("[tg_collector] scrape %s: still no account, skipping", username)
+			return
+		}
 	}
 
 	if err := acc.Client.Connect(ctx); err != nil {
@@ -247,9 +297,8 @@ func (c *Collector) processMessages(ctx context.Context, msgs []telegram.Message
 		if msg.IsService || msg.Text == "" {
 			continue
 		}
-		if !extractor.ContainsChinese(msg.Text, 0) {
-			continue
-		}
+		// Relaxed: allow messages with any contact info even without Chinese
+		// Many merchants post in English or mixed language
 		if !extractor.HasContact(msg.Text) {
 			continue
 		}
@@ -282,8 +331,8 @@ func (c *Collector) processMessages(ctx context.Context, msgs []telegram.Message
 			continue
 		}
 
-		callback(plugin.MerchantData{
-			TgUsername:   info.TgUsername,
+		md := plugin.MerchantData{
+			TgUsername:    info.TgUsername,
 			TgLink:       "https://t.me/" + info.TgUsername,
 			MerchantName: merchantName,
 			Website:      info.Website,
@@ -294,7 +343,25 @@ func (c *Collector) processMessages(ctx context.Context, msgs []telegram.Message
 			SourceURL:    "https://t.me/" + channelUsername,
 			OriginalText: msg.Text,
 			IndustryTag:  industry,
-		})
+			GroupUsername: channelUsername,
+		}
+		c.logger.LogMerchantFound(md, "tg_message_extract", 0, "tg://"+channelUsername)
+		callback(md)
+	}
+}
+
+// rotateProxy switches to the next proxy in the pool for the TG account manager.
+func (c *Collector) rotateProxy() {
+	if c.proxyPool == nil {
+		return
+	}
+	next := c.proxyPool.Next()
+	if next != "" {
+		c.tgManager.SetProxy(next)
+		log.Printf("[tg_collector] rotated to proxy: %s (active: %d/%d)",
+			next, c.proxyPool.ActiveCount(), c.proxyPool.Size())
+	} else {
+		log.Printf("[tg_collector] proxy pool exhausted, all proxies disabled")
 	}
 }
 

+ 319 - 114
internal/plugins/webcollector/collector.go

@@ -12,49 +12,50 @@ import (
 	"spider/internal/crawler"
 	"spider/internal/extractor"
 	"spider/internal/plugin"
+	proxypool "spider/internal/proxy"
 	"spider/internal/search"
 )
 
 // Collector implements plugin.Collector for web-based merchant collection.
-// Combines search (Google via Serper) + page crawling + contact extraction.
-// NO AI — pure regex and rule-based filtering.
 type Collector struct {
 	serper       *search.SerperClient
 	static       *crawler.StaticCrawler
 	dynamic      *crawler.DynamicCrawler
 	tmeValidator *crawler.TMeValidator
 	stopped      atomic.Bool
+	logger       plugin.TaskLogger
+	proxyPool    *proxypool.Pool
 }
 
-// New creates a new web collector.
 func New(serper *search.SerperClient) *Collector {
 	return &Collector{
 		serper:       serper,
 		static:       crawler.NewStaticCrawler(),
 		dynamic:      crawler.NewDynamicCrawler(),
 		tmeValidator: crawler.NewTMeValidator(),
+		logger:       plugin.NopLogger(),
 	}
 }
 
-func (c *Collector) Name() string { return "web_collector" }
-
+func (c *Collector) Name() string            { return "web_collector" }
+func (c *Collector) SetLogger(l plugin.TaskLogger) { c.logger = l }
 func (c *Collector) Stop() error {
 	c.stopped.Store(true)
 	return nil
 }
 
-// Run executes the web collection pipeline:
-// 1. For each keyword, search via Serper API
-// 2. Classify results: t.me links -> direct extract, web pages -> crawl
-// 3. Crawl pages, extract TG usernames and contact info
-// 4. Call callback for each merchant found
-//
-// cfg keys: (none required — keywords come from DB via the task manager)
-// The cfg map can contain:
-//   - "keywords": []string — override keywords (optional)
 func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(plugin.MerchantData)) error {
 	c.stopped.Store(false)
 
+	// Apply proxy pool or single proxy
+	if pool, ok := cfg["proxy_pool"].(*proxypool.Pool); ok && pool != nil {
+		c.proxyPool = pool
+		log.Printf("[web_collector] using proxy pool with %d proxies", pool.Size())
+	} else if proxyURL, ok := cfg["proxy_url"].(string); ok && proxyURL != "" {
+		c.static.SetProxy(proxyURL)
+		log.Printf("[web_collector] using proxy: %s", proxyURL)
+	}
+
 	if c.serper == nil {
 		log.Println("[web_collector] no serper client configured, skipping")
 		return nil
@@ -66,54 +67,48 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 		return nil
 	}
 
-	for _, kw := range keywords {
+	queries := expandSearchQueries(keywords)
+
+	for _, q := range queries {
 		if c.stopped.Load() || ctx.Err() != nil {
 			break
 		}
 
-		log.Printf("[web_collector] searching: %s", kw)
+		// Rotate proxy for each query if using pool
+		c.rotateProxy()
 
-		results, err := c.serper.Search(ctx, kw)
-		if err != nil {
-			log.Printf("[web_collector] search error for %q: %v", kw, err)
-			continue
-		}
+		log.Printf("[web_collector] searching: %s", q)
 
-		for _, r := range results {
-			if c.stopped.Load() || ctx.Err() != nil {
-				break
+		// ── Organic search ──
+		t0 := time.Now()
+		results, err := c.serper.Search(ctx, q)
+		if err != nil {
+			log.Printf("[web_collector] search error: %v", err)
+			c.logger.LogError("search", "", err.Error())
+		} else {
+			log.Printf("[web_collector] organic search %q: %d results in %dms", q, len(results), time.Since(t0).Milliseconds())
+			for i, r := range results {
+				c.logger.LogSearchResult(q+" [organic]", i+1, r.Title, r.URL, r.Snippet)
 			}
+			c.processResults(ctx, results, q, callback)
+		}
 
-			classification := search.ClassifyURL(r.URL)
+		time.Sleep(1 * time.Second)
 
-			switch classification {
-			case "tg_channel":
-				// Direct t.me link — extract username immediately
-				username := extractTGUsername(r.URL)
-				if username == "" {
-					continue
-				}
-				callback(plugin.MerchantData{
-					TgUsername: username,
-					TgLink:     "https://t.me/" + username,
-					SourceType: "web",
-					SourceName: r.Title,
-					SourceURL:  r.URL,
-				})
-
-			case "nav_site":
-				// Crawl the page for TG links and contacts
-				c.crawlPage(ctx, r.URL, r.Title, callback)
-
-			default:
-				// "discard" or unknown — also try rule filter for non-blacklisted
-				if crawler.RuleFilter(r.URL) != crawler.FilterDiscard {
-					c.crawlPage(ctx, r.URL, r.Title, callback)
-				}
+		// ── Video search ──
+		if c.stopped.Load() || ctx.Err() != nil {
+			break
+		}
+		videoResults, err := c.serper.SearchVideos(ctx, q)
+		if err != nil {
+			c.logger.LogError("search_videos", "", err.Error())
+		} else {
+			for i, r := range videoResults {
+				c.logger.LogSearchResult(q+" [video]", i+1, r.Title, r.URL, r.Snippet)
 			}
+			c.processResults(ctx, videoResults, q, callback)
 		}
 
-		// Delay between keywords
 		select {
 		case <-ctx.Done():
 			return nil
@@ -125,106 +120,316 @@ func (c *Collector) Run(ctx context.Context, cfg map[string]any, callback func(p
 	return nil
 }
 
-// crawlPage fetches a page and extracts merchants from it.
-func (c *Collector) crawlPage(ctx context.Context, pageURL, title string, callback func(plugin.MerchantData)) {
-	// Rule-based filter (no LLM)
-	filterResult := crawler.RuleFilter(pageURL)
-	if filterResult == crawler.FilterDiscard {
+// processResults handles search results with full logging at every node.
+func (c *Collector) processResults(ctx context.Context, results []search.SearchResult, query string, callback func(plugin.MerchantData)) {
+	for _, r := range results {
+		if c.stopped.Load() || ctx.Err() != nil {
+			break
+		}
+
+		// ── Node 1: Extract from snippet text ──
+		snippetText := r.Title + " " + r.Snippet
+		c.extractFromSnippet(snippetText, r.Title, r.URL, callback)
+
+		// ── Node 2: Extract URLs from snippet → crawl them ──
+		snippetURLs := reURL.FindAllString(r.Snippet, -1)
+		for _, sURL := range snippetURLs {
+			if c.stopped.Load() || ctx.Err() != nil {
+				break
+			}
+			sURL = strings.TrimRight(sURL, ".,;)\"'")
+
+			if strings.Contains(sURL, "t.me/") || strings.Contains(sURL, "telegram.me/") {
+				username := extractTGUsername(sURL)
+				if username != "" {
+					md := plugin.MerchantData{
+						TgUsername: username, TgLink: "https://t.me/" + username,
+						SourceType: "web", SourceName: r.Title, SourceURL: r.URL,
+					}
+					c.logger.LogMerchantFound(md, "snippet_tme_url", 0, r.URL)
+					callback(md)
+				}
+				continue
+			}
+
+			if isBlacklistDomain(sURL) {
+				c.logger.LogSkip("crawl_snippet_url", sURL, "blacklisted_domain")
+				continue
+			}
+			// Crawl URLs found inside snippets — depth=1, parent is the serper result
+			c.crawlAndExtract(ctx, sURL, r.URL, 1, r.Title, callback)
+		}
+
+		// ── Node 3: Crawl the result URL itself ──
+		classification := search.ClassifyURL(r.URL)
+		c.logger.LogSkip("classify", r.URL, classification) // log classification decision
+
+		switch classification {
+		case "tg_channel":
+			username := extractTGUsername(r.URL)
+			if username != "" {
+				md := plugin.MerchantData{
+					TgUsername: username, TgLink: "https://t.me/" + username,
+					SourceType: "web", SourceName: r.Title, SourceURL: r.URL,
+				}
+				c.logger.LogMerchantFound(md, "direct_tme_link", 0, "")
+				callback(md)
+			}
+
+		case "nav_site", "web_page":
+			if crawler.RuleFilter(r.URL) != crawler.FilterDiscard {
+				c.crawlAndExtract(ctx, r.URL, "", 0, r.Title, callback)
+			} else {
+				c.logger.LogSkip("crawl", r.URL, "rule_filter_discard")
+			}
+
+		default:
+			c.logger.LogSkip("crawl", r.URL, "classification_discard")
+		}
+	}
+}
+
+// extractFromSnippet extracts contacts from snippet/title text and logs everything.
+func (c *Collector) extractFromSnippet(text, title, sourceURL string, callback func(plugin.MerchantData)) {
+	contacts := extractor.ExtractAll(text)
+	var usernames []string
+	for _, info := range contacts {
+		if info.TgUsername == "" {
+			continue
+		}
+		usernames = append(usernames, info.TgUsername)
+		md := plugin.MerchantData{
+			TgUsername: info.TgUsername, TgLink: "https://t.me/" + info.TgUsername,
+			Website: info.Website, Email: info.Email, Phone: info.Phone,
+			SourceType: "web", SourceName: title, SourceURL: sourceURL,
+			OriginalText: text,
+		}
+		c.logger.LogMerchantFound(md, "snippet_regex", 0, "")
+		callback(md)
+	}
+	// Always log snippet extraction — even if empty (for audit: "we looked, nothing found")
+	c.logger.LogSnippetExtract(sourceURL, text, usernames)
+}
+
+// rotateProxy switches to the next proxy in the pool (if pool mode).
+// Returns the proxy URL being used (for health reporting).
+func (c *Collector) rotateProxy() string {
+	if c.proxyPool == nil {
+		return ""
+	}
+	nextURL := c.proxyPool.Next()
+	if nextURL != "" {
+		c.static.SetProxy(nextURL)
+		log.Printf("[web_collector] rotated to proxy: %s", nextURL)
+	}
+	return nextURL
+}
+
+// reportProxyResult reports success/failure to the proxy pool for a specific proxy.
+func (c *Collector) reportProxyResult(proxyURL string, err error) {
+	if c.proxyPool == nil || proxyURL == "" {
+		return
+	}
+	if err != nil {
+		c.proxyPool.ReportFailure(proxyURL)
+	} else {
+		c.proxyPool.ReportSuccess(proxyURL)
+	}
+}
+
+// crawlAndExtract fetches a page, extracts contacts, and follows sub-links.
+// depth tracks how deep we are from the original serper result.
+// parentURL tracks which page led us here.
+func (c *Collector) crawlAndExtract(ctx context.Context, pageURL, parentURL string, depth int, title string, callback func(plugin.MerchantData)) {
+	if depth > 2 {
+		c.logger.LogSkip("crawl", pageURL, "max_depth_exceeded")
 		return
 	}
-	// FilterUncertain: per requirements, discard without AI
-	// FilterValid: proceed
 
-	// Try static first, fallback to dynamic
+	// Rotate proxy and capture which proxy is being used
+	usedProxy := c.rotateProxy()
+
+	// ── Fetch page ──
+	t0 := time.Now()
 	result := c.static.Crawl(ctx, pageURL)
+	c.reportProxyResult(usedProxy, result.Error)
 	if result.Error != nil || result.HTML == "" {
-		result = c.dynamic.Crawl(ctx, pageURL)
+		// On failure with pool, try once more with next proxy
+		if c.proxyPool != nil && result.Error != nil {
+			usedProxy = c.rotateProxy()
+			result = c.static.Crawl(ctx, pageURL)
+			c.reportProxyResult(usedProxy, result.Error)
+		}
+		if result.Error != nil || result.HTML == "" {
+			result = c.dynamic.Crawl(ctx, pageURL)
+		}
 	}
+	dur := time.Since(t0)
+
 	if result.Error != nil || result.HTML == "" {
+		c.logger.LogCrawlPage(pageURL, parentURL, depth, "", nil, 0, result.Error, dur)
 		return
 	}
 
-	// Chinese content filter
-	snippet := result.HTML
-	if len(snippet) > 5000 {
-		snippet = snippet[:5000]
+	// Content filter
+	hasTgLinks := len(result.TgLinks) > 0
+	if !hasTgLinks {
+		snippet := result.HTML
+		if len(snippet) > 5000 {
+			snippet = snippet[:5000]
+		}
+		if !extractor.ContainsChinese(snippet, 0) && !extractor.HasContact(snippet) {
+			c.logger.LogCrawlPage(pageURL, parentURL, depth, snippet, nil, len(result.Links), nil, dur)
+			c.logger.LogSkip("crawl", pageURL, "no_chinese_no_contact")
+			return
+		}
 	}
-	if !extractor.ContainsChinese(snippet, 0) {
-		return
+
+	// ── Log crawl with content summary ──
+	htmlSummary := result.HTML
+	if len(htmlSummary) > 2000 {
+		htmlSummary = htmlSummary[:2000]
 	}
+	c.logger.LogCrawlPage(pageURL, parentURL, depth, htmlSummary, result.TgLinks, len(result.Links), nil, dur)
 
-	// Process t.me links found on the page
+	// ── Extract from t.me links in <a href> ──
+	seenUsernames := map[string]bool{}
 	for _, tgLink := range result.TgLinks {
 		username := crawler.ExtractTGUsername(tgLink)
-		if username == "" {
+		if username == "" || seenUsernames[strings.ToLower(username)] {
 			continue
 		}
-		// t.me dead check (free, unlimited)
-		if !c.tmeValidator.IsAlive(ctx, username) {
-			continue
+		seenUsernames[strings.ToLower(username)] = true
+		md := plugin.MerchantData{
+			TgUsername: username, TgLink: "https://t.me/" + username,
+			SourceType: "web", SourceName: title, SourceURL: pageURL,
 		}
-		callback(plugin.MerchantData{
-			TgUsername: username,
-			TgLink:     "https://t.me/" + username,
-			SourceType: "web",
-			SourceName: title,
-			SourceURL:  pageURL,
-		})
+		c.logger.LogMerchantFound(md, "crawl_href", depth, parentURL)
+		callback(md)
 	}
 
-	// Process other links — crawl merchant sub-pages for contact info
-	for _, link := range result.Links {
-		if c.stopped.Load() || ctx.Err() != nil {
-			break
-		}
-		// Skip TG links (already processed) and blacklisted
-		if strings.Contains(link, "t.me") || strings.Contains(link, "telegram.me") {
+	// ── Extract from page text ──
+	allContacts := extractor.ExtractAll(result.HTML)
+	var extractedNames []string
+	for _, info := range allContacts {
+		if info.TgUsername == "" || seenUsernames[strings.ToLower(info.TgUsername)] {
 			continue
 		}
-		if crawler.RuleFilter(link) == crawler.FilterDiscard {
-			continue
+		seenUsernames[strings.ToLower(info.TgUsername)] = true
+		extractedNames = append(extractedNames, info.TgUsername)
+		md := plugin.MerchantData{
+			TgUsername: info.TgUsername, TgLink: "https://t.me/" + info.TgUsername,
+			Website: info.Website, Email: info.Email, Phone: info.Phone,
+			SourceType: "web", SourceName: title, SourceURL: pageURL,
+		}
+		c.logger.LogMerchantFound(md, "crawl_text", depth, parentURL)
+		callback(md)
+	}
+
+	// Log page extraction results
+	contentSample := result.HTML
+	if len(contentSample) > 1000 {
+		contentSample = contentSample[:1000]
+	}
+	c.logger.LogPageExtract(pageURL, parentURL, depth, contentSample, extractedNames)
+
+	// ── Follow sub-links to deeper pages (depth+1) ──
+	if depth < 2 {
+		subPages := collectSubPages(pageURL, result.Links)
+		for _, link := range subPages {
+			if c.stopped.Load() || ctx.Err() != nil {
+				break
+			}
+			if strings.Contains(link, "t.me") || strings.Contains(link, "telegram.me") {
+				continue
+			}
+			if crawler.RuleFilter(link) == crawler.FilterDiscard {
+				continue
+			}
+			c.crawlAndExtract(ctx, link, pageURL, depth+1, title, callback)
 		}
-		c.crawlMerchantSite(ctx, link, pageURL, callback)
 	}
 }
 
-// crawlMerchantSite crawls a merchant's website for contact info.
-func (c *Collector) crawlMerchantSite(ctx context.Context, siteURL, sourceURL string, callback func(plugin.MerchantData)) {
-	subPages := []string{siteURL, siteURL + "/contact", siteURL + "/about"}
+// collectSubPages picks sub-pages worth crawling from a page's links.
+// Prioritizes contact/about/support pages plus same-domain internal links.
+func collectSubPages(baseURL string, links []string) []string {
+	baseDomain := extractDomain(baseURL)
+	if baseDomain == "" {
+		return nil
+	}
 
-	for _, page := range subPages {
-		if ctx.Err() != nil {
-			break
+	// Priority paths
+	contactPaths := []string{"/contact", "/contact-us", "/about", "/about-us", "/support", "/faq", "/help"}
+	var priority, sameDomain []string
+	seen := map[string]bool{baseURL: true}
+
+	for _, link := range links {
+		if seen[link] {
+			continue
 		}
-		result := c.static.Crawl(ctx, page)
-		if result.Error != nil || result.HTML == "" {
+		seen[link] = true
+		linkDomain := extractDomain(link)
+		if linkDomain != baseDomain {
 			continue
 		}
+		lower := strings.ToLower(link)
+		isPriority := false
+		for _, p := range contactPaths {
+			if strings.Contains(lower, p) {
+				priority = append(priority, link)
+				isPriority = true
+				break
+			}
+		}
+		if !isPriority && len(sameDomain) < 5 {
+			sameDomain = append(sameDomain, link)
+		}
+	}
 
-		info := extractor.Extract(result.HTML)
-		if !info.HasContact {
-			continue
+	result := append(priority, sameDomain...)
+	if len(result) > 10 {
+		result = result[:10]
+	}
+	return result
+}
+
+func expandSearchQueries(keywords []string) []string {
+	suffixes := []string{
+		"",
+		" telegram",
+		" t.me",
+		" 电报",
+		" 联系方式 telegram",
+	}
+	seen := map[string]bool{}
+	var queries []string
+	for _, kw := range keywords {
+		for _, suffix := range suffixes {
+			q := kw + suffix
+			if !seen[q] {
+				seen[q] = true
+				queries = append(queries, q)
+			}
+		}
+	}
+	return queries
+}
+
+func isBlacklistDomain(u string) bool {
+	bl := []string{"youtube.com", "google.com", "twitter.com", "facebook.com",
+		"instagram.com", "bit.ly", "gstatic.com", "wikipedia.org", "x.com"}
+	lower := strings.ToLower(u)
+	for _, b := range bl {
+		if strings.Contains(lower, b) {
+			return true
 		}
-		if info.TgUsername == "" {
-			continue // per requirements: no tg_username = don't insert
-		}
-
-		callback(plugin.MerchantData{
-			TgUsername:   info.TgUsername,
-			TgLink:       "https://t.me/" + info.TgUsername,
-			Website:      siteURL,
-			Email:        info.Email,
-			Phone:        info.Phone,
-			SourceType:   "web",
-			SourceName:   extractDomain(siteURL),
-			SourceURL:    sourceURL,
-			OriginalText: "",
-		})
-		break // found contact info, stop
 	}
+	return false
 }
 
 var reTGUsername = regexp.MustCompile(`(?:t(?:elegram)?\.me)/([a-zA-Z][a-zA-Z0-9_]{4,31})`)
+var reURL = regexp.MustCompile(`https?://[^\s<>"'\x{4e00}-\x{9fa5}]+`)
 
 func extractTGUsername(rawURL string) string {
 	m := reTGUsername.FindStringSubmatch(rawURL)

+ 11 - 2
internal/processor/blacklist.go

@@ -24,6 +24,14 @@ var systemBots = []string{
 	"telegram", "telegramhints", "gif", "pic", "bing", "vid",
 	"bold", "vote", "like", "sticker", "music",
 	"channel_bot", "botfather", "spambot",
+	// Common false positives from HTML/code extraction
+	"github", "gmail", "email", "admin", "login", "signup",
+	"about", "contact", "support", "https", "style",
+	"script", "header", "footer", "button", "input",
+	"image", "video", "media", "share", "click",
+	"undefined", "object", "string", "number", "function",
+	"return", "const", "class", "export", "import",
+	"ssage", "messages", "channel", "username",
 }
 
 var reBase64 = regexp.MustCompile(`^[A-Za-z0-9_-]{16,24}$`)
@@ -64,8 +72,9 @@ func checkBlacklist(raw model.MerchantRaw) string {
 		}
 	}
 
-	// Original text non-Chinese
-	if raw.OriginalText != "" && !extractor.ContainsChinese(raw.OriginalText, 0) {
+	// Original text non-Chinese: only filter if text is long and has NO Chinese at all
+	// Short texts and mixed-language texts are allowed
+	if len(raw.OriginalText) > 200 && !extractor.ContainsChinese(raw.OriginalText, 0) {
 		return "invalid"
 	}
 

+ 91 - 26
internal/processor/pipeline.go

@@ -2,8 +2,10 @@ package processor
 
 import (
 	"context"
+	"fmt"
 	"log"
 	"spider/internal/model"
+	"spider/internal/plugin"
 	"spider/internal/store"
 	"time"
 
@@ -15,9 +17,10 @@ type ProgressFn func(step string, current, total int, message string)
 
 // Processor runs the 4-step cleaning pipeline.
 type Processor struct {
-	store     *store.Store
-	checker   *TMeChecker
+	store      *store.Store
+	checker    *TMeChecker
 	onProgress ProgressFn
+	logger     plugin.TaskLogger
 }
 
 // NewProcessor creates a new Processor.
@@ -25,6 +28,7 @@ func NewProcessor(s *store.Store) *Processor {
 	return &Processor{
 		store:   s,
 		checker: NewTMeChecker(),
+		logger:  plugin.NopLogger(),
 	}
 }
 
@@ -33,6 +37,11 @@ func (p *Processor) SetProgressFn(fn ProgressFn) {
 	p.onProgress = fn
 }
 
+// SetLogger sets the detail logger for cleaning pipeline audit.
+func (p *Processor) SetLogger(l plugin.TaskLogger) {
+	p.logger = l
+}
+
 func (p *Processor) report(step string, current, total int, msg string) {
 	if p.onProgress != nil {
 		p.onProgress(step, current, total, msg)
@@ -41,30 +50,68 @@ func (p *Processor) report(step string, current, total int, msg string) {
 
 // ProcessResult summarizes a processor run.
 type ProcessResult struct {
-	InputCount     int
-	AliveCount     int
-	PassedCount    int
-	DedupedCount   int
-	OutputCount    int
-	HotCount       int
-	WarmCount      int
-	ColdCount      int
+	InputCount  int
+	AliveCount  int
+	PassedCount int
+	DedupedCount int
+	OutputCount int
+	HotCount    int
+	WarmCount   int
+	ColdCount   int
 }
 
 // Process runs the 4-step pipeline on raw merchants with status="raw".
+// Processes in batches to avoid loading all records into memory.
 func (p *Processor) Process(ctx context.Context) (*ProcessResult, error) {
-	raws, err := p.store.ListRawByStatus("raw", 0)
-	if err != nil {
-		return nil, err
-	}
+	const batchSize = 2000
 
-	result := &ProcessResult{InputCount: len(raws)}
-	log.Printf("[processor] processing %d raw merchants", len(raws))
+	totalResult := &ProcessResult{}
+
+	for {
+		if ctx.Err() != nil {
+			return totalResult, ctx.Err()
+		}
 
-	if len(raws) == 0 {
-		return result, nil
+		raws, err := p.store.ListRawByStatus("raw", batchSize)
+		if err != nil {
+			return totalResult, err
+		}
+		if len(raws) == 0 {
+			break
+		}
+
+		result, err := p.processBatch(ctx, raws)
+		if err != nil {
+			return totalResult, err
+		}
+
+		// Merge results
+		totalResult.InputCount += result.InputCount
+		totalResult.AliveCount += result.AliveCount
+		totalResult.PassedCount += result.PassedCount
+		totalResult.DedupedCount += result.DedupedCount
+		totalResult.OutputCount += result.OutputCount
+		totalResult.HotCount += result.HotCount
+		totalResult.WarmCount += result.WarmCount
+		totalResult.ColdCount += result.ColdCount
+
+		// If we got less than batchSize, no more records
+		if len(raws) < batchSize {
+			break
+		}
 	}
 
+	log.Printf("[processor] done: input=%d, Hot=%d, Warm=%d, Cold=%d",
+		totalResult.InputCount, totalResult.HotCount, totalResult.WarmCount, totalResult.ColdCount)
+
+	return totalResult, nil
+}
+
+// processBatch runs the 4-step pipeline on a batch of raw merchants.
+func (p *Processor) processBatch(ctx context.Context, raws []model.MerchantRaw) (*ProcessResult, error) {
+	result := &ProcessResult{InputCount: len(raws)}
+	log.Printf("[processor] processing batch of %d raw merchants", len(raws))
+
 	// Step 1: t.me dead account check
 	p.report("tmechecker", 0, len(raws), "开始死号预检...")
 	alive, dead := p.checker.Filter(ctx, raws)
@@ -72,11 +119,17 @@ func (p *Processor) Process(ctx context.Context) (*ProcessResult, error) {
 	log.Printf("[processor] step1 tmechecker: %d alive, %d dead", len(alive), len(dead))
 	p.report("tmechecker", len(raws), len(raws), "死号预检完成")
 
-	// Mark dead ones
+	// Mark dead ones (batch)
+	var deadIDs []uint
 	for _, d := range dead {
 		p.saveClean(d, "invalid", "", "Cold", nil, 1)
-		p.store.UpdateRawStatus(d.ID, "done")
+		deadIDs = append(deadIDs, d.ID)
+		p.logger.LogCleanStep(d.TgUsername, "tmechecker", "dead", "t.me page not found or no profile")
 	}
+	for _, a := range alive {
+		p.logger.LogCleanStep(a.TgUsername, "tmechecker", "alive", "")
+	}
+	p.store.BatchUpdateRawStatus(deadIDs, "done")
 
 	if ctx.Err() != nil {
 		return result, ctx.Err()
@@ -89,10 +142,13 @@ func (p *Processor) Process(ctx context.Context) (*ProcessResult, error) {
 	log.Printf("[processor] step2 blacklist: %d passed, %d blocked", len(blResult.Passed), len(blResult.Blocked))
 	p.report("blacklist", len(alive), len(alive), "黑名单完成")
 
+	var blockedIDs []uint
 	for _, b := range blResult.Blocked {
 		p.saveClean(b.Raw, b.Status, "", "Cold", nil, 1)
-		p.store.UpdateRawStatus(b.Raw.ID, "done")
+		blockedIDs = append(blockedIDs, b.Raw.ID)
+		p.logger.LogCleanStep(b.Raw.TgUsername, "blacklist", b.Status, "blacklist rule matched")
 	}
+	p.store.BatchUpdateRawStatus(blockedIDs, "done")
 
 	if ctx.Err() != nil {
 		return result, ctx.Err()
@@ -105,20 +161,28 @@ func (p *Processor) Process(ctx context.Context) (*ProcessResult, error) {
 	log.Printf("[processor] step3 dedup: %d keepers, %d duplicates", len(dedupResult.Keepers), len(dedupResult.Duplicates))
 	p.report("dedup", len(blResult.Passed), len(blResult.Passed), "去重完成")
 
+	var dupIDs []uint
 	for _, dup := range dedupResult.Duplicates {
 		p.saveClean(dup, "duplicate", "", "Cold", nil, 1)
-		p.store.UpdateRawStatus(dup.ID, "done")
+		dupIDs = append(dupIDs, dup.ID)
+		p.logger.LogCleanStep(dup.TgUsername, "dedup", "duplicate", "merged into keeper")
+	}
+	for _, k := range dedupResult.Keepers {
+		p.logger.LogCleanStep(k.Best.TgUsername, "dedup", "keeper", fmt.Sprintf("source_count=%d", k.SourceCount))
 	}
+	p.store.BatchUpdateRawStatus(dupIDs, "done")
 
 	if ctx.Err() != nil {
 		return result, ctx.Err()
 	}
 
-	// Step 4: Tag + grade
+	// Step 4: Tag + grade (load rules from DB)
 	p.report("tagger", 0, len(dedupResult.Keepers), "打标签分等级...")
-	tagged := TagAndGrade(dedupResult.Keepers)
+	gradingCfg, _ := p.store.GetGradingConfig()
+	tagged := TagAndGradeWithConfig(dedupResult.Keepers, gradingCfg)
 	log.Printf("[processor] step4 tagger: %d tagged", len(tagged))
 
+	var keeperIDs []uint
 	for _, t := range tagged {
 		switch t.Level {
 		case "Hot":
@@ -131,12 +195,13 @@ func (p *Processor) Process(ctx context.Context) (*ProcessResult, error) {
 
 		sources := MarshalSources(t.Merged.AllSources)
 		p.saveClean(t.Merged.Best, "valid", t.IndustryTag, t.Level, sources, t.Merged.SourceCount)
-		p.store.UpdateRawStatus(t.Merged.Best.ID, "done")
+		keeperIDs = append(keeperIDs, t.Merged.Best.ID)
+		p.logger.LogCleanStep(t.Merged.Best.TgUsername, "tagger", t.Level, fmt.Sprintf("industry=%s", t.IndustryTag))
 	}
+	p.store.BatchUpdateRawStatus(keeperIDs, "done")
 
 	result.OutputCount = len(tagged)
 	p.report("tagger", len(tagged), len(tagged), "分级完成")
-	log.Printf("[processor] done: Hot=%d, Warm=%d, Cold=%d", result.HotCount, result.WarmCount, result.ColdCount)
 
 	return result, nil
 }

+ 63 - 29
internal/processor/tagger.go

@@ -2,28 +2,19 @@ package processor
 
 import (
 	"strings"
-)
 
-// Industry keywords for matching. Extensible via config in the future.
-var industryKeywords = map[string][]string{
-	"机场": {
-		"机场", "节点", "订阅", "clash", "v2ray", "trojan", "shadowsocks", "ss/ssr",
-		"翻墙", "梯子", "科学上网", "加速器", "proxy", "代理",
-	},
-	"VPN": {
-		"vpn", "VPN", "wireguard", "openvpn",
-	},
-}
+	"spider/internal/model"
+)
 
-// TagAndGrade assigns industry_tag and level (Hot/Warm/Cold) to each merchant.
-func TagAndGrade(merchants []MergedMerchant) []TaggedMerchant {
+// TagAndGradeWithConfig assigns industry_tag and level using configurable rules.
+func TagAndGradeWithConfig(merchants []MergedMerchant, cfg *model.GradingConfig) []TaggedMerchant {
 	var result []TaggedMerchant
 	for _, m := range merchants {
 		tagged := TaggedMerchant{Merged: m}
 
-		// Industry matching: check merchant_name + original_text
+		// Industry matching from config
 		text := strings.ToLower(m.Best.MerchantName + " " + m.Best.OriginalText)
-		for industry, keywords := range industryKeywords {
+		for industry, keywords := range cfg.IndustryKeywords {
 			for _, kw := range keywords {
 				if strings.Contains(text, strings.ToLower(kw)) {
 					tagged.IndustryTag = industry
@@ -35,32 +26,75 @@ func TagAndGrade(merchants []MergedMerchant) []TaggedMerchant {
 			}
 		}
 
-		// Inherit from raw if no match found
+		// Inherit from raw if no match
 		if tagged.IndustryTag == "" && m.Best.IndustryTag != "" {
 			tagged.IndustryTag = m.Best.IndustryTag
 		}
 
-		// Level grading
-		hasIndustry := tagged.IndustryTag != ""
-		hasWebsiteOrEmail := m.Best.Website != "" || m.Best.Email != ""
-
-		switch {
-		case hasIndustry && hasWebsiteOrEmail:
-			tagged.Level = "Hot"
-		case hasIndustry:
-			tagged.Level = "Warm"
-		default:
-			tagged.Level = "Cold"
-		}
+		// Level grading from config rules
+		tagged.Level = gradeByRules(m, tagged.IndustryTag, cfg.Levels)
 
 		result = append(result, tagged)
 	}
 	return result
 }
 
+// gradeByRules evaluates levels in order; first level with any matching rule wins.
+// Last level (usually "Cold") is the fallback when it has no rules.
+func gradeByRules(m MergedMerchant, industryTag string, levels []model.LevelDef) string {
+	hasIndustry := industryTag != ""
+	hasWebsite := m.Best.Website != ""
+	hasEmail := m.Best.Email != ""
+	hasPhone := m.Best.Phone != ""
+	sourceCount := m.SourceCount
+
+	for _, level := range levels {
+		if len(level.Rules) == 0 {
+			// No rules = fallback level (last one)
+			continue
+		}
+		for _, rule := range level.Rules {
+			if matchRule(rule, hasIndustry, hasWebsite, hasEmail, hasPhone, sourceCount) {
+				return level.Key
+			}
+		}
+	}
+
+	// Return the last level as default fallback
+	if len(levels) > 0 {
+		return levels[len(levels)-1].Key
+	}
+	return "Cold"
+}
+
+func matchRule(rule model.GradeRule, hasIndustry, hasWebsite, hasEmail, hasPhone bool, sourceCount int) bool {
+	if rule.HasIndustry != nil && *rule.HasIndustry != hasIndustry {
+		return false
+	}
+	if rule.HasWebsite != nil && *rule.HasWebsite != hasWebsite {
+		return false
+	}
+	if rule.HasEmail != nil && *rule.HasEmail != hasEmail {
+		return false
+	}
+	if rule.HasPhone != nil && *rule.HasPhone != hasPhone {
+		return false
+	}
+	if rule.MinSourceCount != nil && sourceCount < *rule.MinSourceCount {
+		return false
+	}
+	return true
+}
+
+// TagAndGrade is the backward-compatible version using default config.
+func TagAndGrade(merchants []MergedMerchant) []TaggedMerchant {
+	cfg := model.DefaultGradingConfig()
+	return TagAndGradeWithConfig(merchants, &cfg)
+}
+
 // TaggedMerchant is the final output of the processor.
 type TaggedMerchant struct {
 	Merged      MergedMerchant
 	IndustryTag string
-	Level       string // Hot / Warm / Cold
+	Level       string
 }

+ 83 - 6
internal/processor/tmechecker.go

@@ -5,22 +5,99 @@ import (
 	"log"
 	"spider/internal/crawler"
 	"spider/internal/model"
+	"sync"
 )
 
 // TMeChecker filters dead TG accounts via HTTP t.me page scraping.
-// Free, unlimited, 100% accurate.
+// Uses a concurrent worker pool for performance.
 type TMeChecker struct {
-	validator *crawler.TMeValidator
+	validator   *crawler.TMeValidator
+	concurrency int
 }
 
-// NewTMeChecker creates a new TMeChecker.
+// NewTMeChecker creates a new TMeChecker with default concurrency.
 func NewTMeChecker() *TMeChecker {
-	return &TMeChecker{validator: crawler.NewTMeValidator()}
+	return &TMeChecker{
+		validator:   crawler.NewTMeValidator(),
+		concurrency: 10,
+	}
+}
+
+// checkedMerchant pairs a raw merchant with its alive status.
+type checkedMerchant struct {
+	raw   model.MerchantRaw
+	alive bool
 }
 
-// Filter checks each raw merchant's tg_username against t.me.
-// Returns alive merchants; dead ones are returned separately with status="invalid".
+// Filter checks each raw merchant's tg_username against t.me concurrently.
+// Returns alive merchants; dead ones are returned separately.
 func (c *TMeChecker) Filter(ctx context.Context, raws []model.MerchantRaw) (alive []model.MerchantRaw, dead []model.MerchantRaw) {
+	if len(raws) == 0 {
+		return nil, nil
+	}
+
+	// For small batches, run sequentially
+	if len(raws) <= 3 {
+		return c.filterSequential(ctx, raws)
+	}
+
+	workers := c.concurrency
+	if workers > len(raws) {
+		workers = len(raws)
+	}
+
+	jobs := make(chan model.MerchantRaw, len(raws))
+	results := make(chan checkedMerchant, len(raws))
+
+	// Start workers
+	var wg sync.WaitGroup
+	for i := 0; i < workers; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for raw := range jobs {
+				if ctx.Err() != nil {
+					results <- checkedMerchant{raw: raw, alive: true} // assume alive on cancel
+					continue
+				}
+				if raw.TgUsername == "" {
+					results <- checkedMerchant{raw: raw, alive: true}
+					continue
+				}
+				isAlive := c.validator.IsAlive(ctx, raw.TgUsername)
+				if !isAlive {
+					log.Printf("[tmechecker] dead: @%s", raw.TgUsername)
+				}
+				results <- checkedMerchant{raw: raw, alive: isAlive}
+			}
+		}()
+	}
+
+	// Send jobs
+	for _, raw := range raws {
+		jobs <- raw
+	}
+	close(jobs)
+
+	// Collect results in background
+	go func() {
+		wg.Wait()
+		close(results)
+	}()
+
+	for r := range results {
+		if r.alive {
+			alive = append(alive, r.raw)
+		} else {
+			dead = append(dead, r.raw)
+		}
+	}
+
+	return
+}
+
+// filterSequential is the simple path for small batches.
+func (c *TMeChecker) filterSequential(ctx context.Context, raws []model.MerchantRaw) (alive []model.MerchantRaw, dead []model.MerchantRaw) {
 	for _, raw := range raws {
 		if ctx.Err() != nil {
 			break

+ 260 - 0
internal/proxy/pool.go

@@ -0,0 +1,260 @@
+package proxy
+
+import (
+	"fmt"
+	"log"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+// Entry represents a single proxy in the pool.
+type Entry struct {
+	mu       sync.Mutex
+	ID       uint
+	Name     string
+	URL      string // protocol://[user:pass@]host:port
+	Region   string
+	failures int32
+	disabled bool
+	coolDown time.Time
+}
+
+func (e *Entry) isAvailable(now time.Time) bool {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	if !e.disabled {
+		return true
+	}
+	if now.After(e.coolDown) {
+		e.disabled = false
+		e.failures = 0
+		return true
+	}
+	return false
+}
+
+func (e *Entry) markSuccess() {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.failures = 0
+	e.disabled = false
+}
+
+func (e *Entry) markFailure(maxFailures int32, coolDur time.Duration) {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.failures++
+	if maxFailures > 0 && e.failures >= maxFailures {
+		e.disabled = true
+		e.coolDown = time.Now().Add(coolDur)
+		log.Printf("[proxy_pool] proxy %s (%s) disabled for %v after %d failures",
+			e.Name, e.URL, coolDur, e.failures)
+	}
+}
+
+func (e *Entry) snapshot() EntrySnapshot {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	return EntrySnapshot{
+		ID:       e.ID,
+		Name:     e.Name,
+		URL:      e.URL,
+		Region:   e.Region,
+		Failures: e.failures,
+		Disabled: e.disabled,
+		CoolDown: e.coolDown,
+	}
+}
+
+// EntrySnapshot is a point-in-time copy of an Entry for display.
+type EntrySnapshot struct {
+	ID       uint      `json:"id"`
+	Name     string    `json:"name"`
+	URL      string    `json:"url"`
+	Region   string    `json:"region"`
+	Failures int32     `json:"failures"`
+	Disabled bool      `json:"disabled"`
+	CoolDown time.Time `json:"cool_down"`
+}
+
+// Pool is a thread-safe proxy pool with round-robin rotation and health tracking.
+type Pool struct {
+	mu      sync.RWMutex
+	entries []*Entry
+	index   atomic.Uint64
+
+	maxFailures int32         // disable proxy after this many consecutive failures
+	coolDown    time.Duration // cooldown period after disabling
+}
+
+// NewPool creates a new proxy pool.
+// maxFailures: number of consecutive failures before disabling a proxy (0 = never disable).
+// coolDown: how long to disable a failed proxy before retrying.
+func NewPool(maxFailures int, coolDown time.Duration) *Pool {
+	return &Pool{
+		maxFailures: int32(maxFailures),
+		coolDown:    coolDown,
+	}
+}
+
+// Add adds a proxy entry to the pool.
+func (p *Pool) Add(id uint, name, proxyURL, region string) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	p.entries = append(p.entries, &Entry{
+		ID:     id,
+		Name:   name,
+		URL:    proxyURL,
+		Region: region,
+	})
+}
+
+// Size returns the total number of proxies in the pool.
+func (p *Pool) Size() int {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	return len(p.entries)
+}
+
+// Next returns the next available proxy URL using round-robin.
+// Returns empty string if no proxies are available.
+func (p *Pool) Next() string {
+	entry := p.NextEntry()
+	if entry == nil {
+		return ""
+	}
+	return entry.URL
+}
+
+// NextEntry returns the next available proxy entry using round-robin.
+// Skips disabled proxies (unless cooldown has expired).
+func (p *Pool) NextEntry() *Entry {
+	p.mu.RLock()
+	n := len(p.entries)
+	entries := p.entries
+	p.mu.RUnlock()
+
+	if n == 0 {
+		return nil
+	}
+
+	now := time.Now()
+	// Try up to n times to find an available proxy
+	for i := 0; i < n; i++ {
+		idx := p.index.Add(1) - 1
+		entry := entries[int(idx)%n]
+		if entry.isAvailable(now) {
+			return entry
+		}
+	}
+
+	// All proxies disabled — return the one with earliest cooldown expiry
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	var best *Entry
+	var bestCool time.Time
+	for _, e := range p.entries {
+		snap := e.snapshot()
+		if best == nil || snap.CoolDown.Before(bestCool) {
+			best = e
+			bestCool = snap.CoolDown
+		}
+	}
+	if best != nil {
+		log.Printf("[proxy_pool] all proxies disabled, using %s (cooldown until %s)", best.Name, bestCool.Format("15:04:05"))
+	}
+	return best
+}
+
+// ReportSuccess marks a proxy as healthy, resetting its failure count.
+func (p *Pool) ReportSuccess(proxyURL string) {
+	entry := p.findByURL(proxyURL)
+	if entry != nil {
+		entry.markSuccess()
+	}
+}
+
+// ReportFailure records a failure for the proxy.
+// If consecutive failures exceed maxFailures, the proxy is temporarily disabled.
+func (p *Pool) ReportFailure(proxyURL string) {
+	entry := p.findByURL(proxyURL)
+	if entry != nil {
+		entry.markFailure(p.maxFailures, p.coolDown)
+	}
+}
+
+func (p *Pool) findByURL(proxyURL string) *Entry {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	for _, e := range p.entries {
+		if e.URL == proxyURL {
+			return e
+		}
+	}
+	return nil
+}
+
+// ActiveCount returns the number of currently active (non-disabled) proxies.
+func (p *Pool) ActiveCount() int {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	count := 0
+	now := time.Now()
+	for _, e := range p.entries {
+		if e.isAvailable(now) {
+			count++
+		}
+	}
+	return count
+}
+
+// AllEntries returns a snapshot of all proxy entries for status display.
+func (p *Pool) AllEntries() []EntrySnapshot {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	result := make([]EntrySnapshot, len(p.entries))
+	for i, e := range p.entries {
+		result[i] = e.snapshot()
+	}
+	return result
+}
+
+// URLs returns all proxy URLs in the pool (including disabled ones).
+func (p *Pool) URLs() []string {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	urls := make([]string, 0, len(p.entries))
+	for _, e := range p.entries {
+		urls = append(urls, e.URL)
+	}
+	return urls
+}
+
+// Summary returns a human-readable summary of the pool state for logging.
+func (p *Pool) Summary() string {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
+	now := time.Now()
+	total := len(p.entries)
+	active := 0
+	var parts []string
+	for _, e := range p.entries {
+		snap := e.snapshot()
+		status := "ok"
+		if snap.Disabled && now.Before(snap.CoolDown) {
+			status = "disabled"
+		} else {
+			active++
+		}
+		if snap.Failures > 0 {
+			parts = append(parts, fmt.Sprintf("%s(%s,failures=%d)", snap.Name, status, snap.Failures))
+		}
+	}
+	summary := fmt.Sprintf("代理池: %d/%d 活跃", active, total)
+	if len(parts) > 0 {
+		summary += ", " + strings.Join(parts, ", ")
+	}
+	return summary
+}

+ 74 - 11
internal/search/serper.go

@@ -11,6 +11,7 @@ import (
 )
 
 const serperEndpoint = "https://google.serper.dev/search"
+const serperVideoEndpoint = "https://google.serper.dev/videos"
 
 // SerperClient Serper API 客户端
 type SerperClient struct {
@@ -37,7 +38,7 @@ type SearchResult struct {
 	Snippet string
 }
 
-// Search 搜索关键词,返回所有翻页结果
+// Search 搜索关键词,返回所有翻页结果(organic)
 func (c *SerperClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
 	var results []SearchResult
 	for page := 1; page <= c.maxPage; page++ {
@@ -53,6 +54,61 @@ func (c *SerperClient) Search(ctx context.Context, query string) ([]SearchResult
 	return results, nil
 }
 
+// SearchVideos 搜索视频结果 — YouTube 等视频描述中经常包含 TG 联系方式
+func (c *SerperClient) SearchVideos(ctx context.Context, query string) ([]SearchResult, error) {
+	body := map[string]interface{}{
+		"q":   query,
+		"num": c.perPage,
+		"gl":  "cn",
+		"hl":  "zh-cn",
+	}
+	data, _ := json.Marshal(body)
+
+	req, err := http.NewRequestWithContext(ctx, "POST", serperVideoEndpoint, bytes.NewReader(data))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("X-API-KEY", c.apiKey)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("serper videos API error: %d", resp.StatusCode)
+	}
+
+	var result struct {
+		Videos []struct {
+			Title   string `json:"title"`
+			Link    string `json:"link"`
+			Snippet string `json:"snippet"`
+			Channel string `json:"channel"`
+		} `json:"videos"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		return nil, err
+	}
+
+	var out []SearchResult
+	for _, v := range result.Videos {
+		// Combine snippet + title + channel for maximum extraction
+		combinedSnippet := v.Snippet
+		if v.Channel != "" {
+			combinedSnippet = v.Snippet + " " + v.Channel
+		}
+		out = append(out, SearchResult{
+			Title:   v.Title,
+			URL:     v.Link,
+			Snippet: combinedSnippet,
+		})
+	}
+	return out, nil
+}
+
 // searchPage 搜索单页
 func (c *SerperClient) searchPage(ctx context.Context, query string, page int) ([]SearchResult, error) {
 	body := map[string]interface{}{
@@ -100,42 +156,49 @@ func (c *SerperClient) searchPage(ctx context.Context, query string, page int) (
 }
 
 // ClassifyURL 判断 URL 类型
-// 返回: "tg_channel", "nav_site", "discard"
+// 返回: "tg_channel", "nav_site", "web_page", "discard"
 func ClassifyURL(rawURL string) string {
 	// t.me 链接
 	if strings.Contains(rawURL, "t.me/") || strings.Contains(rawURL, "telegram.me/") {
 		return "tg_channel"
 	}
 
+	u := strings.ToLower(rawURL)
+
 	// 社交媒体/大站黑名单
 	blacklistDomains := []string{
-		"twitter.com", "facebook.com", "instagram.com", "youtube.com",
+		"twitter.com", "x.com", "facebook.com", "instagram.com", "youtube.com",
 		"google.com", "baidu.com", "weibo.com", "zhihu.com",
-		"github.com", "stackoverflow.com", "wikipedia.org",
+		"stackoverflow.com", "wikipedia.org",
 		"amazon.com", "taobao.com", "jd.com", "tmall.com",
+		"apple.com", "microsoft.com", "qq.com",
 	}
 	for _, d := range blacklistDomains {
-		if strings.Contains(rawURL, d) {
+		if strings.Contains(u, d) {
 			return "discard"
 		}
 	}
 
 	// 黑名单扩展名
-	blacklistExt := []string{".apk", ".zip", ".pdf", ".exe", ".dmg", ".ipa"}
+	blacklistExt := []string{".apk", ".zip", ".pdf", ".exe", ".dmg", ".ipa", ".mp4", ".mp3"}
 	for _, ext := range blacklistExt {
-		if strings.HasSuffix(strings.ToLower(rawURL), ext) {
+		if strings.HasSuffix(u, ext) {
 			return "discard"
 		}
 	}
 
-	// 正向信号:导航站
-	navSignals := []string{"nav", "directory", "catalog", "list", "daohang", "dh"}
-	u := strings.ToLower(rawURL)
+	// 正向信号:导航站/聚合页
+	navSignals := []string{
+		"nav", "directory", "catalog", "list", "daohang", "dh",
+		"导航", "目录", "聚合", "推荐", "收录", "汇总",
+		"telegram", "channel", "group", "tg",
+	}
 	for _, sig := range navSignals {
 		if strings.Contains(u, sig) {
 			return "nav_site"
 		}
 	}
 
-	return "discard"
+	// 不再直接丢弃 — 普通网页也可能含联系方式,标记为 web_page 允许爬取
+	return "web_page"
 }

+ 144 - 0
internal/store/group_member_repo.go

@@ -0,0 +1,144 @@
+package store
+
+import (
+	"spider/internal/model"
+	"strings"
+	"time"
+)
+
+// SaveGroupMember records a group-member relationship (idempotent via unique index).
+func (s *Store) SaveGroupMember(groupUsername, memberUsername, groupTitle, sourceType string, taskID uint) {
+	groupUsername = strings.TrimPrefix(strings.TrimSpace(groupUsername), "@")
+	memberUsername = strings.TrimPrefix(strings.TrimSpace(memberUsername), "@")
+	if groupUsername == "" || memberUsername == "" || groupUsername == memberUsername {
+		return
+	}
+	gm := model.GroupMember{
+		GroupUsername:   groupUsername,
+		MemberUsername: memberUsername,
+		GroupTitle:     groupTitle,
+		SourceType:    sourceType,
+		TaskID:        taskID,
+		DiscoveredAt:  time.Now(),
+	}
+	// Use unique index for idempotency
+	s.DB.Where("group_username = ? AND member_username = ?", groupUsername, memberUsername).
+		FirstOrCreate(&gm)
+}
+
+// BatchSaveGroupMembers saves multiple group-member relationships (idempotent).
+// Returns the count of newly created records.
+func (s *Store) BatchSaveGroupMembers(groupUsername, groupTitle, sourceType string, memberUsernames []string) int {
+	groupUsername = strings.TrimPrefix(strings.TrimSpace(groupUsername), "@")
+	if groupUsername == "" {
+		return 0
+	}
+
+	created := 0
+	for _, mu := range memberUsernames {
+		mu = strings.TrimPrefix(strings.TrimSpace(mu), "@")
+		if mu == "" || mu == groupUsername {
+			continue
+		}
+		gm := model.GroupMember{
+			GroupUsername:   groupUsername,
+			MemberUsername: mu,
+			GroupTitle:     groupTitle,
+			SourceType:    sourceType,
+			DiscoveredAt:  time.Now(),
+		}
+		result := s.DB.Where("group_username = ? AND member_username = ?", groupUsername, mu).
+			FirstOrCreate(&gm)
+		if result.RowsAffected > 0 {
+			created++
+		}
+	}
+	return created
+}
+
+// ListMembersByGroup returns all members found in a group. Supports search by member username.
+func (s *Store) ListMembersByGroup(groupUsername string, page, pageSize int, search string) ([]model.GroupMember, int64, error) {
+	var items []model.GroupMember
+	var total int64
+	q := s.DB.Model(&model.GroupMember{}).Where("group_username = ?", groupUsername)
+	if search != "" {
+		like := "%" + strings.TrimPrefix(strings.TrimSpace(search), "@") + "%"
+		q = q.Where("member_username LIKE ?", like)
+	}
+	q.Count(&total)
+	offset := (page - 1) * pageSize
+	err := q.Order("discovered_at DESC").Offset(offset).Limit(pageSize).Find(&items).Error
+	return items, total, err
+}
+
+// ListGroupsByMember returns all groups a member belongs to.
+func (s *Store) ListGroupsByMember(memberUsername string) ([]model.GroupMember, error) {
+	var items []model.GroupMember
+	err := s.DB.Where("member_username = ?", memberUsername).
+		Order("discovered_at DESC").Find(&items).Error
+	return items, err
+}
+
+// ListGroups returns all unique groups with member counts. Supports search by group username or title.
+func (s *Store) ListGroups(page, pageSize int, search string) ([]GroupSummary, int64, error) {
+	base := s.DB.Model(&model.GroupMember{})
+	if search != "" {
+		like := "%" + search + "%"
+		base = base.Where("group_username LIKE ? OR group_title LIKE ?", like, like)
+	}
+
+	var total int64
+	base.Select("group_username").Group("group_username").Count(&total)
+
+	var summaries []GroupSummary
+	offset := (page - 1) * pageSize
+	q := s.DB.Model(&model.GroupMember{})
+	if search != "" {
+		like := "%" + search + "%"
+		q = q.Where("group_username LIKE ? OR group_title LIKE ?", like, like)
+	}
+	err := q.Select("group_username, MAX(group_title) as group_title, COUNT(*) as member_count, MAX(source_type) as source_type").
+		Group("group_username").
+		Order("member_count DESC").
+		Offset(offset).Limit(pageSize).
+		Scan(&summaries).Error
+	return summaries, total, err
+}
+
+// SearchMembers searches for members by username across all groups.
+func (s *Store) SearchMembers(pattern string, page, pageSize int) ([]MemberSummary, int64, error) {
+	like := "%" + strings.TrimPrefix(strings.TrimSpace(pattern), "@") + "%"
+
+	var total int64
+	s.DB.Model(&model.GroupMember{}).
+		Where("member_username LIKE ?", like).
+		Select("member_username").
+		Group("member_username").
+		Count(&total)
+
+	var summaries []MemberSummary
+	offset := (page - 1) * pageSize
+	err := s.DB.Model(&model.GroupMember{}).
+		Where("member_username LIKE ?", like).
+		Select("member_username, COUNT(DISTINCT group_username) as group_count, MAX(discovered_at) as last_seen").
+		Group("member_username").
+		Order("group_count DESC").
+		Offset(offset).Limit(pageSize).
+		Scan(&summaries).Error
+	return summaries, total, err
+}
+
+// GroupSummary is a summary of a group with member count.
+type GroupSummary struct {
+	GroupUsername string `json:"group_username"`
+	GroupTitle    string `json:"group_title"`
+	MemberCount  int64  `json:"member_count"`
+	SourceType   string `json:"source_type"`
+}
+
+// MemberSummary is a summary of a member across groups.
+type MemberSummary struct {
+	MemberUsername string    `json:"member_username"`
+	GroupCount     int64     `json:"group_count"`
+	LastSeen       time.Time `json:"last_seen"`
+}

+ 33 - 24
internal/store/merchant_repo.go

@@ -9,7 +9,7 @@ import (
 )
 
 // SaveRaw inserts a merchant into merchants_raw from plugin output.
-// Dedup: same tg_username + same source_url => skip.
+// Dedup: same tg_username + same source_url => skip (via unique index).
 // Returns true if a new record was inserted.
 func (s *Store) SaveRaw(data plugin.MerchantData) (bool, error) {
 	if strings.TrimSpace(data.TgUsername) == "" {
@@ -22,34 +22,35 @@ func (s *Store) SaveRaw(data plugin.MerchantData) (bool, error) {
 		tgLink = "https://t.me/" + username
 	}
 
-	raw := model.MerchantRaw{
-		MerchantName:    data.MerchantName,
-		TgUsername:      username,
-		TgLink:          tgLink,
-		Website:         data.Website,
-		Email:           data.Email,
-		Phone:           data.Phone,
-		IndustryTag:     data.IndustryTag,
-		SourceType:      data.SourceType,
-		SourceName:      data.SourceName,
-		SourceURL:       data.SourceURL,
-		OriginalText:    data.OriginalText,
-		Status:          "raw",
+	// Truncate source_url to fit the unique index (255+500 chars × 4 bytes = 3020 < 3072)
+	sourceURL := data.SourceURL
+	if len(sourceURL) > 490 {
+		sourceURL = sourceURL[:490]
 	}
 
-	// Dedup: same tg_username + source_url => skip
-	var count int64
-	s.DB.Model(&model.MerchantRaw{}).
-		Where("tg_username = ? AND source_url = ?", username, data.SourceURL).
-		Count(&count)
-	if count > 0 {
-		return false, nil
+	raw := model.MerchantRaw{
+		MerchantName: data.MerchantName,
+		TgUsername:   username,
+		TgLink:       tgLink,
+		Website:      data.Website,
+		Email:        data.Email,
+		Phone:        data.Phone,
+		IndustryTag:  data.IndustryTag,
+		SourceType:   data.SourceType,
+		SourceName:   data.SourceName,
+		SourceURL:    sourceURL,
+		OriginalText: data.OriginalText,
+		Status:       "raw",
 	}
 
-	if err := s.DB.Create(&raw).Error; err != nil {
-		return false, err
+	// Use unique index for dedup: insert only if (tg_username, source_url) doesn't exist
+	result := s.DB.Where("tg_username = ? AND source_url = ?", username, sourceURL).
+		FirstOrCreate(&raw)
+	if result.Error != nil {
+		return false, result.Error
 	}
-	return true, nil
+	// RowsAffected == 1 means a new record was created
+	return result.RowsAffected > 0, nil
 }
 
 // ListRawByStatus returns raw merchants with the given status.
@@ -68,6 +69,14 @@ func (s *Store) UpdateRawStatus(id uint, status string) error {
 	return s.DB.Model(&model.MerchantRaw{}).Where("id = ?", id).Update("status", status).Error
 }
 
+// BatchUpdateRawStatus sets the status for multiple raw merchants in one query.
+func (s *Store) BatchUpdateRawStatus(ids []uint, status string) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	return s.DB.Model(&model.MerchantRaw{}).Where("id IN ?", ids).Update("status", status).Error
+}
+
 // SaveClean upserts a clean merchant by tg_username.
 func (s *Store) SaveClean(m *model.MerchantClean) error {
 	var existing model.MerchantClean

+ 59 - 0
internal/store/setting_repo.go

@@ -0,0 +1,59 @@
+package store
+
+import (
+	"encoding/json"
+
+	"gorm.io/datatypes"
+	"gorm.io/gorm"
+
+	"spider/internal/model"
+)
+
+// GetSetting reads a setting by key. Returns nil if not found.
+func (s *Store) GetSetting(key string) (*model.Setting, error) {
+	var setting model.Setting
+	err := s.DB.Where("`key` = ?", key).First(&setting).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	return &setting, err
+}
+
+// SetSetting creates or updates a setting.
+func (s *Store) SetSetting(key string, value any) error {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return err
+	}
+
+	var existing model.Setting
+	err = s.DB.Where("`key` = ?", key).First(&existing).Error
+	if err == gorm.ErrRecordNotFound {
+		return s.DB.Create(&model.Setting{
+			Key:   key,
+			Value: datatypes.JSON(data),
+		}).Error
+	}
+	if err != nil {
+		return err
+	}
+	return s.DB.Model(&existing).Update("value", datatypes.JSON(data)).Error
+}
+
+// GetGradingConfig reads the grading config from settings, or returns default.
+func (s *Store) GetGradingConfig() (*model.GradingConfig, error) {
+	setting, err := s.GetSetting("grading")
+	if err != nil {
+		return nil, err
+	}
+	if setting == nil {
+		cfg := model.DefaultGradingConfig()
+		return &cfg, nil
+	}
+	var cfg model.GradingConfig
+	if err := json.Unmarshal(setting.Value, &cfg); err != nil {
+		cfg = model.DefaultGradingConfig()
+		return &cfg, nil
+	}
+	return &cfg, nil
+}

+ 176 - 0
internal/task/detail_logger.go

@@ -0,0 +1,176 @@
+package task
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"gorm.io/gorm"
+
+	"spider/internal/model"
+	"spider/internal/plugin"
+)
+
+// DetailLogger records every operation within a task execution to the database.
+// Thread-safe: can be called concurrently.
+type DetailLogger struct {
+	db     *gorm.DB
+	taskID uint
+	seq    atomic.Int64
+	buf    chan model.TaskDetail
+	done   chan struct{}
+}
+
+// NewDetailLogger creates a new logger that batches writes to the database.
+func NewDetailLogger(db *gorm.DB, taskID uint) *DetailLogger {
+	dl := &DetailLogger{
+		db:     db,
+		taskID: taskID,
+		buf:    make(chan model.TaskDetail, 500),
+		done:   make(chan struct{}),
+	}
+	go dl.flushLoop()
+	return dl
+}
+
+func (dl *DetailLogger) write(action, url, parentURL string, depth int, input, output, status, extra string, duration time.Duration) {
+	seq := int(dl.seq.Add(1))
+	detail := model.TaskDetail{
+		TaskID:    dl.taskID,
+		Seq:       seq,
+		Action:    action,
+		URL:       truncStr(url, 2000),
+		ParentURL: truncStr(parentURL, 2000),
+		Depth:     depth,
+		Input:     truncStr(input, 50000),
+		Output:    truncStr(output, 50000),
+		Status:    status,
+		Duration:  int(duration.Milliseconds()),
+		Extra:     truncStr(extra, 5000),
+	}
+	select {
+	case dl.buf <- detail:
+	default:
+		dl.db.Create(&detail)
+	}
+}
+
+// LogSearchResult records each individual serper result.
+func (dl *DetailLogger) LogSearchResult(query string, position int, title, link, snippet string) {
+	input := fmt.Sprintf("query: %s\nposition: %d", query, position)
+	output := fmt.Sprintf("title: %s\nlink: %s\nsnippet: %s", title, link, snippet)
+	dl.write("search_result", link, "", 0, input, output, "ok", "", 0)
+}
+
+// LogCrawlPage records a page fetch with content summary.
+func (dl *DetailLogger) LogCrawlPage(url, parentURL string, depth int, htmlSummary string, tgLinks []string, allLinksCount int, err error, dur time.Duration) {
+	status := "ok"
+	extra := ""
+	if err != nil {
+		status = "error"
+		extra = err.Error()
+	}
+	output := fmt.Sprintf("tg_links_found: %d\nall_links_found: %d", len(tgLinks), allLinksCount)
+	if len(tgLinks) > 0 {
+		output += "\ntg_links:\n  " + strings.Join(tgLinks, "\n  ")
+	}
+	dl.write("crawl", url, parentURL, depth, htmlSummary, output, status, extra, dur)
+}
+
+// LogSnippetExtract records extraction from a snippet.
+func (dl *DetailLogger) LogSnippetExtract(sourceURL, rawText string, extracted []string) {
+	status := "ok"
+	if len(extracted) == 0 {
+		status = "empty"
+	}
+	dl.write("snippet_extract", sourceURL, "", 0, rawText, toJSON(extracted), status, "", 0)
+}
+
+// LogPageExtract records extraction from a crawled page.
+func (dl *DetailLogger) LogPageExtract(pageURL, parentURL string, depth int, contentSample string, extracted []string) {
+	status := "ok"
+	if len(extracted) == 0 {
+		status = "empty"
+	}
+	dl.write("page_extract", pageURL, parentURL, depth, contentSample, toJSON(extracted), status, "", 0)
+}
+
+// LogMerchantFound records a merchant being produced with full data.
+func (dl *DetailLogger) LogMerchantFound(data plugin.MerchantData, sourceAction string, depth int, parentURL string) {
+	dl.write("merchant_found", data.SourceURL, parentURL, depth,
+		fmt.Sprintf("tg: @%s\nname: %s\nwebsite: %s\nemail: %s\nphone: %s\nindustry: %s",
+			data.TgUsername, data.MerchantName, data.Website, data.Email, data.Phone, data.IndustryTag),
+		sourceAction,
+		"ok",
+		fmt.Sprintf("source_type: %s\nsource_name: %s", data.SourceType, data.SourceName),
+		0)
+}
+
+// LogCleanStep records a cleaning pipeline decision.
+func (dl *DetailLogger) LogCleanStep(tgUsername, step, decision, reason string) {
+	dl.write("clean_"+step, "", "", 0,
+		fmt.Sprintf("@%s", tgUsername),
+		fmt.Sprintf("decision: %s\nreason: %s", decision, reason),
+		"ok", "", 0)
+}
+
+// LogSkip records a skipped operation.
+func (dl *DetailLogger) LogSkip(action, url, reason string) {
+	dl.write(action, url, "", 0, "", reason, "skip", "", 0)
+}
+
+// LogError records an error.
+func (dl *DetailLogger) LogError(action, url, errMsg string) {
+	dl.write(action, url, "", 0, "", "", "error", errMsg, 0)
+}
+
+// Close flushes remaining logs and stops the background writer.
+func (dl *DetailLogger) Close() {
+	close(dl.buf)
+	<-dl.done
+}
+
+func (dl *DetailLogger) flushLoop() {
+	defer close(dl.done)
+	batch := make([]model.TaskDetail, 0, 50)
+	ticker := time.NewTicker(2 * time.Second)
+	defer ticker.Stop()
+
+	flush := func() {
+		if len(batch) == 0 {
+			return
+		}
+		dl.db.CreateInBatches(batch, 50)
+		batch = batch[:0]
+	}
+
+	for {
+		select {
+		case detail, ok := <-dl.buf:
+			if !ok {
+				flush()
+				return
+			}
+			batch = append(batch, detail)
+			if len(batch) >= 50 {
+				flush()
+			}
+		case <-ticker.C:
+			flush()
+		}
+	}
+}
+
+func truncStr(s string, maxLen int) string {
+	if len(s) <= maxLen {
+		return s
+	}
+	return s[:maxLen]
+}
+
+func toJSON(v any) string {
+	b, _ := json.Marshal(v)
+	return string(b)
+}

+ 186 - 9
internal/task/manager.go

@@ -12,15 +12,20 @@ import (
 	"gorm.io/gorm"
 
 	"spider/internal/model"
+	"spider/internal/notification"
 	"spider/internal/plugin"
 	"spider/internal/processor"
+	proxypool "spider/internal/proxy"
 	"spider/internal/store"
 )
 
 // StartRequest is the payload for starting a new task.
 type StartRequest struct {
-	PluginName string `json:"plugin_name" binding:"required"`
-	AutoClean  bool   `json:"auto_clean"` // run processor after collection (default true)
+	PluginName    string `json:"plugin_name" binding:"required"`
+	AutoClean     *bool  `json:"auto_clean"`                       // run processor after collection (default true)
+	TargetGroup   string `json:"target_group,omitempty"`           // target a specific TG group/channel for collection
+	ProxyID       *uint  `json:"proxy_id,omitempty"`               // optional single proxy for this task
+	ProxyMode     string `json:"proxy_mode,omitempty"`             // "single" (default) or "pool"
 }
 
 // Manager manages plugin task lifecycle using goroutines.
@@ -32,8 +37,10 @@ type Manager struct {
 	store     *store.Store
 	processor *processor.Processor
 
-	mu       sync.Mutex
-	running  map[uint]context.CancelFunc // taskID -> cancel
+	mu        sync.Mutex
+	running   map[uint]context.CancelFunc // taskID -> cancel
+	notifier  *notification.Manager
+	proxyPool *proxypool.Pool // current active proxy pool (nil when not using pool mode)
 }
 
 // NewManager creates a new task manager.
@@ -65,6 +72,40 @@ func (m *Manager) StartTask(req StartRequest) (*model.TaskLog, error) {
 		return nil, fmt.Errorf("plugin %s is already running", req.PluginName)
 	}
 
+	// Resolve proxy configuration
+	var proxyID *uint
+	var proxyName string
+	var proxyURL string
+	var pool *proxypool.Pool
+
+	if req.ProxyMode == "pool" {
+		// Pool mode: load all enabled proxies
+		var proxies []model.Proxy
+		m.db.Where("enabled = ?", true).Find(&proxies)
+		if len(proxies) == 0 {
+			return nil, fmt.Errorf("代理池模式但没有可用的代理")
+		}
+		pool = proxypool.NewPool(3, 2*time.Minute)
+		names := make([]string, 0, len(proxies))
+		for _, p := range proxies {
+			pool.Add(p.ID, p.Name, p.ProxyURL(), p.Region)
+			names = append(names, p.Name)
+		}
+		proxyName = fmt.Sprintf("代理池(%d个)", len(proxies))
+		m.mu.Lock()
+		m.proxyPool = pool
+		m.mu.Unlock()
+		log.Printf("[task] using proxy pool with %d proxies: %v", len(proxies), names)
+	} else if req.ProxyID != nil && *req.ProxyID > 0 {
+		// Single proxy mode
+		var proxy model.Proxy
+		if err := m.db.First(&proxy, *req.ProxyID).Error; err == nil {
+			proxyID = &proxy.ID
+			proxyName = proxy.Name
+			proxyURL = proxy.ProxyURL()
+		}
+	}
+
 	// Create task log record
 	now := time.Now()
 	taskLog := &model.TaskLog{
@@ -72,6 +113,9 @@ func (m *Manager) StartTask(req StartRequest) (*model.TaskLog, error) {
 		PluginName: req.PluginName,
 		Status:     "running",
 		StartedAt:  &now,
+		ProxyID:    proxyID,
+		ProxyName:  proxyName,
+		ProxyMode:  req.ProxyMode,
 	}
 	if err := m.db.Create(taskLog).Error; err != nil {
 		return nil, fmt.Errorf("create task log: %w", err)
@@ -83,6 +127,22 @@ func (m *Manager) StartTask(req StartRequest) (*model.TaskLog, error) {
 		m.failTask(taskLog, err)
 		return nil, err
 	}
+	// Set proxy in config
+	if pool != nil {
+		cfg["proxy_pool"] = pool
+		// Also set an initial proxy_url for compatibility
+		cfg["proxy_url"] = pool.Next()
+	} else if proxyURL != "" {
+		cfg["proxy_url"] = proxyURL
+	}
+
+	// If targeting a specific group, override seeds config
+	if req.TargetGroup != "" {
+		cfg["seeds"] = []string{req.TargetGroup}
+		cfg["target_group"] = req.TargetGroup
+		cfg["max_depth"] = 0     // don't BFS discover, just scrape this group
+		cfg["max_channels"] = 1
+	}
 
 	// Start in goroutine
 	ctx, cancel := context.WithCancel(context.Background())
@@ -91,11 +151,8 @@ func (m *Manager) StartTask(req StartRequest) (*model.TaskLog, error) {
 	m.running[taskLog.ID] = cancel
 	m.mu.Unlock()
 
-	autoClean := req.AutoClean
 	// Default to true if not explicitly set
-	if !req.AutoClean {
-		autoClean = true
-	}
+	autoClean := req.AutoClean == nil || *req.AutoClean
 
 	go m.runTask(ctx, taskLog, collector, cfg, autoClean)
 
@@ -106,15 +163,34 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 	defer func() {
 		m.mu.Lock()
 		delete(m.running, taskLog.ID)
+		m.proxyPool = nil // clear stale pool reference
 		m.mu.Unlock()
 	}()
 
+	// Recover from panics to prevent crashing the entire server
+	defer func() {
+		if r := recover(); r != nil {
+			log.Printf("[task] PANIC in task %d: %v", taskLog.ID, r)
+			finishedAt := time.Now()
+			m.db.Model(taskLog).Updates(map[string]any{
+				"status":      "failed",
+				"finished_at": &finishedAt,
+				"detail":      fmt.Sprintf("panic: %v", r),
+			})
+		}
+	}()
+
+	// Create detail logger for this task
+	dl := NewDetailLogger(m.db, taskLog.ID)
+	defer dl.Close()
+	collector.SetLogger(dl)
+
 	m.writeLog(ctx, taskLog.ID, fmt.Sprintf("开始采集: %s", collector.Name()))
 
 	merchantCount := 0
 	errCount := 0
 
-	// Callback: for each merchant found, save to raw table
+	// Callback: for each merchant found, save to raw table + group-member relationship
 	callback := func(data plugin.MerchantData) {
 		inserted, err := m.store.SaveRaw(data)
 		if err != nil {
@@ -122,6 +198,10 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 			log.Printf("[task] save raw error: %v", err)
 			return
 		}
+		// Record group-member relationship if source is a TG group/channel
+		if data.GroupUsername != "" && data.TgUsername != "" {
+			m.store.SaveGroupMember(data.GroupUsername, data.TgUsername, data.GroupTitle, data.SourceType, taskLog.ID)
+		}
 		if inserted {
 			merchantCount++
 			if merchantCount%10 == 0 {
@@ -150,6 +230,7 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 	if runErr != nil {
 		m.failTask(taskLog, runErr)
 		m.writeLog(ctx, taskLog.ID, "采集失败: "+runErr.Error())
+		m.notify("task_failed", "任务失败", fmt.Sprintf("采集任务 #%d (%s) 失败: %s", taskLog.ID, collector.Name(), runErr.Error()))
 		return
 	}
 
@@ -164,6 +245,7 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 			m.writeProgress(ctx, taskLog.ID, step, current, total, msg)
 			m.writeLog(ctx, taskLog.ID, fmt.Sprintf("[%s] %d/%d %s", step, current, total, msg))
 		})
+		m.processor.SetLogger(dl)
 
 		procResult, procErr := m.processor.Process(ctx)
 		if procErr != nil {
@@ -177,6 +259,9 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 	// Complete
 	finishedAt := time.Now()
 	detail := fmt.Sprintf("采集 %d 个商户, 错误 %d 次", merchantCount, errCount)
+	if pool, ok := cfg["proxy_pool"].(*proxypool.Pool); ok && pool != nil {
+		detail += " | " + pool.Summary()
+	}
 	m.db.Model(taskLog).Updates(map[string]any{
 		"status":          "completed",
 		"finished_at":     &finishedAt,
@@ -188,6 +273,7 @@ func (m *Manager) runTask(ctx context.Context, taskLog *model.TaskLog, collector
 	m.writeProgress(ctx, taskLog.ID, "done", 100, 100, "任务完成")
 	m.writeLog(ctx, taskLog.ID, "任务完成")
 	log.Printf("[task] task %d completed: %s", taskLog.ID, detail)
+	m.notify("task_completed", "任务完成", fmt.Sprintf("采集任务 #%d (%s): %s", taskLog.ID, collector.Name(), detail))
 }
 
 // StartClean runs the processor independently (not tied to a plugin).
@@ -223,6 +309,18 @@ func (m *Manager) StartClean() (*model.TaskLog, error) {
 			m.mu.Unlock()
 		}()
 
+		defer func() {
+			if r := recover(); r != nil {
+				log.Printf("[task] PANIC in clean task %d: %v", taskLog.ID, r)
+				finishedAt := time.Now()
+				m.db.Model(taskLog).Updates(map[string]any{
+					"status":      "failed",
+					"finished_at": &finishedAt,
+					"detail":      fmt.Sprintf("panic: %v", r),
+				})
+			}
+		}()
+
 		m.writeLog(ctx, taskLog.ID, "开始独立清洗任务")
 
 		m.processor.SetProgressFn(func(step string, current, total int, msg string) {
@@ -238,6 +336,7 @@ func (m *Manager) StartClean() (*model.TaskLog, error) {
 				"finished_at": &finishedAt,
 				"detail":      err.Error(),
 			})
+			m.notify("task_failed", "清洗任务失败", fmt.Sprintf("清洗任务 #%d 失败: %s", taskLog.ID, err.Error()))
 			return
 		}
 
@@ -252,11 +351,45 @@ func (m *Manager) StartClean() (*model.TaskLog, error) {
 		})
 
 		m.writeLog(ctx, taskLog.ID, "清洗完成: "+detail)
+		m.notify("task_completed", "清洗任务完成", fmt.Sprintf("清洗任务 #%d: %s", taskLog.ID, detail))
+
+		// Notify for new hot merchants
+		if result.HotCount > 0 {
+			m.notify("new_hot_merchant", "发现优质商户", fmt.Sprintf("清洗任务 #%d 发现 %d 个优质商户", taskLog.ID, result.HotCount))
+		}
 	}()
 
 	return taskLog, nil
 }
 
+// StopAll cancels all running tasks (used during graceful shutdown).
+func (m *Manager) StopAll() {
+	m.mu.Lock()
+	running := make(map[uint]context.CancelFunc, len(m.running))
+	for id, cancel := range m.running {
+		running[id] = cancel
+	}
+	m.mu.Unlock()
+
+	for id, cancel := range running {
+		log.Printf("[task] stopping task %d for shutdown", id)
+		cancel()
+
+		finishedAt := time.Now()
+		m.db.Model(&model.TaskLog{}).Where("id = ? AND status = ?", id, "running").
+			Updates(map[string]any{
+				"status":      "stopped",
+				"finished_at": &finishedAt,
+				"detail":      "服务关闭,任务停止",
+			})
+	}
+
+	// Clear proxy pool reference
+	m.mu.Lock()
+	m.proxyPool = nil
+	m.mu.Unlock()
+}
+
 // StopTask cancels a running task.
 func (m *Manager) StopTask(taskID uint) error {
 	// Set Redis stop signal
@@ -368,6 +501,11 @@ func (m *Manager) isStopRequested(ctx context.Context, taskID uint) bool {
 	return val == "1"
 }
 
+// TaskPubSubChannel returns the Redis Pub/Sub channel name for a task.
+func TaskPubSubChannel(taskID uint) string {
+	return fmt.Sprintf("spider:task:events:%d", taskID)
+}
+
 func (m *Manager) writeLog(ctx context.Context, taskID uint, msg string) {
 	key := fmt.Sprintf("spider:task:logs:%d", taskID)
 	ts := time.Now().Format("15:04:05")
@@ -375,6 +513,9 @@ func (m *Manager) writeLog(ctx context.Context, taskID uint, msg string) {
 	m.redis.RPush(ctx, key, line)
 	m.redis.LTrim(ctx, key, -500, -1)
 	m.redis.Expire(ctx, key, 24*time.Hour)
+
+	// Publish to Pub/Sub for real-time WebSocket delivery
+	m.redis.Publish(ctx, TaskPubSubChannel(taskID), line)
 }
 
 func (m *Manager) writeProgress(ctx context.Context, taskID uint, phase string, current, total int, message string) {
@@ -389,4 +530,40 @@ func (m *Manager) writeProgress(ctx context.Context, taskID uint, phase string,
 	}
 	b, _ := json.Marshal(fields)
 	m.redis.Set(ctx, key, string(b), 24*time.Hour)
+
+	// Publish progress to Pub/Sub
+	m.redis.Publish(ctx, TaskPubSubChannel(taskID), fmt.Sprintf("[进度] %s", message))
+}
+
+// GetProxyPool returns the current active proxy pool (may be nil).
+func (m *Manager) GetProxyPool() *proxypool.Pool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.proxyPool
+}
+
+// GetRedis returns the Redis client for WebSocket Pub/Sub subscription.
+func (m *Manager) GetRedis() *redis.Client {
+	return m.redis
+}
+
+// SetNotifier sets the notification manager for event dispatching.
+func (m *Manager) SetNotifier(n *notification.Manager) {
+	m.notifier = n
+}
+
+func (m *Manager) notify(eventType, title, msg string) {
+	if m.notifier == nil {
+		return
+	}
+	m.notifier.Send(notification.Event{
+		Type:    eventType,
+		Title:   title,
+		Message: msg,
+	})
+}
+
+// ListPlugins returns all registered plugin names.
+func (m *Manager) ListPlugins() []string {
+	return m.registry.List()
 }

+ 225 - 0
internal/task/scheduler.go

@@ -0,0 +1,225 @@
+package task
+
+import (
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"gorm.io/gorm"
+
+	"spider/internal/model"
+)
+
+// Scheduler checks enabled ScheduleJobs every minute and starts tasks when due.
+type Scheduler struct {
+	db      *gorm.DB
+	manager *Manager
+
+	mu   sync.Mutex
+	jobs []model.ScheduleJob
+
+	stopCh chan struct{}
+	done   chan struct{}
+}
+
+// NewScheduler creates a new Scheduler.
+func NewScheduler(db *gorm.DB, mgr *Manager) *Scheduler {
+	return &Scheduler{
+		db:      db,
+		manager: mgr,
+		stopCh:  make(chan struct{}),
+		done:    make(chan struct{}),
+	}
+}
+
+// Start loads jobs from DB and begins the ticker loop.
+func (s *Scheduler) Start() {
+	s.loadJobs()
+	go s.loop()
+	log.Println("[scheduler] started")
+}
+
+// Stop signals the loop to exit and waits.
+func (s *Scheduler) Stop() {
+	close(s.stopCh)
+	<-s.done
+	log.Println("[scheduler] stopped")
+}
+
+// Reload re-reads all enabled jobs from the database.
+func (s *Scheduler) Reload() {
+	s.loadJobs()
+	log.Println("[scheduler] reloaded jobs")
+}
+
+func (s *Scheduler) loadJobs() {
+	var jobs []model.ScheduleJob
+	if err := s.db.Where("enabled = ?", true).Find(&jobs).Error; err != nil {
+		log.Printf("[scheduler] load jobs error: %v", err)
+		return
+	}
+	// Calculate NextRunAt for any job that doesn't have one yet
+	now := time.Now()
+	for i := range jobs {
+		if jobs[i].NextRunAt == nil {
+			next, err := calcNextRun(jobs[i].CronExpr, now)
+			if err != nil {
+				log.Printf("[scheduler] bad cron for job %d (%s): %v", jobs[i].ID, jobs[i].CronExpr, err)
+				continue
+			}
+			jobs[i].NextRunAt = &next
+			s.db.Model(&jobs[i]).Update("next_run_at", next)
+		}
+	}
+	s.mu.Lock()
+	s.jobs = jobs
+	s.mu.Unlock()
+}
+
+func (s *Scheduler) loop() {
+	defer close(s.done)
+
+	ticker := time.NewTicker(1 * time.Minute)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-s.stopCh:
+			return
+		case now := <-ticker.C:
+			s.tick(now)
+		}
+	}
+}
+
+func (s *Scheduler) tick(now time.Time) {
+	defer func() {
+		if r := recover(); r != nil {
+			log.Printf("[scheduler] PANIC in tick: %v", r)
+		}
+	}()
+
+	s.mu.Lock()
+	jobs := make([]model.ScheduleJob, len(s.jobs))
+	copy(jobs, s.jobs)
+	s.mu.Unlock()
+
+	for _, job := range jobs {
+		if job.NextRunAt == nil {
+			continue
+		}
+		if !now.Before(*job.NextRunAt) {
+			s.runJob(job, now)
+		}
+	}
+}
+
+func (s *Scheduler) runJob(job model.ScheduleJob, now time.Time) {
+	defer func() {
+		if r := recover(); r != nil {
+			log.Printf("[scheduler] PANIC running job %d: %v", job.ID, r)
+		}
+	}()
+
+	log.Printf("[scheduler] triggering job %d (%s) plugin=%s", job.ID, job.Name, job.PluginName)
+
+	req := StartRequest{
+		PluginName: job.PluginName,
+	}
+	_, err := s.manager.StartTask(req)
+	if err != nil {
+		log.Printf("[scheduler] job %d start error: %v", job.ID, err)
+	}
+
+	s.manager.notify("schedule_run", "定时任务触发",
+		fmt.Sprintf("定时任务 [%s] (插件: %s) 已触发执行", job.Name, job.PluginName))
+
+	// Update last_run_at and next_run_at
+	lastRun := now
+	next, calcErr := calcNextRun(job.CronExpr, now)
+	updates := map[string]any{"last_run_at": lastRun}
+	if calcErr == nil {
+		updates["next_run_at"] = next
+	}
+	s.db.Model(&model.ScheduleJob{}).Where("id = ?", job.ID).Updates(updates)
+
+	// Update in-memory copy
+	s.mu.Lock()
+	for i := range s.jobs {
+		if s.jobs[i].ID == job.ID {
+			s.jobs[i].LastRunAt = &lastRun
+			if calcErr == nil {
+				s.jobs[i].NextRunAt = &next
+			}
+			break
+		}
+	}
+	s.mu.Unlock()
+}
+
+// calcNextRun computes the next run time from a cron expression.
+// Supported formats:
+//   - */N * * * *  — every N minutes
+//   - 0 H * * *    — daily at hour H
+//   - 0 H * * D    — weekly on day D at hour H (0=Sunday)
+func calcNextRun(expr string, after time.Time) (time.Time, error) {
+	parts := strings.Fields(expr)
+	if len(parts) != 5 {
+		return time.Time{}, fmt.Errorf("invalid cron: need 5 fields, got %d", len(parts))
+	}
+
+	minute, hour, _, _, dow := parts[0], parts[1], parts[2], parts[3], parts[4]
+
+	// Case 1: */N * * * * — every N minutes
+	if strings.HasPrefix(minute, "*/") && hour == "*" && dow == "*" {
+		nStr := strings.TrimPrefix(minute, "*/")
+		n, err := strconv.Atoi(nStr)
+		if err != nil || n <= 0 {
+			return time.Time{}, fmt.Errorf("invalid interval: %s", nStr)
+		}
+		next := after.Add(time.Duration(n) * time.Minute)
+		// Truncate seconds
+		next = next.Truncate(time.Minute)
+		return next, nil
+	}
+
+	// Minute must be a number for the remaining patterns
+	m, err := strconv.Atoi(minute)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("invalid minute: %s", minute)
+	}
+	h, err := strconv.Atoi(hour)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("invalid hour: %s", hour)
+	}
+
+	// Case 2: 0 H * * * — daily at hour H
+	if dow == "*" {
+		candidate := time.Date(after.Year(), after.Month(), after.Day(), h, m, 0, 0, after.Location())
+		if !candidate.After(after) {
+			candidate = candidate.AddDate(0, 0, 1)
+		}
+		return candidate, nil
+	}
+
+	// Case 3: 0 H * * D — weekly on day D at hour H
+	d, err := strconv.Atoi(dow)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("invalid day of week: %s", dow)
+	}
+	targetDay := time.Weekday(d)
+	candidate := time.Date(after.Year(), after.Month(), after.Day(), h, m, 0, 0, after.Location())
+	// Move to target weekday
+	daysUntil := int(targetDay) - int(candidate.Weekday())
+	if daysUntil < 0 {
+		daysUntil += 7
+	}
+	candidate = candidate.AddDate(0, 0, daysUntil)
+	if !candidate.After(after) {
+		candidate = candidate.AddDate(0, 0, 7)
+	}
+	return candidate, nil
+}

+ 50 - 1
internal/telegram/account_manager.go

@@ -29,6 +29,7 @@ type AccountManager struct {
 	accounts []*ManagedAccount
 	mu       sync.Mutex
 	redis    *redis.Client
+	proxyURL string // optional proxy for TG connections
 }
 
 // NewAccountManager 创建 AccountManager
@@ -79,8 +80,11 @@ func (m *AccountManager) Acquire(ctx context.Context) (*ManagedAccount, error) {
 			}
 			continue
 		}
-		// Available
+		// Available — apply current proxy to the client
 		acc.InUse = true
+		if m.proxyURL != "" {
+			acc.Client.SetProxy(m.proxyURL)
+		}
 		return acc, nil
 	}
 
@@ -142,6 +146,51 @@ func (m *AccountManager) HandleFloodWait(acc *ManagedAccount, waitSeconds int) e
 	return nil
 }
 
+// GetStatuses returns the current status of all managed accounts.
+func (m *AccountManager) GetStatuses() map[string]string {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	result := make(map[string]string, len(m.accounts))
+	now := time.Now()
+	for _, acc := range m.accounts {
+		switch {
+		case acc.InUse:
+			result[acc.Account.Phone] = "online"
+		case now.Before(acc.CoolUntil):
+			result[acc.Account.Phone] = "cooling"
+		default:
+			result[acc.Account.Phone] = "idle"
+		}
+	}
+	return result
+}
+
+// SetProxy sets the proxy URL for TG connections.
+// Disconnects all idle clients so the next Acquire+Connect uses the new proxy.
+func (m *AccountManager) SetProxy(proxyURL string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if m.proxyURL == proxyURL {
+		return
+	}
+	m.proxyURL = proxyURL
+	// Disconnect idle clients so they reconnect with new proxy on next use
+	for _, acc := range m.accounts {
+		if !acc.InUse {
+			acc.Client.Disconnect()
+			acc.Client.SetProxy(proxyURL)
+		}
+	}
+}
+
+// GetProxy returns the current proxy URL.
+func (m *AccountManager) GetProxy() string {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.proxyURL
+}
+
 // loadCooldowns 从 Redis 加载冷却状态(在持有锁时调用)
 func (m *AccountManager) loadCooldowns() {
 	if m.redis == nil {

BIN
server.exe


+ 4 - 1
web/index.html

@@ -3,7 +3,10 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>商户查找系统</title>
+    <meta name="description" content="商户采集与管理平台 - 多渠道商户发现、数据清洗、分级管理" />
+    <meta name="robots" content="noindex, nofollow" />
+    <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕷</text></svg>" />
+    <title>商户采集系统</title>
   </head>
   <body>
     <div id="root"></div>

+ 3504 - 0
web/package-lock.json

@@ -0,0 +1,3504 @@
+{
+  "name": "spider-web",
+  "version": "0.0.1",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "spider-web",
+      "version": "0.0.1",
+      "dependencies": {
+        "@ant-design/icons": "^5.2.6",
+        "antd": "^5.12.0",
+        "axios": "^1.6.2",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0",
+        "react-router-dom": "^6.21.0",
+        "recharts": "^3.8.1",
+        "zustand": "^4.4.7"
+      },
+      "devDependencies": {
+        "@types/react": "^18.2.43",
+        "@types/react-dom": "^18.2.17",
+        "@vitejs/plugin-react": "^4.2.1",
+        "typescript": "^5.9.3",
+        "vite": "^5.0.8"
+      }
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
+      "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6"
+      }
+    },
+    "node_modules/@ant-design/cssinjs": {
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz",
+      "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "@emotion/hash": "^0.8.0",
+        "@emotion/unitless": "^0.7.5",
+        "classnames": "^2.3.1",
+        "csstype": "^3.1.3",
+        "rc-util": "^5.35.0",
+        "stylis": "^4.3.4"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/cssinjs-utils": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
+      "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/cssinjs": "^1.21.0",
+        "@babel/runtime": "^7.23.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@ant-design/fast-color": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
+      "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@ant-design/icons": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
+      "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.0.0",
+        "@ant-design/icons-svg": "^4.4.0",
+        "@babel/runtime": "^7.24.8",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.31.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+      "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/react-slick": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
+      "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.4",
+        "classnames": "^2.2.5",
+        "json2mq": "^0.2.0",
+        "resize-observer-polyfill": "^1.5.1",
+        "throttle-debounce": "^5.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@rc-component/async-validator": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz",
+      "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.4"
+      },
+      "engines": {
+        "node": ">=14.x"
+      }
+    },
+    "node_modules/@rc-component/color-picker": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
+      "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/fast-color": "^2.0.6",
+        "@babel/runtime": "^7.23.6",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/context": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz",
+      "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/mini-decimal": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz",
+      "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      }
+    },
+    "node_modules/@rc-component/mutate-observer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz",
+      "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/portal": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz",
+      "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/qrcode": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz",
+      "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/tour": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
+      "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/portal": "^1.0.0-9",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.24.4"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rc-component/trigger": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz",
+      "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2",
+        "@rc-component/portal": "^1.1.0",
+        "classnames": "^2.3.2",
+        "rc-motion": "^2.0.0",
+        "rc-resize-observer": "^1.3.1",
+        "rc-util": "^5.44.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@reduxjs/toolkit": {
+      "version": "2.11.2",
+      "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+      "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.0.0",
+        "@standard-schema/utils": "^0.3.0",
+        "immer": "^11.0.0",
+        "redux": "^5.0.1",
+        "redux-thunk": "^3.1.0",
+        "reselect": "^5.1.0"
+      },
+      "peerDependencies": {
+        "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+        "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+      },
+      "peerDependenciesMeta": {
+        "react": {
+          "optional": true
+        },
+        "react-redux": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@reduxjs/toolkit/node_modules/immer": {
+      "version": "11.1.4",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+      "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
+    "node_modules/@remix-run/router": {
+      "version": "1.23.2",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+      "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+      "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+      "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+      "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+      "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+      "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+      "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+      "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+      "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+      "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+      "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+      "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+      "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+      "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+      "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+      "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+      "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+      "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+      "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+      "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+      "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+      "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+      "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+      "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "license": "MIT"
+    },
+    "node_modules/@standard-schema/utils": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+      "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.28",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+      "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "node_modules/@types/use-sync-external-store": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+      "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/antd": {
+      "version": "5.29.3",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
+      "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^7.2.1",
+        "@ant-design/cssinjs": "^1.23.0",
+        "@ant-design/cssinjs-utils": "^1.1.3",
+        "@ant-design/fast-color": "^2.0.6",
+        "@ant-design/icons": "^5.6.1",
+        "@ant-design/react-slick": "~1.1.2",
+        "@babel/runtime": "^7.26.0",
+        "@rc-component/color-picker": "~2.0.1",
+        "@rc-component/mutate-observer": "^1.1.0",
+        "@rc-component/qrcode": "~1.1.0",
+        "@rc-component/tour": "~1.15.1",
+        "@rc-component/trigger": "^2.3.0",
+        "classnames": "^2.5.1",
+        "copy-to-clipboard": "^3.3.3",
+        "dayjs": "^1.11.11",
+        "rc-cascader": "~3.34.0",
+        "rc-checkbox": "~3.5.0",
+        "rc-collapse": "~3.9.0",
+        "rc-dialog": "~9.6.0",
+        "rc-drawer": "~7.3.0",
+        "rc-dropdown": "~4.2.1",
+        "rc-field-form": "~2.7.1",
+        "rc-image": "~7.12.0",
+        "rc-input": "~1.8.0",
+        "rc-input-number": "~9.5.0",
+        "rc-mentions": "~2.20.0",
+        "rc-menu": "~9.16.1",
+        "rc-motion": "^2.9.5",
+        "rc-notification": "~5.6.4",
+        "rc-pagination": "~5.1.0",
+        "rc-picker": "~4.11.3",
+        "rc-progress": "~4.0.0",
+        "rc-rate": "~2.13.1",
+        "rc-resize-observer": "^1.4.3",
+        "rc-segmented": "~2.7.0",
+        "rc-select": "~14.16.8",
+        "rc-slider": "~11.1.9",
+        "rc-steps": "~6.0.1",
+        "rc-switch": "~4.1.0",
+        "rc-table": "~7.54.0",
+        "rc-tabs": "~15.7.0",
+        "rc-textarea": "~1.10.2",
+        "rc-tooltip": "~6.4.0",
+        "rc-tree": "~5.13.1",
+        "rc-tree-select": "~5.27.0",
+        "rc-upload": "~4.11.0",
+        "rc-util": "^5.44.4",
+        "scroll-into-view-if-needed": "^3.1.0",
+        "throttle-debounce": "^5.0.2"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
+      "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.18",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
+      "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001787",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
+      "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/classnames": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+      "license": "MIT"
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
+      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
+      "license": "MIT"
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/copy-to-clipboard": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
+      "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
+      "license": "MIT",
+      "dependencies": {
+        "toggle-selection": "^1.0.6"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.336",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
+      "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-toolkit": {
+      "version": "1.45.1",
+      "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
+      "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
+      "license": "MIT",
+      "workspaces": [
+        "docs",
+        "benchmarks"
+      ]
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+      "license": "MIT"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/immer": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+      "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json2mq": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
+      "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "string-convert": "^0.2.0"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.37",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+      "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.9",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+      "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/rc-cascader": {
+      "version": "3.34.0",
+      "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
+      "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "^2.3.1",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-checkbox": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz",
+      "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.25.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-collapse": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
+      "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.3.4",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dialog": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
+      "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/portal": "^1.0.0-8",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.3.0",
+        "rc-util": "^5.21.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-drawer": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz",
+      "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.9",
+        "@rc-component/portal": "^1.1.1",
+        "classnames": "^2.2.6",
+        "rc-motion": "^2.6.1",
+        "rc-util": "^5.38.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-dropdown": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz",
+      "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.44.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.11.0",
+        "react-dom": ">=16.11.0"
+      }
+    },
+    "node_modules/rc-field-form": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz",
+      "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.0",
+        "@rc-component/async-validator": "^5.0.3",
+        "rc-util": "^5.32.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-image": {
+      "version": "7.12.0",
+      "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
+      "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/portal": "^1.0.2",
+        "classnames": "^2.2.6",
+        "rc-dialog": "~9.6.0",
+        "rc-motion": "^2.6.2",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-input": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
+      "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.18.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-input-number": {
+      "version": "9.5.0",
+      "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
+      "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/mini-decimal": "^1.0.1",
+        "classnames": "^2.2.5",
+        "rc-input": "~1.8.0",
+        "rc-util": "^5.40.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-mentions": {
+      "version": "2.20.0",
+      "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
+      "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.22.5",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.6",
+        "rc-input": "~1.8.0",
+        "rc-menu": "~9.16.0",
+        "rc-textarea": "~1.10.0",
+        "rc-util": "^5.34.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-menu": {
+      "version": "9.16.1",
+      "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz",
+      "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "2.x",
+        "rc-motion": "^2.4.3",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-motion": {
+      "version": "2.9.5",
+      "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz",
+      "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-notification": {
+      "version": "5.6.4",
+      "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
+      "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.9.0",
+        "rc-util": "^5.20.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-overflow": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz",
+      "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.37.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-pagination": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz",
+      "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.3.2",
+        "rc-util": "^5.38.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-picker": {
+      "version": "4.11.3",
+      "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz",
+      "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.24.7",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.2.1",
+        "rc-overflow": "^1.3.2",
+        "rc-resize-observer": "^1.4.0",
+        "rc-util": "^5.43.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "date-fns": ">= 2.x",
+        "dayjs": ">= 1.x",
+        "luxon": ">= 3.x",
+        "moment": ">= 2.x",
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      },
+      "peerDependenciesMeta": {
+        "date-fns": {
+          "optional": true
+        },
+        "dayjs": {
+          "optional": true
+        },
+        "luxon": {
+          "optional": true
+        },
+        "moment": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/rc-progress": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz",
+      "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.6",
+        "rc-util": "^5.16.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-rate": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
+      "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-resize-observer": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz",
+      "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.7",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.44.1",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-segmented": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz",
+      "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.1",
+        "classnames": "^2.2.1",
+        "rc-motion": "^2.4.4",
+        "rc-util": "^5.17.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0",
+        "react-dom": ">=16.0.0"
+      }
+    },
+    "node_modules/rc-select": {
+      "version": "14.16.8",
+      "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
+      "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/trigger": "^2.1.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-overflow": "^1.3.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-slider": {
+      "version": "11.1.9",
+      "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz",
+      "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-steps": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz",
+      "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.16.7",
+        "classnames": "^2.2.3",
+        "rc-util": "^5.16.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-switch": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz",
+      "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.21.0",
+        "classnames": "^2.2.1",
+        "rc-util": "^5.30.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-table": {
+      "version": "7.54.0",
+      "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz",
+      "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "@rc-component/context": "^1.4.0",
+        "classnames": "^2.2.5",
+        "rc-resize-observer": "^1.1.0",
+        "rc-util": "^5.44.3",
+        "rc-virtual-list": "^3.14.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tabs": {
+      "version": "15.7.0",
+      "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz",
+      "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "classnames": "2.x",
+        "rc-dropdown": "~4.2.0",
+        "rc-menu": "~9.16.0",
+        "rc-motion": "^2.6.2",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.34.1"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-textarea": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz",
+      "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.1",
+        "rc-input": "~1.8.0",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.27.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tooltip": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz",
+      "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@rc-component/trigger": "^2.0.0",
+        "classnames": "^2.3.1",
+        "rc-util": "^5.44.3"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-tree": {
+      "version": "5.13.1",
+      "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz",
+      "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "rc-motion": "^2.0.1",
+        "rc-util": "^5.16.1",
+        "rc-virtual-list": "^3.5.1"
+      },
+      "engines": {
+        "node": ">=10.x"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-tree-select": {
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz",
+      "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "classnames": "2.x",
+        "rc-select": "~14.16.2",
+        "rc-tree": "~5.13.0",
+        "rc-util": "^5.43.0"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/rc-upload": {
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz",
+      "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "classnames": "^2.2.5",
+        "rc-util": "^5.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-util": {
+      "version": "5.44.4",
+      "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
+      "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "react-is": "^18.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/rc-virtual-list": {
+      "version": "3.19.2",
+      "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz",
+      "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.20.0",
+        "classnames": "^2.2.6",
+        "rc-resize-observer": "^1.0.0",
+        "rc-util": "^5.36.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+      "license": "MIT"
+    },
+    "node_modules/react-redux": {
+      "version": "9.2.0",
+      "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+      "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "peerDependencies": {
+        "@types/react": "^18.2.25 || ^19",
+        "react": "^18.0 || ^19",
+        "redux": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "redux": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "6.30.3",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+      "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.30.3",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+      "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.2",
+        "react-router": "6.30.3"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/recharts": {
+      "version": "3.8.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
+      "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
+      "license": "MIT",
+      "workspaces": [
+        "www"
+      ],
+      "dependencies": {
+        "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+        "clsx": "^2.1.1",
+        "decimal.js-light": "^2.5.1",
+        "es-toolkit": "^1.39.3",
+        "eventemitter3": "^5.0.1",
+        "immer": "^10.1.1",
+        "react-redux": "8.x.x || 9.x.x",
+        "reselect": "5.1.1",
+        "tiny-invariant": "^1.3.3",
+        "use-sync-external-store": "^1.2.2",
+        "victory-vendor": "^37.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/redux": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+      "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+      "license": "MIT"
+    },
+    "node_modules/redux-thunk": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+      "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "redux": "^5.0.0"
+      }
+    },
+    "node_modules/reselect": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+      "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+      "license": "MIT"
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+      "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.1",
+        "@rollup/rollup-android-arm64": "4.60.1",
+        "@rollup/rollup-darwin-arm64": "4.60.1",
+        "@rollup/rollup-darwin-x64": "4.60.1",
+        "@rollup/rollup-freebsd-arm64": "4.60.1",
+        "@rollup/rollup-freebsd-x64": "4.60.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+        "@rollup/rollup-linux-arm64-musl": "4.60.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+        "@rollup/rollup-linux-loong64-musl": "4.60.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-musl": "4.60.1",
+        "@rollup/rollup-openbsd-x64": "4.60.1",
+        "@rollup/rollup-openharmony-arm64": "4.60.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+        "@rollup/rollup-win32-x64-gnu": "4.60.1",
+        "@rollup/rollup-win32-x64-msvc": "4.60.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
+      "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^3.0.2"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-convert": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
+      "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
+      "license": "MIT"
+    },
+    "node_modules/stylis": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+      "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+      "license": "MIT"
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tiny-invariant": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+      "license": "MIT"
+    },
+    "node_modules/toggle-selection": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+      "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
+      "license": "MIT"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/use-sync-external-store": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/victory-vendor": {
+      "version": "37.3.6",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+      "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/zustand": {
+      "version": "4.5.7",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=16.8",
+        "immer": ">=9.0.6",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 5 - 4
web/package.json

@@ -9,19 +9,20 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@ant-design/icons": "^5.2.6",
+    "antd": "^5.12.0",
+    "axios": "^1.6.2",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-router-dom": "^6.21.0",
-    "antd": "^5.12.0",
-    "@ant-design/icons": "^5.2.6",
-    "axios": "^1.6.2",
+    "recharts": "^3.8.1",
     "zustand": "^4.4.7"
   },
   "devDependencies": {
     "@types/react": "^18.2.43",
     "@types/react-dom": "^18.2.17",
     "@vitejs/plugin-react": "^4.2.1",
-    "typescript": "^5.2.2",
+    "typescript": "^5.9.3",
     "vite": "^5.0.8"
   }
 }

+ 67 - 8
web/src/App.tsx

@@ -1,20 +1,79 @@
 import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { useAppStore } from './store'
 import Layout from './components/Layout'
+import Login from './pages/Login'
+import Dashboard from './pages/Dashboard'
 import MerchantsClean from './pages/MerchantsClean'
+import MerchantsRaw from './pages/MerchantsRaw'
+import Groups from './pages/Groups'
 import Tasks from './pages/Tasks'
 import Keywords from './pages/Keywords'
+import Settings from './pages/Settings'
+import Users from './pages/Users'
+import TgAccounts from './pages/TgAccounts'
+import Schedules from './pages/Schedules'
+import MerchantDetail from './pages/MerchantDetail'
+import AuditLogs from './pages/AuditLogs'
+import Notifications from './pages/Notifications'
+import Analytics from './pages/Analytics'
+import Channels from './pages/Channels'
+import Proxies from './pages/Proxies'
+import ErrorBoundary from './components/ErrorBoundary'
+import ForceChangePassword from './pages/ForceChangePassword'
+
+function LoginGuard() {
+  const { token } = useAppStore()
+  if (token) return <Navigate to="/" replace />
+  return <Login />
+}
+
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+  const { token, user } = useAppStore()
+  if (!token) return <Navigate to="/login" replace />
+  if (user?.must_change_password) return <ForceChangePassword />
+  return <>{children}</>
+}
+
+function GuardedRoute({ children, menuKey }: { children: React.ReactNode; menuKey: string }) {
+  const { token, hasMenu } = useAppStore()
+  if (!token) return <Navigate to="/login" replace />
+  if (!hasMenu(menuKey)) return <Navigate to="/" replace />
+  return <>{children}</>
+}
 
 export default function App() {
   return (
+    <ErrorBoundary>
     <BrowserRouter>
-      <Layout>
-        <Routes>
-          <Route path="/" element={<Navigate to="/merchants" replace />} />
-          <Route path="/merchants" element={<MerchantsClean />} />
-          <Route path="/tasks" element={<Tasks />} />
-          <Route path="/keywords" element={<Keywords />} />
-        </Routes>
-      </Layout>
+      <Routes>
+        <Route path="/login" element={<LoginGuard />} />
+        <Route path="/*" element={
+          <ProtectedRoute>
+            <Layout>
+              <Routes>
+                <Route path="/" element={<Navigate to="/dashboard" replace />} />
+                <Route path="/dashboard" element={<Dashboard />} />
+                <Route path="/merchants" element={<MerchantsClean />} />
+                <Route path="/merchants/:id" element={<MerchantDetail />} />
+                <Route path="/merchants-raw" element={<MerchantsRaw />} />
+                <Route path="/groups" element={<Groups />} />
+                <Route path="/channels" element={<Channels />} />
+                <Route path="/tasks" element={<Tasks />} />
+                <Route path="/keywords" element={<Keywords />} />
+                <Route path="/settings" element={<Settings />} />
+                <Route path="/users" element={<GuardedRoute menuKey="users"><Users /></GuardedRoute>} />
+                <Route path="/tg-accounts" element={<GuardedRoute menuKey="tg-accounts"><TgAccounts /></GuardedRoute>} />
+                <Route path="/proxies" element={<GuardedRoute menuKey="proxies"><Proxies /></GuardedRoute>} />
+                <Route path="/schedules" element={<GuardedRoute menuKey="schedules"><Schedules /></GuardedRoute>} />
+                <Route path="/audit-logs" element={<GuardedRoute menuKey="audit-logs"><AuditLogs /></GuardedRoute>} />
+                <Route path="/analytics" element={<Analytics />} />
+                <Route path="/notifications" element={<GuardedRoute menuKey="notifications"><Notifications /></GuardedRoute>} />
+              </Routes>
+            </Layout>
+          </ProtectedRoute>
+        } />
+      </Routes>
     </BrowserRouter>
+    </ErrorBoundary>
   )
 }

+ 27 - 0
web/src/api/client.ts

@@ -0,0 +1,27 @@
+import axios from 'axios'
+
+const api = axios.create({ baseURL: '/api/v1' })
+
+// Attach JWT token to every request
+api.interceptors.request.use((config) => {
+  const token = localStorage.getItem('token')
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`
+  }
+  return config
+})
+
+// Handle responses and 401
+api.interceptors.response.use(
+  (res) => res.data,
+  (error) => {
+    if (error?.response?.status === 401) {
+      localStorage.removeItem('token')
+      localStorage.removeItem('user')
+      window.location.href = '/login'
+    }
+    return Promise.reject(error)
+  }
+)
+
+export default api

+ 43 - 0
web/src/components/ErrorBoundary.tsx

@@ -0,0 +1,43 @@
+import { Component, type ReactNode } from 'react'
+import { Result, Button } from 'antd'
+
+interface Props {
+  children: ReactNode
+}
+
+interface State {
+  hasError: boolean
+  error: Error | null
+}
+
+export default class ErrorBoundary extends Component<Props, State> {
+  constructor(props: Props) {
+    super(props)
+    this.state = { hasError: false, error: null }
+  }
+
+  static getDerivedStateFromError(error: Error): State {
+    return { hasError: true, error }
+  }
+
+  render() {
+    if (this.state.hasError) {
+      return (
+        <Result
+          status="error"
+          title="页面出错了"
+          subTitle={this.state.error?.message || '未知错误'}
+          extra={
+            <Button type="primary" onClick={() => {
+              this.setState({ hasError: false, error: null })
+              window.location.reload()
+            }}>
+              刷新页面
+            </Button>
+          }
+        />
+      )
+    }
+    return this.props.children
+  }
+}

+ 213 - 61
web/src/components/Layout.tsx

@@ -1,80 +1,192 @@
 import { useState, useEffect } from 'react'
-import { Layout, Menu, theme } from 'antd'
+import { Layout, Menu, theme, Dropdown, Button, Modal, Form, Input, message, Space, Typography } from 'antd'
 import {
+  DashboardOutlined,
   CheckCircleOutlined,
+  DatabaseOutlined,
   PlayCircleOutlined,
   TagsOutlined,
+  SettingOutlined,
+  TeamOutlined,
+  SendOutlined,
+  UserOutlined,
+  LogoutOutlined,
+  KeyOutlined,
+  ClockCircleOutlined,
+  ApartmentOutlined,
+  AuditOutlined,
+  BellOutlined,
+  BarChartOutlined,
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+  NodeIndexOutlined,
+  GlobalOutlined,
 } from '@ant-design/icons'
 import { useNavigate, useLocation } from 'react-router-dom'
+import { useAppStore } from '../store'
+import api from '../api/client'
+import { logoutApi, updateProfile } from '../api'
 
 const { Sider, Header, Content } = Layout
+const { Text } = Typography
 
 interface LayoutProps {
   children: React.ReactNode
 }
 
-const menuItems = [
-  { key: '/merchants', icon: <CheckCircleOutlined />, label: '商户列表' },
-  { key: '/tasks', icon: <PlayCircleOutlined />, label: '任务管理' },
-  { key: '/keywords', icon: <TagsOutlined />, label: '关键词管理' },
-]
-
 export default function AppLayout({ children }: LayoutProps) {
   const navigate = useNavigate()
   const location = useLocation()
-  const [currentTime, setCurrentTime] = useState(new Date())
-  const { token } = theme.useToken()
+  const { token: themeToken } = theme.useToken()
+  const { user, token: authToken, logout, isAdmin, setAuth } = useAppStore()
+  const [pwdModal, setPwdModal] = useState(false)
+  const [pwdForm] = Form.useForm()
+  const [pwdLoading, setPwdLoading] = useState(false)
+  const [profileModal, setProfileModal] = useState(false)
+  const [profileForm] = Form.useForm()
+  const [profileLoading, setProfileLoading] = useState(false)
+  const [collapsed, setCollapsed] = useState(false)
+  const [isMobile, setIsMobile] = useState(false)
+  const [appName, setAppName] = useState('商户采集系统')
+
+  // Load app name from backend
+  useEffect(() => {
+    api.get('/app/info').then((res: any) => {
+      if (res?.data?.name) {
+        setAppName(res.data.name)
+        document.title = res.data.name
+      }
+    }).catch(() => {})
+  }, [])
 
+  // Detect mobile
   useEffect(() => {
-    const timer = setInterval(() => setCurrentTime(new Date()), 1000)
-    return () => clearInterval(timer)
+    const check = () => {
+      const mobile = window.innerWidth < 768
+      setIsMobile(mobile)
+      if (mobile) setCollapsed(true)
+    }
+    check()
+    window.addEventListener('resize', check)
+    return () => window.removeEventListener('resize', check)
   }, [])
 
-  const formatTime = (date: Date) => {
-    return date.toLocaleString('zh-CN', {
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-      hour: '2-digit',
-      minute: '2-digit',
-      second: '2-digit',
-    })
+  const { hasMenu } = useAppStore()
+
+  // All possible menu items — filtered by user permissions
+  const allMenuItems = [
+    { key: '/dashboard', permKey: 'dashboard', icon: <DashboardOutlined />, label: '数据看板' },
+    { key: '/merchants', permKey: 'merchants', icon: <CheckCircleOutlined />, label: '商户列表' },
+    { key: '/merchants-raw', permKey: 'merchants-raw', icon: <DatabaseOutlined />, label: '原始数据' },
+    { key: '/groups', permKey: 'groups', icon: <ApartmentOutlined />, label: '群组分析' },
+    { key: '/channels', permKey: 'channels', icon: <NodeIndexOutlined />, label: '频道管理' },
+    { key: '/analytics', permKey: 'analytics', icon: <BarChartOutlined />, label: '数据分析' },
+    { key: '/tasks', permKey: 'tasks', icon: <PlayCircleOutlined />, label: '任务管理' },
+    { key: '/keywords', permKey: 'keywords', icon: <TagsOutlined />, label: '关键词管理' },
+    { key: '/tg-accounts', permKey: 'tg-accounts', icon: <SendOutlined />, label: 'TG账号' },
+    { key: '/proxies', permKey: 'proxies', icon: <GlobalOutlined />, label: '代理管理' },
+    { key: '/settings', permKey: 'settings', icon: <SettingOutlined />, label: '分级设置' },
+    { key: '/schedules', permKey: 'schedules', icon: <ClockCircleOutlined />, label: '定时任务' },
+    { key: '/notifications', permKey: 'notifications', icon: <BellOutlined />, label: '通知管理' },
+    { key: '/audit-logs', permKey: 'audit-logs', icon: <AuditOutlined />, label: '审计日志' },
+    { key: '/users', permKey: 'users', icon: <TeamOutlined />, label: '用户管理' },
+  ]
+
+  const menuItems = allMenuItems.filter(item => hasMenu(item.permKey))
+
+  const handleLogout = () => {
+    logoutApi().catch(() => {})
+    logout()
+    navigate('/login')
+  }
+
+  const handleChangePwd = async () => {
+    try {
+      const values = await pwdForm.validateFields()
+      setPwdLoading(true)
+      await api.put('/auth/password', values)
+      message.success('密码已修改')
+      setPwdModal(false)
+      pwdForm.resetFields()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || '修改失败')
+    } finally {
+      setPwdLoading(false)
+    }
+  }
+
+  const handleUpdateProfile = async () => {
+    try {
+      const values = await profileForm.validateFields()
+      setProfileLoading(true)
+      const res = await updateProfile({ nickname: values.nickname })
+      // Update local state
+      if (user && authToken) {
+        const updatedUser = { ...user, nickname: res.data.nickname }
+        setAuth(authToken, updatedUser)
+      }
+      message.success('个人信息已更新')
+      setProfileModal(false)
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error('更新失败')
+    } finally {
+      setProfileLoading(false)
+    }
+  }
+
+  const roleLabels: Record<string, string> = { admin: '管理员', operator: '运营', viewer: '只读' }
+
+  const userMenu = {
+    items: [
+      { key: 'profile', icon: <UserOutlined />, label: '个人信息', onClick: () => { setProfileModal(true); profileForm.setFieldsValue({ nickname: user?.nickname || '' }) } },
+      { key: 'pwd', icon: <KeyOutlined />, label: '修改密码', onClick: () => { setPwdModal(true); pwdForm.resetFields() } },
+      { type: 'divider' as const },
+      { key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout, danger: true },
+    ],
   }
 
+  const siderWidth = collapsed ? 80 : 220
+
   return (
     <Layout style={{ minHeight: '100vh' }}>
+      {/* Mobile overlay */}
+      {isMobile && !collapsed && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)',
+            zIndex: 99,
+          }}
+          onClick={() => setCollapsed(true)}
+        />
+      )}
+
       <Sider
         width={220}
+        collapsedWidth={isMobile ? 0 : 80}
+        collapsed={collapsed}
         style={{
-          background: token.colorBgContainer,
-          borderRight: `1px solid ${token.colorBorderSecondary}`,
+          background: themeToken.colorBgContainer,
+          borderRight: `1px solid ${themeToken.colorBorderSecondary}`,
           position: 'fixed',
           height: '100vh',
           left: 0,
           top: 0,
-          bottom: 0,
           zIndex: 100,
+          transition: 'all 0.2s',
         }}
       >
-        <div
-          style={{
-            height: 64,
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'center',
-            borderBottom: `1px solid ${token.colorBorderSecondary}`,
-            padding: '0 16px',
-          }}
-        >
-          <span
-            style={{
-              fontSize: 16,
-              fontWeight: 700,
-              color: token.colorPrimary,
-              whiteSpace: 'nowrap',
-            }}
-          >
-            商户采集系统
+        <div style={{
+          height: 64,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          borderBottom: `1px solid ${themeToken.colorBorderSecondary}`,
+          overflow: 'hidden',
+        }}>
+          <span style={{ fontSize: collapsed ? 14 : 16, fontWeight: 700, color: themeToken.colorPrimary, whiteSpace: 'nowrap' }}>
+            {collapsed ? 'S' : appName}
           </span>
         </div>
         <Menu
@@ -82,31 +194,71 @@ export default function AppLayout({ children }: LayoutProps) {
           selectedKeys={[location.pathname]}
           style={{ border: 'none', marginTop: 8 }}
           items={menuItems}
-          onClick={({ key }) => navigate(key)}
+          onClick={({ key }) => {
+            navigate(key)
+            if (isMobile) setCollapsed(true)
+          }}
         />
       </Sider>
-      <Layout style={{ marginLeft: 220 }}>
-        <Header
-          style={{
-            background: token.colorBgContainer,
-            borderBottom: `1px solid ${token.colorBorderSecondary}`,
-            padding: '0 24px',
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'flex-end',
-            position: 'sticky',
-            top: 0,
-            zIndex: 99,
-          }}
-        >
-          <span style={{ color: token.colorTextSecondary, fontSize: 14 }}>
-            {formatTime(currentTime)}
-          </span>
+      <Layout style={{ marginLeft: isMobile ? 0 : siderWidth, transition: 'margin-left 0.2s' }}>
+        <Header style={{
+          background: themeToken.colorBgContainer,
+          borderBottom: `1px solid ${themeToken.colorBorderSecondary}`,
+          padding: '0 16px',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+          position: 'sticky',
+          top: 0,
+          zIndex: 98,
+        }}>
+          <Button
+            type="text"
+            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
+            onClick={() => setCollapsed(!collapsed)}
+          />
+          <Space>
+            <Text type="secondary">{roleLabels[user?.role || ''] || user?.role}</Text>
+            <Dropdown menu={userMenu} placement="bottomRight">
+              <Button type="text" icon={<UserOutlined />}>
+                {!isMobile && (user?.nickname || user?.username || '用户')}
+              </Button>
+            </Dropdown>
+          </Space>
         </Header>
-        <Content style={{ padding: 24, minHeight: 'calc(100vh - 64px)' }}>
+        <Content style={{ padding: isMobile ? 12 : 24, minHeight: 'calc(100vh - 64px)' }}>
           {children}
         </Content>
       </Layout>
+
+      {/* Profile modal */}
+      <Modal title="个人信息" open={profileModal} onOk={handleUpdateProfile} onCancel={() => setProfileModal(false)}
+        confirmLoading={profileLoading} okText="保存">
+        <Form form={profileForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item label="用户名">
+            <Input disabled value={user?.username} />
+          </Form.Item>
+          <Form.Item label="角色">
+            <Input disabled value={roleLabels[user?.role || ''] || user?.role} />
+          </Form.Item>
+          <Form.Item name="nickname" label="昵称">
+            <Input placeholder="设置你的昵称" />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* Change password modal */}
+      <Modal title="修改密码" open={pwdModal} onOk={handleChangePwd} onCancel={() => setPwdModal(false)}
+        confirmLoading={pwdLoading} okText="确认修改">
+        <Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="old_password" label="当前密码" rules={[{ required: true }]}>
+            <Input.Password />
+          </Form.Item>
+          <Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 8, message: '至少8位,包含大小写和数字' }]}>
+            <Input.Password />
+          </Form.Item>
+        </Form>
+      </Modal>
     </Layout>
   )
 }

+ 251 - 25
web/src/components/TaskControl.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useEffect, useRef } from 'react'
 import {
   Button,
   Modal,
@@ -8,12 +8,18 @@ import {
   Row,
   Col,
   Card,
+  Progress,
+  Statistic,
+  Tag,
+  Select,
+  Radio,
 } from 'antd'
-import { StopOutlined } from '@ant-design/icons'
-import { startTask, stopTask, type StartTaskRequest } from '../api'
+import { StopOutlined, ThunderboltOutlined } from '@ant-design/icons'
+import { startTask, getTask, getEnabledProxies, getProxyPoolStatus, type StartTaskRequest, type Proxy, type ProxyPoolStatus } from '../api'
 import { useAppStore } from '../store'
 
 const { Text } = Typography
+const { Option } = Select
 
 interface PluginButton {
   name: string
@@ -25,19 +31,112 @@ const pluginButtons: PluginButton[] = [
   { name: 'web_collector', label: '网页采集', isPrimary: true },
   { name: 'tg_collector', label: 'TG采集' },
   { name: 'github_collector', label: 'GitHub采集' },
-  { name: 'clean', label: '清洗' },
+  { name: 'clean', label: '清洗数据' },
 ]
 
 interface TaskControlProps {
   onTaskStarted?: () => void
+  onTaskFinished?: () => void
 }
 
-export default function TaskControl({ onTaskStarted }: TaskControlProps) {
-  const { runningTask, setRunningTask } = useAppStore()
+interface ProgressInfo {
+  phase?: string
+  current?: number
+  total?: number
+  message?: string
+}
+
+export default function TaskControl({ onTaskStarted, onTaskFinished }: TaskControlProps) {
+  const { runningTask, setRunningTask, hasAction } = useAppStore()
   const [modalOpen, setModalOpen] = useState(false)
   const [selectedPlugin, setSelectedPlugin] = useState<PluginButton | null>(null)
   const [loading, setLoading] = useState(false)
+  const [proxies, setProxies] = useState<Proxy[]>([])
+  const [selectedProxyId, setSelectedProxyId] = useState<number | undefined>(undefined)
+  const [proxyMode, setProxyMode] = useState<'none' | 'single' | 'pool'>('none')
   const [stopLoading, setStopLoading] = useState(false)
+  const [poolStatus, setPoolStatus] = useState<ProxyPoolStatus | null>(null)
+  const [progress, setProgress] = useState<ProgressInfo | null>(null)
+  const [merchants, setMerchants] = useState(0)
+  const [errors, setErrors] = useState(0)
+  const pollerRef = useRef<ReturnType<typeof setInterval> | null>(null)
+
+  // Load available proxies
+  useEffect(() => {
+    getEnabledProxies().then(r => setProxies(r.data || [])).catch(() => {})
+  }, [])
+
+  // Poll task progress while running
+  useEffect(() => {
+    if (!runningTask || runningTask.status !== 'running') {
+      if (pollerRef.current) {
+        clearInterval(pollerRef.current)
+        pollerRef.current = null
+      }
+      return
+    }
+
+    const poll = async () => {
+      try {
+        const res = await getTask(runningTask.id)
+        const task = res.data.task
+        const prog = res.data.progress
+
+        setMerchants(task.merchants_added)
+        setErrors(task.errors_count)
+
+        // Poll proxy pool status if running pool mode
+        if (task.proxy_mode === 'pool') {
+          getProxyPoolStatus().then(r => setPoolStatus(r.data)).catch(() => {})
+        }
+
+        if (prog) {
+          // Progress may be a JSON string or parsed object
+          let p: ProgressInfo = {}
+          if (typeof prog === 'string') {
+            try { p = JSON.parse(prog) } catch { p = { message: prog } }
+          } else {
+            p = prog as ProgressInfo
+          }
+          setProgress(p)
+        }
+
+        // Task finished
+        if (task.status !== 'running') {
+          setRunningTask(null)
+          setProgress(null)
+          setPoolStatus(null)
+          onTaskFinished?.()
+
+          if (task.status === 'completed') {
+            message.success(`任务完成: 新增 ${task.merchants_added} 个商户`)
+          } else if (task.status === 'stopped') {
+            message.warning('任务已停止')
+            // Suggest running clean if there are raw merchants
+            if (task.merchants_added > 0) {
+              Modal.confirm({
+                title: '采集已停止',
+                content: `已采集 ${task.merchants_added} 条原始数据,是否立即执行清洗?`,
+                okText: '执行清洗',
+                cancelText: '稍后',
+                onOk: () => handleStartClean(),
+              })
+            }
+          } else if (task.status === 'failed') {
+            message.error(`任务失败: ${task.detail}`)
+          }
+        }
+      } catch {
+        // ignore polling errors
+      }
+    }
+
+    poll()
+    pollerRef.current = setInterval(poll, 3000)
+    return () => {
+      if (pollerRef.current) clearInterval(pollerRef.current)
+    }
+  }, [runningTask?.id, runningTask?.status])
 
   const handleClick = (plugin: PluginButton) => {
     setSelectedPlugin(plugin)
@@ -46,14 +145,27 @@ export default function TaskControl({ onTaskStarted }: TaskControlProps) {
 
   const handleConfirm = async () => {
     if (!selectedPlugin) return
+    if (proxyMode === 'single' && !selectedProxyId) {
+      message.warning('请选择一个代理')
+      return
+    }
+    if (proxyMode === 'pool' && proxies.length === 0) {
+      message.warning('没有可用的代理,无法使用代理池模式')
+      return
+    }
     setLoading(true)
     try {
       const req: StartTaskRequest = {
         plugin_name: selectedPlugin.name,
         auto_clean: selectedPlugin.name !== 'clean',
+        ...(proxyMode === 'single' && selectedProxyId ? { proxy_id: selectedProxyId } : {}),
+        ...(proxyMode === 'pool' ? { proxy_mode: 'pool' } : {}),
       }
       const res = await startTask(req)
       setRunningTask(res.data)
+      setMerchants(0)
+      setErrors(0)
+      setProgress(null)
       message.success(`任务「${selectedPlugin.label}」已启动`)
       setModalOpen(false)
       onTaskStarted?.()
@@ -64,13 +176,27 @@ export default function TaskControl({ onTaskStarted }: TaskControlProps) {
     }
   }
 
+  const handleStartClean = async () => {
+    try {
+      const res = await startTask({ plugin_name: 'clean' })
+      setRunningTask(res.data)
+      setMerchants(0)
+      setErrors(0)
+      setProgress(null)
+      message.success('清洗任务已启动')
+      onTaskStarted?.()
+    } catch {
+      message.error('启动清洗失败')
+    }
+  }
+
   const handleStop = async () => {
     if (!runningTask) return
     setStopLoading(true)
     try {
+      const { stopTask } = await import('../api')
       await stopTask(runningTask.id)
-      setRunningTask(null)
-      message.success('任务已停止')
+      message.info('停止信号已发送')
     } catch {
       message.error('停止任务失败')
     } finally {
@@ -78,16 +204,18 @@ export default function TaskControl({ onTaskStarted }: TaskControlProps) {
     }
   }
 
-  const isRunning = !!runningTask
+  const isRunning = !!runningTask && runningTask.status === 'running'
+  const phase = progress?.message || progress?.phase || ''
 
   return (
-    <Card title="任务控制" style={{ marginBottom: 24 }}>
+    <Card title="任务控制" style={{ marginBottom: 16 }}>
       <Space direction="vertical" style={{ width: '100%' }} size="middle">
         <Row gutter={[8, 8]}>
-          {pluginButtons.map((btn) => (
+          {hasAction('task_start') && pluginButtons.map((btn) => (
             <Col key={btn.name}>
               <Button
                 type={btn.isPrimary ? 'primary' : 'default'}
+                icon={btn.name === 'clean' ? <ThunderboltOutlined /> : undefined}
                 disabled={isRunning}
                 onClick={() => handleClick(btn)}
               >
@@ -95,29 +223,74 @@ export default function TaskControl({ onTaskStarted }: TaskControlProps) {
               </Button>
             </Col>
           ))}
-          {isRunning && (
+          {isRunning && hasAction('task_stop') && (
             <Col>
-              <Button
-                danger
-                icon={<StopOutlined />}
-                loading={stopLoading}
-                onClick={handleStop}
-              >
+              <Button danger icon={<StopOutlined />} loading={stopLoading} onClick={handleStop}>
                 停止任务
               </Button>
             </Col>
           )}
         </Row>
 
-        {isRunning && runningTask ? (
+        {/* ── Running task progress ── */}
+        {isRunning && runningTask && (
           <div>
-            <Space>
-              <Text strong>当前任务:</Text>
-              <Text>{runningTask.plugin_name || runningTask.task_type}</Text>
-              <Text type="secondary">状态:{runningTask.status}</Text>
-            </Space>
+            <Row gutter={16} align="middle">
+              <Col>
+                <Tag color="processing" style={{ fontSize: 13 }}>
+                  {runningTask.plugin_name || runningTask.task_type}
+                </Tag>
+              </Col>
+              <Col>
+                <Text type="secondary">{phase}</Text>
+              </Col>
+            </Row>
+
+            {/* Stats row */}
+            <Row gutter={24} style={{ marginTop: 12 }}>
+              <Col>
+                <Statistic title="已采集商户" value={merchants} valueStyle={{ color: '#3f8600', fontSize: 20 }} />
+              </Col>
+              <Col>
+                <Statistic title="错误数" value={errors} valueStyle={{ color: errors > 0 ? '#cf1322' : '#999', fontSize: 20 }} />
+              </Col>
+              {progress?.current != null && progress?.total != null && progress.total > 0 && (
+                <Col flex="auto">
+                  <Text type="secondary" style={{ fontSize: 12 }}>{progress.phase}</Text>
+                  <Progress
+                    percent={Math.round((Number(progress.current) / Number(progress.total)) * 100)}
+                    size="small"
+                    status="active"
+                  />
+                </Col>
+              )}
+            </Row>
+
+            {/* Proxy pool status */}
+            {poolStatus?.active && poolStatus.proxies && (
+              <div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 4 }}>
+                <Text strong style={{ fontSize: 12 }}>
+                  代理池状态: {poolStatus.active_count}/{poolStatus.total} 活跃
+                </Text>
+                <div style={{ marginTop: 4 }}>
+                  {poolStatus.proxies.map(p => (
+                    <Tag
+                      key={p.id}
+                      color={p.disabled ? 'red' : p.failures > 0 ? 'orange' : 'green'}
+                      style={{ marginBottom: 2 }}
+                    >
+                      {p.name}{p.region ? ` (${p.region})` : ''}
+                      {p.failures > 0 ? ` [${p.failures}]` : ''}
+                      {p.disabled ? ' 冷却中' : ''}
+                    </Tag>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
-        ) : (
+        )}
+
+        {!isRunning && (
           <Text type="secondary">暂无运行中的任务</Text>
         )}
       </Space>
@@ -137,6 +310,59 @@ export default function TaskControl({ onTaskStarted }: TaskControlProps) {
             : `将启动「${selectedPlugin?.label}」插件进行商户采集,采集完成后自动执行清洗流程。`
           }
         </p>
+        {selectedPlugin?.name !== 'clean' && proxies.length > 0 && (
+          <div style={{ marginTop: 12 }}>
+            <Text style={{ display: 'block', marginBottom: 8 }}>代理模式</Text>
+            <Radio.Group
+              value={proxyMode}
+              onChange={e => { setProxyMode(e.target.value); if (e.target.value !== 'single') setSelectedProxyId(undefined) }}
+              style={{ marginBottom: 8 }}
+            >
+              <Radio value="none">直连(不使用代理)</Radio>
+              <Radio value="single" disabled={proxies.length === 0}>固定代理</Radio>
+              <Radio value="pool" disabled={proxies.length === 0}>
+                代理池轮换 <Tag color={proxies.length > 0 ? 'blue' : 'default'} style={{ marginLeft: 4 }}>{proxies.length}个</Tag>
+              </Radio>
+            </Radio.Group>
+
+            {proxyMode === 'single' && (
+              <Select
+                style={{ width: '100%' }}
+                placeholder="选择一个代理"
+                value={selectedProxyId}
+                onChange={setSelectedProxyId}
+              >
+                {proxies.map(p => (
+                  <Option key={p.id} value={p.id}>
+                    <Tag color={p.status === 'ok' ? 'green' : p.status === 'fail' ? 'red' : 'default'} style={{ marginRight: 4 }}>
+                      {p.protocol.toUpperCase()}
+                    </Tag>
+                    {p.name} {p.region ? `(${p.region})` : ''}
+                  </Option>
+                ))}
+              </Select>
+            )}
+
+            {proxyMode === 'pool' && (
+              <div style={{ background: '#f5f5f5', padding: '8px 12px', borderRadius: 4, fontSize: 12 }}>
+                <Text type="secondary">
+                  将使用全部 {proxies.length} 个已启用代理进行轮换,每次请求自动切换代理,失败自动跳过并冷却。
+                </Text>
+                <div style={{ marginTop: 4 }}>
+                  {proxies.map(p => (
+                    <Tag
+                      key={p.id}
+                      color={p.status === 'ok' ? 'green' : p.status === 'fail' ? 'red' : 'default'}
+                      style={{ marginBottom: 2 }}
+                    >
+                      {p.name}{p.region ? ` (${p.region})` : ''}
+                    </Tag>
+                  ))}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
       </Modal>
     </Card>
   )

+ 252 - 0
web/src/pages/Analytics.tsx

@@ -0,0 +1,252 @@
+import { useEffect, useState } from 'react'
+import { Card, Row, Col, Statistic, Table, Select, Tag, Typography, Spin, Button, Space } from 'antd'
+import { ArrowRightOutlined, DownloadOutlined } from '@ant-design/icons'
+import {
+  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ReTooltip, Legend,
+  ResponsiveContainer, LineChart, Line, FunnelChart as ReFunnelChart, Funnel, Cell, LabelList,
+} from 'recharts'
+import {
+  getAnalyticsFunnel, getAnalyticsSourceEfficiency, getAnalyticsTrends,
+  type FunnelData, type SourceEfficiency, type TrendPeriod,
+} from '../api'
+
+const { Text, Title } = Typography
+const { Option } = Select
+
+const FUNNEL_COLORS = ['#1890ff', '#36cfc9', '#52c41a', '#faad14', '#ff4d4f']
+const SOURCE_COLORS: Record<string, string> = {
+  web: '#1890ff',
+  tg_channel: '#fa8c16',
+  github: '#722ed1',
+}
+
+function FunnelViz({ data }: { data: FunnelData }) {
+  const stages = [
+    { name: '原始数据', value: data.raw_total },
+    { name: '清洗后', value: data.clean_total },
+    { name: '有效商户', value: data.valid_total },
+    { name: '已联系', value: data.contacted },
+    { name: '已合作', value: data.cooperating },
+  ]
+
+  const rateLabels: Record<string, string> = {
+    raw_to_clean: '采集→清洗',
+    clean_to_valid: '清洗→有效',
+    valid_to_contacted: '有效→联系',
+    contacted_to_cooperating: '联系→合作',
+  }
+
+  return (
+    <div>
+      <ResponsiveContainer width="100%" height={240}>
+        <ReFunnelChart>
+          <ReTooltip />
+          <Funnel
+            dataKey="value"
+            data={stages}
+            isAnimationActive
+          >
+            <LabelList position="center" fill="#fff" dataKey="value" />
+            {stages.map((_, i) => (
+              <Cell key={i} fill={FUNNEL_COLORS[i]} />
+            ))}
+          </Funnel>
+        </ReFunnelChart>
+      </ResponsiveContainer>
+
+      <Row gutter={16} style={{ marginTop: 16 }}>
+        {Object.entries(data.conversion_rates).map(([key, value]) => (
+          <Col span={6} key={key}>
+            <Statistic
+              title={rateLabels[key] || key}
+              value={(value * 100).toFixed(1)}
+              suffix="%"
+              valueStyle={{ fontSize: 18, color: value > 0.3 ? '#52c41a' : value > 0.1 ? '#faad14' : '#ff4d4f' }}
+            />
+          </Col>
+        ))}
+      </Row>
+
+      {/* Stage labels */}
+      <Row gutter={8} style={{ marginTop: 8 }}>
+        {stages.map((s, i) => (
+          <Col key={s.name} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+            <div style={{ width: 10, height: 10, background: FUNNEL_COLORS[i], borderRadius: 2 }} />
+            <Text style={{ fontSize: 12 }}>{s.name}: {s.value.toLocaleString()}</Text>
+            {i < stages.length - 1 && <ArrowRightOutlined style={{ fontSize: 10, color: '#aaa' }} />}
+          </Col>
+        ))}
+      </Row>
+    </div>
+  )
+}
+
+function SourceBarChart({ data }: { data: SourceEfficiency }) {
+  const chartData = (data.by_source_type || []).map(s => ({
+    name: s.source_type,
+    原始: s.raw_count,
+    清洗后: s.clean_count,
+    优质: s.hot_count,
+  }))
+
+  return (
+    <ResponsiveContainer width="100%" height={200}>
+      <BarChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" />
+        <XAxis dataKey="name" />
+        <YAxis />
+        <ReTooltip />
+        <Legend />
+        <Bar dataKey="原始" fill="#bae7ff" />
+        <Bar dataKey="清洗后" fill="#91d5ff" />
+        <Bar dataKey="优质" fill="#ff4d4f" />
+      </BarChart>
+    </ResponsiveContainer>
+  )
+}
+
+function TrendChart({ data }: { data: TrendPeriod[] }) {
+  return (
+    <ResponsiveContainer width="100%" height={280}>
+      <LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" />
+        <XAxis dataKey="period_label" tick={{ fontSize: 12 }} />
+        <YAxis />
+        <ReTooltip />
+        <Legend />
+        <Line type="monotone" dataKey="raw_added" name="原始新增" stroke="#1890ff" dot={false} strokeWidth={2} />
+        <Line type="monotone" dataKey="clean_added" name="清洗新增" stroke="#52c41a" dot={false} strokeWidth={2} />
+      </LineChart>
+    </ResponsiveContainer>
+  )
+}
+
+export default function Analytics() {
+  const [funnel, setFunnel] = useState<FunnelData | null>(null)
+  const [source, setSource] = useState<SourceEfficiency | null>(null)
+  const [trends, setTrends] = useState<TrendPeriod[]>([])
+  const [trendPeriod, setTrendPeriod] = useState('week')
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    Promise.all([
+      getAnalyticsFunnel().then(r => setFunnel(r.data)),
+      getAnalyticsSourceEfficiency().then(r => setSource(r.data)),
+      getAnalyticsTrends({ period: trendPeriod, range: '90' }).then(r => setTrends(r.data.data || [])),
+    ]).finally(() => setLoading(false))
+  }, [])
+
+  useEffect(() => {
+    getAnalyticsTrends({ period: trendPeriod, range: '90' }).then(r => setTrends(r.data.data || []))
+  }, [trendPeriod])
+
+  if (loading) return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
+
+  const sourceTableColumns = [
+    {
+      title: '来源类型', dataIndex: 'source_type', key: 'source_type',
+      render: (v: string) => <Tag color={SOURCE_COLORS[v] || 'default'}>{v}</Tag>,
+    },
+    { title: '原始数量', dataIndex: 'raw_count', key: 'raw_count', render: (v: number) => v.toLocaleString() },
+    { title: '清洗后', dataIndex: 'clean_count', key: 'clean_count', render: (v: number) => v.toLocaleString() },
+    { title: '优质商户', dataIndex: 'hot_count', key: 'hot_count', render: (v: number) => <Text strong style={{ color: '#ff4d4f' }}>{v}</Text> },
+    {
+      title: '转化率', dataIndex: 'efficiency', key: 'efficiency',
+      render: (v: number) => <Tag color={v > 0.05 ? 'green' : v > 0.02 ? 'orange' : 'default'}>{(v * 100).toFixed(2)}%</Tag>,
+    },
+  ]
+
+  const topKeywordCols = [
+    { title: '关键词', dataIndex: 'keyword', key: 'keyword' },
+    { title: '发现商户数', dataIndex: 'merchants_found', key: 'merchants_found', render: (v: number) => v.toLocaleString() },
+  ]
+
+  const topGroupCols = [
+    {
+      title: '群组', dataIndex: 'group_username', key: 'group_username',
+      render: (v: string) => <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">@{v}</a>,
+    },
+    { title: '成员数', dataIndex: 'members_found', key: 'members_found', render: (v: number) => v.toLocaleString() },
+  ]
+
+  return (
+    <div>
+      <Row gutter={[16, 16]}>
+        {/* Funnel */}
+        <Col span={24}>
+          <Card title="转化漏斗" size="small">
+            {funnel && <FunnelViz data={funnel} />}
+          </Card>
+        </Col>
+
+        {/* Source bar chart */}
+        <Col xs={24} md={14}>
+          <Card title="来源效率对比" size="small" style={{ marginBottom: 16 }}>
+            {source && <SourceBarChart data={source} />}
+          </Card>
+          <Card title="来源效率明细" size="small">
+            <Table
+              dataSource={source?.by_source_type || []}
+              columns={sourceTableColumns}
+              rowKey="source_type"
+              pagination={false}
+              size="small"
+            />
+          </Card>
+        </Col>
+
+        {/* Top keywords and groups */}
+        <Col xs={24} md={10}>
+          <Card title="Top 关键词" size="small" style={{ marginBottom: 16 }}>
+            <Table
+              dataSource={source?.top_keywords || []}
+              columns={topKeywordCols}
+              rowKey="keyword"
+              pagination={false}
+              size="small"
+            />
+          </Card>
+          <Card title="Top 群组" size="small">
+            <Table
+              dataSource={source?.top_groups || []}
+              columns={topGroupCols}
+              rowKey="group_username"
+              pagination={false}
+              size="small"
+            />
+          </Card>
+        </Col>
+
+        {/* Trend line chart */}
+        <Col span={24}>
+          <Card
+            title={
+              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                <span>趋势报表</span>
+                <Space>
+                  <Select value={trendPeriod} onChange={setTrendPeriod} size="small" style={{ width: 100 }}>
+                    <Option value="week">按周</Option>
+                    <Option value="month">按月</Option>
+                  </Select>
+                  <Button
+                    size="small"
+                    icon={<DownloadOutlined />}
+                    onClick={() => window.open(`/api/v1/analytics/trends/export?period=${trendPeriod}&range=90`, '_blank')}
+                  >
+                    导出
+                  </Button>
+                </Space>
+              </div>
+            }
+            size="small"
+          >
+            {trends.length > 0
+              ? <TrendChart data={trends} />
+              : <Text type="secondary">暂无数据</Text>
+            }
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  )
+}

+ 121 - 0
web/src/pages/AuditLogs.tsx

@@ -0,0 +1,121 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Table, Select, Input, Tag, Typography, Row, Col, DatePicker } from 'antd'
+import { getAuditLogs, type AuditLog } from '../api'
+import dayjs from 'dayjs'
+
+const { Text } = Typography
+const { Option } = Select
+const { RangePicker } = DatePicker
+
+const actionColors: Record<string, string> = {
+  create: 'green', update: 'blue', delete: 'red', assign: 'purple',
+  import: 'cyan', login: 'default', export: 'default', archive: 'orange',
+  restore: 'lime', merge: 'magenta',
+}
+
+const targetTypeLabels: Record<string, string> = {
+  merchant: '商户', user: '用户', keyword: '关键词', schedule: '定时任务',
+  setting: '设置', task: '任务', notification: '通知',
+}
+
+export default function AuditLogs() {
+  const [data, setData] = useState<AuditLog[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [username, setUsername] = useState('')
+  const [action, setAction] = useState('')
+  const [targetType, setTargetType] = useState('')
+  const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
+
+  const fetchData = useCallback(async (p = 1) => {
+    setLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: p, page_size: 20 }
+      if (username) params.username = username
+      if (action) params.action = action
+      if (targetType) params.target_type = targetType
+      if (dateRange?.[0]) params.date_from = dateRange[0].format('YYYY-MM-DD 00:00:00')
+      if (dateRange?.[1]) params.date_to = dateRange[1].format('YYYY-MM-DD 23:59:59')
+      const res = await getAuditLogs(params)
+      setData(res.data.items)
+      setTotal(res.data.total)
+    } catch { /* ignore */ }
+    setLoading(false)
+  }, [username, action, targetType, dateRange])
+
+  useEffect(() => {
+    setPage(1)
+    fetchData(1)
+  }, [username, action, targetType, dateRange, fetchData])
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
+    {
+      title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170,
+      render: (v: string) => new Date(v).toLocaleString('zh-CN'),
+    },
+    { title: '用户', dataIndex: 'username', key: 'username', width: 100 },
+    {
+      title: '操作', dataIndex: 'action', key: 'action', width: 80,
+      render: (v: string) => <Tag color={actionColors[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 90,
+      render: (v: string) => targetTypeLabels[v] || v,
+    },
+    { title: '目标ID', dataIndex: 'target_id', key: 'target_id', width: 120 },
+    {
+      title: '详情', dataIndex: 'detail', key: 'detail',
+      render: (v: unknown) => {
+        if (!v) return '-'
+        const str = typeof v === 'string' ? v : JSON.stringify(v)
+        return <Text ellipsis style={{ maxWidth: 300 }}>{str}</Text>
+      },
+    },
+    { title: 'IP', dataIndex: 'ip', key: 'ip', width: 120 },
+  ]
+
+  return (
+    <div>
+      <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+        <Col>
+          <Input placeholder="用户名" allowClear style={{ width: 120 }}
+            onChange={e => setUsername(e.target.value)} />
+        </Col>
+        <Col>
+          <Select style={{ width: 120 }} value={action} onChange={setAction} placeholder="操作类型">
+            <Option value="">全部</Option>
+            {Object.keys(actionColors).map(a => <Option key={a} value={a}>{a}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 120 }} value={targetType} onChange={setTargetType} placeholder="目标类型">
+            <Option value="">全部</Option>
+            {Object.entries(targetTypeLabels).map(([k, v]) => <Option key={k} value={k}>{v}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <RangePicker
+            onChange={(dates) => setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null)}
+            placeholder={['开始日期', '结束日期']}
+            style={{ width: 240 }}
+          />
+        </Col>
+      </Row>
+
+      <Table
+        dataSource={data}
+        columns={columns}
+        rowKey="id"
+        loading={loading}
+        pagination={{
+          current: page, pageSize: 20, total,
+          onChange: p => { setPage(p); fetchData(p) },
+          showTotal: t => `共 ${t} 条`,
+        }}
+        scroll={{ x: 1000 }}
+      />
+    </div>
+  )
+}

+ 215 - 0
web/src/pages/Channels.tsx

@@ -0,0 +1,215 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Table, Tag, Select, Input, Button, message, Row, Col, Badge, Statistic, Card, Space, Popconfirm } from 'antd'
+import { DeleteOutlined, LinkOutlined, DownloadOutlined } from '@ant-design/icons'
+import { getChannels, getChannelStats, updateChannelStatus, deleteChannel, cloneGroupMembers, type Channel } from '../api'
+import { useAppStore } from '../store'
+
+const { Option } = Select
+
+const statusColors: Record<string, string> = {
+  pending: 'default',
+  scraped: 'green',
+  failed: 'red',
+  skipped: 'orange',
+}
+
+const sourceColors: Record<string, string> = {
+  seed: 'blue',
+  snowball: 'purple',
+  search: 'cyan',
+  github: 'geekblue',
+}
+
+export default function Channels() {
+  const [data, setData] = useState<Channel[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [status, setStatus] = useState('')
+  const [source, setSource] = useState('')
+  const [search, setSearch] = useState('')
+  const [stats, setStats] = useState<{ total: number; by_status: { key: string; count: number }[]; by_source: { key: string; count: number }[] } | null>(null)
+  const { isAdmin, isOperator } = useAppStore()
+  const [cloning, setCloning] = useState<string | null>(null)
+
+  const handleCloneMembers = async (username: string) => {
+    setCloning(username)
+    try {
+      const res = await cloneGroupMembers(username)
+      const r = res.data
+      message.success(`克隆完成:共 ${r.total_participants} 个成员,有用户名 ${r.with_username} 个,新增 ${r.new_saved} 条`)
+      fetchData(page)
+    } catch (err: unknown) {
+      const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message
+      message.error(msg || '克隆成员失败')
+    } finally {
+      setCloning(null)
+    }
+  }
+
+  const fetchData = useCallback(async (p = 1) => {
+    setLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: p, page_size: 20 }
+      if (status) params.status = status
+      if (source) params.source = source
+      if (search) params.search = search
+      const res = await getChannels(params)
+      setData(res.data.items)
+      setTotal(res.data.total)
+    } catch { message.error('获取频道列表失败') }
+    setLoading(false)
+  }, [status, source, search])
+
+  useEffect(() => {
+    setPage(1)
+    fetchData(1)
+    getChannelStats().then(r => setStats(r.data)).catch(() => {})
+  }, [status, source, search, fetchData])
+
+  const handleStatusChange = async (id: number, newStatus: string) => {
+    try {
+      await updateChannelStatus(id, newStatus)
+      message.success('状态已更新')
+      fetchData(page)
+    } catch { message.error('更新失败') }
+  }
+
+  const handleDelete = async (id: number) => {
+    try {
+      await deleteChannel(id)
+      message.success('已删除')
+      fetchData(page)
+    } catch { message.error('删除失败') }
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
+    {
+      title: '频道', dataIndex: 'username', key: 'username', width: 200,
+      render: (v: string) => (
+        <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">
+          <LinkOutlined /> @{v}
+        </a>
+      ),
+    },
+    {
+      title: '状态', dataIndex: 'status', key: 'status', width: 120,
+      render: (v: string, record: Channel) => isOperator() ? (
+        <Select size="small" value={v} style={{ width: 100 }}
+          onChange={val => handleStatusChange(record.id, val)}>
+          {Object.keys(statusColors).map(s => (
+            <Option key={s} value={s}><Badge color={statusColors[s] === 'default' ? '#d9d9d9' : statusColors[s]} text={s} /></Option>
+          ))}
+        </Select>
+      ) : <Tag color={statusColors[v]}>{v}</Tag>,
+    },
+    {
+      title: '来源', dataIndex: 'source', key: 'source', width: 100,
+      render: (v: string) => <Tag color={sourceColors[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '发现商户', dataIndex: 'merchants_found', key: 'merchants_found', width: 100,
+      render: (v: number) => v > 0 ? <Tag color="green">{v}</Tag> : '-',
+    },
+    {
+      title: '发现时间', dataIndex: 'created_at', key: 'created_at', width: 160,
+      render: (v: string) => new Date(v).toLocaleString('zh-CN'),
+    },
+    {
+      title: '操作', key: 'action', width: 160,
+      render: (_: unknown, r: Channel) => (
+        <Space>
+          {isOperator() && (
+            <Popconfirm
+              title="克隆成员"
+              description={`从 @${r.username} 拉取所有成员?`}
+              onConfirm={() => handleCloneMembers(r.username)}
+              okText="确定"
+              cancelText="取消"
+            >
+              <Button size="small" type="link" icon={<DownloadOutlined />} loading={cloning === r.username}>
+                克隆
+              </Button>
+            </Popconfirm>
+          )}
+          {isAdmin() && (
+            <Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
+              <Button size="small" type="link" danger icon={<DeleteOutlined />} />
+            </Popconfirm>
+          )}
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      {/* Stats cards */}
+      {stats && (
+        <>
+          <Row gutter={[12, 12]} style={{ marginBottom: 8 }}>
+            <Col span={4}>
+              <Card size="small"><Statistic title="总频道数" value={stats.total} /></Card>
+            </Col>
+            {stats.by_status.map(s => (
+              <Col span={4} key={s.key}>
+                <Card size="small">
+                  <Statistic
+                    title={<Tag color={statusColors[s.key]}>{s.key}</Tag>}
+                    value={s.count}
+                    valueStyle={{ color: statusColors[s.key] === 'green' ? '#52c41a' : statusColors[s.key] === 'red' ? '#ff4d4f' : undefined }}
+                  />
+                </Card>
+              </Col>
+            ))}
+          </Row>
+          {stats.by_source.length > 0 && (
+            <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+              {stats.by_source.map(s => (
+                <Col span={4} key={s.key}>
+                  <Card size="small">
+                    <Statistic
+                      title={<Tag color={sourceColors[s.key] ?? 'default'}>{s.key}</Tag>}
+                      value={s.count}
+                      valueStyle={{ fontSize: 16 }}
+                    />
+                  </Card>
+                </Col>
+              ))}
+            </Row>
+          )}
+        </>
+      )}
+
+      <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+        <Col>
+          <Select style={{ width: 120 }} value={status} onChange={setStatus} placeholder="状态">
+            <Option value="">全部</Option>
+            {Object.keys(statusColors).map(s => <Option key={s} value={s}>{s}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 120 }} value={source} onChange={setSource} placeholder="来源">
+            <Option value="">全部</Option>
+            {Object.keys(sourceColors).map(s => <Option key={s} value={s}>{s}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Input.Search placeholder="搜索频道名" allowClear style={{ width: 200 }}
+            onSearch={v => setSearch(v)} />
+        </Col>
+      </Row>
+
+      <Table
+        dataSource={data} columns={columns} rowKey="id" loading={loading}
+        pagination={{
+          current: page, pageSize: 20, total,
+          onChange: p => { setPage(p); fetchData(p) },
+          showTotal: t => `共 ${t} 个频道`,
+        }}
+        scroll={{ x: 900 }}
+      />
+    </div>
+  )
+}

+ 300 - 0
web/src/pages/Dashboard.tsx

@@ -0,0 +1,300 @@
+import { useEffect, useState } from 'react'
+import { Card, Col, Row, Statistic, Table, Tag, Progress, Typography, Spin, Badge, Tooltip, Button, Space } from 'antd'
+import {
+  DatabaseOutlined,
+  CheckCircleOutlined,
+  PlusCircleOutlined,
+  CalendarOutlined,
+  SendOutlined,
+  ThunderboltOutlined,
+  ReloadOutlined,
+} from '@ant-design/icons'
+import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip as ReTooltip, ResponsiveContainer } from 'recharts'
+import { getDashboard, getSystemHealth, getAnalyticsFunnel, DashboardData, TaskLog, type SystemHealth, type FunnelData } from '../api'
+import { useNavigate } from 'react-router-dom'
+import { useAppStore } from '../store'
+
+const { Title, Text } = Typography
+
+const levelColors: Record<string, string> = {
+  Hot: '#f5222d',
+  Warm: '#fa8c16',
+  Cold: '#1890ff',
+  Unknown: '#d9d9d9',
+}
+
+const statusColors: Record<string, string> = {
+  completed: 'green',
+  running: 'blue',
+  failed: 'red',
+  stopped: 'orange',
+  pending: 'default',
+}
+
+function HealthIndicator({ status, label }: { status: string; label: string }) {
+  const color = status === 'ok' ? '#52c41a' : status === 'error' ? '#ff4d4f' : '#faad14'
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+      <div style={{ width: 8, height: 8, borderRadius: '50%', background: color }} />
+      <Text style={{ fontSize: 13 }}>{label}</Text>
+    </div>
+  )
+}
+
+export default function Dashboard() {
+  const [data, setData] = useState<DashboardData | null>(null)
+  const [health, setHealth] = useState<SystemHealth | null>(null)
+  const [funnel, setFunnel] = useState<FunnelData | null>(null)
+  const [loading, setLoading] = useState(true)
+  const navigate = useNavigate()
+  const { isAdmin } = useAppStore()
+
+  const fetchAll = () => {
+    const promises: Promise<unknown>[] = [
+      getDashboard().then(res => setData(res.data)),
+      getAnalyticsFunnel().then(res => setFunnel(res.data)).catch(() => {}),
+    ]
+    if (isAdmin()) {
+      promises.push(getSystemHealth().then(res => setHealth(res.data)).catch(() => {}))
+    }
+    return Promise.all(promises)
+  }
+
+  useEffect(() => {
+    fetchAll().finally(() => setLoading(false))
+    // Auto-refresh every 60s
+    const timer = setInterval(() => { fetchAll() }, 60000)
+    return () => clearInterval(timer)
+  }, [isAdmin])
+
+  if (loading) {
+    return (
+      <div style={{ textAlign: 'center', paddingTop: 120 }}>
+        <Spin size="large" />
+      </div>
+    )
+  }
+
+  if (!data) return null
+
+  const taskColumns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
+    { title: '类型', dataIndex: 'task_type', key: 'task_type', width: 80 },
+    { title: '插件', dataIndex: 'plugin_name', key: 'plugin_name', width: 140 },
+    {
+      title: '状态', dataIndex: 'status', key: 'status', width: 90,
+      render: (s: string) => <Tag color={statusColors[s] || 'default'}>{s}</Tag>,
+    },
+    {
+      title: '处理/新增', key: 'counts', width: 100,
+      render: (_: unknown, r: TaskLog) => `${r.items_processed} / ${r.merchants_added}`,
+    },
+    {
+      title: '时间', dataIndex: 'created_at', key: 'created_at',
+      render: (v: string) => v?.replace('T', ' ').slice(0, 19),
+    },
+  ]
+
+  const renderDistribution = (record: Record<string, number>, colorMap?: Record<string, string>) => {
+    const entries = Object.entries(record || {}).sort((a, b) => b[1] - a[1])
+    const total = entries.reduce((sum, [, v]) => sum + v, 0)
+    if (total === 0) return <Text type="secondary">暂无数据</Text>
+
+    return (
+      <div>
+        {entries.map(([label, count]) => {
+          const pct = Math.round((count / total) * 100)
+          const color = colorMap?.[label] || '#1890ff'
+          return (
+            <div key={label} style={{ marginBottom: 12 }}>
+              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
+                <Text>{label}</Text>
+                <Text type="secondary">{count} ({pct}%)</Text>
+              </div>
+              <Progress percent={pct} showInfo={false} strokeColor={color} size="small" />
+            </div>
+          )
+        })}
+      </div>
+    )
+  }
+
+  const renderTrend = () => {
+    const trend = data.daily_trend || []
+    if (trend.length === 0) return <Text type="secondary">暂无数据</Text>
+    const chartData = trend.map(t => ({ date: t.date.slice(5), count: t.count }))
+    return (
+      <ResponsiveContainer width="100%" height={130}>
+        <AreaChart data={chartData} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
+          <defs>
+            <linearGradient id="countGrad" x1="0" y1="0" x2="0" y2="1">
+              <stop offset="5%" stopColor="#1890ff" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="#1890ff" stopOpacity={0} />
+            </linearGradient>
+          </defs>
+          <CartesianGrid strokeDasharray="3 3" vertical={false} />
+          <XAxis dataKey="date" tick={{ fontSize: 10 }} />
+          <YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
+          <ReTooltip />
+          <Area type="monotone" dataKey="count" name="新增" stroke="#1890ff" fill="url(#countGrad)" strokeWidth={2} dot={false} />
+        </AreaChart>
+      </ResponsiveContainer>
+    )
+  }
+
+  const paletteColors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96', '#fa541c']
+  const makeColorMap = (record: Record<string, number>) => {
+    const map: Record<string, string> = {}
+    Object.keys(record || {}).forEach((k, i) => { map[k] = paletteColors[i % paletteColors.length] })
+    return map
+  }
+
+  // Mini funnel
+  const renderMiniFunnel = () => {
+    if (!funnel) return null
+    const stages = [
+      { label: '原始', value: funnel.raw_total, color: '#e6f7ff' },
+      { label: '清洗', value: funnel.clean_total, color: '#e6fffb' },
+      { label: '有效', value: funnel.valid_total, color: '#f6ffed' },
+      { label: '联系', value: funnel.contacted, color: '#fff7e6' },
+      { label: '合作', value: funnel.cooperating, color: '#fff1f0' },
+    ]
+    const maxVal = Math.max(funnel.raw_total, 1)
+    return (
+      <div>
+        {stages.map((s, i) => (
+          <div key={s.label} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
+            <Text style={{ width: 32, textAlign: 'right', fontSize: 12 }}>{s.label}</Text>
+            <div style={{
+              width: `${Math.max((s.value / maxVal) * 100, 5)}%`,
+              background: s.color, border: '1px solid #d9d9d9', borderRadius: 3,
+              padding: '2px 8px', fontSize: 12, transition: 'width 0.3s',
+            }}>
+              {s.value.toLocaleString()}
+            </div>
+            {i < stages.length - 1 && i < Object.keys(funnel.conversion_rates).length && (
+              <Text type="secondary" style={{ fontSize: 11 }}>
+                {(Object.values(funnel.conversion_rates)[i] * 100).toFixed(1)}%
+              </Text>
+            )}
+          </div>
+        ))}
+        <div style={{ textAlign: 'right', marginTop: 4 }}>
+          <a onClick={() => navigate('/analytics')} style={{ fontSize: 12 }}>查看详细分析 →</a>
+        </div>
+      </div>
+    )
+  }
+
+  // TG account summary
+  const tgSummary = health?.tg_accounts || {}
+  const tgTotal = Object.values(tgSummary).reduce((a, b) => a + b, 0)
+
+  return (
+    <div>
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
+        <Title level={4} style={{ margin: 0 }}>数据看板</Title>
+        <Space>
+          <Text type="secondary" style={{ fontSize: 12 }}>每60秒自动刷新</Text>
+          <Button size="small" icon={<ReloadOutlined />} onClick={() => fetchAll()}>刷新</Button>
+        </Space>
+      </div>
+
+      {/* System health bar (admin only) */}
+      {health && (
+        <Card size="small" style={{ marginBottom: 16, background: '#fafafa' }}>
+          <Row gutter={24} align="middle">
+            <Col>
+              <HealthIndicator status={health.mysql.status} label="MySQL" />
+              {health.mysql.status === 'ok' && (
+                <Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
+                  {health.mysql.in_use}/{health.mysql.max_open}
+                </Text>
+              )}
+            </Col>
+            <Col>
+              <HealthIndicator status={health.redis.status} label="Redis" />
+            </Col>
+            <Col>
+              <Tooltip title={Object.entries(tgSummary).map(([k, v]) => `${k}: ${v}`).join(', ')}>
+                <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+                  <SendOutlined style={{ color: '#1890ff' }} />
+                  <Text style={{ fontSize: 13 }}>TG账号: {tgTotal}个</Text>
+                  {tgSummary['online'] ? <Badge status="processing" text={`${tgSummary['online']}在线`} /> : null}
+                </div>
+              </Tooltip>
+            </Col>
+            <Col>
+              <Tooltip title="最近24小时任务">
+                <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+                  <ThunderboltOutlined style={{ color: '#faad14' }} />
+                  <Text style={{ fontSize: 13 }}>
+                    24h任务: {Object.values(health.tasks_24h).reduce((a, b) => a + b, 0)}
+                    {health.tasks_24h['failed'] ? <Text type="danger"> ({health.tasks_24h['failed']}失败)</Text> : null}
+                  </Text>
+                </div>
+              </Tooltip>
+            </Col>
+            <Col>
+              <Text type="secondary" style={{ fontSize: 12 }}>
+                数据量: {((health.data.merchants_raw + health.data.merchants_clean) / 1000).toFixed(1)}K
+              </Text>
+            </Col>
+          </Row>
+        </Card>
+      )}
+
+      {/* Top row: stats */}
+      <Row gutter={[16, 16]}>
+        <Col xs={12} sm={6}>
+          <Card><Statistic title="原始数据" value={data.raw_total} prefix={<DatabaseOutlined />} /></Card>
+        </Col>
+        <Col xs={12} sm={6}>
+          <Card><Statistic title="有效商户" value={data.valid_total} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#52c41a' }} /></Card>
+        </Col>
+        <Col xs={12} sm={6}>
+          <Card><Statistic title="今日新增" value={data.today_added} prefix={<PlusCircleOutlined />} valueStyle={{ color: '#1890ff' }} /></Card>
+        </Col>
+        <Col xs={12} sm={6}>
+          <Card><Statistic title="本周新增" value={data.week_added} prefix={<CalendarOutlined />} valueStyle={{ color: '#722ed1' }} /></Card>
+        </Col>
+      </Row>
+
+      {/* Middle row: funnel + level + tasks */}
+      <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
+        <Col xs={24} md={8}>
+          <Card title="转化漏斗" style={{ height: '100%' }}>
+            {renderMiniFunnel()}
+          </Card>
+        </Col>
+        <Col xs={24} md={8}>
+          <Card title="等级分布" style={{ height: '100%' }}>
+            {renderDistribution(data.by_level, levelColors)}
+          </Card>
+        </Col>
+        <Col xs={24} md={8}>
+          <Card title="最近任务">
+            <Table dataSource={data.recent_tasks} columns={taskColumns} rowKey="id" size="small" pagination={false} />
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Trend row */}
+      <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
+        <Col span={24}>
+          <Card title="近14天新增趋势">{renderTrend()}</Card>
+        </Col>
+      </Row>
+
+      {/* Bottom row: source + industry */}
+      <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
+        <Col xs={24} md={12}>
+          <Card title="来源分布">{renderDistribution(data.by_source, makeColorMap(data.by_source))}</Card>
+        </Col>
+        <Col xs={24} md={12}>
+          <Card title="行业分布">{renderDistribution(data.by_industry, makeColorMap(data.by_industry))}</Card>
+        </Col>
+      </Row>
+    </div>
+  )
+}

+ 86 - 0
web/src/pages/ForceChangePassword.tsx

@@ -0,0 +1,86 @@
+import { useState } from 'react'
+import { Card, Form, Input, Button, Typography, message, Result } from 'antd'
+import { LockOutlined } from '@ant-design/icons'
+import { useNavigate } from 'react-router-dom'
+import { useAppStore } from '../store'
+import api from '../api/client'
+
+const { Title, Text } = Typography
+
+export default function ForceChangePassword() {
+  const [loading, setLoading] = useState(false)
+  const [done, setDone] = useState(false)
+  const navigate = useNavigate()
+  const { user, setAuth, token } = useAppStore()
+
+  const handleSubmit = async (values: { old_password: string; new_password: string; confirm: string }) => {
+    if (values.new_password !== values.confirm) {
+      message.error('两次密码不一致')
+      return
+    }
+    setLoading(true)
+    try {
+      await api.put('/auth/password', {
+        old_password: values.old_password,
+        new_password: values.new_password,
+      })
+      // Update user in store to clear must_change_password
+      if (user && token) {
+        setAuth(token, { ...user, must_change_password: false })
+      }
+      setDone(true)
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || err?.data?.message || '修改失败')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  if (done) {
+    return (
+      <div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#f0f2f5' }}>
+        <Card style={{ width: 420, borderRadius: 12 }}>
+          <Result
+            status="success"
+            title="密码已修改"
+            subTitle="请使用新密码登录系统"
+            extra={
+              <Button type="primary" onClick={() => navigate('/dashboard')}>
+                进入系统
+              </Button>
+            }
+          />
+        </Card>
+      </div>
+    )
+  }
+
+  return (
+    <div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#f0f2f5' }}>
+      <Card style={{ width: 420, borderRadius: 12, boxShadow: '0 4px 16px rgba(0,0,0,0.1)' }}>
+        <div style={{ textAlign: 'center', marginBottom: 24 }}>
+          <LockOutlined style={{ fontSize: 40, color: '#faad14', marginBottom: 12 }} />
+          <Title level={4} style={{ margin: 0 }}>首次登录需修改密码</Title>
+          <Text type="secondary">为确保账号安全,请设置新密码</Text>
+        </div>
+        <Form onFinish={handleSubmit} layout="vertical">
+          <Form.Item name="old_password" label="当前密码" rules={[{ required: true }]}>
+            <Input.Password placeholder="输入当前密码" />
+          </Form.Item>
+          <Form.Item name="new_password" label="新密码"
+            rules={[{ required: true, min: 8, message: '至少8位,包含大小写和数字' }]}>
+            <Input.Password placeholder="至少8位,包含大小写字母和数字" />
+          </Form.Item>
+          <Form.Item name="confirm" label="确认新密码" rules={[{ required: true }]}>
+            <Input.Password placeholder="再次输入新密码" />
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 44 }}>
+              修改密码
+            </Button>
+          </Form.Item>
+        </Form>
+      </Card>
+    </div>
+  )
+}

+ 38 - 5
web/src/pages/Keywords.tsx

@@ -14,8 +14,10 @@ import {
   Row,
   Col,
 } from 'antd'
-import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
-import { getKeywords, createKeywords, updateKeyword, deleteKeyword, type Keyword } from '../api'
+import { PlusOutlined, DeleteOutlined, UploadOutlined } from '@ant-design/icons'
+import { Upload, Alert } from 'antd'
+import { getKeywords, createKeywords, updateKeyword, deleteKeyword, importKeywordsCSV, type Keyword } from '../api'
+import { useAppStore } from '../store'
 
 const { Option } = Select
 const { TextArea } = Input
@@ -42,6 +44,8 @@ interface BatchFormValues {
 }
 
 export default function Keywords() {
+  const { hasAction } = useAppStore()
+  const canManage = hasAction('keyword_manage')
   const [data, setData] = useState<Keyword[]>([])
   const [total, setTotal] = useState(0)
   const [page, setPage] = useState(1)
@@ -142,6 +146,7 @@ export default function Keywords() {
           checked={v}
           onChange={(checked) => handleToggle(record, checked)}
           checkedChildren="启用"
+          disabled={!canManage}
           unCheckedChildren="禁用"
         />
       ),
@@ -174,9 +179,37 @@ export default function Keywords() {
     <div>
       <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
         <Col>
-          <Button type="primary" icon={<PlusOutlined />} onClick={handleBatchAdd}>
-            批量添加
-          </Button>
+          {canManage && (
+            <Space>
+              <Button type="primary" icon={<PlusOutlined />} onClick={handleBatchAdd}>
+                批量添加
+              </Button>
+              <Upload
+                accept=".csv,.txt"
+                maxCount={1}
+                showUploadList={false}
+                customRequest={async ({ file }) => {
+                  try {
+                    const res = await importKeywordsCSV(file as File)
+                    const d = res.data
+                    Modal.info({
+                      title: '导入结果',
+                      content: (
+                        <div>
+                          <p>成功导入:{d.imported} 个</p>
+                          <p>跳过(已存在):{d.skipped} 个</p>
+                          {d.errors?.length > 0 && <Alert type="warning" message={d.errors.join('\n')} style={{ whiteSpace: 'pre-wrap' }} />}
+                        </div>
+                      ),
+                    })
+                    fetchData(1)
+                  } catch { message.error('导入失败') }
+                }}
+              >
+                <Button icon={<UploadOutlined />}>导入文件</Button>
+              </Upload>
+            </Space>
+          )}
         </Col>
         <Col>
           <Select

+ 78 - 0
web/src/pages/Login.tsx

@@ -0,0 +1,78 @@
+import { useState, useEffect } from 'react'
+import { Form, Input, Button, Card, message, Typography } from 'antd'
+import { UserOutlined, LockOutlined } from '@ant-design/icons'
+import { useNavigate } from 'react-router-dom'
+import { useAppStore } from '../store'
+import { getMyPermissions } from '../api'
+import axios from 'axios'
+
+const { Title } = Typography
+
+export default function Login() {
+  const [loading, setLoading] = useState(false)
+  const [appName, setAppName] = useState('商户采集系统')
+  const navigate = useNavigate()
+  const { setAuth, setPermissions } = useAppStore()
+
+  useEffect(() => {
+    axios.get('/api/v1/app/info').then(res => {
+      if (res.data?.data?.name) {
+        setAppName(res.data.data.name)
+        document.title = res.data.data.name
+      }
+    }).catch(() => {})
+  }, [])
+
+  const handleLogin = async (values: { username: string; password: string }) => {
+    setLoading(true)
+    try {
+      const res = await axios.post('/api/v1/auth/login', values)
+      const { token, user } = res.data.data
+      localStorage.setItem('token', token)
+      localStorage.setItem('user', JSON.stringify(user))
+      setAuth(token, user)
+      // Fetch permissions after auth is set
+      try {
+        const permRes = await getMyPermissions()
+        setPermissions(permRes.data.menus, permRes.data.actions)
+      } catch { /* use defaults */ }
+      message.success(`欢迎回来,${user.nickname || user.username}`)
+      navigate('/', { replace: true })
+    } catch (err: any) {
+      const msg = err?.response?.data?.message || '登录失败'
+      message.error(msg)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div style={{
+      minHeight: '100vh',
+      display: 'flex',
+      justifyContent: 'center',
+      alignItems: 'center',
+      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+    }}>
+      <Card style={{ width: 400, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', borderRadius: 12 }}>
+        <div style={{ textAlign: 'center', marginBottom: 32 }}>
+          <div style={{ fontSize: 40, marginBottom: 8 }}>🕷</div>
+          <Title level={3} style={{ margin: 0 }}>{appName}</Title>
+        </div>
+        <Form onFinish={handleLogin} size="large">
+          <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
+            <Input prefix={<UserOutlined />} placeholder="用户名" />
+          </Form.Item>
+          <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
+            <Input.Password prefix={<LockOutlined />} placeholder="密码" />
+          </Form.Item>
+          <Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 44 }}>
+              登 录
+            </Button>
+          </Form.Item>
+        </Form>
+      </Card>
+    </div>
+  )
+}

+ 419 - 0
web/src/pages/MerchantDetail.tsx

@@ -0,0 +1,419 @@
+import { useEffect, useState } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import {
+  Card, Descriptions, Tag, Badge, Button, Space, Typography, Timeline, Input,
+  message, Spin, Select, Row, Col, Modal, Form,
+} from 'antd'
+import {
+  ArrowLeftOutlined, EditOutlined, LinkOutlined, TeamOutlined, UserSwitchOutlined, SyncOutlined,
+} from '@ant-design/icons'
+import {
+  getMerchant, getMemberGroups, getMerchantNotes, addMerchantNote,
+  updateFollowStatus, getLevelMap, getAssignableUsers, assignMerchant,
+  updateMerchantClean, startTask, recheckMerchant,
+  type MerchantClean, type GroupMember, type MerchantNote, type AssignableUser,
+} from '../api'
+import { useAppStore } from '../store'
+
+const { Text, Title } = Typography
+const { Option } = Select
+
+const defaultLevelColor: Record<string, string> = { Hot: 'red', Warm: 'orange', Cold: 'blue' }
+
+const followStatusLabels: Record<string, string> = {
+  pending: '待跟进', contacted: '已联系', cooperating: '已合作', rejected: '已拒绝',
+}
+const followStatusBadge: Record<string, 'default' | 'processing' | 'success' | 'error'> = {
+  pending: 'default', contacted: 'processing', cooperating: 'success', rejected: 'error',
+}
+const statusBadgeMap: Record<string, 'success' | 'error' | 'warning' | 'default'> = {
+  valid: 'success', invalid: 'error', bot: 'warning', duplicate: 'default',
+}
+const sourceTypeColor: Record<string, string> = { web: 'blue', tg_channel: 'orange', github: 'geekblue' }
+
+interface SourceInfo {
+  source_type: string
+  source_name: string
+  source_url: string
+}
+
+function formatDateTime(dateStr: string) {
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+export default function MerchantDetail() {
+  const { id } = useParams<{ id: string }>()
+  const navigate = useNavigate()
+  const { isOperator, hasAction } = useAppStore()
+  const [merchant, setMerchant] = useState<MerchantClean | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [notes, setNotes] = useState<MerchantNote[]>([])
+  const [noteInput, setNoteInput] = useState('')
+  const [noteLoading, setNoteLoading] = useState(false)
+  const [groups, setGroups] = useState<GroupMember[]>([])
+  const [levelMap, setLevelMap] = useState<Record<string, { label: string; color: string; description: string }>>({})
+  const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
+  // Edit modal
+  const [editModalOpen, setEditModalOpen] = useState(false)
+  const [editForm] = Form.useForm()
+  const [editLoading, setEditLoading] = useState(false)
+
+  useEffect(() => {
+    getLevelMap().then(r => setLevelMap(r.data)).catch(() => {})
+    getAssignableUsers().then(r => setAssignableUsers(r.data || [])).catch(() => {})
+  }, [])
+
+  const loadMerchant = (merchantId: number) => {
+    getMerchant(merchantId).then(res => {
+      const d = res.data as { source: string; data: MerchantClean }
+      setMerchant(d.data)
+      if (d.data.tg_username) {
+        getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
+      }
+      getMerchantNotes(merchantId).then(r => setNotes(r.data || [])).catch(() => {})
+    }).catch(() => {
+      message.error('商户不存在')
+      navigate('/merchants')
+    })
+  }
+
+  useEffect(() => {
+    if (!id) return
+    setLoading(true)
+    getMerchant(Number(id)).then(res => {
+      const d = res.data as { source: string; data: MerchantClean }
+      setMerchant(d.data)
+      if (d.data.tg_username) {
+        getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
+      }
+      getMerchantNotes(Number(id)).then(r => setNotes(r.data || [])).catch(() => {})
+    }).catch(() => {
+      message.error('商户不存在')
+      navigate('/merchants')
+    }).finally(() => setLoading(false))
+  }, [id, navigate])
+
+  const getLevelLabel = (key: string) => levelMap[key]?.label || key
+  const getLevelColor = (key: string) => levelMap[key]?.color || defaultLevelColor[key] || 'default'
+
+  const parseSources = (): SourceInfo[] => {
+    if (!merchant) return []
+    try {
+      if (Array.isArray(merchant.all_sources)) return merchant.all_sources as SourceInfo[]
+      if (typeof merchant.all_sources === 'string') return JSON.parse(merchant.all_sources)
+    } catch { /* ignore */ }
+    return []
+  }
+
+  const handleFollowStatusChange = async (val: string) => {
+    if (!merchant) return
+    try {
+      await updateFollowStatus(merchant.id, val)
+      setMerchant({ ...merchant, follow_status: val })
+      message.success('跟进状态已更新')
+    } catch {
+      message.error('更新失败')
+    }
+  }
+
+  const handleAssign = async (val: string) => {
+    if (!merchant) return
+    try {
+      const res = await assignMerchant(merchant.id, val)
+      setMerchant(res.data)
+      message.success('分配成功')
+    } catch {
+      message.error('分配失败')
+    }
+  }
+
+  const handleAddNote = async () => {
+    if (!merchant || !noteInput.trim()) return
+    setNoteLoading(true)
+    try {
+      await addMerchantNote(merchant.id, noteInput.trim())
+      setNoteInput('')
+      message.success('备注已添加')
+      const res = await getMerchantNotes(merchant.id)
+      setNotes(res.data || [])
+    } catch {
+      message.error('添加失败')
+    } finally {
+      setNoteLoading(false)
+    }
+  }
+
+  const openEditModal = () => {
+    if (!merchant) return
+    editForm.setFieldsValue({
+      merchant_name: merchant.merchant_name,
+      industry_tag: merchant.industry_tag,
+      website: merchant.website,
+      email: merchant.email,
+      phone: merchant.phone,
+      remark: merchant.remark || '',
+    })
+    setEditModalOpen(true)
+  }
+
+  const handleEditSave = async () => {
+    if (!merchant) return
+    try {
+      const values = await editForm.validateFields()
+      setEditLoading(true)
+      const res = await updateMerchantClean(merchant.id, values)
+      setMerchant(res.data)
+      message.success('商户信息已更新')
+      setEditModalOpen(false)
+    } catch {
+      message.error('更新失败')
+    } finally {
+      setEditLoading(false)
+    }
+  }
+
+  const handleCollectGroup = (username: string) => {
+    Modal.confirm({
+      title: `采集群 @${username}`,
+      content: `将使用 TG 采集器采集群 @${username} 中的成员和联系方式。`,
+      okText: '开始采集',
+      onOk: async () => {
+        try {
+          await startTask({ plugin_name: 'tg_collector', target_group: username })
+          message.success(`已启动对 @${username} 的采集任务`)
+        } catch {
+          message.error('启动采集失败')
+        }
+      },
+    })
+  }
+
+  if (loading) return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
+  if (!merchant) return null
+
+  const sources = parseSources()
+
+  return (
+    <div style={{ maxWidth: 1200, margin: '0 auto' }}>
+      {/* Header */}
+      <div style={{ marginBottom: 20 }}>
+        <Button type="link" icon={<ArrowLeftOutlined />} onClick={() => navigate('/merchants')}
+          style={{ padding: 0, marginBottom: 12 }}>
+          返回列表
+        </Button>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+          <Space size="middle">
+            <Title level={4} style={{ margin: 0 }}>
+              <a href={`https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
+                @{merchant.tg_username}
+              </a>
+            </Title>
+            {merchant.merchant_name && <Text type="secondary" style={{ fontSize: 16 }}>{merchant.merchant_name}</Text>}
+            <Tag color={getLevelColor(merchant.level)}>{getLevelLabel(merchant.level)}</Tag>
+            <Badge status={statusBadgeMap[merchant.status] ?? 'default'} text={merchant.status} />
+          </Space>
+          <Space>
+            {hasAction('merchant_edit') && (
+              <Button icon={<SyncOutlined />} onClick={async () => {
+                try {
+                  await recheckMerchant(merchant.id)
+                  message.success('已标记为待重新检查')
+                } catch { message.error('操作失败') }
+              }}>重新检查</Button>
+            )}
+            {hasAction('merchant_edit') && (
+              <Button icon={<EditOutlined />} onClick={openEditModal}>编辑</Button>
+            )}
+          </Space>
+        </div>
+      </div>
+
+      <Row gutter={24}>
+        {/* Left column */}
+        <Col span={16}>
+          {/* Basic info */}
+          <Card title="基本信息" size="small" style={{ marginBottom: 16 }}>
+            <Descriptions column={2} size="small">
+              <Descriptions.Item label="TG链接">
+                <a href={merchant.tg_link || `https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
+                  {merchant.tg_link || `https://t.me/${merchant.tg_username}`}
+                </a>
+              </Descriptions.Item>
+              <Descriptions.Item label="行业">{merchant.industry_tag || '-'}</Descriptions.Item>
+              <Descriptions.Item label="网站" span={2}>
+                {merchant.website ? <a href={merchant.website} target="_blank" rel="noreferrer">{merchant.website}</a> : '-'}
+              </Descriptions.Item>
+              <Descriptions.Item label="邮箱">{merchant.email || '-'}</Descriptions.Item>
+              <Descriptions.Item label="电话">{merchant.phone || '-'}</Descriptions.Item>
+              <Descriptions.Item label="存活状态">
+                {merchant.is_alive ? <Tag color="green">存活</Tag> : <Tag color="red">失效</Tag>}
+              </Descriptions.Item>
+              <Descriptions.Item label="最后检查">
+                {merchant.last_checked_at ? formatDateTime(merchant.last_checked_at) : '-'}
+              </Descriptions.Item>
+              <Descriptions.Item label="创建时间">{formatDateTime(merchant.created_at)}</Descriptions.Item>
+              <Descriptions.Item label="更新时间">{formatDateTime(merchant.updated_at)}</Descriptions.Item>
+              {merchant.remark && (
+                <Descriptions.Item label="备注" span={2}>{merchant.remark}</Descriptions.Item>
+              )}
+            </Descriptions>
+          </Card>
+
+          {/* Sources */}
+          <Card title={`来源记录 (${sources.length})`} size="small" style={{ marginBottom: 16 }}>
+            {sources.length === 0 ? <Text type="secondary">无来源记录</Text> : sources.map((src, idx) => (
+              <div key={idx} style={{
+                background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 6,
+                padding: '10px 14px', marginBottom: 8,
+              }}>
+                <Row align="middle" gutter={8}>
+                  <Col><Tag color={sourceTypeColor[src.source_type] ?? 'default'}>{src.source_type}</Tag></Col>
+                  <Col flex="auto"><Text strong style={{ fontSize: 13 }}>{src.source_name || '未知来源'}</Text></Col>
+                </Row>
+                {src.source_url && (
+                  <div style={{ marginTop: 6 }}>
+                    <LinkOutlined style={{ color: '#1890ff', marginRight: 4 }} />
+                    <a href={src.source_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, wordBreak: 'break-all' }}>
+                      {src.source_url}
+                    </a>
+                  </div>
+                )}
+              </div>
+            ))}
+          </Card>
+
+          {/* Groups */}
+          {groups.length > 0 && (
+            <Card title={<><TeamOutlined /> 所属群/频道 ({groups.length})</>} size="small" style={{ marginBottom: 16 }}>
+              {groups.map((gm, idx) => (
+                <div key={idx} style={{
+                  background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 6,
+                  padding: '8px 14px', marginBottom: 6,
+                  display: 'flex', justifyContent: 'space-between', alignItems: 'center',
+                }}>
+                  <div>
+                    <a href={`https://t.me/${gm.group_username}`} target="_blank" rel="noreferrer">
+                      @{gm.group_username}
+                    </a>
+                    {gm.group_title && <Text type="secondary" style={{ marginLeft: 8 }}>{gm.group_title}</Text>}
+                    <Tag color="green" style={{ marginLeft: 8 }}>{gm.source_type}</Tag>
+                  </div>
+                  {hasAction('task_start') && (
+                    <Button size="small" onClick={() => handleCollectGroup(gm.group_username)}>采集此群</Button>
+                  )}
+                </div>
+              ))}
+            </Card>
+          )}
+
+          {/* TG Preview */}
+          <Card title="TG 页面预览" size="small">
+            <div style={{ border: '1px solid #f0f0f0', borderRadius: 6, overflow: 'hidden' }}>
+              <iframe
+                src={`https://t.me/${merchant.tg_username}`}
+                style={{ width: '100%', height: 300, border: 'none' }}
+                sandbox="allow-scripts allow-same-origin"
+                title="TG Preview"
+              />
+            </div>
+          </Card>
+        </Col>
+
+        {/* Right column */}
+        <Col span={8}>
+          {/* Status controls */}
+          <Card title="跟进信息" size="small" style={{ marginBottom: 16 }}>
+            <div style={{ marginBottom: 12 }}>
+              <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>跟进状态</Text>
+              <Select
+                value={merchant.follow_status || 'pending'}
+                style={{ width: '100%' }}
+                onChange={handleFollowStatusChange}
+                disabled={!hasAction('merchant_edit')}
+              >
+                {Object.entries(followStatusLabels).map(([k, v]) => (
+                  <Option key={k} value={k}>
+                    <Badge status={followStatusBadge[k] ?? 'default'} text={v} />
+                  </Option>
+                ))}
+              </Select>
+            </div>
+            <div>
+              <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
+                <UserSwitchOutlined /> 负责人
+              </Text>
+              <Select
+                value={merchant.assigned_to || undefined}
+                placeholder="未分配"
+                style={{ width: '100%' }}
+                onChange={handleAssign}
+                allowClear
+                disabled={!hasAction('merchant_assign')}
+              >
+                {assignableUsers.map(u => (
+                  <Option key={u.username} value={u.username}>{u.nickname || u.username}</Option>
+                ))}
+              </Select>
+            </div>
+          </Card>
+
+          {/* Notes timeline */}
+          <Card title={`跟进备注 (${notes.length})`} size="small">
+            {hasAction('merchant_edit') && (
+              <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
+                <Input.TextArea
+                  placeholder="输入备注..."
+                  value={noteInput}
+                  onChange={(e) => setNoteInput(e.target.value)}
+                  autoSize={{ minRows: 2, maxRows: 4 }}
+                  style={{ flex: 1 }}
+                />
+                <Button type="primary" loading={noteLoading} onClick={handleAddNote}
+                  disabled={!noteInput.trim()} style={{ alignSelf: 'flex-end' }}>
+                  添加
+                </Button>
+              </div>
+            )}
+
+            {notes.length === 0 ? (
+              <Text type="secondary">暂无备注</Text>
+            ) : (
+              <Timeline
+                items={notes.map(note => ({
+                  children: (
+                    <div>
+                      <div style={{ fontSize: 13 }}>{note.content}</div>
+                      <div style={{ fontSize: 11, color: '#999', marginTop: 4 }}>
+                        {note.created_by} · {formatDateTime(note.created_at)}
+                      </div>
+                    </div>
+                  ),
+                }))}
+              />
+            )}
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Edit Modal */}
+      <Modal
+        title="编辑商户信息"
+        open={editModalOpen}
+        onCancel={() => setEditModalOpen(false)}
+        onOk={handleEditSave}
+        confirmLoading={editLoading}
+        okText="保存"
+        cancelText="取消"
+      >
+        <Form form={editForm} layout="vertical">
+          <Form.Item name="merchant_name" label="商户名"><Input /></Form.Item>
+          <Form.Item name="industry_tag" label="行业标签"><Input /></Form.Item>
+          <Form.Item name="website" label="网站"><Input /></Form.Item>
+          <Form.Item name="email" label="邮箱"><Input /></Form.Item>
+          <Form.Item name="phone" label="电话"><Input /></Form.Item>
+          <Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}

+ 500 - 71
web/src/pages/MerchantsClean.tsx

@@ -1,14 +1,28 @@
-import { useEffect, useState, useCallback } from 'react'
-import { Table, Tag, Select, Input, Space, Button, message, Row, Col, Badge } from 'antd'
-import { DownloadOutlined } from '@ant-design/icons'
-import { getMerchantsClean, type MerchantClean } from '../api'
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Tag, Select, Input, Button, message, Row, Col, Badge, Modal, Typography, Space, Tooltip, Form, Upload, Alert, Switch } from 'antd'
+import { DownloadOutlined, UploadOutlined, LinkOutlined, SearchOutlined, EditOutlined, UserSwitchOutlined } from '@ant-design/icons'
+import { useNavigate } from 'react-router-dom'
+import {
+  getMerchantsClean, getLevelMap, startTask, updateMerchantClean, updateFollowStatus,
+  batchDeleteClean, batchFollowStatus, batchLevel, batchAssign, getAssignableUsers, importMerchantsCSV,
+  getIndustryTags, mergeMerchants, batchRecheck,
+  type MerchantClean, type AssignableUser,
+} from '../api'
+import { useAppStore } from '../store'
 
 const { Option } = Select
+const { Text } = Typography
 
 function formatDateTime(dateStr: string) {
   return new Date(dateStr).toLocaleString('zh-CN')
 }
 
+interface SourceInfo {
+  source_type: string
+  source_name: string
+  source_url: string
+}
+
 const statusOptions = [
   { label: '全部', value: '' },
   { label: 'valid', value: 'valid' },
@@ -17,14 +31,14 @@ const statusOptions = [
   { label: 'duplicate', value: 'duplicate' },
 ]
 
-const levelOptions = [
+const defaultLevelOptions = [
   { label: '全部', value: '' },
-  { label: 'Hot', value: 'Hot' },
-  { label: 'Warm', value: 'Warm' },
-  { label: 'Cold', value: 'Cold' },
+  { label: '优质商户', value: 'Hot' },
+  { label: '普通商户', value: 'Warm' },
+  { label: '待跟进', value: 'Cold' },
 ]
 
-const levelColor: Record<string, string> = {
+const defaultLevelColor: Record<string, string> = {
   Hot: 'red',
   Warm: 'orange',
   Cold: 'blue',
@@ -37,7 +51,30 @@ const statusBadgeMap: Record<string, 'success' | 'error' | 'warning' | 'default'
   duplicate: 'default',
 }
 
+const followStatusOptions = [
+  { label: '全部', value: '' },
+  { label: '待跟进', value: 'pending' },
+  { label: '已联系', value: 'contacted' },
+  { label: '已合作', value: 'cooperating' },
+  { label: '已拒绝', value: 'rejected' },
+]
+
+const followStatusBadge: Record<string, 'default' | 'processing' | 'success' | 'error'> = {
+  pending: 'default',
+  contacted: 'processing',
+  cooperating: 'success',
+  rejected: 'error',
+}
+
+const sourceTypeColor: Record<string, string> = {
+  web: 'blue',
+  tg_channel: 'orange',
+  github: 'geekblue',
+}
+
 export default function MerchantsClean() {
+  const navigate = useNavigate()
+  const { user, isOperator, hasAction } = useAppStore()
   const [data, setData] = useState<MerchantClean[]>([])
   const [total, setTotal] = useState(0)
   const [page, setPage] = useState(1)
@@ -45,6 +82,38 @@ export default function MerchantsClean() {
   const [status, setStatus] = useState('')
   const [level, setLevel] = useState('')
   const [search, setSearch] = useState('')
+  const [searchInput, setSearchInput] = useState('')
+  const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
+  const [followStatus, setFollowStatus] = useState('')
+  const [assignedTo, setAssignedTo] = useState('')
+  const [levelMap, setLevelMap] = useState<Record<string, { label: string; color: string; description: string }>>({})
+  const [editModalOpen, setEditModalOpen] = useState(false)
+  const [editRecord, setEditRecord] = useState<MerchantClean | null>(null)
+  const [editForm] = Form.useForm()
+  const [editLoading, setEditLoading] = useState(false)
+  // Batch selection
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
+  // Assignment
+  const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
+  const [assignModalOpen, setAssignModalOpen] = useState(false)
+  const [assignTarget, setAssignTarget] = useState('')
+  const [assignSingleId, setAssignSingleId] = useState<number | null>(null)
+  // Import
+  const [importModalOpen, setImportModalOpen] = useState(false)
+  const [importLoading, setImportLoading] = useState(false)
+  // Industry filter
+  const [industryTag, setIndustryTag] = useState('')
+  const [industryTags, setIndustryTags] = useState<string[]>([])
+  const [hasContact, setHasContact] = useState(false)
+
+  useEffect(() => {
+    getLevelMap().then(res => setLevelMap(res.data)).catch(() => {})
+    getAssignableUsers().then(res => setAssignableUsers(res.data || [])).catch(() => {})
+    getIndustryTags().then(res => setIndustryTags(res.data || [])).catch(() => {})
+  }, [])
+
+  const getLevelLabel = (key: string) => levelMap[key]?.label || key
+  const getLevelColor = (key: string) => levelMap[key]?.color || defaultLevelColor[key] || 'default'
 
   const fetchData = useCallback(async (currentPage = 1) => {
     setLoading(true)
@@ -57,7 +126,11 @@ export default function MerchantsClean() {
       }
       if (status) params.status = status
       if (level) params.level = level
+      if (followStatus) params.follow_status = followStatus
+      if (assignedTo) params.assigned_to = assignedTo
+      if (industryTag) params.industry_tag = industryTag
       if (search) params.search = search
+      if (hasContact) params.has_contact = '1'
       const res = await getMerchantsClean(params)
       setData(res.data.items)
       setTotal(res.data.total)
@@ -66,144 +139,500 @@ export default function MerchantsClean() {
     } finally {
       setLoading(false)
     }
-  }, [status, level, search])
+  }, [status, level, followStatus, assignedTo, industryTag, search, hasContact])
 
   useEffect(() => {
     setPage(1)
     fetchData(1)
-  }, [status, level, search, fetchData])
+  }, [status, level, followStatus, assignedTo, industryTag, search, hasContact, fetchData])
+
+  const handleSearchInput = (value: string) => {
+    setSearchInput(value)
+    if (debounceTimer.current) clearTimeout(debounceTimer.current)
+    debounceTimer.current = setTimeout(() => setSearch(value), 500)
+  }
 
   const handleExport = () => {
-    let url = '/api/v1/merchants/clean/export?'
-    if (level) url += `level=${level}`
-    window.open(url, '_blank')
+    const params = new URLSearchParams()
+    if (status) params.set('status', status)
+    if (level) params.set('level', level)
+    if (followStatus) params.set('follow_status', followStatus)
+    if (assignedTo) params.set('assigned_to', assignedTo)
+    if (industryTag) params.set('industry_tag', industryTag)
+    if (search) params.set('search', search)
+    if (hasContact) params.set('has_contact', '1')
+    window.open(`/api/v1/merchants/clean/export?${params.toString()}`, '_blank')
+    message.success('正在导出...')
+  }
+
+  const parseSources = (record: MerchantClean): SourceInfo[] => {
+    try {
+      if (Array.isArray(record.all_sources)) return record.all_sources as SourceInfo[]
+      if (typeof record.all_sources === 'string') return JSON.parse(record.all_sources)
+    } catch { /* ignore */ }
+    return []
+  }
+
+  const handleFollowStatusChange = async (id: number, val: string) => {
+    try {
+      await updateFollowStatus(id, val)
+      message.success('跟进状态已更新')
+      setData(prev => prev.map(item => item.id === id ? { ...item, follow_status: val } : item))
+    } catch {
+      message.error('更新跟进状态失败')
+    }
+  }
+
+  const openEditModal = (record: MerchantClean) => {
+    setEditRecord(record)
+    editForm.setFieldsValue({
+      merchant_name: record.merchant_name,
+      industry_tag: record.industry_tag,
+      website: record.website,
+      email: record.email,
+      phone: record.phone,
+      remark: record.remark || '',
+    })
+    setEditModalOpen(true)
+  }
+
+  const handleEditSave = async () => {
+    if (!editRecord) return
+    try {
+      const values = await editForm.validateFields()
+      setEditLoading(true)
+      const res = await updateMerchantClean(editRecord.id, values)
+      message.success('商户信息已更新')
+      setData(prev => prev.map(item => item.id === editRecord.id ? res.data : item))
+      setEditModalOpen(false)
+    } catch {
+      message.error('更新失败')
+    } finally {
+      setEditLoading(false)
+    }
+  }
+
+  const handleCollectGroup = async (username: string) => {
+    Modal.confirm({
+      title: `采集群 @${username}`,
+      content: `将使用 TG 采集器采集群 @${username} 中的成员和联系方式。`,
+      okText: '开始采集',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await startTask({ plugin_name: 'tg_collector', target_group: username })
+          message.success(`已启动对 @${username} 的采集任务`)
+        } catch {
+          message.error('启动采集失败')
+        }
+      },
+    })
+  }
+
+  // ── Batch operations ──
+  const handleBatchFollowStatus = (val: string) => {
+    Modal.confirm({
+      title: `批量修改跟进状态`,
+      content: `将 ${selectedRowKeys.length} 个商户的跟进状态修改为"${followStatusOptions.find(o => o.value === val)?.label}"`,
+      onOk: async () => {
+        await batchFollowStatus(selectedRowKeys as number[], val)
+        message.success('批量修改成功')
+        setSelectedRowKeys([])
+        fetchData(page)
+      },
+    })
+  }
+
+  const handleBatchLevel = (val: string) => {
+    Modal.confirm({
+      title: `批量修改等级`,
+      content: `将 ${selectedRowKeys.length} 个商户的等级修改为"${getLevelLabel(val)}"`,
+      onOk: async () => {
+        await batchLevel(selectedRowKeys as number[], val)
+        message.success('批量修改成功')
+        setSelectedRowKeys([])
+        fetchData(page)
+      },
+    })
+  }
+
+  const handleBatchDelete = () => {
+    Modal.confirm({
+      title: '批量删除',
+      content: `确认删除 ${selectedRowKeys.length} 个商户?此操作不可撤销。`,
+      okType: 'danger',
+      onOk: async () => {
+        await batchDeleteClean(selectedRowKeys as number[])
+        message.success('批量删除成功')
+        setSelectedRowKeys([])
+        fetchData(page)
+      },
+    })
+  }
+
+  const openAssignModal = (singleId?: number) => {
+    setAssignSingleId(singleId ?? null)
+    setAssignTarget('')
+    setAssignModalOpen(true)
+  }
+
+  const handleAssignConfirm = async () => {
+    try {
+      if (assignSingleId) {
+        const res = await (await import('../api')).assignMerchant(assignSingleId, assignTarget)
+        setData(prev => prev.map(item => item.id === assignSingleId ? res.data : item))
+      } else {
+        await batchAssign(selectedRowKeys as number[], assignTarget)
+        setSelectedRowKeys([])
+        fetchData(page)
+      }
+      message.success('分配成功')
+      setAssignModalOpen(false)
+    } catch {
+      message.error('分配失败')
+    }
+  }
+
+  // ── Import ──
+  const handleImport = async (file: File) => {
+    setImportLoading(true)
+    try {
+      const res = await importMerchantsCSV(file)
+      const d = res.data
+      Modal.info({
+        title: '导入结果',
+        content: (
+          <div>
+            <p>成功导入:{d.imported} 条</p>
+            <p>跳过(重复):{d.skipped} 条</p>
+            <p>失败:{d.failed} 条</p>
+            {d.errors && d.errors.length > 0 && (
+              <Alert type="warning" message={d.errors.slice(0, 10).join('\n')} style={{ whiteSpace: 'pre-wrap', marginTop: 8 }} />
+            )}
+          </div>
+        ),
+      })
+      setImportModalOpen(false)
+      fetchData(1)
+    } catch {
+      message.error('导入失败')
+    } finally {
+      setImportLoading(false)
+    }
   }
 
   const columns = [
-    { title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
     {
       title: 'TG用户名',
       dataIndex: 'tg_username',
       key: 'tg_username',
-      render: (v: string) => v ? (
-        <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">@{v}</a>
+      width: 150,
+      render: (v: string, record: MerchantClean) => v ? (
+        <a onClick={() => navigate(`/merchants/${record.id}`)} style={{ cursor: 'pointer' }}>@{v}</a>
       ) : '-',
     },
     {
       title: '商户名',
       dataIndex: 'merchant_name',
       key: 'merchant_name',
-      render: (v: string) => v || '-',
+      ellipsis: true,
+      render: (v: string, record: MerchantClean) => (
+        <a onClick={() => navigate(`/merchants/${record.id}`)} style={{ cursor: 'pointer' }}>{v || '-'}</a>
+      ),
     },
     {
       title: '等级',
       dataIndex: 'level',
       key: 'level',
-      render: (v: string) => <Tag color={levelColor[v] ?? 'default'}>{v}</Tag>,
+      width: 100,
+      render: (v: string) => <Tooltip title={levelMap[v]?.description}><Tag color={getLevelColor(v)}>{getLevelLabel(v)}</Tag></Tooltip>,
+    },
+    {
+      title: '跟进状态',
+      dataIndex: 'follow_status',
+      key: 'follow_status',
+      width: 120,
+      render: (v: string, record: MerchantClean) => (
+        <Select
+          size="small"
+          value={v || 'pending'}
+          style={{ width: 100 }}
+          onChange={(val) => handleFollowStatusChange(record.id, val)}
+          disabled={!hasAction('merchant_edit')}
+        >
+          {followStatusOptions.filter(o => o.value).map(o => (
+            <Option key={o.value} value={o.value}>
+              <Badge status={followStatusBadge[o.value] ?? 'default'} text={o.label} />
+            </Option>
+          ))}
+        </Select>
+      ),
     },
     {
       title: '状态',
       dataIndex: 'status',
       key: 'status',
-      render: (v: string) => (
-        <Badge status={statusBadgeMap[v] ?? 'default'} text={v} />
-      ),
+      width: 80,
+      render: (v: string) => <Badge status={statusBadgeMap[v] ?? 'default'} text={v} />,
     },
     {
-      title: '网站',
-      dataIndex: 'website',
-      key: 'website',
-      ellipsis: true,
-      render: (v: string) => v ? (
-        <a href={v} target="_blank" rel="noreferrer">{v}</a>
-      ) : '-',
+      title: '负责人',
+      dataIndex: 'assigned_to',
+      key: 'assigned_to',
+      width: 90,
+      render: (v: string) => {
+        if (!v) return <Text type="secondary">-</Text>
+        const u = assignableUsers.find(u => u.username === v)
+        return <Tag>{u?.nickname || v}</Tag>
+      },
     },
     {
-      title: '邮箱',
-      dataIndex: 'email',
-      key: 'email',
-      render: (v: string) => v || '-',
+      title: '来源',
+      key: 'sources',
+      width: 220,
+      render: (_: unknown, record: MerchantClean) => {
+        const sources = parseSources(record)
+        if (sources.length === 0) return <Text type="secondary">-</Text>
+        const first = sources[0]
+        return (
+          <div style={{ fontSize: 12, lineHeight: 1.5 }}>
+            <Tag color={sourceTypeColor[first.source_type] ?? 'default'} style={{ fontSize: 11 }}>
+              {first.source_type}
+            </Tag>
+            {sources.length > 1 && (
+              <Text type="secondary" style={{ fontSize: 11 }}>+{sources.length - 1}</Text>
+            )}
+          </div>
+        )
+      },
     },
     {
       title: '行业',
       dataIndex: 'industry_tag',
       key: 'industry_tag',
-      render: (v: string) => v || '-',
-    },
-    {
-      title: '来源数',
-      dataIndex: 'source_count',
-      key: 'source_count',
       width: 80,
+      render: (v: string) => v ? <Tag>{v}</Tag> : '-',
     },
     {
-      title: '创建时间',
-      dataIndex: 'created_at',
-      key: 'created_at',
-      render: (t: string) => formatDateTime(t),
+      title: '操作',
+      key: 'action',
+      width: 130,
+      render: (_: unknown, record: MerchantClean) => (
+        <Space size={4}>
+          {hasAction('merchant_edit') && (
+            <Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
+          )}
+          {hasAction('merchant_assign') && (
+            <Button size="small" type="link" icon={<UserSwitchOutlined />} onClick={() => openAssignModal(record.id)} />
+          )}
+          {hasAction('task_start') && (
+            <Tooltip title="采集此TG群/频道的成员">
+              <Button size="small" type="link" icon={<SearchOutlined />}
+                onClick={() => handleCollectGroup(record.tg_username)} />
+            </Tooltip>
+          )}
+        </Space>
+      ),
     },
   ]
 
+  const rowSelection = (hasAction('merchant_edit') || hasAction('merchant_delete')) ? {
+    selectedRowKeys,
+    onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
+  } : undefined
+
   return (
     <div>
-      <Row gutter={[16, 16]} style={{ marginBottom: 16 }} align="middle">
+      {/* Filters */}
+      <Row gutter={[12, 12]} style={{ marginBottom: 16 }} align="middle">
         <Col>
-          <Select
-            style={{ width: 140 }}
-            value={status}
-            onChange={setStatus}
-            placeholder="状态筛选"
-          >
-            {statusOptions.map((o) => (
-              <Option key={o.value} value={o.value}>{o.label}</Option>
-            ))}
+          <Select style={{ width: 120 }} value={status} onChange={setStatus} placeholder="状态">
+            {statusOptions.map(o => <Option key={o.value} value={o.value}>{o.label}</Option>)}
           </Select>
         </Col>
         <Col>
-          <Select
-            style={{ width: 140 }}
-            value={level}
-            onChange={setLevel}
-            placeholder="等级筛选"
-          >
-            {levelOptions.map((o) => (
-              <Option key={o.value} value={o.value}>{o.label}</Option>
+          <Select style={{ width: 120 }} value={level} onChange={setLevel} placeholder="等级">
+            <Option value="">全部</Option>
+            {Object.entries(levelMap).length > 0
+              ? Object.entries(levelMap).map(([key, info]) => <Option key={key} value={key}>{info.label}</Option>)
+              : defaultLevelOptions.slice(1).map(o => <Option key={o.value} value={o.value}>{o.label}</Option>)
+            }
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 120 }} value={followStatus} onChange={setFollowStatus} placeholder="跟进状态">
+            {followStatusOptions.map(o => <Option key={o.value} value={o.value}>{o.label}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 120 }} value={industryTag} onChange={setIndustryTag} placeholder="行业" allowClear>
+            <Option value="">全部</Option>
+            {industryTags.map(tag => <Option key={tag} value={tag}>{tag}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 140 }} value={assignedTo} onChange={setAssignedTo} placeholder="负责人" allowClear>
+            <Option value="">全部</Option>
+            <Option value={user?.username || ''}>我的商户</Option>
+            <Option value="__unassigned__">未分配</Option>
+            {assignableUsers.filter(u => u.username !== user?.username).map(u => (
+              <Option key={u.username} value={u.username}>{u.nickname || u.username}</Option>
             ))}
           </Select>
         </Col>
         <Col>
           <Input.Search
             placeholder="搜索商户名/TG用户名"
-            value={search}
-            onChange={(e) => setSearch(e.target.value)}
-            style={{ width: 240 }}
+            value={searchInput}
+            onChange={(e) => handleSearchInput(e.target.value)}
+            onSearch={(v) => setSearch(v)}
+            style={{ width: 200 }}
             allowClear
           />
         </Col>
+        <Col>
+          <Space>
+            <Switch size="small" checked={hasContact} onChange={setHasContact} />
+            <Typography.Text style={{ fontSize: 13 }}>有联系方式</Typography.Text>
+          </Space>
+        </Col>
         <Col flex="auto" style={{ textAlign: 'right' }}>
-          <Button icon={<DownloadOutlined />} onClick={handleExport}>
-            导出 CSV
-          </Button>
+          <Space>
+            {hasAction('merchant_import') && (
+              <Button icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>导入</Button>
+            )}
+            {hasAction('merchant_export') && (
+              <Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
+            )}
+          </Space>
         </Col>
       </Row>
 
+      {/* Batch action bar */}
+      {selectedRowKeys.length > 0 && (
+        <div style={{
+          marginBottom: 12, padding: '8px 16px', background: '#e6f7ff',
+          borderRadius: 6, display: 'flex', alignItems: 'center', gap: 12,
+        }}>
+          <Text strong>已选 {selectedRowKeys.length} 项</Text>
+          <Select placeholder="批量跟进状态" size="small" style={{ width: 130 }}
+            onChange={handleBatchFollowStatus} value={undefined}>
+            {followStatusOptions.filter(o => o.value).map(o => (
+              <Option key={o.value} value={o.value}>{o.label}</Option>
+            ))}
+          </Select>
+          <Select placeholder="批量等级" size="small" style={{ width: 120 }}
+            onChange={handleBatchLevel} value={undefined}>
+            {Object.entries(levelMap).map(([key, info]) => (
+              <Option key={key} value={key}>{info.label}</Option>
+            ))}
+          </Select>
+          <Button size="small" onClick={() => openAssignModal()}>批量分配</Button>
+          {selectedRowKeys.length === 2 && (
+            <Button size="small" onClick={() => {
+              const [a, b] = selectedRowKeys as number[]
+              Modal.confirm({
+                title: '合并商户',
+                content: `将商户 #${b} 合并到 #${a}(保留 #${a},删除 #${b})。空字段从 #${b} 补充,来源合并。`,
+                onOk: async () => {
+                  await mergeMerchants(a, b)
+                  message.success('合并成功')
+                  setSelectedRowKeys([])
+                  fetchData(page)
+                },
+              })
+            }}>合并 (2→1)</Button>
+          )}
+          <Button size="small" onClick={async () => {
+            await batchRecheck(selectedRowKeys as number[])
+            message.success('已标记重新检查')
+            setSelectedRowKeys([])
+          }}>重新检查</Button>
+          <Button size="small" danger onClick={handleBatchDelete}>批量删除</Button>
+          <Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>取消选择</Button>
+        </div>
+      )}
+
       <Table
         dataSource={data}
         columns={columns}
         rowKey="id"
         loading={loading}
+        rowSelection={rowSelection}
         pagination={{
           current: page,
           pageSize: 20,
           total,
-          onChange: (p) => {
-            setPage(p)
-            fetchData(p)
-          },
+          onChange: (p) => { setPage(p); fetchData(p) },
           showTotal: (t) => `共 ${t} 条`,
         }}
         scroll={{ x: 1200 }}
       />
+
+      {/* Edit Modal */}
+      <Modal
+        title="编辑商户信息"
+        open={editModalOpen}
+        onCancel={() => setEditModalOpen(false)}
+        onOk={handleEditSave}
+        confirmLoading={editLoading}
+        okText="保存"
+        cancelText="取消"
+      >
+        <Form form={editForm} layout="vertical">
+          <Form.Item name="merchant_name" label="商户名"><Input /></Form.Item>
+          <Form.Item name="industry_tag" label="行业标签"><Input /></Form.Item>
+          <Form.Item name="website" label="网站"><Input /></Form.Item>
+          <Form.Item name="email" label="邮箱"><Input /></Form.Item>
+          <Form.Item name="phone" label="电话"><Input /></Form.Item>
+          <Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
+        </Form>
+      </Modal>
+
+      {/* Assign Modal */}
+      <Modal
+        title={assignSingleId ? '分配负责人' : `批量分配 ${selectedRowKeys.length} 个商户`}
+        open={assignModalOpen}
+        onCancel={() => setAssignModalOpen(false)}
+        onOk={handleAssignConfirm}
+        okText="确认分配"
+      >
+        <Select
+          style={{ width: '100%' }}
+          placeholder="选择负责人"
+          value={assignTarget || undefined}
+          onChange={setAssignTarget}
+          allowClear
+        >
+          {assignableUsers.map(u => (
+            <Option key={u.username} value={u.username}>{u.nickname || u.username}</Option>
+          ))}
+        </Select>
+      </Modal>
+
+      {/* Import Modal */}
+      <Modal
+        title="导入商户数据"
+        open={importModalOpen}
+        onCancel={() => setImportModalOpen(false)}
+        footer={null}
+      >
+        <p style={{ marginBottom: 8 }}>上传 CSV 文件,必须包含 <Text code>tg_username</Text> 列。</p>
+        <p style={{ marginBottom: 16, color: '#888', fontSize: 12 }}>
+          可选列:merchant_name, website, email, phone, industry_tag, level。已存在的 tg_username 将被跳过。
+        </p>
+        <Upload.Dragger
+          accept=".csv"
+          maxCount={1}
+          showUploadList={false}
+          customRequest={({ file }) => handleImport(file as File)}
+          disabled={importLoading}
+        >
+          <p style={{ fontSize: 40, color: '#1890ff' }}><UploadOutlined /></p>
+          <p>{importLoading ? '导入中...' : '点击或拖拽 CSV 文件到此处'}</p>
+        </Upload.Dragger>
+      </Modal>
     </div>
   )
 }

+ 325 - 0
web/src/pages/MerchantsRaw.tsx

@@ -0,0 +1,325 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Tag, Select, Input, Button, message, Row, Col, Badge, Modal, Typography, Space, Tooltip, Descriptions, Popconfirm, Tabs } from 'antd'
+import { DeleteOutlined, EyeOutlined, LinkOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons'
+import { getMerchantsRaw, batchDeleteRaw, getArchivedMerchants, archiveMerchants, restoreArchived, type MerchantRaw, type MerchantArchived } from '../api'
+import { useAppStore } from '../store'
+
+const { Option } = Select
+const { Text } = Typography
+
+function formatDateTime(dateStr: string) {
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+const statusOptions = [
+  { label: '全部', value: '' },
+  { label: '待处理', value: 'raw' },
+  { label: '已处理', value: 'done' },
+]
+
+const sourceTypeOptions = [
+  { label: '全部', value: '' },
+  { label: 'Web', value: 'web' },
+  { label: 'TG频道', value: 'tg_channel' },
+  { label: 'GitHub', value: 'github' },
+]
+
+const statusBadgeMap: Record<string, 'success' | 'error' | 'warning' | 'default' | 'processing'> = {
+  raw: 'processing',
+  done: 'success',
+  processing: 'processing',
+}
+
+const sourceTypeColor: Record<string, string> = {
+  web: 'blue',
+  tg_channel: 'orange',
+  github: 'geekblue',
+}
+
+function RawTab() {
+  const [data, setData] = useState<MerchantRaw[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [status, setStatus] = useState('')
+  const [sourceType, setSourceType] = useState('')
+  const [search, setSearch] = useState('')
+  const [searchInput, setSearchInput] = useState('')
+  const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
+  const [detailRecord, setDetailRecord] = useState<MerchantRaw | null>(null)
+  const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
+  const { isAdmin, isOperator } = useAppStore()
+
+  const canModify = isAdmin() || isOperator()
+
+  const fetchData = useCallback(async (currentPage = 1) => {
+    setLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: currentPage, page_size: 20 }
+      if (status) params.status = status
+      if (sourceType) params.source_type = sourceType
+      if (search) params.search = search
+      const res = await getMerchantsRaw(params)
+      setData(res.data.items)
+      setTotal(res.data.total)
+    } catch {
+      message.error('获取原始数据失败')
+    } finally {
+      setLoading(false)
+    }
+  }, [status, sourceType, search])
+
+  useEffect(() => {
+    setPage(1)
+    fetchData(1)
+  }, [status, sourceType, search, fetchData])
+
+  const handleSearchInput = (value: string) => {
+    setSearchInput(value)
+    if (debounceTimer.current) clearTimeout(debounceTimer.current)
+    debounceTimer.current = setTimeout(() => setSearch(value), 500)
+  }
+
+  const handleBatchDelete = async () => {
+    if (selectedRowKeys.length === 0) return
+    try {
+      await batchDeleteRaw(selectedRowKeys)
+      message.success(`已删除 ${selectedRowKeys.length} 条记录`)
+      setSelectedRowKeys([])
+      fetchData(page)
+    } catch {
+      message.error('批量删除失败')
+    }
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
+    {
+      title: 'TG用户名', dataIndex: 'tg_username', key: 'tg_username', width: 150,
+      render: (v: string) => v ? <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">@{v}</a> : '-',
+    },
+    { title: '商户名', dataIndex: 'merchant_name', key: 'merchant_name', ellipsis: true, render: (v: string) => v || '-' },
+    {
+      title: '来源', dataIndex: 'source_type', key: 'source_type', width: 100,
+      render: (v: string) => <Tag color={sourceTypeColor[v] ?? 'default'}>{v}</Tag>,
+    },
+    { title: '来源名称', dataIndex: 'source_name', key: 'source_name', width: 160, ellipsis: true, render: (v: string) => v || '-' },
+    {
+      title: '来源链接', dataIndex: 'source_url', key: 'source_url', width: 200,
+      render: (v: string) => v ? (
+        <Tooltip title={v}><a href={v} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}><LinkOutlined /> {truncateUrl(v, 30)}</a></Tooltip>
+      ) : '-',
+    },
+    { title: '行业', dataIndex: 'industry_tag', key: 'industry_tag', width: 80, render: (v: string) => v ? <Tag>{v}</Tag> : '-' },
+    {
+      title: '联系方式', key: 'contact', width: 150,
+      render: (_: unknown, record: MerchantRaw) => {
+        const has = []
+        if (record.website) has.push(<Tag key="w" color="blue">网站</Tag>)
+        if (record.email) has.push(<Tag key="e" color="green">邮箱</Tag>)
+        if (record.phone) has.push(<Tag key="p" color="orange">电话</Tag>)
+        return has.length > 0 ? <Space size={4} wrap>{has}</Space> : <Text type="secondary">-</Text>
+      },
+    },
+    {
+      title: '状态', dataIndex: 'status', key: 'status', width: 100,
+      render: (v: string) => <Badge status={statusBadgeMap[v] ?? 'default'} text={v} />,
+    },
+    {
+      title: '采集时间', dataIndex: 'created_at', key: 'created_at', width: 160,
+      render: (v: string) => formatDateTime(v),
+    },
+    {
+      title: '操作', key: 'action', width: 60,
+      render: (_: unknown, record: MerchantRaw) => (
+        <Button size="small" type="link" icon={<EyeOutlined />} onClick={() => setDetailRecord(record)} />
+      ),
+    },
+  ]
+
+  return (
+    <>
+      <Row gutter={[16, 16]} style={{ marginBottom: 16 }} align="middle">
+        <Col>
+          <Select style={{ width: 130 }} value={status} onChange={setStatus} placeholder="状态筛选">
+            {statusOptions.map(o => <Option key={o.value} value={o.value}>{o.label}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Select style={{ width: 130 }} value={sourceType} onChange={setSourceType} placeholder="来源类型">
+            {sourceTypeOptions.map(o => <Option key={o.value} value={o.value}>{o.label}</Option>)}
+          </Select>
+        </Col>
+        <Col>
+          <Input.Search
+            placeholder="搜索商户名/TG用户名"
+            value={searchInput}
+            onChange={(e) => handleSearchInput(e.target.value)}
+            onSearch={(v) => setSearch(v)}
+            style={{ width: 240 }}
+            allowClear
+          />
+        </Col>
+        <Col flex="auto" style={{ textAlign: 'right' }}>
+          {canModify && selectedRowKeys.length > 0 && (
+            <Popconfirm title={`确认删除 ${selectedRowKeys.length} 条记录?`} onConfirm={handleBatchDelete}>
+              <Button danger icon={<DeleteOutlined />}>批量删除 ({selectedRowKeys.length})</Button>
+            </Popconfirm>
+          )}
+        </Col>
+      </Row>
+
+      <Table
+        dataSource={data} columns={columns} rowKey="id" loading={loading}
+        rowSelection={canModify ? { selectedRowKeys, onChange: keys => setSelectedRowKeys(keys as number[]) } : undefined}
+        pagination={{
+          current: page, pageSize: 20, total,
+          onChange: p => { setPage(p); fetchData(p) },
+          showTotal: t => `共 ${t} 条`,
+        }}
+        scroll={{ x: 1200 }}
+      />
+
+      <Modal title={detailRecord ? `原始数据详情 #${detailRecord.id}` : ''} open={!!detailRecord}
+        onCancel={() => setDetailRecord(null)} footer={null} width={600}>
+        {detailRecord && (
+          <Descriptions column={2} bordered size="small">
+            <Descriptions.Item label="ID">{detailRecord.id}</Descriptions.Item>
+            <Descriptions.Item label="状态"><Badge status={statusBadgeMap[detailRecord.status] ?? 'default'} text={detailRecord.status} /></Descriptions.Item>
+            <Descriptions.Item label="TG用户名">
+              {detailRecord.tg_username ? <a href={`https://t.me/${detailRecord.tg_username}`} target="_blank" rel="noreferrer">@{detailRecord.tg_username}</a> : '-'}
+            </Descriptions.Item>
+            <Descriptions.Item label="商户名">{detailRecord.merchant_name || '-'}</Descriptions.Item>
+            <Descriptions.Item label="网站" span={2}>
+              {detailRecord.website ? <a href={detailRecord.website} target="_blank" rel="noreferrer">{detailRecord.website}</a> : '-'}
+            </Descriptions.Item>
+            <Descriptions.Item label="邮箱">{detailRecord.email || '-'}</Descriptions.Item>
+            <Descriptions.Item label="电话">{detailRecord.phone || '-'}</Descriptions.Item>
+            <Descriptions.Item label="来源类型"><Tag color={sourceTypeColor[detailRecord.source_type] ?? 'default'}>{detailRecord.source_type}</Tag></Descriptions.Item>
+            <Descriptions.Item label="来源名称">{detailRecord.source_name || '-'}</Descriptions.Item>
+            <Descriptions.Item label="来源链接" span={2}>
+              {detailRecord.source_url ? <a href={detailRecord.source_url} target="_blank" rel="noreferrer" style={{ wordBreak: 'break-all' }}>{detailRecord.source_url}</a> : '-'}
+            </Descriptions.Item>
+            <Descriptions.Item label="采集时间" span={2}>{formatDateTime(detailRecord.created_at)}</Descriptions.Item>
+          </Descriptions>
+        )}
+      </Modal>
+    </>
+  )
+}
+
+function ArchiveTab() {
+  const [data, setData] = useState<MerchantArchived[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [archiving, setArchiving] = useState(false)
+  const { isAdmin } = useAppStore()
+
+  const fetchData = useCallback(async (p = 1) => {
+    setLoading(true)
+    try {
+      const res = await getArchivedMerchants({ page: p, page_size: 20 })
+      setData(res.data.items)
+      setTotal(res.data.total)
+    } catch { /* ignore */ }
+    setLoading(false)
+  }, [])
+
+  useEffect(() => { fetchData(1) }, [fetchData])
+
+  const handleArchive = () => {
+    Modal.confirm({
+      title: '执行归档',
+      content: '将归档:状态为 invalid/bot 且超过 90 天未更新的商户,以及跟进状态为"已拒绝"且超过 180 天的商户。',
+      onOk: async () => {
+        setArchiving(true)
+        try {
+          const res = await archiveMerchants()
+          message.success(`已归档 ${res.data.archived} 个商户`)
+          fetchData(1)
+        } catch {
+          message.error('归档失败')
+        }
+        setArchiving(false)
+      },
+    })
+  }
+
+  const handleRestore = async (id: number) => {
+    try {
+      await restoreArchived(id)
+      message.success('已恢复')
+      fetchData(page)
+    } catch {
+      message.error('恢复失败')
+    }
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
+    {
+      title: 'TG用户名', dataIndex: 'tg_username', key: 'tg_username', width: 150,
+      render: (v: string) => v ? <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">@{v}</a> : '-',
+    },
+    { title: '商户名', dataIndex: 'merchant_name', key: 'merchant_name', ellipsis: true, render: (v: string) => v || '-' },
+    { title: '等级', dataIndex: 'level', key: 'level', width: 80, render: (v: string) => <Tag>{v}</Tag> },
+    { title: '原状态', dataIndex: 'status', key: 'status', width: 80 },
+    { title: '跟进状态', dataIndex: 'follow_status', key: 'follow_status', width: 90 },
+    {
+      title: '归档原因', dataIndex: 'archive_reason', key: 'archive_reason', width: 150,
+      render: (v: string) => <Tag color="orange">{v}</Tag>,
+    },
+    {
+      title: '归档时间', dataIndex: 'archived_at', key: 'archived_at', width: 160,
+      render: (v: string) => formatDateTime(v),
+    },
+    {
+      title: '操作', key: 'action', width: 80,
+      render: (_: unknown, r: MerchantArchived) => isAdmin() ? (
+        <Popconfirm title="确认恢复此商户?" onConfirm={() => handleRestore(r.id)}>
+          <Button size="small" type="link" icon={<UndoOutlined />}>恢复</Button>
+        </Popconfirm>
+      ) : null,
+    },
+  ]
+
+  return (
+    <>
+      {isAdmin() && (
+        <div style={{ marginBottom: 16 }}>
+          <Button icon={<InboxOutlined />} onClick={handleArchive} loading={archiving}>
+            执行归档
+          </Button>
+          <Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
+            归档 invalid/bot ≥90天 或 rejected ≥180天 的商户
+          </Text>
+        </div>
+      )}
+      <Table
+        dataSource={data} columns={columns} rowKey="id" loading={loading}
+        pagination={{
+          current: page, pageSize: 20, total,
+          onChange: p => { setPage(p); fetchData(p) },
+          showTotal: t => `共 ${t} 条`,
+        }}
+        scroll={{ x: 1000 }}
+      />
+    </>
+  )
+}
+
+export default function MerchantsRaw() {
+  return (
+    <Tabs defaultActiveKey="raw" items={[
+      { key: 'raw', label: '原始数据', children: <RawTab /> },
+      { key: 'archived', label: '归档数据', children: <ArchiveTab /> },
+    ]} />
+  )
+}
+
+function truncateUrl(url: string, max: number): string {
+  if (!url) return '-'
+  if (url.length <= max) return url
+  return url.substring(0, max) + '...'
+}

+ 198 - 0
web/src/pages/Notifications.tsx

@@ -0,0 +1,198 @@
+import { useEffect, useState } from 'react'
+import { Table, Button, Modal, Form, Input, Select, Switch, message, Space, Tag } from 'antd'
+import { PlusOutlined, SendOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
+import {
+  getNotificationConfigs, createNotificationConfig, updateNotificationConfig,
+  deleteNotificationConfig, testNotificationConfig, type NotificationConfig,
+} from '../api'
+
+const { Option } = Select
+
+const eventTypes = [
+  { value: 'task_completed', label: '任务完成' },
+  { value: 'task_failed', label: '任务失败' },
+  { value: 'new_hot_merchant', label: '发现优质商户' },
+  { value: 'schedule_run', label: '定时任务执行' },
+]
+
+const channelTypes = [
+  { value: 'webhook', label: 'Webhook' },
+  { value: 'tg_bot', label: 'TG Bot' },
+]
+
+export default function Notifications() {
+  const [data, setData] = useState<NotificationConfig[]>([])
+  const [loading, setLoading] = useState(false)
+  const [modalOpen, setModalOpen] = useState(false)
+  const [editId, setEditId] = useState<number | null>(null)
+  const [form] = Form.useForm()
+  const [channel, setChannel] = useState('webhook')
+
+  const fetchData = async () => {
+    setLoading(true)
+    try {
+      const res = await getNotificationConfigs()
+      setData(res.data || [])
+    } catch { /* ignore */ }
+    setLoading(false)
+  }
+
+  useEffect(() => { fetchData() }, [])
+
+  const openCreate = () => {
+    setEditId(null)
+    setChannel('webhook')
+    form.resetFields()
+    form.setFieldsValue({ channel: 'webhook', enabled: true })
+    setModalOpen(true)
+  }
+
+  const openEdit = (record: NotificationConfig) => {
+    setEditId(record.id)
+    setChannel(record.channel)
+    form.setFieldsValue({
+      name: record.name,
+      event_type: record.event_type,
+      channel: record.channel,
+      enabled: record.enabled,
+      url: record.config?.url,
+      bot_token: record.config?.bot_token,
+      chat_id: record.config?.chat_id,
+    })
+    setModalOpen(true)
+  }
+
+  const handleSave = async () => {
+    const values = await form.validateFields()
+    const config: Record<string, string> = {}
+    if (values.channel === 'webhook') {
+      config.url = values.url
+    } else {
+      config.bot_token = values.bot_token
+      config.chat_id = values.chat_id
+    }
+
+    const payload = {
+      name: values.name,
+      event_type: values.event_type,
+      channel: values.channel,
+      config: config,
+      enabled: values.enabled,
+    }
+
+    if (editId) {
+      await updateNotificationConfig(editId, payload)
+    } else {
+      await createNotificationConfig(payload)
+    }
+    message.success('保存成功')
+    setModalOpen(false)
+    fetchData()
+  }
+
+  const handleDelete = (id: number) => {
+    Modal.confirm({
+      title: '确认删除',
+      content: '确定删除此通知配置?',
+      onOk: async () => {
+        await deleteNotificationConfig(id)
+        message.success('已删除')
+        fetchData()
+      },
+    })
+  }
+
+  const handleTest = async (id: number) => {
+    try {
+      await testNotificationConfig(id)
+      message.success('测试通知已发送')
+    } catch {
+      message.error('测试失败')
+    }
+  }
+
+  const handleToggle = async (id: number, enabled: boolean) => {
+    await updateNotificationConfig(id, { enabled })
+    fetchData()
+  }
+
+  const columns = [
+    { title: '名称', dataIndex: 'name', key: 'name' },
+    {
+      title: '事件', dataIndex: 'event_type', key: 'event_type',
+      render: (v: string) => <Tag>{eventTypes.find(e => e.value === v)?.label || v}</Tag>,
+    },
+    {
+      title: '渠道', dataIndex: 'channel', key: 'channel',
+      render: (v: string) => <Tag color={v === 'webhook' ? 'blue' : 'orange'}>{v}</Tag>,
+    },
+    {
+      title: '启用', dataIndex: 'enabled', key: 'enabled',
+      render: (v: boolean, r: NotificationConfig) => (
+        <Switch checked={v} size="small" onChange={val => handleToggle(r.id, val)} />
+      ),
+    },
+    {
+      title: '操作', key: 'action',
+      render: (_: unknown, r: NotificationConfig) => (
+        <Space>
+          <Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEdit(r)} />
+          <Button size="small" type="link" icon={<SendOutlined />} onClick={() => handleTest(r.id)}>测试</Button>
+          <Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r.id)} />
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <div style={{ marginBottom: 16 }}>
+        <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增通知配置</Button>
+      </div>
+
+      <Table dataSource={data} columns={columns} rowKey="id" loading={loading} pagination={false} />
+
+      <Modal
+        title={editId ? '编辑通知配置' : '新增通知配置'}
+        open={modalOpen}
+        onCancel={() => setModalOpen(false)}
+        onOk={handleSave}
+        okText="保存"
+      >
+        <Form form={form} layout="vertical">
+          <Form.Item name="name" label="名称" rules={[{ required: true }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="event_type" label="事件类型" rules={[{ required: true }]}>
+            <Select>
+              {eventTypes.map(e => <Option key={e.value} value={e.value}>{e.label}</Option>)}
+            </Select>
+          </Form.Item>
+          <Form.Item name="channel" label="通知渠道" rules={[{ required: true }]}>
+            <Select onChange={v => setChannel(v)}>
+              {channelTypes.map(c => <Option key={c.value} value={c.value}>{c.label}</Option>)}
+            </Select>
+          </Form.Item>
+          {channel === 'webhook' && (
+            <Form.Item name="url" label="Webhook URL" rules={[{ required: true }]}>
+              <Input placeholder="https://..." />
+            </Form.Item>
+          )}
+          {channel === 'tg_bot' && (
+            <>
+              <Form.Item name="bot_token" label="Bot Token" rules={[{ required: true }]}>
+                <Input placeholder="123456:ABC..." />
+              </Form.Item>
+              <Form.Item name="chat_id" label="Chat ID" rules={[{ required: true }]}>
+                <Input placeholder="-100123456789" />
+              </Form.Item>
+            </>
+          )}
+          <Form.Item name="enabled" label="启用" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}

+ 277 - 0
web/src/pages/Proxies.tsx

@@ -0,0 +1,277 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Button, Modal, Form, Input, InputNumber, Select, Switch, Space, message, Popconfirm, Tag, Badge, Typography, Card, Row, Col, Statistic } from 'antd'
+import { PlusOutlined, DeleteOutlined, ApiOutlined, EditOutlined, CheckCircleOutlined, SyncOutlined } from '@ant-design/icons'
+import { getProxies, createProxy, updateProxy, deleteProxy, testProxy, testAllProxies, getProxyPoolStatus, type Proxy, type ProxyPoolStatus } from '../api'
+
+const { Option } = Select
+const { Text } = Typography
+
+const protocolColors: Record<string, string> = { http: 'blue', https: 'green', socks5: 'purple' }
+const statusMap: Record<string, { color: string; text: string }> = {
+  ok: { color: 'green', text: '正常' },
+  fail: { color: 'red', text: '失败' },
+  unknown: { color: 'default', text: '未检测' },
+}
+
+export default function Proxies() {
+  const [data, setData] = useState<Proxy[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [modalOpen, setModalOpen] = useState(false)
+  const [editId, setEditId] = useState<number | null>(null)
+  const [form] = Form.useForm()
+  const [testingId, setTestingId] = useState<number | null>(null)
+  const [testingAll, setTestingAll] = useState(false)
+  const [poolStatus, setPoolStatus] = useState<ProxyPoolStatus | null>(null)
+  const poolTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
+
+  // Poll proxy pool status
+  useEffect(() => {
+    const fetchPool = () => {
+      getProxyPoolStatus().then(r => setPoolStatus(r.data)).catch(() => {})
+    }
+    fetchPool()
+    poolTimerRef.current = setInterval(fetchPool, 5000)
+    return () => { if (poolTimerRef.current) clearInterval(poolTimerRef.current) }
+  }, [])
+
+  const fetchData = useCallback(async (p = 1) => {
+    setLoading(true)
+    try {
+      const res = await getProxies({ page: p, page_size: 20 })
+      setData(res.data.items)
+      setTotal(res.data.total)
+    } catch { message.error('获取代理列表失败') }
+    setLoading(false)
+  }, [])
+
+  useEffect(() => { fetchData(1) }, [fetchData])
+
+  const openCreate = () => {
+    setEditId(null)
+    form.resetFields()
+    form.setFieldsValue({ protocol: 'http', port: 1080 })
+    setModalOpen(true)
+  }
+
+  const openEdit = (record: Proxy) => {
+    setEditId(record.id)
+    form.setFieldsValue({
+      name: record.name,
+      protocol: record.protocol,
+      host: record.host,
+      port: record.port,
+      username: record.username,
+      password: record.password,
+      region: record.region,
+      remark: record.remark,
+    })
+    setModalOpen(true)
+  }
+
+  const handleSave = async () => {
+    try {
+      const values = await form.validateFields()
+      if (editId) {
+        await updateProxy(editId, values)
+        message.success('代理已更新')
+      } else {
+        await createProxy(values)
+        message.success('代理已创建')
+      }
+      setModalOpen(false)
+      fetchData(page)
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || err?.data?.message || '操作失败')
+    }
+  }
+
+  const handleDelete = async (id: number) => {
+    await deleteProxy(id)
+    message.success('已删除')
+    fetchData(page)
+  }
+
+  const handleToggle = async (id: number, enabled: boolean) => {
+    await updateProxy(id, { enabled })
+    fetchData(page)
+  }
+
+  const handleTestAll = async () => {
+    setTestingAll(true)
+    try {
+      const res = await testAllProxies()
+      const r = res.data
+      message.info(`测试完成: ${r.ok} 个正常, ${r.fail} 个失败`)
+      fetchData(page)
+    } catch {
+      message.error('批量测试失败')
+    }
+    setTestingAll(false)
+  }
+
+  const handleTest = async (id: number) => {
+    setTestingId(id)
+    try {
+      const res = await testProxy(id)
+      if (res.data.status === 'ok') {
+        message.success(`代理测试成功`)
+      } else {
+        message.error(`代理测试失败: ${res.data.error || '未知错误'}`)
+      }
+      fetchData(page)
+    } catch {
+      message.error('测试请求失败')
+    }
+    setTestingId(null)
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', width: 50 },
+    { title: '名称', dataIndex: 'name', width: 120 },
+    {
+      title: '协议', dataIndex: 'protocol', width: 80,
+      render: (v: string) => <Tag color={protocolColors[v] ?? 'default'}>{v.toUpperCase()}</Tag>,
+    },
+    {
+      title: '地址', key: 'address', width: 200,
+      render: (_: unknown, r: Proxy) => <Text code style={{ fontSize: 12 }}>{r.host}:{r.port}</Text>,
+    },
+    {
+      title: '认证', key: 'auth', width: 80,
+      render: (_: unknown, r: Proxy) => r.username ? <Tag color="orange">有</Tag> : <Text type="secondary">无</Text>,
+    },
+    {
+      title: '地区', dataIndex: 'region', width: 80,
+      render: (v: string) => v ? <Tag>{v}</Tag> : '-',
+    },
+    {
+      title: '状态', dataIndex: 'status', width: 80,
+      render: (v: string) => {
+        const s = statusMap[v] || statusMap.unknown
+        return <Badge color={s.color === 'default' ? '#d9d9d9' : s.color === 'green' ? '#52c41a' : '#ff4d4f'} text={s.text} />
+      },
+    },
+    {
+      title: '启用', dataIndex: 'enabled', width: 80,
+      render: (v: boolean, r: Proxy) => (
+        <Switch checked={v} size="small" onChange={c => handleToggle(r.id, c)} />
+      ),
+    },
+    {
+      title: '最后检测', dataIndex: 'last_checked_at', width: 140,
+      render: (v: string | null) => v ? <Text style={{ fontSize: 12 }}>{new Date(v).toLocaleString('zh-CN')}</Text> : '-',
+    },
+    {
+      title: '操作', key: 'action', width: 180,
+      render: (_: unknown, r: Proxy) => (
+        <Space>
+          <Button size="small" icon={<CheckCircleOutlined />} loading={testingId === r.id}
+            onClick={() => handleTest(r.id)}>测试</Button>
+          <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
+          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
+            <Button size="small" danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      {/* Proxy Pool Live Status */}
+      {poolStatus?.active && poolStatus.proxies && (
+        <Card
+          size="small"
+          title={<><SyncOutlined spin style={{ marginRight: 8 }} />代理池运行中</>}
+          style={{ marginBottom: 16, borderColor: '#1890ff' }}
+        >
+          <Row gutter={24}>
+            <Col>
+              <Statistic title="总代理" value={poolStatus.total} valueStyle={{ fontSize: 18 }} />
+            </Col>
+            <Col>
+              <Statistic title="活跃" value={poolStatus.active_count} valueStyle={{ fontSize: 18, color: '#3f8600' }} />
+            </Col>
+            <Col>
+              <Statistic
+                title="冷却中"
+                value={(poolStatus.total || 0) - (poolStatus.active_count || 0)}
+                valueStyle={{ fontSize: 18, color: (poolStatus.total || 0) - (poolStatus.active_count || 0) > 0 ? '#cf1322' : '#999' }}
+              />
+            </Col>
+          </Row>
+          <div style={{ marginTop: 8 }}>
+            {poolStatus.proxies.map(p => (
+              <Tag
+                key={p.id}
+                color={p.disabled ? 'red' : p.failures > 0 ? 'orange' : 'green'}
+                style={{ marginBottom: 4 }}
+              >
+                {p.name}{p.region ? ` (${p.region})` : ''}
+                {p.failures > 0 && ` [失败:${p.failures}]`}
+                {p.disabled && ' 冷却中'}
+              </Tag>
+            ))}
+          </div>
+        </Card>
+      )}
+
+      <div style={{ marginBottom: 16 }}>
+        <Space>
+          <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>添加代理</Button>
+          <Button icon={<ApiOutlined />} loading={testingAll} onClick={handleTestAll}>批量测试</Button>
+        </Space>
+      </div>
+
+      <Table dataSource={data} columns={columns} rowKey="id" loading={loading}
+        pagination={{
+          current: page, pageSize: 20, total,
+          onChange: p => { setPage(p); fetchData(p) },
+          showTotal: t => `共 ${t} 个代理`,
+        }}
+        scroll={{ x: 1100 }}
+      />
+
+      <Modal title={editId ? '编辑代理' : '添加代理'} open={modalOpen}
+        onCancel={() => setModalOpen(false)} onOk={handleSave} okText="保存" width={520}>
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="name" label="名称" rules={[{ required: true }]}>
+            <Input placeholder="例:HK-Proxy-01" />
+          </Form.Item>
+          <Space style={{ width: '100%' }} size="middle">
+            <Form.Item name="protocol" label="协议" rules={[{ required: true }]} style={{ width: 120 }}>
+              <Select>
+                <Option value="http">HTTP</Option>
+                <Option value="https">HTTPS</Option>
+                <Option value="socks5">SOCKS5</Option>
+              </Select>
+            </Form.Item>
+            <Form.Item name="host" label="主机" rules={[{ required: true }]} style={{ flex: 1 }}>
+              <Input placeholder="192.168.1.100 或 proxy.example.com" />
+            </Form.Item>
+            <Form.Item name="port" label="端口" rules={[{ required: true }]} style={{ width: 100 }}>
+              <InputNumber min={1} max={65535} style={{ width: '100%' }} />
+            </Form.Item>
+          </Space>
+          <Space style={{ width: '100%' }} size="middle">
+            <Form.Item name="username" label="用户名" style={{ flex: 1 }}>
+              <Input placeholder="可选" />
+            </Form.Item>
+            <Form.Item name="password" label="密码" style={{ flex: 1 }}>
+              <Input.Password placeholder="可选" />
+            </Form.Item>
+          </Space>
+          <Form.Item name="region" label="地区标签">
+            <Input placeholder="例:HK / US / JP(用于分类)" />
+          </Form.Item>
+          <Form.Item name="remark" label="备注">
+            <Input.TextArea rows={2} />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}

+ 181 - 0
web/src/pages/Schedules.tsx

@@ -0,0 +1,181 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Table, Button, Modal, Form, Input, Select, Switch, Space, message, Popconfirm, Typography, Tag } from 'antd'
+import { PlusOutlined, DeleteOutlined, PlayCircleOutlined, EditOutlined } from '@ant-design/icons'
+import { getSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow, type ScheduleJob } from '../api'
+import api from '../api/client'
+
+const { Text } = Typography
+
+export default function Schedules() {
+  const [jobs, setJobs] = useState<ScheduleJob[]>([])
+  const [plugins, setPlugins] = useState<string[]>([])
+  const [loading, setLoading] = useState(false)
+  const [modalOpen, setModalOpen] = useState(false)
+  const [editJob, setEditJob] = useState<ScheduleJob | null>(null)
+  const [form] = Form.useForm()
+  const [editForm] = Form.useForm()
+
+  const fetchJobs = useCallback(async () => {
+    setLoading(true)
+    try {
+      const res = await getSchedules()
+      setJobs(res.data || [])
+    } catch { message.error('获取定时任务失败') }
+    finally { setLoading(false) }
+  }, [])
+
+  const fetchPlugins = useCallback(async () => {
+    try {
+      const res = await api.get('/plugins')
+      setPlugins(res.data || [])
+    } catch { /* ignore */ }
+  }, [])
+
+  useEffect(() => { fetchJobs(); fetchPlugins() }, [fetchJobs, fetchPlugins])
+
+  const handleCreate = async () => {
+    try {
+      const values = await form.validateFields()
+      await createSchedule(values)
+      message.success('定时任务已创建')
+      setModalOpen(false)
+      fetchJobs()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || err?.data?.message || '创建失败')
+    }
+  }
+
+  const handleEdit = async () => {
+    if (!editJob) return
+    try {
+      const values = await editForm.validateFields()
+      await updateSchedule(editJob.id, { name: values.name, cron_expr: values.cron_expr })
+      message.success('定时任务已更新')
+      setEditJob(null)
+      fetchJobs()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || err?.data?.message || '更新失败')
+    }
+  }
+
+  const openEdit = (job: ScheduleJob) => {
+    setEditJob(job)
+    editForm.setFieldsValue({ name: job.name, cron_expr: job.cron_expr })
+  }
+
+  const handleToggle = async (job: ScheduleJob, enabled: boolean) => {
+    try {
+      await updateSchedule(job.id, { enabled })
+      message.success(enabled ? '已启用' : '已禁用')
+      fetchJobs()
+    } catch { message.error('更新失败') }
+  }
+
+  const handleDelete = async (id: number) => {
+    try {
+      await deleteSchedule(id)
+      message.success('已删除')
+      fetchJobs()
+    } catch { message.error('删除失败') }
+  }
+
+  const handleRunNow = async (job: ScheduleJob) => {
+    try {
+      await runScheduleNow(job.id)
+      message.success(`已手动触发 "${job.name}"`)
+      fetchJobs()
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || err?.data?.message || '触发失败')
+    }
+  }
+
+  const formatTime = (v: string | null) => {
+    if (!v) return '-'
+    return new Date(v).toLocaleString('zh-CN')
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', width: 60 },
+    { title: '名称', dataIndex: 'name' },
+    {
+      title: '插件', dataIndex: 'plugin_name',
+      render: (v: string) => <Tag>{v}</Tag>,
+    },
+    { title: 'Cron', dataIndex: 'cron_expr', width: 130, render: (v: string) => <code>{v}</code> },
+    {
+      title: '启用', dataIndex: 'enabled', width: 90,
+      render: (v: boolean, record: ScheduleJob) => (
+        <Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
+      ),
+    },
+    {
+      title: '上次运行', dataIndex: 'last_run_at', width: 160,
+      render: (v: string | null) => <Text style={{ fontSize: 12 }}>{formatTime(v)}</Text>,
+    },
+    {
+      title: '下次运行', dataIndex: 'next_run_at', width: 160,
+      render: (v: string | null) => <Text style={{ fontSize: 12 }}>{formatTime(v)}</Text>,
+    },
+    {
+      title: '操作', key: 'action', width: 220,
+      render: (_: unknown, record: ScheduleJob) => (
+        <Space>
+          <Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleRunNow(record)}>运行</Button>
+          <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
+          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
+            <Button size="small" danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }} style={{ marginBottom: 16 }}>
+        添加定时任务
+      </Button>
+      <Table dataSource={jobs} columns={columns} rowKey="id" loading={loading} pagination={false} />
+
+      {/* Create Modal */}
+      <Modal title="添加定时任务" open={modalOpen} onOk={handleCreate} onCancel={() => setModalOpen(false)} okText="创建">
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="name" label="任务名称" rules={[{ required: true }]}>
+            <Input placeholder="例:每日TG采集" />
+          </Form.Item>
+          <Form.Item name="plugin_name" label="采集插件" rules={[{ required: true }]}>
+            <Select placeholder="选择插件">
+              {plugins.map(p => <Select.Option key={p} value={p}>{p}</Select.Option>)}
+            </Select>
+          </Form.Item>
+          <Form.Item name="cron_expr" label="Cron 表达式" rules={[{ required: true }]}>
+            <Input placeholder="*/30 * * * *" />
+          </Form.Item>
+          <Text type="secondary" style={{ whiteSpace: 'pre-line' }}>
+{`示例:
+*/30 * * * *  → 每30分钟
+0 2 * * *    → 每天凌晨2点
+0 8 * * 1    → 每周一早上8点`}
+          </Text>
+        </Form>
+      </Modal>
+
+      {/* Edit Modal */}
+      <Modal title={`编辑: ${editJob?.name}`} open={!!editJob} onOk={handleEdit} onCancel={() => setEditJob(null)} okText="保存">
+        <Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="name" label="任务名称" rules={[{ required: true }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item label="插件">
+            <Input disabled value={editJob?.plugin_name} />
+          </Form.Item>
+          <Form.Item name="cron_expr" label="Cron 表达式" rules={[{ required: true }]}>
+            <Input placeholder="*/30 * * * *" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}

+ 439 - 49
web/src/pages/Tasks.tsx

@@ -1,10 +1,13 @@
-import { useEffect, useState, useCallback } from 'react'
-import { Table, Tag, Button, message, Badge } from 'antd'
-import { StopOutlined } from '@ant-design/icons'
-import { getTasks, stopTask, type TaskLog } from '../api'
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Tag, Button, message, Badge, Modal, Tabs, Select, Space, Typography, Card } from 'antd'
+import { StopOutlined, EyeOutlined, ConsoleSqlOutlined, ReloadOutlined } from '@ant-design/icons'
+import { getTasks, stopTask, retryTask, getTaskDetails, buildTaskLogWsUrl, type TaskLog, type TaskDetail } from '../api'
 import { useAppStore } from '../store'
 import TaskControl from '../components/TaskControl'
 
+const { Text, Paragraph } = Typography
+const { Option } = Select
+
 const pluginColor: Record<string, string> = {
   web_collector: 'green',
   tg_collector: 'orange',
@@ -20,6 +23,21 @@ const taskStatusBadge: Record<string, 'processing' | 'success' | 'error' | 'warn
   pending: 'default',
 }
 
+const actionColors: Record<string, string> = {
+  search_result: 'blue',
+  crawl: 'cyan',
+  snippet_extract: 'green',
+  page_extract: 'lime',
+  merchant_found: 'gold',
+  classify: 'default',
+  clean_tmechecker: 'purple',
+  clean_blacklist: 'orange',
+  clean_dedup: 'geekblue',
+  clean_tagger: 'magenta',
+  error: 'red',
+  skip: 'default',
+}
+
 function formatDateTime(dateStr: string | null) {
   if (!dateStr) return '-'
   return new Date(dateStr).toLocaleString('zh-CN')
@@ -31,20 +49,59 @@ export default function Tasks() {
   const [page, setPage] = useState(1)
   const [loading, setLoading] = useState(false)
   const [stoppingId, setStoppingId] = useState<number | null>(null)
-  const { runningTask, setRunningTask } = useAppStore()
+  const [filterStatus, setFilterStatus] = useState('')
+  const [filterPlugin, setFilterPlugin] = useState('')
+  const { runningTask, setRunningTask, isOperator, hasAction } = useAppStore()
+
+  // Live log panel
+  const [logTaskId, setLogTaskId] = useState<number | null>(null)
+  const [logLines, setLogLines] = useState<string[]>([])
+  const logRef = useRef<HTMLDivElement>(null)
+  const wsRef = useRef<WebSocket | null>(null)
+  const wsIntentionalClose = useRef(false)
+  const wsReconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const wsReconnectDelay = useRef(1000)
+  const logTaskIdRef = useRef<number | null>(null)
+
+  // Detail modal
+  const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
+  const [details, setDetails] = useState<TaskDetail[]>([])
+  const [detailTotal, setDetailTotal] = useState(0)
+  const [detailPage, setDetailPage] = useState(1)
+  const [detailAction, setDetailAction] = useState('')
+  const [detailStatus, setDetailStatus] = useState('')
+  const [detailSummary, setDetailSummary] = useState<Record<string, Record<string, number>>>({})
+  const [detailLoading, setDetailLoading] = useState(false)
+  const [selectedDetail, setSelectedDetail] = useState<TaskDetail | null>(null)
 
   const fetchTasks = useCallback(async (currentPage = page) => {
     setLoading(true)
     try {
-      const res = await getTasks({ page: currentPage, page_size: 20 })
+      const params: Record<string, unknown> = { page: currentPage, page_size: 20 }
+      if (filterStatus) params.status = filterStatus
+      if (filterPlugin) params.plugin_name = filterPlugin
+      const res = await getTasks(params)
       setTasks(res.data.items)
       setTotal(res.data.total)
+
+      // Auto-detect running task
+      const running = res.data.items.find((t: TaskLog) => t.status === 'running')
+      if (running && !runningTask) {
+        setRunningTask(running)
+      } else if (!running && runningTask) {
+        setRunningTask(null)
+      }
     } catch {
       message.error('获取任务列表失败')
     } finally {
       setLoading(false)
     }
-  }, [page])
+  }, [page, filterStatus, filterPlugin, runningTask, setRunningTask])
+
+  useEffect(() => {
+    fetchTasks(1)
+    setPage(1)
+  }, [filterStatus, filterPlugin])
 
   useEffect(() => {
     fetchTasks(page)
@@ -52,10 +109,113 @@ export default function Tasks() {
 
   useEffect(() => {
     if (!runningTask) return
-    const timer = setInterval(() => fetchTasks(page), 3000)
+    const timer = setInterval(() => fetchTasks(page), 5000)
     return () => clearInterval(timer)
   }, [runningTask, fetchTasks, page])
 
+  // ── Live WebSocket log with auto-reconnect ──
+  const connectWs = useCallback((taskId: number) => {
+    if (wsIntentionalClose.current) return
+
+    const ws = new WebSocket(buildTaskLogWsUrl(taskId))
+    wsRef.current = ws
+
+    ws.onopen = () => {
+      wsReconnectDelay.current = 1000
+    }
+
+    ws.onmessage = (e) => {
+      setLogLines(prev => {
+        const next = [...prev, e.data]
+        return next.length > 500 ? next.slice(-500) : next
+      })
+      setTimeout(() => {
+        if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
+      }, 50)
+    }
+
+    ws.onclose = () => {
+      wsRef.current = null
+      if (wsIntentionalClose.current) return
+      const delay = wsReconnectDelay.current
+      wsReconnectDelay.current = Math.min(delay * 2, 30000)
+      setLogLines(prev => [...prev, `── 连接断开,${delay / 1000}s 后重连... ──`])
+      wsReconnectTimer.current = setTimeout(() => {
+        if (!wsIntentionalClose.current && logTaskIdRef.current === taskId) {
+          connectWs(taskId)
+        }
+      }, delay)
+    }
+
+    ws.onerror = () => {
+      ws.close()
+    }
+  }, [])
+
+  const openLiveLog = (taskId: number) => {
+    wsIntentionalClose.current = true
+    if (wsReconnectTimer.current) { clearTimeout(wsReconnectTimer.current); wsReconnectTimer.current = null }
+    if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
+
+    logTaskIdRef.current = taskId
+    setLogTaskId(taskId)
+    setLogLines([])
+    wsReconnectDelay.current = 1000
+    wsIntentionalClose.current = false
+    connectWs(taskId)
+  }
+
+  const closeLiveLog = () => {
+    wsIntentionalClose.current = true
+    if (wsReconnectTimer.current) { clearTimeout(wsReconnectTimer.current); wsReconnectTimer.current = null }
+    if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
+    logTaskIdRef.current = null
+    setLogTaskId(null)
+    setLogLines([])
+  }
+
+  // Auto-open live log when task starts
+  useEffect(() => {
+    if (runningTask && !logTaskId) {
+      openLiveLog(runningTask.id)
+    }
+  }, [runningTask])
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      wsIntentionalClose.current = true
+      if (wsReconnectTimer.current) clearTimeout(wsReconnectTimer.current)
+      if (wsRef.current) wsRef.current.close()
+    }
+  }, [])
+
+  // ── Detail modal ──
+  const openDetails = async (taskId: number) => {
+    setDetailTaskId(taskId)
+    setDetailPage(1)
+    setDetailAction('')
+    setDetailStatus('')
+    await fetchDetails(taskId, 1, '', '')
+  }
+
+  const fetchDetails = async (taskId: number, pg: number, action: string, status: string) => {
+    setDetailLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: pg, page_size: 30 }
+      if (action) params.action = action
+      if (status) params.status = status
+      const res = await getTaskDetails(taskId, params)
+      setDetails(res.data.items)
+      setDetailTotal(res.data.total)
+      setDetailSummary(res.data.summary)
+    } catch {
+      message.error('获取任务详情失败')
+    } finally {
+      setDetailLoading(false)
+    }
+  }
+
   const handleStop = async (id: number) => {
     setStoppingId(id)
     try {
@@ -70,38 +230,33 @@ export default function Tasks() {
     }
   }
 
-  const columns = [
-    { title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
-    {
-      title: '类型',
-      dataIndex: 'task_type',
-      key: 'task_type',
-      render: (v: string) => <Tag>{v}</Tag>,
-    },
+  const taskColumns = [
+    { title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
     {
       title: '插件',
       dataIndex: 'plugin_name',
       key: 'plugin_name',
       render: (v: string) => v ? <Tag color={pluginColor[v] ?? 'default'}>{v}</Tag> : '-',
     },
+    {
+      title: '代理',
+      dataIndex: 'proxy_name',
+      key: 'proxy_name',
+      width: 130,
+      render: (v: string, record: TaskLog) => {
+        if (!v) return <span style={{ color: '#ccc' }}>直连</span>
+        const isPool = record.proxy_mode === 'pool'
+        return <Tag color={isPool ? 'blue' : 'purple'}>{isPool ? '🔄 ' : ''}{v}</Tag>
+      },
+    },
     {
       title: '状态',
       dataIndex: 'status',
       key: 'status',
-      render: (v: string) => (
-        <Badge status={taskStatusBadge[v] ?? 'default'} text={v} />
-      ),
-    },
-    {
-      title: '新增商户',
-      dataIndex: 'merchants_added',
-      key: 'merchants_added',
-    },
-    {
-      title: '错误数',
-      dataIndex: 'errors_count',
-      key: 'errors_count',
+      render: (v: string) => <Badge status={taskStatusBadge[v] ?? 'default'} text={v} />,
     },
+    { title: '新增', dataIndex: 'merchants_added', key: 'merchants_added', width: 60 },
+    { title: '错误', dataIndex: 'errors_count', key: 'errors_count', width: 60 },
     {
       title: '详情',
       dataIndex: 'detail',
@@ -110,41 +265,171 @@ export default function Tasks() {
       render: (v: string) => v || '-',
     },
     {
-      title: '开始时间',
+      title: '时间',
       dataIndex: 'started_at',
       key: 'started_at',
+      width: 160,
       render: (t: string | null) => formatDateTime(t),
     },
     {
-      title: '结束时间',
-      dataIndex: 'finished_at',
-      key: 'finished_at',
-      render: (t: string | null) => formatDateTime(t),
+      title: '操作',
+      key: 'action',
+      width: 180,
+      render: (_: unknown, record: TaskLog) => (
+        <Space size="small">
+          {record.status === 'running' ? (
+            <>
+              <Button size="small" icon={<ConsoleSqlOutlined />} onClick={() => openLiveLog(record.id)}>日志</Button>
+              <Button danger size="small" icon={<StopOutlined />} loading={stoppingId === record.id} onClick={() => handleStop(record.id)}>停止</Button>
+            </>
+          ) : (
+            <>
+              <Button size="small" icon={<ConsoleSqlOutlined />} onClick={() => openLiveLog(record.id)}>日志</Button>
+              <Button size="small" icon={<EyeOutlined />} onClick={() => openDetails(record.id)}>详情</Button>
+              {(record.status === 'failed' || record.status === 'stopped') && hasAction('task_start') && (
+                <Button size="small" icon={<ReloadOutlined />} onClick={async () => {
+                  try {
+                    await retryTask(record.id)
+                    message.success('重试任务已启动')
+                    fetchTasks(page)
+                  } catch (e: any) {
+                    message.error(e?.response?.data?.message || e?.data?.message || '重试失败')
+                  }
+                }}>重试</Button>
+              )}
+            </>
+          )}
+        </Space>
+      ),
     },
+  ]
+
+  // Get unique action types from summary
+  const actionTypes = Object.keys(detailSummary)
+
+  const detailColumns = [
+    { title: '#', dataIndex: 'seq', key: 'seq', width: 50 },
     {
       title: '操作',
+      dataIndex: 'action',
       key: 'action',
-      render: (_: unknown, record: TaskLog) =>
-        record.status === 'running' ? (
-          <Button
-            danger
-            size="small"
-            icon={<StopOutlined />}
-            loading={stoppingId === record.id}
-            onClick={() => handleStop(record.id)}
-          >
-            停止
-          </Button>
-        ) : null,
+      width: 130,
+      render: (v: string) => <Tag color={actionColors[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      width: 60,
+      render: (v: string) => {
+        const color = v === 'ok' ? 'green' : v === 'error' ? 'red' : v === 'skip' ? 'orange' : 'blue'
+        return <Tag color={color}>{v}</Tag>
+      },
+    },
+    {
+      title: 'URL',
+      dataIndex: 'url',
+      key: 'url',
+      ellipsis: true,
+      render: (v: string, r: TaskDetail) => (
+        <span>
+          {r.depth > 0 && <Tag color="purple">L{r.depth}</Tag>}
+          {v ? <a href={v} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>{v}</a> : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '耗时',
+      dataIndex: 'duration_ms',
+      key: 'duration_ms',
+      width: 70,
+      render: (v: number) => v > 0 ? `${v}ms` : '-',
+    },
+    {
+      title: '',
+      key: 'view',
+      width: 50,
+      render: (_: unknown, record: TaskDetail) => (
+        <Button size="small" type="link" onClick={() => setSelectedDetail(record)}>查看</Button>
+      ),
     },
   ]
 
   return (
     <div>
-      <TaskControl onTaskStarted={() => fetchTasks(1)} />
+      <TaskControl onTaskStarted={() => { fetchTasks(1) }} onTaskFinished={() => { fetchTasks(1) }} />
+
+      {/* ── Live Log Panel ── */}
+      {logTaskId && (
+        <Card
+          title={`任务 #${logTaskId} 实时日志`}
+          extra={<Button size="small" onClick={closeLiveLog}>关闭</Button>}
+          style={{ marginBottom: 16 }}
+          bodyStyle={{ padding: 0 }}
+        >
+          <div
+            ref={logRef}
+            style={{
+              height: 300,
+              overflow: 'auto',
+              background: '#1e1e1e',
+              color: '#d4d4d4',
+              fontFamily: 'Consolas, Monaco, monospace',
+              fontSize: 12,
+              padding: '8px 12px',
+              lineHeight: 1.6,
+            }}
+          >
+            {logLines.length === 0 ? (
+              <Text type="secondary" style={{ color: '#666' }}>等待日志...</Text>
+            ) : (
+              logLines.map((line, i) => (
+                <div key={i} style={{
+                  color: line.includes('错误') || line.includes('失败') ? '#f56c6c' :
+                         line.includes('完成') ? '#67c23a' :
+                         line.includes('进度') ? '#e6a23c' : '#d4d4d4'
+                }}>
+                  {line}
+                </div>
+              ))
+            )}
+          </div>
+        </Card>
+      )}
+
+      {/* ── Filters ── */}
+      <Space style={{ marginBottom: 12 }}>
+        <Select
+          style={{ width: 130 }}
+          value={filterStatus}
+          onChange={(v) => { setFilterStatus(v ?? ''); setPage(1) }}
+          allowClear
+          placeholder="状态筛选"
+        >
+          <Option value="running">运行中</Option>
+          <Option value="completed">已完成</Option>
+          <Option value="failed">失败</Option>
+          <Option value="stopped">已停止</Option>
+        </Select>
+        <Select
+          style={{ width: 150 }}
+          value={filterPlugin}
+          onChange={(v) => { setFilterPlugin(v ?? ''); setPage(1) }}
+          allowClear
+          placeholder="插件筛选"
+        >
+          <Option value="web_collector">web_collector</Option>
+          <Option value="tg_collector">tg_collector</Option>
+          <Option value="github_collector">github_collector</Option>
+          <Option value="clean">clean</Option>
+        </Select>
+        <Button size="small" onClick={() => { setFilterStatus(''); setFilterPlugin(''); setPage(1) }}>清除筛选</Button>
+      </Space>
+
+      {/* ── Task Table ── */}
       <Table
         dataSource={tasks}
-        columns={columns}
+        columns={taskColumns}
         rowKey="id"
         loading={loading}
         pagination={{
@@ -154,8 +439,113 @@ export default function Tasks() {
           onChange: (p) => setPage(p),
           showTotal: (t) => `共 ${t} 条`,
         }}
-        scroll={{ x: 1100 }}
+        scroll={{ x: 1000 }}
       />
+
+      {/* ── Detail Modal ── */}
+      <Modal
+        title={`任务 #${detailTaskId} 执行详情`}
+        open={!!detailTaskId}
+        onCancel={() => { setDetailTaskId(null); setDetails([]) }}
+        footer={null}
+        width={1100}
+      >
+        {/* Summary badges */}
+        <div style={{ marginBottom: 12 }}>
+          {Object.entries(detailSummary).map(([action, statuses]) => {
+            const total = Object.values(statuses).reduce((a, b) => a + b, 0)
+            return (
+              <Tag key={action} color={actionColors[action] ?? 'default'} style={{ marginBottom: 4, cursor: 'pointer' }}
+                onClick={() => { setDetailAction(action); setDetailPage(1); fetchDetails(detailTaskId!, 1, action, detailStatus) }}>
+                {action}: {total}
+              </Tag>
+            )
+          })}
+        </div>
+
+        {/* Filters */}
+        <Space style={{ marginBottom: 12 }}>
+          <Select style={{ width: 160 }} value={detailAction} onChange={(v) => { setDetailAction(v); setDetailPage(1); fetchDetails(detailTaskId!, 1, v, detailStatus) }} allowClear placeholder="按操作筛选">
+            <Option value="">全部</Option>
+            {actionTypes.map(a => <Option key={a} value={a}>{a}</Option>)}
+          </Select>
+          <Select style={{ width: 120 }} value={detailStatus} onChange={(v) => { setDetailStatus(v); setDetailPage(1); fetchDetails(detailTaskId!, 1, detailAction, v) }} allowClear placeholder="按状态筛选">
+            <Option value="">全部</Option>
+            <Option value="ok">ok</Option>
+            <Option value="skip">skip</Option>
+            <Option value="error">error</Option>
+            <Option value="empty">empty</Option>
+          </Select>
+        </Space>
+
+        <Table
+          dataSource={details}
+          columns={detailColumns}
+          rowKey="id"
+          loading={detailLoading}
+          size="small"
+          pagination={{
+            current: detailPage,
+            pageSize: 30,
+            total: detailTotal,
+            onChange: (p) => { setDetailPage(p); fetchDetails(detailTaskId!, p, detailAction, detailStatus) },
+            showTotal: (t) => `共 ${t} 条`,
+            size: 'small',
+          }}
+          scroll={{ x: 900 }}
+        />
+      </Modal>
+
+      {/* ── Single Detail View Modal ── */}
+      <Modal
+        title={`详情 #${selectedDetail?.seq}`}
+        open={!!selectedDetail}
+        onCancel={() => setSelectedDetail(null)}
+        footer={null}
+        width={800}
+      >
+        {selectedDetail && (
+          <Tabs items={[
+            {
+              key: 'info',
+              label: '基本信息',
+              children: (
+                <div>
+                  <p><Text strong>操作:</Text><Tag color={actionColors[selectedDetail.action]}>{selectedDetail.action}</Tag></p>
+                  <p><Text strong>状态:</Text>{selectedDetail.status}</p>
+                  <p><Text strong>URL:</Text>{selectedDetail.url ? <a href={selectedDetail.url} target="_blank" rel="noreferrer">{selectedDetail.url}</a> : '-'}</p>
+                  {selectedDetail.parent_url && <p><Text strong>来源页:</Text><a href={selectedDetail.parent_url} target="_blank" rel="noreferrer">{selectedDetail.parent_url}</a></p>}
+                  {selectedDetail.depth > 0 && <p><Text strong>深度:</Text>Level {selectedDetail.depth}</p>}
+                  {selectedDetail.duration_ms > 0 && <p><Text strong>耗时:</Text>{selectedDetail.duration_ms}ms</p>}
+                  {selectedDetail.extra && <p><Text strong>附加:</Text>{selectedDetail.extra}</p>}
+                </div>
+              ),
+            },
+            {
+              key: 'input',
+              label: '输入内容',
+              children: (
+                <Paragraph>
+                  <pre style={{ maxHeight: 400, overflow: 'auto', background: '#f5f5f5', padding: 12, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
+                    {selectedDetail.input || '(空)'}
+                  </pre>
+                </Paragraph>
+              ),
+            },
+            {
+              key: 'output',
+              label: '输出结果',
+              children: (
+                <Paragraph>
+                  <pre style={{ maxHeight: 400, overflow: 'auto', background: '#f5f5f5', padding: 12, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
+                    {selectedDetail.output || '(空)'}
+                  </pre>
+                </Paragraph>
+              ),
+            },
+          ]} />
+        )}
+      </Modal>
     </div>
   )
 }

+ 332 - 0
web/src/pages/Users.tsx

@@ -0,0 +1,332 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Table, Tag, Button, Modal, Form, Input, Select, Switch, Space, message, Popconfirm, Tabs, Checkbox, Card, Row, Col, Typography, Divider } from 'antd'
+import { PlusOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
+import api from '../api/client'
+import { getAllPermissions, updateRolePermission, resetPermissions, type RolePermission, type PermissionMeta } from '../api'
+
+const { Option } = Select
+const { Text, Title } = Typography
+
+interface User {
+  id: number
+  username: string
+  nickname: string
+  role: string
+  enabled: boolean
+  last_login_at: string | null
+  last_login_ip: string
+  created_at: string
+}
+
+const roleLabels: Record<string, string> = { admin: '管理员', operator: '运营', viewer: '只读' }
+const roleColors: Record<string, string> = { admin: 'red', operator: 'blue', viewer: 'default' }
+
+function UserTab() {
+  const [users, setUsers] = useState<User[]>([])
+  const [loading, setLoading] = useState(false)
+  const [modalOpen, setModalOpen] = useState(false)
+  const [resetModal, setResetModal] = useState<number | null>(null)
+  const [form] = Form.useForm()
+  const [resetForm] = Form.useForm()
+
+  const fetchUsers = useCallback(async () => {
+    setLoading(true)
+    try {
+      const res = await api.get('/users')
+      setUsers(res.data.items || [])
+    } catch { message.error('获取用户列表失败') }
+    finally { setLoading(false) }
+  }, [])
+
+  useEffect(() => { fetchUsers() }, [fetchUsers])
+
+  const handleCreate = async () => {
+    try {
+      const values = await form.validateFields()
+      await api.post('/users', values)
+      message.success('用户已创建')
+      setModalOpen(false)
+      fetchUsers()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || err?.data?.message || '创建失败')
+    }
+  }
+
+  const handleToggle = async (user: User, enabled: boolean) => {
+    await api.put(`/users/${user.id}`, { enabled })
+    message.success('状态已更新')
+    fetchUsers()
+  }
+
+  const handleRoleChange = async (user: User, role: string) => {
+    await api.put(`/users/${user.id}`, { role })
+    message.success('角色已更新')
+    fetchUsers()
+  }
+
+  const handleResetPassword = async () => {
+    if (!resetModal) return
+    try {
+      const values = await resetForm.validateFields()
+      await api.post(`/users/${resetModal}/reset-password`, { new_password: values.new_password })
+      message.success('密码已重置')
+      setResetModal(null)
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.data?.message || '重置失败')
+    }
+  }
+
+  const handleDelete = async (id: number) => {
+    await api.delete(`/users/${id}`)
+    message.success('已删除')
+    fetchUsers()
+  }
+
+  const handleForceLogout = async (id: number) => {
+    await api.post(`/users/${id}/force-logout`)
+    message.success('已强制退出')
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', width: 60 },
+    { title: '用户名', dataIndex: 'username' },
+    { title: '昵称', dataIndex: 'nickname', render: (v: string) => v || '-' },
+    {
+      title: '角色', dataIndex: 'role',
+      render: (v: string, record: User) => (
+        <Select size="small" value={v} style={{ width: 100 }} onChange={(r) => handleRoleChange(record, r)}>
+          <Option value="admin"><Tag color="red">管理员</Tag></Option>
+          <Option value="operator"><Tag color="blue">运营</Tag></Option>
+          <Option value="viewer"><Tag>只读</Tag></Option>
+        </Select>
+      ),
+    },
+    {
+      title: '状态', dataIndex: 'enabled',
+      render: (v: boolean, record: User) => (
+        <Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
+      ),
+    },
+    {
+      title: '最后登录', dataIndex: 'last_login_at', width: 160,
+      render: (v: string | null, record: User) => v ? (
+        <span style={{ fontSize: 12 }}>{new Date(v).toLocaleString('zh-CN')}<br/><Text type="secondary">{record.last_login_ip}</Text></span>
+      ) : '-',
+    },
+    {
+      title: '操作', key: 'action',
+      render: (_: unknown, record: User) => (
+        <Space>
+          <Button size="small" icon={<KeyOutlined />} onClick={() => { setResetModal(record.id); resetForm.resetFields() }}>重置密码</Button>
+          <Popconfirm title="强制此用户退出?" onConfirm={() => handleForceLogout(record.id)}>
+            <Button size="small">强制退出</Button>
+          </Popconfirm>
+          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
+            <Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <>
+      <Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }} style={{ marginBottom: 16 }}>
+        添加用户
+      </Button>
+      <Table dataSource={users} columns={columns} rowKey="id" loading={loading} pagination={false} />
+
+      <Modal title="添加用户" open={modalOpen} onOk={handleCreate} onCancel={() => setModalOpen(false)} okText="创建">
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
+            <Input />
+          </Form.Item>
+          <Form.Item name="password" label="密码" rules={[{ required: true, min: 8, message: '至少8位,包含大小写和数字' }]}>
+            <Input.Password />
+          </Form.Item>
+          <Form.Item name="nickname" label="昵称">
+            <Input />
+          </Form.Item>
+          <Form.Item name="role" label="角色" rules={[{ required: true }]} initialValue="operator">
+            <Select>
+              <Option value="admin">管理员</Option>
+              <Option value="operator">运营</Option>
+              <Option value="viewer">只读</Option>
+            </Select>
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      <Modal title="重置密码" open={!!resetModal} onOk={handleResetPassword} onCancel={() => setResetModal(null)} okText="重置">
+        <Form form={resetForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 8, message: '至少8位' }]}>
+            <Input.Password />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  )
+}
+
+function PermissionTab() {
+  const [roles, setRoles] = useState<RolePermission[]>([])
+  const [allMenus, setAllMenus] = useState<PermissionMeta[]>([])
+  const [allActions, setAllActions] = useState<PermissionMeta[]>([])
+  const [loading, setLoading] = useState(false)
+  const [editRole, setEditRole] = useState<string | null>(null)
+  const [selectedMenus, setSelectedMenus] = useState<string[]>([])
+  const [selectedActions, setSelectedActions] = useState<string[]>([])
+  const [saving, setSaving] = useState(false)
+
+  const fetchData = async () => {
+    setLoading(true)
+    try {
+      const res = await getAllPermissions()
+      setRoles(res.data.roles || [])
+      setAllMenus(res.data.all_menus || [])
+      setAllActions(res.data.all_actions || [])
+    } catch { message.error('获取权限配置失败') }
+    setLoading(false)
+  }
+
+  useEffect(() => { fetchData() }, [])
+
+  const openEdit = (role: RolePermission) => {
+    setEditRole(role.role)
+    setSelectedMenus(role.menus ? role.menus.split(',').filter(Boolean) : [])
+    setSelectedActions(role.actions ? role.actions.split(',').filter(Boolean) : [])
+  }
+
+  const handleSave = async () => {
+    if (!editRole) return
+    setSaving(true)
+    try {
+      await updateRolePermission(editRole, selectedMenus, selectedActions)
+      message.success('权限已更新')
+      setEditRole(null)
+      fetchData()
+    } catch { message.error('保存失败') }
+    setSaving(false)
+  }
+
+  const handleReset = async () => {
+    Modal.confirm({
+      title: '恢复默认权限',
+      content: '将所有角色权限恢复为系统默认值,确认?',
+      onOk: async () => {
+        await resetPermissions()
+        message.success('已恢复默认权限')
+        fetchData()
+      },
+    })
+  }
+
+  return (
+    <>
+      <div style={{ marginBottom: 16 }}>
+        <Button icon={<ReloadOutlined />} onClick={handleReset}>恢复默认权限</Button>
+        <Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
+          配置每个角色可访问的菜单和操作权限。修改后用户需重新登录生效。
+        </Text>
+      </div>
+
+      <Row gutter={16}>
+        {roles.map(role => {
+          const menus = role.menus ? role.menus.split(',').filter(Boolean) : []
+          const actions = role.actions ? role.actions.split(',').filter(Boolean) : []
+          return (
+            <Col span={8} key={role.role}>
+              <Card
+                title={<><Tag color={roleColors[role.role] || 'default'}>{roleLabels[role.role] || role.role}</Tag> {role.role}</>}
+                size="small"
+                extra={<Button size="small" type="link" onClick={() => openEdit(role)}>编辑</Button>}
+                style={{ marginBottom: 16 }}
+              >
+                <div style={{ marginBottom: 8 }}>
+                  <Text strong style={{ fontSize: 12 }}>菜单 ({menus.length}/{allMenus.length})</Text>
+                </div>
+                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
+                  {allMenus.map(m => (
+                    <Tag key={m.key} color={menus.includes(m.key) ? 'blue' : undefined}
+                      style={{ opacity: menus.includes(m.key) ? 1 : 0.3, fontSize: 11 }}>
+                      {m.label}
+                    </Tag>
+                  ))}
+                </div>
+                <div style={{ marginBottom: 8 }}>
+                  <Text strong style={{ fontSize: 12 }}>操作 ({actions.length}/{allActions.length})</Text>
+                </div>
+                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+                  {allActions.map(a => (
+                    <Tag key={a.key} color={actions.includes(a.key) ? 'green' : undefined}
+                      style={{ opacity: actions.includes(a.key) ? 1 : 0.3, fontSize: 11 }}>
+                      {a.label}
+                    </Tag>
+                  ))}
+                </div>
+              </Card>
+            </Col>
+          )
+        })}
+      </Row>
+
+      {/* Edit Modal */}
+      <Modal
+        title={`编辑权限: ${roleLabels[editRole || ''] || editRole}`}
+        open={!!editRole}
+        onCancel={() => setEditRole(null)}
+        onOk={handleSave}
+        confirmLoading={saving}
+        okText="保存"
+        width={600}
+      >
+        <div style={{ marginBottom: 20 }}>
+          <Title level={5} style={{ marginBottom: 8 }}>菜单权限</Title>
+          <div style={{ marginBottom: 8 }}>
+            <Button size="small" type="link" onClick={() => setSelectedMenus(allMenus.map(m => m.key))}>全选</Button>
+            <Button size="small" type="link" onClick={() => setSelectedMenus([])}>全不选</Button>
+          </div>
+          <Checkbox.Group value={selectedMenus} onChange={v => setSelectedMenus(v as string[])}>
+            <Row gutter={[8, 8]}>
+              {allMenus.map(m => (
+                <Col span={8} key={m.key}>
+                  <Checkbox value={m.key}>{m.label}</Checkbox>
+                </Col>
+              ))}
+            </Row>
+          </Checkbox.Group>
+        </div>
+
+        <Divider />
+
+        <div>
+          <Title level={5} style={{ marginBottom: 8 }}>操作权限</Title>
+          <div style={{ marginBottom: 8 }}>
+            <Button size="small" type="link" onClick={() => setSelectedActions(allActions.map(a => a.key))}>全选</Button>
+            <Button size="small" type="link" onClick={() => setSelectedActions([])}>全不选</Button>
+          </div>
+          <Checkbox.Group value={selectedActions} onChange={v => setSelectedActions(v as string[])}>
+            <Row gutter={[8, 8]}>
+              {allActions.map(a => (
+                <Col span={8} key={a.key}>
+                  <Checkbox value={a.key}>{a.label}</Checkbox>
+                </Col>
+              ))}
+            </Row>
+          </Checkbox.Group>
+        </div>
+      </Modal>
+    </>
+  )
+}
+
+export default function Users() {
+  return (
+    <Tabs defaultActiveKey="users" items={[
+      { key: 'users', label: '用户管理', children: <UserTab /> },
+      { key: 'permissions', label: '菜单权限', children: <PermissionTab /> },
+    ]} />
+  )
+}

+ 83 - 1
web/src/store/index.ts

@@ -1,12 +1,94 @@
 import { create } from 'zustand'
 import type { TaskLog } from '../api'
 
+interface UserInfo {
+  id: number
+  username: string
+  nickname: string
+  role: string
+  must_change_password?: boolean
+}
+
 interface AppState {
   runningTask: TaskLog | null
   setRunningTask: (task: TaskLog | null) => void
+
+  // Auth
+  token: string | null
+  user: UserInfo | null
+  setAuth: (token: string, user: UserInfo) => void
+  logout: () => void
+  isAdmin: () => boolean
+  isOperator: () => boolean
+
+  // Permissions
+  menus: string[]
+  actions: string[]
+  setPermissions: (menus: string[], actions: string[]) => void
+  hasMenu: (key: string) => boolean
+  hasAction: (key: string) => boolean
 }
 
-export const useAppStore = create<AppState>((set) => ({
+export const useAppStore = create<AppState>((set, get) => ({
   runningTask: null,
   setRunningTask: (task) => set({ runningTask: task }),
+
+  token: localStorage.getItem('token'),
+  user: (() => {
+    try {
+      const s = localStorage.getItem('user')
+      return s ? JSON.parse(s) : null
+    } catch { return null }
+  })(),
+
+  setAuth: (token, user) => {
+    localStorage.setItem('token', token)
+    localStorage.setItem('user', JSON.stringify(user))
+    set({ token, user })
+  },
+
+  logout: () => {
+    localStorage.removeItem('token')
+    localStorage.removeItem('user')
+    localStorage.removeItem('permissions')
+    set({ token: null, user: null, runningTask: null, menus: [], actions: [] })
+  },
+
+  isAdmin: () => get().user?.role === 'admin',
+  isOperator: () => {
+    const role = get().user?.role
+    return role === 'admin' || role === 'operator'
+  },
+
+  // Permissions
+  menus: (() => {
+    try {
+      const s = localStorage.getItem('permissions')
+      return s ? JSON.parse(s).menus || [] : []
+    } catch { return [] }
+  })(),
+  actions: (() => {
+    try {
+      const s = localStorage.getItem('permissions')
+      return s ? JSON.parse(s).actions || [] : []
+    } catch { return [] }
+  })(),
+
+  setPermissions: (menus, actions) => {
+    localStorage.setItem('permissions', JSON.stringify({ menus, actions }))
+    set({ menus, actions })
+  },
+
+  hasMenu: (key) => {
+    const { menus, user } = get()
+    // If no permissions loaded yet, fallback to role-based defaults
+    if (menus.length === 0 && user?.role === 'admin') return true
+    return menus.includes(key)
+  },
+
+  hasAction: (key) => {
+    const { actions, user } = get()
+    if (actions.length === 0 && user?.role === 'admin') return true
+    return actions.includes(key)
+  },
 }))