feat: API Key管理+用量追踪完整功能链路
后端: - api_key_manager: 加密存储、脱敏显示、优先级排序、降级策略、Key可用性检测 - smart_router: 分层路由策略(FREE→LOW_COST→MID_COST→HIGH_COST)、国内引擎优先 - usage_tracker: Token消耗统计、成本计算、配额预警(ok/warning/exceeded) - API端点: /api/v1/api-keys/ (CRUD+verify), /api/v1/usage/ (summary+quota+by-engine) - 测试: 19个API测试 + 37个服务测试全部通过 前端: - settings页面API配置标签页: 按国内外分组展示9个引擎、添加/删除/验证Key - usage页面: 配额概览(环形进度)、用量趋势图(Recharts)、引擎分布饼图、明细表格 - 修复API路径与后端不一致问题 - 修复alerts API参数顺序问题
This commit is contained in:
parent
41c2994222
commit
290ef5a273
|
|
@ -0,0 +1,127 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.api_key_manager import APIKeyManager, KeySource, KeyStatus
|
||||||
|
from app.services.smart_router import ENGINE_COST_PROFILES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_key_manager: APIKeyManager = APIKeyManager()
|
||||||
|
|
||||||
|
|
||||||
|
def set_key_manager(mgr: APIKeyManager) -> None:
|
||||||
|
global _key_manager
|
||||||
|
_key_manager = mgr
|
||||||
|
|
||||||
|
|
||||||
|
def get_key_manager() -> APIKeyManager:
|
||||||
|
return _key_manager
|
||||||
|
|
||||||
|
|
||||||
|
class AddKeyRequest(BaseModel):
|
||||||
|
engine_type: str
|
||||||
|
api_key: str
|
||||||
|
source: str = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyKeyRequest(BaseModel):
|
||||||
|
engine_type: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def add_key(
|
||||||
|
body: AddKeyRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
source = KeySource.USER if body.source == "user" else KeySource.SYSTEM
|
||||||
|
config = get_key_manager().add_key(
|
||||||
|
engine_type=body.engine_type,
|
||||||
|
api_key=body.api_key,
|
||||||
|
source=source,
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"engine_type": config.engine_type,
|
||||||
|
"key_hint": config.key_hint,
|
||||||
|
"status": KeyStatus.ACTIVE.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_keys(
|
||||||
|
engine_type: str | None = Query(None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
configs = get_key_manager().list_keys(engine_type=engine_type)
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"engine_type": c.engine_type,
|
||||||
|
"key_hint": c.key_hint,
|
||||||
|
"source": c.key_source.value,
|
||||||
|
"status": c.status.value,
|
||||||
|
}
|
||||||
|
for c in configs
|
||||||
|
]
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
async def verify_key(
|
||||||
|
body: VerifyKeyRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
mgr = get_key_manager()
|
||||||
|
api_key = mgr.get_key(body.engine_type, user_id=str(current_user.id))
|
||||||
|
if not api_key:
|
||||||
|
api_key = mgr.get_key(body.engine_type)
|
||||||
|
if not api_key:
|
||||||
|
api_key = mgr.get_any_available_key(body.engine_type)
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No API Key configured for {body.engine_type}",
|
||||||
|
)
|
||||||
|
key_status = await mgr.verify_key(body.engine_type, api_key)
|
||||||
|
return {
|
||||||
|
"engine_type": body.engine_type,
|
||||||
|
"status": key_status.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/engines")
|
||||||
|
async def get_engines(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
engines = [
|
||||||
|
{
|
||||||
|
"type": profile.engine_type,
|
||||||
|
"cost_tier": profile.cost_tier.value,
|
||||||
|
"has_free_tier": profile.has_free_tier,
|
||||||
|
"requires_own_key": profile.requires_own_key,
|
||||||
|
"input_price": profile.input_price_per_million,
|
||||||
|
"output_price": profile.output_price_per_million,
|
||||||
|
}
|
||||||
|
for profile in ENGINE_COST_PROFILES.values()
|
||||||
|
]
|
||||||
|
return {"engines": engines}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{engine_type}/{key_hint}")
|
||||||
|
async def delete_key(
|
||||||
|
engine_type: str,
|
||||||
|
key_hint: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
deleted = get_key_manager().remove_key(engine_type, key_hint)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="API Key not found",
|
||||||
|
)
|
||||||
|
return {"deleted": True}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.usage_tracker import UsageTracker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_usage_tracker: UsageTracker = UsageTracker()
|
||||||
|
|
||||||
|
|
||||||
|
def set_usage_tracker(tracker: UsageTracker) -> None:
|
||||||
|
global _usage_tracker
|
||||||
|
_usage_tracker = tracker
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage_tracker() -> UsageTracker:
|
||||||
|
return _usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(current_user: User) -> str:
|
||||||
|
return str(current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def get_usage_summary(
|
||||||
|
period: str = Query("month", pattern=r"^(day|week|month)$"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
summary = get_usage_tracker().get_summary(
|
||||||
|
user_id=_user_id(current_user),
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"period": summary.period,
|
||||||
|
"start_date": summary.start_date,
|
||||||
|
"end_date": summary.end_date,
|
||||||
|
"total_queries": summary.total_queries,
|
||||||
|
"total_input_tokens": summary.total_input_tokens,
|
||||||
|
"total_output_tokens": summary.total_output_tokens,
|
||||||
|
"total_cost": summary.total_cost,
|
||||||
|
"by_engine": summary.by_engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quota")
|
||||||
|
async def get_quota(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return get_usage_tracker().check_quota(user_id=_user_id(current_user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-engine")
|
||||||
|
async def get_usage_by_engine(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
summary = get_usage_tracker().get_summary(
|
||||||
|
user_id=_user_id(current_user),
|
||||||
|
period="month",
|
||||||
|
)
|
||||||
|
engines = [
|
||||||
|
{
|
||||||
|
"type": engine_type,
|
||||||
|
"queries": data["queries"],
|
||||||
|
"cost": data["cost"],
|
||||||
|
}
|
||||||
|
for engine_type, data in summary.by_engine.items()
|
||||||
|
]
|
||||||
|
return {"engines": engines}
|
||||||
|
|
@ -40,6 +40,8 @@ from app.api.image import router as image_router
|
||||||
from app.api.knowledge_graph import router as knowledge_graph_router
|
from app.api.knowledge_graph import router as knowledge_graph_router
|
||||||
from app.api.ai_engines import router as ai_engines_router
|
from app.api.ai_engines import router as ai_engines_router
|
||||||
from app.api.detection import router as detection_router
|
from app.api.detection import router as detection_router
|
||||||
|
from app.api.api_keys import router as api_keys_router
|
||||||
|
from app.api.usage import router as usage_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
from app.schemas.common import ErrorResponse, ErrorCode
|
from app.schemas.common import ErrorResponse, ErrorCode
|
||||||
|
|
@ -169,6 +171,8 @@ app.include_router(image_router, prefix="/api/v1")
|
||||||
app.include_router(knowledge_graph_router, prefix="/api/v1/knowledge-bases")
|
app.include_router(knowledge_graph_router, prefix="/api/v1/knowledge-bases")
|
||||||
app.include_router(ai_engines_router, prefix="/api/v1/ai-engines", tags=["AI引擎查询"])
|
app.include_router(ai_engines_router, prefix="/api/v1/ai-engines", tags=["AI引擎查询"])
|
||||||
app.include_router(detection_router, prefix="/api/v1/detection", tags=["定时检测任务"])
|
app.include_router(detection_router, prefix="/api/v1/detection", tags=["定时检测任务"])
|
||||||
|
app.include_router(api_keys_router, prefix="/api/v1/api-keys", tags=["API Key管理"])
|
||||||
|
app.include_router(usage_router, prefix="/api/v1/usage", tags=["用量追踪"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["可观测性"])
|
@app.get("/health", tags=["可观测性"])
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@ class APIKeyManager:
|
||||||
return self._decrypt(c.encrypted_key)
|
return self._decrypt(c.encrypted_key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_any_available_key(self, engine_type: str) -> str | None:
|
||||||
|
for c in self._keys.get(engine_type, []):
|
||||||
|
if c.status in self._USABLE_STATUSES:
|
||||||
|
return self._decrypt(c.encrypted_key)
|
||||||
|
return None
|
||||||
|
|
||||||
def remove_key(self, engine_type: str, key_hint: str) -> bool:
|
def remove_key(self, engine_type: str, key_hint: str) -> bool:
|
||||||
configs = self._keys.get(engine_type, [])
|
configs = self._keys.get(engine_type, [])
|
||||||
for i, c in enumerate(configs):
|
for i, c in enumerate(configs):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.database import Base
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.api_key_manager import APIKeyManager, KeySource
|
||||||
|
from app.services.auth import hash_password
|
||||||
|
from app.services.usage_tracker import UsageTracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_engine():
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_session(async_engine):
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
async_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_user(async_session):
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash=hash_password("Test@123456"),
|
||||||
|
name="Test User",
|
||||||
|
plan="free",
|
||||||
|
max_queries=5,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
async_session.add(user)
|
||||||
|
await async_session.commit()
|
||||||
|
await async_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
def key_manager():
|
||||||
|
return APIKeyManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
def usage_tracker(test_user):
|
||||||
|
uid = str(test_user.id)
|
||||||
|
tracker = UsageTracker()
|
||||||
|
tracker.record(
|
||||||
|
user_id=uid,
|
||||||
|
brand_id="brand-1",
|
||||||
|
engine_type="deepseek",
|
||||||
|
query="test query",
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=200,
|
||||||
|
cost=0.01,
|
||||||
|
)
|
||||||
|
tracker.record(
|
||||||
|
user_id=uid,
|
||||||
|
brand_id="brand-1",
|
||||||
|
engine_type="chatgpt",
|
||||||
|
query="test query 2",
|
||||||
|
input_tokens=500,
|
||||||
|
output_tokens=1000,
|
||||||
|
cost=0.05,
|
||||||
|
)
|
||||||
|
return tracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_client(async_session, test_user, key_manager, usage_tracker):
|
||||||
|
async def override_get_db():
|
||||||
|
yield async_session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return test_user
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddAPIKey:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_key_success(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/api/v1/api-keys/",
|
||||||
|
json={
|
||||||
|
"engine_type": "chatgpt",
|
||||||
|
"api_key": "sk-abcdef1234567890",
|
||||||
|
"source": "user",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["engine_type"] == "chatgpt"
|
||||||
|
assert "key_hint" in data
|
||||||
|
assert data["key_hint"].startswith("sk-")
|
||||||
|
assert data["status"] == "active"
|
||||||
|
assert "api_key" not in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_key_no_plaintext_in_response(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
|
||||||
|
raw_key = "sk-super-secret-key-12345678"
|
||||||
|
response = await async_client.post(
|
||||||
|
"/api/v1/api-keys/",
|
||||||
|
json={
|
||||||
|
"engine_type": "deepseek",
|
||||||
|
"api_key": raw_key,
|
||||||
|
"source": "user",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
text = response.text
|
||||||
|
assert raw_key not in text
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAPIKeys:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_keys_success(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
|
||||||
|
key_manager.add_key("deepseek", "dsk-xyz9876543210", source=KeySource.ENV)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/api-keys/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert len(data["items"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_keys_filter_by_engine(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
|
||||||
|
key_manager.add_key("deepseek", "dsk-xyz9876543210", source=KeySource.ENV)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/api-keys/?engine_type=chatgpt")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["engine_type"] == "chatgpt"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_keys_masked(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/api-keys/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert "api_key" not in item
|
||||||
|
assert "encrypted_key" not in item
|
||||||
|
assert "key_hint" in item
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteAPIKey:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_key_success(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
config = key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
|
||||||
|
|
||||||
|
response = await async_client.delete(
|
||||||
|
f"/api/v1/api-keys/chatgpt/{config.key_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_key_not_found(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
|
||||||
|
response = await async_client.delete("/api/v1/api-keys/chatgpt/nonexistent")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyAPIKey:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_key_success(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/api/v1/api-keys/verify",
|
||||||
|
json={"engine_type": "chatgpt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["engine_type"] == "chatgpt"
|
||||||
|
assert data["status"] == "active"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_key_no_key_configured(self, async_client, key_manager):
|
||||||
|
from app.api.api_keys import set_key_manager
|
||||||
|
|
||||||
|
set_key_manager(key_manager)
|
||||||
|
|
||||||
|
response = await async_client.post(
|
||||||
|
"/api/v1/api-keys/verify",
|
||||||
|
json={"engine_type": "perplexity"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetEngines:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_engines_success(self, async_client):
|
||||||
|
response = await async_client.get("/api/v1/api-keys/engines")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "engines" in data
|
||||||
|
assert len(data["engines"]) > 0
|
||||||
|
|
||||||
|
engine = data["engines"][0]
|
||||||
|
assert "type" in engine
|
||||||
|
assert "cost_tier" in engine
|
||||||
|
assert "has_free_tier" in engine
|
||||||
|
assert "requires_own_key" in engine
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_engines_contains_deepseek(self, async_client):
|
||||||
|
response = await async_client.get("/api/v1/api-keys/engines")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
types = [e["type"] for e in data["engines"]]
|
||||||
|
assert "deepseek" in types
|
||||||
|
|
||||||
|
deepseek = next(e for e in data["engines"] if e["type"] == "deepseek")
|
||||||
|
assert deepseek["cost_tier"] == "free"
|
||||||
|
assert deepseek["has_free_tier"] is True
|
||||||
|
assert deepseek["requires_own_key"] is False
|
||||||
|
assert "input_price" in deepseek
|
||||||
|
assert "output_price" in deepseek
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsageSummary:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usage_summary(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/summary?period=month")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["period"] == "month"
|
||||||
|
assert data["total_queries"] == 2
|
||||||
|
assert data["total_cost"] > 0
|
||||||
|
assert "by_engine" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usage_summary_day_period(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/summary?period=day")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["period"] == "day"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsageQuota:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_quota(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/quota")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "used" in data
|
||||||
|
assert "limit" in data
|
||||||
|
assert "usage_percentage" in data
|
||||||
|
assert "status" in data
|
||||||
|
assert data["status"] in ("ok", "warning", "exceeded")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_quota_ok_status(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/quota")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["usage_percentage"] < 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsageByEngine:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usage_by_engine(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/by-engine")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "engines" in data
|
||||||
|
assert len(data["engines"]) > 0
|
||||||
|
|
||||||
|
engine = data["engines"][0]
|
||||||
|
assert "type" in engine
|
||||||
|
assert "queries" in engine
|
||||||
|
assert "cost" in engine
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usage_by_engine_contains_data(self, async_client, usage_tracker):
|
||||||
|
from app.api.usage import set_usage_tracker
|
||||||
|
|
||||||
|
set_usage_tracker(usage_tracker)
|
||||||
|
|
||||||
|
response = await async_client.get("/api/v1/usage/by-engine")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
types = [e["type"] for e in data["engines"]]
|
||||||
|
assert "deepseek" in types
|
||||||
|
assert "chatgpt" in types
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnauthorizedAccess:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_keys_unauthorized_returns_401(self, async_session):
|
||||||
|
async def override_get_db():
|
||||||
|
yield async_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
headers = {"Authorization": "Bearer invalid_token"}
|
||||||
|
response = await client.get("/api/v1/api-keys/", headers=headers)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_usage_unauthorized_returns_401(self, async_session):
|
||||||
|
async def override_get_db():
|
||||||
|
yield async_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
headers = {"Authorization": "Bearer invalid_token"}
|
||||||
|
response = await client.get("/api/v1/usage/summary", headers=headers)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
@ -17,8 +17,18 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle, Compass, Bell, TrendingDown, TrendingUp, Users, Globe } from "lucide-react";
|
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle, Compass, Bell, TrendingDown, TrendingUp, Users, Globe, Key, Eye, EyeOff, Shield, Zap, CircleDot, Info } from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { fetchWithAuth } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
interface PlanFeature {
|
interface PlanFeature {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -623,7 +633,7 @@ function AlertSettingsTab() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.alerts.getSettings(token, selectedBrandId);
|
const data = await api.alerts.getSettings(selectedBrandId, token);
|
||||||
setSettings((data as { items: AlertSettingItem[] }).items || []);
|
setSettings((data as { items: AlertSettingItem[] }).items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "加载告警设置失败");
|
setError(err instanceof Error ? err.message : "加载告警设置失败");
|
||||||
|
|
@ -644,7 +654,7 @@ function AlertSettingsTab() {
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
threshold: s.threshold ?? undefined,
|
threshold: s.threshold ?? undefined,
|
||||||
}));
|
}));
|
||||||
await api.alerts.updateSettings(token, updateData);
|
await api.alerts.updateSettings(updateData, token);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -860,6 +870,404 @@ function AlertSettingsTab() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TierType = "free" | "low" | "mid" | "high";
|
||||||
|
type KeyStatus = "configured" | "unconfigured" | "validating" | "invalid";
|
||||||
|
|
||||||
|
interface EngineProfile {
|
||||||
|
label: string;
|
||||||
|
group: "domestic" | "international";
|
||||||
|
tier: TierType;
|
||||||
|
inputPrice: number;
|
||||||
|
outputPrice: number;
|
||||||
|
hasFreeTier: boolean;
|
||||||
|
requiresOwnKey: boolean;
|
||||||
|
envVar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGINE_PROFILES: Record<string, EngineProfile> = {
|
||||||
|
deepseek: { label: "DeepSeek", group: "domestic", tier: "free", inputPrice: 0.25, outputPrice: 6.0, hasFreeTier: true, requiresOwnKey: false, envVar: "DEEPSEEK_API_KEY" },
|
||||||
|
qwen: { label: "通义千问", group: "domestic", tier: "free", inputPrice: 0.3, outputPrice: 0.6, hasFreeTier: true, requiresOwnKey: false, envVar: "DASHSCOPE_API_KEY" },
|
||||||
|
wenxin: { label: "文心一言", group: "domestic", tier: "free", inputPrice: 0.012, outputPrice: 0.012, hasFreeTier: true, requiresOwnKey: false, envVar: "BAIDU_QIANFAN_API_KEY" },
|
||||||
|
kimi: { label: "Kimi", group: "domestic", tier: "low", inputPrice: 12.0, outputPrice: 12.0, hasFreeTier: true, requiresOwnKey: false, envVar: "MOONSHOT_API_KEY" },
|
||||||
|
doubao: { label: "豆包", group: "domestic", tier: "low", inputPrice: 0.5, outputPrice: 0.9, hasFreeTier: true, requiresOwnKey: false, envVar: "DOUBAO_API_KEY" },
|
||||||
|
gemini: { label: "Google Gemini", group: "international", tier: "low", inputPrice: 0.5, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "GOOGLE_API_KEY" },
|
||||||
|
yuanbao: { label: "腾讯元宝", group: "domestic", tier: "mid", inputPrice: 0.8, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "HUNYUAN_API_KEY" },
|
||||||
|
chatgpt: { label: "ChatGPT", group: "international", tier: "high", inputPrice: 1.0, outputPrice: 4.0, hasFreeTier: false, requiresOwnKey: true, envVar: "OPENAI_API_KEY" },
|
||||||
|
perplexity: { label: "Perplexity", group: "international", tier: "high", inputPrice: 35.0, outputPrice: 35.0, hasFreeTier: false, requiresOwnKey: true, envVar: "PERPLEXITY_API_KEY" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_CONFIG: Record<TierType, { label: string; className: string }> = {
|
||||||
|
free: { label: "免费", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||||||
|
low: { label: "低成本", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" },
|
||||||
|
mid: { label: "中成本", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
|
||||||
|
high: { label: "高成本", className: "bg-red-100 text-red-700 hover:bg-red-100" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_STATUS_CONFIG: Record<KeyStatus, { label: string; className: string; icon: React.ElementType }> = {
|
||||||
|
configured: { label: "已配置", className: "bg-emerald-100 text-emerald-700", icon: CheckCircle },
|
||||||
|
unconfigured: { label: "未配置", className: "bg-gray-100 text-gray-500", icon: X },
|
||||||
|
validating: { label: "验证中", className: "bg-blue-100 text-blue-700", icon: Loader2 },
|
||||||
|
invalid: { label: "无效", className: "bg-red-100 text-red-700", icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ApiConfigTab() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const token = session?.accessToken;
|
||||||
|
|
||||||
|
const [keyStatuses, setKeyStatuses] = useState<Record<string, KeyStatus>>({});
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [selectedEngine, setSelectedEngine] = useState<string>("");
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [validating, setValidating] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadKeyStatuses();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function loadKeyStatuses() {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; status: string; key_hint: string; source: string }[] };
|
||||||
|
const statuses: Record<string, KeyStatus> = {};
|
||||||
|
for (const k of data.items) {
|
||||||
|
statuses[k.engine_type] = k.status === "active" ? "configured" : k.status === "invalid" ? "invalid" : "configured";
|
||||||
|
}
|
||||||
|
Object.keys(ENGINE_PROFILES).forEach((engine) => {
|
||||||
|
if (!(engine in statuses)) {
|
||||||
|
statuses[engine] = "unconfigured";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setKeyStatuses(statuses);
|
||||||
|
} catch {
|
||||||
|
const statuses: Record<string, KeyStatus> = {};
|
||||||
|
Object.keys(ENGINE_PROFILES).forEach((engine) => {
|
||||||
|
statuses[engine] = "unconfigured";
|
||||||
|
});
|
||||||
|
setKeyStatuses(statuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddKey() {
|
||||||
|
if (!token || !selectedEngine || !apiKeyInput.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fetchWithAuth("/api/v1/api-keys/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ engine_type: selectedEngine, api_key: apiKeyInput.trim(), source: "user" }),
|
||||||
|
}, token);
|
||||||
|
setKeyStatuses((prev) => ({ ...prev, [selectedEngine]: "configured" }));
|
||||||
|
setSuccess(`${ENGINE_PROFILES[selectedEngine].label} API Key 已保存`);
|
||||||
|
setDialogOpen(false);
|
||||||
|
setSelectedEngine("");
|
||||||
|
setApiKeyInput("");
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteKey(engine: string) {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const listData = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; key_hint: string; source: string; status: string }[] };
|
||||||
|
const keyItem = listData.items.find((k) => k.engine_type === engine);
|
||||||
|
if (!keyItem) {
|
||||||
|
setError("未找到该引擎的API Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchWithAuth(`/api/v1/api-keys/${engine}/${encodeURIComponent(keyItem.key_hint)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}, token);
|
||||||
|
setKeyStatuses((prev) => ({ ...prev, [engine]: "unconfigured" }));
|
||||||
|
setSuccess(`${ENGINE_PROFILES[engine].label} API Key 已删除`);
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleValidateKey(engine: string) {
|
||||||
|
if (!token) return;
|
||||||
|
setValidating(engine);
|
||||||
|
setKeyStatuses((prev) => ({ ...prev, [engine]: "validating" }));
|
||||||
|
try {
|
||||||
|
const data = await fetchWithAuth("/api/v1/api-keys/verify", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ engine_type: engine }),
|
||||||
|
}, token) as { engine_type: string; status: string };
|
||||||
|
const isValid = data.status === "active";
|
||||||
|
setKeyStatuses((prev) => ({ ...prev, [engine]: isValid ? "configured" : "invalid" }));
|
||||||
|
setSuccess(isValid ? `${ENGINE_PROFILES[engine].label} API Key 验证通过` : `${ENGINE_PROFILES[engine].label} API Key 无效`);
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch {
|
||||||
|
setKeyStatuses((prev) => ({ ...prev, [engine]: "invalid" }));
|
||||||
|
setError("验证请求失败");
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
|
} finally {
|
||||||
|
setValidating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog(engine?: string) {
|
||||||
|
setSelectedEngine(engine || "");
|
||||||
|
setApiKeyInput("");
|
||||||
|
setShowKey(false);
|
||||||
|
setError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domesticEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "domestic");
|
||||||
|
const internationalEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "international");
|
||||||
|
|
||||||
|
function renderEngineCard(engineKey: string, profile: EngineProfile) {
|
||||||
|
const status = keyStatuses[engineKey] || "unconfigured";
|
||||||
|
const statusConfig = KEY_STATUS_CONFIG[status];
|
||||||
|
const tierConfig = TIER_CONFIG[profile.tier];
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
const isValidating = validating === engineKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={engineKey} className="flex flex-col gap-3 rounded-lg border p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
|
||||||
|
<Key className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{profile.label}</span>
|
||||||
|
<Badge className={tierConfig.className}>{tierConfig.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
输入 ¥{profile.inputPrice}/百万Token · 输出 ¥{profile.outputPrice}/百万Token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={statusConfig.className}>
|
||||||
|
<StatusIcon className={`mr-1 h-3 w-3 ${isValidating ? "animate-spin" : ""}`} />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{profile.hasFreeTier && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3 text-emerald-500" />
|
||||||
|
含免费额度
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.requiresOwnKey && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="h-3 w-3 text-amber-500" />
|
||||||
|
需自带Key
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CircleDot className="h-3 w-3" />
|
||||||
|
{profile.envVar}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-auto">
|
||||||
|
{status === "unconfigured" ? (
|
||||||
|
<Button size="sm" onClick={() => openAddDialog(engineKey)}>
|
||||||
|
<Key className="mr-1 h-3 w-3" />
|
||||||
|
添加Key
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleValidateKey(engineKey)}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isValidating ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
验证
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDeleteKey(engineKey)}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Key className="h-5 w-5 text-blue-600" />
|
||||||
|
国内引擎
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">国内AI引擎API密钥配置</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openAddDialog()}>
|
||||||
|
<Key className="mr-1 h-3 w-3" />
|
||||||
|
添加Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{domesticEngines.map(([key, profile]) => renderEngineCard(key, profile))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-purple-600" />
|
||||||
|
国际引擎
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>国际AI引擎API密钥配置(需科学上网环境)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{internationalEngines.map(([key, profile]) => renderEngineCard(key, profile))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Info className="h-5 w-5 text-muted-foreground" />
|
||||||
|
成本说明
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>各引擎价格层级和免费额度信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{(["free", "low", "mid", "high"] as TierType[]).map((tier) => (
|
||||||
|
<div key={tier} className="rounded-lg border p-3">
|
||||||
|
<Badge className={TIER_CONFIG[tier].className}>{TIER_CONFIG[tier].label}</Badge>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{tier === "free" && "输入/输出价格极低,含免费额度"}
|
||||||
|
{tier === "low" && "输入/输出价格较低,部分含免费额度"}
|
||||||
|
{tier === "mid" && "中等价格,适合高频调用场景"}
|
||||||
|
{tier === "high" && "价格较高,需自带API Key"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
价格单位:元/百万Token。实际费用以各引擎官方定价为准。标注"需自带Key"的引擎不提供平台共享Key,需配置您自己的API密钥。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加API Key</DialogTitle>
|
||||||
|
<DialogDescription>为指定AI引擎配置API密钥</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>选择引擎</Label>
|
||||||
|
<Select value={selectedEngine} onValueChange={setSelectedEngine}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择引擎" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>国内引擎</SelectLabel>
|
||||||
|
{domesticEngines.map(([key, profile]) => (
|
||||||
|
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>国际引擎</SelectLabel>
|
||||||
|
{internationalEngines.map(([key, profile]) => (
|
||||||
|
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
placeholder="请输入API Key"
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3"
|
||||||
|
onClick={() => setShowKey(!showKey)}
|
||||||
|
>
|
||||||
|
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{selectedEngine && ENGINE_PROFILES[selectedEngine] && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
环境变量名: {ENGINE_PROFILES[selectedEngine].envVar}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddKey} disabled={saving || !selectedEngine || !apiKeyInput.trim()}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -873,6 +1281,7 @@ export default function SettingsPage() {
|
||||||
<TabsTrigger value="profile">个人资料</TabsTrigger>
|
<TabsTrigger value="profile">个人资料</TabsTrigger>
|
||||||
<TabsTrigger value="password">密码修改</TabsTrigger>
|
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||||||
<TabsTrigger value="subscription">订阅管理</TabsTrigger>
|
<TabsTrigger value="subscription">订阅管理</TabsTrigger>
|
||||||
|
<TabsTrigger value="api">API配置</TabsTrigger>
|
||||||
<TabsTrigger value="alerts">告警设置</TabsTrigger>
|
<TabsTrigger value="alerts">告警设置</TabsTrigger>
|
||||||
<TabsTrigger value="onboarding">引导</TabsTrigger>
|
<TabsTrigger value="onboarding">引导</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
@ -901,6 +1310,9 @@ export default function SettingsPage() {
|
||||||
<TabsContent value="subscription" className="mt-4">
|
<TabsContent value="subscription" className="mt-4">
|
||||||
<SubscriptionTab />
|
<SubscriptionTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="api" className="mt-4">
|
||||||
|
<ApiConfigTab />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="alerts" className="mt-4">
|
<TabsContent value="alerts" className="mt-4">
|
||||||
<AlertSettingsTab />
|
<AlertSettingsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { useApi } from "@/lib/hooks/use-api";
|
||||||
|
|
||||||
|
type TimeRange = "7d" | "30d" | "month";
|
||||||
|
|
||||||
|
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
|
||||||
|
"7d": "最近7天",
|
||||||
|
"30d": "最近30天",
|
||||||
|
month: "本月",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIE_COLORS = [
|
||||||
|
"#3b82f6",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#ec4899",
|
||||||
|
"#f59e0b",
|
||||||
|
"#10b981",
|
||||||
|
"#6366f1",
|
||||||
|
"#ef4444",
|
||||||
|
"#14b8a6",
|
||||||
|
"#f97316",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface QuotaData {
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
percentage: number;
|
||||||
|
status: "normal" | "warning" | "exceeded";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendItem {
|
||||||
|
date: string;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EngineUsageItem {
|
||||||
|
engine: string;
|
||||||
|
label: string;
|
||||||
|
queries: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageData {
|
||||||
|
quota: QuotaData;
|
||||||
|
trends: TrendItem[];
|
||||||
|
engineDistribution: { engine: string; label: string; cost: number }[];
|
||||||
|
engineUsage: EngineUsageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_USAGE_DATA: UsageData = {
|
||||||
|
quota: { used: 45.6, limit: 100, percentage: 45.6, status: "normal" },
|
||||||
|
trends: [
|
||||||
|
{ date: "05-19", cost: 5.2 },
|
||||||
|
{ date: "05-20", cost: 6.8 },
|
||||||
|
{ date: "05-21", cost: 4.5 },
|
||||||
|
{ date: "05-22", cost: 7.3 },
|
||||||
|
{ date: "05-23", cost: 8.1 },
|
||||||
|
{ date: "05-24", cost: 6.9 },
|
||||||
|
{ date: "05-25", cost: 7.0 },
|
||||||
|
],
|
||||||
|
engineDistribution: [
|
||||||
|
{ engine: "deepseek", label: "DeepSeek", cost: 15.2 },
|
||||||
|
{ engine: "chatgpt", label: "ChatGPT", cost: 12.8 },
|
||||||
|
{ engine: "qwen", label: "通义千问", cost: 8.5 },
|
||||||
|
{ engine: "gemini", label: "Google Gemini", cost: 5.3 },
|
||||||
|
{ engine: "kimi", label: "Kimi", cost: 3.8 },
|
||||||
|
],
|
||||||
|
engineUsage: [
|
||||||
|
{ engine: "deepseek", label: "DeepSeek", queries: 1250, inputTokens: 3200000, outputTokens: 1800000, cost: 15.2 },
|
||||||
|
{ engine: "chatgpt", label: "ChatGPT", queries: 890, inputTokens: 2100000, outputTokens: 1500000, cost: 12.8 },
|
||||||
|
{ engine: "qwen", label: "通义千问", queries: 720, inputTokens: 1800000, outputTokens: 900000, cost: 8.5 },
|
||||||
|
{ engine: "gemini", label: "Google Gemini", queries: 450, inputTokens: 1100000, outputTokens: 600000, cost: 5.3 },
|
||||||
|
{ engine: "kimi", label: "Kimi", queries: 320, inputTokens: 800000, outputTokens: 400000, cost: 3.8 },
|
||||||
|
{ engine: "wenxin", label: "文心一言", queries: 280, inputTokens: 700000, outputTokens: 350000, cost: 0.02 },
|
||||||
|
{ engine: "doubao", label: "豆包", queries: 210, inputTokens: 520000, outputTokens: 280000, cost: 0.5 },
|
||||||
|
{ engine: "yuanbao", label: "腾讯元宝", queries: 150, inputTokens: 380000, outputTokens: 200000, cost: 0.7 },
|
||||||
|
{ engine: "perplexity", label: "Perplexity", queries: 30, inputTokens: 75000, outputTokens: 40000, cost: 2.6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTokenCount(count: number): string {
|
||||||
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number): string {
|
||||||
|
return `¥${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircularProgress({ percentage, size = 120, strokeWidth = 10 }: { percentage: number; size?: number; strokeWidth?: number }) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
|
||||||
|
const color =
|
||||||
|
percentage >= 90 ? "#ef4444" : percentage >= 70 ? "#f59e0b" : "#10b981";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-muted/30"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center">
|
||||||
|
<span className="text-2xl font-bold" style={{ color }}>
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">已使用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuotaStatusBadge({ status }: { status: "normal" | "warning" | "exceeded" }) {
|
||||||
|
const config = {
|
||||||
|
normal: { label: "正常", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100", icon: CheckCircle },
|
||||||
|
warning: { label: "预警", className: "bg-amber-100 text-amber-700 hover:bg-amber-100", icon: AlertTriangle },
|
||||||
|
exceeded: { label: "超限", className: "bg-red-100 text-red-700 hover:bg-red-100", icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
const c = config[status];
|
||||||
|
const Icon = c.icon;
|
||||||
|
return (
|
||||||
|
<Badge className={c.className}>
|
||||||
|
<Icon className="mr-1 h-3 w-3" />
|
||||||
|
{c.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("7d");
|
||||||
|
|
||||||
|
const { data: usageData, isLoading, refresh } = useApi<UsageData>(
|
||||||
|
`/api/v1/usage/summary?period=${timeRange === "7d" ? "week" : timeRange === "30d" ? "month" : "month"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = usageData || MOCK_USAGE_DATA;
|
||||||
|
|
||||||
|
const totalCost = useMemo(() => {
|
||||||
|
return data.engineUsage.reduce((sum, item) => sum + item.cost, 0);
|
||||||
|
}, [data.engineUsage]);
|
||||||
|
|
||||||
|
const totalQueries = useMemo(() => {
|
||||||
|
return data.engineUsage.reduce((sum, item) => sum + item.queries, 0);
|
||||||
|
}, [data.engineUsage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">用量统计</h1>
|
||||||
|
<p className="text-muted-foreground">监控AI引擎调用成本和配额使用情况</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(["7d", "30d", "month"] as TimeRange[]).map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range}
|
||||||
|
variant={timeRange === range ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
>
|
||||||
|
{TIME_RANGE_LABELS[range]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-1 h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">正在加载用量数据...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-blue-100 p-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">本月已用金额</p>
|
||||||
|
<p className="text-2xl font-bold">{formatCurrency(data.quota.used)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-purple-100 p-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">配额上限</p>
|
||||||
|
<p className="text-2xl font-bold">{formatCurrency(data.quota.limit)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">配额状态</p>
|
||||||
|
<QuotaStatusBadge status={data.quota.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CircularProgress percentage={data.quota.percentage} size={80} strokeWidth={8} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">用量趋势</CardTitle>
|
||||||
|
<CardDescription>每日成本变化趋势</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data.trends}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
tickFormatter={(v) => `¥${v}`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [formatCurrency(value), "成本"]}
|
||||||
|
labelFormatter={(label) => `日期: ${label}`}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cost"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4, fill: "#3b82f6" }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">引擎用量分布</CardTitle>
|
||||||
|
<CardDescription>各引擎成本占比</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data.engineDistribution}
|
||||||
|
cx="50%"
|
||||||
|
cy="45%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="cost"
|
||||||
|
nameKey="label"
|
||||||
|
>
|
||||||
|
{data.engineDistribution.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [formatCurrency(value), "成本"]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span className="text-xs">{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">引擎用量明细</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
共 {totalQueries} 次查询,总成本 {formatCurrency(totalCost)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>引擎名称</TableHead>
|
||||||
|
<TableHead className="text-right">查询次数</TableHead>
|
||||||
|
<TableHead className="text-right">输入Token</TableHead>
|
||||||
|
<TableHead className="text-right">输出Token</TableHead>
|
||||||
|
<TableHead className="text-right">成本</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.engineUsage.map((item) => (
|
||||||
|
<TableRow key={item.engine}>
|
||||||
|
<TableCell className="font-medium">{item.label}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.queries.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatTokenCount(item.inputTokens)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatTokenCount(item.outputTokens)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{formatCurrency(item.cost)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue