first commit

This commit is contained in:
xuyong
2026-04-15 21:35:26 +08:00
commit 7097fa6b44
69 changed files with 5642 additions and 0 deletions

24
.env.example Normal file
View 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
View 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
View 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
View 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
View File

0
app/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

54
app/api/v1/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

45
app/config/settings.py Normal file
View 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
View File

21
app/core/database.py Normal file
View 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
View 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
View 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
View 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

View File

179
app/datamodels/schemas.py Normal file
View 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
View 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
View 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
View File

View 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()

View 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

View 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()

View 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()

View 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()

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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
View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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
View 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">
...&nbsp;
<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 &copy; {new Date().getFullYear()}
</footer>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }),
};

View 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),
};

View 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" }),
};

View 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}`),
};

View 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}` : ""}`);
},
};

View 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}`),
};

View 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);
};
},
};

View 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
View 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
View 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"]
}

View 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
View 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
View 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

10
run.py Normal file
View File

@@ -0,0 +1,10 @@
import uvicorn
from app.config.settings import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=True,
)