Selaa lähdekoodia

新加api_key和分组管理,支持不同域名给不同的模型配置不同的折扣

lxylxy123321 1 kuukausi sitten
vanhempi
sitoutus
664e584e0b
38 muutettua tiedostoa jossa 3867 lisäystä ja 85 poistoa
  1. 10 7
      backend/.env
  2. 3 0
      backend/.env.example
  3. 2 0
      backend/app/config.py
  4. 23 7
      backend/app/db.py
  5. 12 0
      backend/app/main.py
  6. 127 0
      backend/app/routers/api_keys.py
  7. 153 0
      backend/app/routers/domain_model_prices.py
  8. 125 0
      backend/app/routers/model_groups.py
  9. 90 5
      backend/app/routers/models.py
  10. 49 11
      backend/app/routers/public.py
  11. 19 1
      backend/app/routers/scrape.py
  12. 16 2
      backend/app/services/scraper.py
  13. 168 0
      backend/app/utils/apikey_crypto.py
  14. 13 2
      backend/crawl/main.py
  15. 7 2
      backend/crawl/scrape_aliyun_models.py
  16. 7 0
      backend/main.py
  17. 17 0
      backend/migrations/013_domain_model_prices.sql
  18. 5 0
      backend/migrations/014_model_api_key.sql
  19. 11 0
      backend/migrations/015_api_keys.sql
  20. 18 0
      backend/migrations/016_model_api_key_refactor.sql
  21. 15 0
      backend/migrations/017_model_groups.sql
  22. 187 0
      backend/tests/test_apikey_crypto.py
  23. 315 0
      docs/apikey-decrypt.md
  24. 2 2
      frontend/.env
  25. 8 0
      frontend/src/App.tsx
  26. 166 3
      frontend/src/api.ts
  27. 3 0
      frontend/src/components/BottomNav.tsx
  28. 282 0
      frontend/src/pages/ApiKeys.css
  29. 187 0
      frontend/src/pages/ApiKeys.tsx
  30. 25 0
      frontend/src/pages/Discounts.css
  31. 14 4
      frontend/src/pages/Discounts.tsx
  32. 303 0
      frontend/src/pages/DomainPrices.css
  33. 233 0
      frontend/src/pages/DomainPrices.tsx
  34. 160 0
      frontend/src/pages/ModelGroups.tsx
  35. 629 0
      frontend/src/pages/Models.css
  36. 430 0
      frontend/src/pages/Models.tsx
  37. 23 0
      frontend/src/pages/Scraper.css
  38. 10 39
      frontend/src/pages/Scraper.tsx

+ 10 - 7
backend/.env

@@ -3,17 +3,20 @@ PORT=8000
 
 
 DB_HOST=8.156.90.138
 DB_HOST=8.156.90.138
 DB_PORT=5432
 DB_PORT=5432
-DB_USER=crawl
+DB_USER=crawl_test
 DB_PASSWORD=wsNbzdnmPnpwCj56
 DB_PASSWORD=wsNbzdnmPnpwCj56
-DB_NAME=crawl
+DB_NAME=crawl_test
 
 
-# ALLOWED_ORIGINS=http://localhost:5173
-ALLOWED_ORIGINS=https://crawler.aitoolcore.com
+ALLOWED_ORIGINS=http://localhost:5173
+# ALLOWED_ORIGINS=https://crawler.aitoolcore.com
 GEOIP_DB_PATH=./GeoLite2-City.mmdb
 GEOIP_DB_PATH=./GeoLite2-City.mmdb
 #本地
 #本地
-# PLAYWRIGHT_EXECUTABLE=D:\playwright-browsers\chromium-1208\chrome-win64\chrome.exe
-#生产
-PLAYWRIGHT_EXECUTABLE=/www/wwwroot/playwright/chromium-1045/chrome-linux/chrome
+PLAYWRIGHT_EXECUTABLE=D:\playwright-browsers\chromium-1208\chrome-win64\chrome.exe
+# #生产
+# PLAYWRIGHT_EXECUTABLE=/www/wwwroot/playwright/chromium-1045/chrome-linux/chrome
 PLAYWRIGHT_HEADLESS=true
 PLAYWRIGHT_HEADLESS=true
 
 
 TZ_OFFSET_HOURS=8
 TZ_OFFSET_HOURS=8
+
+# API Key 加密密钥(任意字符串,越长越安全,建议 32 位以上)
+APIKEY_ENCRYPT_KEY=25e9e87b18cf40d0ed0f102b8d2ec3a8

+ 3 - 0
backend/.env.example

@@ -15,6 +15,9 @@ PLAYWRIGHT_HEADLESS=true
 # 定时爬取时区偏移(小时),用于将用户设置的本地时间转换为 UTC,默认 8(UTC+8)
 # 定时爬取时区偏移(小时),用于将用户设置的本地时间转换为 UTC,默认 8(UTC+8)
 TZ_OFFSET_HOURS=8
 TZ_OFFSET_HOURS=8
 
 
+# API Key 加密密钥(任意字符串,越长越安全,建议 32 位以上)
+APIKEY_ENCRYPT_KEY=your_secret_key_here
+
 # Linux 生产环境 Chrome 额外启动参数(逗号分隔)
 # Linux 生产环境 Chrome 额外启动参数(逗号分隔)
 # 解决 crashpad 崩溃问题:禁用 crash reporter,并指定 crash dumps 目录
 # 解决 crashpad 崩溃问题:禁用 crash reporter,并指定 crash dumps 目录
 # PLAYWRIGHT_EXTRA_ARGS=--disable-crash-reporter,--crash-dumps-dir=/tmp
 # PLAYWRIGHT_EXTRA_ARGS=--disable-crash-reporter,--crash-dumps-dir=/tmp

+ 2 - 0
backend/app/config.py

@@ -36,5 +36,7 @@ class Settings:
         self.host = os.getenv("HOST", "0.0.0.0")
         self.host = os.getenv("HOST", "0.0.0.0")
         self.port = int(os.getenv("PORT", "8000"))
         self.port = int(os.getenv("PORT", "8000"))
 
 
+        self.apikey_encrypt_key = os.getenv("APIKEY_ENCRYPT_KEY", "")
+
 
 
 settings = Settings()
 settings = Settings()

+ 23 - 7
backend/app/db.py

@@ -1,25 +1,40 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import logging
 from typing import Optional
 from typing import Optional
 
 
 import asyncpg
 import asyncpg
 
 
 from app.config import settings
 from app.config import settings
 
 
+logger = logging.getLogger(__name__)
+
 _pool: Optional[asyncpg.Pool] = None
 _pool: Optional[asyncpg.Pool] = None
 
 
 
 
 async def init_pool() -> None:
 async def init_pool() -> None:
     """Create the global asyncpg connection pool."""
     """Create the global asyncpg connection pool."""
     global _pool
     global _pool
-    _pool = await asyncpg.create_pool(
-        host=settings.db_host,
-        port=settings.db_port,
-        user=settings.db_user,
-        password=settings.db_password,
-        database=settings.db_name,
-        server_settings={"search_path": "crawl"},
+    logger.info(
+        "Connecting to database %s@%s:%s/%s ...",
+        settings.db_user,
+        settings.db_host,
+        settings.db_port,
+        settings.db_name,
     )
     )
+    try:
+        _pool = await asyncpg.create_pool(
+            host=settings.db_host,
+            port=settings.db_port,
+            user=settings.db_user,
+            password=settings.db_password,
+            database=settings.db_name,
+            server_settings={"search_path": "crawl"},
+        )
+        logger.info("Database connection pool created successfully.")
+    except Exception as exc:
+        logger.error("Failed to connect to database: %s", exc)
+        raise
 
 
 
 
 async def close_pool() -> None:
 async def close_pool() -> None:
@@ -28,6 +43,7 @@ async def close_pool() -> None:
     if _pool is not None:
     if _pool is not None:
         await _pool.close()
         await _pool.close()
         _pool = None
         _pool = None
+        logger.info("Database connection pool closed.")
 
 
 
 
 def get_pool() -> asyncpg.Pool:
 def get_pool() -> asyncpg.Pool:

+ 12 - 0
backend/app/main.py

@@ -1,8 +1,14 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import logging
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from typing import AsyncGenerator
 from typing import AsyncGenerator
 
 
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(levelname)s:     %(name)s - %(message)s",
+)
+
 from fastapi import FastAPI
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 
 
@@ -65,6 +71,9 @@ from app.routers import schedule  # noqa: E402
 from app.routers import discounts  # noqa: E402
 from app.routers import discounts  # noqa: E402
 from app.routers import auth  # noqa: E402
 from app.routers import auth  # noqa: E402
 from app.routers import scrape_stats  # noqa: E402
 from app.routers import scrape_stats  # noqa: E402
+from app.routers import domain_model_prices  # noqa: E402
+from app.routers import api_keys  # noqa: E402
+from app.routers import model_groups  # noqa: E402
 app.include_router(stats.router, prefix="/api")
 app.include_router(stats.router, prefix="/api")
 app.include_router(logs.router, prefix="/api")
 app.include_router(logs.router, prefix="/api")
 app.include_router(scrape.router, prefix="/api")
 app.include_router(scrape.router, prefix="/api")
@@ -74,6 +83,9 @@ app.include_router(schedule.router, prefix="/api")
 app.include_router(discounts.router, prefix="/api")
 app.include_router(discounts.router, prefix="/api")
 app.include_router(auth.router, prefix="/api")
 app.include_router(auth.router, prefix="/api")
 app.include_router(scrape_stats.router, prefix="/api")
 app.include_router(scrape_stats.router, prefix="/api")
+app.include_router(domain_model_prices.router, prefix="/api")
+app.include_router(api_keys.router, prefix="/api")
+app.include_router(model_groups.router, prefix="/api")
 app.include_router(ws.router)
 app.include_router(ws.router)
 
 
 
 

+ 127 - 0
backend/app/routers/api_keys.py

@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel
+
+from app.db import get_pool
+from app.routers.models import _bump_all_domain_versions
+
+router = APIRouter(tags=["api_keys"])
+
+
+class ApiKeyIn(BaseModel):
+    name: str
+    key_value: str
+    note: Optional[str] = None
+
+
+class ApiKeyUpdate(BaseModel):
+    name: Optional[str] = None
+    key_value: Optional[str] = None
+    note: Optional[str] = None
+
+
+class ApiKeyOut(BaseModel):
+    id: int
+    name: str
+    key_value: str
+    note: Optional[str]
+    created_at: datetime
+    updated_at: datetime
+
+
+class BatchAssignIn(BaseModel):
+    """批量将某个 api_key 绑定到一批模型"""
+    api_key_id: Optional[int] = None   # None 表示清除绑定
+    model_ids: List[int]
+
+
+@router.get("/api-keys", response_model=List[ApiKeyOut])
+async def list_api_keys() -> List[ApiKeyOut]:
+    pool = get_pool()
+    rows = await pool.fetch(
+        "SELECT id, name, key_value, note, created_at, updated_at FROM api_keys ORDER BY created_at DESC"
+    )
+    return [ApiKeyOut(**dict(r)) for r in rows]
+
+
+@router.post("/api-keys", response_model=ApiKeyOut, status_code=201)
+async def create_api_key(body: ApiKeyIn) -> ApiKeyOut:
+    pool = get_pool()
+    row = await pool.fetchrow(
+        """
+        INSERT INTO api_keys (name, key_value, note)
+        VALUES ($1, $2, $3)
+        RETURNING id, name, key_value, note, created_at, updated_at
+        """,
+        body.name, body.key_value, body.note,
+    )
+    return ApiKeyOut(**dict(row))
+
+
+@router.put("/api-keys/{key_id}", response_model=ApiKeyOut)
+async def update_api_key(key_id: int, body: ApiKeyUpdate) -> ApiKeyOut:
+    pool = get_pool()
+    existing = await pool.fetchrow(
+        "SELECT id, name, key_value, note, created_at, updated_at FROM api_keys WHERE id = $1",
+        key_id,
+    )
+    if existing is None:
+        raise HTTPException(status_code=404, detail="API Key 不存在")
+    new_name = body.name if body.name is not None else existing["name"]
+    new_value = body.key_value if body.key_value is not None else existing["key_value"]
+    new_note = body.note if body.note is not None else existing["note"]
+    key_value_changed = new_value != existing["key_value"]
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            row = await conn.fetchrow(
+                """
+                UPDATE api_keys SET name = $1, key_value = $2, note = $3, updated_at = NOW()
+                WHERE id = $4
+                RETURNING id, name, key_value, note, created_at, updated_at
+                """,
+                new_name, new_value, new_note, key_id,
+            )
+            # key 值本身变了 → 通知所有绑定了此 key 的客户端
+            if key_value_changed:
+                await _bump_all_domain_versions(conn)
+    return ApiKeyOut(**dict(row))
+
+
+@router.delete("/api-keys/{key_id}", status_code=204, response_model=None)
+async def delete_api_key(key_id: int) -> Response:
+    pool = get_pool()
+    result = await pool.execute("DELETE FROM api_keys WHERE id = $1", key_id)
+    if result == "DELETE 0":
+        raise HTTPException(status_code=404, detail="API Key 不存在")
+    return Response(status_code=204)
+
+
+@router.post("/api-keys/batch-assign", status_code=200)
+async def batch_assign_api_key(body: BatchAssignIn) -> dict:
+    """批量将指定 api_key 绑定到一批模型(api_key_id=None 则清除绑定)"""
+    if not body.model_ids:
+        return {"updated": 0}
+
+    pool = get_pool()
+
+    if body.api_key_id is not None:
+        key_row = await pool.fetchrow("SELECT id FROM api_keys WHERE id = $1", body.api_key_id)
+        if key_row is None:
+            raise HTTPException(status_code=404, detail="API Key 不存在")
+
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            result = await conn.execute(
+                "UPDATE models SET api_key_id = $1 WHERE id = ANY($2::bigint[])",
+                body.api_key_id, body.model_ids,
+            )
+            updated = int(result.split()[-1]) if result else 0
+            if updated > 0:
+                await _bump_all_domain_versions(conn)
+
+    return {"updated": updated}

+ 153 - 0
backend/app/routers/domain_model_prices.py

@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+
+from app.db import get_pool
+
+router = APIRouter(tags=["domain_model_prices"])
+
+
+# ── Pydantic models ──────────────────────────────────────────────────────────
+
+class DomainModelPriceIn(BaseModel):
+    model_name: str
+    discount: float = Field(..., gt=0, le=1, description="折扣系数,如 0.8 表示八折")
+    note: Optional[str] = None
+
+
+class DomainModelPriceOut(BaseModel):
+    id: int
+    domain: str
+    model_name: str
+    discount: float
+    note: Optional[str]
+    created_at: datetime
+    updated_at: datetime
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+async def _bump_domain_version(conn, domain: str) -> None:
+    await conn.execute(
+        """
+        INSERT INTO domain_version (domain, version, updated_at)
+        VALUES ($1, 1, NOW())
+        ON CONFLICT (domain) DO UPDATE
+            SET version = domain_version.version + 1,
+                updated_at = NOW()
+        """,
+        domain,
+    )
+
+
+# ── Routes ───────────────────────────────────────────────────────────────────
+
+@router.get("/discounts/{domain}/model-prices", response_model=List[DomainModelPriceOut])
+async def list_domain_model_prices(domain: str) -> List[DomainModelPriceOut]:
+    """列出某域名下所有已配置的模型折扣。"""
+    pool = get_pool()
+    rows = await pool.fetch(
+        "SELECT * FROM domain_model_prices WHERE domain = $1 ORDER BY model_name",
+        domain,
+    )
+    return [DomainModelPriceOut(**dict(r)) for r in rows]
+
+
+@router.put("/discounts/{domain}/model-prices/{model_name}", response_model=DomainModelPriceOut)
+async def upsert_domain_model_price(
+    domain: str,
+    model_name: str,
+    body: DomainModelPriceIn,
+) -> DomainModelPriceOut:
+    """新增或更新某域名下某模型的折扣系数。"""
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            row = await conn.fetchrow(
+                """
+                INSERT INTO domain_model_prices (domain, model_name, discount, note)
+                VALUES ($1, $2, $3, $4)
+                ON CONFLICT (domain, model_name) DO UPDATE
+                    SET discount   = EXCLUDED.discount,
+                        note       = EXCLUDED.note,
+                        updated_at = NOW()
+                RETURNING *
+                """,
+                domain,
+                model_name,
+                body.discount,
+                body.note,
+            )
+            await _bump_domain_version(conn, domain)
+    return DomainModelPriceOut(**dict(row))
+
+
+@router.delete(
+    "/discounts/{domain}/model-prices/{model_name}",
+    status_code=204,
+    response_model=None,
+)
+async def delete_domain_model_price(domain: str, model_name: str) -> Response:
+    """删除某域名下某模型的折扣配置。"""
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            result = await conn.execute(
+                "DELETE FROM domain_model_prices WHERE domain = $1 AND model_name = $2",
+                domain,
+                model_name,
+            )
+            if result == "DELETE 0":
+                raise HTTPException(status_code=404, detail="配置不存在")
+            await _bump_domain_version(conn, domain)
+    return Response(status_code=204)
+
+
+# ── Batch upsert ─────────────────────────────────────────────────────────────
+
+class BatchItem(BaseModel):
+    model_name: str
+    discount: float = Field(..., gt=0, le=1)
+    note: Optional[str] = None
+
+
+class BatchIn(BaseModel):
+    items: List[BatchItem]
+
+
+@router.put("/discounts/{domain}/model-prices", response_model=List[DomainModelPriceOut])
+async def batch_upsert_domain_model_prices(
+    domain: str,
+    body: BatchIn,
+) -> List[DomainModelPriceOut]:
+    """批量新增或更新某域名下多个模型的折扣系数。"""
+    if not body.items:
+        return []
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            rows = []
+            for item in body.items:
+                row = await conn.fetchrow(
+                    """
+                    INSERT INTO domain_model_prices (domain, model_name, discount, note)
+                    VALUES ($1, $2, $3, $4)
+                    ON CONFLICT (domain, model_name) DO UPDATE
+                        SET discount   = EXCLUDED.discount,
+                            note       = EXCLUDED.note,
+                            updated_at = NOW()
+                    RETURNING *
+                    """,
+                    domain,
+                    item.model_name,
+                    item.discount,
+                    item.note,
+                )
+                rows.append(DomainModelPriceOut(**dict(row)))
+            await _bump_domain_version(conn, domain)
+    return rows

+ 125 - 0
backend/app/routers/model_groups.py

@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel
+
+from app.db import get_pool
+from app.routers.models import _bump_all_domain_versions
+
+router = APIRouter(tags=["model_groups"])
+
+
+class GroupIn(BaseModel):
+    name: str
+    note: Optional[str] = None
+
+
+class GroupUpdate(BaseModel):
+    name: Optional[str] = None
+    note: Optional[str] = None
+
+
+class GroupOut(BaseModel):
+    id: int
+    name: str
+    note: Optional[str]
+    model_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+
+class BatchAssignGroupIn(BaseModel):
+    group_id: Optional[int] = None   # None 表示清除分组
+    model_ids: List[int]
+
+
+@router.get("/model-groups", response_model=List[GroupOut])
+async def list_groups() -> List[GroupOut]:
+    pool = get_pool()
+    rows = await pool.fetch(
+        """
+        SELECT g.id, g.name, g.note, g.created_at, g.updated_at,
+               COUNT(m.id) AS model_count
+        FROM model_groups g
+        LEFT JOIN models m ON m.group_id = g.id
+        GROUP BY g.id
+        ORDER BY g.created_at ASC
+        """
+    )
+    return [GroupOut(**dict(r)) for r in rows]
+
+
+@router.post("/model-groups", response_model=GroupOut, status_code=201)
+async def create_group(body: GroupIn) -> GroupOut:
+    pool = get_pool()
+    row = await pool.fetchrow(
+        """
+        INSERT INTO model_groups (name, note)
+        VALUES ($1, $2)
+        RETURNING id, name, note, created_at, updated_at
+        """,
+        body.name, body.note,
+    )
+    return GroupOut(**dict(row), model_count=0)
+
+
+@router.put("/model-groups/{group_id}", response_model=GroupOut)
+async def update_group(group_id: int, body: GroupUpdate) -> GroupOut:
+    pool = get_pool()
+    existing = await pool.fetchrow(
+        "SELECT id, name, note, created_at, updated_at FROM model_groups WHERE id = $1",
+        group_id,
+    )
+    if existing is None:
+        raise HTTPException(status_code=404, detail="分组不存在")
+    new_name = body.name if body.name is not None else existing["name"]
+    new_note = body.note if body.note is not None else existing["note"]
+    row = await pool.fetchrow(
+        """
+        UPDATE model_groups SET name = $1, note = $2, updated_at = NOW()
+        WHERE id = $3
+        RETURNING id, name, note, created_at, updated_at
+        """,
+        new_name, new_note, group_id,
+    )
+    count = await pool.fetchval("SELECT COUNT(*) FROM models WHERE group_id = $1", group_id)
+    return GroupOut(**dict(row), model_count=count or 0)
+
+
+@router.delete("/model-groups/{group_id}", status_code=204, response_model=None)
+async def delete_group(group_id: int) -> Response:
+    pool = get_pool()
+    # 删除分组时,将该分组下的模型 group_id 置为 NULL
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            await conn.execute("UPDATE models SET group_id = NULL WHERE group_id = $1", group_id)
+            result = await conn.execute("DELETE FROM model_groups WHERE id = $1", group_id)
+    if result == "DELETE 0":
+        raise HTTPException(status_code=404, detail="分组不存在")
+    return Response(status_code=204)
+
+
+@router.post("/model-groups/batch-assign", status_code=200)
+async def batch_assign_group(body: BatchAssignGroupIn) -> dict:
+    """批量将模型归入某个分组(group_id=None 则清除分组)"""
+    if not body.model_ids:
+        return {"updated": 0}
+    pool = get_pool()
+    if body.group_id is not None:
+        exists = await pool.fetchval("SELECT id FROM model_groups WHERE id = $1", body.group_id)
+        if not exists:
+            raise HTTPException(status_code=404, detail="分组不存在")
+    async with pool.acquire() as conn:
+        async with conn.transaction():
+            result = await conn.execute(
+                "UPDATE models SET group_id = $1 WHERE id = ANY($2::bigint[])",
+                body.group_id, body.model_ids,
+            )
+            updated = int(result.split()[-1]) if result else 0
+            if updated > 0:
+                await _bump_all_domain_versions(conn)
+    return {"updated": updated}

+ 90 - 5
backend/app/routers/models.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from datetime import datetime
 from datetime import datetime
-from typing import List
+from typing import List, Optional
 
 
 from fastapi import APIRouter, HTTPException
 from fastapi import APIRouter, HTTPException
 from fastapi.responses import Response
 from fastapi.responses import Response
@@ -12,15 +12,38 @@ from app.db import get_pool
 router = APIRouter(tags=["models"])
 router = APIRouter(tags=["models"])
 
 
 
 
+async def _bump_all_domain_versions(conn) -> None:
+    """api_key 有变动时,所有域名的版本号 +1,让客户端感知到数据变化。"""
+    await conn.execute(
+        "UPDATE domain_version SET version = version + 1, updated_at = NOW()"
+    )
+    await conn.execute(
+        "UPDATE price_snapshot_version SET version = GREATEST(version + 1, 1), updated_at = NOW() WHERE id = 1"
+    )
+
+
 class ModelIn(BaseModel):
 class ModelIn(BaseModel):
     name: str
     name: str
     url: str
     url: str
+    api_key_id: Optional[int] = None
+    group_id: Optional[int] = None
+
+
+class ModelUpdate(BaseModel):
+    name: Optional[str] = None
+    url: Optional[str] = None
+    api_key_id: Optional[int] = None
+    group_id: Optional[int] = None
 
 
 
 
 class ModelOut(BaseModel):
 class ModelOut(BaseModel):
     id: int
     id: int
     name: str
     name: str
     url: str
     url: str
+    api_key_id: Optional[int]
+    api_key_name: Optional[str] = None
+    group_id: Optional[int] = None
+    group_name: Optional[str] = None
     created_at: datetime
     created_at: datetime
 
 
 
 
@@ -28,7 +51,16 @@ class ModelOut(BaseModel):
 async def list_models() -> List[ModelOut]:
 async def list_models() -> List[ModelOut]:
     pool = get_pool()
     pool = get_pool()
     async with pool.acquire() as conn:
     async with pool.acquire() as conn:
-        rows = await conn.fetch("SELECT id, name, url, created_at FROM models ORDER BY created_at DESC")
+        rows = await conn.fetch(
+            """
+            SELECT m.id, m.name, m.url, m.api_key_id, m.group_id, m.created_at,
+                   k.name AS api_key_name, g.name AS group_name
+            FROM models m
+            LEFT JOIN api_keys k ON k.id = m.api_key_id
+            LEFT JOIN model_groups g ON g.id = m.group_id
+            ORDER BY m.created_at DESC
+            """
+        )
     return [ModelOut(**dict(r)) for r in rows]
     return [ModelOut(**dict(r)) for r in rows]
 
 
 
 
@@ -38,12 +70,65 @@ async def create_model(body: ModelIn) -> ModelOut:
     async with pool.acquire() as conn:
     async with pool.acquire() as conn:
         try:
         try:
             row = await conn.fetchrow(
             row = await conn.fetchrow(
-                "INSERT INTO models (name, url) VALUES ($1, $2) RETURNING id, name, url, created_at",
-                body.name, body.url,
+                """
+                INSERT INTO models (name, url, api_key_id, group_id) VALUES ($1, $2, $3, $4)
+                RETURNING id, name, url, api_key_id, group_id, created_at
+                """,
+                body.name, body.url, body.api_key_id, body.group_id,
             )
             )
+            api_key_name = None
+            if row["api_key_id"]:
+                k = await conn.fetchrow("SELECT name FROM api_keys WHERE id = $1", row["api_key_id"])
+                api_key_name = k["name"] if k else None
+            group_name = None
+            if row["group_id"]:
+                g = await conn.fetchrow("SELECT name FROM model_groups WHERE id = $1", row["group_id"])
+                group_name = g["name"] if g else None
+        except Exception:
+            raise HTTPException(status_code=409, detail="该 URL 已存在")
+    return ModelOut(**dict(row), api_key_name=api_key_name, group_name=group_name)
+
+
+@router.put("/models/{model_id}", response_model=ModelOut)
+async def update_model(model_id: int, body: ModelUpdate) -> ModelOut:
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        existing = await conn.fetchrow(
+            "SELECT id, name, url, api_key_id, group_id, created_at FROM models WHERE id = $1",
+            model_id,
+        )
+        if existing is None:
+            raise HTTPException(status_code=404, detail="模型不存在")
+        new_name      = body.name       if body.name       is not None else existing["name"]
+        new_url       = body.url        if body.url        is not None else existing["url"]
+        new_api_key_id = body.api_key_id if body.api_key_id is not None else existing["api_key_id"]
+        new_group_id  = body.group_id   if body.group_id   is not None else existing["group_id"]
+        api_key_changed = new_api_key_id != existing["api_key_id"]
+        try:
+            async with conn.transaction():
+                row = await conn.fetchrow(
+                    """
+                    UPDATE models SET name = $1, url = $2, api_key_id = $3, group_id = $4
+                    WHERE id = $5
+                    RETURNING id, name, url, api_key_id, group_id, created_at
+                    """,
+                    new_name, new_url, new_api_key_id, new_group_id, model_id,
+                )
+                if api_key_changed:
+                    await _bump_all_domain_versions(conn)
+                api_key_name = None
+                if row["api_key_id"]:
+                    k = await conn.fetchrow("SELECT name FROM api_keys WHERE id = $1", row["api_key_id"])
+                    api_key_name = k["name"] if k else None
+                group_name = None
+                if row["group_id"]:
+                    g = await conn.fetchrow("SELECT name FROM model_groups WHERE id = $1", row["group_id"])
+                    group_name = g["name"] if g else None
+        except HTTPException:
+            raise
         except Exception:
         except Exception:
             raise HTTPException(status_code=409, detail="该 URL 已存在")
             raise HTTPException(status_code=409, detail="该 URL 已存在")
-    return ModelOut(**dict(row))
+    return ModelOut(**dict(row), api_key_name=api_key_name, group_name=group_name)
 
 
 
 
 @router.delete("/models/{model_id}", status_code=204, response_model=None)
 @router.delete("/models/{model_id}", status_code=204, response_model=None)

+ 49 - 11
backend/app/routers/public.py

@@ -6,6 +6,7 @@ from urllib.parse import urlparse
 
 
 import json
 import json
 from app.utils.price_parser import parse_prices
 from app.utils.price_parser import parse_prices
+from app.utils.apikey_crypto import try_encrypt
 
 
 from fastapi import APIRouter, HTTPException, Request
 from fastapi import APIRouter, HTTPException, Request
 from pydantic import BaseModel
 from pydantic import BaseModel
@@ -25,6 +26,9 @@ class PublicPriceOut(BaseModel):
     tool_prices: Optional[list] = None
     tool_prices: Optional[list] = None
     icon: Optional[str] = None
     icon: Optional[str] = None
     scraped_at: datetime
     scraped_at: datetime
+    discount: Optional[float] = None
+    api_key: Optional[str] = None
+    group_name: Optional[str] = None
 
 
 
 
 class ParsedPriceItem(BaseModel):
 class ParsedPriceItem(BaseModel):
@@ -51,7 +55,7 @@ class DiscountedPriceItem(BaseModel):
     currency: str = "CNY"
     currency: str = "CNY"
     unit: Optional[str] = None
     unit: Optional[str] = None
     label: Optional[str] = None
     label: Optional[str] = None
-    discount: Optional[float] = None  # None 表示无折扣(原价)
+    discount: Optional[float] = None  # None 表示未知,1.0 表示原价
 
 
 
 
 
 
@@ -66,7 +70,6 @@ class PricesResponse(BaseModel):
     parsed_prices: List[ParsedPriceItem]
     parsed_prices: List[ParsedPriceItem]
     discounted_prices: List[DiscountedPriceItem]
     discounted_prices: List[DiscountedPriceItem]
     types: List[ModelTypeItem]
     types: List[ModelTypeItem]
-    discount: float = 1.0
 
 
 
 
 class UpToDateResponse(BaseModel):
 class UpToDateResponse(BaseModel):
@@ -114,11 +117,20 @@ async def get_public_prices(
 
 
     # 查调用方域名对应的折扣
     # 查调用方域名对应的折扣
     caller_domain = _extract_domain(referer)
     caller_domain = _extract_domain(referer)
-    discount_rate: Optional[float] = None
+    discount_rate: float = 1.0
+    # 域名级别的模型自定义价格:{model_name: {input_price, output_price}}
+    model_custom_prices: dict = {}
     if caller_domain:
     if caller_domain:
         row = await pool.fetchrow("SELECT discount FROM discounts WHERE domain = $1", caller_domain)
         row = await pool.fetchrow("SELECT discount FROM discounts WHERE domain = $1", caller_domain)
         if row:
         if row:
             discount_rate = float(row["discount"])
             discount_rate = float(row["discount"])
+        # 加载该域名下所有模型的自定义折扣
+        mp_rows = await pool.fetch(
+            "SELECT model_name, discount FROM domain_model_prices WHERE domain = $1",
+            caller_domain,
+        )
+        for mp in mp_rows:
+            model_custom_prices[mp["model_name"]] = float(mp["discount"])
 
 
     def _j(v):
     def _j(v):
         if v is None:
         if v is None:
@@ -143,14 +155,34 @@ async def get_public_prices(
     if version != 0 and version == current_version:
     if version != 0 and version == current_version:
         return UpToDateResponse(up_to_date=True, version=current_version)
         return UpToDateResponse(up_to_date=True, version=current_version)
 
 
-    # 从 price_snapshot 读取数据
+    # 从 price_snapshot 读取数据,LEFT JOIN models 取 api_key 和 group
     if url is None:
     if url is None:
         rows = await pool.fetch(
         rows = await pool.fetch(
-            "SELECT url, model_name, prices, model_info, rate_limits, tool_prices, icon, updated_at FROM price_snapshot ORDER BY url"
+            """
+            SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
+                   ps.tool_prices, ps.icon, ps.updated_at,
+                   k.key_value AS api_key,
+                   m.group_id, g.name AS group_name
+            FROM price_snapshot ps
+            LEFT JOIN models m ON m.url = ps.url
+            LEFT JOIN api_keys k ON k.id = m.api_key_id
+            LEFT JOIN model_groups g ON g.id = m.group_id
+            ORDER BY ps.url
+            """
         )
         )
     else:
     else:
         rows = await pool.fetch(
         rows = await pool.fetch(
-            "SELECT url, model_name, prices, model_info, rate_limits, tool_prices, icon, updated_at FROM price_snapshot WHERE url = $1",
+            """
+            SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
+                   ps.tool_prices, ps.icon, ps.updated_at,
+                   k.key_value AS api_key,
+                   m.group_id, g.name AS group_name
+            FROM price_snapshot ps
+            LEFT JOIN models m ON m.url = ps.url
+            LEFT JOIN api_keys k ON k.id = m.api_key_id
+            LEFT JOIN model_groups g ON g.id = m.group_id
+            WHERE ps.url = $1
+            """,
             url,
             url,
         )
         )
         if not rows:
         if not rows:
@@ -180,6 +212,9 @@ async def get_public_prices(
         tool_prices=_j(r["tool_prices"]),
         tool_prices=_j(r["tool_prices"]),
         icon=r["icon"],
         icon=r["icon"],
         scraped_at=r["updated_at"],
         scraped_at=r["updated_at"],
+        discount=model_custom_prices.get(r["model_name"]) if model_custom_prices.get(r["model_name"]) is not None else discount_rate,
+        api_key=try_encrypt(r["api_key"]),
+        group_name=r["group_name"],
     ) for r in rows]
     ) for r in rows]
 
 
     parsed_prices: List[ParsedPriceItem] = []
     parsed_prices: List[ParsedPriceItem] = []
@@ -189,12 +224,16 @@ async def get_public_prices(
         for item in parse_prices(_j(r["prices"]) or {}):
         for item in parse_prices(_j(r["prices"]) or {}):
             parsed_prices.append(ParsedPriceItem(url=r["url"], model_name=r["model_name"], **item))
             parsed_prices.append(ParsedPriceItem(url=r["url"], model_name=r["model_name"], **item))
             d_item = dict(item)
             d_item = dict(item)
-            if discount_rate is not None:
+            model_name = r["model_name"]
+            custom = model_custom_prices.get(model_name)
+            # 模型级折扣优先,没有则用域名全局折扣
+            effective_discount = custom if custom is not None else discount_rate
+            if effective_discount is not None:
                 if d_item.get("input_price") is not None:
                 if d_item.get("input_price") is not None:
-                    d_item["input_price"] = round(d_item["input_price"] * discount_rate, 6)
+                    d_item["input_price"] = round(d_item["input_price"] * effective_discount, 6)
                 if d_item.get("output_price") is not None:
                 if d_item.get("output_price") is not None:
-                    d_item["output_price"] = round(d_item["output_price"] * discount_rate, 6)
-            discounted_prices.append(DiscountedPriceItem(url=r["url"], model_name=r["model_name"], discount=discount_rate, **d_item))
+                    d_item["output_price"] = round(d_item["output_price"] * effective_discount, 6)
+            discounted_prices.append(DiscountedPriceItem(url=r["url"], model_name=r["model_name"], discount=effective_discount, **d_item))
 
 
     all_types = [
     all_types = [
         ModelTypeItem(model_name=r["model_name"], type=_extract_type(_j(r["model_info"])) or [])
         ModelTypeItem(model_name=r["model_name"], type=_extract_type(_j(r["model_info"])) or [])
@@ -207,5 +246,4 @@ async def get_public_prices(
         parsed_prices=parsed_prices,
         parsed_prices=parsed_prices,
         discounted_prices=discounted_prices,
         discounted_prices=discounted_prices,
         types=all_types,
         types=all_types,
-        discount=discount_rate if discount_rate is not None else 1.0,
     )
     )

+ 19 - 1
backend/app/routers/scrape.py

@@ -17,6 +17,8 @@ _scraper = ScraperService()
 
 
 class ScrapeRequest(BaseModel):
 class ScrapeRequest(BaseModel):
     urls: List[str]
     urls: List[str]
+    # 可选:url -> api_key 映射,爬取时传递给爬虫
+    api_keys: Optional[dict] = None
 
 
 
 
 class ScrapeJobOut(BaseModel):
 class ScrapeJobOut(BaseModel):
@@ -47,6 +49,22 @@ class ScrapeJobDetailOut(BaseModel):
 @router.post("/scrape", response_model=ScrapeJobOut, status_code=202)
 @router.post("/scrape", response_model=ScrapeJobOut, status_code=202)
 async def create_scrape_job(body: ScrapeRequest) -> ScrapeJobOut:
 async def create_scrape_job(body: ScrapeRequest) -> ScrapeJobOut:
     pool = get_pool()
     pool = get_pool()
+
+    # 如果没有传 api_keys,从数据库中自动查询(通过 api_key_id JOIN api_keys 表)
+    api_keys = body.api_keys or {}
+    if not api_keys:
+        async with pool.acquire() as conn:
+            rows = await conn.fetch(
+                """
+                SELECT m.url, k.key_value
+                FROM models m
+                JOIN api_keys k ON k.id = m.api_key_id
+                WHERE m.url = ANY($1::text[]) AND m.api_key_id IS NOT NULL
+                """,
+                body.urls,
+            )
+            api_keys = {row["url"]: row["key_value"] for row in rows}
+
     async with pool.acquire() as conn:
     async with pool.acquire() as conn:
         row = await conn.fetchrow(
         row = await conn.fetchrow(
             """
             """
@@ -58,7 +76,7 @@ async def create_scrape_job(body: ScrapeRequest) -> ScrapeJobOut:
         )
         )
 
 
     job_id = str(row["id"])
     job_id = str(row["id"])
-    asyncio.create_task(_scraper.run_job(job_id, body.urls, pool))
+    asyncio.create_task(_scraper.run_job(job_id, body.urls, pool, api_keys=api_keys))
 
 
     return ScrapeJobOut(
     return ScrapeJobOut(
         job_id=job_id,
         job_id=job_id,

+ 16 - 2
backend/app/services/scraper.py

@@ -25,8 +25,10 @@ from main import scrape_all  # noqa: E402  (backend/crawl/main.py)
 class ScraperService:
 class ScraperService:
     """Manages the lifecycle of a scrape job."""
     """Manages the lifecycle of a scrape job."""
 
 
-    async def run_job(self, job_id: str, urls: list[str], pool: Any) -> None:
+    async def run_job(self, job_id: str, urls: list[str], pool: Any, api_keys: dict[str, str] | None = None) -> None:
         loop = asyncio.get_event_loop()
         loop = asyncio.get_event_loop()
+        if api_keys is None:
+            api_keys = {}
 
 
         async with pool.acquire() as conn:
         async with pool.acquire() as conn:
             await conn.execute(
             await conn.execute(
@@ -52,15 +54,27 @@ class ScraperService:
             if existing_snapshot_urls != set(urls):
             if existing_snapshot_urls != set(urls):
                 any_changed = True
                 any_changed = True
 
 
+            # 查出 url -> name 映射,用于 model_hint
+            async with pool.acquire() as conn:
+                name_rows = await conn.fetch(
+                    "SELECT url, name FROM models WHERE url = ANY($1::text[])",
+                    urls,
+                )
+                model_names = {row["url"]: row["name"] for row in name_rows}
+
             for url in urls:
             for url in urls:
+                api_key = api_keys.get(url)
+                model_hint = model_names.get(url)
                 result: dict = await loop.run_in_executor(
                 result: dict = await loop.run_in_executor(
                     None,
                     None,
-                    lambda u=url: scrape_all(
+                    lambda u=url, k=api_key, h=model_hint: scrape_all(
                         u,
                         u,
                         headless=headless,
                         headless=headless,
                         timeout=20000,
                         timeout=20000,
                         executable_path=exec_path,
                         executable_path=exec_path,
                         modules=["info", "rate", "tool", "price", "icon"],
                         modules=["info", "rate", "tool", "price", "icon"],
+                        api_key=k,
+                        model_hint=h,
                     ),
                     ),
                 )
                 )
 
 

+ 168 - 0
backend/app/utils/apikey_crypto.py

@@ -0,0 +1,168 @@
+"""
+API Key 加密/解密工具(纯标准库,无第三方依赖)
+
+算法:XOR 密钥流 + 字节循环位移 + Base64 URL-safe 编码
+
+加密流程:
+  1. 用 SECRET_KEY 通过 SHA-256 派生密钥流(hashlib,标准库)
+  2. 明文每字节与密钥流对应字节 XOR
+  3. 每字节循环左移 (i % 5 + 1) 位
+  4. Base64 URL-safe 编码(无 = 填充)
+
+解密流程(完全对称,逆序):
+  1. Base64 URL-safe 解码
+  2. 每字节循环右移 (i % 5 + 1) 位
+  3. 与相同密钥流 XOR
+  4. UTF-8 解码
+
+解密示例(JavaScript):
+  见文件末尾注释
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import os
+from typing import Optional
+
+
+def _derive_keystream(key: str, length: int) -> bytes:
+    """
+    从 key 派生指定长度的密钥流。
+    原理:反复对 key + 块索引 做 SHA-256,拼接直到够用。
+    """
+    stream = bytearray()
+    block = 0
+    key_bytes = key.encode("utf-8")
+    while len(stream) < length:
+        h = hashlib.sha256(key_bytes + block.to_bytes(4, "big")).digest()
+        stream.extend(h)
+        block += 1
+    return bytes(stream[:length])
+
+
+def _rotl8(byte: int, n: int) -> int:
+    """8 位循环左移 n 位。"""
+    n = n % 8
+    return ((byte << n) | (byte >> (8 - n))) & 0xFF
+
+
+def _rotr8(byte: int, n: int) -> int:
+    """8 位循环右移 n 位。"""
+    n = n % 8
+    return ((byte >> n) | (byte << (8 - n))) & 0xFF
+
+
+def _get_key() -> str:
+    key = os.environ.get("APIKEY_ENCRYPT_KEY", "")
+    if not key:
+        raise RuntimeError("APIKEY_ENCRYPT_KEY 未配置")
+    return key
+
+
+def encrypt_api_key(plaintext: str) -> str:
+    """将 API Key 明文加密为 Base64 URL-safe 密文字符串。"""
+    data = plaintext.encode("utf-8")
+    keystream = _derive_keystream(_get_key(), len(data))
+    result = bytearray(len(data))
+    for i, byte in enumerate(data):
+        xored = byte ^ keystream[i]
+        result[i] = _rotl8(xored, i % 5 + 1)
+    return base64.urlsafe_b64encode(result).rstrip(b"=").decode("ascii")
+
+
+def decrypt_api_key(ciphertext: str) -> str:
+    """将 Base64 URL-safe 密文解密为 API Key 明文。"""
+    # 补回 Base64 padding
+    rem = len(ciphertext) % 4
+    if rem:
+        ciphertext += "=" * (4 - rem)
+    data = base64.urlsafe_b64decode(ciphertext)
+    keystream = _derive_keystream(_get_key(), len(data))
+    result = bytearray(len(data))
+    for i, byte in enumerate(data):
+        unshifted = _rotr8(byte, i % 5 + 1)
+        result[i] = unshifted ^ keystream[i]
+    return result.decode("utf-8")
+
+
+def try_encrypt(plaintext: Optional[str]) -> Optional[str]:
+    """安全封装:None 直接返回 None,加密失败返回 None。"""
+    if not plaintext:
+        return None
+    try:
+        return encrypt_api_key(plaintext)
+    except Exception:
+        return None
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# JavaScript 解密实现(供调用方参考)
+# ─────────────────────────────────────────────────────────────────────────────
+#
+# async function deriveKeystream(key, length) {
+#   const enc = new TextEncoder();
+#   const keyBytes = enc.encode(key);
+#   const stream = [];
+#   let block = 0;
+#   while (stream.length < length) {
+#     const blockBytes = new Uint8Array(4);
+#     new DataView(blockBytes.buffer).setUint32(0, block, false); // big-endian
+#     const input = new Uint8Array([...keyBytes, ...blockBytes]);
+#     const hash = await crypto.subtle.digest('SHA-256', input);
+#     stream.push(...new Uint8Array(hash));
+#     block++;
+#   }
+#   return stream.slice(0, length);
+# }
+#
+# function rotr8(byte, n) {
+#   n = n % 8;
+#   return ((byte >>> n) | (byte << (8 - n))) & 0xFF;
+# }
+#
+# function base64urlDecode(str) {
+#   str = str.replace(/-/g, '+').replace(/_/g, '/');
+#   while (str.length % 4) str += '=';
+#   return Uint8Array.from(atob(str), c => c.charCodeAt(0));
+# }
+#
+# async function decryptApiKey(ciphertext, secretKey) {
+#   const data = base64urlDecode(ciphertext);
+#   const keystream = await deriveKeystream(secretKey, data.length);
+#   const result = new Uint8Array(data.length);
+#   for (let i = 0; i < data.length; i++) {
+#     const unshifted = rotr8(data[i], i % 5 + 1);
+#     result[i] = unshifted ^ keystream[i];
+#   }
+#   return new TextDecoder().decode(result);
+# }
+#
+# // 使用示例:
+# // const apiKey = await decryptApiKey(encryptedValue, 'your_secret_key');
+#
+# ─────────────────────────────────────────────────────────────────────────────
+# Python 解密示例(调用方如果用 Python)
+# ─────────────────────────────────────────────────────────────────────────────
+#
+# import base64, hashlib
+#
+# def decrypt_api_key(ciphertext: str, secret_key: str) -> str:
+#     rem = len(ciphertext) % 4
+#     if rem:
+#         ciphertext += '=' * (4 - rem)
+#     data = base64.urlsafe_b64decode(ciphertext)
+#     # 派生密钥流
+#     stream, block = bytearray(), 0
+#     key_bytes = secret_key.encode()
+#     while len(stream) < len(data):
+#         stream.extend(hashlib.sha256(key_bytes + block.to_bytes(4, 'big')).digest())
+#         block += 1
+#     keystream = bytes(stream[:len(data)])
+#     # 解密
+#     result = bytearray(len(data))
+#     for i, byte in enumerate(data):
+#         n = i % 5 + 1
+#         unshifted = ((byte >> n) | (byte << (8 - n))) & 0xFF
+#         result[i] = unshifted ^ keystream[i]
+#     return result.decode('utf-8')

+ 13 - 2
backend/crawl/main.py

@@ -87,17 +87,22 @@ def scrape_all(
     timeout: int = 20000,
     timeout: int = 20000,
     executable_path: Optional[str] = None,
     executable_path: Optional[str] = None,
     modules: Optional[List[str]] = None,
     modules: Optional[List[str]] = None,
+    api_key: Optional[str] = None,
+    model_hint: Optional[str] = None,
 ) -> Dict:
 ) -> Dict:
     """
     """
     对单个 URL 运行所有(或指定)模块,共享一个浏览器实例。
     对单个 URL 运行所有(或指定)模块,共享一个浏览器实例。
 
 
     modules 可选值: ["info", "rate", "tool", "price"]
     modules 可选值: ["info", "rate", "tool", "price"]
     默认全部运行。
     默认全部运行。
+    api_key: 可选的 API 密钥,将通过请求头传递给目标站点。
+    model_hint: 可选的模型名称提示,优先用于 API JSON 匹配,而不是从 URL 提取。
     """
     """
     if modules is None:
     if modules is None:
         modules = ["info", "rate", "tool", "price", "icon"]
         modules = ["info", "rate", "tool", "price", "icon"]
 
 
-    target = _extract_model_id_from_url(url)
+    # 优先用外部传入的 model_hint,否则从 URL 提取
+    target = model_hint.strip() if model_hint and model_hint.strip() else _extract_model_id_from_url(url)
     result: Dict = {"url": url, "model_id": target, "error": None}
     result: Dict = {"url": url, "model_id": target, "error": None}
 
 
     # price 模块复用原始脚本,独立启动浏览器(原脚本结构限制)
     # price 模块复用原始脚本,独立启动浏览器(原脚本结构限制)
@@ -120,7 +125,12 @@ def scrape_all(
                 launch_kwargs["args"] = extra_args
                 launch_kwargs["args"] = extra_args
 
 
             browser = p.chromium.launch(**launch_kwargs)
             browser = p.chromium.launch(**launch_kwargs)
-            page = browser.new_context().new_page()
+
+            # 如果有 api_key,通过额外请求头传递
+            context_kwargs: Dict = {}
+            if api_key:
+                context_kwargs["extra_http_headers"] = {"Authorization": f"Bearer {api_key}"}
+            page = browser.new_context(**context_kwargs).new_page()
 
 
             # 拦截 API 响应
             # 拦截 API 响应
             def on_response(resp):
             def on_response(resp):
@@ -189,6 +199,7 @@ def scrape_all(
             headless=headless,
             headless=headless,
             timeout=timeout,
             timeout=timeout,
             executable_path=executable_path,
             executable_path=executable_path,
+            api_key=api_key,
         )
         )
         result["prices"] = price_result.get("prices", {})
         result["prices"] = price_result.get("prices", {})
         if price_result.get("error"):
         if price_result.get("error"):

+ 7 - 2
backend/crawl/scrape_aliyun_models.py

@@ -628,7 +628,7 @@ def extract_price_items_global(html: str) -> List[Dict]:
     return parse_prices_from_text(ancestor.get_text(separator="\n"))
     return parse_prices_from_text(ancestor.get_text(separator="\n"))
 
 
 
 
-def scrape_model_price(url: str, headless: bool = True, timeout: int = 20000, executable_path: Optional[str] = None) -> Dict:
+def scrape_model_price(url: str, headless: bool = True, timeout: int = 20000, executable_path: Optional[str] = None, api_key: Optional[str] = None) -> Dict:
     result = {"url": url, "error": None, "items": []}
     result = {"url": url, "error": None, "items": []}
 
 
     with sync_playwright() as p:
     with sync_playwright() as p:
@@ -642,7 +642,12 @@ def scrape_model_price(url: str, headless: bool = True, timeout: int = 20000, ex
             launch_kwargs["args"] = extra_args
             launch_kwargs["args"] = extra_args
 
 
         browser = p.chromium.launch(**launch_kwargs)
         browser = p.chromium.launch(**launch_kwargs)
-        context = browser.new_context()
+
+        # 如果有 api_key,通过额外请求头传递
+        context_kwargs = {}
+        if api_key:
+            context_kwargs["extra_http_headers"] = {"Authorization": f"Bearer {api_key}"}
+        context = browser.new_context(**context_kwargs)
         page = context.new_page()
         page = context.new_page()
 
 
         network_hits = []
         network_hits = []

+ 7 - 0
backend/main.py

@@ -1,5 +1,12 @@
+import logging
+
 import uvicorn
 import uvicorn
 from app.config import settings
 from app.config import settings
 
 
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(levelname)s:     %(name)s - %(message)s",
+)
+
 if __name__ == "__main__":
 if __name__ == "__main__":
     uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=True)
     uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=True)

+ 17 - 0
backend/migrations/013_domain_model_prices.sql

@@ -0,0 +1,17 @@
+-- Migration 013: per-domain per-model discount overrides
+SET search_path TO crawl;
+
+-- 域名下某个模型的自定义折扣,优先级高于域名全局折扣
+-- model_name 对应 price_snapshot.model_name
+CREATE TABLE IF NOT EXISTS domain_model_prices (
+    id           BIGSERIAL    PRIMARY KEY,
+    domain       VARCHAR(255) NOT NULL,
+    model_name   VARCHAR(255) NOT NULL,
+    discount     NUMERIC(5,4) NOT NULL CHECK (discount > 0 AND discount <= 1),
+    note         TEXT,
+    created_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    updated_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    UNIQUE (domain, model_name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_dmp_domain ON domain_model_prices (domain);

+ 5 - 0
backend/migrations/014_model_api_key.sql

@@ -0,0 +1,5 @@
+-- Migration 014: add api_key to models table
+SET search_path TO crawl;
+
+ALTER TABLE models
+    ADD COLUMN IF NOT EXISTS api_key TEXT;

+ 11 - 0
backend/migrations/015_api_keys.sql

@@ -0,0 +1,11 @@
+-- Migration 015: API Key 管理表
+SET search_path TO crawl;
+
+CREATE TABLE IF NOT EXISTS api_keys (
+    id          BIGSERIAL    PRIMARY KEY,
+    name        VARCHAR(200) NOT NULL,          -- 用户自定义名称,如"阿里云主账号"
+    key_value   TEXT         NOT NULL,          -- 实际 key 值
+    note        TEXT,                           -- 备注说明
+    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
+);

+ 18 - 0
backend/migrations/016_model_api_key_refactor.sql

@@ -0,0 +1,18 @@
+-- Migration 016: 将 models.api_key 从存储实际 key 值改为存储 api_keys.id
+SET search_path TO crawl;
+
+-- 1. 添加新列 api_key_id
+ALTER TABLE models ADD COLUMN IF NOT EXISTS api_key_id BIGINT;
+
+-- 2. 尝试迁移现有数据(如果 api_key 值能匹配到 api_keys.key_value,则填充 api_key_id)
+UPDATE models m
+SET api_key_id = (SELECT id FROM api_keys WHERE key_value = m.api_key LIMIT 1)
+WHERE m.api_key IS NOT NULL;
+
+-- 3. 删除旧列 api_key
+ALTER TABLE models DROP COLUMN IF EXISTS api_key;
+
+-- 4. 添加外键约束(可选,如果需要严格约束)
+-- ALTER TABLE models ADD CONSTRAINT fk_models_api_key FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
+
+COMMENT ON COLUMN models.api_key_id IS '关联的 API Key ID,NULL 表示未绑定';

+ 15 - 0
backend/migrations/017_model_groups.sql

@@ -0,0 +1,15 @@
+-- Migration 017: 模型分组
+SET search_path TO crawl;
+
+CREATE TABLE IF NOT EXISTS model_groups (
+    id          BIGSERIAL    PRIMARY KEY,
+    name        VARCHAR(200) NOT NULL,
+    note        TEXT,
+    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE models ADD COLUMN IF NOT EXISTS group_id BIGINT;
+
+COMMENT ON TABLE  model_groups      IS '模型分组,如"文本生成"、"图像生成"等';
+COMMENT ON COLUMN models.group_id   IS '所属分组 ID,NULL 表示未分组';

+ 187 - 0
backend/tests/test_apikey_crypto.py

@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""
+API Key 加解密测试脚本
+
+测试三种场景:
+1. Python 加密 → Python 解密
+2. Python 加密 → JavaScript 解密(输出 JS 代码供浏览器控制台测试)
+3. 多个不同长度的 key 测试
+"""
+import os
+import sys
+
+# 设置测试密钥
+os.environ['APIKEY_ENCRYPT_KEY'] = '25e9e87b18cf40d0ed0f102b8d2ec3a8'
+
+sys.path.insert(0, 'backend')
+from app.utils.apikey_crypto import encrypt_api_key, decrypt_api_key
+
+
+def test_python():
+    print("=" * 60)
+    print("测试 1: Python 加密 → Python 解密")
+    print("=" * 60)
+    
+    test_cases = [
+        "sk-1234567890abcdef",
+        "sk-very-long-api-key-with-special-chars-!@#$%^&*()",
+        "short",
+        "中文测试密钥",
+        "a" * 100,  # 长 key
+    ]
+    
+    all_ok = True
+    for i, original in enumerate(test_cases, 1):
+        try:
+            encrypted = encrypt_api_key(original)
+            decrypted = decrypt_api_key(encrypted)
+            ok = original == decrypted
+            all_ok = all_ok and ok
+            
+            print(f"\n[{i}] {'✓' if ok else '✗'}")
+            print(f"  原文: {original[:50]}{'...' if len(original) > 50 else ''}")
+            print(f"  密文: {encrypted[:60]}{'...' if len(encrypted) > 60 else ''}")
+            print(f"  解密: {decrypted[:50]}{'...' if len(decrypted) > 50 else ''}")
+            if not ok:
+                print(f"  ❌ 解密失败!")
+        except Exception as e:
+            print(f"\n[{i}] ✗ 异常: {e}")
+            all_ok = False
+    
+    print(f"\n{'='*60}")
+    print(f"Python 测试结果: {'全部通过 ✓' if all_ok else '有失败 ✗'}")
+    print(f"{'='*60}\n")
+    return all_ok
+
+
+def test_javascript():
+    print("=" * 60)
+    print("测试 2: JavaScript 解密验证")
+    print("=" * 60)
+    
+    test_key = "sk-test-key-for-javascript-1234567890"
+    encrypted = encrypt_api_key(test_key)
+    secret = os.environ['APIKEY_ENCRYPT_KEY']
+    
+    print(f"\n原文: {test_key}")
+    print(f"密文: {encrypted}")
+    print(f"密钥: {secret}")
+    
+    print("\n复制以下 JavaScript 代码到浏览器控制台运行:")
+    print("-" * 60)
+    
+    js_code = f"""
+// ── 解密函数 ──────────────────────────────────────────────────────────────────
+
+async function deriveKeystream(key, length) {{
+  const enc = new TextEncoder();
+  const keyBytes = enc.encode(key);
+  const stream = [];
+  let block = 0;
+  while (stream.length < length) {{
+    const blockBytes = new Uint8Array(4);
+    new DataView(blockBytes.buffer).setUint32(0, block, false);
+    const input = new Uint8Array([...keyBytes, ...blockBytes]);
+    const hash = await crypto.subtle.digest('SHA-256', input);
+    stream.push(...new Uint8Array(hash));
+    block++;
+  }}
+  return stream.slice(0, length);
+}}
+
+function rotr8(byte, n) {{
+  n = n % 8;
+  return ((byte >>> n) | (byte << (8 - n))) & 0xFF;
+}}
+
+function base64urlDecode(str) {{
+  str = str.replace(/-/g, '+').replace(/_/g, '/');
+  while (str.length % 4) str += '=';
+  return Uint8Array.from(atob(str), c => c.charCodeAt(0));
+}}
+
+async function decryptApiKey(ciphertext, secretKey) {{
+  const data = base64urlDecode(ciphertext);
+  const keystream = await deriveKeystream(secretKey, data.length);
+  const result = new Uint8Array(data.length);
+  for (let i = 0; i < data.length; i++) {{
+    const unshifted = rotr8(data[i], i % 5 + 1);
+    result[i] = unshifted ^ keystream[i];
+  }}
+  return new TextDecoder().decode(result);
+}}
+
+// ── 测试数据 ──────────────────────────────────────────────────────────────────
+
+const encrypted = "{encrypted}";
+const secretKey = "{secret}";
+const expected  = "{test_key}";
+
+// ── 执行解密 ──────────────────────────────────────────────────────────────────
+
+decryptApiKey(encrypted, secretKey).then(decrypted => {{
+  console.log('原文:', expected);
+  console.log('密文:', encrypted);
+  console.log('解密:', decrypted);
+  console.log('验证:', decrypted === expected ? '✓ 通过' : '✗ 失败');
+}});
+"""
+    
+    print(js_code)
+    print("-" * 60)
+    print("\n预期输出: 解密结果应该等于原文")
+    print(f"{'='*60}\n")
+
+
+def test_edge_cases():
+    print("=" * 60)
+    print("测试 3: 边界情况")
+    print("=" * 60)
+    
+    cases = [
+        ("空字符串", ""),
+        ("单字符", "a"),
+        ("特殊符号", "!@#$%^&*()_+-=[]{{}}|;':\",./<>?"),
+        ("Emoji", "🔑🚀💻"),
+    ]
+    
+    all_ok = True
+    for name, original in cases:
+        if not original:  # 空字符串跳过
+            print(f"\n[{name}] 跳过(空字符串)")
+            continue
+        try:
+            encrypted = encrypt_api_key(original)
+            decrypted = decrypt_api_key(encrypted)
+            ok = original == decrypted
+            all_ok = all_ok and ok
+            print(f"\n[{name}] {'✓' if ok else '✗'}")
+            print(f"  原文: {original}")
+            print(f"  密文: {encrypted}")
+            print(f"  解密: {decrypted}")
+        except Exception as e:
+            print(f"\n[{name}] ✗ 异常: {e}")
+            all_ok = False
+    
+    print(f"\n{'='*60}")
+    print(f"边界测试结果: {'全部通过 ✓' if all_ok else '有失败 ✗'}")
+    print(f"{'='*60}\n")
+    return all_ok
+
+
+if __name__ == "__main__":
+    print("\n🔐 API Key 加解密测试\n")
+    
+    r1 = test_python()
+    test_javascript()
+    r2 = test_edge_cases()
+    
+    print("=" * 60)
+    print("总结")
+    print("=" * 60)
+    print(f"Python 加解密: {'✓ 通过' if r1 else '✗ 失败'}")
+    print(f"边界情况:     {'✓ 通过' if r2 else '✗ 失败'}")
+    print(f"\n最终结果: {'🎉 全部通过' if r1 and r2 else '❌ 有失败'}")
+    print("=" * 60)
+    
+    sys.exit(0 if r1 and r2 else 1)

+ 315 - 0
docs/apikey-decrypt.md

@@ -0,0 +1,315 @@
+# API Key 解密说明
+
+调用 `/api/public/prices` 接口时,返回数据中每个模型的 `api_key` 字段是加密后的密文。本文档说明如何在你的应用中解密还原出原始 API Key。
+
+---
+
+## 所需信息
+
+| 项目 | 值 |
+|------|-----|
+| 加密密钥 | `25e9e87b18cf40d0ed0f102b8d2ec3a8` |
+| 算法 | XOR 密钥流 + 字节循环位移 + Base64 URL-safe |
+| 依赖 | 无第三方库,纯标准库实现 |
+
+> ⚠️ 请妥善保管加密密钥,不要泄露给未授权方。
+
+---
+
+## 算法说明
+
+### 加密流程(服务端)
+
+```
+明文 UTF-8 字节
+  → 与密钥流逐字节 XOR
+  → 每字节循环左移 (i % 5 + 1) 位
+  → Base64 URL-safe 编码(无 = 填充)
+  → 密文字符串
+```
+
+### 解密流程(客户端,完全对称)
+
+```
+密文字符串
+  → Base64 URL-safe 解码
+  → 每字节循环右移 (i % 5 + 1) 位
+  → 与相同密钥流逐字节 XOR
+  → UTF-8 解码
+  → 原始 API Key
+```
+
+### 密钥流派生方式
+
+对 `SECRET_KEY + 块索引(4字节大端)` 反复做 SHA-256,拼接直到长度够用:
+
+```
+block_0 = SHA-256(key_bytes + 0x00000000)   # 32 字节
+block_1 = SHA-256(key_bytes + 0x00000001)   # 32 字节
+block_2 = SHA-256(key_bytes + 0x00000002)   # 32 字节
+...
+keystream = block_0 + block_1 + block_2 + ...  取前 N 字节
+```
+
+---
+
+## 各语言解密实现
+
+### JavaScript / TypeScript
+
+适用于浏览器和 Node.js(18+),使用内置 `crypto.subtle`。
+
+```javascript
+async function deriveKeystream(key, length) {
+  const enc = new TextEncoder();
+  const keyBytes = enc.encode(key);
+  const stream = [];
+  let block = 0;
+  while (stream.length < length) {
+    const blockBytes = new Uint8Array(4);
+    new DataView(blockBytes.buffer).setUint32(0, block, false); // big-endian
+    const input = new Uint8Array([...keyBytes, ...blockBytes]);
+    const hash = await crypto.subtle.digest('SHA-256', input);
+    stream.push(...new Uint8Array(hash));
+    block++;
+  }
+  return stream.slice(0, length);
+}
+
+function rotr8(byte, n) {
+  n = n % 8;
+  return ((byte >>> n) | (byte << (8 - n))) & 0xFF;
+}
+
+function base64urlDecode(str) {
+  str = str.replace(/-/g, '+').replace(/_/g, '/');
+  while (str.length % 4) str += '=';
+  return Uint8Array.from(atob(str), c => c.charCodeAt(0));
+}
+
+async function decryptApiKey(ciphertext, secretKey) {
+  const data = base64urlDecode(ciphertext);
+  const keystream = await deriveKeystream(secretKey, data.length);
+  const result = new Uint8Array(data.length);
+  for (let i = 0; i < data.length; i++) {
+    const unshifted = rotr8(data[i], i % 5 + 1);
+    result[i] = unshifted ^ keystream[i];
+  }
+  return new TextDecoder().decode(result);
+}
+
+// 使用示例
+const SECRET_KEY = '25e9e87b18cf40d0ed0f102b8d2ec3a8';
+
+const apiKey = await decryptApiKey(model.api_key, SECRET_KEY);
+console.log(apiKey); // sk-xxxxxxxxxxxxxxxx
+```
+
+---
+
+### Python
+
+使用标准库 `hashlib` + `base64`,无需安装任何包。
+
+```python
+import base64
+import hashlib
+
+
+def decrypt_api_key(ciphertext: str, secret_key: str) -> str:
+    # 1. Base64 URL-safe 解码
+    rem = len(ciphertext) % 4
+    if rem:
+        ciphertext += '=' * (4 - rem)
+    data = base64.urlsafe_b64decode(ciphertext)
+
+    # 2. 派生密钥流
+    stream, block = bytearray(), 0
+    key_bytes = secret_key.encode('utf-8')
+    while len(stream) < len(data):
+        stream.extend(
+            hashlib.sha256(key_bytes + block.to_bytes(4, 'big')).digest()
+        )
+        block += 1
+    keystream = bytes(stream[:len(data)])
+
+    # 3. 循环右移 + XOR
+    result = bytearray(len(data))
+    for i, byte in enumerate(data):
+        n = i % 5 + 1
+        unshifted = ((byte >> n) | (byte << (8 - n))) & 0xFF
+        result[i] = unshifted ^ keystream[i]
+
+    return result.decode('utf-8')
+
+
+# 使用示例
+SECRET_KEY = '25e9e87b18cf40d0ed0f102b8d2ec3a8'
+
+api_key = decrypt_api_key(model['api_key'], SECRET_KEY)
+print(api_key)  # sk-xxxxxxxxxxxxxxxx
+```
+
+---
+
+### Java
+
+使用 JDK 内置 `MessageDigest` + `Base64`,无需第三方依赖。
+
+```java
+import java.util.Base64;
+import java.security.MessageDigest;
+import java.nio.charset.StandardCharsets;
+
+public class ApiKeyDecryptor {
+
+    private static final String SECRET_KEY = "25e9e87b18cf40d0ed0f102b8d2ec3a8";
+
+    private static byte[] deriveKeystream(String key, int length) throws Exception {
+        MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
+        byte[] stream = new byte[length];
+        int pos = 0, block = 0;
+
+        while (pos < length) {
+            sha256.reset();
+            sha256.update(keyBytes);
+            sha256.update(new byte[]{
+                (byte)(block >> 24), (byte)(block >> 16),
+                (byte)(block >> 8),  (byte) block
+            });
+            byte[] hash = sha256.digest();
+            int toCopy = Math.min(hash.length, length - pos);
+            System.arraycopy(hash, 0, stream, pos, toCopy);
+            pos += toCopy;
+            block++;
+        }
+        return stream;
+    }
+
+    private static int rotr8(int b, int n) {
+        n = n % 8;
+        return ((b >>> n) | (b << (8 - n))) & 0xFF;
+    }
+
+    public static String decrypt(String ciphertext) throws Exception {
+        // 1. Base64 URL-safe 解码
+        String padded = ciphertext.replace('-', '+').replace('_', '/');
+        while (padded.length() % 4 != 0) padded += "=";
+        byte[] data = Base64.getDecoder().decode(padded);
+
+        // 2. 派生密钥流
+        byte[] keystream = deriveKeystream(SECRET_KEY, data.length);
+
+        // 3. 循环右移 + XOR
+        byte[] result = new byte[data.length];
+        for (int i = 0; i < data.length; i++) {
+            int unshifted = rotr8(data[i] & 0xFF, i % 5 + 1);
+            result[i] = (byte)(unshifted ^ keystream[i]);
+        }
+        return new String(result, StandardCharsets.UTF_8);
+    }
+
+    // 使用示例
+    public static void main(String[] args) throws Exception {
+        String encrypted = "..."; // 从 API 返回的 api_key 字段
+        String apiKey = decrypt(encrypted);
+        System.out.println(apiKey); // sk-xxxxxxxxxxxxxxxx
+    }
+}
+```
+
+---
+
+### Go
+
+```go
+package main
+
+import (
+    "crypto/sha256"
+    "encoding/base64"
+    "fmt"
+)
+
+const secretKey = "25e9e87b18cf40d0ed0f102b8d2ec3a8"
+
+func deriveKeystream(key string, length int) []byte {
+    keyBytes := []byte(key)
+    stream := make([]byte, 0, length+32)
+    for block := 0; len(stream) < length; block++ {
+        blockBytes := []byte{
+            byte(block >> 24), byte(block >> 16),
+            byte(block >> 8), byte(block),
+        }
+        h := sha256.Sum256(append(keyBytes, blockBytes...))
+        stream = append(stream, h[:]...)
+    }
+    return stream[:length]
+}
+
+func rotr8(b byte, n int) byte {
+    n = n % 8
+    return (b >> n) | (b << (8 - n))
+}
+
+func decryptApiKey(ciphertext string) (string, error) {
+    // Base64 URL-safe 解码
+    rem := len(ciphertext) % 4
+    if rem != 0 {
+        for i := 0; i < 4-rem; i++ {
+            ciphertext += "="
+        }
+    }
+    data, err := base64.URLEncoding.DecodeString(ciphertext)
+    if err != nil {
+        return "", err
+    }
+
+    keystream := deriveKeystream(secretKey, len(data))
+    result := make([]byte, len(data))
+    for i, b := range data {
+        unshifted := rotr8(b, i%5+1)
+        result[i] = unshifted ^ keystream[i]
+    }
+    return string(result), nil
+}
+
+func main() {
+    encrypted := "..." // 从 API 返回的 api_key 字段
+    apiKey, err := decryptApiKey(encrypted)
+    if err != nil {
+        panic(err)
+    }
+    fmt.Println(apiKey) // sk-xxxxxxxxxxxxxxxx
+}
+```
+
+---
+
+## 完整使用示例
+
+请求 `/api/public/prices` 后,遍历 `models` 数组,对每个有 `api_key` 的模型解密:
+
+```javascript
+const SECRET_KEY = '25e9e87b18cf40d0ed0f102b8d2ec3a8';
+
+const res = await fetch('https://your-api.com/api/public/prices', {
+  headers: { 'Referer': 'https://your-domain.com' }
+});
+const data = await res.json();
+
+for (const model of data.models) {
+  if (model.api_key) {
+    model.api_key_plain = await decryptApiKey(model.api_key, SECRET_KEY);
+  }
+}
+```
+
+---
+
+## 注意事项
+
+- `api_key` 字段为 `null` 时表示该模型未配置 API Key,无需解密
+- 密钥更换后,旧密文将无法解密,需重新请求接口获取新密文
+- 建议将 `SECRET_KEY` 存储在环境变量中,不要硬编码在源码里

+ 2 - 2
frontend/.env

@@ -1,4 +1,4 @@
 #测试
 #测试
-# VITE_API_BASE_URL=http://localhost:8000
+VITE_API_BASE_URL=http://localhost:8000
 #生产
 #生产
-VITE_API_BASE_URL=https://crawler-api.aitoolcore.com
+# VITE_API_BASE_URL=https://crawler-api.aitoolcore.com

+ 8 - 0
frontend/src/App.tsx

@@ -3,9 +3,13 @@ import { BottomNav } from './components/BottomNav';
 import { RequireAuth } from './components/RequireAuth';
 import { RequireAuth } from './components/RequireAuth';
 import { Dashboard } from './pages/Dashboard';
 import { Dashboard } from './pages/Dashboard';
 import { Discounts } from './pages/Discounts';
 import { Discounts } from './pages/Discounts';
+import { DomainPrices } from './pages/DomainPrices';
 import { Login } from './pages/Login';
 import { Login } from './pages/Login';
 import { Logs } from './pages/Logs';
 import { Logs } from './pages/Logs';
 import { MapPage } from './pages/Map';
 import { MapPage } from './pages/Map';
+import { ApiKeys } from './pages/ApiKeys';
+import { ModelGroups } from './pages/ModelGroups';
+import { Models } from './pages/Models';
 import { ScrapeDashboard } from './pages/ScrapeDashboard';
 import { ScrapeDashboard } from './pages/ScrapeDashboard';
 import { Scraper } from './pages/Scraper';
 import { Scraper } from './pages/Scraper';
 import './index.css';
 import './index.css';
@@ -24,9 +28,13 @@ export default function App() {
                   <Route path="/" element={<Dashboard />} />
                   <Route path="/" element={<Dashboard />} />
                   <Route path="/logs" element={<Logs />} />
                   <Route path="/logs" element={<Logs />} />
                   <Route path="/map" element={<MapPage />} />
                   <Route path="/map" element={<MapPage />} />
+                  <Route path="/models" element={<Models />} />
+                  <Route path="/api-keys" element={<ApiKeys />} />
+                  <Route path="/model-groups" element={<ModelGroups />} />
                   <Route path="/scraper" element={<Scraper />} />
                   <Route path="/scraper" element={<Scraper />} />
                   <Route path="/scrape-dashboard" element={<ScrapeDashboard />} />
                   <Route path="/scrape-dashboard" element={<ScrapeDashboard />} />
                   <Route path="/discounts" element={<Discounts />} />
                   <Route path="/discounts" element={<Discounts />} />
+                  <Route path="/discounts/:domain/model-prices" element={<DomainPrices />} />
                   <Route path="*" element={<Navigate to="/" replace />} />
                   <Route path="*" element={<Navigate to="/" replace />} />
                 </Routes>
                 </Routes>
               </div>
               </div>

+ 166 - 3
frontend/src/api.ts

@@ -30,20 +30,30 @@ export async function postScrape(urls: string[]): Promise<ScrapeJob> {
   return res.json() as Promise<ScrapeJob>;
   return res.json() as Promise<ScrapeJob>;
 }
 }
 
 
-export interface Model { id: number; name: string; url: string; created_at: string; }
+export interface Model { id: number; name: string; url: string; api_key_id: number | null; api_key_name: string | null; group_id: number | null; group_name: string | null; created_at: string; }
 
 
 export const fetchModels = () => get<Model[]>('/api/models');
 export const fetchModels = () => get<Model[]>('/api/models');
 
 
-export async function createModel(name: string, url: string): Promise<Model> {
+export async function createModel(name: string, url: string, api_key_id?: number, group_id?: number): Promise<Model> {
   const res = await fetch(`${BASE}/api/models`, {
   const res = await fetch(`${BASE}/api/models`, {
     method: 'POST',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ name, url }),
+    body: JSON.stringify({ name, url, api_key_id: api_key_id ?? null, group_id: group_id ?? null }),
   });
   });
   if (!res.ok) throw new Error(`添加失败: ${res.status}`);
   if (!res.ok) throw new Error(`添加失败: ${res.status}`);
   return res.json() as Promise<Model>;
   return res.json() as Promise<Model>;
 }
 }
 
 
+export async function updateModel(id: number, data: { name?: string; url?: string; api_key_id?: number | null; group_id?: number | null }): Promise<Model> {
+  const res = await fetch(`${BASE}/api/models/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!res.ok) throw new Error(`更新失败: ${res.status}`);
+  return res.json() as Promise<Model>;
+}
+
 export async function deleteModel(id: number): Promise<void> {
 export async function deleteModel(id: number): Promise<void> {
   const res = await fetch(`${BASE}/api/models/${id}`, { method: 'DELETE' });
   const res = await fetch(`${BASE}/api/models/${id}`, { method: 'DELETE' });
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
@@ -87,6 +97,63 @@ export async function deleteDiscount(id: number): Promise<void> {
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
 }
 }
 
 
+// ── Domain model prices ──────────────────────────────────────────────────────
+
+export interface DomainModelPrice {
+  id: number;
+  domain: string;
+  model_name: string;
+  discount: number;
+  note: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export const fetchDomainModelPrices = (domain: string) =>
+  get<DomainModelPrice[]>(`/api/discounts/${encodeURIComponent(domain)}/model-prices`);
+
+export async function upsertDomainModelPrice(
+  domain: string,
+  model_name: string,
+  discount: number,
+  note?: string,
+): Promise<DomainModelPrice> {
+  const res = await fetch(
+    `${BASE}/api/discounts/${encodeURIComponent(domain)}/model-prices/${encodeURIComponent(model_name)}`,
+    {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model_name, discount, note }),
+    },
+  );
+  if (!res.ok) throw new Error(`保存失败: ${res.status}`);
+  return res.json() as Promise<DomainModelPrice>;
+}
+
+export async function batchUpsertDomainModelPrices(
+  domain: string,
+  items: { model_name: string; discount: number; note?: string }[],
+): Promise<DomainModelPrice[]> {
+  const res = await fetch(
+    `${BASE}/api/discounts/${encodeURIComponent(domain)}/model-prices`,
+    {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ items }),
+    },
+  );
+  if (!res.ok) throw new Error(`批量保存失败: ${res.status}`);
+  return res.json() as Promise<DomainModelPrice[]>;
+}
+
+export async function deleteDomainModelPrice(domain: string, model_name: string): Promise<void> {
+  const res = await fetch(
+    `${BASE}/api/discounts/${encodeURIComponent(domain)}/model-prices/${encodeURIComponent(model_name)}`,
+    { method: 'DELETE' },
+  );
+  if (!res.ok) throw new Error(`删除失败: ${res.status}`);
+}
+
 export async function login(username: string, password: string): Promise<string> {
 export async function login(username: string, password: string): Promise<string> {
   const res = await fetch(`${BASE}/api/auth/login`, {
   const res = await fetch(`${BASE}/api/auth/login`, {
     method: 'POST',
     method: 'POST',
@@ -112,3 +179,99 @@ export interface ScrapeStats {
 }
 }
 
 
 export const fetchScrapeStats = () => get<ScrapeStats>('/api/scrape-stats');
 export const fetchScrapeStats = () => get<ScrapeStats>('/api/scrape-stats');
+
+// ── API Keys ─────────────────────────────────────────────────────────────────
+
+export interface ApiKey {
+  id: number;
+  name: string;
+  key_value: string;
+  note: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export const fetchApiKeys = () => get<ApiKey[]>('/api/api-keys');
+
+export async function createApiKey(name: string, key_value: string, note?: string): Promise<ApiKey> {
+  const res = await fetch(`${BASE}/api/api-keys`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ name, key_value, note: note || null }),
+  });
+  if (!res.ok) throw new Error(`添加失败: ${res.status}`);
+  return res.json() as Promise<ApiKey>;
+}
+
+export async function updateApiKey(id: number, data: { name?: string; key_value?: string; note?: string | null }): Promise<ApiKey> {
+  const res = await fetch(`${BASE}/api/api-keys/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!res.ok) throw new Error(`更新失败: ${res.status}`);
+  return res.json() as Promise<ApiKey>;
+}
+
+export async function deleteApiKey(id: number): Promise<void> {
+  const res = await fetch(`${BASE}/api/api-keys/${id}`, { method: 'DELETE' });
+  if (!res.ok) throw new Error(`删除失败: ${res.status}`);
+}
+
+export async function batchAssignApiKey(api_key_id: number | null, model_ids: number[]): Promise<{ updated: number }> {
+  const res = await fetch(`${BASE}/api/api-keys/batch-assign`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ api_key_id, model_ids }),
+  });
+  if (!res.ok) throw new Error(`批量配置失败: ${res.status}`);
+  return res.json() as Promise<{ updated: number }>;
+}
+
+// ── Model Groups ──────────────────────────────────────────────────────────────
+
+export interface ModelGroup {
+  id: number;
+  name: string;
+  note: string | null;
+  model_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export const fetchModelGroups = () => get<ModelGroup[]>('/api/model-groups');
+
+export async function createModelGroup(name: string, note?: string): Promise<ModelGroup> {
+  const res = await fetch(`${BASE}/api/model-groups`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ name, note: note || null }),
+  });
+  if (!res.ok) throw new Error(`添加失败: ${res.status}`);
+  return res.json() as Promise<ModelGroup>;
+}
+
+export async function updateModelGroup(id: number, data: { name?: string; note?: string | null }): Promise<ModelGroup> {
+  const res = await fetch(`${BASE}/api/model-groups/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!res.ok) throw new Error(`更新失败: ${res.status}`);
+  return res.json() as Promise<ModelGroup>;
+}
+
+export async function deleteModelGroup(id: number): Promise<void> {
+  const res = await fetch(`${BASE}/api/model-groups/${id}`, { method: 'DELETE' });
+  if (!res.ok) throw new Error(`删除失败: ${res.status}`);
+}
+
+export async function batchAssignModelGroup(group_id: number | null, model_ids: number[]): Promise<{ updated: number }> {
+  const res = await fetch(`${BASE}/api/model-groups/batch-assign`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ group_id, model_ids }),
+  });
+  if (!res.ok) throw new Error(`批量分组失败: ${res.status}`);
+  return res.json() as Promise<{ updated: number }>;
+}

+ 3 - 0
frontend/src/components/BottomNav.tsx

@@ -5,6 +5,9 @@ const NAV_ITEMS = [
   { to: '/', label: '仪表盘', icon: '⊞' },
   { to: '/', label: '仪表盘', icon: '⊞' },
   { to: '/logs', label: '日志', icon: '≡' },
   { to: '/logs', label: '日志', icon: '≡' },
   { to: '/map', label: '地图', icon: '◎' },
   { to: '/map', label: '地图', icon: '◎' },
+  { to: '/models', label: '模型', icon: '◈' },
+  { to: '/model-groups', label: '分组', icon: '▤' },
+  { to: '/api-keys', label: 'API Key', icon: '🔑' },
   { to: '/scraper', label: '爬取', icon: '⟳' },
   { to: '/scraper', label: '爬取', icon: '⟳' },
   { to: '/discounts', label: '折扣', icon: '%' },
   { to: '/discounts', label: '折扣', icon: '%' },
 ];
 ];

+ 282 - 0
frontend/src/pages/ApiKeys.css

@@ -0,0 +1,282 @@
+.ak-page {
+  padding: 24px;
+  padding-bottom: 52px;
+  color: #e6edf3;
+}
+
+/* ── 标题栏 ── */
+.ak-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: 20px;
+  gap: 16px;
+}
+
+.ak-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #00d4ff;
+  letter-spacing: 0.05em;
+  margin-bottom: 4px;
+}
+
+.ak-subtitle {
+  font-size: 12px;
+  color: #8b949e;
+}
+
+.ak-add-btn {
+  background: #00d4ff22;
+  border: 1px solid #00d4ff;
+  border-radius: 6px;
+  color: #00d4ff;
+  padding: 8px 18px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+  font-family: inherit;
+  flex-shrink: 0;
+  transition: background 0.15s;
+}
+.ak-add-btn:hover { background: #00d4ff33; }
+
+/* ── 错误 ── */
+.ak-error {
+  background: rgba(248, 81, 73, 0.1);
+  border: 1px solid #f85149;
+  color: #f85149;
+  padding: 10px 14px;
+  border-radius: 6px;
+  font-size: 13px;
+  margin-bottom: 16px;
+}
+
+/* ── 新增表单卡片 ── */
+.ak-form-card {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  padding: 18px 20px;
+  margin-bottom: 20px;
+  max-width: 760px;
+}
+
+.ak-form-title {
+  font-size: 13px;
+  color: #8b949e;
+  margin-bottom: 14px;
+}
+
+.ak-form-body {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+
+.ak-field {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+  flex: 1;
+  min-width: 160px;
+}
+
+.ak-field--wide {
+  flex: 2;
+  min-width: 240px;
+}
+
+.ak-label {
+  font-size: 12px;
+  color: #8b949e;
+}
+
+.ak-required { color: #f85149; margin-left: 2px; }
+.ak-optional { font-size: 11px; color: #8b949e; opacity: 0.6; margin-left: 4px; }
+
+.ak-hint {
+  font-size: 11px;
+  color: #8b949e;
+  opacity: 0.6;
+}
+
+.ak-input {
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  font-family: inherit;
+  font-size: 13px;
+  padding: 8px 12px;
+  outline: none;
+  width: 100%;
+  box-sizing: border-box;
+  transition: border-color 0.15s;
+}
+.ak-input:focus { border-color: #00d4ff; }
+.ak-input--mono { font-family: monospace; letter-spacing: 0.04em; }
+
+.ak-form-error {
+  color: #f85149;
+  font-size: 12px;
+  margin-bottom: 10px;
+}
+
+.ak-form-actions {
+  display: flex;
+  gap: 8px;
+}
+
+/* ── 通用按钮 ── */
+.ak-btn {
+  background: #21262d;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  padding: 6px 14px;
+  font-size: 12px;
+  cursor: pointer;
+  white-space: nowrap;
+  font-family: inherit;
+  transition: opacity 0.15s;
+}
+.ak-btn:hover:not(:disabled) { opacity: 0.8; }
+.ak-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.ak-btn--confirm { background: #3fb95022; border-color: #3fb950; color: #3fb950; }
+.ak-btn--cancel  { color: #8b949e; }
+.ak-btn--edit    { background: #00d4ff11; border-color: #00d4ff; color: #00d4ff; }
+.ak-btn--save    { background: #3fb95022; border-color: #3fb950; color: #3fb950; }
+.ak-btn--delete  { background: #f8514911; border-color: #f85149; color: #f85149; }
+
+/* ── Key 卡片列表 ── */
+.ak-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  max-width: 900px;
+}
+
+.ak-card {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  overflow: hidden;
+  transition: border-color 0.15s;
+}
+.ak-card:hover { border-color: #444d56; }
+.ak-card--editing { border-color: #00d4ff55; }
+
+.ak-card-main {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding: 16px 18px;
+  gap: 16px;
+}
+
+.ak-card-left {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+  flex: 1;
+  min-width: 0;
+}
+
+.ak-card-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #e6edf3;
+}
+
+.ak-card-note {
+  font-size: 12px;
+  color: #8b949e;
+}
+
+.ak-card-key {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 4px;
+}
+
+.ak-key-val {
+  font-family: monospace;
+  font-size: 13px;
+  color: #3fb950;
+  letter-spacing: 0.04em;
+  word-break: break-all;
+}
+
+.ak-key-toggle {
+  background: transparent;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #8b949e;
+  font-size: 11px;
+  padding: 2px 8px;
+  cursor: pointer;
+  font-family: inherit;
+  flex-shrink: 0;
+  transition: border-color 0.15s, color 0.15s;
+}
+.ak-key-toggle:hover { border-color: #8b949e; color: #e6edf3; }
+
+.ak-card-right {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.ak-card-time {
+  font-size: 11px;
+  color: #8b949e;
+  white-space: nowrap;
+}
+
+.ak-card-actions {
+  display: flex;
+  gap: 6px;
+}
+
+/* 编辑抽屉 */
+.ak-edit-drawer {
+  border-top: 1px solid #30363d;
+  background: #0d1117;
+  padding: 16px 18px;
+}
+
+/* ── 状态 ── */
+.ak-empty {
+  color: #8b949e;
+  font-size: 13px;
+  padding: 48px 0;
+  text-align: center;
+}
+
+.ak-count {
+  margin-top: 14px;
+  font-size: 12px;
+  color: #8b949e;
+  text-align: right;
+  max-width: 900px;
+}
+
+/* 分组模型数量徽章 */
+.mg-count-badge {
+  display: inline-block;
+  background: #00d4ff11;
+  border: 1px solid #00d4ff33;
+  border-radius: 10px;
+  color: #00d4ff;
+  font-size: 11px;
+  font-weight: normal;
+  padding: 1px 8px;
+  margin-left: 10px;
+  vertical-align: middle;
+}

+ 187 - 0
frontend/src/pages/ApiKeys.tsx

@@ -0,0 +1,187 @@
+import { useEffect, useState } from 'react';
+import { createApiKey, deleteApiKey, fetchApiKeys, updateApiKey } from '../api';
+import type { ApiKey } from '../api';
+import './ApiKeys.css';
+
+type EditState = { id: number; name: string; key_value: string; note: string };
+
+export function ApiKeys() {
+  const [keys, setKeys] = useState<ApiKey[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  // 新增
+  const [showAdd, setShowAdd] = useState(false);
+  const [newName, setNewName] = useState('');
+  const [newValue, setNewValue] = useState('');
+  const [newNote, setNewNote] = useState('');
+  const [addError, setAddError] = useState<string | null>(null);
+  const [adding, setAdding] = useState(false);
+
+  // 编辑
+  const [editState, setEditState] = useState<EditState | null>(null);
+  const [editError, setEditError] = useState<string | null>(null);
+  const [saving, setSaving] = useState(false);
+
+  // 显示/隐藏 key 值
+  const [visible, setVisible] = useState<Set<number>>(new Set());
+
+  const load = () => {
+    setLoading(true);
+    fetchApiKeys()
+      .then(setKeys)
+      .catch(() => setError('加载失败'))
+      .finally(() => setLoading(false));
+  };
+
+  useEffect(() => { load(); }, []);
+
+  const toggleVisible = (id: number) =>
+    setVisible(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
+
+  const handleAdd = async () => {
+    if (!newName.trim() || !newValue.trim()) { setAddError('名称和 Key 值不能为空'); return; }
+    setAdding(true); setAddError(null);
+    try {
+      await createApiKey(newName.trim(), newValue.trim(), newNote.trim() || undefined);
+      setNewName(''); setNewValue(''); setNewNote(''); setShowAdd(false);
+      load();
+    } catch (e) { setAddError(e instanceof Error ? e.message : String(e)); }
+    finally { setAdding(false); }
+  };
+
+  const handleDelete = async (id: number) => {
+    if (!confirm('确认删除该 API Key?删除后绑定此 Key 的模型不会自动清除。')) return;
+    try { await deleteApiKey(id); if (editState?.id === id) setEditState(null); load(); }
+    catch (e) { setError(e instanceof Error ? e.message : String(e)); }
+  };
+
+  const startEdit = (k: ApiKey) => {
+    if (editState?.id === k.id) { setEditState(null); return; }
+    setEditState({ id: k.id, name: k.name, key_value: k.key_value, note: k.note ?? '' });
+    setEditError(null);
+  };
+
+  const handleSave = async () => {
+    if (!editState) return;
+    if (!editState.name.trim() || !editState.key_value.trim()) { setEditError('名称和 Key 值不能为空'); return; }
+    setSaving(true); setEditError(null);
+    try {
+      await updateApiKey(editState.id, {
+        name: editState.name.trim(),
+        key_value: editState.key_value.trim(),
+        note: editState.note.trim() || null,
+      });
+      setEditState(null); load();
+    } catch (e) { setEditError(e instanceof Error ? e.message : String(e)); }
+    finally { setSaving(false); }
+  };
+
+  const mask = (v: string) => v.length <= 8 ? '••••••••' : v.slice(0, 6) + '••••••••' + v.slice(-4);
+
+  return (
+    <div className="ak-page">
+      <div className="ak-header">
+        <div>
+          <div className="ak-title">API Key 管理</div>
+          <div className="ak-subtitle">在此统一管理所有 Key,然后在「模型管理」中批量绑定</div>
+        </div>
+        <button className="ak-add-btn" onClick={() => { setShowAdd(v => !v); setAddError(null); }}>
+          + 添加 Key
+        </button>
+      </div>
+
+      {error && <div className="ak-error">{error}</div>}
+
+      {/* 新增表单 */}
+      {showAdd && (
+        <div className="ak-form-card">
+          <div className="ak-form-title">添加新 API Key</div>
+          <div className="ak-form-body">
+            <div className="ak-field">
+              <label className="ak-label">名称 <span className="ak-required">*</span></label>
+              <input className="ak-input" placeholder="如:阿里云主账号" value={newName} onChange={e => setNewName(e.target.value)} />
+              <span className="ak-hint">用于识别这个 Key 的用途</span>
+            </div>
+            <div className="ak-field ak-field--wide">
+              <label className="ak-label">Key 值 <span className="ak-required">*</span></label>
+              <input className="ak-input ak-input--mono" placeholder="sk-xxxxxxxxxxxxxxxx" type="password" value={newValue} onChange={e => setNewValue(e.target.value)} />
+            </div>
+            <div className="ak-field ak-field--wide">
+              <label className="ak-label">备注 <span className="ak-optional">可选</span></label>
+              <input className="ak-input" placeholder="如:用于阿里云百炼 qwen 系列,有效期至 2026-12" value={newNote} onChange={e => setNewNote(e.target.value)} />
+            </div>
+          </div>
+          {addError && <div className="ak-form-error">{addError}</div>}
+          <div className="ak-form-actions">
+            <button className="ak-btn ak-btn--confirm" onClick={handleAdd} disabled={adding}>{adding ? '添加中…' : '确认添加'}</button>
+            <button className="ak-btn ak-btn--cancel" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
+          </div>
+        </div>
+      )}
+
+      {/* Key 列表 */}
+      {loading ? (
+        <div className="ak-empty">加载中…</div>
+      ) : keys.length === 0 ? (
+        <div className="ak-empty">暂无 API Key,点击「添加 Key」开始</div>
+      ) : (
+        <div className="ak-list">
+          {keys.map(k => (
+            <div key={k.id} className={`ak-card${editState?.id === k.id ? ' ak-card--editing' : ''}`}>
+              {/* 卡片主体 */}
+              <div className="ak-card-main">
+                <div className="ak-card-left">
+                  <div className="ak-card-name">{k.name}</div>
+                  {k.note && <div className="ak-card-note">{k.note}</div>}
+                  <div className="ak-card-key">
+                    <span className="ak-key-val">{visible.has(k.id) ? k.key_value : mask(k.key_value)}</span>
+                    <button className="ak-key-toggle" onClick={() => toggleVisible(k.id)}>
+                      {visible.has(k.id) ? '隐藏' : '显示'}
+                    </button>
+                  </div>
+                </div>
+                <div className="ak-card-right">
+                  <div className="ak-card-time">创建于 {new Date(k.created_at).toLocaleDateString()}</div>
+                  <div className="ak-card-actions">
+                    <button className={`ak-btn ${editState?.id === k.id ? 'ak-btn--cancel' : 'ak-btn--edit'}`} onClick={() => startEdit(k)}>
+                      {editState?.id === k.id ? '收起' : '编辑'}
+                    </button>
+                    <button className="ak-btn ak-btn--delete" onClick={() => handleDelete(k.id)}>删除</button>
+                  </div>
+                </div>
+              </div>
+
+              {/* 编辑抽屉 */}
+              {editState?.id === k.id && (
+                <div className="ak-edit-drawer">
+                  <div className="ak-form-body">
+                    <div className="ak-field">
+                      <label className="ak-label">名称</label>
+                      <input className="ak-input" value={editState.name} onChange={e => setEditState(s => s ? { ...s, name: e.target.value } : s)} />
+                    </div>
+                    <div className="ak-field ak-field--wide">
+                      <label className="ak-label">Key 值</label>
+                      <input className="ak-input ak-input--mono" type="password" value={editState.key_value} onChange={e => setEditState(s => s ? { ...s, key_value: e.target.value } : s)} />
+                    </div>
+                    <div className="ak-field ak-field--wide">
+                      <label className="ak-label">备注</label>
+                      <input className="ak-input" placeholder="可选" value={editState.note} onChange={e => setEditState(s => s ? { ...s, note: e.target.value } : s)} />
+                    </div>
+                  </div>
+                  {editError && <div className="ak-form-error">{editError}</div>}
+                  <div className="ak-form-actions">
+                    <button className="ak-btn ak-btn--confirm" onClick={handleSave} disabled={saving}>{saving ? '保存中…' : '保存'}</button>
+                    <button className="ak-btn ak-btn--cancel" onClick={() => setEditState(null)}>取消</button>
+                  </div>
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+
+      {keys.length > 0 && <div className="ak-count">共 {keys.length} 个 Key</div>}
+    </div>
+  );
+}

+ 25 - 0
frontend/src/pages/Discounts.css

@@ -116,3 +116,28 @@
   padding: 40px 0;
   padding: 40px 0;
   font-size: 13px;
   font-size: 13px;
 }
 }
+
+/* 域名可点击样式 */
+.td-domain-link {
+  background: none;
+  border: none;
+  padding: 0;
+  color: #00d4ff;
+  font-family: monospace;
+  font-size: 13px;
+  cursor: pointer;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+  text-decoration-color: transparent;
+  transition: text-decoration-color 0.15s;
+}
+
+.td-domain-link:hover {
+  text-decoration-color: #00d4ff;
+}
+
+/* 模型价格按钮 */
+.discount-btn--config {
+  border-color: #3fb950;
+  color: #3fb950;
+}

+ 14 - 4
frontend/src/pages/Discounts.tsx

@@ -1,4 +1,5 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { deleteDiscount, fetchDiscounts, upsertDiscount } from '../api';
 import { deleteDiscount, fetchDiscounts, upsertDiscount } from '../api';
 import type { Discount } from '../types';
 import type { Discount } from '../types';
 import './Discounts.css';
 import './Discounts.css';
@@ -10,6 +11,7 @@ export function Discounts() {
   const [note, setNote] = useState('');
   const [note, setNote] = useState('');
   const [editing, setEditing] = useState<Discount | null>(null);
   const [editing, setEditing] = useState<Discount | null>(null);
   const [error, setError] = useState('');
   const [error, setError] = useState('');
+  const navigate = useNavigate();
 
 
   const load = () => fetchDiscounts().then(setList).catch(() => {});
   const load = () => fetchDiscounts().then(setList).catch(() => {});
 
 
@@ -97,15 +99,23 @@ export function Discounts() {
           <tbody>
           <tbody>
             {list.map(item => (
             {list.map(item => (
               <tr key={item.id}>
               <tr key={item.id}>
-                <td className="td-domain">{item.domain}</td>
+                <td className="td-domain">
+                    <button
+                      className="td-domain-link"
+                      onClick={() => navigate(`/discounts/${encodeURIComponent(item.domain)}/model-prices`)}
+                    >
+                      {item.domain}
+                    </button>
+                  </td>
                 <td className="td-discount">{item.discount}</td>
                 <td className="td-discount">{item.discount}</td>
                 <td className="td-discount">{Math.round(item.discount * 10)}折</td>
                 <td className="td-discount">{Math.round(item.discount * 10)}折</td>
                 <td className="td-note">{item.note ?? '—'}</td>
                 <td className="td-note">{item.note ?? '—'}</td>
                 <td className="td-time">{new Date(item.updated_at).toLocaleString()}</td>
                 <td className="td-time">{new Date(item.updated_at).toLocaleString()}</td>
                 <td className="td-actions">
                 <td className="td-actions">
-                  <button className="discount-btn discount-btn--sm" onClick={() => startEdit(item)}>编辑</button>
-                  <button className="discount-btn discount-btn--sm discount-btn--danger" onClick={() => handleDelete(item.id)}>删除</button>
-                </td>
+                    <button className="discount-btn discount-btn--sm discount-btn--config" onClick={() => navigate(`/discounts/${encodeURIComponent(item.domain)}/model-prices`)}>模型价格</button>
+                    <button className="discount-btn discount-btn--sm" onClick={() => startEdit(item)}>编辑</button>
+                    <button className="discount-btn discount-btn--sm discount-btn--danger" onClick={() => handleDelete(item.id)}>删除</button>
+                  </td>
               </tr>
               </tr>
             ))}
             ))}
           </tbody>
           </tbody>

+ 303 - 0
frontend/src/pages/DomainPrices.css

@@ -0,0 +1,303 @@
+.dp-page {
+  padding: 24px;
+  color: #e6edf3;
+}
+
+.dp-header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 12px;
+}
+
+.dp-back-btn {
+  background: none;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #8b949e;
+  padding: 6px 12px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.dp-back-btn:hover {
+  color: #e6edf3;
+  border-color: #8b949e;
+}
+
+.dp-title-wrap {
+  display: flex;
+  align-items: baseline;
+  gap: 12px;
+}
+
+.dp-title {
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 0.05em;
+  color: #00d4ff;
+}
+
+.dp-domain {
+  font-size: 14px;
+  font-family: monospace;
+  color: #8b949e;
+}
+
+.dp-hint {
+  font-size: 13px;
+  color: #8b949e;
+  margin-bottom: 16px;
+  line-height: 1.6;
+}
+
+/* Action bar */
+.dp-action-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  padding: 10px 14px;
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  margin-bottom: 12px;
+  opacity: 0;
+  pointer-events: none;
+  transform: translateY(-4px);
+  transition: opacity 0.15s, transform 0.15s;
+}
+
+.dp-action-bar--visible {
+  opacity: 1;
+  pointer-events: auto;
+  transform: translateY(0);
+}
+
+.dp-action-count {
+  font-size: 13px;
+  color: #00d4ff;
+  white-space: nowrap;
+  margin-right: 4px;
+}
+
+.dp-error {
+  color: #f85149;
+  font-size: 13px;
+  margin-bottom: 10px;
+}
+
+/* Inputs */
+.dp-input {
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  padding: 7px 11px;
+  font-size: 13px;
+}
+
+.dp-input--short {
+  width: 140px;
+}
+
+.dp-input--note {
+  flex: 1;
+  min-width: 120px;
+}
+
+.dp-input:focus {
+  outline: none;
+  border-color: #00d4ff;
+}
+
+/* Buttons */
+.dp-btn {
+  background: #21262d;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  padding: 7px 16px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+
+.dp-btn--primary {
+  background: #00d4ff22;
+  border-color: #00d4ff;
+  color: #00d4ff;
+}
+
+.dp-btn--danger {
+  border-color: #f85149;
+  color: #f85149;
+}
+
+.dp-btn--sm {
+  padding: 3px 10px;
+  font-size: 12px;
+}
+
+.dp-btn:hover:not(:disabled) { opacity: 0.8; }
+.dp-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* Search */
+.dp-search-wrap {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  padding: 8px 12px;
+  margin-bottom: 10px;
+  transition: border-color 0.15s;
+}
+
+.dp-search-wrap:focus-within {
+  border-color: #00d4ff;
+}
+
+.dp-search-icon {
+  font-size: 14px;
+  opacity: 0.5;
+  flex-shrink: 0;
+}
+
+.dp-search-input {
+  flex: 1;
+  background: none;
+  border: none;
+  outline: none;
+  color: #e6edf3;
+  font-size: 13px;
+}
+
+.dp-search-input::placeholder {
+  color: #484f58;
+}
+
+.dp-search-clear {
+  background: none;
+  border: none;
+  color: #8b949e;
+  cursor: pointer;
+  font-size: 12px;
+  padding: 0 2px;
+  line-height: 1;
+}
+
+.dp-search-clear:hover {
+  color: #e6edf3;
+}
+
+/* Table */
+.dp-table-wrap {
+  overflow-x: auto;
+  border: 1px solid #21262d;
+  border-radius: 8px;
+}
+
+.dp-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+.dp-table th {
+  text-align: left;
+  padding: 10px 14px;
+  border-bottom: 1px solid #30363d;
+  color: #8b949e;
+  font-weight: 500;
+  background: #0d1117;
+}
+
+.dp-th-check {
+  width: 40px;
+  text-align: center !important;
+}
+
+.dp-table td {
+  padding: 10px 14px;
+  border-bottom: 1px solid #161b22;
+  vertical-align: middle;
+}
+
+.dp-table tbody tr:last-child td {
+  border-bottom: none;
+}
+
+/* Row states */
+.dp-row {
+  cursor: pointer;
+  transition: background 0.1s;
+}
+
+.dp-row:hover {
+  background: #161b22;
+}
+
+.dp-row--selected {
+  background: #0c2233 !important;
+}
+
+.dp-row--configured .dp-td-model {
+  color: #e6edf3;
+}
+
+/* Checkbox */
+.dp-td-check {
+  width: 40px;
+  text-align: center;
+}
+
+.dp-checkbox {
+  width: 15px;
+  height: 15px;
+  accent-color: #00d4ff;
+  cursor: pointer;
+}
+
+/* Cell styles */
+.dp-td-model {
+  font-family: monospace;
+  color: #8b949e;
+}
+
+.dp-row--configured .dp-td-model {
+  color: #e6edf3;
+}
+
+.dp-discount-tag {
+  color: #f0c040;
+  font-family: monospace;
+}
+
+.dp-discount-label {
+  font-size: 11px;
+  color: #8b949e;
+}
+
+.dp-no-config {
+  color: #484f58;
+}
+
+.dp-td-note {
+  color: #8b949e;
+  font-size: 12px;
+}
+
+.dp-td-actions {
+  display: flex;
+  gap: 6px;
+}
+
+.dp-empty {
+  text-align: center;
+  color: #8b949e;
+  padding: 40px 0;
+  font-size: 13px;
+}

+ 233 - 0
frontend/src/pages/DomainPrices.tsx

@@ -0,0 +1,233 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+  batchUpsertDomainModelPrices,
+  deleteDomainModelPrice,
+  fetchDomainModelPrices,
+  fetchModels,
+  type DomainModelPrice,
+  type Model,
+} from '../api';
+import './DomainPrices.css';
+
+export function DomainPrices() {
+  const { domain } = useParams<{ domain: string }>();
+  const navigate = useNavigate();
+
+  const [prices, setPrices] = useState<DomainModelPrice[]>([]);
+  const [models, setModels] = useState<Model[]>([]);
+  const [error, setError] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  // 勾选的模型集合
+  const [selected, setSelected] = useState<Set<string>>(new Set());
+  // 批量折扣输入
+  const [batchDiscount, setBatchDiscount] = useState('');
+  const [batchNote, setBatchNote] = useState('');
+
+  const decodedDomain = domain ? decodeURIComponent(domain) : '';
+
+  const load = () => {
+    if (!decodedDomain) return;
+    fetchDomainModelPrices(decodedDomain).then(setPrices).catch(() => {});
+  };
+
+  useEffect(() => {
+    load();
+    fetchModels().then(setModels).catch(() => {});
+  }, [decodedDomain]);
+
+  const [search, setSearch] = useState('');
+
+  const configuredMap = new Map(prices.map(p => [p.model_name, p]));
+
+  const filteredModels = search.trim()
+    ? models.filter(m => m.name.toLowerCase().includes(search.trim().toLowerCase()))
+    : models;
+
+  const toggleOne = (name: string) => {
+    setSelected(prev => {
+      const next = new Set(prev);
+      next.has(name) ? next.delete(name) : next.add(name);
+      return next;
+    });
+  };
+
+  const toggleAll = () => {
+    if (selected.size === filteredModels.length && filteredModels.length > 0) {
+      // 取消当前搜索结果的全选
+      setSelected(prev => {
+        const next = new Set(prev);
+        filteredModels.forEach(m => next.delete(m.name));
+        return next;
+      });
+    } else {
+      // 全选当前搜索结果
+      setSelected(prev => {
+        const next = new Set(prev);
+        filteredModels.forEach(m => next.add(m.name));
+        return next;
+      });
+    }
+  };
+
+  const handleSave = async () => {
+    setError('');
+    if (selected.size === 0) { setError('请先勾选要配置的模型'); return; }
+    const d = parseFloat(batchDiscount);
+    if (isNaN(d) || d <= 0 || d > 1) {
+      setError('折扣系数需在 0~1 之间,如 0.8 表示八折');
+      return;
+    }
+    setSaving(true);
+    try {
+      const items = Array.from(selected).map(name => ({
+        model_name: name,
+        discount: d,
+        note: batchNote.trim() || undefined,
+      }));
+      await batchUpsertDomainModelPrices(decodedDomain, items);
+      setSelected(new Set());
+      setBatchDiscount('');
+      setBatchNote('');
+      load();
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleDelete = async (modelName: string) => {
+    await deleteDomainModelPrice(decodedDomain, modelName);
+    load();
+  };
+
+  const allChecked = filteredModels.length > 0 && filteredModels.every(m => selected.has(m.name));
+  const indeterminate = filteredModels.some(m => selected.has(m.name)) && !allChecked;
+
+  return (
+    <div className="dp-page">
+      <header className="dp-header">
+        <button className="dp-back-btn" onClick={() => navigate('/discounts')}>← 返回</button>
+        <div className="dp-title-wrap">
+          <span className="dp-title">模型折扣配置</span>
+          <span className="dp-domain">{decodedDomain}</span>
+        </div>
+      </header>
+
+      <p className="dp-hint">
+        勾选模型后,填写折扣系数统一保存。已配置的模型会显示当前折扣,未配置的沿用域名全局折扣。
+      </p>
+
+      {/* 操作栏:仅勾选后显示 */}
+      <div className={`dp-action-bar${selected.size > 0 ? ' dp-action-bar--visible' : ''}`}>
+        <span className="dp-action-count">已选 {selected.size} 个模型</span>
+        <input
+          className="dp-input dp-input--short"
+          placeholder="折扣系数,如 0.8"
+          value={batchDiscount}
+          onChange={e => setBatchDiscount(e.target.value)}
+        />
+        <input
+          className="dp-input dp-input--note"
+          placeholder="备注(可选)"
+          value={batchNote}
+          onChange={e => setBatchNote(e.target.value)}
+        />
+        <button className="dp-btn dp-btn--primary" onClick={handleSave} disabled={saving}>
+          {saving ? '保存中…' : '保存'}
+        </button>
+        <button className="dp-btn" onClick={() => setSelected(new Set())}>取消选择</button>
+      </div>
+      {error && <div className="dp-error">{error}</div>}
+
+      {/* 搜索框 */}
+      <div className="dp-search-wrap">
+        <span className="dp-search-icon">🔍</span>
+        <input
+          className="dp-search-input"
+          placeholder="搜索模型名称…"
+          value={search}
+          onChange={e => setSearch(e.target.value)}
+        />
+        {search && (
+          <button className="dp-search-clear" onClick={() => setSearch('')}>✕</button>
+        )}
+      </div>
+
+      {/* 模型列表 */}
+      <div className="dp-table-wrap">
+        <table className="dp-table">
+          <thead>
+            <tr>
+              <th className="dp-th-check">
+                <input
+                  type="checkbox"
+                  className="dp-checkbox"
+                  checked={allChecked}
+                  ref={el => { if (el) el.indeterminate = indeterminate; }}
+                  onChange={toggleAll}
+                />
+              </th>
+              <th>模型名称</th>
+              <th>当前折扣</th>
+              <th>备注</th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            {filteredModels.map(m => {
+              const configured = configuredMap.get(m.name);
+              const isSelected = selected.has(m.name);
+              return (
+                <tr
+                  key={m.id}
+                  className={`dp-row${isSelected ? ' dp-row--selected' : ''}${configured ? ' dp-row--configured' : ''}`}
+                  onClick={() => toggleOne(m.name)}
+                >
+                  <td className="dp-td-check" onClick={e => e.stopPropagation()}>
+                    <input
+                      type="checkbox"
+                      className="dp-checkbox"
+                      checked={isSelected}
+                      onChange={() => toggleOne(m.name)}
+                    />
+                  </td>
+                  <td className="dp-td-model">{m.name}</td>
+                  <td className="dp-td-discount">
+                    {configured ? (
+                      <span className="dp-discount-tag">
+                        {configured.discount} &nbsp;
+                        <span className="dp-discount-label">{Math.round(configured.discount * 10)}折</span>
+                      </span>
+                    ) : (
+                      <span className="dp-no-config">—</span>
+                    )}
+                  </td>
+                  <td className="dp-td-note">{configured?.note ?? '—'}</td>
+                  <td className="dp-td-actions" onClick={e => e.stopPropagation()}>
+                    {configured && (
+                      <button
+                        className="dp-btn dp-btn--sm dp-btn--danger"
+                        onClick={() => handleDelete(m.name)}
+                      >
+                        删除
+                      </button>
+                    )}
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+        {models.length === 0 && (
+          <div className="dp-empty">暂无模型数据</div>
+        )}
+        {models.length > 0 && filteredModels.length === 0 && (
+          <div className="dp-empty">没有匹配的模型</div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 160 - 0
frontend/src/pages/ModelGroups.tsx

@@ -0,0 +1,160 @@
+import { useEffect, useState } from 'react';
+import { createModelGroup, deleteModelGroup, fetchModelGroups, updateModelGroup } from '../api';
+import type { ModelGroup } from '../api';
+import './ApiKeys.css'; // 复用相同样式
+
+type EditState = { id: number; name: string; note: string };
+
+export function ModelGroups() {
+  const [groups, setGroups] = useState<ModelGroup[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const [showAdd, setShowAdd] = useState(false);
+  const [newName, setNewName] = useState('');
+  const [newNote, setNewNote] = useState('');
+  const [addError, setAddError] = useState<string | null>(null);
+  const [adding, setAdding] = useState(false);
+
+  const [editState, setEditState] = useState<EditState | null>(null);
+  const [editError, setEditError] = useState<string | null>(null);
+  const [saving, setSaving] = useState(false);
+
+  const load = () => {
+    setLoading(true);
+    fetchModelGroups()
+      .then(setGroups)
+      .catch(() => setError('加载失败'))
+      .finally(() => setLoading(false));
+  };
+
+  useEffect(() => { load(); }, []);
+
+  const handleAdd = async () => {
+    if (!newName.trim()) { setAddError('分组名称不能为空'); return; }
+    setAdding(true); setAddError(null);
+    try {
+      await createModelGroup(newName.trim(), newNote.trim() || undefined);
+      setNewName(''); setNewNote(''); setShowAdd(false);
+      load();
+    } catch (e) { setAddError(e instanceof Error ? e.message : String(e)); }
+    finally { setAdding(false); }
+  };
+
+  const handleDelete = async (id: number, name: string) => {
+    if (!confirm(`确认删除分组「${name}」?该分组下的模型将变为未分组状态。`)) return;
+    try { await deleteModelGroup(id); if (editState?.id === id) setEditState(null); load(); }
+    catch (e) { setError(e instanceof Error ? e.message : String(e)); }
+  };
+
+  const startEdit = (g: ModelGroup) => {
+    if (editState?.id === g.id) { setEditState(null); return; }
+    setEditState({ id: g.id, name: g.name, note: g.note ?? '' });
+    setEditError(null);
+  };
+
+  const handleSave = async () => {
+    if (!editState) return;
+    if (!editState.name.trim()) { setEditError('分组名称不能为空'); return; }
+    setSaving(true); setEditError(null);
+    try {
+      await updateModelGroup(editState.id, { name: editState.name.trim(), note: editState.note.trim() || null });
+      setEditState(null); load();
+    } catch (e) { setEditError(e instanceof Error ? e.message : String(e)); }
+    finally { setSaving(false); }
+  };
+
+  return (
+    <div className="ak-page">
+      <div className="ak-header">
+        <div>
+          <div className="ak-title">模型分组管理</div>
+          <div className="ak-subtitle">在此管理分组,然后在「模型管理」中批量归组</div>
+        </div>
+        <button className="ak-add-btn" onClick={() => { setShowAdd(v => !v); setAddError(null); }}>
+          + 添加分组
+        </button>
+      </div>
+
+      {error && <div className="ak-error">{error}</div>}
+
+      {showAdd && (
+        <div className="ak-form-card">
+          <div className="ak-form-title">添加新分组</div>
+          <div className="ak-form-body">
+            <div className="ak-field">
+              <label className="ak-label">分组名称 <span className="ak-required">*</span></label>
+              <input className="ak-input" placeholder="如:文本生成、图像生成" value={newName}
+                onChange={e => setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} />
+            </div>
+            <div className="ak-field ak-field--wide">
+              <label className="ak-label">备注 <span className="ak-optional">可选</span></label>
+              <input className="ak-input" placeholder="描述这个分组的用途" value={newNote}
+                onChange={e => setNewNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} />
+            </div>
+          </div>
+          {addError && <div className="ak-form-error">{addError}</div>}
+          <div className="ak-form-actions">
+            <button className="ak-btn ak-btn--confirm" onClick={handleAdd} disabled={adding}>{adding ? '添加中…' : '确认添加'}</button>
+            <button className="ak-btn ak-btn--cancel" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
+          </div>
+        </div>
+      )}
+
+      {loading ? (
+        <div className="ak-empty">加载中…</div>
+      ) : groups.length === 0 ? (
+        <div className="ak-empty">暂无分组,点击「添加分组」开始</div>
+      ) : (
+        <div className="ak-list">
+          {groups.map(g => (
+            <div key={g.id} className={`ak-card${editState?.id === g.id ? ' ak-card--editing' : ''}`}>
+              <div className="ak-card-main">
+                <div className="ak-card-left">
+                  <div className="ak-card-name">
+                    {g.name}
+                    <span className="mg-count-badge">{g.model_count} 个模型</span>
+                  </div>
+                  {g.note && <div className="ak-card-note">{g.note}</div>}
+                </div>
+                <div className="ak-card-right">
+                  <div className="ak-card-time">创建于 {new Date(g.created_at).toLocaleDateString()}</div>
+                  <div className="ak-card-actions">
+                    <button className={`ak-btn ${editState?.id === g.id ? 'ak-btn--cancel' : 'ak-btn--edit'}`} onClick={() => startEdit(g)}>
+                      {editState?.id === g.id ? '收起' : '编辑'}
+                    </button>
+                    <button className="ak-btn ak-btn--delete" onClick={() => handleDelete(g.id, g.name)}>删除</button>
+                  </div>
+                </div>
+              </div>
+
+              {editState?.id === g.id && (
+                <div className="ak-edit-drawer">
+                  <div className="ak-form-body">
+                    <div className="ak-field">
+                      <label className="ak-label">分组名称</label>
+                      <input className="ak-input" value={editState.name}
+                        onChange={e => setEditState(s => s ? { ...s, name: e.target.value } : s)} />
+                    </div>
+                    <div className="ak-field ak-field--wide">
+                      <label className="ak-label">备注</label>
+                      <input className="ak-input" placeholder="可选" value={editState.note}
+                        onChange={e => setEditState(s => s ? { ...s, note: e.target.value } : s)} />
+                    </div>
+                  </div>
+                  {editError && <div className="ak-form-error">{editError}</div>}
+                  <div className="ak-form-actions">
+                    <button className="ak-btn ak-btn--confirm" onClick={handleSave} disabled={saving}>{saving ? '保存中…' : '保存'}</button>
+                    <button className="ak-btn ak-btn--cancel" onClick={() => setEditState(null)}>取消</button>
+                  </div>
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+
+      {groups.length > 0 && <div className="ak-count">共 {groups.length} 个分组</div>}
+    </div>
+  );
+}

+ 629 - 0
frontend/src/pages/Models.css

@@ -0,0 +1,629 @@
+.models-page {
+  padding: 24px;
+  padding-bottom: 52px;
+  color: #e6edf3;
+}
+
+/* ── 顶部标题栏 ── */
+.models-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+}
+
+.models-title {
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 0.05em;
+  color: #00d4ff;
+}
+
+.models-add-btn {
+  background: #00d4ff22;
+  border: 1px solid #00d4ff;
+  border-radius: 6px;
+  color: #00d4ff;
+  padding: 8px 18px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+  font-family: inherit;
+  transition: background 0.15s;
+}
+.models-add-btn:hover { background: #00d4ff33; }
+
+/* ── 错误提示 ── */
+.models-error {
+  background: rgba(248, 81, 73, 0.1);
+  border: 1px solid #f85149;
+  color: #f85149;
+  padding: 10px 14px;
+  border-radius: 6px;
+  font-size: 13px;
+  margin-bottom: 16px;
+}
+
+/* ── 新增 / 编辑表单卡片 ── */
+.models-form-card {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  padding: 18px 20px;
+  margin-bottom: 20px;
+}
+
+/* 横向排列的字段行 */
+.models-form-row {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+
+.models-form-field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  flex: 1;
+  min-width: 160px;
+}
+
+.models-form-field--wide {
+  flex: 2;
+  min-width: 260px;
+}
+
+.models-form-label {
+  font-size: 12px;
+  color: #8b949e;
+}
+
+.models-optional {
+  font-size: 11px;
+  color: #8b949e;
+  opacity: 0.6;
+  margin-left: 4px;
+}
+
+.models-input {
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  font-family: inherit;
+  font-size: 13px;
+  padding: 8px 12px;
+  outline: none;
+  width: 100%;
+  box-sizing: border-box;
+  transition: border-color 0.15s;
+}
+.models-input:focus { border-color: #00d4ff; }
+
+.models-form-error {
+  color: #f85149;
+  font-size: 12px;
+  margin-bottom: 10px;
+}
+
+.models-form-actions {
+  display: flex;
+  gap: 8px;
+}
+
+/* ── 通用按钮 ── */
+.models-btn {
+  background: #21262d;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  padding: 6px 14px;
+  font-size: 12px;
+  cursor: pointer;
+  white-space: nowrap;
+  font-family: inherit;
+  transition: opacity 0.15s;
+}
+.models-btn:hover:not(:disabled) { opacity: 0.8; }
+.models-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.models-btn--confirm {
+  background: #3fb95022;
+  border-color: #3fb950;
+  color: #3fb950;
+}
+.models-btn--cancel { color: #8b949e; }
+.models-btn--edit {
+  background: #00d4ff11;
+  border-color: #00d4ff;
+  color: #00d4ff;
+}
+.models-btn--save {
+  background: #3fb95022;
+  border-color: #3fb950;
+  color: #3fb950;
+}
+.models-btn--delete {
+  background: #f8514911;
+  border-color: #f85149;
+  color: #f85149;
+}
+
+/* ── 表格 ── */
+.models-table-wrap {
+  overflow-x: auto;
+}
+
+.models-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+.models-table th {
+  text-align: left;
+  padding: 10px 12px;
+  border-bottom: 1px solid #30363d;
+  color: #8b949e;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+.models-table td {
+  padding: 10px 12px;
+  border-bottom: 1px solid #21262d;
+  vertical-align: middle;
+}
+
+.models-row:hover td { background: rgba(0, 212, 255, 0.03); }
+.models-row--active td { background: rgba(0, 212, 255, 0.05); }
+
+/* 编辑抽屉行 */
+.models-row-edit td {
+  padding: 0;
+  border-bottom: 1px solid #30363d;
+  background: #0d1117;
+}
+
+.models-edit-drawer {
+  padding: 16px 20px;
+}
+
+.models-edit-fields {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+
+.models-edit-field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  flex: 1;
+  min-width: 160px;
+}
+
+.models-edit-field--wide {
+  flex: 2;
+  min-width: 260px;
+}
+
+/* 列样式 */
+.models-td-name {
+  color: #00d4ff;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+.models-td-url {
+  max-width: 0;          /* 配合 table-layout 让 overflow 生效 */
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.models-url-link {
+  color: #8b949e;
+  font-size: 12px;
+  text-decoration: none;
+  transition: color 0.15s;
+}
+.models-url-link:hover { color: #00d4ff; }
+
+.models-td-key { white-space: nowrap; }
+
+.models-key-wrap {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.models-key-val {
+  font-family: monospace;
+  font-size: 12px;
+  color: #3fb950;
+  letter-spacing: 0.04em;
+}
+
+.models-key-toggle {
+  background: transparent;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #8b949e;
+  font-size: 11px;
+  padding: 1px 6px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: border-color 0.15s, color 0.15s;
+}
+.models-key-toggle:hover {
+  border-color: #8b949e;
+  color: #e6edf3;
+}
+
+.models-key-empty { color: #8b949e; }
+
+.models-td-time {
+  color: #8b949e;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.models-actions {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+}
+
+.models-inline-error {
+  color: #f85149;
+  font-size: 11px;
+}
+
+/* ── 状态提示 ── */
+.models-loading,
+.models-empty {
+  color: #8b949e;
+  font-size: 13px;
+  padding: 48px 0;
+  text-align: center;
+}
+
+.models-count {
+  margin-top: 14px;
+  font-size: 12px;
+  color: #8b949e;
+  text-align: right;
+}
+
+/* ── 批量配置按钮 ── */
+.models-header-actions {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+}
+
+.models-batch-btn {
+  background: #3fb95022;
+  border: 1px solid #3fb950;
+  border-radius: 6px;
+  color: #3fb950;
+  padding: 8px 16px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+  font-family: inherit;
+  transition: background 0.15s;
+}
+.models-batch-btn:hover { background: #3fb95033; }
+
+/* ── Modal 遮罩 ── */
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.65);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+  padding: 20px;
+}
+
+.modal-box {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 10px;
+  width: 100%;
+  max-width: 640px;
+  max-height: 85vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* Modal 标题 */
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 20px;
+  border-bottom: 1px solid #30363d;
+  flex-shrink: 0;
+}
+
+.modal-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #e6edf3;
+}
+
+.modal-close {
+  background: transparent;
+  border: none;
+  color: #8b949e;
+  font-size: 16px;
+  cursor: pointer;
+  padding: 2px 6px;
+  border-radius: 4px;
+  transition: color 0.15s;
+}
+.modal-close:hover { color: #e6edf3; }
+
+/* Modal 内容区(可滚动) */
+.modal-section {
+  padding: 16px 20px;
+  border-bottom: 1px solid #21262d;
+  overflow-y: auto;
+}
+
+.modal-section-label {
+  font-size: 12px;
+  color: #8b949e;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.modal-required { color: #f85149; }
+
+.modal-manage-link {
+  margin-left: auto;
+  font-size: 11px;
+  color: #00d4ff;
+  text-decoration: none;
+}
+.modal-manage-link:hover { text-decoration: underline; }
+
+.modal-count-badge {
+  background: #21262d;
+  border: 1px solid #30363d;
+  border-radius: 10px;
+  padding: 1px 8px;
+  font-size: 11px;
+  color: #8b949e;
+}
+
+.modal-select-all {
+  background: transparent;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #8b949e;
+  font-size: 11px;
+  padding: 2px 8px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: border-color 0.15s;
+}
+.modal-select-all:hover { border-color: #8b949e; color: #e6edf3; }
+
+.modal-empty-hint {
+  color: #8b949e;
+  font-size: 13px;
+  padding: 12px 0;
+}
+.modal-empty-hint a { color: #00d4ff; }
+
+/* Key 选择列表 */
+.modal-key-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 180px;
+  overflow-y: auto;
+}
+
+.modal-key-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  padding: 10px 12px;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: border-color 0.15s, background 0.15s;
+}
+.modal-key-item:hover { border-color: #444d56; background: #1c2128; }
+.modal-key-item--selected { border-color: #00d4ff55; background: #00d4ff0a; }
+.modal-key-item input[type="radio"] { margin-top: 2px; flex-shrink: 0; accent-color: #00d4ff; }
+
+.modal-key-info {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.modal-key-name {
+  font-size: 13px;
+  color: #e6edf3;
+  font-weight: 500;
+}
+.modal-key-name--clear { color: #8b949e; font-style: italic; }
+
+.modal-key-note {
+  font-size: 11px;
+  color: #8b949e;
+}
+
+/* 模型搜索 */
+.modal-search {
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  font-family: inherit;
+  font-size: 13px;
+  padding: 7px 12px;
+  outline: none;
+  width: 100%;
+  box-sizing: border-box;
+  margin-bottom: 8px;
+  transition: border-color 0.15s;
+}
+.modal-search:focus { border-color: #00d4ff; }
+
+/* 模型选择列表 */
+.modal-model-list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-height: 220px;
+  overflow-y: auto;
+}
+
+.modal-model-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 10px;
+  border: 1px solid transparent;
+  border-radius: 5px;
+  cursor: pointer;
+  transition: background 0.12s, border-color 0.12s;
+}
+.modal-model-item:hover { background: #1c2128; }
+.modal-model-item--selected { background: #00d4ff08; border-color: #00d4ff33; }
+.modal-model-item input[type="checkbox"] { flex-shrink: 0; accent-color: #00d4ff; }
+
+.modal-model-name {
+  font-size: 13px;
+  color: #e6edf3;
+  flex: 1;
+}
+
+.modal-model-has-key {
+  font-size: 11px;
+  color: #3fb950;
+  background: #3fb95015;
+  border: 1px solid #3fb95040;
+  border-radius: 4px;
+  padding: 1px 6px;
+  white-space: nowrap;
+}
+
+.modal-model-no-key {
+  font-size: 11px;
+  color: #8b949e;
+  white-space: nowrap;
+}
+
+/* Modal 错误 */
+.modal-error {
+  padding: 0 20px 12px;
+  color: #f85149;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+
+/* Modal 底部 */
+.modal-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 14px 20px;
+  border-top: 1px solid #30363d;
+  flex-shrink: 0;
+}
+
+.modal-btn {
+  background: #21262d;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  color: #e6edf3;
+  padding: 8px 18px;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: opacity 0.15s;
+}
+.modal-btn:hover:not(:disabled) { opacity: 0.8; }
+.modal-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.modal-btn--cancel { color: #8b949e; }
+.modal-btn--confirm {
+  background: #00d4ff22;
+  border-color: #00d4ff;
+  color: #00d4ff;
+}
+
+/* ── Key 名称徽章 ── */
+.models-key-badge {
+  display: inline-block;
+  background: #3fb95018;
+  border: 1px solid #3fb95050;
+  border-radius: 4px;
+  color: #3fb950;
+  font-size: 12px;
+  padding: 2px 8px;
+  white-space: nowrap;
+  max-width: 140px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* ── Select 下拉 ── */
+.models-select {
+  appearance: none;
+  cursor: pointer;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238b949e'/%3E%3C/svg%3E");
+  background-repeat: no-repeat;
+  background-position: right 10px center;
+  padding-right: 28px;
+}
+.models-select option { background: #161b22; }
+
+/* ── Modal 配置预览 ── */
+.modal-preview {
+  padding: 10px 20px;
+  font-size: 13px;
+  color: #8b949e;
+  border-top: 1px solid #21262d;
+}
+.modal-preview strong { color: #e6edf3; }
+.modal-preview-key { color: #00d4ff; margin-left: 4px; }
+.modal-preview-clear { color: #f85149; margin-left: 4px; }
+
+/* 分组徽章 */
+.models-group-badge {
+  display: inline-block;
+  background: #8957e511;
+  border: 1px solid #8957e540;
+  border-radius: 4px;
+  color: #b083f0;
+  font-size: 12px;
+  padding: 2px 8px;
+  white-space: nowrap;
+  max-width: 110px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 批量分组按钮 */
+.models-batch-btn--group {
+  background: #8957e511;
+  border-color: #8957e5;
+  color: #b083f0;
+}
+.models-batch-btn--group:hover { background: #8957e522; }

+ 430 - 0
frontend/src/pages/Models.tsx

@@ -0,0 +1,430 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import {
+  batchAssignApiKey, batchAssignModelGroup,
+  createModel, deleteModel,
+  fetchApiKeys, fetchModelGroups, fetchModels,
+  updateModel,
+} from '../api';
+import type { ApiKey, Model, ModelGroup } from '../api';
+import './Models.css';
+
+type EditState = { id: number; name: string; url: string; api_key_id: number | null; group_id: number | null };
+
+// ── 批量配置 API Key Modal ─────────────────────────────────────────────────────
+function BatchKeyModal({ models, apiKeys, onClose, onDone }: {
+  models: Model[]; apiKeys: ApiKey[]; onClose: () => void; onDone: () => void;
+}) {
+  const [selectedKey, setSelectedKey] = useState<number | null | 'clear'>(null);
+  const [selectedModels, setSelectedModels] = useState<Set<number>>(new Set());
+  const [search, setSearch] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const filtered = models.filter(m => !search || m.name.toLowerCase().includes(search.toLowerCase()));
+  const toggleModel = (id: number) => setSelectedModels(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
+  const toggleAll = () => selectedModels.size === filtered.length ? setSelectedModels(new Set()) : setSelectedModels(new Set(filtered.map(m => m.id)));
+
+  const handleSubmit = async () => {
+    if (selectedKey === null) { setError('请先选择一个 API Key'); return; }
+    if (selectedModels.size === 0) { setError('请至少选择一个模型'); return; }
+    setSubmitting(true); setError(null);
+    try {
+      const r = await batchAssignApiKey(selectedKey === 'clear' ? null : selectedKey, Array.from(selectedModels));
+      onDone(); onClose(); alert(`已成功配置 ${r.updated} 个模型`);
+    } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
+    finally { setSubmitting(false); }
+  };
+
+  const selectedKeyObj = typeof selectedKey === 'number' ? apiKeys.find(k => k.id === selectedKey) : null;
+
+  return (
+    <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
+      <div className="modal-box">
+        <div className="modal-header">
+          <span className="modal-title">批量配置 API Key</span>
+          <button className="modal-close" onClick={onClose}>✕</button>
+        </div>
+        <div className="modal-section">
+          <div className="modal-section-label">
+            选择 API Key <span className="modal-required">*</span>
+            <Link to="/api-keys" className="modal-manage-link" onClick={onClose}>去管理 Key →</Link>
+          </div>
+          {apiKeys.length === 0 ? (
+            <div className="modal-empty-hint">暂无可用 Key,请先前往 <Link to="/api-keys" onClick={onClose}>API Key 管理</Link> 添加</div>
+          ) : (
+            <div className="modal-key-list">
+              <label className={`modal-key-item${selectedKey === 'clear' ? ' modal-key-item--selected' : ''}`}>
+                <input type="radio" name="apikey" checked={selectedKey === 'clear'} onChange={() => setSelectedKey('clear')} />
+                <div className="modal-key-info"><span className="modal-key-name modal-key-name--clear">清除绑定</span><span className="modal-key-note">将所选模型的 API Key 设为空</span></div>
+              </label>
+              {apiKeys.map(k => (
+                <label key={k.id} className={`modal-key-item${selectedKey === k.id ? ' modal-key-item--selected' : ''}`}>
+                  <input type="radio" name="apikey" checked={selectedKey === k.id} onChange={() => setSelectedKey(k.id)} />
+                  <div className="modal-key-info"><span className="modal-key-name">{k.name}</span>{k.note && <span className="modal-key-note">{k.note}</span>}</div>
+                </label>
+              ))}
+            </div>
+          )}
+        </div>
+        <div className="modal-section">
+          <div className="modal-section-label">
+            选择模型
+            <span className="modal-count-badge">{selectedModels.size} / {models.length}</span>
+            <button className="modal-select-all" onClick={toggleAll}>{selectedModels.size === filtered.length && filtered.length > 0 ? '取消全选' : '全选'}</button>
+          </div>
+          <input className="modal-search" placeholder="搜索模型名称…" value={search} onChange={e => setSearch(e.target.value)} />
+          <div className="modal-model-list">
+            {filtered.map(m => (
+              <label key={m.id} className={`modal-model-item${selectedModels.has(m.id) ? ' modal-model-item--selected' : ''}`}>
+                <input type="checkbox" checked={selectedModels.has(m.id)} onChange={() => toggleModel(m.id)} />
+                <span className="modal-model-name">{m.name}</span>
+                {m.api_key_name ? <span className="modal-model-has-key">{m.api_key_name}</span> : <span className="modal-model-no-key">未绑定</span>}
+              </label>
+            ))}
+          </div>
+        </div>
+        {selectedKey !== null && selectedModels.size > 0 && (
+          <div className="modal-preview">
+            将 <strong>{selectedModels.size}</strong> 个模型绑定到
+            {selectedKey === 'clear' ? <span className="modal-preview-clear">「清除绑定」</span> : <span className="modal-preview-key">「{selectedKeyObj?.name}」</span>}
+          </div>
+        )}
+        {error && <div className="modal-error">{error}</div>}
+        <div className="modal-footer">
+          <button className="modal-btn modal-btn--cancel" onClick={onClose}>关闭</button>
+          <button className="modal-btn modal-btn--confirm" onClick={handleSubmit} disabled={submitting}>
+            {submitting ? '配置中…' : `确认配置(${selectedModels.size} 个模型)`}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── 批量分组 Modal ─────────────────────────────────────────────────────────────
+function BatchGroupModal({ models, groups, onClose, onDone }: {
+  models: Model[]; groups: ModelGroup[]; onClose: () => void; onDone: () => void;
+}) {
+  const [selectedGroup, setSelectedGroup] = useState<number | null | 'clear'>(null);
+  const [selectedModels, setSelectedModels] = useState<Set<number>>(new Set());
+  const [search, setSearch] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const filtered = models.filter(m => !search || m.name.toLowerCase().includes(search.toLowerCase()));
+  const toggleModel = (id: number) => setSelectedModels(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
+  const toggleAll = () => selectedModels.size === filtered.length ? setSelectedModels(new Set()) : setSelectedModels(new Set(filtered.map(m => m.id)));
+
+  const handleSubmit = async () => {
+    if (selectedGroup === null) { setError('请先选择一个分组'); return; }
+    if (selectedModels.size === 0) { setError('请至少选择一个模型'); return; }
+    setSubmitting(true); setError(null);
+    try {
+      const r = await batchAssignModelGroup(selectedGroup === 'clear' ? null : selectedGroup, Array.from(selectedModels));
+      onDone(); onClose(); alert(`已成功分组 ${r.updated} 个模型`);
+    } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
+    finally { setSubmitting(false); }
+  };
+
+  const selectedGroupObj = typeof selectedGroup === 'number' ? groups.find(g => g.id === selectedGroup) : null;
+
+  return (
+    <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
+      <div className="modal-box">
+        <div className="modal-header">
+          <span className="modal-title">批量设置分组</span>
+          <button className="modal-close" onClick={onClose}>✕</button>
+        </div>
+        <div className="modal-section">
+          <div className="modal-section-label">
+            选择分组 <span className="modal-required">*</span>
+            <Link to="/model-groups" className="modal-manage-link" onClick={onClose}>去管理分组 →</Link>
+          </div>
+          {groups.length === 0 ? (
+            <div className="modal-empty-hint">暂无分组,请先前往 <Link to="/model-groups" onClick={onClose}>分组管理</Link> 添加</div>
+          ) : (
+            <div className="modal-key-list">
+              <label className={`modal-key-item${selectedGroup === 'clear' ? ' modal-key-item--selected' : ''}`}>
+                <input type="radio" name="group" checked={selectedGroup === 'clear'} onChange={() => setSelectedGroup('clear')} />
+                <div className="modal-key-info"><span className="modal-key-name modal-key-name--clear">清除分组</span><span className="modal-key-note">将所选模型设为未分组</span></div>
+              </label>
+              {groups.map(g => (
+                <label key={g.id} className={`modal-key-item${selectedGroup === g.id ? ' modal-key-item--selected' : ''}`}>
+                  <input type="radio" name="group" checked={selectedGroup === g.id} onChange={() => setSelectedGroup(g.id)} />
+                  <div className="modal-key-info">
+                    <span className="modal-key-name">{g.name}</span>
+                    {g.note && <span className="modal-key-note">{g.note}</span>}
+                  </div>
+                </label>
+              ))}
+            </div>
+          )}
+        </div>
+        <div className="modal-section">
+          <div className="modal-section-label">
+            选择模型
+            <span className="modal-count-badge">{selectedModels.size} / {models.length}</span>
+            <button className="modal-select-all" onClick={toggleAll}>{selectedModels.size === filtered.length && filtered.length > 0 ? '取消全选' : '全选'}</button>
+          </div>
+          <input className="modal-search" placeholder="搜索模型名称…" value={search} onChange={e => setSearch(e.target.value)} />
+          <div className="modal-model-list">
+            {filtered.map(m => (
+              <label key={m.id} className={`modal-model-item${selectedModels.has(m.id) ? ' modal-model-item--selected' : ''}`}>
+                <input type="checkbox" checked={selectedModels.has(m.id)} onChange={() => toggleModel(m.id)} />
+                <span className="modal-model-name">{m.name}</span>
+                {m.group_name ? <span className="modal-model-has-key">{m.group_name}</span> : <span className="modal-model-no-key">未分组</span>}
+              </label>
+            ))}
+          </div>
+        </div>
+        {selectedGroup !== null && selectedModels.size > 0 && (
+          <div className="modal-preview">
+            将 <strong>{selectedModels.size}</strong> 个模型移入
+            {selectedGroup === 'clear' ? <span className="modal-preview-clear">「清除分组」</span> : <span className="modal-preview-key">「{selectedGroupObj?.name}」</span>}
+          </div>
+        )}
+        {error && <div className="modal-error">{error}</div>}
+        <div className="modal-footer">
+          <button className="modal-btn modal-btn--cancel" onClick={onClose}>关闭</button>
+          <button className="modal-btn modal-btn--confirm" onClick={handleSubmit} disabled={submitting}>
+            {submitting ? '设置中…' : `确认设置(${selectedModels.size} 个模型)`}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── 主页面 ────────────────────────────────────────────────────────────────────
+export function Models() {
+  const [models, setModels] = useState<Model[]>([]);
+  const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
+  const [groups, setGroups] = useState<ModelGroup[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const [showAdd, setShowAdd] = useState(false);
+  const [newName, setNewName] = useState('');
+  const [newUrl, setNewUrl] = useState('');
+  const [newApiKeyId, setNewApiKeyId] = useState<number | ''>('');
+  const [newGroupId, setNewGroupId] = useState<number | ''>('');
+  const [addError, setAddError] = useState<string | null>(null);
+  const [adding, setAdding] = useState(false);
+
+  const [editState, setEditState] = useState<EditState | null>(null);
+  const [editError, setEditError] = useState<string | null>(null);
+  const [saving, setSaving] = useState(false);
+
+  const [showBatchKey, setShowBatchKey] = useState(false);
+  const [showBatchGroup, setShowBatchGroup] = useState(false);
+
+  const load = () => {
+    setLoading(true);
+    Promise.all([fetchModels(), fetchApiKeys(), fetchModelGroups()])
+      .then(([m, k, g]) => { setModels(m); setApiKeys(k); setGroups(g); })
+      .catch(() => setError('加载失败'))
+      .finally(() => setLoading(false));
+  };
+
+  useEffect(() => { load(); }, []);
+
+  const handleAdd = async () => {
+    if (!newName.trim() || !newUrl.trim()) { setAddError('名称和 URL 不能为空'); return; }
+    setAdding(true); setAddError(null);
+    try {
+      await createModel(newName.trim(), newUrl.trim(),
+        newApiKeyId !== '' ? newApiKeyId : undefined,
+        newGroupId !== '' ? newGroupId : undefined,
+      );
+      setNewName(''); setNewUrl(''); setNewApiKeyId(''); setNewGroupId(''); setShowAdd(false);
+      load();
+    } catch (e) { setAddError(e instanceof Error ? e.message : String(e)); }
+    finally { setAdding(false); }
+  };
+
+  const handleDelete = async (id: number) => {
+    if (!confirm('确认删除该模型?')) return;
+    try { await deleteModel(id); if (editState?.id === id) setEditState(null); load(); }
+    catch (e) { setError(e instanceof Error ? e.message : String(e)); }
+  };
+
+  const startEdit = (m: Model) => {
+    if (editState?.id === m.id) { setEditState(null); return; }
+    setEditState({ id: m.id, name: m.name, url: m.url, api_key_id: m.api_key_id, group_id: m.group_id });
+    setEditError(null);
+  };
+
+  const handleSave = async () => {
+    if (!editState) return;
+    if (!editState.name.trim() || !editState.url.trim()) { setEditError('名称和 URL 不能为空'); return; }
+    setSaving(true); setEditError(null);
+    try {
+      await updateModel(editState.id, {
+        name: editState.name.trim(), url: editState.url.trim(),
+        api_key_id: editState.api_key_id, group_id: editState.group_id,
+      });
+      setEditState(null); load();
+    } catch (e) { setEditError(e instanceof Error ? e.message : String(e)); }
+    finally { setSaving(false); }
+  };
+
+  return (
+    <div className="models-page">
+      <div className="models-header">
+        <span className="models-title">模型管理</span>
+        <div className="models-header-actions">
+          <button className="models-batch-btn models-batch-btn--group" onClick={() => setShowBatchGroup(true)}>
+            ▤ 批量设置分组
+          </button>
+          <button className="models-batch-btn" onClick={() => setShowBatchKey(true)}>
+            🔑 批量配置 API Key
+          </button>
+          <button className="models-add-btn" onClick={() => { setShowAdd(v => !v); setAddError(null); }}>
+            + 添加模型
+          </button>
+        </div>
+      </div>
+
+      {error && <div className="models-error">{error}</div>}
+
+      {showAdd && (
+        <div className="models-form-card">
+          <div className="models-form-row">
+            <div className="models-form-field">
+              <label className="models-form-label">模型名称</label>
+              <input className="models-input" placeholder="如 qwen3-max" value={newName}
+                onChange={e => setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} />
+            </div>
+            <div className="models-form-field models-form-field--wide">
+              <label className="models-form-label">URL</label>
+              <input className="models-input" placeholder="https://bailian.console.aliyun.com/..." value={newUrl}
+                onChange={e => setNewUrl(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} />
+            </div>
+            <div className="models-form-field">
+              <label className="models-form-label">分组 <span className="models-optional">可选</span></label>
+              <select className="models-input models-select" value={newGroupId}
+                onChange={e => setNewGroupId(e.target.value === '' ? '' : Number(e.target.value))}>
+                <option value="">— 不分组 —</option>
+                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
+              </select>
+            </div>
+            <div className="models-form-field">
+              <label className="models-form-label">API Key <span className="models-optional">可选</span></label>
+              <select className="models-input models-select" value={newApiKeyId}
+                onChange={e => setNewApiKeyId(e.target.value === '' ? '' : Number(e.target.value))}>
+                <option value="">— 不绑定 —</option>
+                {apiKeys.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
+              </select>
+            </div>
+          </div>
+          {addError && <div className="models-form-error">{addError}</div>}
+          <div className="models-form-actions">
+            <button className="models-btn models-btn--confirm" onClick={handleAdd} disabled={adding}>{adding ? '添加中…' : '确认添加'}</button>
+            <button className="models-btn models-btn--cancel" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
+          </div>
+        </div>
+      )}
+
+      {loading ? (
+        <div className="models-loading">加载中…</div>
+      ) : models.length === 0 ? (
+        <div className="models-empty">暂无模型,点击「添加模型」开始</div>
+      ) : (
+        <div className="models-table-wrap">
+          <table className="models-table">
+            <thead>
+              <tr>
+                <th style={{ width: 150 }}>名称</th>
+                <th>URL</th>
+                <th style={{ width: 120 }}>分组</th>
+                <th style={{ width: 130 }}>API Key</th>
+                <th style={{ width: 150 }}>创建时间</th>
+                <th style={{ width: 120 }}>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {models.map(m => (
+                <>
+                  <tr key={m.id} className={`models-row${editState?.id === m.id ? ' models-row--active' : ''}`}>
+                    <td className="models-td-name">{m.name}</td>
+                    <td className="models-td-url">
+                      <a href={m.url} target="_blank" rel="noopener noreferrer" className="models-url-link" title={m.url}>{m.url}</a>
+                    </td>
+                    <td className="models-td-key">
+                      {m.group_name
+                        ? <span className="models-group-badge">{m.group_name}</span>
+                        : <span className="models-key-empty">—</span>}
+                    </td>
+                    <td className="models-td-key">
+                      {m.api_key_name
+                        ? <span className="models-key-badge">{m.api_key_name}</span>
+                        : <span className="models-key-empty">—</span>}
+                    </td>
+                    <td className="models-td-time">{new Date(m.created_at).toLocaleString()}</td>
+                    <td>
+                      <div className="models-actions">
+                        <button className={`models-btn ${editState?.id === m.id ? 'models-btn--cancel' : 'models-btn--edit'}`} onClick={() => startEdit(m)}>
+                          {editState?.id === m.id ? '收起' : '编辑'}
+                        </button>
+                        <button className="models-btn models-btn--delete" onClick={() => handleDelete(m.id)}>删除</button>
+                      </div>
+                    </td>
+                  </tr>
+                  {editState?.id === m.id && (
+                    <tr key={`edit-${m.id}`} className="models-row-edit">
+                      <td colSpan={6}>
+                        <div className="models-edit-drawer">
+                          <div className="models-edit-fields">
+                            <div className="models-edit-field">
+                              <label className="models-form-label">名称</label>
+                              <input className="models-input" value={editState.name}
+                                onChange={e => setEditState(s => s ? { ...s, name: e.target.value } : s)} />
+                            </div>
+                            <div className="models-edit-field models-edit-field--wide">
+                              <label className="models-form-label">URL</label>
+                              <input className="models-input" value={editState.url}
+                                onChange={e => setEditState(s => s ? { ...s, url: e.target.value } : s)} />
+                            </div>
+                            <div className="models-edit-field">
+                              <label className="models-form-label">分组</label>
+                              <select className="models-input models-select"
+                                value={editState.group_id ?? ''}
+                                onChange={e => setEditState(s => s ? { ...s, group_id: e.target.value === '' ? null : Number(e.target.value) } : s)}>
+                                <option value="">— 不分组 —</option>
+                                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
+                              </select>
+                            </div>
+                            <div className="models-edit-field">
+                              <label className="models-form-label">API Key</label>
+                              <select className="models-input models-select"
+                                value={editState.api_key_id ?? ''}
+                                onChange={e => setEditState(s => s ? { ...s, api_key_id: e.target.value === '' ? null : Number(e.target.value) } : s)}>
+                                <option value="">— 不绑定 —</option>
+                                {apiKeys.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
+                              </select>
+                            </div>
+                          </div>
+                          {editError && <div className="models-form-error">{editError}</div>}
+                          <div className="models-form-actions">
+                            <button className="models-btn models-btn--save" onClick={handleSave} disabled={saving}>{saving ? '保存中…' : '保存'}</button>
+                            <button className="models-btn models-btn--cancel" onClick={() => setEditState(null)}>取消</button>
+                          </div>
+                        </div>
+                      </td>
+                    </tr>
+                  )}
+                </>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {models.length > 0 && <div className="models-count">共 {models.length} 个模型</div>}
+
+      {showBatchKey && <BatchKeyModal models={models} apiKeys={apiKeys} onClose={() => setShowBatchKey(false)} onDone={load} />}
+      {showBatchGroup && <BatchGroupModal models={models} groups={groups} onClose={() => setShowBatchGroup(false)} onDone={load} />}
+    </div>
+  );
+}

+ 23 - 0
frontend/src/pages/Scraper.css

@@ -639,3 +639,26 @@
   min-width: 28px;
   min-width: 28px;
   text-align: center;
   text-align: center;
 }
 }
+
+/* 模型管理跳转链接 */
+.sidebar-manage-link {
+  font-size: 11px;
+  color: var(--neon-cyan);
+  text-decoration: none;
+  border: 1px solid var(--neon-cyan);
+  padding: 2px 8px;
+  border-radius: 3px;
+  transition: background 0.15s;
+}
+
+.sidebar-manage-link:hover {
+  background: rgba(0, 212, 255, 0.1);
+}
+
+/* API Key 徽章 */
+.model-key-badge {
+  font-size: 11px;
+  flex-shrink: 0;
+  opacity: 0.8;
+  cursor: default;
+}

+ 10 - 39
frontend/src/pages/Scraper.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
-import { createModel, deleteModel, fetchModels, fetchScrapeJob, fetchScrapeJobs, fetchSchedule, postScrape, updateSchedule } from '../api';
+import { Link } from 'react-router-dom';
+import { fetchModels, fetchScrapeJob, fetchScrapeJobs, fetchSchedule, postScrape, updateSchedule } from '../api';
 import type { Model, Schedule } from '../api';
 import type { Model, Schedule } from '../api';
 import type { ScrapeJob, ScrapeJobDetail } from '../types';
 import type { ScrapeJob, ScrapeJobDetail } from '../types';
 import './Scraper.css';
 import './Scraper.css';
@@ -98,10 +99,6 @@ function PriceCard({ result }: { result: NonNullable<ScrapeJobDetail['results']>
 export function Scraper() {
 export function Scraper() {
   const [models, setModels] = useState<Model[]>([]);
   const [models, setModels] = useState<Model[]>([]);
   const [selected, setSelected] = useState<Set<number>>(new Set());
   const [selected, setSelected] = useState<Set<number>>(new Set());
-  const [showAdd, setShowAdd] = useState(false);
-  const [newName, setNewName] = useState('');
-  const [newUrl, setNewUrl] = useState('');
-  const [addError, setAddError] = useState<string | null>(null);
 
 
   const [schedule, setSchedule] = useState<Schedule | null>(null);
   const [schedule, setSchedule] = useState<Schedule | null>(null);
   const [scheduleEdit, setScheduleEdit] = useState({ interval_days: 1, start_hour: 2 });
   const [scheduleEdit, setScheduleEdit] = useState({ interval_days: 1, start_hour: 2 });
@@ -189,18 +186,6 @@ export function Scraper() {
     }
     }
   };
   };
 
 
-  const handleAdd = async () => {
-    if (!newName.trim() || !newUrl.trim()) return;
-    setAddError(null);
-    try {
-      await createModel(newName.trim(), newUrl.trim());
-      setNewName(''); setNewUrl(''); setShowAdd(false);
-      loadModels();
-    } catch (e) {
-      setAddError(e instanceof Error ? e.message : String(e));
-    }
-  };
-
   const handleToggleSchedule = async () => {
   const handleToggleSchedule = async () => {
     if (!schedule) return;
     if (!schedule) return;
     setScheduleSaving(true);
     setScheduleSaving(true);
@@ -219,12 +204,6 @@ export function Scraper() {
     } finally { setScheduleSaving(false); }
     } finally { setScheduleSaving(false); }
   };
   };
 
 
-  const handleDelete = async (id: number) => {
-    await deleteModel(id);
-    setSelected(prev => { const n = new Set(prev); n.delete(id); return n; });
-    loadModels();
-  };
-
   const handleHistoryClick = async (jobId: string) => {
   const handleHistoryClick = async (jobId: string) => {
     // 已展开则收起
     // 已展开则收起
     if (expandedJobs[jobId]) {
     if (expandedJobs[jobId]) {
@@ -244,21 +223,9 @@ export function Scraper() {
       <aside className="model-sidebar">
       <aside className="model-sidebar">
         <div className="sidebar-header">
         <div className="sidebar-header">
           <span className="sidebar-title">模型列表</span>
           <span className="sidebar-title">模型列表</span>
-          <button className="icon-btn" onClick={() => setShowAdd(v => !v)} title="添加模型">+</button>
+          <Link to="/models" className="sidebar-manage-link" title="管理模型">管理</Link>
         </div>
         </div>
 
 
-        {showAdd && (
-          <div className="add-form">
-            <input className="add-input" placeholder="模型名称" value={newName} onChange={e => setNewName(e.target.value)} />
-            <input className="add-input" placeholder="URL" value={newUrl} onChange={e => setNewUrl(e.target.value)} />
-            {addError && <div className="add-error">{addError}</div>}
-            <div className="add-actions">
-              <button className="add-confirm-btn" onClick={handleAdd}>确认添加</button>
-              <button className="add-cancel-btn" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
-            </div>
-          </div>
-        )}
-
         <div className="model-list-header">
         <div className="model-list-header">
           <label className="check-label">
           <label className="check-label">
             <input type="checkbox" checked={models.length > 0 && selected.size === models.length} onChange={toggleAll} />
             <input type="checkbox" checked={models.length > 0 && selected.size === models.length} onChange={toggleAll} />
@@ -268,14 +235,18 @@ export function Scraper() {
         </div>
         </div>
 
 
         <ul className="model-list">
         <ul className="model-list">
-          {models.length === 0 && <li className="empty-msg">暂无模型,点击 + 添加</li>}
+          {models.length === 0 && (
+            <li className="empty-msg">
+              暂无模型,<Link to="/models" className="sidebar-manage-link">去添加</Link>
+            </li>
+          )}
           {models.map(m => (
           {models.map(m => (
             <li key={m.id} className={`model-item ${selected.has(m.id) ? 'model-item--selected' : ''}`}>
             <li key={m.id} className={`model-item ${selected.has(m.id) ? 'model-item--selected' : ''}`}>
               <label className="check-label">
               <label className="check-label">
                 <input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)} />
                 <input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)} />
-                <span className="model-name">{m.name}</span>
+                <span className="model-name" title={m.url}>{m.name}</span>
               </label>
               </label>
-              <button className="del-btn" onClick={() => handleDelete(m.id)} title="删除">✕</button>
+              {m.api_key && <span className="model-key-badge" title="已配置 API Key">🔑</span>}
             </li>
             </li>
           ))}
           ))}
         </ul>
         </ul>