فهرست منبع

feat: 添加超级管理员余额管理功能

- 新增 super_admin 表 balance/phone/balance_warning_sent/balance_depleted_sent 字段
- 新增 super_admin_balance_log 余额变动日志表
- 新增 pending_deductions 待补偿扣减记录表
- 新增超管余额充值/扣减/查询 API
- 新增余额预警/耗尽短信通知(复用 AIGC-Space 余额预警模板)
- License 检查 API 增加余额状态返回
- 后台任务:余额预警检查(10分钟)+ 扣减补偿重试(30秒)
- 前端:超级管理员页面增加余额显示、充值弹窗、余额记录弹窗

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mengboxin137-blip 11 ساعت پیش
والد
کامیت
11bcba7f8b

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
.claude-terminal


+ 9 - 0
.gitignore

@@ -2,13 +2,22 @@
 **/__pycache__/
 **/*.pyc
 **/*.pyo
+**/.venv/
+**/uv.lock
+
 # Frontend
 frontend/node_modules/
 frontend/dist/
+
+# Environment
+**/.env
+
 # Logs
 **/*.log
+
 # claude
 .claude
+
 # Editor
 .vscode/
 .idea/

+ 5 - 0
backend/.env

@@ -21,6 +21,11 @@ SMS_TEMPLATE_CODE_EXPIRED=SMS_506350367
 SMS_TEMPLATE_CODE_RESTORED=SMS_506275397
 SMS_TEMPLATE_CODE_WARNING=SMS_506330424
 
+# 超管余额预警短信模板(复用 AIGC-Space 的余额预警模板 SMS_505565009)
+SMS_TEMPLATE_CODE_SA_BALANCE_WARNING=SMS_505565009
+SMS_TEMPLATE_CODE_SA_BALANCE_DEPLETED=SMS_505565009
+SA_BALANCE_WARNING_THRESHOLD=100
+
 # Redis
 REDIS_HOST=192.168.0.3
 REDIS_PORT=6379

+ 5 - 0
backend/app/config.py

@@ -31,6 +31,11 @@ class Settings(BaseSettings):
     sms_template_code_restored: str = "SMS_506275397"
     sms_template_code_warning: str = "SMS_506330424"
 
+    # 超管余额预警短信模板
+    sms_template_code_sa_balance_warning: str = ""    # 余额预警 {companyName, remainAmount}
+    sms_template_code_sa_balance_depleted: str = ""   # 余额耗尽 {companyName}
+    sa_balance_warning_threshold: float = 100.0       # 全局预警阈值(元)
+
     # Redis
     redis_host: str = "localhost"
     redis_port: int = 6379

+ 97 - 4
backend/app/main.py

@@ -10,14 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware
 from app.config import settings
 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.routers import sa_balance as sa_balance_router
 from app.database import async_session
 from app.services.license import _notify_on_expired, _notify_on_warning
 from app.services.domain_fetch import fetch_domain_transactions
+from app.services.sms import send_sa_balance_warning, send_sa_balance_depleted
+from app.services.compensation_service import process_pending
 from app.models.license import SuperAdminLicense
-from app.models.monitoring import FetchScheduleConfig, FetchLog
+from app.models.monitoring import FetchScheduleConfig, FetchLog, SuperAdmin
 from app.models.domain import MonitoredDomain
+from app.models.visitor import VisitorInfo
 from app.redis import close_redis
 from sqlalchemy import select, text
+from decimal import Decimal
 
 logger = logging.getLogger(__name__)
 
@@ -119,6 +124,81 @@ async def _daily_fetch():
         await asyncio.sleep(60)
 
 
+async def _check_sa_balances():
+    """定期检查超管余额:余额预警 + 余额耗尽通知(每 10 分钟)"""
+    while True:
+        try:
+            async with async_session() as session:
+                result = await session.execute(select(SuperAdmin).order_by(SuperAdmin.id))
+                threshold = Decimal(str(settings.sa_balance_warning_threshold))
+
+                for sa in result.scalars().all():
+                    balance = Decimal(str(sa.balance or 0))
+                    company = sa.remark or sa.username or str(sa.id)
+
+                    # 手机号:优先用超管自身 phone,否则从域名关联的 VisitorInfo 获取
+                    phone = sa.phone
+                    if not phone:
+                        domain_row = (await session.execute(
+                            select(MonitoredDomain).where(
+                                MonitoredDomain.super_admin_id == sa.id,
+                                MonitoredDomain.is_active == True,
+                            ).limit(1)
+                        )).scalar_one_or_none()
+                        if domain_row:
+                            visitor = (await session.execute(
+                                select(VisitorInfo).where(VisitorInfo.domain_id == domain_row.id)
+                            )).scalar_one_or_none()
+                            if visitor:
+                                phone = visitor.phone
+
+                    if balance <= 0:
+                        # 余额耗尽
+                        if not sa.balance_depleted_sent and phone:
+                            ok, reason = await send_sa_balance_depleted(phone, company)
+                            if ok:
+                                sa.balance_depleted_sent = True
+                                await session.commit()
+                                logger.info("超管 %d(%s) 余额耗尽,短信已发送", sa.id, company)
+                            else:
+                                logger.warning("超管 %d 耗尽短信发送失败: %s", sa.id, reason)
+                    elif balance <= threshold:
+                        # 余额不足但未耗尽
+                        if not sa.balance_warning_sent and phone:
+                            ok, reason = await send_sa_balance_warning(phone, company, f"{balance:.2f}")
+                            if ok:
+                                sa.balance_warning_sent = True
+                                await session.commit()
+                                logger.info("超管 %d(%s) 余额预警(%.2f),短信已发送", sa.id, company, balance)
+                            else:
+                                logger.warning("超管 %d 预警短信发送失败: %s", sa.id, reason)
+                    else:
+                        # 余额恢复,重置标记
+                        if sa.balance_warning_sent or sa.balance_depleted_sent:
+                            sa.balance_warning_sent = False
+                            sa.balance_depleted_sent = False
+                            await session.commit()
+                            logger.info("超管 %d(%s) 余额恢复至 %.2f,重置预警标记", sa.id, company, balance)
+        except Exception:
+            logger.exception("定时检查超管余额异常")
+
+        await asyncio.sleep(600)  # 10 分钟
+
+
+async def _process_compensation():
+    """定期处理待补偿扣减记录(每 30 秒)"""
+    while True:
+        try:
+            async with async_session() as session:
+                count = await process_pending(session)
+                if count > 0:
+                    logger.info("补偿任务处理了 %d 条记录", count)
+        except Exception:
+            logger.exception("补偿任务异常")
+
+        await asyncio.sleep(30)
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # 启动时立即检查一次
@@ -158,14 +238,25 @@ async def lifespan(app: FastAPI):
     except Exception:
         logger.exception("启动时检查 License 异常")
 
-    # 启动后台定时任务
+    # 启动后台定时任务(错开启动,避免同时抢连接)
     license_task = asyncio.create_task(_check_licenses())
+    await asyncio.sleep(2)
+
     fetch_task = asyncio.create_task(_daily_fetch())
-    logger.info("后台任务已启动:License 检查 + 定时爬取")
+    await asyncio.sleep(2)
+
+    balance_task = asyncio.create_task(_check_sa_balances())
+    await asyncio.sleep(2)
+
+    compensation_task = asyncio.create_task(_process_compensation())
+
+    logger.info("后台任务已启动:License 检查 + 定时爬取 + 超管余额预警 + 扣减补偿")
     yield
     license_task.cancel()
     fetch_task.cancel()
-    for task in (license_task, fetch_task):
+    balance_task.cancel()
+    compensation_task.cancel()
+    for task in (license_task, fetch_task, balance_task, compensation_task):
         try:
             await task
         except asyncio.CancelledError:
@@ -195,6 +286,8 @@ app.include_router(monitoring.router)
 app.include_router(license_router.router)
 app.include_router(public_license_router)
 app.include_router(auth_router.router)
+app.include_router(sa_balance_router.router)
+app.include_router(sa_balance_router.public_router)
 
 
 @app.get("/health")

+ 19 - 0
backend/app/models/compensation.py

@@ -0,0 +1,19 @@
+"""待补偿扣减记录模型"""
+from sqlalchemy import Column, Integer, String, Numeric, Text, DateTime, func
+from app.models import Base
+
+
+class PendingDeduction(Base):
+    """待补偿扣减记录:扣减失败时写入,后台任务重试"""
+    __tablename__ = "pending_deductions"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    target_type = Column(String(20), nullable=False)       # 'tenant' / 'sa'
+    target_id = Column(Integer, nullable=False)            # tenant_id 或 panel_sa_id
+    amount = Column(Numeric(20, 4), nullable=False)
+    biz_order_no = Column(String(100), nullable=False)
+    retry_count = Column(Integer, default=0, server_default="0")
+    last_error = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

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

@@ -34,6 +34,25 @@ class SuperAdmin(Base):
     username = Column(String, nullable=False)
     nickname = Column(String)
     remark = Column(String)  # 域名备注,作为超管显示名称
+    balance = Column(Numeric(20, 4), server_default="0")
+    phone = Column(String(20))
+    balance_warning_sent = Column(Boolean, server_default="false", default=False)
+    balance_depleted_sent = Column(Boolean, server_default="false", default=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+
+class SuperAdminBalanceLog(Base):
+    """超级管理员余额变动日志表"""
+    __tablename__ = "super_admin_balance_log"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    super_admin_id = Column(Integer, nullable=False, index=True)
+    change_amount = Column(Numeric(20, 4), nullable=False)
+    balance_after = Column(Numeric(20, 4), nullable=False)
+    biz_type = Column(String(50), nullable=False)  # recharge / consume / adjust
+    biz_order_no = Column(String(100))
+    remark = Column(Text)
     created_at = Column(DateTime(timezone=True), server_default=func.now())
 
 

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

@@ -0,0 +1,153 @@
+"""超级管理员余额管理路由"""
+import logging
+from decimal import Decimal
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.services import sa_balance as svc
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/sa-balance", tags=["超管余额"])
+public_router = APIRouter(prefix="/api/public/sa-balance", tags=["超管余额公开接口"])
+
+
+# ---------- 请求/响应 Schema ----------
+
+class RechargeRequest(BaseModel):
+    sa_id: int
+    amount: float
+    remark: str = ""
+
+
+class DeductRequest(BaseModel):
+    sa_id: int
+    amount: float
+    biz_order_no: str
+
+
+# ---------- 内部控制面板接口 ----------
+
+@router.post("/recharge", summary="给超管充值")
+async def handle_recharge(
+    payload: RechargeRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        result = await svc.recharge(db, payload.sa_id, Decimal(str(payload.amount)), payload.remark)
+        return {"code": 200, "data": result}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/list", summary="所有超管余额列表")
+async def handle_list_sa_balance(
+    db: AsyncSession = Depends(get_db),
+):
+    items = await svc.get_all_sa_balance(db)
+    return {"code": 200, "data": items}
+
+
+@router.get("/{sa_id}", summary="查询超管余额详情")
+async def handle_get_sa_balance(
+    sa_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        info = await svc.get_sa_balance_info(db, sa_id)
+        return {"code": 200, "data": info}
+    except ValueError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+
+
+@router.get("/{sa_id}/logs", summary="查询超管余额变动日志")
+async def handle_get_sa_balance_logs(
+    sa_id: int,
+    page: int = Query(1, ge=1),
+    size: int = Query(20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await svc.get_balance_logs(db, sa_id, page, size)
+    return {"code": 200, "data": result}
+
+
+# ---------- AIGC-Space 调用的公开接口 ----------
+
+@public_router.post("/deduct", summary="扣减超管余额(AIGC-Space 调用)")
+async def handle_deduct(
+    payload: DeductRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        ok, reason = await svc.deduct(db, payload.sa_id, Decimal(str(payload.amount)), payload.biz_order_no)
+        return {"code": 200, "success": ok, "reason": reason}
+    except Exception as e:
+        # 扣减异常(DB 错误等),写入补偿记录,后台任务会重试
+        logger.exception("超管扣减异常,写入补偿记录: sa_id=%d, amount=%s, order=%s",
+                        payload.sa_id, payload.amount, payload.biz_order_no)
+        try:
+            from app.services.compensation_service import record_pending
+            await record_pending(
+                db,
+                target_type="sa",
+                target_id=payload.sa_id,
+                amount=Decimal(str(payload.amount)),
+                biz_order_no=payload.biz_order_no,
+                error_msg=str(e),
+            )
+            # 返回 success=True,告诉调用方"已接收,补偿任务会处理"
+            return {"code": 200, "success": True, "reason": "已记录待补偿", "compensated": True}
+        except Exception as record_err:
+            logger.error("补偿记录写入失败: %s", record_err)
+            return {"code": 200, "success": False, "reason": f"扣减异常且补偿记录失败: {e}"}
+
+
+class RecordPendingRequest(BaseModel):
+    sa_id: int
+    amount: float
+    biz_order_no: str
+    error_msg: str = ""
+
+
+@public_router.post("/record-pending", summary="记录待补偿扣减(AIGC-Space 网络失败时调用)")
+async def handle_record_pending(
+    payload: RecordPendingRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """当 AIGC-Space 调用 /deduct 失败(网络超时等)时,调用此端点记录待补偿"""
+    try:
+        from app.services.compensation_service import record_pending
+        await record_pending(
+            db,
+            target_type="sa",
+            target_id=payload.sa_id,
+            amount=Decimal(str(payload.amount)),
+            biz_order_no=payload.biz_order_no,
+            error_msg=payload.error_msg,
+        )
+        return {"code": 200, "success": True, "reason": "已记录待补偿"}
+    except Exception as e:
+        logger.error("记录待补偿失败: %s", e)
+        return {"code": 200, "success": False, "reason": str(e)}
+
+
+@public_router.get("/{sa_id}/status", summary="查询超管余额状态(AIGC-Space 调用)")
+async def handle_sa_balance_status(
+    sa_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        balance = await svc.get_balance(db, sa_id)
+        return {
+            "code": 200,
+            "data": {
+                "sa_id": sa_id,
+                "balance": str(balance),
+                "is_sufficient": balance > 0,
+            },
+        }
+    except ValueError as e:
+        raise HTTPException(status_code=404, detail=str(e))

+ 111 - 0
backend/app/services/compensation_service.py

@@ -0,0 +1,111 @@
+"""扣减补偿服务
+
+当租户或超管扣减因异常(网络、DB超时等)失败时,写入 pending_deductions 表,
+后台任务定期扫描重试,利用 biz_order_no 幂等机制保证不重复扣减。
+"""
+import logging
+from datetime import datetime, timezone
+from decimal import Decimal
+
+from sqlalchemy import select, update, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.compensation import PendingDeduction
+from app.models.monitoring import SuperAdmin
+
+logger = logging.getLogger(__name__)
+
+
+async def record_pending(
+    db: AsyncSession,
+    target_type: str,
+    target_id: int,
+    amount: Decimal,
+    biz_order_no: str,
+    error_msg: str = "",
+):
+    """写入一条待补偿记录"""
+    record = PendingDeduction(
+        target_type=target_type,
+        target_id=target_id,
+        amount=amount,
+        biz_order_no=biz_order_no,
+        last_error=error_msg[:500] if error_msg else "",
+    )
+    db.add(record)
+    await db.commit()
+    logger.warning(
+        "写入待补偿记录: type=%s id=%d amount=%s order=%s error=%s",
+        target_type, target_id, amount, biz_order_no, error_msg[:100],
+    )
+
+
+async def process_pending(db: AsyncSession) -> int:
+    """处理所有待补偿记录,返回成功处理的数量"""
+    # 取最早创建的 50 条(FIFO)
+    result = await db.execute(
+        select(PendingDeduction)
+        .order_by(PendingDeduction.created_at)
+        .limit(50)
+    )
+    records = result.scalars().all()
+    if not records:
+        return 0
+
+    success_count = 0
+    for rec in records:
+        ok = False
+        error_msg = ""
+
+        try:
+            if rec.target_type == "tenant":
+                ok, error_msg = await _retry_tenant_deduct(db, rec)
+            elif rec.target_type == "sa":
+                ok, error_msg = await _retry_sa_deduct(db, rec)
+            else:
+                logger.error("未知的 target_type: %s", rec.target_type)
+                error_msg = f"unknown target_type: {rec.target_type}"
+        except Exception as e:
+            logger.exception("补偿重试异常: id=%d", rec.id)
+            error_msg = str(e)[:500]
+
+        if ok:
+            await db.delete(rec)
+            await db.commit()
+            success_count += 1
+            logger.info("补偿成功: type=%s id=%d order=%s", rec.target_type, rec.target_id, rec.biz_order_no)
+        else:
+            # 更新重试次数和错误信息
+            rec.retry_count += 1
+            rec.last_error = error_msg[:500]
+            rec.updated_at = datetime.now(timezone.utc)
+            await db.commit()
+
+            if rec.retry_count >= 10:
+                logger.error(
+                    "补偿记录重试超过10次: type=%s id=%d order=%s error=%s",
+                    rec.target_type, rec.target_id, rec.biz_order_no, error_msg,
+                )
+
+    return success_count
+
+
+async def _retry_tenant_deduct(db: AsyncSession, rec: PendingDeduction) -> tuple[bool, str]:
+    """重试租户扣减"""
+    # 本地租户扣减在 AIGC-Space 侧,这里只处理控制面板侧的超管扣减
+    # 如果未来需要在控制面板侧也管理租户扣减补偿,在这里实现
+    return False, "租户补偿暂不支持(应在 AIGC-Space 侧处理)"
+
+
+async def _retry_sa_deduct(db: AsyncSession, rec: PendingDeduction) -> tuple[bool, str]:
+    """重试超管扣减"""
+    from app.services.sa_balance import deduct
+
+    ok, reason = await deduct(db, rec.target_id, rec.amount, rec.biz_order_no)
+    if ok:
+        return True, ""
+    # 余额不足也算"处理完成"(虽然扣不了,但不需要再重试了)
+    if "余额不足" in reason:
+        logger.warning("补偿扣减: 超管 %d 余额不足,跳过不再重试, order=%s", rec.target_id, rec.biz_order_no)
+        return True, ""
+    return False, reason

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

@@ -520,10 +520,24 @@ async def check_license_by_referer(
     )
     sa = sa_result.scalar_one_or_none()
 
+    # 余额检查:License 有效时额外检查超管余额
+    license_valid = lic.status == "active"
+    sa_balance = float(sa.balance or 0) if sa else 0
+    sa_balance_ok = sa_balance > 0
+
+    # 如果 License 有效但超管余额不足,整体 valid 为 false
+    valid = license_valid and sa_balance_ok
+    status = lic.status
+    if license_valid and not sa_balance_ok:
+        status = "insufficient_balance"
+
     return {
-        "valid": lic.status == "active",
-        "status": lic.status,
+        "valid": valid,
+        "status": status,
         "super_admin_name": sa.remark or sa.username if sa else str(lic.super_admin_id),
+        "super_admin_id": lic.super_admin_id,
+        "sa_balance": sa_balance,
+        "sa_balance_ok": sa_balance_ok,
         "license_key": lic.license_key,
         "expires_at": _to_str(lic.expires_at),
         "days_left": days_left,

+ 203 - 0
backend/app/services/sa_balance.py

@@ -0,0 +1,203 @@
+"""超级管理员余额管理服务"""
+import logging
+from decimal import Decimal
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.exc import IntegrityError
+
+from app.models.monitoring import SuperAdmin, SuperAdminBalanceLog
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+async def get_balance(db: AsyncSession, sa_id: int) -> Decimal:
+    """获取超管余额"""
+    result = await db.execute(
+        select(SuperAdmin.balance).where(SuperAdmin.id == sa_id)
+    )
+    row = result.scalar_one_or_none()
+    if row is None:
+        raise ValueError(f"超管 {sa_id} 不存在")
+    return Decimal(str(row or 0))
+
+
+async def check_balance(db: AsyncSession, sa_id: int) -> bool:
+    """检查超管余额是否 > 0"""
+    balance = await get_balance(db, sa_id)
+    return balance > 0
+
+
+async def recharge(
+    db: AsyncSession,
+    sa_id: int,
+    amount: Decimal,
+    remark: str = "",
+) -> dict:
+    """充值超管余额。返回 {balance_after, message}"""
+    if amount <= 0:
+        raise ValueError("充值金额必须大于 0")
+
+    result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == sa_id).with_for_update()
+    )
+    sa = result.scalar_one_or_none()
+    if not sa:
+        raise ValueError(f"超管 {sa_id} 不存在")
+
+    current = Decimal(str(sa.balance or 0))
+    balance_after = current + amount
+    sa.balance = balance_after
+    # 充值后重置预警标记,允许下次余额下降时再次触发
+    sa.balance_warning_sent = False
+    sa.balance_depleted_sent = False
+
+    log = SuperAdminBalanceLog(
+        super_admin_id=sa_id,
+        change_amount=amount,
+        balance_after=balance_after,
+        biz_type="recharge",
+        remark=remark,
+    )
+    db.add(log)
+    await db.commit()
+
+    logger.info("超管 %d 充值 %s 元,余额 %s", sa_id, amount, balance_after)
+    return {"balance_after": str(balance_after), "message": "充值成功"}
+
+
+async def deduct(
+    db: AsyncSession,
+    sa_id: int,
+    amount: Decimal,
+    biz_order_no: str,
+) -> tuple[bool, str]:
+    """扣减超管余额。返回 (success, reason)。幂等:同一 biz_order_no 不重复扣减。"""
+    if amount <= 0:
+        return False, "扣减金额必须大于 0"
+
+    # 先检查幂等:是否已存在相同 biz_order_no 的记录
+    if biz_order_no:
+        exist = await db.execute(
+            select(SuperAdminBalanceLog.id).where(
+                SuperAdminBalanceLog.super_admin_id == sa_id,
+                SuperAdminBalanceLog.biz_type == "consume",
+                SuperAdminBalanceLog.biz_order_no == biz_order_no,
+            )
+        )
+        if exist.scalar_one_or_none():
+            return True, "已扣减(幂等)"
+
+    result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == sa_id).with_for_update()
+    )
+    sa = result.scalar_one_or_none()
+    if not sa:
+        return False, f"超管 {sa_id} 不存在"
+
+    current = Decimal(str(sa.balance or 0))
+    if current < amount:
+        logger.warning("超管 %d 余额不足: 当前 %s, 需扣 %s", sa_id, current, amount)
+        return False, "余额不足"
+
+    balance_after = current - amount
+    sa.balance = balance_after
+
+    log = SuperAdminBalanceLog(
+        super_admin_id=sa_id,
+        change_amount=-amount,
+        balance_after=balance_after,
+        biz_type="consume",
+        biz_order_no=biz_order_no,
+    )
+    db.add(log)
+
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        return True, "已扣减(幂等)"
+
+    logger.info("超管 %d 扣减 %s 元(订单 %s),余额 %s", sa_id, amount, biz_order_no, balance_after)
+    return True, "扣减成功"
+
+
+async def get_balance_logs(
+    db: AsyncSession,
+    sa_id: int,
+    page: int = 1,
+    size: int = 20,
+) -> dict:
+    """分页查询余额变动日志"""
+    count_stmt = select(func.count()).select_from(SuperAdminBalanceLog).where(
+        SuperAdminBalanceLog.super_admin_id == sa_id
+    )
+    total_result = await db.execute(count_stmt)
+    total = total_result.scalar() or 0
+
+    offset = (page - 1) * size
+    stmt = (
+        select(SuperAdminBalanceLog)
+        .where(SuperAdminBalanceLog.super_admin_id == sa_id)
+        .order_by(SuperAdminBalanceLog.created_at.desc())
+        .offset(offset)
+        .limit(size)
+    )
+    result = await db.execute(stmt)
+    logs = result.scalars().all()
+
+    items = []
+    for log in logs:
+        items.append({
+            "id": log.id,
+            "change_amount": str(log.change_amount),
+            "balance_after": str(log.balance_after),
+            "biz_type": log.biz_type,
+            "biz_order_no": log.biz_order_no,
+            "remark": log.remark,
+            "created_at": log.created_at.isoformat() if log.created_at else None,
+        })
+
+    return {"total": total, "items": items}
+
+
+async def get_all_sa_balance(db: AsyncSession) -> list[dict]:
+    """获取所有超管的余额信息(用于预警检查)"""
+    result = await db.execute(
+        select(SuperAdmin).order_by(SuperAdmin.id)
+    )
+    sas = result.scalars().all()
+    return [
+        {
+            "id": sa.id,
+            "username": sa.username,
+            "remark": sa.remark,
+            "phone": sa.phone,
+            "balance": Decimal(str(sa.balance or 0)),
+            "balance_warning_sent": sa.balance_warning_sent,
+            "balance_depleted_sent": sa.balance_depleted_sent,
+        }
+        for sa in sas
+    ]
+
+
+async def get_sa_balance_info(db: AsyncSession, sa_id: int) -> dict:
+    """获取超管余额详情(余额 + 基本信息)"""
+    result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == sa_id)
+    )
+    sa = result.scalar_one_or_none()
+    if not sa:
+        raise ValueError(f"超管 {sa_id} 不存在")
+
+    return {
+        "id": sa.id,
+        "username": sa.username,
+        "nickname": sa.nickname,
+        "remark": sa.remark,
+        "phone": sa.phone,
+        "balance": str(sa.balance or 0),
+        "balance_warning_sent": sa.balance_warning_sent,
+        "balance_depleted_sent": sa.balance_depleted_sent,
+    }

+ 16 - 0
backend/app/services/sms.py

@@ -115,6 +115,22 @@ async def send_license_warning(phone: str, company: str, days: int, retry: bool
     return await _send_sms(phone, settings.sms_template_code_warning, {"company": company, "days": str(days)}, retry=retry)
 
 
+async def send_sa_balance_warning(phone: str, company: str, amount: str, retry: bool = False) -> tuple[bool, str]:
+    """发送超管余额预警短信。返回 (success, reason)"""
+    if not settings.sms_template_code_sa_balance_warning:
+        logger.warning("超管余额预警短信模板未配置,跳过发送")
+        return False, "模板未配置"
+    return await _send_sms(phone, settings.sms_template_code_sa_balance_warning, {"companyName": company, "remainAmount": amount}, retry=retry)
+
+
+async def send_sa_balance_depleted(phone: str, company: str, retry: bool = False) -> tuple[bool, str]:
+    """发送超管余额耗尽短信。返回 (success, reason)"""
+    if not settings.sms_template_code_sa_balance_depleted:
+        logger.warning("超管余额耗尽短信模板未配置,跳过发送")
+        return False, "模板未配置"
+    return await _send_sms(phone, settings.sms_template_code_sa_balance_depleted, {"companyName": company}, retry=retry)
+
+
 def generate_verify_code() -> str:
     """生成 6 位数字验证码"""
     return "".join(random.choices(string.digits, k=6))

+ 25 - 0
backend/migrations/008_sa_balance.sql

@@ -0,0 +1,25 @@
+-- 超级管理员余额管理
+-- 1. super_admin 表新增余额、手机号、预警标记字段
+ALTER TABLE domain_monitor.super_admin ADD COLUMN IF NOT EXISTS balance NUMERIC(20,4) DEFAULT 0;
+ALTER TABLE domain_monitor.super_admin ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
+ALTER TABLE domain_monitor.super_admin ADD COLUMN IF NOT EXISTS balance_warning_sent BOOLEAN DEFAULT FALSE;
+ALTER TABLE domain_monitor.super_admin ADD COLUMN IF NOT EXISTS balance_depleted_sent BOOLEAN DEFAULT FALSE;
+
+-- 2. 超管余额变动日志表
+CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_balance_log (
+    id SERIAL PRIMARY KEY,
+    super_admin_id INTEGER NOT NULL,
+    change_amount NUMERIC(20,4) NOT NULL,
+    balance_after NUMERIC(20,4) NOT NULL,
+    biz_type VARCHAR(50) NOT NULL,
+    biz_order_no VARCHAR(100),
+    remark TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_sa_balance_log_sa_id
+    ON domain_monitor.super_admin_balance_log(super_admin_id);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_sa_balance_log_unique
+    ON domain_monitor.super_admin_balance_log(super_admin_id, biz_type, biz_order_no)
+    WHERE biz_order_no IS NOT NULL;

+ 16 - 0
backend/migrations/009_pending_deductions.sql

@@ -0,0 +1,16 @@
+-- 待补偿扣减记录表
+-- 当租户或超管扣减因异常失败时,写入此表,后台任务定期重试
+CREATE TABLE IF NOT EXISTS domain_monitor.pending_deductions (
+    id SERIAL PRIMARY KEY,
+    target_type VARCHAR(20) NOT NULL,          -- 'tenant' / 'sa'
+    target_id INTEGER NOT NULL,                -- tenant_id 或 panel_sa_id
+    amount NUMERIC(20, 4) NOT NULL,
+    biz_order_no VARCHAR(100) NOT NULL,
+    retry_count INTEGER DEFAULT 0,
+    last_error TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_pending_deductions_created
+    ON domain_monitor.pending_deductions(created_at);

+ 17 - 0
frontend/src/api/monitoring.ts

@@ -36,3 +36,20 @@ export const monitoringApi = {
     return api.get(`/api/public/monitoring/daily-stats?${qs.toString()}`);
   },
 };
+
+/** 超管余额管理接口 */
+export const saBalanceApi = {
+  /** 获取所有超管余额列表 */
+  list: () => api.get("/api/sa-balance/list"),
+
+  /** 获取超管余额详情 */
+  get: (saId: number) => api.get(`/api/sa-balance/${saId}`),
+
+  /** 充值 */
+  recharge: (saId: number, amount: number, remark: string = "") =>
+    api.post("/api/sa-balance/recharge", { sa_id: saId, amount, remark }),
+
+  /** 获取余额变动日志 */
+  getLogs: (saId: number, page: number = 1, size: number = 20) =>
+    api.get(`/api/sa-balance/${saId}/logs?page=${page}&size=${size}`),
+};

+ 195 - 14
frontend/src/pages/SuperAdminsPage.tsx

@@ -1,11 +1,14 @@
 import { useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { monitoringApi } from "../api/monitoring";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { monitoringApi, saBalanceApi } from "../api/monitoring";
 import { T } from "../theme";
 import { Card, Badge, LoadingDots } from "../components/Shared";
 import { PageLayout, FilterBar, TooltipTh } from "../components/PageLayout";
 
+const WARNING_THRESHOLD = 100;
+
 export function SuperAdminsPage() {
+  const qc = useQueryClient();
   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 } = {};
@@ -13,9 +16,32 @@ export function SuperAdminsPage() {
   if (query.endDate) statsParams.end_date = query.endDate;
   if (query.saName) statsParams.super_admin_name = query.saName;
 
+  // 充值弹窗状态
+  const [rechargeTarget, setRechargeTarget] = useState<{ id: number; name: string; balance: string } | null>(null);
+  const [rechargeForm, setRechargeForm] = useState({ amount: "", remark: "" });
+  // 余额记录弹窗状态
+  const [logsTarget, setLogsTarget] = useState<{ id: number; name: string } | null>(null);
+  const [logsPage, setLogsPage] = useState(1);
+
   const { data: dashboard, isLoading: dashLoading } = useQuery({ queryKey: ["dashboard", statsParams], queryFn: () => monitoringApi.getDashboard(Object.keys(statsParams).length ? statsParams : undefined).then((r) => r.data) });
+  const { data: balanceList } = useQuery({ queryKey: ["sa-balance-list"], queryFn: () => saBalanceApi.list().then((r) => r.data.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 });
+  const { data: logsData } = useQuery({
+    queryKey: ["sa-balance-logs", logsTarget?.id, logsPage],
+    queryFn: () => saBalanceApi.getLogs(logsTarget!.id, logsPage).then((r) => r.data.data),
+    enabled: !!logsTarget,
+  });
+
+  const rechargeMutation = useMutation({
+    mutationFn: () => saBalanceApi.recharge(rechargeTarget!.id, parseFloat(rechargeForm.amount), rechargeForm.remark),
+    onSuccess: () => {
+      qc.invalidateQueries({ queryKey: ["sa-balance-list"] });
+      qc.invalidateQueries({ queryKey: ["dashboard"] });
+      setRechargeTarget(null);
+      setRechargeForm({ amount: "", remark: "" });
+    },
+  });
 
   if (dashLoading || (hasDate && dailyLoading)) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><LoadingDots /></PageLayout>;
 
@@ -23,8 +49,12 @@ export function SuperAdminsPage() {
   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";
 
+  // 余额查找辅助函数
+  const findBalance = (saId: number) => balanceList?.find((b: any) => b.id === saId);
+  const balanceColor = (bal: number) => bal <= 0 ? T.danger : bal <= WARNING_THRESHOLD ? "#f59e0b" : T.success;
+
   return (
-    <PageLayout title="超级管理员" subtitle={hasDate ? `${query.startDate} ~ ${query.endDate} 每日消费统计` : "查看各超级管理员消费数据"}>
+    <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 }}>
@@ -40,6 +70,38 @@ export function SuperAdminsPage() {
           ))}
         </div>
       </Card>
+
+      {/* 超管余额总览卡片 */}
+      {balanceList && balanceList.length > 0 && (
+        <Card title="余额总览">
+          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
+            {balanceList.map((sa: any) => {
+              const bal = parseFloat(sa.balance || 0);
+              return (
+                <div key={sa.id} style={{ background: T.bg, borderRadius: 10, padding: "14px 18px", border: `1px solid ${bal <= 0 ? T.danger + "40" : T.border}` }}>
+                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
+                    <span style={{ fontSize: 14, fontWeight: 600, color: T.heading }}>{sa.remark || sa.username}</span>
+                    <span style={{ fontSize: 18, fontWeight: 700, color: balanceColor(bal) }}>¥{bal.toFixed(2)}</span>
+                  </div>
+                  <div style={{ display: "flex", gap: 6 }}>
+                    <button
+                      onClick={() => { setRechargeTarget({ id: sa.id, name: sa.remark || sa.username, balance: sa.balance }); setRechargeForm({ amount: "", remark: "" }); }}
+                      style={{ flex: 1, padding: "5px 0", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}
+                    >充值</button>
+                    <button
+                      onClick={() => { setLogsTarget({ id: sa.id, name: sa.remark || sa.username }); setLogsPage(1); }}
+                      style={{ flex: 1, padding: "5px 0", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "transparent", color: T.textSec }}
+                    >余额记录</button>
+                  </div>
+                  {bal <= 0 && <div style={{ marginTop: 6, fontSize: 11, color: T.danger, fontWeight: 500 }}>余额已耗尽,旗下所有服务已暂停</div>}
+                  {bal > 0 && bal <= WARNING_THRESHOLD && <div style={{ marginTop: 6, fontSize: 11, color: "#f59e0b", fontWeight: 500 }}>余额不足,请及时充值</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>
@@ -53,17 +115,136 @@ export function SuperAdminsPage() {
           ))
       )}
       {!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>
-      ))}
+      {!hasDate && dashboard && dashboard.super_admins.map((sa: any) => {
+        const balInfo = findBalance(sa.super_admin_id);
+        const bal = balInfo ? parseFloat(balInfo.balance || 0) : null;
+        return (
+          <Card
+            key={sa.super_admin_id}
+            title={`${sa.nickname || sa.username}`}
+            extra={
+              <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
+                {bal !== null && (
+                  <span style={{ fontSize: 14, fontWeight: 700, color: balanceColor(bal) }}>
+                    ¥{bal.toFixed(2)}
+                  </span>
+                )}
+                <Badge active text={`${sa.tenant_count} 个租户`} />
+              </div>
+            }
+          >
+            <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec, display: "flex", gap: 16, alignItems: "center" }}>
+              <span>消费 ¥{sa.total_consumption}</span>
+              <span>收取 ¥{sa.total_tenant_charged}</span>
+              {bal !== null && (
+                <button
+                  onClick={() => { setRechargeTarget({ id: sa.super_admin_id, name: sa.nickname || sa.username, balance: balInfo.balance }); setRechargeForm({ amount: "", remark: "" }); }}
+                  style={{ padding: "3px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}
+                >充值</button>
+              )}
+            </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>
+        );
+      })}
+
+      {/* 充值弹窗 */}
+      {rechargeTarget && (
+        <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setRechargeTarget(null)}>
+          <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 400, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
+            <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>充值 — {rechargeTarget.name}</h3>
+            <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>
+              当前余额:<span style={{ fontWeight: 700, color: balanceColor(parseFloat(rechargeTarget.balance)) }}>¥{parseFloat(rechargeTarget.balance).toFixed(2)}</span>
+            </div>
+            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+              <div>
+                <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>充值金额(元)</label>
+                <input
+                  type="number"
+                  min="0.01"
+                  step="0.01"
+                  value={rechargeForm.amount}
+                  onChange={(e) => setRechargeForm({ ...rechargeForm, amount: e.target.value })}
+                  placeholder="请输入充值金额"
+                  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
+                  value={rechargeForm.remark}
+                  onChange={(e) => setRechargeForm({ ...rechargeForm, remark: e.target.value })}
+                  placeholder="充值备注"
+                  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={() => setRechargeTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
+              <button
+                onClick={() => rechargeMutation.mutate()}
+                disabled={!rechargeForm.amount || parseFloat(rechargeForm.amount) <= 0 || rechargeMutation.isPending}
+                style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: rechargeMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}
+              >{rechargeMutation.isPending ? "充值中..." : "确认充值"}</button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* 余额记录弹窗 */}
+      {logsTarget && (
+        <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setLogsTarget(null)}>
+          <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 640, maxHeight: "80vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
+            <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>余额记录 — {logsTarget.name}</h3>
+            {logsData?.items?.length === 0 ? (
+              <div style={{ textAlign: "center", padding: 32, color: T.textSec, fontSize: 13 }}>暂无记录</div>
+            ) : (
+              <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+                <thead>
+                  <tr>
+                    <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>时间</th>
+                    <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>类型</th>
+                    <th style={{ padding: "8px 10px", textAlign: "right", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>变动</th>
+                    <th style={{ padding: "8px 10px", textAlign: "right", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额</th>
+                    <th style={{ padding: "8px 10px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>备注</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {(logsData?.items || []).map((log: any) => {
+                    const change = parseFloat(log.change_amount);
+                    const typeLabel = log.biz_type === "recharge" ? "充值" : log.biz_type === "consume" ? "消费" : "调整";
+                    const typeColor = log.biz_type === "recharge" ? T.success : log.biz_type === "consume" ? T.danger : T.textSec;
+                    return (
+                      <tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}>
+                        <td style={{ padding: "8px 10px", fontSize: 12, color: T.textSec }}>{log.created_at?.replace("T", " ").split("+")[0]}</td>
+                        <td style={{ padding: "8px 10px" }}><span style={{ color: typeColor, fontWeight: 500 }}>{typeLabel}</span></td>
+                        <td style={{ padding: "8px 10px", textAlign: "right", fontWeight: 600, color: change >= 0 ? T.success : T.danger }}>{change >= 0 ? "+" : ""}{change.toFixed(2)}</td>
+                        <td style={{ padding: "8px 10px", textAlign: "right" }}>¥{parseFloat(log.balance_after).toFixed(2)}</td>
+                        <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.remark || ""}>{log.remark || log.biz_order_no || "-"}</td>
+                      </tr>
+                    );
+                  })}
+                </tbody>
+              </table>
+            )}
+            {logsData && logsData.total > 20 && (
+              <div style={{ display: "flex", justifyContent: "center", gap: 8, marginTop: 16 }}>
+                <button disabled={logsPage <= 1} onClick={() => setLogsPage(logsPage - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>上一页</button>
+                <span style={{ fontSize: 12, color: T.textSec, lineHeight: "28px" }}>第 {logsPage} 页 / 共 {Math.ceil(logsData.total / 20)} 页</span>
+                <button disabled={logsPage >= Math.ceil(logsData.total / 20)} onClick={() => setLogsPage(logsPage + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>下一页</button>
+              </div>
+            )}
+            <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
+              <button onClick={() => setLogsTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>关闭</button>
+            </div>
+          </div>
+        </div>
+      )}
     </PageLayout>
   );
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است