Bladeren bron

完善部分功能

lxylxy123321 1 week geleden
bovenliggende
commit
0b4d2c701f

+ 7 - 5
backend/app/main.py

@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import math
 from contextlib import asynccontextmanager
 from datetime import datetime, timezone, timedelta
 
@@ -7,7 +8,7 @@ from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from app.config import settings
-from app.routers import domains, monitoring, license as license_router
+from app.routers import domains, monitoring, license as license_router, auth as auth_router
 from app.routers.license import public_router as public_license_router
 from app.database import async_session
 from app.services.license import _notify_on_expired, _notify_on_warning
@@ -52,7 +53,7 @@ async def _check_licenses():
                     )
                 )
                 for lic in warning_result.scalars().all():
-                    days_left = (lic.expires_at - datetime.now(timezone.utc)).days
+                    days_left = math.ceil((lic.expires_at - datetime.now(timezone.utc)).total_seconds() / 86400)
                     logger.info("检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                     await _notify_on_warning(session, lic, days_left)
                     lic.warning_sent = True
@@ -65,7 +66,6 @@ async def _check_licenses():
 
 async def _daily_fetch():
     """定时爬取:每分钟检查 DB 配置,到了目标时间就爬取当天流水"""
-    last_fetch_date = None
     while True:
         try:
             async with async_session() as session:
@@ -78,7 +78,7 @@ async def _daily_fetch():
                     today = now.strftime("%Y-%m-%d")
 
                     # 已过目标时间且今天还没爬过
-                    if now.hour * 60 + now.minute >= h * 60 + m and last_fetch_date != today:
+                    if now.hour * 60 + now.minute >= h * 60 + m and config.last_fetch_date != today:
                         logger.info("开始定时爬取当天流水: %s", today)
 
                         # 查询今日已失败的域名(跳过)
@@ -109,6 +109,7 @@ async def _daily_fetch():
                                 session.add(FetchLog(
                                     domain=d.domain, status="failed", message=error_msg
                                 ))
+                        config.last_fetch_date = today
                         await session.commit()
                         last_fetch_date = today
                         logger.info("当天定时爬取全部完成")
@@ -147,7 +148,7 @@ async def lifespan(app: FastAPI):
                 )
             )
             for lic in warning_result.scalars().all():
-                days_left = (lic.expires_at - datetime.now(timezone.utc)).days
+                days_left = math.ceil((lic.expires_at - datetime.now(timezone.utc)).total_seconds() / 86400)
                 logger.info("启动时检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                 await _notify_on_warning(session, lic, days_left)
                 lic.warning_sent = True
@@ -193,6 +194,7 @@ app.include_router(domains.router)
 app.include_router(monitoring.router)
 app.include_router(license_router.router)
 app.include_router(public_license_router)
+app.include_router(auth_router.router)
 
 
 @app.get("/health")

+ 1 - 0
backend/app/models/monitoring.py

@@ -22,6 +22,7 @@ class FetchScheduleConfig(Base):
     id = Column(Integer, primary_key=True, autoincrement=True)
     enabled = Column(Boolean, server_default="false", default=False)
     schedule_time = Column(String(5), server_default="02:00")  # HH:MM
+    last_fetch_date = Column(String(10))  # YYYY-MM-DD,记录当天是否已爬取
 
 
 class SuperAdmin(Base):

+ 14 - 0
backend/app/models/user.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
+from app.models import Base
+
+
+class User(Base):
+    """系统用户表"""
+    __tablename__ = "users"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    username = Column(String, unique=True, nullable=False, index=True)
+    password_hash = Column(String, nullable=False)
+    is_active = Column(Boolean, server_default="true", default=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())

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

@@ -0,0 +1,33 @@
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.database import get_db
+from app.models.user import User
+import bcrypt
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+class LoginRequest(BaseModel):
+    username: str
+    password: str
+
+
+@router.post("/login")
+async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(
+        select(User).where(User.username == payload.username)
+    )
+    user = result.scalar_one_or_none()
+    if not user or not user.is_active:
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+
+    pw_hash = user.password_hash.encode("utf-8")
+    if not bcrypt.checkpw(payload.password.encode("utf-8"), pw_hash):
+        raise HTTPException(status_code=401, detail="用户名或密码错误")
+
+    return {"message": "登录成功", "username": user.username}

+ 0 - 13
backend/app/routers/domains.py

@@ -104,19 +104,6 @@ async def remove_domain(domain_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{domain_id}/transactions")
-async def get_domain_transactions(
-    domain_id: int,
-    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),
-    db: AsyncSession = Depends(get_db),
-):
-    """爬取指定域名的监控数据并入库"""
-    result = await db.execute(
-        select(MonitoredDomain).where(MonitoredDomain.id == domain_id)
-    )
-    record = result.scalar_one_or_none()
-    if not record:
-        raise HTTPException(status_code=404, detail="域名不存在")
-
 async def get_domain_transactions(
     domain_id: int,
     fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),

+ 7 - 4
backend/app/routers/license.py

@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Depends, Header, HTTPException, Query
+from fastapi import APIRouter, Depends, Header, HTTPException, Query, BackgroundTasks
 from sqlalchemy.ext.asyncio import AsyncSession
 from app.database import get_db
 from app.services.license import (
@@ -63,10 +63,11 @@ async def handle_get_license(
 async def handle_update_license(
     license_id: int,
     payload: LicenseUpdate,
+    background_tasks: BackgroundTasks,
     db: AsyncSession = Depends(get_db),
 ):
     try:
-        return await update_license(db, license_id, payload)
+        return await update_license(db, license_id, payload, background_tasks)
     except ValueError as e:
         raise HTTPException(status_code=400, detail=str(e))
 
@@ -74,10 +75,11 @@ async def handle_update_license(
 @router.post("/{license_id}/revoke", summary="吊销 License")
 async def handle_revoke_license(
     license_id: int,
+    background_tasks: BackgroundTasks,
     db: AsyncSession = Depends(get_db),
 ):
     try:
-        return await revoke_license(db, license_id)
+        return await revoke_license(db, license_id, background_tasks)
     except ValueError as e:
         raise HTTPException(status_code=404, detail=str(e))
 
@@ -85,10 +87,11 @@ async def handle_revoke_license(
 @router.post("/{license_id}/restore", summary="恢复已吊销的 License")
 async def handle_restore_license(
     license_id: int,
+    background_tasks: BackgroundTasks,
     db: AsyncSession = Depends(get_db),
 ):
     try:
-        return await restore_license(db, license_id)
+        return await restore_license(db, license_id, background_tasks)
     except ValueError as e:
         raise HTTPException(status_code=400, detail=str(e))
 

+ 98 - 59
backend/app/services/license.py

@@ -60,74 +60,88 @@ async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) ->
     return visitor.phone, company
 
 
-async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
-    """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
+async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense, retry: bool = False) -> tuple[str, str]:
+    """License 过期通知短信。返回 (status, reason)"""
     try:
         if lic.expired_sent:
-            logger.info("License #%d 已发送过过期短信,跳过", lic.id)
-            return "skipped"
+            return "skipped", "已发送过过期短信"
         phone, company = await _get_contact_for_license(db, lic)
         if not phone:
-            logger.warning("License #%d 无联系人手机号,跳过预警短信", lic.id)
-            return "skipped"
-        logger.info("发送 License 预警短信(过期): license_id=%d, phone=%s, company=%s", lic.id, phone, company)
-        ok, reason = await send_license_expired(phone, company)
+            return "skipped", "无联系人手机号"
+        ok, reason = await send_license_expired(phone, company, retry=retry)
         if ok:
-            logger.info("License 预警短信发送成功: license_id=%d", lic.id)
             lic.expired_sent = True
             await db.commit()
-            return "sent"
-        logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
-        return "failed"
-    except Exception:
-        logger.exception("发送预警短信异常,license_id=%d", lic.id)
-        return "failed"
+            return "sent", ""
+        if "冷却" in reason and not retry:
+            return "pending", "短信冷却中,稍后自动重试发送"
+        return "failed", reason
+    except Exception as e:
+        logger.exception("发送过期短信异常,license_id=%d", lic.id)
+        return "failed", str(e)
 
 
-async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
-    """License valid 从 false 变为 true 时发送恢复短信。返回: sent | skipped | failed"""
+async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense, retry: bool = False) -> tuple[str, str]:
+    """License 恢复通知短信。返回 (status, reason)"""
     try:
         phone, company = await _get_contact_for_license(db, lic)
         if not phone:
-            logger.warning("License #%d 无联系人手机号,跳过恢复短信", lic.id)
-            return "skipped"
-        logger.info("发送 License 恢复短信: license_id=%d, phone=%s, company=%s", lic.id, phone, company)
-        ok, reason = await send_license_restored(phone, company)
+            return "skipped", "无联系人手机号"
+        ok, reason = await send_license_restored(phone, company, retry=retry)
         if ok:
-            logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
             lic.expired_sent = False
             lic.warning_sent = False
             await db.commit()
-            return "sent"
-        logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
-        return "failed"
-    except Exception:
+            return "sent", ""
+        if "冷却" in reason and not retry:
+            return "pending", "短信冷却中,稍后自动重试发送"
+        return "failed", reason
+    except Exception as e:
         logger.exception("发送恢复短信异常,license_id=%d", lic.id)
-        return "failed"
+        return "failed", str(e)
 
 
-async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
-    """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
+async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int, retry: bool = False) -> tuple[str, str]:
+    """License 即将过期预警短信。返回 (status, reason)"""
     try:
         if lic.warning_sent:
-            logger.info("License #%d 已发送过预警短信,跳过", lic.id)
-            return "skipped"
+            return "skipped", "已发送过预警短信"
         phone, company = await _get_contact_for_license(db, lic)
         if not phone:
-            logger.warning("License #%d 无联系人手机号,跳过即将过期预警短信", lic.id)
-            return "skipped"
-        logger.info("发送 License 即将过期预警: license_id=%d, phone=%s, company=%s, days_left=%d", lic.id, phone, company, days_left)
-        ok, reason = await send_license_warning(phone, company, days_left)
+            return "skipped", "无联系人手机号"
+        ok, reason = await send_license_warning(phone, company, days_left, retry=retry)
         if ok:
-            logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
             lic.warning_sent = True
             await db.commit()
-            return "sent"
-        logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
-        return "failed"
-    except Exception:
-        logger.exception("发送即将过期预警短信异常,license_id=%d", lic.id)
-        return "failed"
+            return "sent", ""
+        if "冷却" in reason and not retry:
+            return "pending", "短信冷却中,稍后自动重试发送"
+        return "failed", reason
+    except Exception as e:
+        logger.exception("发送预警短信异常,license_id=%d", lic.id)
+        return "failed", str(e)
+
+
+async def _retry_send_sms(db: AsyncSession, lic: SuperAdminLicense, sms_type: str, days_left: int = 0) -> None:
+    """后台任务:等待冷却后重试发送 License 相关短信"""
+    import asyncio
+    from app.database import async_session
+
+    async with async_session() as session:
+        result = await session.execute(
+            select(SuperAdminLicense).where(SuperAdminLicense.id == lic.id)
+        )
+        lic = result.scalar_one_or_none()
+        if not lic:
+            return
+        # 冷却 30 秒后重试
+        await asyncio.sleep(30)
+        if sms_type == "expired":
+            await _notify_on_expired(session, lic, retry=True)
+        elif sms_type == "restored":
+            await _notify_on_restored(session, lic, retry=True)
+        elif sms_type == "warning":
+            await _notify_on_warning(session, lic, days_left, retry=True)
 
 
 def _calc_days_left(expires_at: datetime) -> int:
@@ -266,10 +280,11 @@ async def get_license_status(
 
     days_left = _calc_days_left(lic.expires_at)
     sms_status = None
+    sms_reason = ""
     if lic.status == "active" and days_left <= 0:
         lic.status = "expired"
         await db.commit()
-        sms_status = await _notify_on_expired(db, lic)
+        sms_status, sms_reason = await _notify_on_expired(db, lic)
 
     sa_result = await db.execute(
         select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
@@ -292,9 +307,9 @@ async def get_license_status(
 
 
 async def revoke_license(
-    db: AsyncSession, license_id: int
+    db: AsyncSession, license_id: int, background_tasks=None
 ) -> dict:
-    """吊销 License"""
+    """吊销 License。状态立即生效,短信后台发送。"""
     result = await db.execute(
         select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
     )
@@ -305,14 +320,16 @@ async def revoke_license(
     lic.status = "revoked"
     await db.commit()
     # 吊销即 valid 变为 false,不管原状态如何都发预警短信
-    sms_status = await _notify_on_expired(db, lic)
-    return {"message": "License已吊销", "sms_status": sms_status}
+    sms_status, sms_reason = await _notify_on_expired(db, lic)
+    if sms_status == "pending" and background_tasks:
+        background_tasks.add_task(_retry_send_sms, db, lic, "expired")
+    return {"message": "License已吊销", "sms_status": sms_status, "sms_reason": sms_reason}
 
 
 async def update_license(
-    db: AsyncSession, license_id: int, payload: LicenseUpdate
+    db: AsyncSession, license_id: int, payload: LicenseUpdate, background_tasks=None
 ) -> dict:
-    """更新 License 的 key 或过期时间"""
+    """更新 License 的 key 或过期时间。状态立即生效,短信后台发送。"""
     result = await db.execute(
         select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
     )
@@ -337,31 +354,50 @@ async def update_license(
         changed = True
     if not changed:
         raise ValueError("没有需要更新的字段")
+
     # 修改过期时间后已超出 7 天窗口,重置预警标记
-    if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
+    new_days_left = _calc_days_left(lic.expires_at) if payload.expires_at else None
+    if new_days_left is not None and new_days_left > 7:
         lic.warning_sent = False
         lic.expired_sent = False
     elif old_status != "active" and lic.status == "active":
         lic.warning_sent = False
         lic.expired_sent = False
+    elif new_days_left is not None and 0 < new_days_left <= 7 and not lic.warning_sent:
+        # 修改后进入7天内,立即发送预警短信
+        lic.warning_sent = True
+        sms_status, sms_reason = await _notify_on_warning(db, lic, new_days_left)
+        if sms_status == "pending" and background_tasks:
+            background_tasks.add_task(_retry_send_sms, db, lic, "warning", new_days_left)
+        await db.commit()
+        return {"message": "License 已更新", "license_id": lic.id, "sms_status": sms_status, "sms_type": "warning", "sms_reason": sms_reason}
+
     await db.commit()
 
     # valid 变化时发送短信
+    sms_reason = ""
+    sms_status = "none"
+    sms_type = None
     if old_status == "active" and lic.status != "active":
-        sms_status = await _notify_on_expired(db, lic)
         sms_type = "expired"
     elif old_status != "active" and lic.status == "active":
-        sms_status = await _notify_on_restored(db, lic)
         sms_type = "restored"
-    else:
-        sms_type = None
-    return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status, "sms_type": sms_type}
+
+    if sms_type:
+        if sms_type == "expired":
+            sms_status, sms_reason = await _notify_on_expired(db, lic)
+        else:
+            sms_status, sms_reason = await _notify_on_restored(db, lic)
+        if sms_status == "pending" and background_tasks:
+            background_tasks.add_task(_retry_send_sms, db, lic, sms_type)
+
+    return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status, "sms_type": sms_type, "sms_reason": sms_reason}
 
 
 async def restore_license(
-    db: AsyncSession, license_id: int
+    db: AsyncSession, license_id: int, background_tasks=None
 ) -> dict:
-    """恢复已吊销的 License"""
+    """恢复已吊销的 License。状态立即生效,短信后台发送。"""
     result = await db.execute(
         select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
     )
@@ -374,8 +410,11 @@ async def restore_license(
     lic.warning_sent = False
     lic.expired_sent = False
     await db.commit()
-    sms_status = await _notify_on_restored(db, lic)
-    return {"message": "License已恢复", "sms_status": sms_status}
+
+    sms_status, sms_reason = await _notify_on_restored(db, lic)
+    if sms_status == "pending" and background_tasks:
+        background_tasks.add_task(_retry_send_sms, db, lic, "restored")
+    return {"message": "License已恢复", "sms_status": sms_status, "sms_reason": sms_reason}
 
 
 async def delete_license(

+ 2 - 2
backend/app/services/monitoring.py

@@ -104,7 +104,7 @@ async def get_dashboard(
         sa_discount_val = float(c.super_admin_discount or 1)
         agg[c.tenant_id][c.user_id].append(ConsumptionRecord(
             user_id=c.user_id,
-            username=c.user_id,
+            username=c.username or c.user_id,
             tenant_name=tenant.company_name if tenant else None,
             order_no=c.order_no or "",
             model_name=model_info.model_name if model_info else c.model_code,
@@ -135,7 +135,7 @@ async def get_dashboard(
             for uid, records in users_map.items():
                 user_list.append(UserConsumption(
                     user_id=uid,
-                    username=uid,
+                    username=records[0].username if records else uid,
                     nickname=None,
                     total_consumption=f"{sum(float(r.amount) for r in records):.4f}",
                     tenant_actual_total=f"{sum(float(r.tenant_actual_price) for r in records):.4f}",

+ 21 - 11
backend/app/services/sms.py

@@ -33,18 +33,28 @@ def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
     return base64.b64encode(h.digest()).decode()
 
 
-async def _send_sms(phone: str, template_code: str, template_params: dict) -> tuple[bool, str]:
-    """调用阿里云 SendSms 接口。返回 (success, reason)"""
+async def _send_sms(phone: str, template_code: str, template_params: dict, retry: bool = False) -> tuple[bool, str]:
+    """调用阿里云 SendSms 接口。返回 (success, reason)
+
+    retry=True 时遇到冷却期等待后重试;retry=False(默认)时遇到冷却期立即返回。
+    """
     if not settings.sms_access_key_id or not settings.sms_access_key_secret:
         logger.warning("阿里云短信 AK/SK 未配置,跳过发送")
         return False, "AK/SK未配置"
 
-    # Redis 冷却期:30 秒内不发第二次
     r = await get_redis()
     cooldown_key = f"sms:cooldown:{phone}"
+
     if await r.exists(cooldown_key):
-        logger.info("手机号 %s 在 30 秒冷却期内,跳过发送", phone)
-        return False, "冷却期内,跳过发送"
+        if retry:
+            ttl = await r.ttl(cooldown_key)
+            wait_sec = max(ttl, 1) + 1
+            logger.info("手机号 %s 在冷却期内,等待 %d 秒后重试发送", phone, wait_sec)
+            import asyncio
+            await asyncio.sleep(wait_sec)
+        else:
+            logger.info("手机号 %s 在冷却期内,跳过立即发送,等待后台重试", phone)
+            return False, "冷却期内,等待后台重试"
 
     await r.setex(cooldown_key, 30, "1")
 
@@ -90,19 +100,19 @@ async def send_sms_code(phone: str, code: str) -> tuple[bool, str]:
     return await _send_sms(phone, settings.sms_template_code_verify, {"code": code})
 
 
-async def send_license_expired(phone: str, company: str) -> tuple[bool, str]:
+async def send_license_expired(phone: str, company: str, retry: bool = False) -> tuple[bool, str]:
     """发送 License 过期通知短信。返回 (success, reason)"""
-    return await _send_sms(phone, settings.sms_template_code_expired, {"company": company})
+    return await _send_sms(phone, settings.sms_template_code_expired, {"company": company}, retry=retry)
 
 
-async def send_license_restored(phone: str, company: str) -> tuple[bool, str]:
+async def send_license_restored(phone: str, company: str, retry: bool = False) -> tuple[bool, str]:
     """发送 License 恢复通知短信。返回 (success, reason)"""
-    return await _send_sms(phone, settings.sms_template_code_restored, {"company": company})
+    return await _send_sms(phone, settings.sms_template_code_restored, {"company": company}, retry=retry)
 
 
-async def send_license_warning(phone: str, company: str, days: int) -> tuple[bool, str]:
+async def send_license_warning(phone: str, company: str, days: int, retry: bool = False) -> tuple[bool, str]:
     """发送 License 即将过期预警短信。返回 (success, reason)"""
-    return await _send_sms(phone, settings.sms_template_code_warning, {"company": company, "days": str(days)})
+    return await _send_sms(phone, settings.sms_template_code_warning, {"company": company, "days": str(days)}, retry=retry)
 
 
 def generate_verify_code() -> str:

+ 1 - 0
backend/migrations/006_add_last_fetch_date.sql

@@ -0,0 +1 @@
+ALTER TABLE domain_monitor.fetch_schedule_config ADD COLUMN IF NOT EXISTS last_fetch_date VARCHAR(10);

+ 15 - 0
backend/migrations/007_users.sql

@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS domain_monitor.users (
+    id SERIAL PRIMARY KEY,
+    username VARCHAR NOT NULL UNIQUE,
+    password_hash VARCHAR NOT NULL,
+    is_active BOOLEAN DEFAULT true,
+    created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_username ON domain_monitor.users (username);
+
+-- 默认管理员账号: admin / admin123
+-- 密码使用 bcrypt 哈希
+INSERT INTO domain_monitor.users (username, password_hash)
+VALUES ('admin', '$2b$12$jZ4f6ZAa5b/LmOsbBieOBe9qE25R1klvyZ74jaFdtQsjNpYsDEU5C')
+ON CONFLICT (username) DO NOTHING;

+ 1 - 0
backend/pyproject.toml

@@ -5,6 +5,7 @@ description = "Domain Transaction Flow Monitor API"
 requires-python = ">=3.11"
 dependencies = [
     "asyncpg>=0.31.0",
+    "bcrypt>=5.0.0",
     "fastapi>=0.136.1",
     "httpx>=0.28.1",
     "pydantic-settings>=2.14.1",

+ 72 - 0
backend/uv.lock

@@ -96,6 +96,7 @@ version = "0.1.0"
 source = { editable = "." }
 dependencies = [
     { name = "asyncpg" },
+    { name = "bcrypt" },
     { name = "fastapi" },
     { name = "httpx" },
     { name = "pydantic-settings" },
@@ -108,6 +109,7 @@ dependencies = [
 [package.metadata]
 requires-dist = [
     { name = "asyncpg", specifier = ">=0.31.0" },
+    { name = "bcrypt", specifier = ">=5.0.0" },
     { name = "fastapi", specifier = ">=0.136.1" },
     { name = "httpx", specifier = ">=0.28.1" },
     { name = "pydantic-settings", specifier = ">=2.14.1" },
@@ -117,6 +119,76 @@ requires-dist = [
     { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" },
 ]
 
+[[package]]
+name = "bcrypt"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
+    { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
+    { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
+    { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
+    { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
+    { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
+    { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
+    { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
+    { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
+    { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
+    { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
+    { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
+    { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
+    { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
+    { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
+    { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
+    { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
+    { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
+    { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
+    { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
+    { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
+    { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
+    { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
+    { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
+    { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
+    { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
+    { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
+]
+
 [[package]]
 name = "certifi"
 version = "2026.4.22"

+ 60 - 1
frontend/package-lock.json

@@ -11,7 +11,8 @@
         "@tanstack/react-query": "^5.100.10",
         "axios": "^1.16.0",
         "react": "^19.2.6",
-        "react-dom": "^19.2.6"
+        "react-dom": "^19.2.6",
+        "react-router-dom": "^7.15.0"
       },
       "devDependencies": {
         "@eslint/js": "^10.0.1",
@@ -1359,6 +1360,19 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2653,6 +2667,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
       "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -2660,6 +2675,44 @@
         "react": "^19.2.6"
       }
     },
+    "node_modules/react-router": {
+      "version": "7.15.0",
+      "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.15.0.tgz",
+      "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.15.0",
+      "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.15.0.tgz",
+      "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.15.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
     "node_modules/rolldown": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
@@ -2717,6 +2770,12 @@
         "semver": "bin/semver.js"
       }
     },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

+ 2 - 1
frontend/package.json

@@ -13,7 +13,8 @@
     "@tanstack/react-query": "^5.100.10",
     "axios": "^1.16.0",
     "react": "^19.2.6",
-    "react-dom": "^19.2.6"
+    "react-dom": "^19.2.6",
+    "react-router-dom": "^7.15.0"
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",

+ 28 - 1079
frontend/src/App.tsx

@@ -1,1089 +1,38 @@
-import { useState, useEffect } from "react";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { domainApi } from "./api/domains";
-import { monitoringApi } from "./api/monitoring";
-import { licenseApi } from "./api/license";
-import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
+import { Routes, Route } from "react-router-dom";
+import { globalStyle } from "./theme";
+import { MainLayout } from "./layouts/MainLayout";
+import { ProtectedRoute } from "./pages/LoginPage";
+import { DashboardPage } from "./pages/DashboardPage";
+import { DomainsPage } from "./pages/DomainsPage";
+import { MonitoringPage } from "./pages/MonitoringPage";
+import { SuperAdminsPage } from "./pages/SuperAdminsPage";
+import { TenantsPage } from "./pages/TenantsPage";
+import { UsersPage } from "./pages/UsersPage";
+import { LicensePage } from "./pages/LicensePage";
+import { FetchLogsPage } from "./pages/FetchLogsPage";
+import { LoginPage } from "./pages/LoginPage";
 
-/* ===== 颜色主题 ===== */
-const T = {
-  primary: "#6366f1",
-  primaryHover: "#4f46e5",
-  danger: "#ef4444",
-  success: "#22c55e",
-  sidebar: "#1e293b",
-  sidebarText: "#94a3b8",
-  sidebarActive: "#ffffff",
-  bg: "#f8fafc",
-  card: "#ffffff",
-  text: "#334155",
-  textSec: "#94a3b8",
-  border: "#e2e8f0",
-  heading: "#0f172a",
-};
-
-/* ===== 全局样式 ===== */
-const globalStyle = `
-  *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
-  html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; }
-  @keyframes spin { to { transform:rotate(360deg); } }
-  @keyframes fadeIn { from { opacity:0; transform:translateY(-10px); } to { opacity:1; transform:translateY(0); } }
-`;
 function GlobalStyle() { return <style>{globalStyle}</style>; }
 
-/* ===== 通用组件 ===== */
-function Icon({ d }: { d: string }) {
-  return <svg style={{ width: 18, height: 18, flexShrink: 0 }} viewBox="0 0 24 24" fill="currentColor" opacity={0.7}><path d={d} /></svg>;
-}
-
-function Badge({ active, text }: { active: boolean; text: string }) {
-  return (
-    <span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "3px 10px", borderRadius: 99, fontSize: 12, fontWeight: 500, background: active ? "#f0fdf4" : "#fef2f2", color: active ? "#16a34a" : "#dc2626" }}>
-      <span style={{ width: 6, height: 6, borderRadius: "50%", background: "currentColor" }} />{text}
-    </span>
-  );
-}
-
-function Card({ title, children, extra }: { title: string; children: React.ReactNode; extra?: React.ReactNode }) {
-  return (
-    <div style={{ background: T.card, borderRadius: 10, boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}`, marginBottom: 20 }}>
-      <div style={{ padding: "14px 22px", borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
-        <h2 style={{ fontSize: 15, fontWeight: 600, color: T.heading }}>{title}</h2>
-        {extra}
-      </div>
-      <div style={{ padding: 22 }}>{children}</div>
-    </div>
-  );
-}
-
-/** 开关组件 */
-function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
-  return (
-    <label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 14 }}>
-      <div onClick={() => onChange(!checked)} style={{ width: 40, height: 22, borderRadius: 11, position: "relative", transition: "background 0.2s", background: checked ? T.primary : T.border }}>
-        <div style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2, transition: "left 0.2s", boxShadow: "0 1px 2px rgba(0,0,0,0.15)" }} />
-      </div>
-      {label && <span style={{ color: T.text }}>{label}</span>}
-    </label>
-  );
-}
-
-/** Toast 提示 — 页面中上部固定位置 */
-function Toast({ message, type }: { message: string; type: "success" | "error" }) {
-  return (
-    <div style={{
-      position: "fixed", top: 20, left: "50%", transform: "translateX(-50%)",
-      zIndex: 9999, padding: "12px 24px", borderRadius: 8, fontSize: 14,
-      fontWeight: 500, color: "#fff",
-      background: type === "success" ? T.success : T.danger,
-      boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
-      animation: "fadeIn 0.3s ease",
-    }}>{message}</div>
-  );
-}
-
-/* ===== 侧边栏 ===== */
-function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void }) {
-  const items = [
-    { key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" },
-    { key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" },
-    { key: "super_admins", label: "超级管理员", icon: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" },
-    { key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" },
-    { key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" },
-    { key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" },
-    { key: "fetch_logs", label: "爬取日志", icon: "M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" },
-  ];
-  return (
-    <div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
-      <div style={{ color: "#fff", fontSize: 16, fontWeight: 700, padding: "0 12px 20px", borderBottom: "1px solid rgba(255,255,255,0.08)", marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
-        <span style={{ width: 28, height: 28, background: T.primary, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 700 }}>D</span>
-        域名流水监控
-      </div>
-      {items.map((item) => (
-        <div key={item.key} onClick={() => setPage(item.key)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 8, fontSize: 14, cursor: "pointer", marginBottom: 2, color: page === item.key ? "#fff" : T.sidebarText, background: page === item.key ? T.primary : "transparent", transition: "all 0.2s" }}>
-          <Icon d={item.icon} />{item.label}
-        </div>
-      ))}
-    </div>
-  );
-}
-
-/** 爬取控制面板 — 自动 + 手动 */
-function FetchControls() {
-  const queryClient = useQueryClient();
-  const [autoFetch, setAutoFetch] = useState(false);
-  const [scheduleTime, setScheduleTime] = useState("02:00");
-  const [fetchDate, setFetchDate] = useState("");
-  const [fetchingByDate, setFetchingByDate] = useState(false);
-  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
-  const [configLoaded, setConfigLoaded] = useState(false);
-
-  // 加载配置
-  useEffect(() => {
-    domainApi.getSchedule().then(res => {
-      setAutoFetch(res.data.enabled);
-      setScheduleTime(res.data.schedule_time);
-      setConfigLoaded(true);
-    }).catch(() => setConfigLoaded(true));
-  }, []);
-
-  const showToast = (message: string, type: "success" | "error") => {
-    setToast({ message, type });
-    setTimeout(() => setToast(null), 4000);
-  };
-
-  // 保存配置
-  const handleSaveSchedule = () => {
-    domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(res => {
-      showToast("配置已保存", "success");
-    }).catch(() => showToast("保存失败", "error"));
-  };
-
-  const batchMutation = useMutation({
-    mutationFn: () => domainApi.fetchAll(),
-    onSuccess: (res) => {
-      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
-      const data = res.data;
-      if (data.errors && data.errors.length > 0) {
-        showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
-      } else if (data.total === 0) {
-        showToast("没有启用中的域名", "error");
-      } else {
-        showToast(`全部爬取成功,共 ${data.total} 个域名`, "success");
-      }
-    },
-    onError: () => { showToast("爬取请求失败,请稍后重试", "error"); },
-  });
-
-  const fetchByDateMutation = useMutation({
-    mutationFn: (date: string) => domainApi.fetchAll(date),
-    onSuccess: (res) => {
-      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
-      setFetchingByDate(false);
-      const data = res.data;
-      if (data.errors && data.errors.length > 0) {
-        showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
-      } else if (data.total === 0) {
-        showToast("没有启用中的域名", "error");
-      } else {
-        showToast(`按日期爬取成功,共 ${data.total} 个域名`, "success");
-      }
-    },
-    onError: () => { setFetchingByDate(false); showToast("爬取请求失败,请稍后重试", "error"); },
-  });
-
-  const handleFetchByDate = () => {
-    if (!fetchDate) return;
-    setFetchingByDate(true);
-    fetchByDateMutation.mutate(fetchDate);
-  };
-
-  return (
-    <>
-      {toast && <Toast message={toast.message} type={toast.type} />}
-      <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
-        <Toggle checked={autoFetch} onChange={setAutoFetch} label="每日定时爬取" />
-        {autoFetch && (
-          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
-            <span style={{ fontSize: 13, color: T.textSec }}>时间</span>
-            <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
-          </div>
-        )}
-        <button onClick={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
-          保存配置
-        </button>
-        <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
-          {batchMutation.isPending ? "爬取中..." : "全部爬取"}
-        </button>
-        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
-          <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
-          <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
-          <button onClick={handleFetchByDate} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
-            {fetchingByDate ? "爬取中..." : "爬取"}
-          </button>
-        </div>
-      </div>
-    </>
-  );
-}
-
-/* ===== 域名管理页面 ===== */
-function DomainsPage() {
-  const [newDomain, setNewDomain] = useState("");
-  const [newRemark, setNewRemark] = useState("");
-  const [fetchingId, setFetchingId] = useState<number | null>(null);
-  const [fetchDate, setFetchDate] = useState("");
-  const [showDatePicker, setShowDatePicker] = useState<number | null>(null);
-  const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
-  const [editingRemarkValue, setEditingRemarkValue] = useState("");
-  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
-  const queryClient = useQueryClient();
-  const showToast = (message: string, type: "success" | "error") => {
-    setToast({ message, type });
-    setTimeout(() => setToast(null), 4000);
-  };
-  const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
-  const addMutation = useMutation({ mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setNewDomain(""); setNewRemark(""); showToast("域名添加成功", "success"); }, onError: () => { showToast("添加失败,请检查域名格式", "error"); } });
-  const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); showToast("域名已删除", "success"); }, onError: () => { showToast("删除失败", "error"); } });
-  const fetchMutation = useMutation({ mutationFn: ({ id, date }: { id: number; date?: string }) => domainApi.fetchTransactions(id, date), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); setShowDatePicker(null); showToast(`${res.data.domain} 爬取成功`, "success"); }, onError: () => { setFetchingId(null); showToast("爬取失败,请检查域名是否可达", "error"); } });
-  const updateRemarkMutation = useMutation({ mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setEditingRemarkId(null); showToast("备注更新成功", "success"); }, onError: () => { showToast("备注更新失败", "error"); } });
-  const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate({ id, date: fetchDate || undefined }); };
-  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
-  const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
-  const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
-  const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
-  const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
-  return (
-    <>
-      {toast && <Toast message={toast.message} type={toast.type} />}
-      <div style={{ marginLeft: 220, padding: 32 }}>
-      <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
-      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
-        {[{ label: "域名总数", value: domains?.length ?? 0 }, { label: "启用中", value: activeCount }, { label: "已停用", value: (domains?.length ?? 0) - activeCount }, { label: "今日新增", value: 0 }].map((s) => (
-          <div key={s.label} style={{ background: T.card, borderRadius: 10, padding: "18px 22px", boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}` }}>
-            <div style={{ fontSize: 13, color: T.textSec, marginBottom: 6 }}>{s.label}</div>
-            <div style={{ fontSize: 26, fontWeight: 700, color: T.heading }}>{s.value}</div>
-          </div>
-        ))}
-      </div>
-      <Card title="添加域名">
-        <form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
-          <input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
-          <input value={newRemark} onChange={(e) => setNewRemark(e.target.value)} placeholder="备注(用于标识超管)" style={{ width: 180, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
-          <button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
-        </form>
-      </Card>
-      <Card title="域名列表" extra={<FetchControls />}>
-        {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !domains?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12 }}>🌐</div><p>暂无域名</p></div> : (
-          <table style={{ width: "100%", borderCollapse: "collapse" }}>
-            <thead><tr>{["ID", "域名", "备注", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "10px 14px", textAlign: "left", fontSize: 12, fontWeight: 600, color: T.textSec, textTransform: "uppercase", background: T.bg, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-            <tbody>
-              {domains.map((d: MonitoredDomain) => (
-                <tr key={d.id} style={{ borderBottom: `1px solid ${T.border}` }}>
-                  <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{d.id}</td>
-                  <td style={{ padding: "12px 14px", fontWeight: 500 }}>{d.domain}</td>
-                  <td style={{ padding: "12px 14px" }}>
-                    {editingRemarkId === d.id ? (
-                      <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
-                        <input value={editingRemarkValue} onChange={(e) => setEditingRemarkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveRemark(d.id); if (e.key === "Escape") setEditingRemarkId(null); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.primary}`, fontSize: 13, outline: "none", width: 160 }} autoFocus />
-                        <button onClick={() => saveRemark(d.id)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.success, color: "#fff" }}>保存</button>
-                        <button onClick={() => setEditingRemarkId(null)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
-                      </div>
-                    ) : (
-                      <span onDoubleClick={() => startEditRemark(d)} style={{ cursor: "pointer", fontSize: 13, color: d.remark ? T.text : T.textSec, padding: "2px 4px", borderRadius: 4 }} title="双击编辑">{d.remark || "双击添加备注"}</span>
-                    )}
-                  </td>
-                  <td style={{ padding: "12px 14px" }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
-                  <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
-                  <td style={{ padding: "12px 14px", display: "flex", gap: 6, alignItems: "center" }}>
-                    {showDatePicker === d.id ? (
-                      <>
-                        <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "3px 6px", borderRadius: 4, border: `1px solid ${T.border}`, fontSize: 12, outline: "none", width: 140 }} />
-                        <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500 }}>
-                          {fetchingId === d.id ? "爬取中" : "爬取"}
-                        </button>
-                        <button onClick={() => { setShowDatePicker(null); setFetchDate(""); }} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
-                      </>
-                    ) : (
-                      <>
-                        <button onClick={() => setShowDatePicker(d.id)} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500 }}>
-                          按日期爬取
-                        </button>
-                        <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "transparent", color: T.text, fontWeight: 500 }}>
-                          {fetchingId === d.id ? "爬取中" : "爬取当天"}
-                        </button>
-                      </>
-                    )}
-                    <button onClick={() => deleteMutation.mutate(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger, fontWeight: 500 }}>删除</button>
-                  </td>
-                </tr>
-              ))}
-            </tbody>
-          </table>
-        )}
-      </Card>
-    </div>
-    </>
-  );
-}
-
-/* ===== 可折叠面板 ===== */
-function CollapsePanel({ title, children, defaultOpen = false, badge }: { title: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; badge?: string }) {
-  const [open, setOpen] = useState(defaultOpen);
-  return (
-    <div style={{ border: `1px solid ${T.border}`, borderRadius: 8, marginBottom: 8, overflow: "hidden" }}>
-      <div onClick={() => setOpen(!open)} style={{ padding: "10px 16px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "space-between", background: open ? T.bg : "#fff", userSelect: "none" }}>
-        <span style={{ fontWeight: 500, color: T.heading }}>{title}</span>
-        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
-          {badge && <span style={{ fontSize: 12, color: T.textSec }}>{badge}</span>}
-          <span style={{ fontSize: 12, color: T.textSec, transition: "transform 0.2s", transform: open ? "rotate(90deg)" : "rotate(0deg)" }}>{">"}</span>
-        </div>
-      </div>
-      {open && <div style={{ padding: 16 }}>{children}</div>}
-    </div>
-  );
-}
-
-/* ===== 监控大屏页面 ===== */
-function MonitoringPage() {
-  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
-
-  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
-  if (query.startDate) params.start_date = query.startDate;
-  if (query.endDate) params.end_date = query.endDate;
-  if (query.saName) params.super_admin_name = query.saName;
-
-  const { data: dashboard, isLoading } = useQuery({
-    queryKey: ["dashboard", params],
-    queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
-  });
-
-  return (
-    <div style={{ marginLeft: 220, padding: 32 }}>
-      <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
-
-      <FilterBar currentQuery={query} onSearch={setQuery} />
-
-      {isLoading ? <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div> : !dashboard ? (
-        <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card>
-      ) : (
-        <>
-          <Card title="平台汇总">
-            <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
-              {[
-                { label: "超级管理员数", value: dashboard.overview.total_super_admins },
-                { label: "租户总数", value: dashboard.overview.total_tenants },
-                { label: "用户总数", value: dashboard.overview.total_users },
-                { label: "总消费(元)", value: dashboard.overview.total_consumption },
-                { label: "总收取(元)", value: dashboard.overview.total_tenant_charged },
-                { label: "总余额(元)", value: dashboard.overview.total_balance },
-              ].map((s) => (
-                <div key={s.label} style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}>
-                  <div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>{s.label}</div>
-                  <div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{s.value}</div>
-                </div>
-              ))}
-            </div>
-          </Card>
-
-          {dashboard.super_admins.map((sa) => (
-            <Card key={sa.super_admin_id} title={`${sa.remark || sa.source_domain || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
-              <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
-              {sa.tenants.map((tenant) => (
-                <CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
-                  <div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
-                  {tenant.users.map((u) => (
-                    <CollapsePanel key={u.user_id} title={u.nickname || u.username} badge={`消费 ¥${u.total_consumption}`} defaultOpen={false}>
-                      <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>企业实际支付 ¥{u.tenant_actual_total}</div>
-                      {u.consumption_records.length > 0 && (
-                        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-                          <thead><tr>{["订单号", "模型", "金额(元)", "用户折扣", "企业折扣", "超管折扣", "时间", "已开票"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-                          <tbody>{u.consumption_records.map((r: any, idx: number) => (<tr key={idx}><td style={{ padding: "6px 10px", color: T.textSec }}>{r.order_no || "-"}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.user_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.super_admin_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.created_at}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.invoiced ? "是" : "否"}</td></tr>))}</tbody>
-                        </table>
-                      )}
-                    </CollapsePanel>
-                  ))}
-                </CollapsePanel>
-              ))}
-            </Card>
-          ))}
-        </>
-      )}
-    </div>
-  );
-}
-
-/* ===== 通用页面布局 ===== */
-function PageLayout({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
-  return (
-    <div style={{ marginLeft: 220, padding: 32 }}>
-      <div style={{ marginBottom: 28 }}>
-        <h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>{title}</h1>
-        <p style={{ color: T.textSec, fontSize: 14 }}>{subtitle}</p>
-      </div>
-      {children}
-    </div>
-  );
-}
-
-/* 筛选条件组件(复用)— 输入与查询解耦,点击"查询"才触发请求,支持查询历史管理 */
-function FilterBar({ currentQuery, onSearch, showTenantFilter }: {
-  currentQuery: { startDate: string; endDate: string; saName: string; tenantName: string };
-  onSearch: (q: { startDate: string; endDate: string; saName: string; tenantName: string }) => void;
-  showTenantFilter?: boolean;
-}) {
-  const [input, setInput] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
-
-  useEffect(() => {
-    setInput({ startDate: currentQuery.startDate, endDate: currentQuery.endDate, saName: currentQuery.saName, tenantName: currentQuery.tenantName });
-  }, [currentQuery]);
-
-  const handleSearch = () => {
-    onSearch({ startDate: input.startDate, endDate: input.endDate, saName: input.saName, tenantName: input.tenantName });
-  };
-
-  const hasActiveFilter = currentQuery.startDate || currentQuery.endDate || currentQuery.saName || currentQuery.tenantName;
-
-  const removeCondition = (key: string) => {
-    const next = { ...currentQuery, [key]: "" };
-    onSearch(next);
-  };
-
-  const clearAll = () => {
-    onSearch({ startDate: "", endDate: "", saName: "", tenantName: "" });
-  };
-
-  return (
-    <Card title="筛选条件">
-      <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap", marginBottom: hasActiveFilter ? 12 : 0 }}>
-        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>开始日期</label><input type="date" value={input.startDate} onChange={(e) => setInput({ ...input, startDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
-        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={input.endDate} onChange={(e) => setInput({ ...input, endDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
-        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员名称</label><input type="text" value={input.saName} onChange={(e) => setInput({ ...input, saName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
-        {showTenantFilter && (
-          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>租户名称</label><input type="text" value={input.tenantName} onChange={(e) => setInput({ ...input, tenantName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
-        )}
-        <button onClick={handleSearch} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
-      </div>
-      {hasActiveFilter && (
-        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", paddingTop: 12, borderTop: `1px solid ${T.border}` }}>
-          {currentQuery.startDate && <Tag label={`开始: ${currentQuery.startDate}`} onRemove={() => removeCondition("startDate")} />}
-          {currentQuery.endDate && <Tag label={`结束: ${currentQuery.endDate}`} onRemove={() => removeCondition("endDate")} />}
-          {currentQuery.saName && <Tag label={`超管: ${currentQuery.saName}`} onRemove={() => removeCondition("saName")} />}
-          {currentQuery.tenantName && <Tag label={`租户: ${currentQuery.tenantName}`} onRemove={() => removeCondition("tenantName")} />}
-          <button onClick={clearAll} style={{ padding: "2px 10px", borderRadius: 99, fontSize: 12, cursor: "pointer", border: "none", background: "#fef2f2", color: T.danger, fontWeight: 500 }}>清空全部</button>
-        </div>
-      )}
-    </Card>
-  );
-}
-
-function Tag({ label, onRemove }: { label: string; onRemove: () => void }) {
-  return (
-    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 12px", borderRadius: 99, fontSize: 12, background: "#eff6ff", color: T.primary, fontWeight: 500 }}>
-      {label}
-      <span onClick={onRemove} style={{ cursor: "pointer", fontWeight: 700, opacity: 0.6 }} title="移除该条件">✕</span>
-    </span>
-  );
-}
-
-/** 带提示的表头 */
-function TooltipTh({ text, tip }: { text: string; tip: string }) {
-  return (
-    <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }} title={tip}>
-      {text}
-      <span style={{ marginLeft: 3, fontSize: 10, opacity: 0.5, cursor: "help" }}>?</span>
-    </th>
-  );
-}
-
-/* ===== 超级管理员页面 ===== */
-function SuperAdminsPage() {
-  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
-
-  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
-  if (query.startDate) params.start_date = query.startDate;
-  if (query.endDate) params.end_date = query.endDate;
-  if (query.saName) params.super_admin_name = query.saName;
-
-  const hasDate = Boolean(query.startDate && query.endDate);
-
-  const { data: dashboard, isLoading: dashLoading } = useQuery({
-    queryKey: ["dashboard", params],
-    queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
-    enabled: !hasDate,
-  });
-
-  const { data: dailyStats, isLoading: dailyLoading } = useQuery({
-    queryKey: ["daily-stats", params],
-    queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined }).then((r) => r.data),
-    enabled: hasDate,
-  });
-
-  if (dashLoading || dailyLoading) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
-
-  if (hasDate) {
-    const saStats = dailyStats?.sa_stats || [];
-    return (
-      <PageLayout title="超级管理员" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
-        <FilterBar currentQuery={query} onSearch={setQuery} />
-        {saStats.length === 0 ? <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div></Card> : saStats.map((sa: any, saIdx: number) => (
-          <Card key={saIdx} title={`${sa.sa_name} 每日消费明细`}>
-            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
-                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
-              </tr></thead>
-              <tbody>
-                {(sa.tenants || []).map((t: any, idx: number) => (
-                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
-                    <td style={{ padding: "8px 12px", fontWeight: 500 }}>{t.date}</td>
-                    <td style={{ padding: "8px 12px", color: T.textSec }}>{t.tenant_name || "-"}</td>
-                    <td style={{ padding: "8px 12px" }}>{t.consumption}</td>
-                    <td style={{ padding: "8px 12px" }}>{t.charged}</td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          </Card>
-        ))}
-      </PageLayout>
-    );
-  }
-
-  if (!dashboard) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
-
-  const totalCount = dashboard.super_admins.length;
-  const totalConsumption = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_consumption || 0), 0).toFixed(4);
-  const totalCharged = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_tenant_charged || 0), 0).toFixed(4);
-
-  return (
-    <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据">
-      <FilterBar currentQuery={query} onSearch={setQuery} />
-      <Card title="统计">
-        <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
-          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>管理员数</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCount}</div></div>
-          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总消费(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalConsumption}</div></div>
-          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总收取(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCharged}</div></div>
-        </div>
-      </Card>
-      {dashboard.super_admins.map((sa: any) => (
-        <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
-          <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
-          {sa.tenants.length === 0 ? <div style={{ textAlign: "center", padding: 20, color: T.textSec, fontSize: 13 }}>暂无租户数据</div> : (
-            <table style={{ width: "100%", borderCollapse: "collapse" }}>
-              <thead><tr>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
-                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
-              </tr></thead>
-              <tbody>
-                {sa.tenants.map((t: any) => (
-                  <tr key={t.tenant_id} style={{ borderBottom: `1px solid ${T.border}` }}>
-                    <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
-                    <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
-                    <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
-                    <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
-                    <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          )}
-        </Card>
-      ))}
-    </PageLayout>
-  );
-}
-
-/* ===== 租户页面 ===== */
-function TenantsPage() {
-  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
-
-  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
-  if (query.startDate) params.start_date = query.startDate;
-  if (query.endDate) params.end_date = query.endDate;
-  if (query.saName) params.super_admin_name = query.saName;
-
-  const hasDate = Boolean(query.startDate && query.endDate);
-
-  const { data: dashboard, isLoading: dashLoading } = useQuery({
-    queryKey: ["dashboard", params],
-    queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
-    enabled: !hasDate,
-  });
-
-  const { data: dailyStats, isLoading: dailyLoading } = useQuery({
-    queryKey: ["daily-stats", params],
-    queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined, tenant_name: query.tenantName || undefined }).then((r) => r.data),
-    enabled: hasDate,
-  });
-
-  if (dashLoading || dailyLoading) return <PageLayout title="租户" subtitle="查看各租户消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
-
-  if (hasDate) {
-    const saStats = dailyStats?.sa_stats || [];
-    const rows: any[] = [];
-    saStats.forEach((sa: any) => {
-      (sa.tenants || []).forEach((t: any) => {
-        rows.push({ sa_name: sa.sa_name, tenant_name: t.tenant_name || "-", date: t.date, consumption: t.consumption, charged: t.charged });
-      });
-    });
-    return (
-      <PageLayout title="租户" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
-        <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
-        <Card title={`每日明细(共 ${rows.length} 条)`}>
-          {rows.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div> : (
-            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
-                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
-                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
-              </tr></thead>
-              <tbody>
-                {rows.map((r: any, idx: number) => (
-                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
-                    <td style={{ padding: "8px 12px", color: T.textSec }}>{r.sa_name}</td>
-                    <td style={{ padding: "8px 12px", fontWeight: 500 }}>{r.date}</td>
-                    <td style={{ padding: "8px 12px" }}>{r.tenant_name}</td>
-                    <td style={{ padding: "8px 12px" }}>{r.consumption}</td>
-                    <td style={{ padding: "8px 12px" }}>{r.charged}</td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          )}
-        </Card>
-      </PageLayout>
-    );
-  }
-
-  if (!dashboard) return <PageLayout title="租户" subtitle="查看各租户消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
-
-  const allTenants: any[] = [];
-  dashboard.super_admins.forEach((sa: any) => {
-    sa.tenants.forEach((t: any) => {
-      allTenants.push({ ...t, sa_name: sa.remark || sa.source_domain || sa.username });
-    });
-  });
-
-  const filteredTenants = query.tenantName
-    ? allTenants.filter((t: any) => t.company_name?.toLowerCase().includes(query.tenantName.toLowerCase()) || t.subdomain?.toLowerCase().includes(query.tenantName.toLowerCase()))
-    : allTenants;
-
-  return (
-    <PageLayout title="租户" subtitle="查看各租户消费数据">
-      <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
-      <Card title={`租户列表(共 ${filteredTenants.length} 个)`}>
-        {filteredTenants.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无匹配的租户数据</div> : (
-          <table style={{ width: "100%", borderCollapse: "collapse" }}>
-            <thead><tr>
-              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
-              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
-              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
-              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
-              <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
-              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
-            </tr></thead>
-            <tbody>
-              {filteredTenants.map((t: any) => (
-                <tr key={`${t.sa_name}-${t.tenant_id}`} style={{ borderBottom: `1px solid ${T.border}` }}>
-                  <td style={{ padding: "10px 12px", color: T.textSec }}>{t.sa_name}</td>
-                  <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
-                  <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
-                  <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
-                  <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
-                  <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
-                </tr>
-              ))}
-            </tbody>
-          </table>
-        )}
-      </Card>
-    </PageLayout>
-  );
-}
-
-/* ===== 用户页面 ===== */
-function UsersPage() {
-  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
-  const [page, setPage] = useState(1);
-  const [pageSize, setPageSize] = useState(20);
-
-  const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string; page?: number; page_size?: number } = {};
-  if (query.startDate) params.start_date = query.startDate;
-  if (query.endDate) params.end_date = query.endDate;
-  if (query.saName) params.super_admin_name = query.saName;
-  if (query.tenantName) params.tenant_name = query.tenantName;
-  params.page = page;
-  params.page_size = pageSize;
-
-  const { data: details, isLoading } = useQuery({
-    queryKey: ["consumption-details", params],
-    queryFn: () => monitoringApi.getConsumptionDetails(params).then((r) => r.data),
-  });
-
-  if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
-  if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
-
-  const records = details.records || [];
-  const total = details.total ?? 0;
-  const totalPages = Math.max(1, Math.ceil(total / pageSize));
-
-  return (
-    <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
-      <FilterBar currentQuery={query} onSearch={(q) => { setQuery(q); setPage(1); }} showTenantFilter />
-      <Card title={`消费明细(共 ${total} 笔)`}>
-        {records.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无消费记录</div> : (
-          <>
-            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-              <tbody>
-                {records.map((r: any, idx: number) => (
-                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
-                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td>
-                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td>
-                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td>
-                    <td style={{ padding: "8px 10px" }}>{r.user_consumed}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td>
-                    <td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td>
-                    <td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16, fontSize: 13 }}>
-              <div style={{ display: "flex", alignItems: "center", gap: 8, color: T.textSec }}>
-                <span>每页</span>
-                <select value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none", background: "#fff" }}>
-                  {[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n} 条</option>)}
-                </select>
-                <span>,共 {total} 条</span>
-              </div>
-              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
-                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
-                <span style={{ color: T.textSec }}>第 {page} / {totalPages} 页</span>
-                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
-              </div>
-            </div>
-          </>
-        )}
-      </Card>
-    </PageLayout>
-  );
-}
-
-/* ===== License 许可页面 ===== */
-function LicensePage() {
-  const queryClient = useQueryClient();
-  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
-  const showToast = (message: string, type: "success" | "error") => {
-    setToast({ message, type });
-    setTimeout(() => setToast(null), 4000);
-  };
-
-  // 域名列表(仅统计卡片用)
-  const { data: domains, isLoading: domainsLoading } = useQuery({
-    queryKey: ["license-domains"],
-    queryFn: () => domainApi.list().then((r) => r.data),
-  });
-
-  // 超级管理员下拉选项
-  const { data: saOptions, isLoading: saLoading } = useQuery({
-    queryKey: ["sa-options"],
-    queryFn: () => licenseApi.getSuperAdmins().then((r) => r.data),
-  });
-
-  // License 列表
-  const { data: licenses, isLoading: licensesLoading } = useQuery({
-    queryKey: ["licenses"],
-    queryFn: () => licenseApi.list({ size: 100 }).then((r) => r.data),
-  });
-
-  // 创建表单
-  const [createForm, setCreateForm] = useState({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
-
-  const createMutation = useMutation({
-    mutationFn: (data: any) => licenseApi.create(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["licenses"] });
-      setCreateForm({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
-      showToast("License 创建成功", "success");
-    },
-    onError: (err: any) => { showToast(err.response?.data?.detail || "创建失败", "error"); },
-  });
-
-  // 短信状态提示 — 根据操作类型显示不同文案
-  const showSmsToast = (status: string, action: "expired" | "restored" = "restored") => {
-    const label = action === "expired" ? "预警" : "恢复";
-    if (status === "sent") showToast(`${label}短信已发送成功`, "success");
-    else if (status === "skipped") showToast(`未找到联系人手机号,${label}短信未发送`, "error");
-    else if (status === "failed") showToast(`业务操作成功,但${label}短信发送失败,请查看日志`, "error");
-  };
-
-  const revokeMutation = useMutation({
-    mutationFn: (id: number) => licenseApi.revoke(id),
-    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); showSmsToast(res.data?.sms_status, "expired"); },
-    onError: () => { showToast("吊销失败", "error"); },
-  });
-
-  const deleteMutation = useMutation({
-    mutationFn: (id: number) => licenseApi.delete(id),
-    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已删除", "success"); },
-    onError: () => { showToast("删除失败", "error"); },
-  });
-
-  const restoreMutation = useMutation({
-    mutationFn: (id: number) => licenseApi.restore(id),
-    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); showSmsToast(res.data?.sms_status, "restored"); },
-    onError: () => { showToast("恢复失败", "error"); },
-  });
-
-  const updateMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
-      licenseApi.update(id, data),
-    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status, res.data?.sms_type ?? "restored"); },
-    onError: () => { showToast("更新失败", "error"); },
-  });
-
-  // 编辑弹窗
-  const [editTarget, setEditTarget] = useState<any | null>(null);
-  const [editForm, setEditForm] = useState({ license_key: "", expires_at: "" });
-
-  const handleSaveEdit = () => {
-    if (!editTarget) return;
-    const data: { license_key?: string; expires_at?: string } = {};
-    if (editForm.license_key && editForm.license_key !== editTarget.license_key) data.license_key = editForm.license_key;
-    if (editForm.expires_at && editForm.expires_at !== (editTarget.expires_at || "").split(".")[0]?.substring(0, 16)) data.expires_at = editForm.expires_at;
-    if (Object.keys(data).length === 0) { showToast("未做任何修改", "error"); return; }
-    updateMutation.mutate({ id: editTarget.id, data });
-  };
-
-  useEffect(() => {
-    if (editTarget) {
-      setEditForm({
-        license_key: editTarget.license_key || "",
-        expires_at: editTarget.expires_at ? editTarget.expires_at.split(".")[0].substring(0, 16) : "",
-      });
-    }
-  }, [editTarget?.id]);
-
-  const handleCreate = (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!createForm.super_admin_id || !createForm.license_key || !createForm.expires_at) {
-      showToast("请填写必填项(超级管理员、License Key、过期时间)", "error");
-      return;
-    }
-    const data: any = {
-      super_admin_id: parseInt(createForm.super_admin_id),
-      license_key: createForm.license_key,
-      expires_at: createForm.expires_at,
-    };
-    if (createForm.max_tenants) data.max_tenants = parseInt(createForm.max_tenants);
-    if (createForm.max_users) data.max_users_per_tenant = parseInt(createForm.max_users);
-    if (createForm.remark) data.remark = createForm.remark;
-    createMutation.mutate(data);
-  };
-
-  const statusColor = (status: string) => {
-    if (status === "active") return { bg: "#f0fdf4", color: "#16a34a", text: "有效" };
-    if (status === "expired") return { bg: "#fef2f2", color: "#dc2626", text: "已过期" };
-    if (status === "revoked") return { bg: "#f1f5f9", color: "#64748b", text: "已吊销" };
-    return { bg: "#f1f5f9", color: "#94a3b8", text: status };
-  };
-
-  if (domainsLoading || saLoading || licensesLoading) return <PageLayout title="License 许可" subtitle="管理系统授权许可"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
-
-  return (
-    <>
-      {toast && <Toast message={toast.message} type={toast.type} />}
-      <PageLayout title="License 许可" subtitle="管理系统授权许可">
-        {/* 统计卡片 */}
-        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
-          {[
-            { label: "监控域名数", value: domains?.length ?? 0 },
-            { label: "有效 License", value: licenses?.items?.filter((l: any) => l.status === "active").length ?? 0 },
-            { label: "已过期", value: licenses?.items?.filter((l: any) => l.status === "expired").length ?? 0 },
-            { label: "已吊销", value: licenses?.items?.filter((l: any) => l.status === "revoked").length ?? 0 },
-          ].map((s) => (
-            <div key={s.label} style={{ background: T.card, borderRadius: 10, padding: "18px 22px", boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}` }}>
-              <div style={{ fontSize: 13, color: T.textSec, marginBottom: 6 }}>{s.label}</div>
-              <div style={{ fontSize: 26, fontWeight: 700, color: T.heading }}>{s.value}</div>
-            </div>
-          ))}
-        </div>
-
-        {/* 创建 License */}
-        <Card title="创建 License">
-          <form onSubmit={handleCreate} style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员 *</label>
-              <select value={createForm.super_admin_id} onChange={(e) => setCreateForm({ ...createForm, super_admin_id: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, minWidth: 200, outline: "none", background: "#fff" }}>
-                <option value="">-- 请选择 --</option>
-                {saOptions?.map((sa: any) => <option key={sa.id} value={sa.id}>{sa.remark || sa.source_domain || sa.username} (ID: {sa.id})</option>)}
-              </select>
-            </div>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key *</label><input value={createForm.license_key} onChange={(e) => setCreateForm({ ...createForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 220, outline: "none" }} /></div>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间 *</label><input type="datetime-local" value={createForm.expires_at} onChange={(e) => setCreateForm({ ...createForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>最大租户数</label><input type="number" value={createForm.max_tenants} onChange={(e) => setCreateForm({ ...createForm, max_tenants: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 100, outline: "none" }} /></div>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>每租户最大用户数</label><input type="number" value={createForm.max_users} onChange={(e) => setCreateForm({ ...createForm, max_users: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 120, outline: "none" }} /></div>
-            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>备注</label><input value={createForm.remark} onChange={(e) => setCreateForm({ ...createForm, remark: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 150, outline: "none" }} /></div>
-            <button type="submit" disabled={createMutation.isPending} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: createMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{createMutation.isPending ? "创建中..." : "创建"}</button>
-          </form>
-        </Card>
-
-        {/* License 列表 */}
-        <Card title={`License 列表(共 ${licenses?.total ?? 0} 条)`}>
-          {!licenses?.items?.length ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无 License 记录</div> : (
-            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>{["超管", "关联域名", "联系人", "License Key", "过期时间", "状态", "剩余天数", "备注", "操作"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-              <tbody>
-                {licenses.items.map((l: any) => {
-                  const sc = statusColor(l.status);
-                  const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
-                  return (
-                    <tr key={l.id} style={{ borderBottom: `1px solid ${T.border}` }}>
-                      <td style={{ padding: "8px 10px" }}>{l.super_admin_name || l.super_admin_id}</td>
-                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontSize: 12, color: T.textSec }}>{l.domain || "-"}</td>
-                      <td style={{ padding: "8px 10px", fontSize: 12 }}>
-                        {l.contact?.name || l.contact?.phone || l.contact?.email ? (
-                          <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
-                            {l.contact.name && <span style={{ fontWeight: 500 }}>{l.contact.name}</span>}
-                            {l.contact.phone && <span style={{ color: T.textSec }}>{l.contact.phone}</span>}
-                            {l.contact.email && <span style={{ color: T.textSec }}>{l.contact.email}</span>}
-                          </div>
-                        ) : "-"}
-                      </td>
-                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontWeight: 500, fontSize: 12 }}>{l.license_key}</td>
-                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? (l.expires_at.replace("T", " ").split("+")[0] || "-") : "-"}</td>
-                      <td style={{ padding: "8px 10px" }}>
-                        <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 12, background: sc.bg, color: sc.color }}>{sc.text}</span>
-                      </td>
-                      <td style={{ padding: "8px 10px", color: daysLeft <= 7 && l.status === "active" ? T.danger : T.text }}>{l.status === "active" ? `${daysLeft} 天` : "-"}</td>
-                      <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={l.remark || ""}>{l.remark || "-"}</td>
-                      <td style={{ padding: "8px 10px", display: "flex", gap: 6 }}>
-                        <button onClick={() => setEditTarget(l)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}>编辑</button>
-                        {l.status === "active" && <button onClick={() => revokeMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #f59e0b`, background: "transparent", color: "#f59e0b" }}>吊销</button>}
-                        {l.status === "revoked" && <button onClick={() => restoreMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #22c55e`, background: "transparent", color: "#22c55e" }}>恢复</button>}
-                        <button onClick={() => deleteMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger }}>删除</button>
-                      </td>
-                    </tr>
-                  );
-                })}
-              </tbody>
-            </table>
-          )}
-        </Card>
-
-        {/* 编辑弹窗 */}
-        {editTarget && (
-          <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setEditTarget(null)}>
-            <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 420, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
-              <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>编辑 License #{editTarget.id}</h3>
-              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
-                <div>
-                  <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key</label>
-                  <input value={editForm.license_key} onChange={(e) => setEditForm({ ...editForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
-                </div>
-                <div>
-                  <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间</label>
-                  <input type="datetime-local" value={editForm.expires_at} onChange={(e) => setEditForm({ ...editForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
-                </div>
-              </div>
-              <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
-                <button onClick={() => setEditTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
-                <button onClick={handleSaveEdit} disabled={updateMutation.isPending} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: updateMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}>{updateMutation.isPending ? "保存中..." : "保存"}</button>
-              </div>
-            </div>
-          </div>
-        )}
-      </PageLayout>
-    </>
-  );
-}
-
-/* ===== 爬取日志页面 ===== */
-function FetchLogsPage() {
-  const [filters, setFilters] = useState({ domain: "", status: "" });
-  const [page, setPage] = useState(1);
-  const size = 20;
-
-  const params: Record<string, string | number> = { page, size };
-  if (filters.domain) params.domain = filters.domain;
-  if (filters.status) params.status = filters.status;
-
-  const { data, isLoading } = useQuery({
-    queryKey: ["fetch-logs", params],
-    queryFn: () => domainApi.getFetchLogs(params).then((r) => r.data),
-  });
-
-  const statusBadge = (status: string) => {
-    if (status === "success") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#f0fdf4", color: "#16a34a" }}>成功</span>;
-    if (status === "failed") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fef2f2", color: "#dc2626" }}>失败</span>;
-    if (status === "skipped") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fefce8", color: "#ca8a04" }}>跳过</span>;
-    return <span style={{ color: T.textSec }}>{status}</span>;
-  };
-
-  const total = data?.total ?? 0;
-  const totalPages = Math.max(1, Math.ceil(total / size));
-
-  return (
-    <PageLayout title="爬取日志" subtitle="查看域名爬取历史记录">
-      <Card title="筛选">
-        <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
-          <div>
-            <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>域名</label>
-            <input type="text" value={filters.domain} onChange={(e) => setFilters({ ...filters, domain: e.target.value })} placeholder="输入域名筛选" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 200 }} />
-          </div>
-          <div>
-            <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>状态</label>
-            <select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", background: "#fff" }}>
-              <option value="">全部</option>
-              <option value="success">成功</option>
-              <option value="failed">失败</option>
-              <option value="skipped">跳过</option>
-            </select>
-          </div>
-          <button onClick={() => { setPage(1); }} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
-        </div>
-      </Card>
-
-      <Card title={`日志记录(共 ${total} 条)`}>
-        {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !data?.items?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}>暂无爬取日志</div> : (
-          <>
-            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>{["ID", "域名", "状态", "信息", "时间"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-              <tbody>
-                {data.items.map((log: any) => (
-                  <tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}>
-                    <td style={{ padding: "8px 10px", color: T.textSec }}>{log.id}</td>
-                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{log.domain}</td>
-                    <td style={{ padding: "8px 10px" }}>{statusBadge(log.status)}</td>
-                    <td style={{ padding: "8px 10px", color: log.status === "failed" ? T.danger : T.text, maxWidth: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.message}>{log.message}</td>
-                    <td style={{ padding: "8px 10px", color: T.textSec, whiteSpace: "nowrap" }}>{log.created_at ? new Date(log.created_at).toLocaleString("zh-CN") : "-"}</td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-            {totalPages > 1 && (
-              <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, marginTop: 16 }}>
-                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
-                <span style={{ fontSize: 13, color: T.textSec }}>第 {page} / {totalPages} 页</span>
-                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
-              </div>
-            )}
-          </>
-        )}
-      </Card>
-    </PageLayout>
-  );
-}
-
-/* ===== App ===== */
 function App() {
-  const [page, setPage] = useState("domains");
-  const pageMap: Record<string, React.ReactNode> = {
-    domains: <DomainsPage />,
-    monitoring: <MonitoringPage />,
-    super_admins: <SuperAdminsPage />,
-    tenants: <TenantsPage />,
-    users: <UsersPage />,
-    license: <LicensePage />,
-    fetch_logs: <FetchLogsPage />,
-  };
   return (
     <>
       <GlobalStyle />
-      <Sidebar page={page} setPage={setPage} />
-      {pageMap[page] || <DomainsPage />}
+      <Routes>
+        <Route path="/login" element={<LoginPage />} />
+        <Route element={<ProtectedRoute />}>
+          <Route element={<MainLayout />}>
+            <Route path="/" element={<DashboardPage />} />
+            <Route path="/domains" element={<DomainsPage />} />
+            <Route path="/monitoring" element={<MonitoringPage />} />
+            <Route path="/super-admins" element={<SuperAdminsPage />} />
+            <Route path="/tenants" element={<TenantsPage />} />
+            <Route path="/users" element={<UsersPage />} />
+            <Route path="/license" element={<LicensePage />} />
+            <Route path="/fetch-logs" element={<FetchLogsPage />} />
+          </Route>
+        </Route>
+      </Routes>
     </>
   );
 }

+ 6 - 0
frontend/src/api/domains.ts

@@ -31,3 +31,9 @@ export const domainApi = {
   getFetchLogs: (params?: { domain?: string; status?: string; page?: number; size?: number }) =>
     api.get("/api/domains/fetch-logs", { params }),
 };
+
+/** 登录 */
+export const loginApi = {
+  login: (username: string, password: string) =>
+    api.post("/api/auth/login", { username, password }),
+};

+ 83 - 0
frontend/src/components/PageLayout.tsx

@@ -0,0 +1,83 @@
+import { useState, useEffect } from "react";
+import { T } from "../theme";
+import { Card } from "./Shared";
+
+function Tag({ label, onRemove }: { label: string; onRemove: () => void }) {
+  return (
+    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 12px", borderRadius: 99, fontSize: 12, background: "#eff6ff", color: T.primary, fontWeight: 500 }}>
+      {label}
+      <span onClick={onRemove} style={{ cursor: "pointer", fontWeight: 700, opacity: 0.6 }} title="移除该条件">✕</span>
+    </span>
+  );
+}
+
+export function TooltipTh({ text, tip }: { text: string; tip: string }) {
+  return (
+    <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }} title={tip}>
+      {text}
+      <span style={{ marginLeft: 3, fontSize: 10, opacity: 0.5, cursor: "help" }}>?</span>
+    </th>
+  );
+}
+
+export function PageLayout({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
+  return (
+    <div style={{ marginLeft: 220, padding: 32 }}>
+      <div style={{ marginBottom: 28 }}>
+        <h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>{title}</h1>
+        <p style={{ color: T.textSec, fontSize: 14 }}>{subtitle}</p>
+      </div>
+      {children}
+    </div>
+  );
+}
+
+export function FilterBar({ currentQuery, onSearch, showTenantFilter }: {
+  currentQuery: { startDate: string; endDate: string; saName: string; tenantName: string };
+  onSearch: (q: { startDate: string; endDate: string; saName: string; tenantName: string }) => void;
+  showTenantFilter?: boolean;
+}) {
+  const [input, setInput] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+
+  useEffect(() => {
+    setInput({ startDate: currentQuery.startDate, endDate: currentQuery.endDate, saName: currentQuery.saName, tenantName: currentQuery.tenantName });
+  }, [currentQuery]);
+
+  const handleSearch = () => {
+    onSearch({ startDate: input.startDate, endDate: input.endDate, saName: input.saName, tenantName: input.tenantName });
+  };
+
+  const hasActiveFilter = currentQuery.startDate || currentQuery.endDate || currentQuery.saName || currentQuery.tenantName;
+
+  const removeCondition = (key: string) => {
+    const next = { ...currentQuery, [key]: "" };
+    onSearch(next);
+  };
+
+  const clearAll = () => {
+    onSearch({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  };
+
+  return (
+    <Card title="筛选条件">
+      <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap", marginBottom: hasActiveFilter ? 12 : 0 }}>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>开始日期</label><input type="date" value={input.startDate} onChange={(e) => setInput({ ...input, startDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={input.endDate} onChange={(e) => setInput({ ...input, endDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员名称</label><input type="text" value={input.saName} onChange={(e) => setInput({ ...input, saName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
+        {showTenantFilter && (
+          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>租户名称</label><input type="text" value={input.tenantName} onChange={(e) => setInput({ ...input, tenantName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
+        )}
+        <button onClick={handleSearch} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
+      </div>
+      {hasActiveFilter && (
+        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", paddingTop: 12, borderTop: `1px solid ${T.border}` }}>
+          {currentQuery.startDate && <Tag label={`开始: ${currentQuery.startDate}`} onRemove={() => removeCondition("startDate")} />}
+          {currentQuery.endDate && <Tag label={`结束: ${currentQuery.endDate}`} onRemove={() => removeCondition("endDate")} />}
+          {currentQuery.saName && <Tag label={`超管: ${currentQuery.saName}`} onRemove={() => removeCondition("saName")} />}
+          {currentQuery.tenantName && <Tag label={`租户: ${currentQuery.tenantName}`} onRemove={() => removeCondition("tenantName")} />}
+          <button onClick={clearAll} style={{ padding: "2px 10px", borderRadius: 99, fontSize: 12, cursor: "pointer", border: "none", background: "#fef2f2", color: T.danger, fontWeight: 500 }}>清空全部</button>
+        </div>
+      )}
+    </Card>
+  );
+}

+ 124 - 0
frontend/src/components/Shared.tsx

@@ -0,0 +1,124 @@
+import { useState } from "react";
+import { T } from "../theme";
+
+export function Icon({ d }: { d: string }) {
+  return <svg style={{ width: 18, height: 18, flexShrink: 0 }} viewBox="0 0 24 24" fill="currentColor" opacity={0.7}><path d={d} /></svg>;
+}
+
+export function Badge({ active, text }: { active: boolean; text: string }) {
+  return (
+    <span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "3px 10px", borderRadius: 99, fontSize: 12, fontWeight: 500, background: active ? "#f0fdf4" : "#fef2f2", color: active ? "#16a34a" : "#dc2626" }}>
+      <span style={{ width: 6, height: 6, borderRadius: "50%", background: "currentColor" }} />{text}
+    </span>
+  );
+}
+
+export function Card({ title, children, extra }: { title: string; children: React.ReactNode; extra?: React.ReactNode }) {
+  return (
+    <div style={{ background: T.card, borderRadius: 12, boxShadow: "0 1px 3px rgba(0,0,0,0.06)", border: `1px solid ${T.border}`, marginBottom: 20, transition: "box-shadow 0.2s" }}>
+      <div style={{ padding: "16px 22px", borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
+        <h2 style={{ fontSize: 15, fontWeight: 600, color: T.heading }}>{title}</h2>
+        {extra}
+      </div>
+      <div style={{ padding: 22 }}>{children}</div>
+    </div>
+  );
+}
+
+export function StatCard({ label, value, color, icon }: { label: string; value: string | number; color: string; icon: string }) {
+  const [hovered, setHovered] = useState(false);
+  return (
+    <div
+      onMouseEnter={() => setHovered(true)}
+      onMouseLeave={() => setHovered(false)}
+      style={{
+        background: T.card, borderRadius: 12, padding: "20px 24px",
+        boxShadow: hovered ? "0 4px 16px rgba(0,0,0,0.1)" : "0 1px 4px rgba(0,0,0,0.06)",
+        border: `1px solid ${T.border}`,
+        transition: "all 0.25s ease",
+        transform: hovered ? "translateY(-2px)" : "none",
+      }}
+    >
+      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
+        <span style={{ fontSize: 13, color: T.textSec, fontWeight: 500 }}>{label}</span>
+        <div style={{ width: 36, height: 36, borderRadius: 10, background: `${color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>
+          {icon}
+        </div>
+      </div>
+      <div style={{ fontSize: 28, fontWeight: 700, color: T.heading, lineHeight: 1.2 }}>{value}</div>
+    </div>
+  );
+}
+
+export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
+  return (
+    <label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 14 }}>
+      <div onClick={() => onChange(!checked)} style={{ width: 40, height: 22, borderRadius: 11, position: "relative", transition: "background 0.2s", background: checked ? T.primary : T.border }}>
+        <div style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", position: "absolute", top: 2, left: checked ? 20 : 2, transition: "left 0.2s", boxShadow: "0 1px 2px rgba(0,0,0,0.15)" }} />
+      </div>
+      {label && <span style={{ color: T.text }}>{label}</span>}
+    </label>
+  );
+}
+
+export function LoadingDots() {
+  return (
+    <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>
+      <div style={{ display: "inline-block", width: 32, height: 32, border: `3px solid ${T.border}`, borderTopColor: T.primary, borderRadius: "50%", animation: "spin 0.8s linear infinite", marginBottom: 12 }} />
+      <p style={{ fontSize: 13 }}>加载中...</p>
+    </div>
+  );
+}
+
+export function Toast({ message, type }: { message: string; type: "success" | "error" }) {
+  return (
+    <div style={{
+      position: "fixed", top: 20, left: "50%", transform: "translateX(-50%)",
+      zIndex: 9999, padding: "12px 24px", borderRadius: 8, fontSize: 14,
+      fontWeight: 500, color: "#fff",
+      background: type === "success" ? T.success : T.danger,
+      boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
+      animation: "fadeIn 0.3s ease",
+    }}>{message}</div>
+  );
+}
+
+export function ConfirmDialog({ title, message, onConfirm, onCancel }: { title: string; message: string; onConfirm: () => void; onCancel: () => void }) {
+  return (
+    <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 2000 }} onClick={onCancel}>
+      <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 14, padding: "32px 28px 24px", width: 400, boxShadow: "0 24px 80px rgba(0,0,0,0.25)", animation: "scaleIn 0.2s ease" }}>
+        <div style={{ width: 48, height: 48, borderRadius: "50%", background: "#fef2f2", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 16px", fontSize: 24 }}>
+          ⚠️
+        </div>
+        <h3 style={{ margin: "0 0 8px", fontSize: 17, fontWeight: 600, color: T.heading, textAlign: "center" }}>{title}</h3>
+        <p style={{ margin: "0 0 28px", fontSize: 14, color: T.textSec, textAlign: "center", lineHeight: 1.6 }}>{message}</p>
+        <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
+          <button onClick={onCancel} style={{ padding: "9px 24px", borderRadius: 8, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text, fontWeight: 500 }}>取消</button>
+          <button onClick={onConfirm} style={{ padding: "9px 24px", borderRadius: 8, fontSize: 13, cursor: "pointer", border: "none", background: T.danger, color: "#fff", fontWeight: 500 }}>确认</button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function CollapsePanel({ title, children, defaultOpen = false, badge }: { title: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; badge?: string }) {
+  const [open, setOpen] = useState(defaultOpen);
+  const [hovered, setHovered] = useState(false);
+  return (
+    <div style={{ border: `1px solid ${T.border}`, borderRadius: 8, marginBottom: 8, overflow: "hidden" }}>
+      <div
+        onClick={() => setOpen(!open)}
+        onMouseEnter={() => setHovered(true)}
+        onMouseLeave={() => setHovered(false)}
+        style={{ padding: "10px 16px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "space-between", background: hovered ? T.bg : open ? T.bg : "#fff", userSelect: "none", transition: "background 0.15s" }}
+      >
+        <span style={{ fontWeight: 500, color: T.heading }}>{title}</span>
+        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
+          {badge && <span style={{ fontSize: 12, color: T.textSec }}>{badge}</span>}
+          <span style={{ fontSize: 12, color: T.textSec, transition: "transform 0.2s", transform: open ? "rotate(90deg)" : "rotate(0deg)" }}>{">"}</span>
+        </div>
+      </div>
+      {open && <div style={{ padding: 16 }}>{children}</div>}
+    </div>
+  );
+}

+ 69 - 0
frontend/src/components/Sidebar.tsx

@@ -0,0 +1,69 @@
+import { useNavigate, useLocation } from "react-router-dom";
+import { T } from "../theme";
+import { Icon } from "./Shared";
+
+const navItems = [
+  { key: "dashboard", label: "仪表盘", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z", path: "/" },
+  { key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", path: "/domains" },
+  { key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z", path: "/monitoring" },
+  { key: "super_admins", label: "超级管理员", icon: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z", path: "/super-admins" },
+  { key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z", path: "/tenants" },
+  { key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z", path: "/users" },
+  { key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z", path: "/license" },
+  { key: "fetch_logs", label: "爬取日志", icon: "M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z", path: "/fetch-logs" },
+];
+
+export function Sidebar({ onLogout }: { onLogout: () => void }) {
+  const navigate = useNavigate();
+  const location = useLocation();
+
+  const handleNav = (path: string) => {
+    navigate(path);
+  };
+
+  return (
+    <div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100, display: "flex", flexDirection: "column" }}>
+      <div style={{ color: "#fff", fontSize: 15, fontWeight: 700, padding: "0 12px 22px", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 16, display: "flex", alignItems: "center", gap: 10 }}>
+        <span style={{ width: 30, height: 30, background: `linear-gradient(135deg, ${T.primary}, #8b5cf6)`, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 700 }}>D</span>
+        域名流水监控
+      </div>
+      <div style={{ flex: 1 }}>
+      {navItems.map((item) => {
+        const isActive = location.pathname === item.path;
+        return (
+          <div
+            key={item.key}
+            onClick={() => handleNav(item.path)}
+            role="button"
+            tabIndex={0}
+            onKeyDown={(e) => { if (e.key === "Enter") handleNav(item.path); }}
+            className={isActive ? "" : "nav-item"}
+            style={{
+              display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 8,
+              fontSize: 14, cursor: "pointer", marginBottom: 2,
+              color: isActive ? "#fff" : T.sidebarText,
+              background: isActive ? T.primary : "transparent",
+              transition: "all 0.15s",
+              fontWeight: isActive ? 600 : 400,
+            }}
+          >
+            <Icon d={item.icon} />{item.label}
+          </div>
+        );
+      })}
+      </div>
+      <div style={{ position: "absolute", bottom: 24, left: 12, right: 12 }}>
+        <div
+          onClick={onLogout}
+          role="button"
+          tabIndex={0}
+          onKeyDown={(e) => { if (e.key === "Enter") onLogout(); }}
+          style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 8, fontSize: 14, cursor: "pointer", color: T.sidebarText, transition: "all 0.15s" }}
+          className="nav-item"
+        >
+          <Icon d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z" />退出登录
+        </div>
+      </div>
+    </div>
+  );
+}

+ 18 - 0
frontend/src/layouts/MainLayout.tsx

@@ -0,0 +1,18 @@
+import { Outlet, useNavigate } from "react-router-dom";
+import { Sidebar } from "../components/Sidebar";
+
+export function MainLayout() {
+  const navigate = useNavigate();
+
+  const handleLogout = () => {
+    sessionStorage.removeItem("auth");
+    navigate("/login");
+  };
+
+  return (
+    <>
+      <Sidebar onLogout={handleLogout} />
+      <Outlet />
+    </>
+  );
+}

+ 6 - 3
frontend/src/main.tsx

@@ -1,5 +1,6 @@
 import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import App from "./App";
 
@@ -7,8 +8,10 @@ const queryClient = new QueryClient();
 
 createRoot(document.getElementById("root")!).render(
   <StrictMode>
-    <QueryClientProvider client={queryClient}>
-      <App />
-    </QueryClientProvider>
+    <BrowserRouter>
+      <QueryClientProvider client={queryClient}>
+        <App />
+      </QueryClientProvider>
+    </BrowserRouter>
   </StrictMode>
 );

+ 199 - 0
frontend/src/pages/DashboardPage.tsx

@@ -0,0 +1,199 @@
+import { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { domainApi } from "../api/domains";
+import { monitoringApi } from "../api/monitoring";
+import { licenseApi } from "../api/license";
+import type { MonitoredDomain } from "../types/domain";
+import { T } from "../theme";
+import { Card, StatCard, LoadingDots, Toast } from "../components/Shared";
+
+function FetchControls() {
+  const queryClient = useQueryClient();
+  const [autoFetch, setAutoFetch] = useState(false);
+  const [scheduleTime, setScheduleTime] = useState("02:00");
+  const [fetchDate, setFetchDate] = useState("");
+  const [fetchingByDate, setFetchingByDate] = useState(false);
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+
+  useEffect(() => {
+    domainApi.getSchedule().then(res => {
+      setAutoFetch(res.data.enabled);
+      setScheduleTime(res.data.schedule_time);
+    }).catch(() => {});
+  }, []);
+
+  const showToast = (message: string, type: "success" | "error") => {
+    setToast({ message, type });
+    setTimeout(() => setToast(null), 4000);
+  };
+
+  const handleSaveSchedule = () => {
+    domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(() => {
+      showToast("配置已保存", "success");
+    }).catch(() => showToast("保存失败", "error"));
+  };
+
+  const batchMutation = useMutation({
+    mutationFn: () => domainApi.fetchAll(),
+    onSuccess: (res: any) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      const data = res.data;
+      if (data.errors && data.errors.length > 0) showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
+      else if (data.total === 0) showToast("没有启用中的域名", "error");
+      else showToast(`全部爬取成功,共 ${data.total} 个域名`, "success");
+    },
+    onError: () => showToast("爬取请求失败,请稍后重试", "error"),
+  });
+
+  const fetchByDateMutation = useMutation({
+    mutationFn: (date: string) => domainApi.fetchAll(date),
+    onSuccess: (res: any) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      setFetchingByDate(false);
+      const data = res.data;
+      if (data.errors && data.errors.length > 0) showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
+      else if (data.total === 0) showToast("没有启用中的域名", "error");
+      else showToast(`按日期爬取成功,共 ${data.total} 个域名`, "success");
+    },
+    onError: () => {
+      setFetchingByDate(false);
+      showToast("爬取请求失败,请稍后重试", "error");
+    },
+  });
+
+  return (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
+        <Toggle checked={autoFetch} onChange={setAutoFetch} label="每日定时爬取" />
+        {autoFetch && (
+          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+            <span style={{ fontSize: 13, color: T.textSec }}>时间</span>
+            <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
+          </div>
+        )}
+        <button onClick={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>保存配置</button>
+        <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{batchMutation.isPending ? "爬取中..." : "全部爬取"}</button>
+        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+          <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
+          <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
+          <button onClick={() => { if (fetchDate) { setFetchingByDate(true); fetchByDateMutation.mutate(fetchDate); } }} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>{fetchingByDate ? "爬取中..." : "爬取"}</button>
+        </div>
+      </div>
+    </>
+  );
+}
+
+export function DashboardPage() {
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const [fetching, setFetching] = useState(false);
+
+  const { data: domains } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
+  const { data: dashboard, isLoading: dashLoading } = useQuery({ queryKey: ["dashboard"], queryFn: () => monitoringApi.getDashboard().then((r) => r.data) });
+  const { data: licenses } = useQuery({ queryKey: ["licenses"], queryFn: () => licenseApi.list({ size: 100 }).then((r) => r.data) });
+  const { data: recentLogs } = useQuery({ queryKey: ["recent-logs"], queryFn: () => domainApi.getFetchLogs({ page: 1, size: 8 }).then((r) => r.data) });
+
+  const batchMutation = useMutation({
+    mutationFn: () => domainApi.fetchAll(),
+    onSuccess: () => {
+      setFetching(false);
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      queryClient.invalidateQueries({ queryKey: ["recent-logs"] });
+    },
+    onError: () => setFetching(false),
+  });
+
+  const today = new Date();
+  const greeting = today.getHours() < 12 ? "上午" : today.getHours() < 18 ? "下午" : "晚上";
+  const dateStr = today.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric", weekday: "long" });
+
+  const domainCount = domains?.length ?? 0;
+  const activeDomains = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
+  const totalConsumption = dashboard?.overview?.total_consumption ?? "-";
+  const totalUsers = dashboard?.overview?.total_users ?? "-";
+  const activeLicenses = licenses?.items?.filter((l: any) => l.status === "active").length ?? 0;
+  const expiredLicenses = licenses?.items?.filter((l: any) => l.status === "expired").length ?? 0;
+  const revokedLicenses = licenses?.items?.filter((l: any) => l.status === "revoked").length ?? 0;
+
+  const expiringLicenses = licenses?.items?.filter((l: any) => {
+    if (l.status !== "active") return false;
+    const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+    return daysLeft <= 7;
+  }) ?? [];
+
+  const statusColor = (status: string) => {
+    if (status === "success") return { bg: "#f0fdf4", color: "#16a34a", text: "成功" };
+    if (status === "failed") return { bg: "#fef2f2", color: "#dc2626", text: "失败" };
+    return { bg: "#fefce8", color: "#ca8a04", text: "跳过" };
+  };
+
+  return (
+    <div style={{ marginLeft: 220, padding: 32 }}>
+      <div style={{ marginBottom: 28, display: "flex", alignItems: "flex-end", justifyContent: "space-between" }}>
+        <div>
+          <h1 style={{ fontSize: 26, fontWeight: 600, color: T.heading, marginBottom: 4 }}>{greeting}好</h1>
+          <p style={{ color: T.textSec, fontSize: 14 }}>{dateStr} · 欢迎使用域名流水监控系统</p>
+        </div>
+        <div style={{ display: "flex", gap: 10 }}>
+          <button onClick={() => { setFetching(true); batchMutation.mutate(); }} disabled={fetching} style={{ padding: "9px 22px", borderRadius: 8, fontSize: 13, cursor: fetching ? "default" : "pointer", border: "none", background: fetching ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap", transition: "all 0.15s", boxShadow: fetching ? "none" : "0 2px 8px rgba(99,102,241,0.3)" }}>{fetching ? "爬取中..." : "全部爬取"}</button>
+          <button onClick={() => navigate("/domains")} style={{ padding: "9px 22px", borderRadius: 8, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text, fontWeight: 500, whiteSpace: "nowrap", transition: "all 0.15s" }}>管理域名</button>
+        </div>
+      </div>
+
+      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
+        {[
+          { label: "监控域名", value: `${activeDomains} / ${domainCount}`, color: T.primary, icon: "🌐" },
+          { label: "总消费金额", value: `¥${totalConsumption}`, color: "#8b5cf6", icon: "💰" },
+          { label: "活跃用户", value: totalUsers, color: "#06b6d4", icon: "👥" },
+          { label: "有效 License", value: activeLicenses, color: "#16a34a", icon: "🔑" },
+        ].map((s) => (
+          <StatCard key={s.label} label={s.label} value={dashLoading ? "..." : s.value} color={s.color} icon={s.icon} />
+        ))}
+      </div>
+
+      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 24 }}>
+        <Card title="License 状态">
+          <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16, textAlign: "center" }}>
+            <div style={{ padding: "16px 12px", borderRadius: 10, background: "#f0fdf4" }}><div style={{ fontSize: 26, fontWeight: 700, color: "#16a34a" }}>{activeLicenses}</div><div style={{ fontSize: 13, color: "#15803d", fontWeight: 500, marginTop: 4 }}>有效</div></div>
+            <div style={{ padding: "16px 12px", borderRadius: 10, background: "#fef2f2" }}><div style={{ fontSize: 26, fontWeight: 700, color: "#dc2626" }}>{expiredLicenses}</div><div style={{ fontSize: 13, color: "#b91c1c", fontWeight: 500, marginTop: 4 }}>过期</div></div>
+            <div style={{ padding: "16px 12px", borderRadius: 10, background: "#f1f5f9" }}><div style={{ fontSize: 26, fontWeight: 700, color: "#64748b" }}>{revokedLicenses}</div><div style={{ fontSize: 13, color: "#475569", fontWeight: 500, marginTop: 4 }}>吊销</div></div>
+          </div>
+        </Card>
+        <Card title="即将过期(7天内)">
+          {expiringLicenses.length === 0 ? <div style={{ textAlign: "center", padding: "32px 16px", color: T.textSec, fontSize: 14 }}>暂无即将过期的 License</div> : (
+            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
+              {expiringLicenses.map((l: any) => {
+                const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+                return (
+                  <div key={l.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "10px 14px", borderRadius: 8, background: "#fef2f2", border: `1px solid #fecaca` }}>
+                    <span style={{ fontWeight: 500, fontSize: 13, maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{l.super_admin_name || l.license_key}</span>
+                    <span style={{ fontSize: 12, color: "#dc2626", fontWeight: 600, background: "#fff", padding: "2px 10px", borderRadius: 99 }}>剩余 {daysLeft} 天</span>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </Card>
+      </div>
+
+      <Card title="最近爬取记录">
+        {!recentLogs?.items?.length ? <div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12, opacity: 0.5 }}>📋</div><p style={{ fontSize: 14, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无爬取记录</p><p style={{ fontSize: 13 }}>点击"全部爬取"按钮开始抓取数据</p></div> : (
+          <div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "8px" }}>
+            {recentLogs.items.map((log: any) => {
+              const sc = statusColor(log.status);
+              return (
+                <div key={log.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: 8, fontSize: 13, background: T.bg, transition: "background 0.15s" }}>
+                  <span style={{ display: "inline-block", padding: "2px 10px", borderRadius: 99, fontSize: 11, fontWeight: 600, background: sc.bg, color: sc.color }}>{sc.text}</span>
+                  <span style={{ fontWeight: 500, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{log.domain}</span>
+                  <span style={{ color: T.textSec, whiteSpace: "nowrap", fontSize: 12, fontFamily: "monospace" }}>{log.created_at ? new Date(log.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }) : "-"}</span>
+                </div>
+              );
+            })}
+          </div>
+        )}
+      </Card>
+    </div>
+  );
+}

+ 205 - 0
frontend/src/pages/DomainsPage.tsx

@@ -0,0 +1,205 @@
+import { useState, useEffect } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { domainApi } from "../api/domains";
+import type { MonitoredDomain, MonitoredDomainCreate } from "../types/domain";
+import { T } from "../theme";
+import { Card, Badge, LoadingDots, Toast, ConfirmDialog, StatCard } from "../components/Shared";
+
+function FetchControls() {
+  const queryClient = useQueryClient();
+  const [autoFetch, setAutoFetch] = useState(false);
+  const [scheduleTime, setScheduleTime] = useState("02:00");
+  const [fetchDate, setFetchDate] = useState("");
+  const [fetchingByDate, setFetchingByDate] = useState(false);
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+
+  useEffect(() => {
+    domainApi.getSchedule().then(res => { setAutoFetch(res.data.enabled); setScheduleTime(res.data.schedule_time); }).catch(() => {});
+  }, []);
+
+  const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); setTimeout(() => setToast(null), 4000); };
+  const handleSaveSchedule = () => { domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(() => showToast("配置已保存", "success")).catch(() => showToast("保存失败", "error")); };
+
+  const batchMutation = useMutation({
+    mutationFn: () => domainApi.fetchAll(),
+    onSuccess: (res: any) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      const d = res.data;
+      if (d.errors?.length) showToast(`部分失败: ${d.errors.length}/${d.total}`, "error");
+      else if (!d.total) showToast("没有启用中的域名", "error");
+      else showToast(`全部爬取成功,共 ${d.total} 个域名`, "success");
+    },
+    onError: () => showToast("爬取请求失败", "error"),
+  });
+
+  const fetchByDateMutation = useMutation({
+    mutationFn: (date: string) => domainApi.fetchAll(date),
+    onSuccess: (res: any) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      setFetchingByDate(false);
+      const d = res.data;
+      if (d.errors?.length) showToast(`部分失败: ${d.errors.length}/${d.total}`, "error");
+      else showToast(`爬取成功,共 ${d.total} 个域名`, "success");
+    },
+    onError: () => {
+      setFetchingByDate(false);
+      showToast("爬取请求失败", "error");
+    },
+  });
+
+  return (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
+        <label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 14 }}>
+          <div onClick={() => setAutoFetch(!autoFetch)} style={{ width: 40, height: 22, borderRadius: 11, position: "relative", transition: "background 0.2s", background: autoFetch ? T.primary : T.border }}>
+            <div style={{ width: 18, height: 18, borderRadius: "50%", background: "#fff", position: "absolute", top: 2, left: autoFetch ? 20 : 2, transition: "left 0.2s", boxShadow: "0 1px 2px rgba(0,0,0,0.15)" }} />
+          </div>
+          <span style={{ color: T.text }}>每日定时爬取</span>
+        </label>
+        {autoFetch && <><span style={{ fontSize: 13, color: T.textSec }}>时间</span><input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} /></>}
+        <button onClick={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>保存配置</button>
+        <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{batchMutation.isPending ? "爬取中..." : "全部爬取"}</button>
+        <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
+        <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
+        <button onClick={() => { if (fetchDate) { setFetchingByDate(true); fetchByDateMutation.mutate(fetchDate); } }} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>{fetchingByDate ? "爬取中..." : "爬取"}</button>
+      </div>
+    </>
+  );
+}
+
+export function DomainsPage() {
+  const [newDomain, setNewDomain] = useState("");
+  const [newRemark, setNewRemark] = useState("");
+  const [fetchingId, setFetchingId] = useState<number | null>(null);
+  const [fetchDate, setFetchDate] = useState("");
+  const [showDatePicker, setShowDatePicker] = useState<number | null>(null);
+  const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
+  const [editingRemarkValue, setEditingRemarkValue] = useState("");
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+  const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
+  const queryClient = useQueryClient();
+  const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); setTimeout(() => setToast(null), 4000); };
+
+  const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
+  const addMutation = useMutation({
+    mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["domains"] });
+      setNewDomain("");
+      setNewRemark("");
+      showToast("域名添加成功", "success");
+    },
+    onError: () => showToast("添加失败,请检查域名格式", "error"),
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => domainApi.remove(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["domains"] });
+      showToast("域名已删除", "success");
+    },
+    onError: () => showToast("删除失败", "error"),
+  });
+
+  const fetchMutation = useMutation({
+    mutationFn: ({ id, date }: { id: number; date?: string }) => domainApi.fetchTransactions(id, date),
+    onSuccess: (res: any) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      setFetchingId(null);
+      setShowDatePicker(null);
+      showToast(`${res.data.domain} 爬取成功`, "success");
+    },
+    onError: () => {
+      setFetchingId(null);
+      showToast("爬取失败,请检查域名是否可达", "error");
+    },
+  });
+
+  const updateRemarkMutation = useMutation({
+    mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["domains"] });
+      setEditingRemarkId(null);
+      showToast("备注更新成功", "success");
+    },
+    onError: () => showToast("备注更新失败", "error"),
+  });
+
+  const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate({ id, date: fetchDate || undefined }); };
+  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
+  const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
+  const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
+  const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
+  const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
+
+  return (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <div style={{ marginLeft: 220, padding: 32 }}>
+        <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
+        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
+          {[
+            { label: "域名总数", value: domains?.length ?? 0, accent: T.primary, icon: "🌐" },
+            { label: "启用中", value: activeCount, accent: "#22c55e", icon: "✅" },
+            { label: "已停用", value: (domains?.length ?? 0) - activeCount, accent: T.textSec, icon: "⏸️" },
+            { label: "今日新增", value: 0, accent: "#f59e0b", icon: "📝" },
+          ].map((s) => (
+            <StatCard key={s.label} label={s.label} value={s.value} color={s.accent} icon={s.icon} />
+          ))}
+        </div>
+        <Card title="添加域名">
+          <form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
+            <input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
+            <input value={newRemark} onChange={(e) => setNewRemark(e.target.value)} placeholder="备注(用于标识超管)" style={{ width: 180, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
+            <button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
+          </form>
+        </Card>
+        <Card title="域名列表" extra={<FetchControls />}>
+          {isLoading ? <LoadingDots /> : !domains?.length ? <div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>🌐</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无域名</p><p style={{ fontSize: 13 }}>在上方输入框中添加需要监控的域名</p></div> : (
+            <table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
+              <thead><tr>{["ID", "域名", "备注", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "12px 14px", textAlign: "left", fontSize: 11, fontWeight: 600, color: T.textSec, letterSpacing: "0.05em", textTransform: "uppercase", background: T.bg, borderBottom: `2px solid ${T.border}`, position: "sticky", top: 0, zIndex: 1 }}>{h}</th>)}</tr></thead>
+              <tbody>
+                {domains.map((d: MonitoredDomain) => (
+                  <tr key={d.id} style={{ transition: "background 0.15s" }}>
+                    <td style={{ padding: "14px 14px", fontSize: 13, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{d.id}</td>
+                    <td style={{ padding: "14px 14px", fontWeight: 600, borderBottom: `1px solid ${T.border}` }}>{d.domain}</td>
+                    <td style={{ padding: "14px 14px", borderBottom: `1px solid ${T.border}` }}>
+                      {editingRemarkId === d.id ? (
+                        <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
+                          <input value={editingRemarkValue} onChange={(e) => setEditingRemarkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveRemark(d.id); if (e.key === "Escape") setEditingRemarkId(null); }} style={{ padding: "5px 10px", borderRadius: 6, border: `1px solid ${T.primary}`, fontSize: 13, outline: "none", width: 160 }} autoFocus />
+                          <button onClick={() => saveRemark(d.id)} style={{ padding: "4px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.success, color: "#fff", fontWeight: 500 }}>保存</button>
+                          <button onClick={() => setEditingRemarkId(null)} style={{ padding: "4px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.bg, color: T.text, fontWeight: 500 }}>取消</button>
+                        </div>
+                      ) : (
+                        <span onDoubleClick={() => startEditRemark(d)} style={{ cursor: "pointer", fontSize: 13, color: d.remark ? T.text : T.textSec, padding: "4px 8px", borderRadius: 6, transition: "background 0.15s" }} title="双击编辑">{d.remark || <span style={{ fontStyle: "italic" }}>双击添加备注</span>}</span>
+                      )}
+                    </td>
+                    <td style={{ padding: "14px 14px", borderBottom: `1px solid ${T.border}` }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
+                    <td style={{ padding: "14px 14px", fontSize: 13, color: T.textSec, borderBottom: `1px solid ${T.border}`, fontFamily: "monospace" }}>{fmtDate(d.created_at)}</td>
+                    <td style={{ padding: "14px 14px", display: "flex", gap: 6, alignItems: "center", borderBottom: `1px solid ${T.border}` }}>
+                      {showDatePicker === d.id ? (
+                        <>
+                          <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "5px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 12, outline: "none" }} />
+                          <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500 }}>{fetchingId === d.id ? "爬取中" : "爬取"}</button>
+                          <button onClick={() => { setShowDatePicker(null); setFetchDate(""); }} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.bg, color: T.text, fontWeight: 500 }}>取消</button>
+                        </>
+                      ) : (
+                        <>
+                          <button onClick={() => setShowDatePicker(d.id)} style={{ padding: "5px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text, fontWeight: 500 }}>按日期爬取</button>
+                          <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500 }}>{fetchingId === d.id ? "爬取中" : "爬取"}</button>
+                        </>
+                      )}
+                      <button onClick={() => setConfirmDelete(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "#fef2f2", color: T.danger, fontWeight: 500 }}>删除</button>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </Card>
+      </div>
+      {confirmDelete && <ConfirmDialog title="确认删除域名" message="删除后将无法恢复,确定要删除该域名吗?" onConfirm={() => { deleteMutation.mutate(confirmDelete); setConfirmDelete(null); }} onCancel={() => setConfirmDelete(null)} />}
+    </>
+  );
+}

+ 59 - 0
frontend/src/pages/FetchLogsPage.tsx

@@ -0,0 +1,59 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { domainApi } from "../api/domains";
+import { T } from "../theme";
+import { Card, LoadingDots } from "../components/Shared";
+import { PageLayout } from "../components/PageLayout";
+
+export function FetchLogsPage() {
+  const [filters, setFilters] = useState({ domain: "", status: "" });
+  const [page, setPage] = useState(1);
+  const size = 20;
+
+  const params: Record<string, string | number> = { page, size };
+  if (filters.domain) params.domain = filters.domain;
+  if (filters.status) params.status = filters.status;
+
+  const { data, isLoading } = useQuery({ queryKey: ["fetch-logs", params], queryFn: () => domainApi.getFetchLogs(params).then((r) => r.data) });
+
+  const statusBadge = (status: string) => {
+    if (status === "success") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#f0fdf4", color: "#16a34a" }}>成功</span>;
+    if (status === "failed") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fef2f2", color: "#dc2626" }}>失败</span>;
+    if (status === "skipped") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fefce8", color: "#ca8a04" }}>跳过</span>;
+    return <span style={{ color: T.textSec }}>{status}</span>;
+  };
+
+  const total = data?.total ?? 0;
+  const totalPages = Math.max(1, Math.ceil(total / size));
+
+  return (
+    <PageLayout title="爬取日志" subtitle="查看域名爬取历史记录">
+      <Card title="筛选">
+        <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
+          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>域名</label><input type="text" value={filters.domain} onChange={(e) => setFilters({ ...filters, domain: e.target.value })} placeholder="输入域名筛选" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 200 }} /></div>
+          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>状态</label><select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", background: "#fff" }}><option value="">全部</option><option value="success">成功</option><option value="failed">失败</option><option value="skipped">跳过</option></select></div>
+          <button onClick={() => setPage(1)} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
+        </div>
+      </Card>
+
+      <Card title={`日志记录(共 ${total} 条)`}>
+        {isLoading ? <LoadingDots /> : !data?.items?.length ? <div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>📋</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无爬取日志</p><p style={{ fontSize: 13 }}>执行爬取操作后,日志记录将显示在这里</p></div> : (
+          <>
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["ID", "域名", "状态", "信息", "时间"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <tbody>{data.items.map((log: any) => (<tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "8px 10px", color: T.textSec }}>{log.id}</td><td style={{ padding: "8px 10px", fontWeight: 500 }}>{log.domain}</td><td style={{ padding: "8px 10px" }}>{statusBadge(log.status)}</td><td style={{ padding: "8px 10px", color: log.status === "failed" ? T.danger : T.text, maxWidth: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.message}>{log.message}</td><td style={{ padding: "8px 10px", color: T.textSec, whiteSpace: "nowrap" }}>{log.created_at ? new Date(log.created_at).toLocaleString("zh-CN") : "-"}</td></tr>))}</tbody>
+            </table>
+            {totalPages > 1 && (
+              <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, marginTop: 16 }}>
+                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: page <= 1 ? "#f8fafc" : "#fff", color: page <= 1 ? T.textSec : T.text, transition: "all 0.15s" }}>上一页</button>
+                <span style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, background: T.primary, color: "#fff", fontWeight: 500 }}>{page}</span>
+                <span style={{ color: T.textSec }}>/ {totalPages}</span>
+                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: page >= totalPages ? "#f8fafc" : "#fff", color: page >= totalPages ? T.textSec : T.text, transition: "all 0.15s" }}>下一页</button>
+              </div>
+            )}
+          </>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}

+ 150 - 0
frontend/src/pages/LicensePage.tsx

@@ -0,0 +1,150 @@
+import { useState, useEffect } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { domainApi } from "../api/domains";
+import { licenseApi } from "../api/license";
+import { T } from "../theme";
+import { Card, Badge, LoadingDots, Toast, ConfirmDialog } from "../components/Shared";
+import { PageLayout } from "../components/PageLayout";
+
+export function LicensePage() {
+  const queryClient = useQueryClient();
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+  const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); setTimeout(() => setToast(null), 4000); };
+
+  const { data: domains, isLoading: domainsLoading } = useQuery({ queryKey: ["license-domains"], queryFn: () => domainApi.list().then((r) => r.data) });
+  const { data: saOptions, isLoading: saLoading } = useQuery({ queryKey: ["sa-options"], queryFn: () => licenseApi.getSuperAdmins().then((r) => r.data) });
+  const { data: licenses, isLoading: licensesLoading } = useQuery({ queryKey: ["licenses"], queryFn: () => licenseApi.list({ size: 100 }).then((r) => r.data) });
+
+  const [createForm, setCreateForm] = useState({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
+  const [editTarget, setEditTarget] = useState<any | null>(null);
+  const [editForm, setEditForm] = useState({ license_key: "", expires_at: "" });
+  const [confirmAction, setConfirmAction] = useState<{ type: "revoke" | "delete"; id: number } | null>(null);
+
+  const createMutation = useMutation({ mutationFn: (data: any) => licenseApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setCreateForm({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" }); showToast("License 创建成功", "success"); }, onError: (err: any) => showToast(err.response?.data?.detail || "创建失败", "error") });
+  const revokeMutation = useMutation({ mutationFn: (id: number) => licenseApi.revoke(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); }, onError: () => showToast("吊销失败", "error") });
+  const deleteMutation = useMutation({ mutationFn: (id: number) => licenseApi.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已删除", "success"); }, onError: () => showToast("删除失败", "error") });
+  const restoreMutation = useMutation({ mutationFn: (id: number) => licenseApi.restore(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); }, onError: () => showToast("恢复失败", "error") });
+  const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) => licenseApi.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); }, onError: () => showToast("更新失败", "error") });
+
+  useEffect(() => {
+    if (editTarget) setEditForm({ license_key: editTarget.license_key || "", expires_at: editTarget.expires_at ? editTarget.expires_at.split(".")[0].substring(0, 16) : "" });
+  }, [editTarget?.id]);
+
+  const handleCreate = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!createForm.super_admin_id || !createForm.license_key || !createForm.expires_at) { showToast("请填写必填项(超级管理员、License Key、过期时间)", "error"); return; }
+    const data: any = { super_admin_id: parseInt(createForm.super_admin_id), license_key: createForm.license_key, expires_at: createForm.expires_at };
+    if (createForm.max_tenants) data.max_tenants = parseInt(createForm.max_tenants);
+    if (createForm.max_users) data.max_users_per_tenant = parseInt(createForm.max_users);
+    if (createForm.remark) data.remark = createForm.remark;
+    createMutation.mutate(data);
+  };
+
+  const handleSaveEdit = () => {
+    if (!editTarget) return;
+    const data: { license_key?: string; expires_at?: string } = {};
+    if (editForm.license_key && editForm.license_key !== editTarget.license_key) data.license_key = editForm.license_key;
+    if (editForm.expires_at && editForm.expires_at !== (editTarget.expires_at || "").split(".")[0]?.substring(0, 16)) data.expires_at = editForm.expires_at;
+    if (Object.keys(data).length === 0) { showToast("未做任何修改", "error"); return; }
+    updateMutation.mutate({ id: editTarget.id, data });
+  };
+
+  const statusColor = (status: string) => {
+    if (status === "active") return { bg: "#f0fdf4", color: "#16a34a", text: "有效" };
+    if (status === "expired") return { bg: "#fef2f2", color: "#dc2626", text: "已过期" };
+    if (status === "revoked") return { bg: "#f1f5f9", color: "#64748b", text: "已吊销" };
+    return { bg: "#f1f5f9", color: "#94a3b8", text: status };
+  };
+
+  if (domainsLoading || saLoading || licensesLoading) return <PageLayout title="License 许可" subtitle="管理系统授权许可"><LoadingDots /></PageLayout>;
+
+  return (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <PageLayout title="License 许可" subtitle="管理系统授权许可">
+        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
+          {[{ label: "监控域名数", value: domains?.length ?? 0, accent: T.primary }, { label: "有效 License", value: licenses?.items?.filter((l: any) => l.status === "active").length ?? 0, accent: "#22c55e" }, { label: "已过期", value: licenses?.items?.filter((l: any) => l.status === "expired").length ?? 0, accent: "#dc2626" }, { label: "已吊销", value: licenses?.items?.filter((l: any) => l.status === "revoked").length ?? 0, accent: "#64748b" }].map((s) => (
+            <div key={s.label} style={{ background: T.card, borderRadius: 10, padding: "18px 22px", boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}`, borderLeft: `3px solid ${s.accent}` }}>
+              <div style={{ fontSize: 13, color: T.textSec, marginBottom: 6 }}>{s.label}</div>
+              <div style={{ fontSize: 26, fontWeight: 700, color: T.heading }}>{s.value}</div>
+            </div>
+          ))}
+        </div>
+
+        <Card title="创建 License">
+          <form onSubmit={handleCreate} style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员 *</label>
+              <select value={createForm.super_admin_id} onChange={(e) => setCreateForm({ ...createForm, super_admin_id: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, minWidth: 200, outline: "none", background: "#fff" }}>
+                <option value="">-- 请选择 --</option>
+                {saOptions?.map((sa: any) => <option key={sa.id} value={sa.id}>{sa.remark || sa.source_domain || sa.username} (ID: {sa.id})</option>)}
+              </select>
+            </div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key *</label><input value={createForm.license_key} onChange={(e) => setCreateForm({ ...createForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 220, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间 *</label><input type="datetime-local" value={createForm.expires_at} onChange={(e) => setCreateForm({ ...createForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>最大租户数</label><input type="number" value={createForm.max_tenants} onChange={(e) => setCreateForm({ ...createForm, max_tenants: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 100, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>每租户最大用户数</label><input type="number" value={createForm.max_users} onChange={(e) => setCreateForm({ ...createForm, max_users: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 120, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>备注</label><input value={createForm.remark} onChange={(e) => setCreateForm({ ...createForm, remark: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 150, outline: "none" }} /></div>
+            <button type="submit" disabled={createMutation.isPending} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: createMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{createMutation.isPending ? "创建中..." : "创建"}</button>
+          </form>
+        </Card>
+
+        <Card title={`License 列表(共 ${licenses?.total ?? 0} 条)`}>
+          {!licenses?.items?.length ? <div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>🔑</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无 License 记录</p><p style={{ fontSize: 13 }}>在上方表单中创建新的 License</p></div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["超管", "关联域名", "联系人", "License Key", "过期时间", "状态", "剩余天数", "备注", "操作"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <tbody>
+                {licenses.items.map((l: any) => {
+                  const sc = statusColor(l.status);
+                  const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+                  return (
+                    <tr key={l.id} style={{ borderBottom: `1px solid ${T.border}` }}>
+                      <td style={{ padding: "8px 10px" }}>{l.super_admin_name || l.super_admin_id}</td>
+                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontSize: 12, color: T.textSec }}>{l.domain || "-"}</td>
+                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.contact?.name || l.contact?.phone || l.contact?.email ? (<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>{l.contact.name && <span style={{ fontWeight: 500 }}>{l.contact.name}</span>}{l.contact.phone && <span style={{ color: T.textSec }}>{l.contact.phone}</span>}{l.contact.email && <span style={{ color: T.textSec }}>{l.contact.email}</span>}</div>) : "-"}</td>
+                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontWeight: 500, fontSize: 12 }}>{l.license_key}</td>
+                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? (l.expires_at.replace("T", " ").split("+")[0] || "-") : "-"}</td>
+                      <td style={{ padding: "8px 10px" }}><span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 12, background: sc.bg, color: sc.color }}>{sc.text}</span></td>
+                      <td style={{ padding: "8px 10px", color: daysLeft <= 7 && l.status === "active" ? T.danger : T.text }}>{l.status === "active" ? `${daysLeft} 天` : "-"}</td>
+                      <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={l.remark || ""}>{l.remark || "-"}</td>
+                      <td style={{ padding: "8px 10px", display: "flex", gap: 6 }}>
+                        <button onClick={() => setEditTarget(l)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}>编辑</button>
+                        {l.status === "active" && <button onClick={() => setConfirmAction({ type: "revoke", id: l.id })} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #f59e0b`, background: "transparent", color: "#f59e0b" }}>吊销</button>}
+                        {l.status === "revoked" && <button onClick={() => restoreMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #22c55e`, background: "transparent", color: "#22c55e" }}>恢复</button>}
+                        <button onClick={() => setConfirmAction({ type: "delete", id: l.id })} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger }}>删除</button>
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          )}
+        </Card>
+
+        {editTarget && (
+          <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setEditTarget(null)}>
+            <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 420, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
+              <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>编辑 License #{editTarget.id}</h3>
+              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+                <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key</label><input value={editForm.license_key} onChange={(e) => setEditForm({ ...editForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} /></div>
+                <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间</label><input type="datetime-local" value={editForm.expires_at} onChange={(e) => setEditForm({ ...editForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} /></div>
+              </div>
+              <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
+                <button onClick={() => setEditTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
+                <button onClick={handleSaveEdit} disabled={updateMutation.isPending} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: updateMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}>{updateMutation.isPending ? "保存中..." : "保存"}</button>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {confirmAction && (
+          <ConfirmDialog
+            title={confirmAction.type === "revoke" ? "确认吊销 License" : "确认删除 License"}
+            message={confirmAction.type === "revoke" ? "吊销后 License 将失效且无法恢复,确定要继续吗?" : "删除后将永久清除该 License 记录,确定要继续吗?"}
+            onConfirm={() => { if (confirmAction.type === "revoke") revokeMutation.mutate(confirmAction.id); else deleteMutation.mutate(confirmAction.id); setConfirmAction(null); }}
+            onCancel={() => setConfirmAction(null)}
+          />
+        )}
+      </PageLayout>
+    </>
+  );
+}

+ 72 - 0
frontend/src/pages/LoginPage.tsx

@@ -0,0 +1,72 @@
+import { useState } from "react";
+import { useNavigate, Outlet } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { T, globalStyle } from "../theme";
+import { loginApi } from "../api/domains";
+
+function GlobalStyle() { return <style>{globalStyle}</style>; }
+
+export function LoginPage() {
+  const navigate = useNavigate();
+  const [username, setUsername] = useState("");
+  const [password, setPassword] = useState("");
+  const [error, setError] = useState("");
+  const [pending, setPending] = useState(false);
+
+  const handleLogin = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setError("");
+    if (!username || !password) { setError("请输入用户名和密码"); return; }
+    setPending(true);
+    try {
+      await loginApi.login(username, password);
+      sessionStorage.setItem("auth", "1");
+      navigate("/");
+    } catch (err: any) {
+      setError(err.response?.data?.detail || "用户名或密码错误");
+      setPending(false);
+    }
+  };
+
+  return (
+    <>
+      <GlobalStyle />
+      <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: `linear-gradient(135deg, ${T.bg} 0%, #e0e7ff 50%, #ddd6fe 100%)` }}>
+        <div style={{ width: 420, animation: "scaleIn 0.3s ease" }}>
+          <div style={{ textAlign: "center", marginBottom: 36 }}>
+            <div style={{ width: 60, height: 60, background: `linear-gradient(135deg, ${T.primary}, #8b5cf6)`, borderRadius: 16, display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 26, fontWeight: 700, color: "#fff", marginBottom: 18, boxShadow: "0 12px 32px rgba(99,102,241,0.35)" }}>D</div>
+            <h1 style={{ fontSize: 24, fontWeight: 700, color: T.heading, marginBottom: 6 }}>域名流水监控</h1>
+            <p style={{ fontSize: 14, color: T.textSec }}>请登录以继续</p>
+          </div>
+          <div style={{ background: T.card, borderRadius: 16, boxShadow: "0 8px 32px rgba(0,0,0,0.1)", border: `1px solid ${T.border}`, padding: 36 }}>
+            <form onSubmit={handleLogin}>
+              <div style={{ marginBottom: 22 }}>
+                <label style={{ display: "block", fontSize: 13, fontWeight: 500, color: T.heading, marginBottom: 8 }}>用户名</label>
+                <input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名" style={{ width: "100%", padding: "11px 14px", border: `1px solid ${T.border}`, borderRadius: 10, fontSize: 14, color: T.text, background: "#fff", outline: "none", boxSizing: "border-box", transition: "all 0.2s" }} autoFocus />
+              </div>
+              <div style={{ marginBottom: 28 }}>
+                <label style={{ display: "block", fontSize: 13, fontWeight: 500, color: T.heading, marginBottom: 8 }}>密码</label>
+                <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="请输入密码" onKeyDown={(e) => { if (e.key === "Enter") handleLogin(e as any); }} style={{ width: "100%", padding: "11px 14px", border: `1px solid ${T.border}`, borderRadius: 10, fontSize: 14, color: T.text, background: "#fff", outline: "none", boxSizing: "border-box", transition: "all 0.2s" }} />
+              </div>
+              {error && (
+                <div style={{ marginBottom: 20, padding: "12px 14px", borderRadius: 10, fontSize: 13, background: "#fef2f2", color: T.danger, display: "flex", alignItems: "center", gap: 8, animation: "fadeIn 0.2s ease", border: "1px solid #fecaca" }}>
+                  <span style={{ fontSize: 16, fontWeight: 700 }}>!</span>{error}
+                </div>
+              )}
+              <button type="submit" disabled={pending} style={{ width: "100%", padding: "13px", borderRadius: 10, fontSize: 15, fontWeight: 600, cursor: pending ? "default" : "pointer", border: "none", background: pending ? "#a5b4fc" : T.primary, color: "#fff", transition: "all 0.2s", boxShadow: pending ? "none" : "0 4px 14px rgba(99,102,241,0.35)" }}>
+                {pending ? "登录中..." : "登 录"}
+              </button>
+            </form>
+          </div>
+          <p style={{ textAlign: "center", marginTop: 24, fontSize: 12, color: T.textSec }}>© 2026 域名流水监控系统</p>
+        </div>
+      </div>
+    </>
+  );
+}
+
+export function ProtectedRoute() {
+  const authenticated = sessionStorage.getItem("auth") === "1";
+  if (!authenticated) return <LoginPage />;
+  return <Outlet />;
+}

+ 67 - 0
frontend/src/pages/MonitoringPage.tsx

@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { monitoringApi } from "../api/monitoring";
+import { T } from "../theme";
+import { Card, Badge, LoadingDots, CollapsePanel } from "../components/Shared";
+import { FilterBar } from "../components/PageLayout";
+
+export function MonitoringPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+
+  const { data: dashboard, isLoading } = useQuery({ queryKey: ["dashboard", params], queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data) });
+
+  return (
+    <div style={{ marginLeft: 220, padding: 32 }}>
+      <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
+      <FilterBar currentQuery={query} onSearch={setQuery} />
+      {isLoading ? <LoadingDots /> : !dashboard ? (
+        <Card title="数据"><div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>📊</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无监控数据</p><p style={{ fontSize: 13 }}>请先在域名管理页面添加并爬取域名流水</p></div></Card>
+      ) : (
+        <>
+          <Card title="平台汇总">
+            <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
+              {[
+                { label: "超级管理员数", value: dashboard.overview.total_super_admins, icon: "👤" },
+                { label: "租户总数", value: dashboard.overview.total_tenants, icon: "🏢" },
+                { label: "用户总数", value: dashboard.overview.total_users, icon: "👥" },
+                { label: "总消费(元)", value: dashboard.overview.total_consumption, icon: "💰" },
+                { label: "总收取(元)", value: dashboard.overview.total_tenant_charged, icon: "💵" },
+                { label: "总余额(元)", value: dashboard.overview.total_balance, icon: "📊" },
+              ].map((s) => (
+                <div key={s.label} style={{ background: T.bg, borderRadius: 10, padding: "16px 20px", display: "flex", alignItems: "center", gap: 14 }}>
+                  <div style={{ width: 40, height: 40, borderRadius: 10, background: `${T.primary}12`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, flexShrink: 0 }}>{s.icon}</div>
+                  <div><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>{s.label}</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{s.value}</div></div>
+                </div>
+              ))}
+            </div>
+          </Card>
+          {dashboard.super_admins.map((sa: any) => (
+            <Card key={sa.super_admin_id} title={`${sa.remark || sa.source_domain || sa.username}`} extra={<div style={{ display: "flex", gap: 8, alignItems: "center" }}><span style={{ fontSize: 13, fontWeight: 600, color: "#16a34a" }}>收取 ¥{sa.total_tenant_charged}</span><Badge active text={`${sa.tenant_count} 个租户`} /></div>}>
+              <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
+              {sa.tenants.map((tenant: any) => (
+                <CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
+                  <div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
+                  {tenant.users.map((u: any) => (
+                    <CollapsePanel key={u.user_id} title={u.username} badge={`消费 ¥${u.total_consumption}`}>
+                      <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>企业实际支付 ¥{u.tenant_actual_total}</div>
+                      {u.consumption_records.length > 0 && (
+                        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+                          <thead><tr>{["订单号", "模型", "金额(元)", "用户折扣", "企业折扣", "超管折扣", "时间", "已开票"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+                          <tbody>{u.consumption_records.map((r: any, idx: number) => (<tr key={idx}><td style={{ padding: "6px 10px", color: T.textSec }}>{r.order_no || "-"}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.user_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.super_admin_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.created_at}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.invoiced ? "是" : "否"}</td></tr>))}</tbody>
+                        </table>
+                      )}
+                    </CollapsePanel>
+                  ))}
+                </CollapsePanel>
+              ))}
+            </Card>
+          ))}
+        </>
+      )}
+    </div>
+  );
+}

+ 69 - 0
frontend/src/pages/SuperAdminsPage.tsx

@@ -0,0 +1,69 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { monitoringApi } from "../api/monitoring";
+import { T } from "../theme";
+import { Card, Badge, LoadingDots } from "../components/Shared";
+import { PageLayout, FilterBar, TooltipTh } from "../components/PageLayout";
+
+export function SuperAdminsPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  const hasDate = Boolean(query.startDate && query.endDate);
+  const statsParams: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) statsParams.start_date = query.startDate;
+  if (query.endDate) statsParams.end_date = query.endDate;
+  if (query.saName) statsParams.super_admin_name = query.saName;
+
+  const { data: dashboard, isLoading: dashLoading } = useQuery({ queryKey: ["dashboard", statsParams], queryFn: () => monitoringApi.getDashboard(Object.keys(statsParams).length ? statsParams : undefined).then((r) => r.data) });
+  const dailyParams = hasDate ? statsParams as { start_date: string; end_date: string; super_admin_name?: string } : undefined;
+  const { data: dailyStats, isLoading: dailyLoading } = useQuery({ queryKey: ["daily-stats", dailyParams], queryFn: () => monitoringApi.getDailyStats(dailyParams!).then((r) => r.data), enabled: hasDate });
+
+  if (dashLoading || (hasDate && dailyLoading)) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><LoadingDots /></PageLayout>;
+
+  const totalCount = dashboard?.super_admins?.length ?? 0;
+  const totalConsumption = dashboard?.super_admins?.reduce((s: number, sa: any) => s + parseFloat(sa.total_consumption || 0), 0).toFixed(4) ?? "0";
+  const totalCharged = dashboard?.super_admins?.reduce((s: number, sa: any) => s + parseFloat(sa.total_tenant_charged || 0), 0).toFixed(4) ?? "0";
+
+  return (
+    <PageLayout title="超级管理员" subtitle={hasDate ? `${query.startDate} ~ ${query.endDate} 每日消费统计` : "查看各超级管理员消费数据"}>
+      <FilterBar currentQuery={query} onSearch={setQuery} />
+      <Card title="统计">
+        <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
+          {[
+            { label: "管理员数", value: totalCount, icon: "👤" },
+            { label: "总消费(元)", value: totalConsumption, icon: "💰" },
+            { label: "总收取(元)", value: totalCharged, icon: "💵" },
+          ].map((s) => (
+            <div key={s.label} style={{ background: T.bg, borderRadius: 10, padding: "16px 20px", display: "flex", alignItems: "center", gap: 14 }}>
+              <div style={{ width: 40, height: 40, borderRadius: 10, background: `${T.primary}12`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, flexShrink: 0 }}>{s.icon}</div>
+              <div><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>{s.label}</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{s.value}</div></div>
+            </div>
+          ))}
+        </div>
+      </Card>
+      {hasDate && (
+        (dailyStats?.sa_stats || []).length === 0
+          ? <Card title="每日消费明细"><div style={{ textAlign: "center", padding: "40px 24px", color: T.textSec }}><div style={{ fontSize: 36, marginBottom: 12, opacity: 0.5 }}>📅</div><p style={{ fontSize: 14, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无每日数据</p><p style={{ fontSize: 13 }}>尝试调整筛选日期范围</p></div></Card>
+          : dailyStats?.sa_stats?.map((sa: any, saIdx: number) => (
+            <Card key={saIdx} title={`${sa.sa_name} 每日消费明细`}>
+              <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+                <thead><tr><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th><TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" /></tr></thead>
+                <tbody>{(sa.tenants || []).map((t: any, idx: number) => (<tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "8px 12px", fontWeight: 500 }}>{t.date}</td><td style={{ padding: "8px 12px", color: T.textSec }}>{t.tenant_name || "-"}</td><td style={{ padding: "8px 12px" }}>{t.consumption}</td><td style={{ padding: "8px 12px" }}>{t.charged}</td></tr>))}</tbody>
+              </table>
+            </Card>
+          ))
+      )}
+      {!hasDate && !dashboard && <Card title="数据"><div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>📊</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无数据</p><p style={{ fontSize: 13 }}>请先在域名管理页面添加并爬取域名流水</p></div></Card>}
+      {!hasDate && dashboard && dashboard.super_admins.map((sa: any) => (
+        <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active text={`${sa.tenant_count} 个租户`} />}>
+          <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
+          {sa.tenants.length === 0 ? <div style={{ textAlign: "center", padding: 24, color: T.textSec, fontSize: 13 }}>暂无租户数据</div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse" }}>
+              <thead><tr><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th><TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" /><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th></tr></thead>
+              <tbody>{sa.tenants.map((t: any) => (<tr key={t.tenant_id} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td><td style={{ padding: "10px 12px" }}>{t.user_count}</td><td style={{ padding: "10px 12px" }}>{t.total_consumption}</td><td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td><td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td></tr>))}</tbody>
+            </table>
+          )}
+        </Card>
+      ))}
+    </PageLayout>
+  );
+}

+ 58 - 0
frontend/src/pages/TenantsPage.tsx

@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { monitoringApi } from "../api/monitoring";
+import { T } from "../theme";
+import { Card, LoadingDots } from "../components/Shared";
+import { PageLayout, FilterBar, TooltipTh } from "../components/PageLayout";
+
+export function TenantsPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+  const hasDate = Boolean(query.startDate && query.endDate);
+
+  const { data: dashboard, isLoading: dashLoading } = useQuery({ queryKey: ["dashboard", params], queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data), enabled: !hasDate });
+  const { data: dailyStats, isLoading: dailyLoading } = useQuery({ queryKey: ["daily-stats", params], queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined, tenant_name: query.tenantName || undefined }).then((r) => r.data), enabled: hasDate });
+
+  if (dashLoading || dailyLoading) return <PageLayout title="租户" subtitle="查看各租户消费数据"><LoadingDots /></PageLayout>;
+
+  if (hasDate) {
+    const rows: any[] = [];
+    (dailyStats?.sa_stats || []).forEach((sa: any) => { (sa.tenants || []).forEach((t: any) => { rows.push({ sa_name: sa.sa_name, tenant_name: t.tenant_name || "-", date: t.date, consumption: t.consumption, charged: t.charged }); }); });
+    return (
+      <PageLayout title="租户" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
+        <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
+        <Card title={`每日明细(共 ${rows.length} 条)`}>
+          {rows.length === 0 ? <div style={{ textAlign: "center", padding: "32px 24px", color: T.textSec }}><div style={{ fontSize: 32, marginBottom: 8, opacity: 0.5 }}>📅</div><p style={{ fontSize: 13, color: T.heading, fontWeight: 500 }}>暂无每日消费数据</p></div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th><TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" /></tr></thead>
+              <tbody>{rows.map((r: any, idx: number) => (<tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "8px 12px", color: T.textSec }}>{r.sa_name}</td><td style={{ padding: "8px 12px", fontWeight: 500 }}>{r.date}</td><td style={{ padding: "8px 12px" }}>{r.tenant_name}</td><td style={{ padding: "8px 12px" }}>{r.consumption}</td><td style={{ padding: "8px 12px" }}>{r.charged}</td></tr>))}</tbody>
+            </table>
+          )}
+        </Card>
+      </PageLayout>
+    );
+  }
+
+  if (!dashboard) return <PageLayout title="租户" subtitle="查看各租户消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>🏢</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无租户数据</p><p style={{ fontSize: 13 }}>请先爬取域名流水以获取租户信息</p></div></Card></PageLayout>;
+
+  const allTenants: any[] = [];
+  dashboard.super_admins.forEach((sa: any) => { sa.tenants.forEach((t: any) => { allTenants.push({ ...t, sa_name: sa.remark || sa.source_domain || sa.username }); }); });
+  const filteredTenants = query.tenantName ? allTenants.filter((t: any) => t.company_name?.toLowerCase().includes(query.tenantName.toLowerCase()) || t.subdomain?.toLowerCase().includes(query.tenantName.toLowerCase())) : allTenants;
+
+  return (
+    <PageLayout title="租户" subtitle="查看各租户消费数据">
+      <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
+      <Card title={`租户列表(共 ${filteredTenants.length} 个)`}>
+        {filteredTenants.length === 0 ? <div style={{ textAlign: "center", padding: "40px 24px", color: T.textSec }}><div style={{ fontSize: 36, marginBottom: 12, opacity: 0.5 }}>🏢</div><p style={{ fontSize: 14, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无匹配的租户</p><p style={{ fontSize: 13 }}>尝试调整筛选条件</p></div> : (
+          <table style={{ width: "100%", borderCollapse: "collapse" }}>
+            <thead><tr><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th><TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" /><th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th></tr></thead>
+            <tbody>{filteredTenants.map((t: any) => (<tr key={`${t.sa_name}-${t.tenant_id}`} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "10px 12px", color: T.textSec }}>{t.sa_name}</td><td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td><td style={{ padding: "10px 12px" }}>{t.user_count}</td><td style={{ padding: "10px 12px" }}>{t.total_consumption}</td><td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td><td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td></tr>))}</tbody>
+          </table>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}

+ 58 - 0
frontend/src/pages/UsersPage.tsx

@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { monitoringApi } from "../api/monitoring";
+import { T } from "../theme";
+import { Card, LoadingDots } from "../components/Shared";
+import { PageLayout, FilterBar } from "../components/PageLayout";
+
+export function UsersPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
+
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string; page?: number; page_size?: number } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+  if (query.tenantName) params.tenant_name = query.tenantName;
+  params.page = page;
+  params.page_size = pageSize;
+
+  const { data: details, isLoading } = useQuery({ queryKey: ["consumption-details", params], queryFn: () => monitoringApi.getConsumptionDetails(params).then((r) => r.data) });
+
+  if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><LoadingDots /></PageLayout>;
+  if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: "48px 24px", color: T.textSec }}><div style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>💳</div><p style={{ fontSize: 15, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无消费记录</p><p style={{ fontSize: 13 }}>请先爬取域名流水以获取消费明细</p></div></Card></PageLayout>;
+
+  const records = details.records || [];
+  const total = details.total ?? 0;
+  const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+  return (
+    <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
+      <FilterBar currentQuery={query} onSearch={(q) => { setQuery(q); setPage(1); }} showTenantFilter />
+      <Card title={`消费明细(共 ${total} 笔)`}>
+        {records.length === 0 ? <div style={{ textAlign: "center", padding: "40px 24px", color: T.textSec }}><div style={{ fontSize: 36, marginBottom: 12, opacity: 0.5 }}>💳</div><p style={{ fontSize: 14, fontWeight: 500, color: T.heading, marginBottom: 4 }}>暂无消费记录</p><p style={{ fontSize: 13 }}>爬取域名流水后,消费明细将显示在这里</p></div> : (
+          <>
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <tbody>{records.map((r: any, idx: number) => (<tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}><td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td><td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td><td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td><td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td><td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td><td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td><td style={{ padding: "8px 10px" }}>{r.user_consumed}</td><td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td><td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td><td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td><td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td></tr>))}</tbody>
+            </table>
+            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16, fontSize: 13 }}>
+              <div style={{ display: "flex", alignItems: "center", gap: 8, color: T.textSec }}>
+                <span>每页</span>
+                <select value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none", background: "#fff", cursor: "pointer" }}>{[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n} 条</option>)}</select>
+                <span>,共 {total} 条</span>
+              </div>
+              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
+                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: page <= 1 ? "#f8fafc" : "#fff", color: page <= 1 ? T.textSec : T.text, transition: "all 0.15s" }}>上一页</button>
+                <span style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, background: T.primary, color: "#fff", fontWeight: 500 }}>{page}</span>
+                <span style={{ color: T.textSec }}>/ {totalPages}</span>
+                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "6px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: page >= totalPages ? "#f8fafc" : "#fff", color: page >= totalPages ? T.textSec : T.text, transition: "all 0.15s" }}>下一页</button>
+              </div>
+            </div>
+          </>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}

+ 37 - 0
frontend/src/theme.ts

@@ -0,0 +1,37 @@
+/* ===== 颜色主题 ===== */
+export const T = {
+  primary: "#6366f1",
+  primaryHover: "#4f46e5",
+  danger: "#ef4444",
+  success: "#22c55e",
+  sidebar: "#1e293b",
+  sidebarText: "#94a3b8",
+  sidebarActive: "#ffffff",
+  bg: "#f8fafc",
+  card: "#ffffff",
+  text: "#334155",
+  textSec: "#94a3b8",
+  border: "#e2e8f0",
+  heading: "#0f172a",
+};
+
+/* ===== 全局样式 ===== */
+export const globalStyle = `
+  *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
+  html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+  @keyframes fadeIn { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:translateY(0); } }
+  @keyframes slideDown { from { opacity:0; transform:translateY(-20px); } to { opacity:1; transform:translateY(0); } }
+  @keyframes scaleIn { from { opacity:0; transform:scale(0.96); } to { opacity:1; transform:scale(1); } }
+  input:focus, select:focus, textarea:focus { border-color: ${T.primary} !important; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
+  button:hover:not(:disabled) { filter: brightness(0.95); }
+  button:active:not(:disabled) { transform: scale(0.98); }
+  .nav-item:hover { background: rgba(255,255,255,0.08) !important; color: #fff !important; }
+  .nav-item:hover svg { opacity: 1 !important; }
+  tr:hover td { background: rgba(99,102,241,0.03); }
+  ::selection { background: ${T.primary}22; color: ${T.heading}; }
+  ::-webkit-scrollbar { width: 6px; }
+  ::-webkit-scrollbar-track { background: transparent; }
+  ::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 3px; }
+  ::-webkit-scrollbar-thumb:hover { background: ${T.textSec}; }
+`;