소스 검색

完善了接口返回参数,新增登录页面

lxylxy123321 1 주 전
부모
커밋
e87cf14e5b

+ 137 - 0
README.md

@@ -0,0 +1,137 @@
+# Sentinel Lens
+
+阿里云百炼模型价格监控系统,自动爬取模型价格、限流信息、工具调用价格,提供可视化仪表盘和对外价格查询 API。
+
+## 功能
+
+- 自动爬取阿里云百炼模型页面的价格、限流、工具调用价格、模型信息
+- 支持定时爬取(可配置间隔天数和开始时间)
+- 对外提供价格查询 API,支持按域名配置折扣率
+- 实时访问日志 + 地理分布可视化
+- 爬虫仪表盘:每日爬取次数、模型排行、任务状态
+- 登录认证(JWT + bcrypt)
+
+## 技术栈
+
+- 后端:FastAPI + asyncpg + PostgreSQL + Playwright
+- 前端:React + TypeScript + Vite
+- 爬虫:Playwright(Chromium)
+
+## 项目结构
+
+```
+├── backend/
+│   ├── app/
+│   │   ├── routers/        # API 路由
+│   │   ├── services/       # 爬虫、调度器、WebSocket
+│   │   └── utils/          # 价格解析工具
+│   ├── crawl/              # 爬虫核心逻辑
+│   ├── migrations/         # 数据库迁移 SQL
+│   └── requirements.txt
+├── frontend/
+│   └── src/
+│       ├── pages/          # 页面组件
+│       ├── components/     # 公共组件
+│       └── hooks/          # 自定义 Hook
+├── docs/
+│   └── api.md              # 接口文档
+└── README.md
+```
+
+## 快速开始
+
+### 1. 数据库
+
+创建 PostgreSQL 数据库,按顺序执行 `backend/migrations/` 下的 SQL 文件:
+
+```bash
+psql -U <user> -d <dbname> -f backend/migrations/001_init.sql
+psql -U <user> -d <dbname> -f backend/migrations/002_models.sql
+# ... 依次执行到最新
+```
+
+### 2. 后端
+
+```bash
+cd backend
+cp .env.example .env
+# 编辑 .env 填写数据库连接信息
+pip install -r requirements.txt
+playwright install chromium
+python main.py
+```
+
+后端默认运行在 `http://localhost:8000`。
+
+**环境变量说明**
+
+| 变量                  | 说明                              | 默认值                    |
+|-----------------------|-----------------------------------|---------------------------|
+| `HOST`                | 监听地址                          | `0.0.0.0`                 |
+| `PORT`                | 监听端口                          | `8000`                    |
+| `DB_HOST`             | 数据库地址                        | `localhost`               |
+| `DB_PORT`             | 数据库端口                        | `5432`                    |
+| `DB_USER`             | 数据库用户名                      | —                         |
+| `DB_PASSWORD`         | 数据库密码                        | —                         |
+| `DB_NAME`             | 数据库名                          | —                         |
+| `ALLOWED_ORIGINS`     | 前端跨域地址                      | `http://localhost:5173`   |
+| `GEOIP_DB_PATH`       | GeoLite2 City 数据库路径          | `./GeoLite2-City.mmdb`    |
+| `PLAYWRIGHT_EXECUTABLE` | Chromium 可执行文件路径(可选) | 系统默认                  |
+| `JWT_SECRET`          | JWT 签名密钥(生产环境必须修改)  | `change-me-in-production` |
+
+### 3. 前端
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+前端默认运行在 `http://localhost:5173`。
+
+**环境变量**
+
+| 变量                | 说明           | 默认值                  |
+|---------------------|----------------|-------------------------|
+| `VITE_API_BASE_URL` | 后端 API 地址  | `http://localhost:8000` |
+
+### 4. 登录
+
+默认账户:
+
+| 账户名 | 密码       |
+|--------|------------|
+| admin  | admin123   |
+
+> 生产环境请修改 `.env` 中的 `JWT_SECRET`,并在登录后及时修改密码。
+
+## 使用说明
+
+### 爬取模型价格
+
+1. 进入「爬取」页面,点击 + 添加模型 URL(阿里云百炼模型详情页地址)
+2. 勾选要爬取的模型,点击「爬取已选」
+3. 在历史记录中查看任务状态和爬取结果
+
+### 定时爬取
+
+在「爬取」页面底部配置定时爬取,设置间隔天数和开始时间后开启即可。
+
+### 价格查询 API
+
+对外暴露的价格接口无需认证,可直接调用:
+
+```
+GET http://localhost:8000/api/public/prices
+GET http://localhost:8000/api/public/prices?url=<模型页面URL>
+```
+
+响应中的 `discounted_prices` 会根据请求来源域名自动应用折扣,折扣配置在「折扣」页面管理。
+
+### 折扣配置
+
+在「折扣」页面为指定域名配置折扣率(如 `0.8` 表示八折)。当该域名的前端页面调用价格 API 时,返回的 `discounted_prices` 中的价格会自动乘以折扣率。
+
+## 接口文档
+
+详见 [docs/api.md](docs/api.md)。

+ 17 - 1
backend/app/main.py

@@ -7,7 +7,7 @@ from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from app.config import settings
-from app.db import close_pool, init_pool
+from app.db import close_pool, init_pool, get_pool
 from app.middleware.logging import LoggingMiddleware
 from app.services.ws_hub import hub as ws_hub  # noqa: F401
 from app.services.scheduler import start_scheduler, stop_scheduler
@@ -16,6 +16,18 @@ from app.services.scheduler import start_scheduler, stop_scheduler
 @asynccontextmanager
 async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
     await init_pool()
+    # 建 users 表并初始化 admin
+    pool = get_pool()
+    await pool.execute("""
+        CREATE TABLE IF NOT EXISTS crawl.users (
+            id           BIGSERIAL    PRIMARY KEY,
+            username     VARCHAR(100) NOT NULL UNIQUE,
+            password_hash TEXT        NOT NULL,
+            created_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW()
+        )
+    """)
+    from app.routers.auth import ensure_admin_user
+    await ensure_admin_user()
     await start_scheduler()
     yield
     await stop_scheduler()
@@ -42,6 +54,8 @@ from app.routers import ws  # noqa: E402
 from app.routers import models  # noqa: E402
 from app.routers import schedule  # noqa: E402
 from app.routers import discounts  # noqa: E402
+from app.routers import auth  # noqa: E402
+from app.routers import scrape_stats  # noqa: E402
 app.include_router(stats.router, prefix="/api")
 app.include_router(logs.router, prefix="/api")
 app.include_router(scrape.router, prefix="/api")
@@ -49,6 +63,8 @@ app.include_router(public.router, prefix="/api/public")
 app.include_router(models.router, prefix="/api")
 app.include_router(schedule.router, prefix="/api")
 app.include_router(discounts.router, prefix="/api")
+app.include_router(auth.router, prefix="/api")
+app.include_router(scrape_stats.router, prefix="/api")
 app.include_router(ws.router)
 
 

+ 63 - 0
backend/app/routers/auth.py

@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import os
+from datetime import datetime, timedelta, timezone
+
+import bcrypt
+from fastapi import APIRouter, HTTPException
+from jose import jwt
+from pydantic import BaseModel
+
+from app.db import get_pool
+
+router = APIRouter(tags=["auth"])
+
+SECRET_KEY = os.environ.get("JWT_SECRET", "change-me-in-production-please")
+ALGORITHM = "HS256"
+TOKEN_EXPIRE_HOURS = 12
+
+
+class LoginIn(BaseModel):
+    username: str
+    password: str
+
+
+class TokenOut(BaseModel):
+    access_token: str
+    token_type: str = "bearer"
+
+
+def _hash_password(plain: str) -> str:
+    return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
+
+
+def _verify_password(plain: str, hashed: str) -> bool:
+    return bcrypt.checkpw(plain.encode(), hashed.encode())
+
+
+def _create_token(username: str) -> str:
+    expire = datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)
+    return jwt.encode({"sub": username, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
+
+
+async def ensure_admin_user() -> None:
+    """启动时确保 admin 账户存在,不存在则创建。"""
+    pool = get_pool()
+    row = await pool.fetchrow("SELECT id FROM crawl.users WHERE username = 'admin'")
+    if not row:
+        hashed = _hash_password("admin123")
+        await pool.execute(
+            "INSERT INTO crawl.users (username, password_hash) VALUES ('admin', $1)",
+            hashed,
+        )
+
+
+@router.post("/auth/login", response_model=TokenOut)
+async def login(body: LoginIn) -> TokenOut:
+    pool = get_pool()
+    row = await pool.fetchrow(
+        "SELECT password_hash FROM crawl.users WHERE username = $1", body.username
+    )
+    if not row or not _verify_password(body.password, row["password_hash"]):
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+    return TokenOut(access_token=_create_token(body.username))

+ 23 - 1
backend/app/routers/public.py

@@ -53,10 +53,18 @@ class DiscountedPriceItem(BaseModel):
     discount: Optional[float] = None  # None 表示无折扣(原价)
 
 
+
+class ModelTypeItem(BaseModel):
+    model_name: str
+    type: List[str]
+
+
 class PricesResponse(BaseModel):
     models: List[PublicPriceOut]
     parsed_prices: List[ParsedPriceItem]
     discounted_prices: List[DiscountedPriceItem]
+    types: List[ModelTypeItem]
+    discount: float = 1.0
 
 
 def _extract_domain(referer: Optional[str]) -> Optional[str]:
@@ -130,6 +138,15 @@ async def get_public_prices(request: Request, url: Optional[str] = None) -> Pric
         if not rows:
             raise HTTPException(status_code=404, detail="No scrape results found for the given URL")
 
+    def _extract_type(model_info: Optional[dict]) -> Optional[List[str]]:
+        if not model_info:
+            return None
+        tags = model_info.get("display_tags", [])
+        # 只保留模型类型标签,排除系列名和能力标签(深度思考等)
+        TYPE_TAGS = {"文本生成", "图像生成", "视觉理解", "音频理解", "视频理解", "视频生成", "向量表示", "语音识别", "语音合成"}
+        result = [t for t in tags if t in TYPE_TAGS]
+        return result if result else None
+
     models = [PublicPriceOut(
         url=r["url"],
         model_name=r["model_name"],
@@ -164,4 +181,9 @@ async def get_public_prices(request: Request, url: Optional[str] = None) -> Pric
                 **d_item,
             ))
 
-    return PricesResponse(models=models, parsed_prices=parsed_prices, discounted_prices=discounted_prices)
+    all_types = [
+        ModelTypeItem(model_name=r["model_name"], type=_extract_type(_j(r["model_info"])) or [])
+        for r in rows
+    ]
+
+    return PricesResponse(models=models, parsed_prices=parsed_prices, discounted_prices=discounted_prices, types=all_types, discount=discount_rate if discount_rate is not None else 1.0)

+ 117 - 0
backend/app/routers/scrape_stats.py

@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from typing import List, Optional
+from datetime import date
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from app.db import get_pool
+
+router = APIRouter(tags=["scrape-stats"])
+
+
+class ScrapeOverview(BaseModel):
+    total_jobs: int
+    success_jobs: int
+    failed_jobs: int
+    total_models_scraped: int
+    last_scraped_at: Optional[str] = None
+
+
+class DailyCount(BaseModel):
+    date: str
+    count: int
+
+
+class ModelRankItem(BaseModel):
+    model_name: str
+    count: int
+
+
+class RecentJob(BaseModel):
+    job_id: str
+    status: str
+    model_count: int
+    created_at: str
+
+
+class ScrapeStatsOut(BaseModel):
+    overview: ScrapeOverview
+    daily_counts: List[DailyCount]
+    model_ranks: List[ModelRankItem]
+    recent_jobs: List[RecentJob]
+
+
+@router.get("/scrape-stats", response_model=ScrapeStatsOut)
+async def get_scrape_stats() -> ScrapeStatsOut:
+    pool = get_pool()
+
+    # 总览
+    overview_row = await pool.fetchrow("""
+        SELECT
+            COUNT(*)                                            AS total_jobs,
+            COUNT(*) FILTER (WHERE status = 'done')            AS success_jobs,
+            COUNT(*) FILTER (WHERE status = 'failed')          AS failed_jobs,
+            MAX(updated_at)                                     AS last_scraped_at
+        FROM scrape_jobs
+    """)
+
+    total_models = await pool.fetchval(
+        "SELECT COUNT(DISTINCT url) FROM scrape_results"
+    ) or 0
+
+    overview = ScrapeOverview(
+        total_jobs=overview_row["total_jobs"] or 0,
+        success_jobs=overview_row["success_jobs"] or 0,
+        failed_jobs=overview_row["failed_jobs"] or 0,
+        total_models_scraped=total_models,
+        last_scraped_at=overview_row["last_scraped_at"].isoformat() if overview_row["last_scraped_at"] else None,
+    )
+
+    # 近30天每日爬取次数
+    daily_rows = await pool.fetch("""
+        SELECT DATE(created_at) AS day, COUNT(*) AS cnt
+        FROM scrape_jobs
+        WHERE created_at >= NOW() - INTERVAL '30 days'
+        GROUP BY day
+        ORDER BY day
+    """)
+    daily_counts = [DailyCount(date=str(r["day"]), count=r["cnt"]) for r in daily_rows]
+
+    # 模型爬取排行 Top 15
+    rank_rows = await pool.fetch("""
+        SELECT model_name, COUNT(*) AS cnt
+        FROM scrape_results
+        GROUP BY model_name
+        ORDER BY cnt DESC
+        LIMIT 15
+    """)
+    model_ranks = [ModelRankItem(model_name=r["model_name"], count=r["cnt"]) for r in rank_rows]
+
+    # 最近10条任务
+    recent_rows = await pool.fetch("""
+        SELECT j.id, j.status, j.created_at,
+               COUNT(r.id) AS model_count
+        FROM scrape_jobs j
+        LEFT JOIN scrape_results r ON r.job_id = j.id
+        GROUP BY j.id, j.status, j.created_at
+        ORDER BY j.created_at DESC
+        LIMIT 10
+    """)
+    recent_jobs = [
+        RecentJob(
+            job_id=str(r["id"]),
+            status=r["status"],
+            model_count=r["model_count"],
+            created_at=r["created_at"].isoformat(),
+        )
+        for r in recent_rows
+    ]
+
+    return ScrapeStatsOut(
+        overview=overview,
+        daily_counts=daily_counts,
+        model_ranks=model_ranks,
+        recent_jobs=recent_jobs,
+    )

+ 1 - 0
backend/crawl/scrape_model_info.py

@@ -31,6 +31,7 @@ CAPABILITY_LABELS: Dict[str, str] = {
     "VID":       "视频理解",
     "VG":        "视频生成",
     "IMG":       "图像生成",
+    "IG":        "图像生成",
     "EMB":       "向量表示",
     "ASR":       "语音识别",
     "TTS":       "语音合成",

+ 8 - 5
backend/crawl/scrape_rate_limits.py

@@ -85,13 +85,16 @@ def parse_rate_limits_from_text(text: str) -> Dict:
     for pattern, key in FIELD_PATTERNS:
         if key in result:
             continue
-        # 找字段名,然后取紧跟其后的数值
-        m = re.search(pattern + r"\s*([0-9][0-9,,]*(?:\.\d+)?\s*[KkMm万]?)", text, re.I)
+        # 匹配数值或 "-"(空值)
+        m = re.search(pattern + r"\s*(-|[0-9][0-9,,]*(?:\.\d+)?\s*[KkMm万]?)", text, re.I)
         if m:
             val = m.group(1).strip().replace(",", ",")
-            # 统一大写 K
-            val = re.sub(r"k$", "K", val)
-            result[key] = val
+            if val == "-":
+                result[key] = None
+            else:
+                # 统一大写 K
+                val = re.sub(r"k$", "K", val)
+                result[key] = val
 
     return result
 

+ 12 - 0
backend/migrations/009_users.sql

@@ -0,0 +1,12 @@
+-- Migration 009: users table with bcrypt hashed password
+SET search_path TO crawl;
+
+CREATE TABLE IF NOT EXISTS users (
+    id           BIGSERIAL    PRIMARY KEY,
+    username     VARCHAR(100) NOT NULL UNIQUE,
+    password_hash TEXT        NOT NULL,
+    created_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW()
+);
+
+-- 初始 admin 账户,密码 admin123 的 bcrypt hash
+-- 运行时由应用启动时自动插入,见 auth router

+ 3 - 0
backend/requirements.txt

@@ -11,3 +11,6 @@ pytest-asyncio
 hypothesis
 httpx
 apscheduler
+bcrypt
+python-jose[cryptography]
+python-multipart

+ 479 - 0
docs/api.md

@@ -0,0 +1,479 @@
+# Sentinel Lens API 文档
+
+Base URL: `http://localhost:8000`
+
+---
+
+## 认证
+
+### POST /api/auth/login
+
+登录获取 JWT Token。
+
+**Request Body**
+```json
+{
+  "username": "admin",
+  "password": "admin123"
+}
+```
+
+**Response 200**
+```json
+{
+  "access_token": "eyJ...",
+  "token_type": "bearer"
+}
+```
+
+**Response 401** — 用户名或密码错误
+
+---
+
+## 公开价格接口
+
+### GET /api/public/prices
+
+获取最新一次爬取任务的所有模型价格数据。调用时会自动记录来源 IP 和 Referer,并根据域名匹配折扣。
+
+**Query Parameters**
+
+| 参数 | 类型   | 必填 | 说明                     |
+|------|--------|------|--------------------------|
+| url  | string | 否   | 指定单个模型页面 URL 过滤 |
+
+**Response 200**
+```json
+{
+  "models": [
+    {
+      "url": "https://bailian.console.aliyun.com/...",
+      "model_name": "qwen-plus-latest",
+      "prices": { "256k<input": { "输入": {...}, "输出": {...} } },
+      "model_info": {
+        "model_code": "qwen-plus-latest",
+        "display_tags": ["Qwen3", "文本生成"],
+        "description": "...",
+        "input_modalities": ["Text"],
+        "output_modalities": ["Text"],
+        "features": { "cache存储": true, "function calling": true, ... }
+      },
+      "rate_limits": { "RPM": "30000", "TPM": "10000000", "上下文长度": "1M" },
+      "tool_prices": [{ "label": "联网搜索", "price": 0, "unit": "元/千次", "note": null }],
+      "scraped_at": "2026-04-03T09:30:12+00:00"
+    }
+  ],
+  "parsed_prices": [
+    {
+      "url": "...",
+      "model_name": "qwen-plus-latest",
+      "tier_min": null,
+      "tier_max": 256,
+      "tier_unit": "k",
+      "input_price": 0.8,
+      "output_price": 2.0,
+      "currency": "CNY",
+      "unit": "元/百万tokens",
+      "label": "输入"
+    }
+  ],
+  "discounted_prices": [
+    {
+      "url": "...",
+      "model_name": "qwen-plus-latest",
+      "tier_min": null,
+      "tier_max": 256,
+      "tier_unit": "k",
+      "input_price": 0.64,
+      "output_price": 1.6,
+      "currency": "CNY",
+      "unit": "元/百万tokens",
+      "label": "输入",
+      "discount": 0.8
+    }
+  ],
+  "types": [
+    { "model_name": "qwen-plus-latest", "type": ["文本生成"] },
+    { "model_name": "qwen-vl-plus", "type": ["视觉理解"] }
+  ],
+  "discount": 0.8
+}
+```
+
+> `discount` 为调用方域名对应的折扣率,无折扣时为 `1.0`。
+> `discounted_prices` 中的价格已乘以折扣率。
+
+**Response 404** — 指定 url 无爬取结果
+
+---
+
+## 爬取任务
+
+### POST /api/scrape
+
+创建爬取任务,异步执行。
+
+**Request Body**
+```json
+{
+  "urls": [
+    "https://bailian.console.aliyun.com/...#/model-market/detail/qwen-plus",
+    "https://bailian.console.aliyun.com/...#/model-market/detail/qwen-max"
+  ]
+}
+```
+
+**Response 202**
+```json
+{
+  "job_id": "550e8400-e29b-41d4-a716-446655440000",
+  "status": "pending",
+  "error": null,
+  "created_at": "2026-04-03T09:30:00+00:00"
+}
+```
+
+---
+
+### GET /api/scrape
+
+获取所有爬取任务列表,按创建时间倒序。
+
+**Response 200**
+```json
+[
+  {
+    "job_id": "550e8400-...",
+    "status": "done",
+    "error": null,
+    "created_at": "2026-04-03T09:30:00+00:00"
+  }
+]
+```
+
+`status` 枚举值:`pending` | `running` | `done` | `failed`
+
+---
+
+### GET /api/scrape/{job_id}
+
+获取单个任务详情,`done` 状态时包含爬取结果。
+
+**Response 200**
+```json
+{
+  "job_id": "550e8400-...",
+  "status": "done",
+  "error": null,
+  "created_at": "2026-04-03T09:30:00+00:00",
+  "results": [
+    {
+      "url": "...",
+      "model_name": "qwen-plus-latest",
+      "prices": {},
+      "model_info": {},
+      "rate_limits": {},
+      "tool_prices": [],
+      "scraped_at": "2026-04-03T09:30:12+00:00"
+    }
+  ]
+}
+```
+
+**Response 404** — 任务不存在
+
+---
+
+## 模型管理
+
+### GET /api/models
+
+获取所有已注册模型列表。
+
+**Response 200**
+```json
+[
+  {
+    "id": 1,
+    "name": "qwen-plus",
+    "url": "https://bailian.console.aliyun.com/...#/model-market/detail/qwen-plus",
+    "created_at": "2026-04-01T00:00:00+00:00"
+  }
+]
+```
+
+---
+
+### POST /api/models
+
+添加模型。
+
+**Request Body**
+```json
+{
+  "name": "qwen-plus",
+  "url": "https://bailian.console.aliyun.com/...#/model-market/detail/qwen-plus"
+}
+```
+
+**Response 201** — 同上结构
+
+**Response 409** — URL 已存在
+
+---
+
+### DELETE /api/models/{model_id}
+
+删除模型。
+
+**Response 204** — 成功
+
+**Response 404** — 模型不存在
+
+---
+
+## 定时爬取配置
+
+### GET /api/schedule
+
+获取当前定时爬取配置。
+
+**Response 200**
+```json
+{
+  "enabled": false,
+  "interval_days": 1,
+  "start_hour": 2,
+  "updated_at": "2026-04-01T00:00:00+00:00"
+}
+```
+
+---
+
+### PUT /api/schedule
+
+更新定时爬取配置。
+
+**Request Body**
+```json
+{
+  "enabled": true,
+  "interval_days": 1,
+  "start_hour": 2
+}
+```
+
+**Response 200** — 同上结构
+
+---
+
+## 折扣管理
+
+### GET /api/discounts
+
+获取所有域名折扣配置。
+
+**Response 200**
+```json
+[
+  {
+    "id": 1,
+    "domain": "example.com",
+    "discount": 0.8,
+    "note": "合作伙伴八折",
+    "created_at": "2026-04-01T00:00:00+00:00",
+    "updated_at": "2026-04-01T00:00:00+00:00"
+  }
+]
+```
+
+---
+
+### POST /api/discounts
+
+新增或更新域名折扣(domain 唯一,重复则覆盖)。
+
+**Request Body**
+```json
+{
+  "domain": "example.com",
+  "discount": 0.8,
+  "note": "合作伙伴八折"
+}
+```
+
+> `discount` 范围:`(0, 1]`,如 `0.8` 表示八折。
+
+**Response 201** — 同上结构
+
+---
+
+### PUT /api/discounts/{discount_id}
+
+更新指定折扣记录。
+
+**Request Body** — 同 POST
+
+**Response 200** — 同上结构
+
+**Response 404** — 不存在
+
+---
+
+### DELETE /api/discounts/{discount_id}
+
+删除折扣记录。
+
+**Response 204** — 成功
+
+**Response 404** — 不存在
+
+---
+
+## 系统统计
+
+### GET /api/stats
+
+获取系统运行统计。
+
+**Response 200**
+```json
+{
+  "uptime_seconds": 3600.5,
+  "total_hits": 1024,
+  "active_ips": 3,
+  "avg_latency_ms": 45.2
+}
+```
+
+> `active_ips` 为最近 5 分钟内有请求的 IP 数量。
+
+---
+
+### GET /api/geo/distribution
+
+获取访问来源国家分布。
+
+**Response 200**
+```json
+[
+  { "country": "China", "count": 800, "percentage": 78.13 }
+]
+```
+
+---
+
+### GET /api/geo/points
+
+获取访问来源地理坐标点(最多 1000 条)。
+
+**Response 200**
+```json
+[
+  {
+    "latitude": 39.9042,
+    "longitude": 116.4074,
+    "country": "China",
+    "city": "Beijing",
+    "hit_count": 120
+  }
+]
+```
+
+---
+
+### GET /api/prices/top-ips
+
+获取价格接口调用量 Top 20 IP。
+
+**Response 200**
+```json
+[
+  { "ip": "127.0.0.1", "hit_count": 21, "percentage": 100.0 }
+]
+```
+
+---
+
+## 访问日志
+
+### GET /api/logs
+
+获取访问日志,支持分页。
+
+**Query Parameters**
+
+| 参数      | 类型 | 默认值 | 说明           |
+|-----------|------|--------|----------------|
+| page      | int  | 1      | 页码,从 1 开始 |
+| page_size | int  | 50     | 每页条数,最大 500 |
+
+**Response 200**
+```json
+[
+  {
+    "id": 1,
+    "ip": "127.0.0.1",
+    "method": "GET",
+    "path": "/api/public/prices",
+    "status_code": 200,
+    "latency_ms": 45.2,
+    "country": "China",
+    "city": "Beijing",
+    "latitude": 39.9042,
+    "longitude": 116.4074,
+    "created_at": "2026-04-03T09:30:00+00:00"
+  }
+]
+```
+
+---
+
+## 爬虫统计
+
+### GET /api/scrape-stats
+
+获取爬虫运行统计,用于仪表盘展示。
+
+**Response 200**
+```json
+{
+  "overview": {
+    "total_jobs": 25,
+    "success_jobs": 22,
+    "failed_jobs": 3,
+    "total_models_scraped": 7,
+    "last_scraped_at": "2026-04-03T09:30:34+00:00"
+  },
+  "daily_counts": [
+    { "date": "2026-03-05", "count": 2 },
+    { "date": "2026-04-03", "count": 5 }
+  ],
+  "model_ranks": [
+    { "model_name": "qwen-plus-latest", "count": 10 },
+    { "model_name": "qwen-max", "count": 8 }
+  ],
+  "recent_jobs": [
+    {
+      "job_id": "550e8400-...",
+      "status": "done",
+      "model_count": 5,
+      "created_at": "2026-04-03T09:30:00+00:00"
+    }
+  ]
+}
+```
+
+---
+
+## WebSocket
+
+### WS /ws/logs
+
+实时推送访问日志。连接后服务端会主动推送每条新请求的 JSON 数据,格式同 `/api/logs` 单条记录。
+
+**连接示例**
+```js
+const ws = new WebSocket('ws://localhost:8000/ws/logs');
+ws.onmessage = (e) => console.log(JSON.parse(e.data));
+```

+ 25 - 11
frontend/src/App.tsx

@@ -1,25 +1,39 @@
-import { BrowserRouter, Route, Routes } from 'react-router-dom';
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
 import { BottomNav } from './components/BottomNav';
+import { RequireAuth } from './components/RequireAuth';
 import { Dashboard } from './pages/Dashboard';
 import { Discounts } from './pages/Discounts';
+import { Login } from './pages/Login';
 import { Logs } from './pages/Logs';
 import { MapPage } from './pages/Map';
+import { ScrapeDashboard } from './pages/ScrapeDashboard';
 import { Scraper } from './pages/Scraper';
 import './index.css';
 
 export default function App() {
   return (
     <BrowserRouter>
-      <BottomNav />
-      <div style={{ marginLeft: 64, minHeight: '100vh' }}>
-        <Routes>
-          <Route path="/" element={<Dashboard />} />
-          <Route path="/logs" element={<Logs />} />
-          <Route path="/map" element={<MapPage />} />
-          <Route path="/scraper" element={<Scraper />} />
-          <Route path="/discounts" element={<Discounts />} />
-        </Routes>
-      </div>
+      <Routes>
+        <Route path="/login" element={<Login />} />
+        <Route path="/*" element={
+          <RequireAuth>
+            <>
+              <BottomNav />
+              <div style={{ marginLeft: 64, minHeight: '100vh' }}>
+                <Routes>
+                  <Route path="/" element={<Dashboard />} />
+                  <Route path="/logs" element={<Logs />} />
+                  <Route path="/map" element={<MapPage />} />
+                  <Route path="/scraper" element={<Scraper />} />
+                  <Route path="/scrape-dashboard" element={<ScrapeDashboard />} />
+                  <Route path="/discounts" element={<Discounts />} />
+                  <Route path="*" element={<Navigate to="/" replace />} />
+                </Routes>
+              </div>
+            </>
+          </RequireAuth>
+        } />
+      </Routes>
     </BrowserRouter>
   );
 }

+ 26 - 0
frontend/src/api.ts

@@ -86,3 +86,29 @@ export async function deleteDiscount(id: number): Promise<void> {
   const res = await fetch(`${BASE}/api/discounts/${id}`, { method: 'DELETE' });
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
 }
+
+export async function login(username: string, password: string): Promise<string> {
+  const res = await fetch(`${BASE}/api/auth/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password }),
+  });
+  if (!res.ok) throw new Error('用户名或密码错误');
+  const data = await res.json() as { access_token: string };
+  return data.access_token;
+}
+
+export interface ScrapeStats {
+  overview: {
+    total_jobs: number;
+    success_jobs: number;
+    failed_jobs: number;
+    total_models_scraped: number;
+    last_scraped_at: string | null;
+  };
+  daily_counts: { date: string; count: number }[];
+  model_ranks: { model_name: string; count: number }[];
+  recent_jobs: { job_id: string; status: string; model_count: number; created_at: string }[];
+}
+
+export const fetchScrapeStats = () => get<ScrapeStats>('/api/scrape-stats');

+ 7 - 0
frontend/src/components/RequireAuth.tsx

@@ -0,0 +1,7 @@
+import { Navigate } from 'react-router-dom';
+
+export function RequireAuth({ children }: { children: React.ReactNode }) {
+  const token = localStorage.getItem('token');
+  if (!token) return <Navigate to="/login" replace />;
+  return <>{children}</>;
+}

+ 125 - 4
frontend/src/pages/Dashboard.css

@@ -1,6 +1,6 @@
 .dashboard {
-  padding: 20px 16px;
-  max-width: 640px;
+  padding: 20px 24px;
+  max-width: 1100px;
   margin: 0 auto;
 }
 
@@ -145,8 +145,6 @@
   color: var(--text-muted);
   text-align: right;
 }
-  font-size: 12px;
-}
 
 .empty-msg {
   color: var(--text-muted);
@@ -154,3 +152,126 @@
   text-align: center;
   padding: 20px 0;
 }
+
+/* 爬虫统计扩展 */
+.stat-grid--5 {
+  grid-template-columns: repeat(5, 1fr);
+  margin-bottom: 16px;
+}
+
+.stat-value--sm {
+  font-size: 22px;
+  font-weight: bold;
+}
+
+.stat-value--xs {
+  font-size: 12px;
+  color: var(--text-primary);
+  font-weight: normal;
+  line-height: 1.4;
+}
+
+.neon-red { color: var(--neon-red, #ff4466); }
+
+.dash-panel {
+  background: var(--bg-card);
+  border: 1px solid var(--bg-border);
+  border-radius: 4px;
+  padding: 16px;
+  margin-bottom: 16px;
+}
+
+.dash-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.dash-panel--half {
+  margin-bottom: 0;
+}
+
+.line-chart {
+  width: 100%;
+  height: 100px;
+  display: block;
+}
+
+.chart-empty {
+  color: var(--text-muted);
+  font-size: 12px;
+  text-align: center;
+  padding: 20px 0;
+}
+
+.bar-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.bar-row {
+  display: grid;
+  grid-template-columns: 130px 1fr 32px;
+  align-items: center;
+  gap: 8px;
+  font-size: 11px;
+}
+
+.bar-label {
+  color: var(--text-muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bar-track {
+  background: var(--bg-border);
+  border-radius: 2px;
+  height: 5px;
+  overflow: hidden;
+}
+
+.bar-fill {
+  height: 100%;
+  background: var(--neon-cyan, #00d4ff);
+  border-radius: 2px;
+}
+
+.bar-count {
+  color: var(--neon-cyan);
+  text-align: right;
+  font-size: 11px;
+}
+
+.dash-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 11px;
+}
+
+.dash-table th {
+  color: var(--text-muted);
+  font-weight: normal;
+  text-align: left;
+  padding: 4px 6px 8px 0;
+  border-bottom: 1px solid var(--bg-border);
+}
+
+.dash-table td {
+  padding: 5px 6px 5px 0;
+  border-bottom: 1px solid var(--bg-border);
+  color: var(--text-primary);
+}
+
+.dash-table tr:last-child td { border-bottom: none; }
+
+.td-time { color: var(--text-muted); font-size: 10px; }
+
+.status-done    { color: var(--neon-green); }
+.status-failed  { color: var(--neon-red, #ff4466); }
+.status-running,
+.status-pending { color: #ffaa00; }

+ 131 - 24
frontend/src/pages/Dashboard.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
-import { fetchTopPriceIps, fetchStats } from '../api';
+import { fetchTopPriceIps, fetchStats, fetchScrapeStats, type ScrapeStats } from '../api';
 import { usePolling } from '../hooks/usePolling';
 import './Dashboard.css';
 
@@ -10,12 +10,49 @@ function formatUptime(seconds: number): string {
   return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
 }
 
+function LineChart({ data }: { data: { date: string; count: number }[] }) {
+  if (data.length === 0) return <div className="chart-empty">暂无数据</div>;
+  const W = 600, H = 100, PAD = { top: 8, right: 12, bottom: 24, left: 28 };
+  const innerW = W - PAD.left - PAD.right;
+  const innerH = H - PAD.top - PAD.bottom;
+  const maxVal = Math.max(...data.map(d => d.count), 1);
+  const x = (i: number) => PAD.left + (i / (data.length - 1 || 1)) * innerW;
+  const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
+  const points = data.map((d, i) => `${x(i)},${y(d.count)}`).join(' ');
+  const area = `M${x(0)},${y(data[0].count)} ` +
+    data.map((d, i) => `L${x(i)},${y(d.count)}`).join(' ') +
+    ` L${x(data.length - 1)},${PAD.top + innerH} L${x(0)},${PAD.top + innerH} Z`;
+  const labelIdxs = new Set([0, Math.floor(data.length / 2), data.length - 1]);
+  return (
+    <svg viewBox={`0 0 ${W} ${H}`} className="line-chart" preserveAspectRatio="none">
+      {[0, 0.5, 1].map(t => (
+        <line key={t} x1={PAD.left} y1={PAD.top + innerH * (1 - t)}
+          x2={PAD.left + innerW} y2={PAD.top + innerH * (1 - t)}
+          stroke="#1e2a3a" strokeWidth="1" />
+      ))}
+      {[0, Math.round(maxVal / 2), maxVal].map((v, i) => (
+        <text key={i} x={PAD.left - 4} y={y(v) + 4} textAnchor="end" fontSize="9" fill="#6b7a8d">{v}</text>
+      ))}
+      <path d={area} fill="rgba(0,212,255,0.08)" />
+      <polyline points={points} fill="none" stroke="#00d4ff" strokeWidth="1.5" />
+      {data.map((d, i) => <circle key={i} cx={x(i)} cy={y(d.count)} r="2.5" fill="#00d4ff" />)}
+      {data.map((d, i) => labelIdxs.has(i) && (
+        <text key={i} x={x(i)} y={H - 4} textAnchor="middle" fontSize="9" fill="#6b7a8d">{d.date.slice(5)}</text>
+      ))}
+    </svg>
+  );
+}
+
+const STATUS_MAP: Record<string, string> = {
+  done: '完成', failed: '失败', running: '运行中', pending: '等待中',
+};
+
 export function Dashboard() {
   const { data: stats } = usePolling(fetchStats, 5000);
   const { data: topIps } = usePolling(fetchTopPriceIps, 10000);
   const [displayUptime, setDisplayUptime] = useState<number | null>(null);
+  const [scrapeStats, setScrapeStats] = useState<ScrapeStats | null>(null);
 
-  // 每次从后端拿到 uptime 后,本地每秒递增
   useEffect(() => {
     if (stats == null) return;
     setDisplayUptime(stats.uptime_seconds);
@@ -23,15 +60,23 @@ export function Dashboard() {
     return () => clearInterval(timer);
   }, [stats?.uptime_seconds != null ? Math.floor(stats.uptime_seconds / 5) : null]);
 
+  useEffect(() => {
+    fetchScrapeStats().then(setScrapeStats).catch(() => {});
+  }, []);
+
+  const ov = scrapeStats?.overview;
+  const successRate = ov && ov.total_jobs > 0
+    ? Math.round(ov.success_jobs / ov.total_jobs * 100) : 0;
+  const maxRank = Math.max(...(scrapeStats?.model_ranks.map(r => r.count) ?? [1]), 1);
+
   return (
     <div className="dashboard">
       <header className="dash-header">
-        <span className="dash-logo">◈ 哨兵监控</span>
-        <span className="dash-status">
-          <span className="dot dot--green" /> 运行中
-        </span>
+        <span className="dash-logo">◈ 爬虫监控</span>
+        <span className="dash-status"><span className="dot dot--green" /> 运行中</span>
       </header>
 
+      {/* 系统统计 */}
       <div className="stat-grid">
         <div className="stat-card">
           <div className="stat-label">系统运行时间</div>
@@ -39,45 +84,107 @@ export function Dashboard() {
         </div>
         <div className="stat-card">
           <div className="stat-label">价格接口请求数</div>
-          <div className="stat-value neon-green">
-            {stats ? stats.total_hits.toLocaleString() : '—'}
-          </div>
+          <div className="stat-value neon-green">{stats ? stats.total_hits.toLocaleString() : '—'}</div>
         </div>
         <div className="stat-card">
           <div className="stat-label">活跃 IP 数</div>
-          <div className="stat-value neon-cyan">
-            <span className="blink">✦</span> {stats ? stats.active_ips : '—'}
-          </div>
+          <div className="stat-value neon-cyan"><span className="blink">✦</span> {stats ? stats.active_ips : '—'}</div>
         </div>
         <div className="stat-card">
           <div className="stat-label">平均延迟</div>
-          <div className="stat-value neon-cyan">
-            <span className="blink">◈</span> {stats ? `${stats.avg_latency_ms.toFixed(0)}ms` : '—'}
-          </div>
+          <div className="stat-value neon-cyan"><span className="blink">◈</span> {stats ? `${stats.avg_latency_ms.toFixed(0)}ms` : '—'}</div>
         </div>
       </div>
 
+      {/* 价格接口调用来源 */}
       <section className="geo-section">
-        <div className="section-title">
-          价格接口调用来源 <span className="globe">📡</span>
-        </div>
+        <div className="section-title">价格接口调用来源 <span>📡</span></div>
         {topIps && topIps.length > 0 ? (
           <ul className="geo-list">
             {topIps.map((item) => (
               <li key={item.ip} className="geo-item">
                 <span className="geo-country">{item.ip}</span>
-                <div className="geo-bar-wrap">
-                  <div className="geo-bar" style={{ width: `${item.percentage}%` }} />
-                </div>
+                <div className="geo-bar-wrap"><div className="geo-bar" style={{ width: `${item.percentage}%` }} /></div>
                 <span className="geo-pct">{item.hit_count} 次</span>
                 <span className="geo-pct geo-pct--dim">{item.percentage}%</span>
               </li>
             ))}
           </ul>
-        ) : (
-          <div className="empty-msg">暂无价格接口调用记录</div>
-        )}
+        ) : <div className="empty-msg">暂无价格接口调用记录</div>}
       </section>
+
+      {/* 爬虫统计卡片 */}
+      <div className="section-title" style={{ margin: '20px 0 10px' }}>爬虫统计</div>
+      <div className="stat-grid stat-grid--5">
+        <div className="stat-card">
+          <div className="stat-label">总爬取任务</div>
+          <div className="stat-value neon-cyan">{ov ? ov.total_jobs : '—'}</div>
+        </div>
+        <div className="stat-card">
+          <div className="stat-label">成功 / 失败</div>
+          <div className="stat-value stat-value--sm">
+            <span className="neon-green">{ov ? ov.success_jobs : '—'}</span>
+            <span style={{ color: 'var(--text-muted)' }}> / </span>
+            <span className="neon-red">{ov ? ov.failed_jobs : '—'}</span>
+          </div>
+        </div>
+        <div className="stat-card">
+          <div className="stat-label">成功率</div>
+          <div className="stat-value neon-green">{ov ? `${successRate}%` : '—'}</div>
+        </div>
+        <div className="stat-card">
+          <div className="stat-label">爬取模型数</div>
+          <div className="stat-value neon-cyan">{ov ? ov.total_models_scraped : '—'}</div>
+        </div>
+        <div className="stat-card">
+          <div className="stat-label">最近爬取</div>
+          <div className="stat-value stat-value--xs">
+            {ov?.last_scraped_at ? new Date(ov.last_scraped_at).toLocaleString() : '—'}
+          </div>
+        </div>
+      </div>
+
+      {/* 近30天折线图 */}
+      <div className="dash-panel">
+        <div className="section-title">近30天每日爬取次数</div>
+        <LineChart data={scrapeStats?.daily_counts ?? []} />
+      </div>
+
+      <div className="dash-row">
+        {/* 模型排行 */}
+        <div className="dash-panel dash-panel--half">
+          <div className="section-title">模型爬取次数 Top 15</div>
+          <div className="bar-list">
+            {scrapeStats?.model_ranks.length ? scrapeStats.model_ranks.map((r, i) => (
+              <div key={i} className="bar-row">
+                <span className="bar-label" title={r.model_name}>{r.model_name}</span>
+                <div className="bar-track">
+                  <div className="bar-fill" style={{ width: `${(r.count / maxRank) * 100}%` }} />
+                </div>
+                <span className="bar-count">{r.count}</span>
+              </div>
+            )) : <div className="empty-msg">暂无数据</div>}
+          </div>
+        </div>
+
+        {/* 最近任务 */}
+        <div className="dash-panel dash-panel--half">
+          <div className="section-title">最近任务</div>
+          <table className="dash-table">
+            <thead><tr><th>任务 ID</th><th>状态</th><th>模型数</th><th>时间</th></tr></thead>
+            <tbody>
+              {scrapeStats?.recent_jobs.map(j => (
+                <tr key={j.job_id}>
+                  <td className="neon-cyan">{j.job_id.slice(0, 8)}…</td>
+                  <td className={`status-${j.status}`}>{STATUS_MAP[j.status] ?? j.status}</td>
+                  <td>{j.model_count}</td>
+                  <td className="td-time">{new Date(j.created_at).toLocaleString()}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      </div>
     </div>
   );
 }

+ 90 - 0
frontend/src/pages/Login.css

@@ -0,0 +1,90 @@
+.login-page {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-base, #0a0e17);
+}
+
+.login-card {
+  width: 360px;
+  background: var(--bg-card, #0d1117);
+  border: 1px solid var(--bg-border, #1e2a3a);
+  border-radius: 8px;
+  padding: 40px 32px;
+  display: flex;
+  flex-direction: column;
+  gap: 28px;
+}
+
+.login-logo {
+  font-size: 20px;
+  color: var(--neon-cyan, #00d4ff);
+  letter-spacing: 0.12em;
+  text-align: center;
+}
+
+.login-form {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.login-field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.login-label {
+  font-size: 12px;
+  color: var(--text-muted, #6b7a8d);
+  letter-spacing: 0.06em;
+}
+
+.login-input {
+  background: var(--bg-base, #0a0e17);
+  border: 1px solid var(--bg-border, #1e2a3a);
+  border-radius: 4px;
+  color: var(--text-primary, #e0e6f0);
+  font-size: 13px;
+  padding: 10px 12px;
+  outline: none;
+  transition: border-color 0.2s;
+}
+
+.login-input:focus {
+  border-color: var(--neon-cyan, #00d4ff);
+}
+
+.login-error {
+  font-size: 12px;
+  color: var(--neon-red, #ff4466);
+  background: rgba(255, 68, 102, 0.08);
+  border: 1px solid var(--neon-red, #ff4466);
+  border-radius: 4px;
+  padding: 8px 10px;
+}
+
+.login-btn {
+  background: transparent;
+  border: 1px solid var(--neon-cyan, #00d4ff);
+  color: var(--neon-cyan, #00d4ff);
+  padding: 11px;
+  font-size: 13px;
+  letter-spacing: 0.1em;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: background 0.2s, box-shadow 0.2s;
+  margin-top: 4px;
+}
+
+.login-btn:hover:not(:disabled) {
+  background: rgba(0, 212, 255, 0.1);
+  box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
+}
+
+.login-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 65 - 0
frontend/src/pages/Login.tsx

@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { login } from '../api';
+import './Login.css';
+
+export function Login() {
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const navigate = useNavigate();
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+    setLoading(true);
+    try {
+      const token = await login(username, password);
+      localStorage.setItem('token', token);
+      navigate('/');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : '登录失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="login-page">
+      <div className="login-card">
+        <div className="login-logo">◈ 爬虫监控</div>
+        <form className="login-form" onSubmit={handleSubmit}>
+          <div className="login-field">
+            <label className="login-label">账户名</label>
+            <input
+              className="login-input"
+              type="text"
+              value={username}
+              onChange={e => setUsername(e.target.value)}
+              placeholder="请输入账户名"
+              autoComplete="username"
+              required
+            />
+          </div>
+          <div className="login-field">
+            <label className="login-label">密码</label>
+            <input
+              className="login-input"
+              type="password"
+              value={password}
+              onChange={e => setPassword(e.target.value)}
+              placeholder="请输入密码"
+              autoComplete="current-password"
+              required
+            />
+          </div>
+          {error && <div className="login-error">{error}</div>}
+          <button className="login-btn" type="submit" disabled={loading}>
+            {loading ? '登录中…' : '登录'}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 184 - 0
frontend/src/pages/ScrapeDashboard.css

@@ -0,0 +1,184 @@
+.sd-page {
+  padding: 20px 24px;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  min-height: 100vh;
+  box-sizing: border-box;
+}
+
+.sd-title {
+  font-size: 14px;
+  color: var(--text-muted);
+  letter-spacing: 0.12em;
+}
+
+.sd-loading {
+  color: var(--text-muted);
+  font-size: 13px;
+  padding: 40px;
+  text-align: center;
+}
+
+/* 统计卡片 */
+.sd-cards {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 12px;
+}
+
+.sd-card {
+  background: var(--bg-card);
+  border: 1px solid var(--bg-border);
+  border-radius: 6px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.sd-card-label {
+  font-size: 11px;
+  color: var(--text-muted);
+  letter-spacing: 0.06em;
+}
+
+.sd-card-value {
+  font-size: 22px;
+  font-weight: bold;
+  letter-spacing: 0.04em;
+}
+
+.sd-card-value--sm {
+  font-size: 12px;
+  color: var(--text-primary);
+  font-weight: normal;
+}
+
+.sd-slash { color: var(--text-muted); font-size: 16px; }
+
+.neon-cyan  { color: var(--neon-cyan, #00d4ff); }
+.neon-green { color: var(--neon-green, #00ff88); }
+.neon-red   { color: var(--neon-red, #ff4466); }
+
+/* 主网格 */
+.sd-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+.sd-panel {
+  background: var(--bg-card);
+  border: 1px solid var(--bg-border);
+  border-radius: 6px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.sd-panel--wide {
+  grid-column: 1 / -1;
+}
+
+.sd-panel-title {
+  font-size: 11px;
+  color: var(--text-muted);
+  letter-spacing: 0.08em;
+}
+
+/* 折线图 */
+.line-chart {
+  width: 100%;
+  height: 120px;
+  display: block;
+}
+
+.chart-empty {
+  color: var(--text-muted);
+  font-size: 12px;
+  text-align: center;
+  padding: 20px 0;
+}
+
+/* 条形图 */
+.bar-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow-y: auto;
+  max-height: 300px;
+}
+
+.bar-row {
+  display: grid;
+  grid-template-columns: 140px 1fr 36px;
+  align-items: center;
+  gap: 8px;
+  font-size: 11px;
+}
+
+.bar-label {
+  color: var(--text-muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bar-track {
+  background: var(--bg-border);
+  border-radius: 2px;
+  height: 6px;
+  overflow: hidden;
+}
+
+.bar-fill {
+  height: 100%;
+  background: var(--neon-cyan, #00d4ff);
+  border-radius: 2px;
+  transition: width 0.4s ease;
+}
+
+.bar-count {
+  color: var(--neon-cyan, #00d4ff);
+  text-align: right;
+  font-size: 11px;
+}
+
+/* 最近任务表格 */
+.sd-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 11px;
+}
+
+.sd-table th {
+  color: var(--text-muted);
+  font-weight: normal;
+  text-align: left;
+  padding: 4px 8px 8px 0;
+  border-bottom: 1px solid var(--bg-border);
+  letter-spacing: 0.06em;
+}
+
+.sd-table td {
+  padding: 6px 8px 6px 0;
+  border-bottom: 1px solid var(--bg-border);
+  color: var(--text-primary);
+}
+
+.sd-table tr:last-child td { border-bottom: none; }
+
+.td-time { color: var(--text-muted); font-size: 10px; }
+
+.status-done    { color: var(--neon-green, #00ff88); }
+.status-failed  { color: var(--neon-red, #ff4466); }
+.status-running,
+.status-pending { color: #ffaa00; }
+
+@media (max-width: 900px) {
+  .sd-cards { grid-template-columns: repeat(2, 1fr); }
+  .sd-grid  { grid-template-columns: 1fr; }
+  .sd-panel--wide { grid-column: auto; }
+}

+ 171 - 0
frontend/src/pages/ScrapeDashboard.tsx

@@ -0,0 +1,171 @@
+import { useEffect, useState } from 'react';
+import { fetchScrapeStats, type ScrapeStats } from '../api';
+import './ScrapeDashboard.css';
+
+// 纯 SVG 折线图
+function LineChart({ data }: { data: { date: string; count: number }[] }) {
+  if (data.length === 0) return <div className="chart-empty">暂无数据</div>;
+
+  const W = 600, H = 120, PAD = { top: 10, right: 16, bottom: 28, left: 32 };
+  const innerW = W - PAD.left - PAD.right;
+  const innerH = H - PAD.top - PAD.bottom;
+  const maxVal = Math.max(...data.map(d => d.count), 1);
+
+  const x = (i: number) => PAD.left + (i / (data.length - 1 || 1)) * innerW;
+  const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
+
+  const points = data.map((d, i) => `${x(i)},${y(d.count)}`).join(' ');
+  const area = `M${x(0)},${y(0)} ` +
+    data.map((d, i) => `L${x(i)},${y(d.count)}`).join(' ') +
+    ` L${x(data.length - 1)},${PAD.top + innerH} L${x(0)},${PAD.top + innerH} Z`;
+
+  // 只显示首尾和中间几个日期标签
+  const labelIdxs = new Set([0, Math.floor(data.length / 2), data.length - 1]);
+
+  return (
+    <svg viewBox={`0 0 ${W} ${H}`} className="line-chart" preserveAspectRatio="none">
+      {/* 网格线 */}
+      {[0, 0.5, 1].map(t => (
+        <line key={t}
+          x1={PAD.left} y1={PAD.top + innerH * (1 - t)}
+          x2={PAD.left + innerW} y2={PAD.top + innerH * (1 - t)}
+          stroke="#1e2a3a" strokeWidth="1"
+        />
+      ))}
+      {/* Y轴标签 */}
+      {[0, Math.round(maxVal / 2), maxVal].map((v, i) => (
+        <text key={i} x={PAD.left - 4} y={y(v) + 4} textAnchor="end" fontSize="9" fill="#6b7a8d">{v}</text>
+      ))}
+      {/* 面积填充 */}
+      <path d={area} fill="rgba(0,212,255,0.08)" />
+      {/* 折线 */}
+      <polyline points={points} fill="none" stroke="#00d4ff" strokeWidth="1.5" />
+      {/* 数据点 */}
+      {data.map((d, i) => (
+        <circle key={i} cx={x(i)} cy={y(d.count)} r="2.5" fill="#00d4ff" />
+      ))}
+      {/* X轴日期标签 */}
+      {data.map((d, i) => labelIdxs.has(i) && (
+        <text key={i} x={x(i)} y={H - 4} textAnchor="middle" fontSize="9" fill="#6b7a8d">
+          {d.date.slice(5)}
+        </text>
+      ))}
+    </svg>
+  );
+}
+
+// 横向条形图
+function BarChart({ data }: { data: { model_name: string; count: number }[] }) {
+  if (data.length === 0) return <div className="chart-empty">暂无数据</div>;
+  const max = Math.max(...data.map(d => d.count), 1);
+  return (
+    <div className="bar-list">
+      {data.map((d, i) => (
+        <div key={i} className="bar-row">
+          <span className="bar-label" title={d.model_name}>{d.model_name}</span>
+          <div className="bar-track">
+            <div className="bar-fill" style={{ width: `${(d.count / max) * 100}%` }} />
+          </div>
+          <span className="bar-count">{d.count}</span>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+const STATUS_MAP: Record<string, string> = {
+  done: '完成', failed: '失败', running: '运行中', pending: '等待中',
+};
+
+export function ScrapeDashboard() {
+  const [data, setData] = useState<ScrapeStats | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    fetchScrapeStats().then(setData).finally(() => setLoading(false));
+  }, []);
+
+  if (loading) return <div className="sd-page"><div className="sd-loading">加载中…</div></div>;
+  if (!data) return <div className="sd-page"><div className="sd-loading">加载失败</div></div>;
+
+  const { overview, daily_counts, model_ranks, recent_jobs } = data;
+  const successRate = overview.total_jobs > 0
+    ? Math.round(overview.success_jobs / overview.total_jobs * 100) : 0;
+
+  return (
+    <div className="sd-page">
+      <div className="sd-title">◈ 爬虫仪表盘</div>
+
+      {/* 统计卡片 */}
+      <div className="sd-cards">
+        <div className="sd-card">
+          <div className="sd-card-label">总爬取任务</div>
+          <div className="sd-card-value neon-cyan">{overview.total_jobs}</div>
+        </div>
+        <div className="sd-card">
+          <div className="sd-card-label">成功 / 失败</div>
+          <div className="sd-card-value">
+            <span className="neon-green">{overview.success_jobs}</span>
+            <span className="sd-slash"> / </span>
+            <span className="neon-red">{overview.failed_jobs}</span>
+          </div>
+        </div>
+        <div className="sd-card">
+          <div className="sd-card-label">成功率</div>
+          <div className="sd-card-value neon-green">{successRate}%</div>
+        </div>
+        <div className="sd-card">
+          <div className="sd-card-label">爬取模型数</div>
+          <div className="sd-card-value neon-cyan">{overview.total_models_scraped}</div>
+        </div>
+        <div className="sd-card">
+          <div className="sd-card-label">最近爬取</div>
+          <div className="sd-card-value sd-card-value--sm">
+            {overview.last_scraped_at
+              ? new Date(overview.last_scraped_at).toLocaleString()
+              : '—'}
+          </div>
+        </div>
+      </div>
+
+      <div className="sd-grid">
+        {/* 每日爬取折线图 */}
+        <div className="sd-panel sd-panel--wide">
+          <div className="sd-panel-title">近30天每日爬取次数</div>
+          <LineChart data={daily_counts} />
+        </div>
+
+        {/* 模型爬取排行 */}
+        <div className="sd-panel">
+          <div className="sd-panel-title">模型爬取次数 Top 15</div>
+          <BarChart data={model_ranks} />
+        </div>
+
+        {/* 最近任务 */}
+        <div className="sd-panel">
+          <div className="sd-panel-title">最近任务</div>
+          <table className="sd-table">
+            <thead>
+              <tr>
+                <th>任务 ID</th>
+                <th>状态</th>
+                <th>模型数</th>
+                <th>时间</th>
+              </tr>
+            </thead>
+            <tbody>
+              {recent_jobs.map(j => (
+                <tr key={j.job_id}>
+                  <td className="neon-cyan">{j.job_id.slice(0, 8)}…</td>
+                  <td className={`status-${j.status}`}>{STATUS_MAP[j.status] ?? j.status}</td>
+                  <td>{j.model_count}</td>
+                  <td className="td-time">{new Date(j.created_at).toLocaleString()}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 73 - 9
frontend/src/pages/Scraper.css

@@ -1,16 +1,17 @@
 .scraper-page {
   padding: 16px;
+  padding-bottom: 52px;
   height: calc(100vh - 64px);
   box-sizing: border-box;
-  overflow: hidden;
+  overflow-y: auto;
 }
 
 .scraper-layout {
   display: grid;
   grid-template-columns: 280px 1fr;
   gap: 16px;
-  height: 100%;
-  overflow: hidden;
+  min-height: 100%;
+  align-items: start;
 }
 
 /* 左侧模型列表 */
@@ -22,10 +23,11 @@
   border: 1px solid var(--bg-border);
   border-radius: 4px;
   padding: 14px;
-  overflow-y: auto;
-  height: 100%;
-  min-height: 0;
   box-sizing: border-box;
+  position: sticky;
+  top: 0;
+  max-height: calc(100vh - 64px - 32px);
+  overflow-y: auto;
 }
 
 .sidebar-header {
@@ -136,6 +138,7 @@
   flex-direction: column;
   gap: 4px;
   min-height: 0;
+  max-height: 100%;
 }
 
 .model-item {
@@ -183,12 +186,10 @@
 
 /* 右侧主区域 */
 .scraper-main {
-  overflow-y: auto;
-  height: 100%;
   display: flex;
   flex-direction: column;
   gap: 12px;
-  min-height: 0;
+  min-width: 0;
 }
 
 .scraper-header {
@@ -575,3 +576,66 @@
 .kv-val { color: var(--neon-cyan); }
 
 .price-note { color: var(--text-muted); font-size: 10px; }
+
+/* 历史记录分页 */
+.pagination {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  font-size: 12px;
+  color: var(--text-muted);
+  position: fixed;
+  bottom: 0;
+  left: 64px;
+  right: 0;
+  background: var(--bg-panel, #0d1117);
+  padding: 10px 0;
+  border-top: 1px solid var(--bg-border);
+  z-index: 10;
+}
+
+.pagination-info { color: var(--text-muted); }
+
+.page-size-select {
+  background: var(--bg-card);
+  border: 1px solid var(--bg-border);
+  border-radius: 3px;
+  color: var(--text-primary);
+  font-size: 12px;
+  padding: 2px 4px;
+  outline: none;
+  cursor: pointer;
+}
+
+.page-size-select:focus { border-color: var(--neon-cyan); }
+
+.page-btn {
+  background: transparent;
+  border: 1px solid var(--bg-border);
+  color: var(--text-muted);
+  padding: 3px 10px;
+  border-radius: 3px;
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.page-btn:hover:not(:disabled) {
+  border-color: var(--neon-cyan);
+  color: var(--neon-cyan);
+}
+
+.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.page-num {
+  background: var(--neon-cyan);
+  color: #000;
+  border: 1px solid var(--neon-cyan);
+  padding: 3px 10px;
+  border-radius: 3px;
+  font-size: 12px;
+  font-weight: bold;
+  min-width: 28px;
+  text-align: center;
+}

+ 20 - 2
frontend/src/pages/Scraper.tsx

@@ -54,7 +54,7 @@ function PriceCard({ result }: { result: NonNullable<ScrapeJobDetail['results']>
             {Object.entries(rate_limits).map(([k, v]) => (
               <div key={k} className="kv-item">
                 <span className="kv-key">{k}</span>
-                <span className="kv-val">{v}</span>
+                <span className="kv-val">{v ?? '-'}</span>
               </div>
             ))}
           </div>
@@ -111,6 +111,8 @@ export function Scraper() {
   const [expandedJobs, setExpandedJobs] = useState<Record<string, ScrapeJobDetail>>({});
   const [history, setHistory] = useState<ScrapeJob[]>([]);
   const [error, setError] = useState<string | null>(null);
+  const [historyPage, setHistoryPage] = useState(1);
+  const [historyPageSize, setHistoryPageSize] = useState(10);
   const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
   const resultRef = useRef<HTMLDivElement>(null);
 
@@ -323,7 +325,7 @@ export function Scraper() {
           <section className="history-section">
             <div className="section-title">历史记录</div>
             <ul className="history-list">
-              {history.map(job => {
+              {history.slice((historyPage - 1) * historyPageSize, historyPage * historyPageSize).map(job => {
                 const expanded = expandedJobs[job.job_id];
                 const isOpen = !!expanded;
                 return (
@@ -358,6 +360,22 @@ export function Scraper() {
                 );
               })}
             </ul>
+            {/* 分页 */}
+            {(() => {
+              const totalPages = Math.ceil(history.length / historyPageSize);
+              return (
+                <div className="pagination">
+                  <span className="pagination-info">共 {history.length} 条</span>
+                  <span>每页</span>
+                  <select className="page-size-select" value={historyPageSize} onChange={e => { setHistoryPageSize(Number(e.target.value)); setHistoryPage(1); }}>
+                    {[5, 10, 20, 50].map(n => <option key={n} value={n}>{n}</option>)}
+                  </select>
+                  <button className="page-btn" disabled={historyPage <= 1} onClick={() => setHistoryPage(p => p - 1)}>上一页</button>
+                  <span className="page-num">{historyPage}</span>
+                  <button className="page-btn" disabled={historyPage >= totalPages} onClick={() => setHistoryPage(p => p + 1)}>下一页</button>
+                </div>
+              );
+            })()}
           </section>
         )}
       </main>