Feature: Multi-tenant + Billing + Analytics - 3 new modules with 40+ API endpoints
This commit is contained in:
1 parent
e9ec429d21
commit
aec63eaf56
10 files changed
+2056
-49
No files matched your search
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
数据分析管理后端 API
|
||||||
|
- 事件采集、聚合统计、仪表盘配置、趋势分析
|
||||||
|
- 路由前缀: /app/api/admin/analytics
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
|
from ...database import SessionLocal, get_db
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CST_TZ = None
|
||||||
|
|
||||||
|
def _format_cst_time(dt_val):
|
||||||
|
global CST_TZ
|
||||||
|
if CST_TZ is None:
|
||||||
|
from datetime import timezone
|
||||||
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
if dt_val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt_val, str):
|
||||||
|
return dt_val
|
||||||
|
if dt_val.tzinfo is None:
|
||||||
|
dt_val = dt_val.replace(tzinfo=CST_TZ)
|
||||||
|
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计概览
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def analytics_overview(
|
||||||
|
days: int = Query(30, description="统计天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""数据分析总览"""
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# User stats
|
||||||
|
total_users = db.execute(text("SELECT COUNT(*) FROM users")).scalar()
|
||||||
|
active_users = db.execute(text("""
|
||||||
|
SELECT COUNT(DISTINCT user_id) FROM token_usage_logs
|
||||||
|
WHERE created_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
new_users = db.execute(text("""
|
||||||
|
SELECT COUNT(*) FROM users WHERE created_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
|
||||||
|
# API stats
|
||||||
|
api_calls = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(call_count), 0) FROM api_call_stats
|
||||||
|
WHERE updated_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
avg_response = db.execute(text("""
|
||||||
|
SELECT COALESCE(AVG(total_response_time/NULLIF(call_count,0)), 0) FROM api_call_stats
|
||||||
|
WHERE updated_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
error_rate = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(error_count)/NULLIF(SUM(call_count),0)*100, 0) FROM api_call_stats
|
||||||
|
WHERE updated_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
|
||||||
|
# Token stats
|
||||||
|
tokens_used = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(total_tokens), 0) FROM token_usage_logs
|
||||||
|
WHERE created_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
total_cost = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(cost), 0) FROM token_usage_logs
|
||||||
|
WHERE created_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
|
||||||
|
# Chat stats
|
||||||
|
total_chats = db.execute(text("SELECT COUNT(*) FROM conversations")).scalar()
|
||||||
|
total_messages = db.execute(text("SELECT COUNT(*) FROM messages")).scalar()
|
||||||
|
|
||||||
|
# Revenue (from billing_transactions)
|
||||||
|
revenue = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
|
||||||
|
WHERE transaction_type='recharge' AND status='completed' AND created_at >= :since
|
||||||
|
"""), {"since": since}).scalar()
|
||||||
|
|
||||||
|
return {"code": 200, "data": {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"new_users": new_users,
|
||||||
|
"api_calls": api_calls,
|
||||||
|
"avg_response_time": round(float(avg_response), 2),
|
||||||
|
"error_rate": round(float(error_rate), 2),
|
||||||
|
"tokens_used": tokens_used,
|
||||||
|
"total_cost": float(total_cost),
|
||||||
|
"total_chats": total_chats,
|
||||||
|
"total_messages": total_messages,
|
||||||
|
"revenue": float(revenue),
|
||||||
|
"period_days": days,
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 趋势分析
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/trends/api-calls")
|
||||||
|
async def api_call_trends(
|
||||||
|
days: int = Query(7, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""API调用趋势(按天)"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT DATE(created_at) as d,
|
||||||
|
COUNT(*) as calls,
|
||||||
|
COALESCE(SUM(CASE WHEN status='error' THEN 1 ELSE 0 END), 0) as errors
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE event_type='api_call' AND created_at >= :since
|
||||||
|
GROUP BY DATE(created_at) ORDER BY d
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
# Fallback to api_call_stats
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT DATE(updated_at) as d, SUM(call_count) as calls, SUM(error_count) as errors
|
||||||
|
FROM api_call_stats WHERE updated_at >= :since
|
||||||
|
GROUP BY DATE(updated_at) ORDER BY d
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
data = [{"date": str(r[0]), "calls": int(r[1] or 0), "errors": int(r[2] or 0)} for r in rows]
|
||||||
|
return {"code": 200, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends/tokens")
|
||||||
|
async def token_usage_trends(
|
||||||
|
days: int = Query(7, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Token使用趋势(按天+按模型)"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT DATE(created_at) as d, model_name,
|
||||||
|
SUM(total_tokens) as tokens, SUM(cost) as cost
|
||||||
|
FROM token_usage_logs
|
||||||
|
WHERE created_at >= :since
|
||||||
|
GROUP BY DATE(created_at), model_name
|
||||||
|
ORDER BY d, tokens DESC
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
by_date = {}
|
||||||
|
for r in rows:
|
||||||
|
d = str(r[0])
|
||||||
|
if d not in by_date:
|
||||||
|
by_date[d] = {"date": d, "total_tokens": 0, "models": {}}
|
||||||
|
by_date[d]["total_tokens"] += int(r[2] or 0)
|
||||||
|
model = r[1] or "unknown"
|
||||||
|
by_date[d]["models"][model] = int(r[2] or 0)
|
||||||
|
|
||||||
|
# Model distribution
|
||||||
|
model_dist = {}
|
||||||
|
for r in rows:
|
||||||
|
model = r[1] or "unknown"
|
||||||
|
model_dist[model] = model_dist.get(model, 0) + int(r[2] or 0)
|
||||||
|
|
||||||
|
return {"code": 200, "data": {
|
||||||
|
"daily": list(by_date.values()),
|
||||||
|
"model_distribution": model_dist,
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends/users")
|
||||||
|
async def user_growth_trends(
|
||||||
|
days: int = Query(30, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""用户增长趋势"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT DATE(created_at) as d, COUNT(*) as new_users
|
||||||
|
FROM users WHERE created_at >= :since
|
||||||
|
GROUP BY DATE(created_at) ORDER BY d
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
data = [{"date": str(r[0]), "new_users": int(r[1])} for r in rows]
|
||||||
|
|
||||||
|
# Cumulative
|
||||||
|
cumulative = 0
|
||||||
|
total_before = db.execute(text("SELECT COUNT(*) FROM users WHERE created_at < :since"),
|
||||||
|
{"since": since}).scalar()
|
||||||
|
cumulative = total_before
|
||||||
|
for item in data:
|
||||||
|
cumulative += item["new_users"]
|
||||||
|
item["cumulative"] = cumulative
|
||||||
|
|
||||||
|
return {"code": 200, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends/revenue")
|
||||||
|
async def revenue_trends(
|
||||||
|
days: int = Query(30, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""收入趋势"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT DATE(created_at) as d,
|
||||||
|
COALESCE(SUM(CASE WHEN transaction_type='recharge' THEN amount ELSE 0 END), 0) as revenue,
|
||||||
|
COALESCE(SUM(CASE WHEN transaction_type='consumption' THEN amount ELSE 0 END), 0) as consumption
|
||||||
|
FROM billing_transactions
|
||||||
|
WHERE status='completed' AND created_at >= :since
|
||||||
|
GROUP BY DATE(created_at) ORDER BY d
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
data = [{"date": str(r[0]), "revenue": float(r[1] or 0), "consumption": float(r[2] or 0)} for r in rows]
|
||||||
|
return {"code": 200, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 模型使用分析
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/models/usage")
|
||||||
|
async def model_usage_analysis(
|
||||||
|
days: int = Query(30, description="天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""模型使用分析"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT model_name,
|
||||||
|
COUNT(*) as call_count,
|
||||||
|
SUM(total_tokens) as total_tokens,
|
||||||
|
SUM(prompt_tokens) as prompt_tokens,
|
||||||
|
SUM(completion_tokens) as completion_tokens,
|
||||||
|
SUM(cost) as total_cost,
|
||||||
|
AVG(total_tokens) as avg_tokens
|
||||||
|
FROM token_usage_logs
|
||||||
|
WHERE created_at >= :since AND model_name IS NOT NULL
|
||||||
|
GROUP BY model_name
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"""), {"since": since}).fetchall()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"model_name": r[0],
|
||||||
|
"call_count": int(r[1]),
|
||||||
|
"total_tokens": int(r[2] or 0),
|
||||||
|
"prompt_tokens": int(r[3] or 0),
|
||||||
|
"completion_tokens": int(r[4] or 0),
|
||||||
|
"total_cost": float(r[5] or 0),
|
||||||
|
"avg_tokens": round(float(r[6] or 0), 0),
|
||||||
|
} for r in rows]
|
||||||
|
return {"code": 200, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 仪表盘配置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/dashboards")
|
||||||
|
async def list_dashboards(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""获取仪表盘配置列表"""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT ad.*, u.username as created_by_name
|
||||||
|
FROM analytics_dashboards ad
|
||||||
|
LEFT JOIN users u ON ad.created_by = u.id
|
||||||
|
ORDER BY ad.is_default DESC, ad.created_at DESC
|
||||||
|
""")).fetchall()
|
||||||
|
cols = ['id','tenant_id','name','description','config','is_default',
|
||||||
|
'created_by','created_at','updated_at','created_by_name']
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = _format_cst_time_dict(r, cols)
|
||||||
|
items.append(d)
|
||||||
|
return {"code": 200, "data": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_cst_time_dict(row, columns):
|
||||||
|
result = {}
|
||||||
|
for col, val in zip(columns, row):
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
result[col] = _format_cst_time(val)
|
||||||
|
elif isinstance(val, str) and val and val.startswith('[') or (isinstance(val, str) and val and val.startswith('{')):
|
||||||
|
try:
|
||||||
|
result[col] = json.loads(val)
|
||||||
|
except:
|
||||||
|
result[col] = val
|
||||||
|
else:
|
||||||
|
result[col] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboards/create")
|
||||||
|
async def create_dashboard(
|
||||||
|
name: str = Query(...),
|
||||||
|
description: str = Query(""),
|
||||||
|
config: str = Query(..., description="仪表盘配置JSON"),
|
||||||
|
is_default: int = Query(0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建仪表盘"""
|
||||||
|
did = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
config_json = json.loads(config)
|
||||||
|
except:
|
||||||
|
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
|
||||||
|
|
||||||
|
if is_default:
|
||||||
|
db.execute(text("UPDATE analytics_dashboards SET is_default=0"))
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
|
||||||
|
VALUES (:id, :name, :desc, :config, :default, :uid)
|
||||||
|
"""), {
|
||||||
|
"id": did, "name": name, "desc": description,
|
||||||
|
"config": json.dumps(config_json), "default": is_default, "uid": user.id
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"id": did, "message": "仪表盘创建成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dashboards/{dashboard_id}")
|
||||||
|
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""删除仪表盘"""
|
||||||
|
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "仪表盘已删除"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 事件记录
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post("/events/record")
|
||||||
|
async def record_event(
|
||||||
|
event_type: str = Query(..., description="事件类型"),
|
||||||
|
event_category: str = Query("general"),
|
||||||
|
event_data: str = Query("", description="事件数据JSON"),
|
||||||
|
duration_ms: int = Query(None),
|
||||||
|
status: str = Query("success"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""记录分析事件"""
|
||||||
|
data = None
|
||||||
|
if event_data:
|
||||||
|
try:
|
||||||
|
data = json.loads(event_data)
|
||||||
|
except:
|
||||||
|
data = {"raw": event_data}
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO analytics_events (tenant_id, user_id, event_type, event_category, event_data, duration_ms, status)
|
||||||
|
VALUES (:tid, :uid, :et, :ec, :data, :dur, :status)
|
||||||
|
"""), {
|
||||||
|
"tid": None, "uid": user.id, "et": event_type, "ec": event_category,
|
||||||
|
"data": json.dumps(data) if data else None,
|
||||||
|
"dur": duration_ms, "status": status
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "事件已记录"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def list_events(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
event_type: str = Query(""),
|
||||||
|
event_category: str = Query(""),
|
||||||
|
status: str = Query(""),
|
||||||
|
days: int = Query(7, description="查询天数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取事件列表"""
|
||||||
|
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
where = "ae.created_at >= :since"
|
||||||
|
params = {"since": since}
|
||||||
|
if event_type:
|
||||||
|
where += " AND ae.event_type=:et"
|
||||||
|
params["et"] = event_type
|
||||||
|
if event_category:
|
||||||
|
where += " AND ae.event_category=:ec"
|
||||||
|
params["ec"] = event_category
|
||||||
|
if status:
|
||||||
|
where += " AND ae.status=:st"
|
||||||
|
params["st"] = status
|
||||||
|
|
||||||
|
total = db.execute(text(f"SELECT COUNT(*) FROM analytics_events ae WHERE {where}"), params).scalar()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT ae.*, u.username
|
||||||
|
FROM analytics_events ae
|
||||||
|
LEFT JOIN users u ON ae.user_id = u.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY ae.created_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||||
|
cols = ['id','tenant_id','user_id','event_type','event_category','event_data',
|
||||||
|
'ip_address','user_agent','duration_ms','status','created_at','username']
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = {}
|
||||||
|
for col, val in zip(cols, r):
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
d[col] = _format_cst_time(val)
|
||||||
|
elif isinstance(val, str) and val and (val.startswith('{') or val.startswith('[')):
|
||||||
|
try:
|
||||||
|
d[col] = json.loads(val)
|
||||||
|
except:
|
||||||
|
d[col] = val
|
||||||
|
else:
|
||||||
|
d[col] = val
|
||||||
|
items.append(d)
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
计费与支付管理后端 API
|
||||||
|
- 计费规则、充值套餐、交易记录、账单管理、支付配置
|
||||||
|
- 路由前缀: /app/api/admin/billing
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
|
from ...database import SessionLocal, get_db
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CST_TZ = None
|
||||||
|
|
||||||
|
def _format_cst_time(dt_val):
|
||||||
|
global CST_TZ
|
||||||
|
if CST_TZ is None:
|
||||||
|
from datetime import timezone
|
||||||
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
if dt_val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt_val, str):
|
||||||
|
return dt_val
|
||||||
|
if dt_val.tzinfo is None:
|
||||||
|
dt_val = dt_val.replace(tzinfo=CST_TZ)
|
||||||
|
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
def _row_to_dict(row, columns):
|
||||||
|
return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 计费规则
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/rules")
|
||||||
|
async def list_billing_rules(
|
||||||
|
status: str = Query("", description="状态筛选"),
|
||||||
|
billing_type: str = Query("", description="计费类型筛选"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取计费规则列表"""
|
||||||
|
where = "1=1"
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
where += " AND status=:status"
|
||||||
|
params["status"] = status
|
||||||
|
if billing_type:
|
||||||
|
where += " AND billing_type=:bt"
|
||||||
|
params["bt"] = billing_type
|
||||||
|
|
||||||
|
total = db.execute(text(f"SELECT COUNT(*) FROM billing_rules WHERE {where}"), params).scalar()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT * FROM billing_rules WHERE {where}
|
||||||
|
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||||
|
cols = ['id','name','description','billing_type','model_id','model_name','rate','currency',
|
||||||
|
'min_charge','free_quota','tiered_pricing','status','created_at','updated_at']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules/create")
|
||||||
|
async def create_billing_rule(
|
||||||
|
name: str = Query(...),
|
||||||
|
description: str = Query(""),
|
||||||
|
billing_type: str = Query("per_token"),
|
||||||
|
model_id: str = Query(""),
|
||||||
|
model_name: str = Query(""),
|
||||||
|
rate: float = Query(0.0),
|
||||||
|
min_charge: float = Query(0.01),
|
||||||
|
free_quota: int = Query(0),
|
||||||
|
tiered_pricing: str = Query("", description="阶梯定价JSON"),
|
||||||
|
status: str = Query("active"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建计费规则"""
|
||||||
|
rid = str(uuid.uuid4())
|
||||||
|
tp = None
|
||||||
|
if tiered_pricing:
|
||||||
|
try:
|
||||||
|
tp = json.loads(tiered_pricing)
|
||||||
|
except:
|
||||||
|
tp = None
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO billing_rules (id, name, description, billing_type, model_id, model_name, rate,
|
||||||
|
min_charge, free_quota, tiered_pricing, status)
|
||||||
|
VALUES (:id, :name, :desc, :bt, :mid, :mname, :rate, :mc, :fq, :tp, :status)
|
||||||
|
"""), {
|
||||||
|
"id": rid, "name": name, "desc": description, "bt": billing_type,
|
||||||
|
"mid": model_id or None, "mname": model_name or None, "rate": rate,
|
||||||
|
"mc": min_charge, "fq": free_quota, "tp": json.dumps(tp) if tp else None,
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"id": rid, "message": "计费规则创建成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rules/{rule_id}")
|
||||||
|
async def update_billing_rule(
|
||||||
|
rule_id: str,
|
||||||
|
name: str = Query(None),
|
||||||
|
description: str = Query(None),
|
||||||
|
rate: float = Query(None),
|
||||||
|
min_charge: float = Query(None),
|
||||||
|
free_quota: int = Query(None),
|
||||||
|
status: str = Query(None),
|
||||||
|
tiered_pricing: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新计费规则"""
|
||||||
|
sets = []
|
||||||
|
params = {"id": rule_id}
|
||||||
|
for field, val in [("name", name), ("description", description), ("rate", rate),
|
||||||
|
("min_charge", min_charge), ("free_quota", free_quota), ("status", status)]:
|
||||||
|
if val is not None:
|
||||||
|
sets.append(f"{field}=:{field}")
|
||||||
|
params[field] = val
|
||||||
|
if tiered_pricing is not None:
|
||||||
|
sets.append("tiered_pricing=:tp")
|
||||||
|
try:
|
||||||
|
params["tp"] = json.dumps(json.loads(tiered_pricing))
|
||||||
|
except:
|
||||||
|
params["tp"] = None
|
||||||
|
|
||||||
|
if not sets:
|
||||||
|
return {"code": 200, "data": {"message": "无更新"}}
|
||||||
|
db.execute(text(f"UPDATE billing_rules SET {','.join(sets)} WHERE id=:id"), params)
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "更新成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/rules/{rule_id}")
|
||||||
|
async def delete_billing_rule(rule_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""删除计费规则"""
|
||||||
|
db.execute(text("UPDATE billing_rules SET status='inactive' WHERE id=:id"), {"id": rule_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "规则已停用"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 充值套餐
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/packages")
|
||||||
|
async def list_packages(
|
||||||
|
status: str = Query(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取充值套餐列表"""
|
||||||
|
where = "1=1"
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
where += " AND status=:status"
|
||||||
|
params["status"] = status
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT * FROM recharge_packages WHERE {where}
|
||||||
|
ORDER BY sort_order ASC, price ASC
|
||||||
|
"""), params).fetchall()
|
||||||
|
cols = ['id','name','description','tokens','bonus_tokens','price','original_price',
|
||||||
|
'currency','valid_days','is_popular','sort_order','status','created_at','updated_at']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/packages/create")
|
||||||
|
async def create_package(
|
||||||
|
name: str = Query(...),
|
||||||
|
description: str = Query(""),
|
||||||
|
tokens: int = Query(...),
|
||||||
|
bonus_tokens: int = Query(0),
|
||||||
|
price: float = Query(...),
|
||||||
|
original_price: float = Query(0),
|
||||||
|
valid_days: int = Query(365),
|
||||||
|
is_popular: int = Query(0),
|
||||||
|
sort_order: int = Query(0),
|
||||||
|
status: str = Query("active"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建充值套餐"""
|
||||||
|
pid = str(uuid.uuid4())
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO recharge_packages (id, name, description, tokens, bonus_tokens, price, original_price,
|
||||||
|
valid_days, is_popular, sort_order, status)
|
||||||
|
VALUES (:id, :name, :desc, :tokens, :bonus, :price, :oprice, :vd, :pop, :sort, :status)
|
||||||
|
"""), {
|
||||||
|
"id": pid, "name": name, "desc": description, "tokens": tokens, "bonus": bonus_tokens,
|
||||||
|
"price": price, "oprice": original_price or None, "vd": valid_days,
|
||||||
|
"pop": is_popular, "sort": sort_order, "status": status
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"id": pid, "message": "套餐创建成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/packages/{package_id}")
|
||||||
|
async def update_package(
|
||||||
|
package_id: str,
|
||||||
|
name: str = Query(None),
|
||||||
|
price: float = Query(None),
|
||||||
|
tokens: int = Query(None),
|
||||||
|
bonus_tokens: int = Query(None),
|
||||||
|
status: str = Query(None),
|
||||||
|
is_popular: int = Query(None),
|
||||||
|
sort_order: int = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新充值套餐"""
|
||||||
|
sets = []
|
||||||
|
params = {"id": package_id}
|
||||||
|
for field, val in [("name", name), ("price", price), ("tokens", tokens),
|
||||||
|
("bonus_tokens", bonus_tokens), ("status", status),
|
||||||
|
("is_popular", is_popular), ("sort_order", sort_order)]:
|
||||||
|
if val is not None:
|
||||||
|
sets.append(f"{field}=:{field}")
|
||||||
|
params[field] = val
|
||||||
|
if not sets:
|
||||||
|
return {"code": 200, "data": {"message": "无更新"}}
|
||||||
|
db.execute(text(f"UPDATE recharge_packages SET {','.join(sets)} WHERE id=:id"), params)
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "更新成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/packages/{package_id}")
|
||||||
|
async def delete_package(package_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""删除充值套餐"""
|
||||||
|
db.execute(text("UPDATE recharge_packages SET status='inactive' WHERE id=:id"), {"id": package_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "套餐已停用"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 交易记录
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/transactions")
|
||||||
|
async def list_transactions(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
user_id: str = Query("", description="用户筛选"),
|
||||||
|
transaction_type: str = Query("", description="类型筛选"),
|
||||||
|
start_date: str = Query("", description="开始日期"),
|
||||||
|
end_date: str = Query("", description="结束日期"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取交易记录"""
|
||||||
|
where = "1=1"
|
||||||
|
params = {}
|
||||||
|
if user_id:
|
||||||
|
where += " AND bt.user_id=:uid"
|
||||||
|
params["uid"] = user_id
|
||||||
|
if transaction_type:
|
||||||
|
where += " AND bt.transaction_type=:tt"
|
||||||
|
params["tt"] = transaction_type
|
||||||
|
if start_date:
|
||||||
|
where += " AND bt.created_at>=:sd"
|
||||||
|
params["sd"] = start_date
|
||||||
|
if end_date:
|
||||||
|
where += " AND bt.created_at<=:ed"
|
||||||
|
params["ed"] = end_date + " 23:59:59"
|
||||||
|
|
||||||
|
total = db.execute(text(f"SELECT COUNT(*) FROM billing_transactions bt WHERE {where}"), params).scalar()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT bt.*, u.username
|
||||||
|
FROM billing_transactions bt
|
||||||
|
LEFT JOIN users u ON bt.user_id = u.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY bt.created_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||||
|
cols = ['id','tenant_id','user_id','order_id','transaction_type','amount','tokens',
|
||||||
|
'balance_after','model_id','model_name','description','metadata','status',
|
||||||
|
'created_at','username']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 账单管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/invoices")
|
||||||
|
async def list_invoices(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: str = Query(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取账单列表"""
|
||||||
|
where = "1=1"
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
where += " AND status=:status"
|
||||||
|
params["status"] = status
|
||||||
|
|
||||||
|
total = db.execute(text(f"SELECT COUNT(*) FROM invoices WHERE {where}"), params).scalar()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT i.*, u.username
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN users u ON i.user_id = u.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||||
|
cols = ['id','invoice_number','tenant_id','user_id','period_start','period_end',
|
||||||
|
'subtotal','discount','tax','total','items','status','issued_at','paid_at',
|
||||||
|
'due_at','payment_method','created_at','updated_at','username']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invoices/generate")
|
||||||
|
async def generate_invoice(
|
||||||
|
user_id: str = Query(..., description="用户ID"),
|
||||||
|
period_start: str = Query(..., description="周期开始"),
|
||||||
|
period_end: str = Query(..., description="周期结束"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""生成账单"""
|
||||||
|
# Calculate usage from token_usage_logs
|
||||||
|
usage = db.execute(text("""
|
||||||
|
SELECT model_name, SUM(total_tokens) as tokens, SUM(cost) as total_cost
|
||||||
|
FROM token_usage_logs
|
||||||
|
WHERE user_id=:uid AND created_at>=:ps AND created_at<=:pe
|
||||||
|
GROUP BY model_name
|
||||||
|
"""), {"uid": user_id, "ps": period_start, "pe": period_end}).fetchall()
|
||||||
|
|
||||||
|
if not usage:
|
||||||
|
return {"code": 200, "data": {"message": "该周期内无使用记录"}}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
subtotal = 0
|
||||||
|
for row in usage:
|
||||||
|
model_name = row[0] or "未知模型"
|
||||||
|
tokens = row[1] or 0
|
||||||
|
cost = float(row[2] or 0)
|
||||||
|
items.append({"name": model_name, "quantity": tokens, "unit_price": cost / tokens if tokens > 0 else 0, "amount": cost})
|
||||||
|
subtotal += cost
|
||||||
|
|
||||||
|
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||||
|
iid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO invoices (id, invoice_number, user_id, period_start, period_end, subtotal, total, items, status)
|
||||||
|
VALUES (:id, :num, :uid, :ps, :pe, :sub, :total, :items, 'issued')
|
||||||
|
"""), {
|
||||||
|
"id": iid, "num": invoice_number, "uid": user_id,
|
||||||
|
"ps": period_start, "pe": period_end, "sub": subtotal,
|
||||||
|
"total": subtotal, "items": json.dumps(items)
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"id": iid, "invoice_number": invoice_number, "total": subtotal, "items": items}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 支付配置 (扩展现有 payment_configs)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/payment-configs")
|
||||||
|
async def list_payment_configs(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""获取支付配置列表"""
|
||||||
|
rows = db.execute(text("SELECT * FROM payment_configs ORDER BY created_at DESC")).fetchall()
|
||||||
|
cols = ['id','type','app_id','merchant_id','api_key','cert_path','gateway_url','status',
|
||||||
|
'created_at','updated_at','api_v3_key','cert_serial_no','alipay_public_key',
|
||||||
|
'app_private_key','notify_url','return_url']
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = _row_to_dict(r, cols)
|
||||||
|
# Mask sensitive fields
|
||||||
|
d['api_key'] = '***' if d.get('api_key') else None
|
||||||
|
d['api_v3_key'] = '***' if d.get('api_v3_key') else None
|
||||||
|
d['app_private_key'] = '***' if d.get('app_private_key') else None
|
||||||
|
items.append(d)
|
||||||
|
return {"code": 200, "data": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/payment-configs/{config_id}")
|
||||||
|
async def update_payment_config(
|
||||||
|
config_id: str,
|
||||||
|
status: str = Query(None),
|
||||||
|
app_id: str = Query(None),
|
||||||
|
merchant_id: str = Query(None),
|
||||||
|
gateway_url: str = Query(None),
|
||||||
|
notify_url: str = Query(None),
|
||||||
|
return_url: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新支付配置"""
|
||||||
|
sets = []
|
||||||
|
params = {"id": config_id}
|
||||||
|
for field, val in [("status", status), ("app_id", app_id), ("merchant_id", merchant_id),
|
||||||
|
("gateway_url", gateway_url), ("notify_url", notify_url), ("return_url", return_url)]:
|
||||||
|
if val is not None:
|
||||||
|
sets.append(f"{field}=:{field}")
|
||||||
|
params[field] = val
|
||||||
|
if not sets:
|
||||||
|
return {"code": 200, "data": {"message": "无更新"}}
|
||||||
|
db.execute(text(f"UPDATE payment_configs SET {','.join(sets)} WHERE id=:id"), params)
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "支付配置已更新"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/stats/overview")
|
||||||
|
async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""计费统计概览"""
|
||||||
|
total_revenue = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
|
||||||
|
WHERE transaction_type='recharge' AND status='completed'
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
total_consumption = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
|
||||||
|
WHERE transaction_type='consumption' AND status='completed'
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
total_transactions = db.execute(text("SELECT COUNT(*) FROM billing_transactions")).scalar()
|
||||||
|
pending_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status IN ('issued','overdue')")).scalar()
|
||||||
|
paid_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status='paid'")).scalar()
|
||||||
|
active_rules = db.execute(text("SELECT COUNT(*) FROM billing_rules WHERE status='active'")).scalar()
|
||||||
|
active_packages = db.execute(text("SELECT COUNT(*) FROM recharge_packages WHERE status='active'")).scalar()
|
||||||
|
|
||||||
|
# Revenue by day (last 7 days)
|
||||||
|
revenue_7d = db.execute(text("""
|
||||||
|
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
|
||||||
|
FROM billing_transactions
|
||||||
|
WHERE transaction_type='recharge' AND status='completed'
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY DATE(created_at) ORDER BY d
|
||||||
|
""")).fetchall()
|
||||||
|
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
|
||||||
|
|
||||||
|
return {"code": 200, "data": {
|
||||||
|
"total_revenue": float(total_revenue),
|
||||||
|
"total_consumption": float(total_consumption),
|
||||||
|
"total_transactions": total_transactions,
|
||||||
|
"pending_invoices": pending_invoices,
|
||||||
|
"paid_invoices": paid_invoices,
|
||||||
|
"active_rules": active_rules,
|
||||||
|
"active_packages": active_packages,
|
||||||
|
"daily_revenue_7d": daily_revenue,
|
||||||
|
}}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
多租户管理后端 API
|
||||||
|
- 租户 CRUD、成员管理、配额管理、邀请管理
|
||||||
|
- 路由前缀: /app/api/admin/tenant
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
|
from ...database import SessionLocal, get_db
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CST_TZ = None
|
||||||
|
|
||||||
|
def _format_cst_time(dt_val):
|
||||||
|
"""Format datetime to CST string."""
|
||||||
|
global CST_TZ
|
||||||
|
if CST_TZ is None:
|
||||||
|
from datetime import timezone
|
||||||
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
if dt_val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt_val, str):
|
||||||
|
return dt_val
|
||||||
|
if dt_val.tzinfo is None:
|
||||||
|
dt_val = dt_val.replace(tzinfo=CST_TZ)
|
||||||
|
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
def _row_to_dict(row, columns):
|
||||||
|
"""Convert a SQLAlchemy row to dict."""
|
||||||
|
return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)}
|
||||||
|
|
||||||
|
def _execute_raw(db: Session, sql: str, params: dict = None):
|
||||||
|
"""Execute raw SQL and return results."""
|
||||||
|
result = db.execute(text(sql), params or {})
|
||||||
|
if result.returns_rows:
|
||||||
|
cols = list(result.keys())
|
||||||
|
rows = result.fetchall()
|
||||||
|
return [_row_to_dict(row, cols) for row in rows]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 租户管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_tenants(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
keyword: str = Query("", description="搜索关键词"),
|
||||||
|
status: str = Query("", description="状态筛选"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取租户列表"""
|
||||||
|
where = "1=1"
|
||||||
|
params = {}
|
||||||
|
if keyword:
|
||||||
|
where += " AND (name LIKE :kw OR slug LIKE :kw)"
|
||||||
|
params["kw"] = f"%{keyword}%"
|
||||||
|
if status:
|
||||||
|
where += " AND status = :status"
|
||||||
|
params["status"] = status
|
||||||
|
|
||||||
|
count_sql = f"SELECT COUNT(*) FROM tenants WHERE {where}"
|
||||||
|
total = db.execute(text(count_sql), params).scalar()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
list_sql = f"""
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count
|
||||||
|
FROM tenants t WHERE {where}
|
||||||
|
ORDER BY t.created_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
params["limit"] = page_size
|
||||||
|
params["offset"] = offset
|
||||||
|
rows = db.execute(text(list_sql), params).fetchall()
|
||||||
|
cols = list(db.execute(text(list_sql), params).keys())
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
d = _row_to_dict(row, cols)
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_tenant(
|
||||||
|
name: str = Query(..., description="租户名称"),
|
||||||
|
slug: str = Query(..., description="租户标识"),
|
||||||
|
description: str = Query("", description="描述"),
|
||||||
|
max_members: int = Query(10, description="最大成员数"),
|
||||||
|
max_storage_mb: int = Query(10240, description="最大存储(MB)"),
|
||||||
|
max_api_calls_per_day: int = Query(10000, description="每日API调用上限"),
|
||||||
|
expire_at: str = Query("", description="到期时间"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建租户"""
|
||||||
|
tid = str(uuid.uuid4())
|
||||||
|
expire = None
|
||||||
|
if expire_at:
|
||||||
|
try:
|
||||||
|
expire = datetime.fromisoformat(expire_at)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check slug uniqueness
|
||||||
|
existing = db.execute(text("SELECT id FROM tenants WHERE slug=:slug"), {"slug": slug}).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="slug已存在")
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day, expire_at)
|
||||||
|
VALUES (:id, :name, :slug, :desc, 'active', :mm, :ms, :ma, :exp)
|
||||||
|
"""), {
|
||||||
|
"id": tid, "name": name, "slug": slug, "desc": description,
|
||||||
|
"mm": max_members, "ms": max_storage_mb, "ma": max_api_calls_per_day,
|
||||||
|
"exp": expire
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add creator as owner
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
|
||||||
|
VALUES (:mid, :tid, :uid, 'owner', 'active')
|
||||||
|
"""), {"mid": str(uuid.uuid4()), "tid": tid, "uid": user.id})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"id": tid, "message": "租户创建成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}")
|
||||||
|
async def get_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""获取租户详情"""
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count,
|
||||||
|
(SELECT COUNT(*) FROM tenant_quotas WHERE tenant_id=t.id) as quota_count
|
||||||
|
FROM tenants t WHERE t.id=:id
|
||||||
|
"""), {"id": tenant_id}).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="租户不存在")
|
||||||
|
cols = list(db.execute(text("SELECT t.* FROM tenants t WHERE t.id=:id"), {"id": tenant_id}).keys())
|
||||||
|
# Re-query with proper cols
|
||||||
|
cols = ['id','name','slug','logo','description','status','plan_id','max_members','max_storage_mb',
|
||||||
|
'max_api_calls_per_day','settings','created_at','updated_at','expire_at','member_count','quota_count']
|
||||||
|
return {"code": 200, "data": _row_to_dict(row, cols)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}")
|
||||||
|
async def update_tenant(
|
||||||
|
tenant_id: str,
|
||||||
|
name: str = Query(None),
|
||||||
|
description: str = Query(None),
|
||||||
|
status: str = Query(None),
|
||||||
|
max_members: int = Query(None),
|
||||||
|
max_storage_mb: int = Query(None),
|
||||||
|
max_api_calls_per_day: int = Query(None),
|
||||||
|
expire_at: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新租户"""
|
||||||
|
sets = []
|
||||||
|
params = {"id": tenant_id}
|
||||||
|
for field, val in [("name", name), ("description", description), ("status", status),
|
||||||
|
("max_members", max_members), ("max_storage_mb", max_storage_mb),
|
||||||
|
("max_api_calls_per_day", max_api_calls_per_day)]:
|
||||||
|
if val is not None:
|
||||||
|
sets.append(f"{field}=:{field}")
|
||||||
|
params[field] = val
|
||||||
|
if expire_at is not None:
|
||||||
|
sets.append("expire_at=:expire_at")
|
||||||
|
try:
|
||||||
|
params["expire_at"] = datetime.fromisoformat(expire_at) if expire_at else None
|
||||||
|
except:
|
||||||
|
params["expire_at"] = None
|
||||||
|
|
||||||
|
if not sets:
|
||||||
|
return {"code": 200, "data": {"message": "无更新"}}
|
||||||
|
|
||||||
|
db.execute(text(f"UPDATE tenants SET {','.join(sets)} WHERE id=:id"), params)
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "更新成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}")
|
||||||
|
async def delete_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""删除租户(软删除 - 标记为terminated)"""
|
||||||
|
if tenant_id == "default-tenant-0001":
|
||||||
|
raise HTTPException(status_code=400, detail="默认租户不可删除")
|
||||||
|
db.execute(text("UPDATE tenants SET status='terminated' WHERE id=:id"), {"id": tenant_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "租户已终止"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 成员管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}/members")
|
||||||
|
async def list_members(
|
||||||
|
tenant_id: str,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取租户成员列表"""
|
||||||
|
total = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status!='removed'"),
|
||||||
|
{"tid": tenant_id}).scalar()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT tm.id, tm.tenant_id, tm.user_id, tm.role, tm.status, tm.joined_at,
|
||||||
|
u.username, u.email, u.avatar, u.role as user_role
|
||||||
|
FROM tenant_members tm
|
||||||
|
LEFT JOIN users u ON tm.user_id = u.id
|
||||||
|
WHERE tm.tenant_id=:tid AND tm.status!='removed'
|
||||||
|
ORDER BY tm.joined_at DESC LIMIT :limit OFFSET :offset
|
||||||
|
"""), {"tid": tenant_id, "limit": page_size, "offset": offset}).fetchall()
|
||||||
|
cols = ['id','tenant_id','user_id','role','status','joined_at','username','email','avatar','user_role']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tenant_id}/members/add")
|
||||||
|
async def add_member(
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str = Query(..., description="用户ID"),
|
||||||
|
role: str = Query("member", description="角色: owner/admin/member/viewer"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""添加成员到租户"""
|
||||||
|
# Check tenant exists
|
||||||
|
t = db.execute(text("SELECT max_members FROM tenants WHERE id=:id AND status='active'"), {"id": tenant_id}).fetchone()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="租户不存在或已停用")
|
||||||
|
|
||||||
|
# Check member limit
|
||||||
|
cnt = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"),
|
||||||
|
{"tid": tenant_id}).scalar()
|
||||||
|
if cnt >= t[0]:
|
||||||
|
raise HTTPException(status_code=400, detail="已达成员上限")
|
||||||
|
|
||||||
|
# Check if already member
|
||||||
|
existing = db.execute(text("SELECT id FROM tenant_members WHERE tenant_id=:tid AND user_id=:uid"),
|
||||||
|
{"tid": tenant_id, "uid": user_id}).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.execute(text("UPDATE tenant_members SET status='active', role=:role WHERE tenant_id=:tid AND user_id=:uid"),
|
||||||
|
{"tid": tenant_id, "uid": user_id, "role": role})
|
||||||
|
else:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
|
||||||
|
VALUES (:mid, :tid, :uid, :role, 'active')
|
||||||
|
"""), {"mid": str(uuid.uuid4()), "tid": tenant_id, "uid": user_id, "role": role})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "成员添加成功"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}/members/{member_id}")
|
||||||
|
async def update_member_role(
|
||||||
|
tenant_id: str,
|
||||||
|
member_id: str,
|
||||||
|
role: str = Query(..., description="新角色"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新成员角色"""
|
||||||
|
db.execute(text("UPDATE tenant_members SET role=:role WHERE id=:mid AND tenant_id=:tid"),
|
||||||
|
{"role": role, "mid": member_id, "tid": tenant_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "角色已更新"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}/members/{member_id}")
|
||||||
|
async def remove_member(tenant_id: str, member_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""移除成员"""
|
||||||
|
db.execute(text("UPDATE tenant_members SET status='removed', removed_at=NOW() WHERE id=:mid AND tenant_id=:tid"),
|
||||||
|
{"mid": member_id, "tid": tenant_id})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "成员已移除"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配额管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}/quotas")
|
||||||
|
async def get_quotas(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""获取租户配额使用情况"""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT tq.*, t.max_members, t.max_storage_mb, t.max_api_calls_per_day
|
||||||
|
FROM tenant_quotas tq
|
||||||
|
RIGHT JOIN tenants t ON tq.tenant_id = t.id
|
||||||
|
WHERE t.id=:tid
|
||||||
|
"""), {"tid": tenant_id}).fetchall()
|
||||||
|
|
||||||
|
# Build quota summary
|
||||||
|
quotas = {
|
||||||
|
"members": {"used": 0, "limit": 0},
|
||||||
|
"storage": {"used": 0, "limit": 0},
|
||||||
|
"api_calls": {"used": 0, "limit": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get actual counts
|
||||||
|
member_count = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"),
|
||||||
|
{"tid": tenant_id}).scalar()
|
||||||
|
tenant_info = db.execute(text("SELECT max_members, max_storage_mb, max_api_calls_per_day FROM tenants WHERE id=:tid"),
|
||||||
|
{"tid": tenant_id}).fetchone()
|
||||||
|
|
||||||
|
if tenant_info:
|
||||||
|
quotas["members"] = {"used": member_count, "limit": tenant_info[0]}
|
||||||
|
quotas["storage"] = {"used": 0, "limit": tenant_info[1]}
|
||||||
|
quotas["api_calls"] = {"used": 0, "limit": tenant_info[2]}
|
||||||
|
|
||||||
|
# Get quota records
|
||||||
|
qrows = db.execute(text("SELECT * FROM tenant_quotas WHERE tenant_id=:tid"), {"tid": tenant_id}).fetchall()
|
||||||
|
qcols = ['id','tenant_id','resource_type','used','quota_limit','period','reset_at','updated_at']
|
||||||
|
for r in qrows:
|
||||||
|
d = _row_to_dict(r, qcols)
|
||||||
|
rt = d['resource_type']
|
||||||
|
if rt in quotas:
|
||||||
|
quotas[rt]["used"] = d['used']
|
||||||
|
if d['quota_limit'] > 0:
|
||||||
|
quotas[rt]["limit"] = d['quota_limit']
|
||||||
|
else:
|
||||||
|
quotas[rt] = {"used": d['used'], "limit": d['quota_limit']}
|
||||||
|
|
||||||
|
return {"code": 200, "data": quotas}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}/quotas")
|
||||||
|
async def update_quotas(
|
||||||
|
tenant_id: str,
|
||||||
|
resource_type: str = Query(..., description="资源类型"),
|
||||||
|
quota_limit: int = Query(..., description="配额上限"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""更新租户配额"""
|
||||||
|
existing = db.execute(text("SELECT id FROM tenant_quotas WHERE tenant_id=:tid AND resource_type=:rt"),
|
||||||
|
{"tid": tenant_id, "rt": resource_type}).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.execute(text("UPDATE tenant_quotas SET quota_limit=:ql WHERE tenant_id=:tid AND resource_type=:rt"),
|
||||||
|
{"ql": quota_limit, "tid": tenant_id, "rt": resource_type})
|
||||||
|
else:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO tenant_quotas (id, tenant_id, resource_type, used, quota_limit, period)
|
||||||
|
VALUES (:id, :tid, :rt, 0, :ql, 'total')
|
||||||
|
"""), {"id": str(uuid.uuid4()), "tid": tenant_id, "rt": resource_type, "ql": quota_limit})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"message": "配额已更新"}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 邀请管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post("/{tenant_id}/invitations")
|
||||||
|
async def create_invitation(
|
||||||
|
tenant_id: str,
|
||||||
|
email: str = Query(..., description="邀请邮箱"),
|
||||||
|
role: str = Query("member", description="角色"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""创建租户邀请"""
|
||||||
|
import secrets
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now() + timedelta(days=7)
|
||||||
|
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO tenant_invitations (id, tenant_id, email, role, token, status, invited_by, expires_at)
|
||||||
|
VALUES (:id, :tid, :email, :role, :token, 'pending', :uid, :exp)
|
||||||
|
"""), {
|
||||||
|
"id": str(uuid.uuid4()), "tid": tenant_id, "email": email,
|
||||||
|
"role": role, "token": token, "uid": user.id, "exp": expires
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
return {"code": 200, "data": {"token": token, "expires_at": _format_cst_time(expires)}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}/invitations")
|
||||||
|
async def list_invitations(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""获取租户邀请列表"""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT ti.*, u.username as invited_by_name
|
||||||
|
FROM tenant_invitations ti
|
||||||
|
LEFT JOIN users u ON ti.invited_by = u.id
|
||||||
|
WHERE ti.tenant_id=:tid
|
||||||
|
ORDER BY ti.created_at DESC
|
||||||
|
"""), {"tid": tenant_id}).fetchall()
|
||||||
|
cols = ['id','tenant_id','email','role','token','status','invited_by','accepted_by',
|
||||||
|
'expires_at','created_at','invited_by_name']
|
||||||
|
items = [_row_to_dict(r, cols) for r in rows]
|
||||||
|
return {"code": 200, "data": items}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/stats/overview")
|
||||||
|
async def tenant_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""租户统计概览"""
|
||||||
|
total = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status!='terminated'")).scalar()
|
||||||
|
active = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='active'")).scalar()
|
||||||
|
suspended = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='suspended'")).scalar()
|
||||||
|
total_members = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE status='active'")).scalar()
|
||||||
|
total_invitations = db.execute(text("SELECT COUNT(*) FROM tenant_invitations WHERE status='pending'")).scalar()
|
||||||
|
|
||||||
|
return {"code": 200, "data": {
|
||||||
|
"total_tenants": total,
|
||||||
|
"active_tenants": active,
|
||||||
|
"suspended_tenants": suspended,
|
||||||
|
"total_members": total_members,
|
||||||
|
"pending_invitations": total_invitations,
|
||||||
|
}}
|
||||||
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||||
|
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
|
||||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||||
from app.api.admin import asset_admin, achievement_admin
|
from app.api.admin import asset_admin, achievement_admin
|
||||||
@@ -437,11 +437,10 @@ app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=[
|
|||||||
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
|
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
|
||||||
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
|
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
|
||||||
achievement_admin.router,
|
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
|
||||||
prefix="/app/api/admin/achievement",
|
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
|
||||||
tags=["achievement"]
|
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import PrivacySettings from '@/pages/admin/PrivacySettings';
|
|||||||
import ImBots from '@/pages/admin/ImBots';
|
import ImBots from '@/pages/admin/ImBots';
|
||||||
import Promotion from '@/pages/admin/Promotion';
|
import Promotion from '@/pages/admin/Promotion';
|
||||||
import Achievement from '@/pages/admin/Achievement';
|
import Achievement from '@/pages/admin/Achievement';
|
||||||
|
import Tenant from '@/pages/admin/Tenant';
|
||||||
|
import Billing from '@/pages/admin/Billing';
|
||||||
|
import Analytics from '@/pages/admin/Analytics';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -127,6 +130,9 @@ const App: React.FC = () => {
|
|||||||
<Route path="privacy" element={<PrivacySettings />} />
|
<Route path="privacy" element={<PrivacySettings />} />
|
||||||
<Route path="promotion" element={<Promotion />} />
|
<Route path="promotion" element={<Promotion />} />
|
||||||
<Route path="achievement" element={<Achievement />} />
|
<Route path="achievement" element={<Achievement />} />
|
||||||
|
<Route path="tenant" element={<Tenant />} />
|
||||||
|
<Route path="billing" element={<Billing />} />
|
||||||
|
<Route path="analytics" element={<Analytics />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
|||||||
+61
-42
@@ -378,48 +378,7 @@ export const adminAPI = {
|
|||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
|
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`),
|
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${t sp = new URLSearchParams();
|
||||||
revokeMembership: (userId: string) => put<any>(`/admin/membership/users/${userId}/revoke`),
|
|
||||||
getUserUsage: (userId: string) => get<any>(`/admin/membership/users/${userId}/usage`),
|
|
||||||
getMembershipStats: () => get<any>('/admin/membership/stats'),
|
|
||||||
// 广告管理
|
|
||||||
getAds: () => get<any[]>('/admin/ads/'),
|
|
||||||
addAd: (data: any) => post<any>('/admin/ads/', data),
|
|
||||||
updateAd: (id: string, data: any) => put<any>(`/admin/ads/${id}`, data),
|
|
||||||
deleteAd: (id: string) => del<any>(`/admin/ads/${id}`),
|
|
||||||
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
|
|
||||||
// 应用市场审核
|
|
||||||
getPendingApps: () => get<any[]>('/admin/market/apps/pending'),
|
|
||||||
getMarketOverview: () => get<any>('/admin/market/analytics/overview'),
|
|
||||||
getTopApps: (metric: string, limit: number) => get<any>(`/admin/market/analytics/top-apps?metric=${metric}&limit=${limit}`),
|
|
||||||
getRecentReviews: () => get<any>('/admin/market/analytics/recent-reviews'),
|
|
||||||
getAdminMarketApps: (query: string) => get<any>(`/admin/market/apps?${query}`),
|
|
||||||
getMarketCategories: () => get<any>('/admin/market/categories'),
|
|
||||||
setFeaturedApp: (id: string, featured: boolean, order: number) => put<any>(`/admin/market/apps/${id}/featured`, { featured, featured_order: order }),
|
|
||||||
deleteMarketApp: (id: string) => del<any>(`/admin/market/apps/${id}`),
|
|
||||||
updateMarketApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
|
||||||
approveApp: (id: string) => put<any>(`/admin/market/apps/${id}/approve`),
|
|
||||||
rejectApp: (id: string) => put<any>(`/admin/market/apps/${id.includes('?') ? id.split('?')[0] + '/reject?' + id.split('?')[1] : id + '/reject'}`),
|
|
||||||
getAdminApps: () => get<any[]>('/admin/market/apps'),
|
|
||||||
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
|
||||||
getCoupons: () => get<any[]>('/admin/market/coupons'),
|
|
||||||
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
|
|
||||||
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
|
|
||||||
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
|
|
||||||
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
|
|
||||||
// 日志与风控
|
|
||||||
getOperationLogs: (params?: any) => {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
|
||||||
return get<any[]>(`/admin/logs/operations${sp.toString() ? `?${sp.toString()}` : ''}`);
|
|
||||||
},
|
|
||||||
getTokenLogs: (params?: any) => {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
|
||||||
return get<any[]>(`/admin/logs/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
|
||||||
},
|
|
||||||
getPaymentLogs: (params?: any) => {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||||
},
|
},
|
||||||
@@ -908,4 +867,64 @@ export const evolutionAPI = {
|
|||||||
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
|
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
|
||||||
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
|
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
|
||||||
|
|
||||||
|
// 多租户管理
|
||||||
|
getTenants: (page?: number, keyword?: string, status?: string) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (page) sp.set('page', String(page));
|
||||||
|
if (keyword) sp.set('keyword', keyword);
|
||||||
|
if (status) sp.set('status', status);
|
||||||
|
sp.set('page_size', '20');
|
||||||
|
return get<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||||
|
},
|
||||||
|
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
||||||
|
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
|
||||||
|
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
|
||||||
|
deleteTenant: (id: string) => del<any>(`/admin/tenant/${id}`),
|
||||||
|
getTenantMembers: (tenantId: string, page?: number) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.set('page', String(page || 1));
|
||||||
|
sp.set('page_size', '100');
|
||||||
|
return get<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
|
||||||
|
},
|
||||||
|
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
|
||||||
|
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
|
||||||
|
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
|
||||||
|
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
|
||||||
|
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
|
||||||
|
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
|
||||||
|
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
|
||||||
|
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
|
||||||
|
|
||||||
|
// 计费与支付
|
||||||
|
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
|
||||||
|
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
|
||||||
|
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
|
||||||
|
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
|
||||||
|
getBillingPackages: () => get<any>('/admin/billing/packages'),
|
||||||
|
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
|
||||||
|
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
|
||||||
|
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
|
||||||
|
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
|
||||||
|
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
|
||||||
|
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
|
||||||
|
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
|
||||||
|
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
|
||||||
|
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
|
||||||
|
|
||||||
|
// 数据分析
|
||||||
|
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
|
||||||
|
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
|
||||||
|
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
|
||||||
|
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
|
||||||
|
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
|
||||||
|
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
|
||||||
|
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
|
||||||
|
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
|
||||||
|
deleteAnalyticsDashboard: (id: string) => del<any>(`/admin/analytics/dashboards/${id}`),
|
||||||
|
getAnalyticsEvents: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
|
return get<any>(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,9 @@ const adminNavItems = [
|
|||||||
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
||||||
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
|
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
|
||||||
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
|
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
|
||||||
|
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
|
||||||
|
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
|
||||||
|
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
|
||||||
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -52,7 +55,24 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
|||||||
</svg>,
|
</svg>,
|
||||||
achievement: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
achievement: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
||||||
</svg>,};
|
</svg>,
|
||||||
|
tenant: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 21V8l9-5 9 5v13"/>
|
||||||
|
<path d="M9 21v-6h6v6"/>
|
||||||
|
<path d="M3 13h18"/>
|
||||||
|
</svg>,
|
||||||
|
billing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||||
|
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
<line x1="6" y1="15" x2="10" y2="15"/>
|
||||||
|
</svg>,
|
||||||
|
analytics: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
|
<line x1="3" y1="20" x2="21" y2="20"/>
|
||||||
|
</svg>,
|
||||||
|
};
|
||||||
|
|
||||||
const AdminLayout: React.FC = () => {
|
const AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
total_users: number; active_users: number; new_users: number;
|
||||||
|
api_calls: number; avg_response_time: number; error_rate: number;
|
||||||
|
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
|
||||||
|
}
|
||||||
|
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
|
||||||
|
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
|
||||||
|
|
||||||
|
const Analytics: React.FC = () => {
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
|
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
|
||||||
|
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
|
||||||
|
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
|
||||||
|
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
|
||||||
|
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [ov, api, tok, usr, rev, mu] = await Promise.all([
|
||||||
|
adminAPI.getAnalyticsOverview(days),
|
||||||
|
adminAPI.getAnalyticsApiTrends(days),
|
||||||
|
adminAPI.getAnalyticsTokenTrends(days),
|
||||||
|
adminAPI.getAnalyticsUserTrends(days),
|
||||||
|
adminAPI.getAnalyticsRevenueTrends(days),
|
||||||
|
adminAPI.getAnalyticsModelUsage(days),
|
||||||
|
]);
|
||||||
|
setOverview((ov as any)?.data || ov);
|
||||||
|
setApiTrend((api as any)?.data || []);
|
||||||
|
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
|
||||||
|
setTokenTrend(td);
|
||||||
|
setUserTrend((usr as any)?.data || []);
|
||||||
|
setRevenueTrend((rev as any)?.data || []);
|
||||||
|
setModelUsage((mu as any)?.data || []);
|
||||||
|
} catch (e) { console.error('fetchAnalytics:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||||
|
|
||||||
|
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
||||||
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
||||||
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
|
||||||
|
{data.map((d: any, i) => {
|
||||||
|
const v = d[key] || 0;
|
||||||
|
const h = (v / max) * 140;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
||||||
|
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
||||||
|
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
|
||||||
|
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
||||||
|
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
||||||
|
const values = data.map((d: any) => d[key] || 0);
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const w = 100 / (data.length - 1 || 1);
|
||||||
|
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
|
||||||
|
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
|
||||||
|
{data.map((d: any, i) => (
|
||||||
|
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = overview ? [
|
||||||
|
{ label: '总用户数', value: overview.total_users, color: '#6366f1', icon: 'U' },
|
||||||
|
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
||||||
|
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
||||||
|
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
||||||
|
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
|
||||||
|
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
|
||||||
|
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
|
||||||
|
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{[7, 14, 30, 90].map(d => (
|
||||||
|
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||||
|
background: days === d ? '#6366f1' : '#1e293b', color: days === d ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#64748b' }}>加载中...</div> : (
|
||||||
|
<>
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
{statCards.map((s, i) => (
|
||||||
|
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '2px' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
||||||
|
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
||||||
|
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
||||||
|
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
||||||
|
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model usage */}
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
||||||
|
{modelUsage.length > 0 ? (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead><tr style={{ borderBottom: '2px solid #334155' }}>
|
||||||
|
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{modelUsage.map((m, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid #334155' }}>
|
||||||
|
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
|
||||||
|
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.avg_tokens.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无模型使用数据</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model distribution pie (CSS) */}
|
||||||
|
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||||
|
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
||||||
|
{(() => {
|
||||||
|
const entries = Object.entries(tokenTrend.model_distribution);
|
||||||
|
const total = entries.reduce((s, [, v]) => s + v, 1);
|
||||||
|
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
|
return entries.map(([model, val], i) => {
|
||||||
|
const pct = (val / total) * 100;
|
||||||
|
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
||||||
|
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||||
|
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
||||||
|
return (
|
||||||
|
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
||||||
|
<span style={{ fontSize: '13px', color: '#e2e8f0' }}>{model}</span>
|
||||||
|
<span style={{ fontSize: '13px', color: '#94a3b8', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
|
interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; }
|
||||||
|
interface Package { id: string; name: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; }
|
||||||
|
interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; }
|
||||||
|
interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; }
|
||||||
|
|
||||||
|
const Billing: React.FC = () => {
|
||||||
|
const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview');
|
||||||
|
const [rules, setRules] = useState<Rule[]>([]);
|
||||||
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchRules = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getBillingRules();
|
||||||
|
const d = (res as any)?.data || res;
|
||||||
|
setRules(d?.items || []);
|
||||||
|
} catch (e) { console.error('fetchRules:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPackages = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getBillingPackages();
|
||||||
|
const d = (res as any)?.data || res;
|
||||||
|
setPackages(Array.isArray(d) ? d : d?.items || []);
|
||||||
|
} catch (e) { console.error('fetchPackages:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getBillingTransactions(page);
|
||||||
|
const d = (res as any)?.data || res;
|
||||||
|
setTransactions(d?.items || []);
|
||||||
|
setTotal(d?.total || 0);
|
||||||
|
} catch (e) { console.error('fetchTransactions:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getBillingStats();
|
||||||
|
setStats((res as any)?.data || res);
|
||||||
|
} catch (e) { console.error('fetchStats:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||||
|
useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]);
|
||||||
|
useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]);
|
||||||
|
useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]);
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = { per_token: '#6366f1', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
|
||||||
|
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
{[
|
||||||
|
{ label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' },
|
||||||
|
{ label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' },
|
||||||
|
{ label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' },
|
||||||
|
{ label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||||
|
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setTab(k)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||||
|
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{tab === 'overview' && stats && (
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px' }}>近7天收入趋势</h3>
|
||||||
|
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
|
||||||
|
{stats.daily_revenue_7d.map((d, i) => {
|
||||||
|
const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1);
|
||||||
|
const h = (d.revenue / max) * 160;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontSize: '11px', color: '#94a3b8', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
|
||||||
|
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6366f1, #818cf8)', borderRadius: '4px 4px 0 0' }} />
|
||||||
|
<div style={{ fontSize: '11px', color: '#64748b', marginTop: '6px' }}>{d.date.substring(5)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无收入数据</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
{tab === 'rules' && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<thead><tr style={{ background: '#0f172a' }}>
|
||||||
|
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<tr key={r.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.model_name || '通用'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.free_quota?.toLocaleString() || 0}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ color: r.status === 'active' ? '#10b981' : '#64748b', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
{tab === 'packages' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
|
||||||
|
{packages.map(p => (
|
||||||
|
<div key={p.id} style={{ background: '#1e293b', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6366f1' : '1px solid #334155' }}>
|
||||||
|
{p.is_popular === 1 && (
|
||||||
|
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6366f1', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
|
||||||
|
)}
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '16px' }}>{p.description || ''}</p>
|
||||||
|
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6366f1' }}>¥{p.price}</div>
|
||||||
|
{p.original_price && <div style={{ fontSize: '14px', color: '#64748b', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
|
||||||
|
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid #334155', fontSize: '13px', color: '#94a3b8' }}>
|
||||||
|
<div>主额度: <span style={{ color: '#e2e8f0', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
|
||||||
|
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '12px', fontSize: '12px' }}>
|
||||||
|
<span style={{ color: p.status === 'active' ? '#10b981' : '#64748b' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transactions */}
|
||||||
|
{tab === 'transactions' && (
|
||||||
|
<>
|
||||||
|
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<thead><tr style={{ background: '#0f172a' }}>
|
||||||
|
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map(t => (
|
||||||
|
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 16)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.tokens?.toLocaleString() || '-'}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.description || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#64748b' }}>暂无交易记录</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{total > 20 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||||
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||||
|
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
|
||||||
|
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Billing;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string; name: string; slug: string; logo: string; description: string;
|
||||||
|
status: string; max_members: number; max_storage_mb: number;
|
||||||
|
max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string;
|
||||||
|
}
|
||||||
|
interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; }
|
||||||
|
interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; }
|
||||||
|
|
||||||
|
const Tenant: React.FC = () => {
|
||||||
|
const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list');
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [selectedTenant, setSelectedTenant] = useState<string>('');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||||
|
|
||||||
|
const fetchTenants = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getTenants(page);
|
||||||
|
const d = (res as any)?.data || res;
|
||||||
|
setTenants(d?.items || []);
|
||||||
|
setTotal(d?.total || 0);
|
||||||
|
} catch (e) { console.error('fetchTenants:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getTenantStats();
|
||||||
|
setStats((res as any)?.data || res);
|
||||||
|
} catch (e) { console.error('fetchStats:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
if (!selectedTenant) return;
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.getTenantMembers(selectedTenant);
|
||||||
|
const d = (res as any)?.data || res;
|
||||||
|
setMembers(d?.items || []);
|
||||||
|
} catch (e) { console.error('fetchMembers:', e); }
|
||||||
|
}, [selectedTenant]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]);
|
||||||
|
useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`;
|
||||||
|
await adminAPI.createTenant(params);
|
||||||
|
setShowCreate(false);
|
||||||
|
setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||||
|
fetchTenants();
|
||||||
|
} catch (e) { console.error('create:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
|
||||||
|
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#94a3b8', viewer: '#64748b' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
{[
|
||||||
|
{ label: '总租户数', value: stats.total_tenants, color: '#6366f1' },
|
||||||
|
{ label: '活跃租户', value: stats.active_tenants, color: '#10b981' },
|
||||||
|
{ label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' },
|
||||||
|
{ label: '总成员数', value: stats.total_members, color: '#3b82f6' },
|
||||||
|
{ label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
|
||||||
|
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||||
|
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setTab(k)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||||
|
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8',
|
||||||
|
fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||||
|
))}
|
||||||
|
{tab === 'list' && (
|
||||||
|
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
|
||||||
|
border: 'none', cursor: 'pointer', background: '#6366f1', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
|
||||||
|
+ 创建租户
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{tab === 'list' && (
|
||||||
|
<div>
|
||||||
|
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#0f172a' }}>
|
||||||
|
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenants.map(t => (
|
||||||
|
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.slug}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||||
|
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 10)}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid #475569',
|
||||||
|
background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '12px' }}>管理</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{total > 20 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||||
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||||
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||||
|
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
|
||||||
|
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
|
||||||
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'members' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '8px', background: '#1e293b', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px' }}>
|
||||||
|
<option value="">选择租户...</option>
|
||||||
|
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{selectedTenant && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#0f172a' }}>
|
||||||
|
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map(m => (
|
||||||
|
<tr key={m.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.email || '-'}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||||
|
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{m.joined_at?.substring(0, 16)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'stats' && stats && (
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '32px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '24px' }}>
|
||||||
|
{Object.entries(stats).map(([k, v]) => (
|
||||||
|
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#0f172a', borderRadius: '8px' }}>
|
||||||
|
<span style={{ fontSize: '14px', color: '#94a3b8' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
|
||||||
|
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6366f1' }}>{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create modal */}
|
||||||
|
{showCreate && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
|
||||||
|
onClick={() => setShowCreate(false)}>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ background: '#1e293b', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw' }}>
|
||||||
|
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px' }}>创建租户</h2>
|
||||||
|
{[
|
||||||
|
{ k: 'name', label: '租户名称', type: 'text' },
|
||||||
|
{ k: 'slug', label: '标识(英文)', type: 'text' },
|
||||||
|
{ k: 'description', label: '描述', type: 'text' },
|
||||||
|
{ k: 'max_members', label: '最大成员数', type: 'number' },
|
||||||
|
{ k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' },
|
||||||
|
{ k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' },
|
||||||
|
].map(f => (
|
||||||
|
<div key={f.k} style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{f.label}</label>
|
||||||
|
<input type={f.type} value={(form as any)[f.k]} onChange={e => setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#0f172a', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||||
|
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid #475569', background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '14px' }}>取消</button>
|
||||||
|
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tenant;
|
||||||
Reference in new issue
Block a user