first commit
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# SuperDream Configuration
|
||||||
|
|
||||||
|
# App
|
||||||
|
SD_APP_NAME=SuperDream
|
||||||
|
SD_DEBUG=false
|
||||||
|
SD_HOST=0.0.0.0
|
||||||
|
SD_PORT=18000
|
||||||
|
|
||||||
|
# Database (MySQL)
|
||||||
|
SD_DB_TYPE=mysql
|
||||||
|
SD_DB_HOST=localhost
|
||||||
|
SD_DB_PORT=3306
|
||||||
|
SD_DB_USER=root
|
||||||
|
SD_DB_PASSWORD=
|
||||||
|
SD_DB_NAME=superdream
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
SD_JWT_SECRET=superdream-secret-change-me
|
||||||
|
SD_JWT_ALGORITHM=HS256
|
||||||
|
SD_JWT_ACCESS_EXPIRE_MINUTES=30
|
||||||
|
SD_JWT_REFRESH_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
SD_DATA_DIR=./data
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Byte-compiled
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SuperDream is a full-stack web application with a Python/FastAPI backend and a React/TypeScript/Vite frontend. The backend serves a REST API and also serves the built frontend as a SPA in production.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # Install Python dependencies
|
||||||
|
python run.py # Start backend dev server (port 18000, auto-reload)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install # Install frontend dependencies
|
||||||
|
npm run dev # Start Vite dev server (port 3000, proxies /api to backend)
|
||||||
|
npm run build # TypeScript check + production build to frontend/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker-compose up # Build and run full stack (maps port 18000:8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (`/app`)
|
||||||
|
|
||||||
|
- **Entry point:** `run.py` → starts uvicorn with `app.main:app`
|
||||||
|
- **App factory:** `app/main.py` → `create_app()` builds the FastAPI instance
|
||||||
|
- **Config:** `app/config/settings.py` — Pydantic BaseSettings with `SD_` env prefix, loads from `.env`
|
||||||
|
- **API routes:** `app/api/v1/` — versioned REST endpoints (health, example CRUD)
|
||||||
|
- **Services:** `app/services/` — business logic layer (currently in-memory storage)
|
||||||
|
- **Data models:** `app/datamodels/schemas.py` — Pydantic request/response schemas
|
||||||
|
- **Models:** `app/models/` — placeholder for ORM models
|
||||||
|
|
||||||
|
### Frontend (`/frontend/src`)
|
||||||
|
|
||||||
|
- **React 19 + TypeScript (strict mode) + Vite**
|
||||||
|
- **Path alias:** `@` → `frontend/src/`
|
||||||
|
- **Components:** `components/` — Header, StatusBar
|
||||||
|
- **Services:** `services/` — HTTP client wrapper and API service modules
|
||||||
|
- **Hooks:** `hooks/` — `useFetch` for data fetching with loading/error states
|
||||||
|
- **Styling:** Tailwind CSS via CDN, dark theme by default with custom SuperDream color palette
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
|
||||||
|
In development, the frontend Vite dev server (port 3000) proxies `/api` and `/data/files` to the backend (port 18000). In production, FastAPI serves the built frontend from `frontend/dist/`, mounting `/assets` and falling back to `index.html` for SPA routing.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All backend settings use the `SD_` environment variable prefix (e.g., `SD_PORT`, `SD_DB_TYPE`). Copy `.env.example` to `.env` to configure. Database mode is either `"file"` or `"mysql"`.
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:20-bookworm AS frontend-build
|
||||||
|
WORKDIR /frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /backend
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY run.py .
|
||||||
|
|
||||||
|
# Copy frontend build output
|
||||||
|
COPY --from=frontend-build /frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
EXPOSE 18000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "18000"]
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
54
app/api/v1/auth.py
Normal file
54
app/api/v1/auth.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.datamodels.schemas import (
|
||||||
|
RegisterRequest, LoginRequest, TokenResponse, RefreshRequest,
|
||||||
|
ForgotPasswordRequest, ResetPasswordRequest, UserResponse, MessageResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse)
|
||||||
|
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await AuthService.register(db, body.email, body.password)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await AuthService.login(db, body.email, body.password)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await AuthService.refresh(db, body.refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=MessageResponse)
|
||||||
|
async def logout():
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password", response_model=MessageResponse)
|
||||||
|
async def forgot_password(body: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
token = await AuthService.forgot_password(db, body.email)
|
||||||
|
if token:
|
||||||
|
# MVP: print token to console; production: send email
|
||||||
|
print(f"[Password Reset] email={body.email} token={token}")
|
||||||
|
return {"message": "If the email exists, a reset link has been sent"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password", response_model=MessageResponse)
|
||||||
|
async def reset_password(body: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
await AuthService.reset_password(db, body.token, body.new_password)
|
||||||
|
return {"message": "Password reset successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def me(user: User = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
20
app/api/v1/example.py
Normal file
20
app/api/v1/example.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.services.example_service import ExampleService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
service = ExampleService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/examples")
|
||||||
|
async def list_examples():
|
||||||
|
return await service.list_all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/examples/{example_id}")
|
||||||
|
async def get_example(example_id: str):
|
||||||
|
return await service.get_by_id(example_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/examples")
|
||||||
|
async def create_example(data: dict):
|
||||||
|
return await service.create(data)
|
||||||
8
app/api/v1/health.py
Normal file
8
app/api/v1/health.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok", "service": "SuperDream"}
|
||||||
39
app/api/v1/keys.py
Normal file
39
app/api/v1/keys.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.services.key_service import KeyService
|
||||||
|
from app.datamodels.schemas import CreateKeyRequest, ApiKeyResponse, ApiKeyCreatedResponse, MessageResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/keys", tags=["keys"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[ApiKeyResponse])
|
||||||
|
async def list_keys(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await KeyService.list_keys(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiKeyCreatedResponse)
|
||||||
|
async def create_key(
|
||||||
|
body: CreateKeyRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await KeyService.create_key(db, user.id, body.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{key_id}", response_model=MessageResponse)
|
||||||
|
async def delete_key(
|
||||||
|
key_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await KeyService.delete_key(db, user.id, key_id)
|
||||||
|
return {"message": "Key deleted"}
|
||||||
16
app/api/v1/models.py
Normal file
16
app/api/v1/models.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.model_service import ModelService
|
||||||
|
from app.datamodels.schemas import ModelPricingResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/models", tags=["models"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[ModelPricingResponse])
|
||||||
|
async def list_models(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Public endpoint: list available models and pricing."""
|
||||||
|
return await ModelService.list_models(db)
|
||||||
64
app/api/v1/usage.py
Normal file
64
app/api/v1/usage.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.services.usage_service import UsageService
|
||||||
|
from app.datamodels.schemas import (
|
||||||
|
UsageSummaryResponse, DailyUsageResponse,
|
||||||
|
ModelUsageResponse, KeyUsageResponse, UsageLogResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/usage", tags=["usage"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=UsageSummaryResponse)
|
||||||
|
async def usage_summary(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await UsageService.summary(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily", response_model=List[DailyUsageResponse])
|
||||||
|
async def usage_daily(
|
||||||
|
start: Optional[date] = Query(None),
|
||||||
|
end: Optional[date] = Query(None),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await UsageService.daily(db, user.id, start, end)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-model", response_model=List[ModelUsageResponse])
|
||||||
|
async def usage_by_model(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await UsageService.by_model(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-key", response_model=List[KeyUsageResponse])
|
||||||
|
async def usage_by_key(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await UsageService.by_key(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=List[UsageLogResponse])
|
||||||
|
async def usage_logs(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
model: Optional[str] = Query(None),
|
||||||
|
key_id: Optional[str] = Query(None),
|
||||||
|
start: Optional[date] = Query(None),
|
||||||
|
end: Optional[date] = Query(None),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await UsageService.logs(db, user.id, page, size, model, key_id, start, end)
|
||||||
42
app/api/v1/wallet.py
Normal file
42
app/api/v1/wallet.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.services.wallet_service import WalletService
|
||||||
|
from app.datamodels.schemas import (
|
||||||
|
BalanceResponse, RedeemCodeRequest, TransactionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/wallet", tags=["wallet"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance", response_model=BalanceResponse)
|
||||||
|
async def get_balance(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
balance = await WalletService.get_balance(db, user.id)
|
||||||
|
return {"balance": balance}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/redeem", response_model=TransactionResponse)
|
||||||
|
async def redeem_code(
|
||||||
|
body: RedeemCodeRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await WalletService.redeem_code(db, user.id, body.code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions", response_model=List[TransactionResponse])
|
||||||
|
async def list_transactions(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await WalletService.list_transactions(db, user.id, page, size)
|
||||||
0
app/config/__init__.py
Normal file
0
app/config/__init__.py
Normal file
45
app/config/settings.py
Normal file
45
app/config/settings.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = "SuperDream"
|
||||||
|
debug: bool = False
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 18000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db_type: str = "mysql"
|
||||||
|
db_host: str = "10.11.0.43"
|
||||||
|
db_port: int = 3306
|
||||||
|
db_user: str = "root"
|
||||||
|
db_password: str = "for_develop_only"
|
||||||
|
db_name: str = "superdream"
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
data_dir: str = "./data"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt_secret: str = "superdream-secret-change-me"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_access_expire_minutes: int = 30
|
||||||
|
jwt_refresh_expire_days: int = 7
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
return (
|
||||||
|
f"mysql+aiomysql://{self.db_user}:{self.db_password}"
|
||||||
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_prefix = "SD_"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
21
app/core/database.py
Normal file
21
app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=settings.debug)
|
||||||
|
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
import app.models # noqa: F401 — ensure all models are registered
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
25
app/core/dependencies.py
Normal file
25
app/core/dependencies.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db # noqa: F401
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.core.exceptions import UnauthorizedError
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
security_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if not payload or payload.get("type") != "access":
|
||||||
|
raise UnauthorizedError("Invalid or expired token")
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user or user.status != "active":
|
||||||
|
raise UnauthorizedError("User not found or disabled")
|
||||||
|
return user
|
||||||
21
app/core/exceptions.py
Normal file
21
app/core/exceptions.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Resource not found"):
|
||||||
|
super().__init__(status_code=404, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Bad request"):
|
||||||
|
super().__init__(status_code=400, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Not authenticated"):
|
||||||
|
super().__init__(status_code=401, detail=detail, headers={"WWW-Authenticate": "Bearer"})
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Forbidden"):
|
||||||
|
super().__init__(status_code=403, detail=detail)
|
||||||
40
app/core/security.py
Normal file
40
app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_access_expire_minutes)
|
||||||
|
payload = {"sub": user_id, "exp": expire, "type": "access"}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: str) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(days=settings.jwt_refresh_expire_days)
|
||||||
|
payload = {"sub": user_id, "exp": expire, "type": "refresh"}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def create_reset_token(user_id: str) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(hours=1)
|
||||||
|
payload = {"sub": user_id, "exp": expire, "type": "reset"}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
0
app/datamodels/__init__.py
Normal file
0
app/datamodels/__init__.py
Normal file
179
app/datamodels/schemas.py
Normal file
179
app/datamodels/schemas.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
balance: Decimal
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── API Key ──
|
||||||
|
|
||||||
|
class CreateKeyRequest(BaseModel):
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
key_prefix: str
|
||||||
|
key_suffix: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreatedResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
key: str
|
||||||
|
key_prefix: str
|
||||||
|
key_suffix: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wallet ──
|
||||||
|
|
||||||
|
class RedeemCodeRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
amount: Decimal
|
||||||
|
balance_after: Decimal
|
||||||
|
reference_id: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceResponse(BaseModel):
|
||||||
|
balance: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
# ── Models ──
|
||||||
|
|
||||||
|
class ModelPricingResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
model_name: str
|
||||||
|
provider: str
|
||||||
|
input_price_per_1k: Decimal
|
||||||
|
output_price_per_1k: Decimal
|
||||||
|
status: str
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Example (legacy) ──
|
||||||
|
|
||||||
|
class ExampleCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ── Common ──
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Usage ──
|
||||||
|
|
||||||
|
class UsageSummaryResponse(BaseModel):
|
||||||
|
today_tokens: int
|
||||||
|
today_cost: Decimal
|
||||||
|
month_tokens: int
|
||||||
|
month_cost: Decimal
|
||||||
|
total_requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class DailyUsageResponse(BaseModel):
|
||||||
|
date: date
|
||||||
|
total_tokens: int
|
||||||
|
cost: Decimal
|
||||||
|
requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUsageResponse(BaseModel):
|
||||||
|
model: str
|
||||||
|
total_tokens: int
|
||||||
|
cost: Decimal
|
||||||
|
requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class KeyUsageResponse(BaseModel):
|
||||||
|
key_id: str
|
||||||
|
key_name: str
|
||||||
|
key_prefix: str
|
||||||
|
key_suffix: str
|
||||||
|
total_tokens: int
|
||||||
|
cost: Decimal
|
||||||
|
requests: int
|
||||||
|
|
||||||
|
|
||||||
|
class UsageLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key_id: str
|
||||||
|
model: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
cost: Decimal
|
||||||
|
request_time: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
63
app/main.py
Normal file
63
app/main.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from app.api.v1 import health, example, auth, keys, models as models_api, wallet, usage
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.core.database import init_db
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="SuperDream",
|
||||||
|
description="SuperDream API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register API routers
|
||||||
|
app.include_router(health.router, prefix="/api/v1", tags=["health"])
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
app.include_router(keys.router, prefix="/api/v1")
|
||||||
|
app.include_router(models_api.router, prefix="/api/v1")
|
||||||
|
app.include_router(wallet.router, prefix="/api/v1")
|
||||||
|
app.include_router(usage.router, prefix="/api/v1")
|
||||||
|
app.include_router(example.router, prefix="/api/v1", tags=["example"])
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
if os.path.exists(static_dir):
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
# Serve frontend dist in production
|
||||||
|
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
||||||
|
if os.path.exists(frontend_dist):
|
||||||
|
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="frontend-assets")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_frontend(full_path: str):
|
||||||
|
file_path = os.path.join(frontend_dist, full_path)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(os.path.join(frontend_dist, "index.html"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
87
app/models/__init__.py
Normal file
87
app/models/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Integer, BigInteger, DateTime, Numeric, Enum, ForeignKey, Index, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
balance: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0"))
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active") # active / disabled
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
api_keys: Mapped[List[ApiKey]] = relationship(back_populates="user")
|
||||||
|
transactions: Mapped[List[Transaction]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # SHA256
|
||||||
|
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
key_suffix: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active") # active / revoked
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="api_keys")
|
||||||
|
|
||||||
|
|
||||||
|
class UsageLog(Base):
|
||||||
|
__tablename__ = "usage_logs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_usage_user_time", "user_id", "request_time"),
|
||||||
|
Index("ix_usage_model", "user_id", "model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
key_id: Mapped[str] = mapped_column(String(36), ForeignKey("api_keys.id"), nullable=False)
|
||||||
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
completion_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
cost: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0"))
|
||||||
|
request_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
response_time: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="success") # success / error
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False) # topup / consume / refund
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
|
||||||
|
balance_after: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
|
||||||
|
reference_id: Mapped[str] = mapped_column(String(100), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="transactions")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPricing(Base):
|
||||||
|
__tablename__ = "models_pricing"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
model_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
input_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
|
||||||
|
output_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="available") # available / offline
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
91
app/services/auth_service.py
Normal file
91
app/services/auth_service.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||||
|
from app.core.exceptions import BadRequestError, UnauthorizedError
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password(password: str) -> None:
|
||||||
|
if len(password) < 6:
|
||||||
|
raise BadRequestError("密码至少 6 位")
|
||||||
|
has_letter = bool(re.search(r"[a-zA-Z]", password))
|
||||||
|
has_digit = bool(re.search(r"[0-9]", password))
|
||||||
|
if not (has_letter and has_digit):
|
||||||
|
raise BadRequestError("密码需同时包含字母和数字")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def register(db: AsyncSession, email: str, password: str) -> User:
|
||||||
|
_validate_password(password)
|
||||||
|
|
||||||
|
existing = await db.execute(select(User).where(User.email == email))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise BadRequestError("Email already registered")
|
||||||
|
|
||||||
|
user = User(email=email, password_hash=hash_password(password))
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def login(db: AsyncSession, email: str, password: str) -> dict:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not verify_password(password, user.password_hash):
|
||||||
|
raise UnauthorizedError("Invalid email or password")
|
||||||
|
if user.status != "active":
|
||||||
|
raise UnauthorizedError("Account is disabled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": create_access_token(user.id),
|
||||||
|
"refresh_token": create_refresh_token(user.id),
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def refresh(db: AsyncSession, refresh_token: str) -> dict:
|
||||||
|
payload = decode_token(refresh_token)
|
||||||
|
if not payload or payload.get("type") != "refresh":
|
||||||
|
raise UnauthorizedError("Invalid refresh token")
|
||||||
|
|
||||||
|
user = await db.get(User, payload["sub"])
|
||||||
|
if not user or user.status != "active":
|
||||||
|
raise UnauthorizedError("User not found or disabled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": create_access_token(user.id),
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def forgot_password(db: AsyncSession, email: str) -> str:
|
||||||
|
"""Generate a password reset token. MVP: returns the token directly (production: send via email)."""
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
# Don't reveal whether email exists
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from app.core.security import create_reset_token
|
||||||
|
return create_reset_token(user.id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def reset_password(db: AsyncSession, token: str, new_password: str) -> None:
|
||||||
|
_validate_password(new_password)
|
||||||
|
|
||||||
|
payload = decode_token(token)
|
||||||
|
if not payload or payload.get("type") != "reset":
|
||||||
|
raise BadRequestError("Invalid or expired reset token")
|
||||||
|
|
||||||
|
user = await db.get(User, payload["sub"])
|
||||||
|
if not user:
|
||||||
|
raise BadRequestError("User not found")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
await db.commit()
|
||||||
24
app/services/example_service.py
Normal file
24
app/services/example_service.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleService:
|
||||||
|
def __init__(self):
|
||||||
|
self._store: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
async def list_all(self) -> List[dict]:
|
||||||
|
return list(self._store.values())
|
||||||
|
|
||||||
|
async def get_by_id(self, example_id: str) -> Optional[dict]:
|
||||||
|
return self._store.get(example_id)
|
||||||
|
|
||||||
|
async def create(self, data: dict) -> dict:
|
||||||
|
example_id = str(uuid.uuid4())
|
||||||
|
item = {
|
||||||
|
"id": example_id,
|
||||||
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
|
**data,
|
||||||
|
}
|
||||||
|
self._store[example_id] = item
|
||||||
|
return item
|
||||||
84
app/services/key_service.py
Normal file
84
app/services/key_service.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import ApiKey
|
||||||
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
|
|
||||||
|
MAX_KEYS_PER_USER = 5
|
||||||
|
KEY_PREFIX = "sk-sd-"
|
||||||
|
|
||||||
|
|
||||||
|
class KeyService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_key() -> str:
|
||||||
|
return KEY_PREFIX + secrets.token_urlsafe(36)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_key(raw_key: str) -> str:
|
||||||
|
return hashlib.sha256(raw_key.encode()).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def list_keys(db: AsyncSession, user_id: str) -> list:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiKey)
|
||||||
|
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
|
||||||
|
.order_by(ApiKey.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_key(db: AsyncSession, user_id: str, name: str = "") -> dict:
|
||||||
|
# Check limit
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(ApiKey)
|
||||||
|
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
|
||||||
|
)
|
||||||
|
count = count_result.scalar()
|
||||||
|
if count >= MAX_KEYS_PER_USER:
|
||||||
|
raise BadRequestError(f"最多创建 {MAX_KEYS_PER_USER} 个 Key")
|
||||||
|
|
||||||
|
raw_key = KeyService._generate_key()
|
||||||
|
key_hash = KeyService._hash_key(raw_key)
|
||||||
|
|
||||||
|
# prefix/suffix for masked display (after "sk-sd-")
|
||||||
|
body = raw_key[len(KEY_PREFIX):]
|
||||||
|
key_prefix = body[:4]
|
||||||
|
key_suffix = body[-4:]
|
||||||
|
|
||||||
|
api_key = ApiKey(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
key_suffix=key_suffix,
|
||||||
|
)
|
||||||
|
db.add(api_key)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(api_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": api_key.id,
|
||||||
|
"name": api_key.name,
|
||||||
|
"key": raw_key, # only returned once
|
||||||
|
"key_prefix": key_prefix,
|
||||||
|
"key_suffix": key_suffix,
|
||||||
|
"created_at": api_key.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_key(db: AsyncSession, user_id: str, key_id: str) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
|
||||||
|
)
|
||||||
|
api_key = result.scalar_one_or_none()
|
||||||
|
if not api_key:
|
||||||
|
raise NotFoundError("Key not found")
|
||||||
|
|
||||||
|
api_key.status = "revoked"
|
||||||
|
await db.commit()
|
||||||
16
app/services/model_service.py
Normal file
16
app/services/model_service.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import ModelPricing
|
||||||
|
|
||||||
|
|
||||||
|
class ModelService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def list_models(db: AsyncSession) -> list:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ModelPricing)
|
||||||
|
.where(ModelPricing.status == "available")
|
||||||
|
.order_by(ModelPricing.provider, ModelPricing.model_name)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
139
app/services/usage_service.py
Normal file
139
app/services/usage_service.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, cast, Date
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import UsageLog, ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
class UsageService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def summary(db: AsyncSession, user_id: str) -> dict:
|
||||||
|
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||||
|
month_start = today_start.replace(day=1)
|
||||||
|
|
||||||
|
# Today
|
||||||
|
today_row = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(func.sum(UsageLog.total_tokens), 0),
|
||||||
|
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
|
||||||
|
).where(UsageLog.user_id == user_id, UsageLog.request_time >= today_start)
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
# This month
|
||||||
|
month_row = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(func.sum(UsageLog.total_tokens), 0),
|
||||||
|
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
|
||||||
|
func.count(),
|
||||||
|
).where(UsageLog.user_id == user_id, UsageLog.request_time >= month_start)
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"today_tokens": int(today_row[0]),
|
||||||
|
"today_cost": today_row[1],
|
||||||
|
"month_tokens": int(month_row[0]),
|
||||||
|
"month_cost": month_row[1],
|
||||||
|
"total_requests": int(month_row[2]),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def daily(
|
||||||
|
db: AsyncSession, user_id: str,
|
||||||
|
start: Optional[date] = None, end: Optional[date] = None,
|
||||||
|
) -> list:
|
||||||
|
if not start:
|
||||||
|
start = date.today() - timedelta(days=29)
|
||||||
|
if not end:
|
||||||
|
end = date.today()
|
||||||
|
|
||||||
|
day_col = cast(UsageLog.request_time, Date).label("day")
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
day_col,
|
||||||
|
func.coalesce(func.sum(UsageLog.total_tokens), 0),
|
||||||
|
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
UsageLog.user_id == user_id,
|
||||||
|
cast(UsageLog.request_time, Date) >= start,
|
||||||
|
cast(UsageLog.request_time, Date) <= end,
|
||||||
|
)
|
||||||
|
.group_by(day_col)
|
||||||
|
.order_by(day_col)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"date": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])}
|
||||||
|
for row in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_model(db: AsyncSession, user_id: str) -> list:
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
UsageLog.model,
|
||||||
|
func.coalesce(func.sum(UsageLog.total_tokens), 0),
|
||||||
|
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.where(UsageLog.user_id == user_id)
|
||||||
|
.group_by(UsageLog.model)
|
||||||
|
.order_by(func.sum(UsageLog.cost).desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"model": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])}
|
||||||
|
for row in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def by_key(db: AsyncSession, user_id: str) -> list:
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
UsageLog.key_id,
|
||||||
|
ApiKey.name,
|
||||||
|
ApiKey.key_prefix,
|
||||||
|
ApiKey.key_suffix,
|
||||||
|
func.coalesce(func.sum(UsageLog.total_tokens), 0),
|
||||||
|
func.coalesce(func.sum(UsageLog.cost), Decimal("0")),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.join(ApiKey, UsageLog.key_id == ApiKey.id)
|
||||||
|
.where(UsageLog.user_id == user_id)
|
||||||
|
.group_by(UsageLog.key_id, ApiKey.name, ApiKey.key_prefix, ApiKey.key_suffix)
|
||||||
|
.order_by(func.sum(UsageLog.cost).desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key_id": row[0], "key_name": row[1] or "",
|
||||||
|
"key_prefix": row[2], "key_suffix": row[3],
|
||||||
|
"total_tokens": int(row[4]), "cost": row[5], "requests": int(row[6]),
|
||||||
|
}
|
||||||
|
for row in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def logs(
|
||||||
|
db: AsyncSession, user_id: str,
|
||||||
|
page: int = 1, size: int = 20,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
key_id: Optional[str] = None,
|
||||||
|
start: Optional[date] = None,
|
||||||
|
end: Optional[date] = None,
|
||||||
|
) -> list:
|
||||||
|
q = select(UsageLog).where(UsageLog.user_id == user_id)
|
||||||
|
if model:
|
||||||
|
q = q.where(UsageLog.model == model)
|
||||||
|
if key_id:
|
||||||
|
q = q.where(UsageLog.key_id == key_id)
|
||||||
|
if start:
|
||||||
|
q = q.where(UsageLog.request_time >= datetime.combine(start, datetime.min.time()))
|
||||||
|
if end:
|
||||||
|
q = q.where(UsageLog.request_time < datetime.combine(end + timedelta(days=1), datetime.min.time()))
|
||||||
|
|
||||||
|
q = q.order_by(UsageLog.request_time.desc()).offset((page - 1) * size).limit(size)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalars().all()
|
||||||
60
app/services/wallet_service.py
Normal file
60
app/services/wallet_service.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import User, Transaction
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
|
||||||
|
# MVP: hardcoded redeem codes → amount mapping
|
||||||
|
REDEEM_CODES = {
|
||||||
|
"SUPERDREAM10": Decimal("10"),
|
||||||
|
"SUPERDREAM50": Decimal("50"),
|
||||||
|
"SUPERDREAM100": Decimal("100"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WalletService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_balance(db: AsyncSession, user_id: str) -> Decimal:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
return user.balance
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def redeem_code(db: AsyncSession, user_id: str, code: str) -> Transaction:
|
||||||
|
amount = REDEEM_CODES.get(code.upper())
|
||||||
|
if not amount:
|
||||||
|
raise BadRequestError("无效的兑换码")
|
||||||
|
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
user.balance += amount
|
||||||
|
new_balance = user.balance
|
||||||
|
|
||||||
|
txn = Transaction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=user_id,
|
||||||
|
type="topup",
|
||||||
|
amount=amount,
|
||||||
|
balance_after=new_balance,
|
||||||
|
reference_id=f"redeem:{code.upper()}",
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(txn)
|
||||||
|
return txn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def list_transactions(
|
||||||
|
db: AsyncSession, user_id: str, page: int = 1, size: int = 20
|
||||||
|
) -> list:
|
||||||
|
offset = (page - 1) * size
|
||||||
|
result = await db.execute(
|
||||||
|
select(Transaction)
|
||||||
|
.where(Transaction.user_id == user_id)
|
||||||
|
.order_by(Transaction.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(size)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
container_name: superdream
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "18000:18000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/backend/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
56
frontend/index.html
Normal file
56
frontend/index.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SuperDream</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
superdream: {
|
||||||
|
primary: "#8B5CF6",
|
||||||
|
bg: "var(--sd-bg)",
|
||||||
|
panel: "var(--sd-panel)",
|
||||||
|
accent: "var(--sd-accent)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sd-bg: #f8f9fa;
|
||||||
|
--sd-panel: #ffffff;
|
||||||
|
--sd-accent: #7c3aed;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--sd-bg: #0f0f23;
|
||||||
|
--sd-panel: #1a1a2e;
|
||||||
|
--sd-accent: #a78bfa;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--sd-bg);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// Apply saved theme before first paint to prevent flash
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem("sd_theme");
|
||||||
|
if (t === "light") document.documentElement.classList.remove("dark");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2285
frontend/package-lock.json
generated
Normal file
2285
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "superdream-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/App.tsx
Normal file
44
frontend/src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import Header from "./components/Header";
|
||||||
|
import StatusBar from "./components/StatusBar";
|
||||||
|
import AuthGuard from "./components/AuthGuard";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import Pricing from "./pages/Pricing";
|
||||||
|
import Docs from "./pages/Docs";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Register from "./pages/Register";
|
||||||
|
import ForgotPassword from "./pages/ForgotPassword";
|
||||||
|
import ResetPassword from "./pages/ResetPassword";
|
||||||
|
import DashboardLayout from "./pages/dashboard/Layout";
|
||||||
|
import Overview from "./pages/dashboard/Overview";
|
||||||
|
import Keys from "./pages/dashboard/Keys";
|
||||||
|
import Wallet from "./pages/dashboard/Wallet";
|
||||||
|
import Usage from "./pages/dashboard/Usage";
|
||||||
|
import Logs from "./pages/dashboard/Logs";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-superdream-bg text-gray-800 dark:text-gray-200">
|
||||||
|
<Header title="SuperDream" />
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
|
<Route path="/docs" element={<Docs />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route path="/dashboard" element={<AuthGuard><DashboardLayout /></AuthGuard>}>
|
||||||
|
<Route index element={<Overview />} />
|
||||||
|
<Route path="keys" element={<Keys />} />
|
||||||
|
<Route path="wallet" element={<Wallet />} />
|
||||||
|
<Route path="usage" element={<Usage />} />
|
||||||
|
<Route path="logs" element={<Logs />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/AuthGuard.tsx
Normal file
9
frontend/src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { authStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
if (!authStore.isLoggedIn()) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
89
frontend/src/components/Header.tsx
Normal file
89
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { authStore } from "../stores/authStore";
|
||||||
|
import { themeStore } from "../stores/themeStore";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ title }: HeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loggedIn, setLoggedIn] = useState(authStore.isLoggedIn());
|
||||||
|
const [user, setUser] = useState(authStore.getUser());
|
||||||
|
const [dark, setDark] = useState(themeStore.isDark());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStore.isLoggedIn() && !authStore.getUser()) {
|
||||||
|
authStore.fetchUser();
|
||||||
|
}
|
||||||
|
const unsub1 = authStore.subscribe(() => {
|
||||||
|
setLoggedIn(authStore.isLoggedIn());
|
||||||
|
setUser(authStore.getUser());
|
||||||
|
});
|
||||||
|
const unsub2 = themeStore.subscribe(() => setDark(themeStore.isDark()));
|
||||||
|
return () => { unsub1(); unsub2(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.clearTokens();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between px-6 py-4 bg-superdream-panel border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<Link to="/" className="text-xl font-bold text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
<nav className="flex gap-4 text-sm text-gray-600 dark:text-gray-400 items-center">
|
||||||
|
<Link to="/pricing" className="hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
价格
|
||||||
|
</Link>
|
||||||
|
<Link to="/docs" className="hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
文档
|
||||||
|
</Link>
|
||||||
|
{loggedIn ? (
|
||||||
|
<>
|
||||||
|
<Link to="/dashboard" className="hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
控制台
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{user?.email}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="hover:text-gray-900 dark:hover:text-white transition"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/login" className="hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
登录
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="px-3 py-1 rounded-lg bg-superdream-primary text-white hover:bg-purple-600 transition"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => themeStore.toggle()}
|
||||||
|
className="ml-1 p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition text-gray-600 dark:text-gray-400"
|
||||||
|
title={dark ? "切换亮色模式" : "切换暗色模式"}
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zm5-5a.75.75 0 01.75.75h1.5a.75.75 0 010 1.5h-1.5A.75.75 0 0115 10zM2.75 10a.75.75 0 010-1.5h1.5a.75.75 0 010 1.5h-1.5zm12.542-4.793a.75.75 0 010 1.06l-1.06 1.06a.75.75 0 11-1.06-1.06l1.06-1.06a.75.75 0 011.06 0zM6.828 14.232a.75.75 0 010 1.061l-1.06 1.06a.75.75 0 01-1.061-1.06l1.06-1.06a.75.75 0 011.06 0zm8.485 1.06a.75.75 0 01-1.06 0l-1.06-1.06a.75.75 0 111.06-1.06l1.06 1.06a.75.75 0 010 1.06zM6.828 5.768a.75.75 0 01-1.061 0l-1.06-1.06a.75.75 0 011.06-1.061l1.06 1.06a.75.75 0 010 1.06zM10 7a3 3 0 100 6 3 3 0 000-6z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.655.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/StatusBar.tsx
Normal file
34
frontend/src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useFetch } from "../hooks/useFetch";
|
||||||
|
import { httpClient } from "../services/httpClient";
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBar() {
|
||||||
|
const { data, loading, error } = useFetch<HealthStatus>(() =>
|
||||||
|
httpClient.get("/health")
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-6 py-2 bg-superdream-panel border-t border-gray-200 dark:border-gray-800 text-xs text-gray-500">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
loading
|
||||||
|
? "bg-yellow-400"
|
||||||
|
: error
|
||||||
|
? "bg-red-500"
|
||||||
|
: "bg-green-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{loading
|
||||||
|
? "Connecting..."
|
||||||
|
: error
|
||||||
|
? `Error: ${error}`
|
||||||
|
: `${data?.service} - ${data?.status}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useFetch.ts
Normal file
27
frontend/src/hooks/useFetch.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useFetch<T>(fetcher: () => Promise<T>) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetcher()
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setData(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading, error };
|
||||||
|
}
|
||||||
12
frontend/src/index.tsx
Normal file
12
frontend/src/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
160
frontend/src/pages/Docs.tsx
Normal file
160
frontend/src/pages/Docs.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const BASE_URL_EXAMPLE = "https://api.superdream.example.com";
|
||||||
|
|
||||||
|
export default function Docs() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">API 文档</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-10">
|
||||||
|
SuperDream 兼容 OpenAI API 格式,你可以使用任何支持 OpenAI 的 SDK 直接接入。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick start */}
|
||||||
|
<Section title="快速开始">
|
||||||
|
<ol className="list-decimal list-inside text-sm text-gray-700 dark:text-gray-300 space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link to="/register" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
注册账号
|
||||||
|
</Link>
|
||||||
|
,在控制台获取 API Key
|
||||||
|
</li>
|
||||||
|
<li>在钱包页面充值余额</li>
|
||||||
|
<li>将下方示例中的 API Key 替换为你自己的 Key 即可调用</li>
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<Section title="Base URL">
|
||||||
|
<Code>{`${BASE_URL_EXAMPLE}/v1`}</Code>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
将你的 OpenAI SDK 的 base_url 指向此地址即可。
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
|
<Section title="认证方式">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
在请求 Header 中携带 API Key:
|
||||||
|
</p>
|
||||||
|
<Code>Authorization: Bearer sk-sd-your-api-key</Code>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Python example */}
|
||||||
|
<Section title="Python 示例">
|
||||||
|
<CodeBlock>{`from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key="sk-sd-your-api-key",
|
||||||
|
base_url="${BASE_URL_EXAMPLE}/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": "Hello!"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.choices[0].message.content)`}</CodeBlock>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* curl example */}
|
||||||
|
<Section title="cURL 示例">
|
||||||
|
<CodeBlock>{`curl ${BASE_URL_EXAMPLE}/v1/chat/completions \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer sk-sd-your-api-key" \\
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello!"}
|
||||||
|
]
|
||||||
|
}'`}</CodeBlock>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Node.js example */}
|
||||||
|
<Section title="Node.js 示例">
|
||||||
|
<CodeBlock>{`import OpenAI from "openai";
|
||||||
|
|
||||||
|
const client = new OpenAI({
|
||||||
|
apiKey: "sk-sd-your-api-key",
|
||||||
|
baseURL: "${BASE_URL_EXAMPLE}/v1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Hello!" }
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(response.choices[0].message.content);`}</CodeBlock>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Supported models */}
|
||||||
|
<Section title="支持的模型">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
在请求的 <code className="text-superdream-accent">model</code> 字段中指定模型名称即可切换模型。
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
查看完整的模型列表和定价:
|
||||||
|
<Link to="/pricing" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition ml-1">
|
||||||
|
定价页面
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<Section title="常见问题">
|
||||||
|
<Faq q="和直接用 OpenAI API 有什么区别?">
|
||||||
|
SuperDream 聚合了多家模型供应商,你可以通过一个统一接口调用不同厂商的模型,无需分别申请账号和管理密钥。
|
||||||
|
</Faq>
|
||||||
|
<Faq q="计费方式是什么?">
|
||||||
|
按实际消耗的 token 数量计费,输入和输出分别计价。费用在每次调用完成后实时从余额扣除。
|
||||||
|
</Faq>
|
||||||
|
<Faq q="API Key 丢失怎么办?">
|
||||||
|
Key 创建后仅显示一次,丢失后无法找回。你可以在控制台删除旧 Key 并创建新的。
|
||||||
|
</Faq>
|
||||||
|
<Faq q="支持流式输出吗?">
|
||||||
|
支持。在请求中设置 <code className="text-superdream-accent">stream: true</code> 即可使用 SSE 流式输出。
|
||||||
|
</Faq>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<code className="block bg-superdream-panel border border-gray-200 dark:border-gray-800 rounded-lg px-4 py-2 text-sm text-superdream-accent font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<pre className="bg-superdream-panel border border-gray-200 dark:border-gray-800 rounded-lg px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono overflow-x-auto leading-relaxed whitespace-pre">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Faq({ q, children }: { q: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-1">{q}</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/pages/ForgotPassword.tsx
Normal file
86
frontend/src/pages/ForgotPassword.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.forgotPassword(email);
|
||||||
|
setSent(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "请求失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-md bg-superdream-panel rounded-xl p-8 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">邮件已发送</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
如果该邮箱已注册,你将收到一封密码重置邮件。请检查收件箱。
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">忘记密码</h2>
|
||||||
|
<p className="text-gray-500 text-sm mb-6 text-center">
|
||||||
|
输入注册邮箱,我们将发送密码重置链接
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "发送中..." : "发送重置链接"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-gray-500">
|
||||||
|
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/pages/Home.tsx
Normal file
108
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "统一接口",
|
||||||
|
desc: "兼容 OpenAI API 格式,一个接口调用所有主流大模型,无需逐家对接。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "按量计费",
|
||||||
|
desc: "按实际 token 用量付费,无月费无绑定,用多少付多少。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "多模型聚合",
|
||||||
|
desc: "GPT-4o、Claude、Gemini、DeepSeek 等主流模型一站接入,自由切换。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "实时监控",
|
||||||
|
desc: "每笔调用实时记录,用量、费用一目了然,支持多维度统计分析。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const models = [
|
||||||
|
{ name: "GPT-4o", provider: "OpenAI" },
|
||||||
|
{ name: "GPT-4o-mini", provider: "OpenAI" },
|
||||||
|
{ name: "Claude Sonnet 4", provider: "Anthropic" },
|
||||||
|
{ name: "Claude Haiku 3.5", provider: "Anthropic" },
|
||||||
|
{ name: "Gemini 2.5 Pro", provider: "Google" },
|
||||||
|
{ name: "DeepSeek V3", provider: "DeepSeek" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="flex flex-col items-center justify-center text-center px-6 py-20">
|
||||||
|
<h1 className="text-5xl font-extrabold text-gray-900 dark:text-white mb-4 leading-tight">
|
||||||
|
大模型 API<span className="text-superdream-accent">,触手可及</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mb-8">
|
||||||
|
SuperDream 聚合主流大模型 API,提供统一的 OpenAI 兼容接口。按量计费,即充即用,几行代码即可接入。
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="px-6 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition text-sm"
|
||||||
|
>
|
||||||
|
免费注册
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className="px-6 py-3 rounded-lg border border-gray-400 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold hover:border-superdream-accent hover:text-gray-900 dark:hover:text-white transition text-sm"
|
||||||
|
>
|
||||||
|
查看价格
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="px-6 py-16 max-w-5xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center mb-10">为什么选择 SuperDream</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="bg-superdream-panel rounded-xl p-6 border border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-superdream-accent mb-2">{f.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Supported models */}
|
||||||
|
<section className="px-6 py-16 max-w-5xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center mb-10">支持的模型</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{models.map((m) => (
|
||||||
|
<div key={m.name} className="bg-superdream-panel rounded-xl p-4 border border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">{m.name}</span>
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-200 dark:bg-gray-800 px-2 py-0.5 rounded">{m.provider}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-gray-500 text-sm mt-4">
|
||||||
|
更多模型持续接入中...
|
||||||
|
<Link to="/pricing" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
查看完整定价
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="px-6 py-16 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">立即开始</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">注册账号,获取 API Key,几分钟完成接入</p>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="inline-block px-8 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition"
|
||||||
|
>
|
||||||
|
免费注册
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="px-6 py-6 border-t border-gray-200 dark:border-gray-800 text-center text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
SuperDream © {new Date().getFullYear()}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/pages/Login.tsx
Normal file
86
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
import { authStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await authService.login(email, password);
|
||||||
|
authStore.setTokens(res.access_token, res.refresh_token);
|
||||||
|
await authStore.fetchUser();
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "登录失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">登录</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-gray-500 space-x-4">
|
||||||
|
<Link to="/register" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
注册账号
|
||||||
|
</Link>
|
||||||
|
<Link to="/forgot-password" className="hover:text-gray-900 dark:hover:text-white transition">
|
||||||
|
忘记密码
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/pages/Pricing.tsx
Normal file
98
frontend/src/pages/Pricing.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { httpClient } from "../services/httpClient";
|
||||||
|
|
||||||
|
interface ModelPrice {
|
||||||
|
id: number;
|
||||||
|
model_name: string;
|
||||||
|
provider: string;
|
||||||
|
input_price_per_1k: string;
|
||||||
|
output_price_per_1k: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pricing() {
|
||||||
|
const [models, setModels] = useState<ModelPrice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
httpClient
|
||||||
|
.get<ModelPrice[]>("/models")
|
||||||
|
.then(setModels)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group by provider
|
||||||
|
const grouped: Record<string, ModelPrice[]> = {};
|
||||||
|
models.forEach((m) => {
|
||||||
|
if (!grouped[m.provider]) grouped[m.provider] = [];
|
||||||
|
grouped[m.provider].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<section className="max-w-4xl mx-auto px-6 py-16">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white text-center mb-2">模型定价</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center mb-10">
|
||||||
|
按实际 token 用量计费,价格透明,无隐藏费用
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-center py-12">加载中...</p>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 mb-2">暂无定价数据</p>
|
||||||
|
<p className="text-gray-400 dark:text-gray-600 text-sm">模型定价由管理员在后台配置后展示</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(grouped).map(([provider, list]) => (
|
||||||
|
<div key={provider} className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-superdream-accent mb-3">{provider}</h2>
|
||||||
|
<div className="bg-superdream-panel rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="text-left px-5 py-3 font-medium">模型</th>
|
||||||
|
<th className="text-right px-5 py-3 font-medium">输入价格 / 1K tokens</th>
|
||||||
|
<th className="text-right px-5 py-3 font-medium">输出价格 / 1K tokens</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((m) => (
|
||||||
|
<tr key={m.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||||
|
<td className="px-5 py-3 text-gray-900 dark:text-white font-medium">{m.model_name}</td>
|
||||||
|
<td className="px-5 py-3 text-gray-700 dark:text-gray-300 text-right">¥{m.input_price_per_1k}</td>
|
||||||
|
<td className="px-5 py-3 text-gray-700 dark:text-gray-300 text-right">¥{m.output_price_per_1k}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Billing notes */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-6 border border-gray-200 dark:border-gray-800 mt-4">
|
||||||
|
<h3 className="text-gray-900 dark:text-white font-semibold mb-3">计费说明</h3>
|
||||||
|
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-disc list-inside">
|
||||||
|
<li>按实际使用的 token 数量计费,输入和输出分别计价</li>
|
||||||
|
<li>费用在每次 API 调用完成后实时从余额中扣除</li>
|
||||||
|
<li>支持通过兑换码充值余额,后续将开放更多充值方式</li>
|
||||||
|
<li>所有调用记录和费用明细可在控制台实时查看</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-10">
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="inline-block px-6 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition text-sm"
|
||||||
|
>
|
||||||
|
立即注册,开始使用
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/pages/Register.tsx
Normal file
111
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
import { authStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("两次密码输入不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("密码至少 6 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||||||
|
setError("密码需同时包含字母和数字");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authService.register(email, password);
|
||||||
|
// Auto-login after registration
|
||||||
|
const res = await authService.login(email, password);
|
||||||
|
authStore.setTokens(res.access_token, res.refresh_token);
|
||||||
|
await authStore.fetchUser();
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "注册失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">注册</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">确认密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "注册中..." : "注册"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-gray-500">
|
||||||
|
<span>已有账号?</span>
|
||||||
|
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition ml-1">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/pages/ResetPassword.tsx
Normal file
111
frontend/src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { authService } from "../services/authService";
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token") || "";
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("两次密码输入不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("密码至少 6 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||||||
|
setError("密码需同时包含字母和数字");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
setError("缺少重置 token,请通过邮件链接访问");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.resetPassword(token, password);
|
||||||
|
setDone(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "重置失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-md bg-superdream-panel rounded-xl p-8 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">密码已重置</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">请使用新密码登录。</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="px-5 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition"
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">重置密码</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">新密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">确认密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "重置中..." : "重置密码"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/pages/dashboard/Keys.tsx
Normal file
191
frontend/src/pages/dashboard/Keys.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { keyService, ApiKeyInfo } from "../../services/keyService";
|
||||||
|
|
||||||
|
export default function Keys() {
|
||||||
|
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [newKey, setNewKey] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const fetchKeys = async () => {
|
||||||
|
try {
|
||||||
|
const data = await keyService.list();
|
||||||
|
setKeys(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setError("");
|
||||||
|
setNewKey("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await keyService.create(name);
|
||||||
|
setNewKey(res.key);
|
||||||
|
setName("");
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await keyService.remove(deleteTarget);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (text: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">API Key 管理</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Key */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key 名称(可选)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="例如:测试环境"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "创建中..." : "创建 Key"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newly created key */}
|
||||||
|
{newKey && (
|
||||||
|
<div className="mb-4 p-4 rounded-xl bg-green-500/10 border border-green-500/30">
|
||||||
|
<p className="text-sm text-green-400 mb-2">Key 创建成功!请立即复制保存,此 Key 仅显示一次。</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm text-gray-900 dark:text-white bg-superdream-bg px-3 py-2 rounded-lg break-all">
|
||||||
|
{newKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(newKey)}
|
||||||
|
className="px-3 py-2 rounded-lg bg-gray-300 dark:bg-gray-700 text-sm text-gray-900 dark:text-white hover:bg-gray-400 dark:hover:bg-gray-600 transition whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key list */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">名称</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Key</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">创建时间</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
暂无 Key,点击上方按钮创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
keys.map((k) => (
|
||||||
|
<tr key={k.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-white">{k.name || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">
|
||||||
|
sk-sd-{k.key_prefix}...{k.key_suffix}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{new Date(k.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(k.id)}
|
||||||
|
className="text-red-400 hover:text-red-300 transition"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirm modal */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60"
|
||||||
|
onClick={() => !deleting && setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-superdream-panel border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-sm shadow-xl">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">确认删除</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
删除后该 Key 将立即失效且无法恢复,确定要删除吗?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-400 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 rounded-lg bg-red-600 text-gray-900 dark:text-white text-sm font-medium hover:bg-red-500 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "删除中..." : "确认删除"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/pages/dashboard/Layout.tsx
Normal file
37
frontend/src/pages/dashboard/Layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Outlet, NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/dashboard", label: "概览", end: true },
|
||||||
|
{ to: "/dashboard/keys", label: "API Key" },
|
||||||
|
{ to: "/dashboard/wallet", label: "钱包" },
|
||||||
|
{ to: "/dashboard/usage", label: "用量" },
|
||||||
|
{ to: "/dashboard/logs", label: "调用日志" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<aside className="w-48 bg-superdream-panel border-r border-gray-200 dark:border-gray-800 p-4 flex flex-col gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-3 py-2 rounded-lg text-sm transition ${
|
||||||
|
isActive
|
||||||
|
? "bg-superdream-primary/20 text-superdream-accent"
|
||||||
|
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/src/pages/dashboard/Logs.tsx
Normal file
136
frontend/src/pages/dashboard/Logs.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { usageService, UsageLogItem } from "../../services/usageService";
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [logs, setLogs] = useState<UsageLogItem[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await usageService.logs({
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
model: model || undefined,
|
||||||
|
});
|
||||||
|
setLogs(data);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const handleFilter = () => {
|
||||||
|
setPage(1);
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">调用日志</h2>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">模型</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="如 gpt-4o"
|
||||||
|
className="px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleFilter}
|
||||||
|
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition"
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">时间</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">模型</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Prompt</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Completion</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Total</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">费用</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
加载中...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
暂无调用记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{new Date(log.request_time).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-white">{log.model}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.prompt_tokens.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.completion_tokens.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-white text-right font-medium">{log.total_tokens.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-superdream-accent text-right">¥{log.cost}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
log.status === "success"
|
||||||
|
? "bg-green-500/10 text-green-400"
|
||||||
|
: "bg-red-500/10 text-red-400"
|
||||||
|
}`}>
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex justify-between items-center mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">第 {page} 页</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={logs.length < pageSize}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/pages/dashboard/Overview.tsx
Normal file
42
frontend/src/pages/dashboard/Overview.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { walletService } from "../../services/walletService";
|
||||||
|
import { keyService } from "../../services/keyService";
|
||||||
|
import { usageService, UsageSummary } from "../../services/usageService";
|
||||||
|
|
||||||
|
export default function Overview() {
|
||||||
|
const [balance, setBalance] = useState("0");
|
||||||
|
const [keyCount, setKeyCount] = useState(0);
|
||||||
|
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
walletService.getBalance().then((b) => setBalance(b.balance)).catch(() => {});
|
||||||
|
keyService.list().then((keys) => setKeyCount(keys.length)).catch(() => {});
|
||||||
|
usageService.summary().then(setSummary).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">控制台</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">余额</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">活跃 Key</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{keyCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">今日调用</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||||
|
{summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">今日消耗</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{summary?.today_cost ?? "0"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/pages/dashboard/Usage.tsx
Normal file
107
frontend/src/pages/dashboard/Usage.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
BarChart, Bar, Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
usageService, UsageSummary, DailyUsage, ModelUsage,
|
||||||
|
} from "../../services/usageService";
|
||||||
|
|
||||||
|
const COLORS = ["#8B5CF6", "#a78bfa", "#6366f1", "#818cf8", "#c084fc", "#7c3aed"];
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||||
|
const [daily, setDaily] = useState<DailyUsage[]>([]);
|
||||||
|
const [byModel, setByModel] = useState<ModelUsage[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usageService.summary().then(setSummary).catch(() => {});
|
||||||
|
usageService.daily().then(setDaily).catch(() => {});
|
||||||
|
usageService.byModel().then(setByModel).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">用量统计</h2>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card label="今日 Tokens" value={summary?.today_tokens?.toLocaleString() ?? "0"} />
|
||||||
|
<Card label="今日消耗" value={`¥${summary?.today_cost ?? "0"}`} />
|
||||||
|
<Card label="本月 Tokens" value={summary?.month_tokens?.toLocaleString() ?? "0"} />
|
||||||
|
<Card label="本月消耗" value={`¥${summary?.month_cost ?? "0"}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily chart */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">每日用量(最近 30 天)</h3>
|
||||||
|
{daily.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm py-8 text-center">暂无数据</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={daily}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
itemStyle={{ color: "#a78bfa" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total_tokens"
|
||||||
|
name="Tokens"
|
||||||
|
stroke="#8B5CF6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By model chart */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">按模型统计</h3>
|
||||||
|
{byModel.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm py-8 text-center">暂无数据</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={byModel}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="model"
|
||||||
|
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
itemStyle={{ color: "#a78bfa" }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_tokens" name="Tokens" radius={[4, 4, 0, 0]}>
|
||||||
|
{byModel.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/pages/dashboard/Wallet.tsx
Normal file
139
frontend/src/pages/dashboard/Wallet.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { walletService, TransactionInfo } from "../../services/walletService";
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
topup: "充值",
|
||||||
|
consume: "消费",
|
||||||
|
refund: "退款",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Wallet() {
|
||||||
|
const [balance, setBalance] = useState("0");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [transactions, setTransactions] = useState<TransactionInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [b, txns] = await Promise.all([
|
||||||
|
walletService.getBalance(),
|
||||||
|
walletService.transactions(),
|
||||||
|
]);
|
||||||
|
setBalance(b.balance);
|
||||||
|
setTransactions(txns);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRedeem = async () => {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const txn = await walletService.redeem(code.trim());
|
||||||
|
setSuccess(`充值成功!+¥${txn.amount}`);
|
||||||
|
setCode("");
|
||||||
|
await fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">钱包</h2>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-6 mb-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">当前余额</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redeem */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">兑换码充值</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-3 p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="输入兑换码"
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleRedeem}
|
||||||
|
disabled={loading || !code.trim()}
|
||||||
|
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "兑换中..." : "兑换"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions */}
|
||||||
|
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
交易记录
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">类型</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">金额</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">余额</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">备注</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
暂无交易记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
transactions.map((txn) => (
|
||||||
|
<tr key={txn.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-white">
|
||||||
|
{TYPE_LABELS[txn.type] || txn.type}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-3 font-medium ${txn.type === "consume" ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{txn.type === "consume" ? "-" : "+"}¥{txn.amount}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">¥{txn.balance_after}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{txn.reference_id || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{new Date(txn.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/services/authService.ts
Normal file
34
frontend/src/services/authService.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { httpClient } from "./httpClient";
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
balance: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
register: (email: string, password: string) =>
|
||||||
|
httpClient.post<UserInfo>("/auth/register", { email, password }),
|
||||||
|
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
httpClient.post<TokenResponse>("/auth/login", { email, password }),
|
||||||
|
|
||||||
|
refresh: (refresh_token: string) =>
|
||||||
|
httpClient.post<TokenResponse>("/auth/refresh", { refresh_token }),
|
||||||
|
|
||||||
|
me: () => httpClient.get<UserInfo>("/auth/me"),
|
||||||
|
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
httpClient.post<{ message: string }>("/auth/forgot-password", { email }),
|
||||||
|
|
||||||
|
resetPassword: (token: string, new_password: string) =>
|
||||||
|
httpClient.post<{ message: string }>("/auth/reset-password", { token, new_password }),
|
||||||
|
};
|
||||||
9
frontend/src/services/exampleService.ts
Normal file
9
frontend/src/services/exampleService.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { httpClient } from "./httpClient";
|
||||||
|
import { Example } from "../types";
|
||||||
|
|
||||||
|
export const exampleService = {
|
||||||
|
list: () => httpClient.get<Example[]>("/examples"),
|
||||||
|
get: (id: string) => httpClient.get<Example>(`/examples/${id}`),
|
||||||
|
create: (data: { name: string; description?: string }) =>
|
||||||
|
httpClient.post<Example>("/examples", data),
|
||||||
|
};
|
||||||
36
frontend/src/services/httpClient.ts
Normal file
36
frontend/src/services/httpClient.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const BASE_URL = "/api/v1";
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const token = localStorage.getItem("sd_access_token");
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = {
|
||||||
|
get: <T>(endpoint: string) => request<T>(endpoint),
|
||||||
|
post: <T>(endpoint: string, body: unknown) =>
|
||||||
|
request<T>(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: <T>(endpoint: string) =>
|
||||||
|
request<T>(endpoint, { method: "DELETE" }),
|
||||||
|
};
|
||||||
25
frontend/src/services/keyService.ts
Normal file
25
frontend/src/services/keyService.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { httpClient } from "./httpClient";
|
||||||
|
|
||||||
|
export interface ApiKeyInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
key_suffix: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyCreated {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
key_prefix: string;
|
||||||
|
key_suffix: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyService = {
|
||||||
|
list: () => httpClient.get<ApiKeyInfo[]>("/keys"),
|
||||||
|
create: (name: string) => httpClient.post<ApiKeyCreated>("/keys", { name }),
|
||||||
|
remove: (keyId: string) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
|
||||||
|
};
|
||||||
80
frontend/src/services/usageService.ts
Normal file
80
frontend/src/services/usageService.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { httpClient } from "./httpClient";
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
today_tokens: number;
|
||||||
|
today_cost: string;
|
||||||
|
month_tokens: number;
|
||||||
|
month_cost: string;
|
||||||
|
total_requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyUsage {
|
||||||
|
date: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost: string;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelUsage {
|
||||||
|
model: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost: string;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyUsage {
|
||||||
|
key_id: string;
|
||||||
|
key_name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
key_suffix: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost: string;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageLogItem {
|
||||||
|
id: number;
|
||||||
|
key_id: string;
|
||||||
|
model: string;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
cost: string;
|
||||||
|
request_time: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usageService = {
|
||||||
|
summary: () => httpClient.get<UsageSummary>("/usage/summary"),
|
||||||
|
|
||||||
|
daily: (start?: string, end?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (start) params.set("start", start);
|
||||||
|
if (end) params.set("end", end);
|
||||||
|
const qs = params.toString();
|
||||||
|
return httpClient.get<DailyUsage[]>(`/usage/daily${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
byModel: () => httpClient.get<ModelUsage[]>("/usage/by-model"),
|
||||||
|
|
||||||
|
byKey: () => httpClient.get<KeyUsage[]>("/usage/by-key"),
|
||||||
|
|
||||||
|
logs: (params: {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
model?: string;
|
||||||
|
key_id?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
} = {}) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.page) sp.set("page", String(params.page));
|
||||||
|
if (params.size) sp.set("size", String(params.size));
|
||||||
|
if (params.model) sp.set("model", params.model);
|
||||||
|
if (params.key_id) sp.set("key_id", params.key_id);
|
||||||
|
if (params.start) sp.set("start", params.start);
|
||||||
|
if (params.end) sp.set("end", params.end);
|
||||||
|
const qs = sp.toString();
|
||||||
|
return httpClient.get<UsageLogItem[]>(`/usage/logs${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
21
frontend/src/services/walletService.ts
Normal file
21
frontend/src/services/walletService.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { httpClient } from "./httpClient";
|
||||||
|
|
||||||
|
export interface BalanceInfo {
|
||||||
|
balance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionInfo {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
amount: string;
|
||||||
|
balance_after: string;
|
||||||
|
reference_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletService = {
|
||||||
|
getBalance: () => httpClient.get<BalanceInfo>("/wallet/balance"),
|
||||||
|
redeem: (code: string) => httpClient.post<TransactionInfo>("/wallet/redeem", { code }),
|
||||||
|
transactions: (page = 1, size = 20) =>
|
||||||
|
httpClient.get<TransactionInfo[]>(`/wallet/transactions?page=${page}&size=${size}`),
|
||||||
|
};
|
||||||
51
frontend/src/stores/authStore.ts
Normal file
51
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { authService, UserInfo } from "../services/authService";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "sd_access_token";
|
||||||
|
const REFRESH_KEY = "sd_refresh_token";
|
||||||
|
|
||||||
|
let currentUser: UserInfo | null = null;
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = {
|
||||||
|
getToken: (): string | null => localStorage.getItem(TOKEN_KEY),
|
||||||
|
|
||||||
|
setTokens: (access: string, refresh?: string) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, access);
|
||||||
|
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTokens: () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
currentUser = null;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
isLoggedIn: (): boolean => !!localStorage.getItem(TOKEN_KEY),
|
||||||
|
|
||||||
|
getUser: (): UserInfo | null => currentUser,
|
||||||
|
|
||||||
|
fetchUser: async (): Promise<UserInfo | null> => {
|
||||||
|
if (!authStore.isLoggedIn()) return null;
|
||||||
|
try {
|
||||||
|
currentUser = await authService.me();
|
||||||
|
notify();
|
||||||
|
return currentUser;
|
||||||
|
} catch {
|
||||||
|
authStore.clearTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: (fn: () => void) => {
|
||||||
|
listeners.push(fn);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
39
frontend/src/stores/themeStore.ts
Normal file
39
frontend/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const THEME_KEY = "sd_theme";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
if (theme === "dark") {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeStore = {
|
||||||
|
get: (): Theme => {
|
||||||
|
return (localStorage.getItem(THEME_KEY) as Theme) || "dark";
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle: () => {
|
||||||
|
const next: Theme = themeStore.get() === "dark" ? "light" : "dark";
|
||||||
|
localStorage.setItem(THEME_KEY, next);
|
||||||
|
applyTheme(next);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
isDark: (): boolean => themeStore.get() === "dark",
|
||||||
|
|
||||||
|
subscribe: (fn: () => void) => {
|
||||||
|
listeners.push(fn);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
11
frontend/src/types.ts
Normal file
11
frontend/src/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Example {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/services/walletservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"}
|
||||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:18000",
|
||||||
|
"/data/files": "http://127.0.0.1:18000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi>=0.104.1
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
aiofiles>=23.2.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.23
|
||||||
|
aiomysql>=0.2.0
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
Reference in New Issue
Block a user