feat: add DaisyUI frontend theme and document management system
Docker Build / Build and Push Docker Image (push) Failing after 1m35s

This commit is contained in:
2026-06-13 01:36:06 +08:00
parent 824c6d9133
commit e83ec743c8
118 changed files with 18487 additions and 15 deletions
+1
View File
@@ -10,6 +10,7 @@ build
logs
web/default/dist
web/classic/dist
web/daisy/dist
web/node_modules
web/dist
.env
+170
View File
@@ -0,0 +1,170 @@
# ModelsToken 管理平台 - 产品需求文档 (PRD)
## 1. 产品概述
ModelsToken 是一个 AI API 管理与分发平台,为开发者和企业提供统一的 AI 模型接入、密钥管理、用量计费、渠道代理等一站式服务。新前端将采用 React + DaisyUI 5 + TypeScript 构建,替换现有的 Default/Classic 双前端,同时新增本地文档管理功能。
- 目标用户:AI 应用开发者、企业运维人员、API 服务管理者
- 核心价值:简化 AI API 的管理复杂度,提供直观的操作界面和完整的文档支持
## 2. 核心功能
### 2.1 用户角色
| 角色 | 注册方式 | 核心权限 |
|------|----------|----------|
| 普通用户 | 用户名/邮箱/OAuth | 密钥管理、充值、订阅、日志查看、文档访问 |
| 管理员 | 由超级管理员指定 | 渠道管理、用户管理、兑换码、模型管理、订阅管理 |
| 超级管理员 | 系统初始化 | 全部权限 + 系统设置 |
### 2.2 功能模块
#### 公共页面(无需登录)
1. **首页**:Hero 区域、特性展示、快速入门指引
2. **登录页**:用户名/密码、OAuth 登录(GitHub/Discord/OIDC/LinuxDO/微信/Telegram/自定义)
3. **注册页**:注册表单 + Turnstile 人机验证
4. **忘记密码**:邮箱重置链接
5. **模型定价**:模型价格列表、搜索筛选
6. **关于页面**:项目信息、版本、许可证
7. **用户协议/隐私政策**
8. **初始化向导**:首次部署配置
#### 用户功能(需登录)
1. **仪表盘**:额度概览、使用趋势图、API 信息面板、公告、FAQ
2. **API 密钥管理**:创建/编辑/删除/批量操作、额度限制、模型限制、IP 限制
3. **钱包/充值**:余额查看、兑换码充值、在线支付(易支付/Stripe/Creem/Waffo)、签到
4. **订阅管理**:查看计划、购买订阅、当前订阅状态
5. **使用日志**:请求日志搜索/筛选、MJ 日志、任务日志、统计图表
6. **个人设置**:资料编辑、2FA 设置、Passkey 管理、OAuth 绑定、语言切换
7. **Playground**API 在线调试、Chat Completions 测试
8. **文档中心**(新增):本地文档管理、分类浏览、搜索、Markdown 渲染
#### 管理员功能
1. **渠道管理**:CRUD、测试、余额更新、标签管理、批量操作、多密钥、Codex OAuth、Ollama 管理
2. **用户管理**:列表/搜索/创建/编辑/升降级/启禁/额度调整
3. **兑换码管理**CRUD、批量删除无效码
4. **模型管理**:模型元数据 CRUD、上游同步、缺失模型检测
5. **供应商管理**CRUD
6. **订阅管理**:计划 CRUD、用户订阅管理
7. **部署管理**io.net 部署 CRUD、容器管理、日志
#### 超级管理员 - 系统设置
1. **站点设置**:名称/Logo/页脚/公告/首页内容/服务器地址
2. **认证设置**:注册/登录开关、OAuth 配置、Turnstile、Passkey、自定义 OAuth
3. **计费设置**:额度/倍率/支付配置/签到/分组倍率
4. **内容设置**:公告/FAQ/Uptime Kuma/聊天/绘图/Midjourney
5. **模型设置**:透传/思维模型/Gemini/Claude 配置
6. **运维设置**:重试/自动禁用/SMTP/性能监控/日志
7. **安全设置**:速率限制/敏感词/SSRF 防护/IP 过滤
### 2.3 新增功能 - 本地文档管理
| 功能 | 说明 |
|------|------|
| 文档分类 | 支持多级分类树,管理员可创建/编辑/删除分类 |
| 文档 CRUD | 管理员创建/编辑/删除文档,支持 Markdown 编辑器 |
| 文档浏览 | 用户按分类浏览文档,支持搜索 |
| 文档搜索 | 全文搜索文档标题和内容 |
| 文档版本 | 文档更新历史记录 |
| 权限控制 | 可设置文档为公开/登录可见/管理员可见 |
## 3. 核心流程
### 3.1 用户认证流程
```mermaid
flowchart TD
"访问平台" --> "已登录?"
"已登录?" -->|"是"| "仪表盘"
"已登录?" -->|"否"| "登录页"
"登录页" --> "输入凭证"
"输入凭证" --> "需要2FA?"
"需要2FA?" -->|"是"| "输入2FA码"
"需要2FA?" -->|"否"| "验证成功"
"输入2FA码" --> "验证成功"
"验证成功" --> "仪表盘"
"登录页" --> "OAuth登录"
"OAuth登录" --> "OAuth回调"
"OAuth回调" --> "已绑定账号?"
"已绑定账号?" -->|"是"| "仪表盘"
"已绑定账号?" -->|"否"| "绑定/注册"
```
### 3.2 API 调用流程
```mermaid
flowchart TD
"创建API密钥" --> "配置密钥参数"
"配置密钥参数" --> "使用密钥调用API"
"使用密钥调用API" --> "平台路由到渠道"
"平台路由到渠道" --> "返回结果"
"返回结果" --> "记录日志"
"记录日志" --> "扣除额度"
```
### 3.3 文档管理流程(新增)
```mermaid
flowchart TD
"管理员创建分类" --> "创建文档"
"创建文档" --> "Markdown编辑"
"Markdown编辑" --> "设置可见性"
"设置可见性" --> "发布文档"
"发布文档" --> "用户浏览/搜索"
```
## 4. 用户界面设计
### 4.1 设计风格
- **主色调**:深蓝 (#1e293b) + 亮蓝 (#3b82f6) 渐变,搭配 DaisyUI 的 `business` 主题
- **辅助色**:翡翠绿 (#10b981) 用于成功/在线状态,琥珀色 (#f59e0b) 用于警告
- **按钮风格**:DaisyUI 默认圆角按钮,主要操作用 `btn-primary`,危险操作用 `btn-error`
- **字体**JetBrains Mono(代码/密钥)+ Noto Sans SC(中文正文)
- **布局风格**:左侧固定导航栏 + 顶部状态栏 + 主内容区,响应式折叠
- **图标**Lucide React 图标库
- **动效**DaisyUI 内置动画 + 页面切换淡入
### 4.2 页面设计概览
| 页面 | 模块 | UI 元素 |
|------|------|---------|
| 首页 | Hero | 渐变背景、特性卡片、快速开始按钮 |
| 登录 | 表单 | 居中卡片、OAuth 按钮组、Turnstile |
| 仪表盘 | 统计卡片 | 4 列额度卡片、折线图、公告栏、API 信息 |
| 密钥管理 | 数据表 | 搜索栏、筛选器、表格、批量操作栏 |
| 渠道管理 | 数据表+表单 | 标签筛选、测试按钮、多密钥管理抽屉 |
| 系统设置 | 标签页 | 7 大分类侧边导航、表单分组、开关/输入框 |
| 文档中心 | 侧边树+内容 | 分类树导航、Markdown 渲染、搜索框、面包屑 |
| Playground | 分栏 | 左侧参数面板、右侧响应面板、模型选择器 |
### 4.3 响应式设计
- 桌面优先(1280px+
- 平板适配(768px-1279px):侧边栏折叠为抽屉
- 移动端适配(<768px):单列布局,表格改为卡片列表
### 4.4 布局结构
```
┌──────────────────────────────────────────────┐
│ 顶部导航栏 (Navbar) │
│ Logo | 搜索 | 通知 | 用户菜单 | 主题切换 │
├──────┬───────────────────────────────────────┤
│ │ │
│ 侧边 │ 主内容区 │
│ 导航 │ │
│ 栏 │ ┌─────────────────────────────────┐ │
│ │ │ 面包屑 + 页面标题 + 操作按钮 │ │
│ 仪表盘│ ├─────────────────────────────────┤ │
│ 密钥 │ │ │ │
│ 渠道 │ │ 页面内容 │ │
│ 用户 │ │ │ │
│ 日志 │ │ │ │
│ 钱包 │ └─────────────────────────────────┘ │
│ 订阅 │ │
│ 文档 │ │
│ 设置 │ │
│ │ │
└──────┴───────────────────────────────────────┘
```
+459
View File
@@ -0,0 +1,459 @@
# ModelsToken 管理平台 - 技术架构文档
## 1. 架构设计
```mermaid
flowchart TB
subgraph "前端 (React + DaisyUI 5)"
A["React 18"] --> B["React Router v6"]
B --> C["页面组件"]
C --> D["DaisyUI 5 组件"]
D --> E["Tailwind CSS 4"]
A --> F["Zustand 状态管理"]
A --> G["React Query 数据请求"]
A --> H["i18next 国际化"]
A --> I["React Markdown 渲染"]
end
subgraph "后端 (Go + Gin)"
J["Gin HTTP Server"]
J --> K["API 路由"]
J --> L["Relay 代理"]
K --> M["控制器"]
M --> N["模型层"]
N --> O["数据库 (SQLite/MySQL/PostgreSQL)"]
end
C -->|"Axios HTTP"| K
```
## 2. 技术说明
- **前端框架**React 18 + TypeScript
- **UI 库**DaisyUI 5 + Tailwind CSS 4
- **构建工具**Vite 6
- **路由**React Router v6(懒加载)
- **状态管理**Zustand(轻量级,替代 Redux
- **数据请求**TanStack React Query v5 + Axios
- **国际化**i18next + react-i18next
- **图表**Recharts
- **Markdown**react-markdown + remark-gfm + rehype-highlight
- **图标**Lucide React
- **代码高亮**highlight.js
- **表单验证**React Hook Form + Zod
- **通知**react-hot-toast
- **项目目录**`web/daisy/`
## 3. 路由定义
### 3.1 公共路由
| 路由 | 用途 |
|------|------|
| `/` | 首页 |
| `/login` | 登录 |
| `/register` | 注册 |
| `/forgot-password` | 忘记密码 |
| `/reset-password` | 密码重置确认 |
| `/setup` | 初始化向导 |
| `/pricing` | 模型定价 |
| `/about` | 关于 |
| `/user-agreement` | 用户协议 |
| `/privacy-policy` | 隐私政策 |
| `/oauth/callback/:provider` | OAuth 回调 |
### 3.2 认证后路由
| 路由 | 用途 |
|------|------|
| `/dashboard` | 仪表盘 |
| `/tokens` | API 密钥管理 |
| `/wallet` | 钱包/充值 |
| `/subscriptions` | 订阅管理 |
| `/logs` | 使用日志 |
| `/logs/midjourney` | MJ 日志 |
| `/logs/tasks` | 任务日志 |
| `/profile` | 个人设置 |
| `/playground` | Playground |
| `/docs` | 文档中心(新增) |
| `/docs/:slug` | 文档详情(新增) |
### 3.3 管理员路由
| 路由 | 用途 |
|------|------|
| `/admin/channels` | 渠道管理 |
| `/admin/users` | 用户管理 |
| `/admin/redemptions` | 兑换码管理 |
| `/admin/models` | 模型管理 |
| `/admin/vendors` | 供应商管理 |
| `/admin/deployments` | 部署管理 |
| `/admin/subscriptions` | 订阅计划管理 |
### 3.4 超级管理员路由
| 路由 | 用途 |
|------|------|
| `/settings/site` | 站点设置 |
| `/settings/auth` | 认证设置 |
| `/settings/billing` | 计费设置 |
| `/settings/content` | 内容设置 |
| `/settings/models` | 模型设置 |
| `/settings/operations` | 运维设置 |
| `/settings/security` | 安全设置 |
| `/settings/docs` | 文档管理(新增) |
## 4. API 定义
### 4.1 核心类型
```typescript
// 用户
interface User {
id: number;
username: string;
display_name: string;
email: string;
role: number; // 1=user, 10=admin, 100=root
status: number;
quota: number;
used_quota: number;
request_count: number;
group: string;
aff_code: string;
inviter_id: number;
language: string;
access_token: string;
created_time: number;
}
// 渠道
interface Channel {
id: number;
type: number;
key: string;
openai_organization?: string;
base_url: string;
models: string;
model_mapping?: string;
group: string;
groups: string[];
name: string;
priority: number;
weight: number;
status: number;
tag?: string;
setting?: string;
test_time: number;
response_time: number;
balance: number;
balance_updated_time: number;
created_time: number;
}
// 令牌
interface Token {
id: number;
user_id: number;
key: string;
status: number;
name: string;
created_time: number;
accessed_time: number;
expired_time: number;
remain_quota: number;
unlimited_quota: boolean;
used_quota: number;
models: string;
subnet: string;
group: string;
}
// 日志
interface Log {
id: number;
user_id: number;
created_at: number;
type: number;
content: string;
username: string;
token_name: string;
model_name: string;
quota: number;
prompt_tokens: number;
completion_tokens: number;
channel_id: number;
token_id: number;
group: string;
request_id: string;
ip: string;
detail: string;
}
// 订阅计划
interface SubscriptionPlan {
id: number;
name: string;
description: string;
price: number;
currency: string;
duration_days: number;
quota: number;
models: string;
enabled: boolean;
sort_order: number;
created_time: number;
}
// 文档(新增)
interface Document {
id: number;
title: string;
slug: string;
content: string; // Markdown
category_id: number;
category?: DocumentCategory;
visibility: 'public' | 'auth' | 'admin';
sort_order: number;
created_at: string;
updated_at: string;
author_id: number;
author?: User;
versions?: DocumentVersion[];
}
interface DocumentCategory {
id: number;
name: string;
slug: string;
parent_id: number | null;
children?: DocumentCategory[];
sort_order: number;
}
interface DocumentVersion {
id: number;
document_id: number;
content: string;
created_at: string;
author_id: number;
}
```
### 4.2 新增文档管理 API
| 端点 | 方法 | 权限 | 说明 |
|------|------|------|------|
| `/api/docs/categories` | GET | 公开 | 获取分类树 |
| `/api/docs/categories` | POST | Admin | 创建分类 |
| `/api/docs/categories/:id` | PUT | Admin | 更新分类 |
| `/api/docs/categories/:id` | DELETE | Admin | 删除分类 |
| `/api/docs/` | GET | 按可见性 | 文档列表(支持搜索) |
| `/api/docs/:slug` | GET | 按可见性 | 获取文档详情 |
| `/api/docs/` | POST | Admin | 创建文档 |
| `/api/docs/:id` | PUT | Admin | 更新文档 |
| `/api/docs/:id` | DELETE | Admin | 删除文档 |
| `/api/docs/:id/versions` | GET | Admin | 文档版本历史 |
## 5. 项目目录结构
```
web/daisy/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── public/
│ └── manifest.json
└── src/
├── main.tsx # 入口
├── App.tsx # 根组件 + 路由
├── vite-env.d.ts
├── api/ # API 请求层
│ ├── client.ts # Axios 实例 + 拦截器
│ ├── auth.ts # 认证 API
│ ├── channel.ts # 渠道 API
│ ├── token.ts # 令牌 API
│ ├── user.ts # 用户 API
│ ├── log.ts # 日志 API
│ ├── subscription.ts # 订阅 API
│ ├── redemption.ts # 兑换码 API
│ ├── model.ts # 模型 API
│ ├── vendor.ts # 供应商 API
│ ├── deployment.ts # 部署 API
│ ├── option.ts # 系统设置 API
│ ├── payment.ts # 支付 API
│ └── doc.ts # 文档 API(新增)
├── stores/ # Zustand 状态
│ ├── auth.ts # 认证状态
│ └── ui.ts # UI 状态(侧边栏/主题)
├── hooks/ # 自定义 Hooks
│ ├── useAuth.ts
│ ├── usePermission.ts
│ └── useQuota.ts
├── components/ # 通用组件
│ ├── layout/
│ │ ├── AppLayout.tsx # 主布局
│ │ ├── Sidebar.tsx # 侧边导航
│ │ ├── Navbar.tsx # 顶部导航
│ │ └── Breadcrumb.tsx # 面包屑
│ ├── common/
│ │ ├── QuotaDisplay.tsx # 额度显示
│ │ ├── ModelBadge.tsx # 模型标签
│ │ ├── StatusBadge.tsx # 状态标签
│ │ ├── SearchInput.tsx # 搜索框
│ │ ├── DataTable.tsx # 数据表格
│ │ ├── ConfirmDialog.tsx # 确认对话框
│ │ └── LoadingSpinner.tsx # 加载动画
│ └── charts/
│ ├── QuotaChart.tsx # 额度趋势图
│ └── StatsChart.tsx # 统计图表
├── pages/ # 页面组件
│ ├── public/
│ │ ├── Home.tsx
│ │ ├── Login.tsx
│ │ ├── Register.tsx
│ │ ├── ForgotPassword.tsx
│ │ ├── Pricing.tsx
│ │ ├── About.tsx
│ │ └── Setup.tsx
│ ├── dashboard/
│ │ └── Dashboard.tsx
│ ├── tokens/
│ │ ├── TokenList.tsx
│ │ └── TokenForm.tsx
│ ├── channels/
│ │ ├── ChannelList.tsx
│ │ └── ChannelForm.tsx
│ ├── users/
│ │ ├── UserList.tsx
│ │ └── UserForm.tsx
│ ├── logs/
│ │ ├── LogList.tsx
│ │ ├── MidjourneyLog.tsx
│ │ └── TaskLog.tsx
│ ├── wallet/
│ │ └── Wallet.tsx
│ ├── subscriptions/
│ │ ├── PlanList.tsx
│ │ └── MySubscription.tsx
│ ├── redemptions/
│ │ └── RedemptionList.tsx
│ ├── models/
│ │ └── ModelList.tsx
│ ├── vendors/
│ │ └── VendorList.tsx
│ ├── deployments/
│ │ └── DeploymentList.tsx
│ ├── playground/
│ │ └── Playground.tsx
│ ├── profile/
│ │ └── Profile.tsx
│ ├── docs/ # 文档中心(新增)
│ │ ├── DocCenter.tsx # 文档浏览主页
│ │ ├── DocViewer.tsx # 文档阅读页
│ │ ├── DocEditor.tsx # 文档编辑页(管理员)
│ │ └── DocCategoryManager.tsx # 分类管理(管理员)
│ └── settings/
│ ├── SiteSettings.tsx
│ ├── AuthSettings.tsx
│ ├── BillingSettings.tsx
│ ├── ContentSettings.tsx
│ ├── ModelSettings.tsx
│ ├── OperationsSettings.tsx
│ ├── SecuritySettings.tsx
│ └── DocSettings.tsx # 文档设置(新增)
├── i18n/ # 国际化
│ ├── index.ts
│ └── locales/
│ ├── en.json
│ └── zh.json
├── lib/ # 工具函数
│ ├── constants.ts
│ ├── utils.ts
│ ├── quota.ts
│ └── channel-types.ts
└── types/ # TypeScript 类型
├── api.ts
├── channel.ts
├── token.ts
├── user.ts
├── log.ts
├── subscription.ts
├── doc.ts
└── option.ts
```
## 6. 数据模型(新增文档管理)
```mermaid
erDiagram
"document_categories" {
int id PK
string name
string slug UK
int parent_id FK
int sort_order
timestamp created_at
}
"documents" {
int id PK
string title
string slug UK
text content
int category_id FK
string visibility
int sort_order
int author_id FK
timestamp created_at
timestamp updated_at
}
"document_versions" {
int id PK
int document_id FK
text content
int author_id FK
timestamp created_at
}
"document_categories" ||--o{ "document_categories" : "parent"
"document_categories" ||--o{ "documents" : "has"
"documents" ||--o{ "document_versions" : "has"
```
### DDL
```sql
CREATE TABLE document_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
parent_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL UNIQUE,
content TEXT NOT NULL,
category_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'auth', 'admin')),
sort_order INTEGER DEFAULT 0,
author_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE document_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_documents_slug ON documents(slug);
CREATE INDEX idx_documents_category ON documents(category_id);
CREATE INDEX idx_documents_visibility ON documents(visibility);
CREATE INDEX idx_document_versions_doc ON document_versions(document_id);
```
+10
View File
@@ -20,6 +20,15 @@ COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM node:22-alpine AS builder-daisy
WORKDIR /build
COPY web/daisy/package.json web/daisy/package-lock.json ./
RUN npm ci
COPY ./web/daisy ./
COPY ./VERSION /build/VERSION
RUN VITE_REACT_APP_VERSION=$(cat /build/VERSION) npm run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -36,6 +45,7 @@ RUN go mod download
COPY . .
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
COPY --from=builder-daisy /build/dist ./web/daisy/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+2 -2
View File
@@ -30,9 +30,9 @@ func GetTheme() string {
}
// SetTheme updates the frontend theme atomically.
// Only "default" and "classic" are accepted; other values are silently ignored.
// Only "default", "classic", and "daisy" are accepted; other values are silently ignored.
func SetTheme(t string) {
if t == "default" || t == "classic" {
if t == "default" || t == "classic" || t == "daisy" {
themeValue.Store(t)
}
}
+15 -6
View File
@@ -48,22 +48,31 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
type themeAwareFileSystem struct {
defaultFS static.ServeFileSystem
classicFS static.ServeFileSystem
daisyFS static.ServeFileSystem
}
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
if GetTheme() == "classic" {
switch GetTheme() {
case "classic":
return t.classicFS.Exists(prefix, path)
case "daisy":
return t.daisyFS.Exists(prefix, path)
default:
return t.defaultFS.Exists(prefix, path)
}
return t.defaultFS.Exists(prefix, path)
}
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
if GetTheme() == "classic" {
switch GetTheme() {
case "classic":
return t.classicFS.Open(name)
case "daisy":
return t.daisyFS.Open(name)
default:
return t.defaultFS.Open(name)
}
return t.defaultFS.Open(name)
}
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
func NewThemeAwareFS(defaultFS, classicFS, daisyFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS, daisyFS: daisyFS}
}
+250
View File
@@ -0,0 +1,250 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetCategories 获取文档分类列表(公开)
func GetCategories(c *gin.Context) {
categories, err := model.GetDocumentCategories()
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, categories)
}
// CreateCategory 创建文档分类(管理员)
func CreateCategory(c *gin.Context) {
var category model.DocumentCategory
if err := c.ShouldBindJSON(&category); err != nil {
common.ApiError(c, err)
return
}
if category.Name == "" {
common.ApiErrorMsg(c, "分类名称不能为空")
return
}
if category.Slug == "" {
common.ApiErrorMsg(c, "分类标识不能为空")
return
}
if err := model.CreateDocumentCategory(&category); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &category)
}
// UpdateCategory 更新文档分类(管理员)
func UpdateCategory(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
var category model.DocumentCategory
if err := c.ShouldBindJSON(&category); err != nil {
common.ApiError(c, err)
return
}
category.Id = id
if err := model.UpdateDocumentCategory(&category); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &category)
}
// DeleteCategory 删除文档分类(管理员)
func DeleteCategory(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeleteDocumentCategory(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
// GetDocuments 获取文档列表(公开,根据认证状态过滤可见性)
func GetDocuments(c *gin.Context) {
keyword := c.Query("keyword")
categoryIdStr := c.Query("category_id")
var categoryId *int
if categoryIdStr != "" {
id, err := strconv.Atoi(categoryIdStr)
if err == nil {
categoryId = &id
}
}
// 根据用户认证状态决定可见性过滤
visibility := c.Query("visibility")
role := c.GetInt("role")
if role >= common.RoleAdminUser {
// 管理员可看所有,如果指定了 visibility 则按指定值过滤
// visibility 保持原值
} else if role >= common.RoleCommonUser {
// 普通用户只能看 public 和 auth
if visibility == "admin" {
visibility = "" // 不允许看 admin
}
// 如果没有指定 visibility,则过滤出 public 和 auth
} else {
// 未登录用户只能看 public
visibility = "public"
}
pageInfo := common.GetPageQuery(c)
documents, total, err := model.GetDocuments(keyword, visibility, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(documents)
common.ApiSuccess(c, pageInfo)
}
// GetDocument 获取单个文档(根据可见性检查权限)
func GetDocument(c *gin.Context) {
slug := c.Param("slug")
doc, err := model.GetDocumentBySlug(slug)
if err != nil {
common.ApiError(c, err)
return
}
// 检查可见性权限
role := c.GetInt("role")
switch doc.Visibility {
case "admin":
if role < common.RoleAdminUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权访问该文档",
})
return
}
case "auth":
if role < common.RoleCommonUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先登录后查看该文档",
})
return
}
}
common.ApiSuccess(c, doc)
}
// CreateDocument 创建文档(管理员)
func CreateDocument(c *gin.Context) {
var doc model.Document
if err := c.ShouldBindJSON(&doc); err != nil {
common.ApiError(c, err)
return
}
if doc.Title == "" {
common.ApiErrorMsg(c, "文档标题不能为空")
return
}
if doc.Slug == "" {
common.ApiErrorMsg(c, "文档标识不能为空")
return
}
if doc.Content == "" {
common.ApiErrorMsg(c, "文档内容不能为空")
return
}
if doc.Visibility == "" {
doc.Visibility = "public"
}
doc.AuthorId = c.GetInt("id")
if err := model.CreateDocument(&doc); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &doc)
}
// UpdateDocument 更新文档(管理员,自动创建版本记录)
func UpdateDocument(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
var doc model.Document
if err := c.ShouldBindJSON(&doc); err != nil {
common.ApiError(c, err)
return
}
doc.Id = id
// 获取旧文档内容,自动创建版本记录
oldDoc, err := model.GetDocumentById(id)
if err != nil {
common.ApiError(c, err)
return
}
version := &model.DocumentVersion{
DocumentId: oldDoc.Id,
Content: oldDoc.Content,
AuthorId: oldDoc.AuthorId,
}
if err := model.CreateDocumentVersion(version); err != nil {
common.ApiError(c, err)
return
}
if err := model.UpdateDocument(&doc); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &doc)
}
// DeleteDocument 删除文档(管理员)
func DeleteDocument(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeleteDocument(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
// GetDocumentVersions 获取文档版本历史(管理员)
func GetDocumentVersions(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
pageInfo := common.GetPageQuery(c)
versions, total, err := model.GetDocumentVersions(id, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(versions)
common.ApiSuccess(c, pageInfo)
}
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
@@ -205,10 +204,10 @@ func UpdateOption(c *gin.Context) {
return
}
case "theme.frontend":
if option.Value != "default" && option.Value != "classic" {
if option.Value != "default" && option.Value != "classic" && option.Value != "daisy" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)、daisyDaisyUI 前端)",
})
return
}
+8
View File
@@ -47,6 +47,12 @@ var classicBuildFS embed.FS
//go:embed web/classic/dist/index.html
var classicIndexPage []byte
//go:embed web/daisy/dist
var daisyBuildFS embed.FS
//go:embed web/daisy/dist/index.html
var daisyIndexPage []byte
func main() {
startTime := time.Now()
@@ -195,6 +201,8 @@ func main() {
DefaultIndexPage: indexPage,
ClassicBuildFS: classicBuildFS,
ClassicIndexPage: classicIndexPage,
DaisyBuildFS: daisyBuildFS,
DaisyIndexPage: daisyIndexPage,
})
var port = os.Getenv("PORT")
if port == "" {
+71
View File
@@ -0,0 +1,71 @@
package model
import (
"time"
)
type Document struct {
Id int `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"not null"`
Slug string `json:"slug" gorm:"uniqueIndex;not null"`
Content string `json:"content" gorm:"type:text;not null"`
CategoryId *int `json:"category_id" gorm:"index"`
Visibility string `json:"visibility" gorm:"default:'public'"` // public, auth, admin
SortOrder int `json:"sort_order" gorm:"default:0"`
AuthorId int `json:"author_id" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func GetDocuments(keyword string, visibility string, categoryId *int, startIdx int, num int) ([]*Document, int64, error) {
query := DB.Model(&Document{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("title LIKE ? OR content LIKE ?", like, like)
}
if visibility != "" {
query = query.Where("visibility = ?", visibility)
}
if categoryId != nil {
query = query.Where("category_id = ?", *categoryId)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var documents []*Document
if err := query.Order("sort_order ASC, id DESC").Offset(startIdx).Limit(num).Find(&documents).Error; err != nil {
return nil, 0, err
}
return documents, total, nil
}
func GetDocumentBySlug(slug string) (*Document, error) {
var doc Document
err := DB.Where("slug = ?", slug).First(&doc).Error
if err != nil {
return nil, err
}
return &doc, nil
}
func GetDocumentById(id int) (*Document, error) {
var doc Document
err := DB.First(&doc, id).Error
if err != nil {
return nil, err
}
return &doc, nil
}
func CreateDocument(doc *Document) error {
return DB.Create(doc).Error
}
func UpdateDocument(doc *Document) error {
return DB.Model(doc).Select("title", "slug", "content", "category_id", "visibility", "sort_order").Updates(doc).Error
}
func DeleteDocument(id int) error {
return DB.Delete(&Document{}, id).Error
}
+38
View File
@@ -0,0 +1,38 @@
package model
import (
"time"
)
type DocumentCategory struct {
Id int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Slug string `json:"slug" gorm:"uniqueIndex;not null"`
ParentId *int `json:"parent_id" gorm:"index"`
SortOrder int `json:"sort_order" gorm:"default:0"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func GetDocumentCategories() ([]*DocumentCategory, error) {
var categories []*DocumentCategory
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
return categories, err
}
func GetDocumentCategoryTree() ([]*DocumentCategory, error) {
var categories []*DocumentCategory
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
return categories, err
}
func CreateDocumentCategory(category *DocumentCategory) error {
return DB.Create(category).Error
}
func UpdateDocumentCategory(category *DocumentCategory) error {
return DB.Model(category).Select("name", "slug", "parent_id", "sort_order").Updates(category).Error
}
func DeleteDocumentCategory(id int) error {
return DB.Delete(&DocumentCategory{}, id).Error
}
+30
View File
@@ -0,0 +1,30 @@
package model
import (
"time"
)
type DocumentVersion struct {
Id int `json:"id" gorm:"primaryKey"`
DocumentId int `json:"document_id" gorm:"index;not null"`
Content string `json:"content" gorm:"type:text;not null"`
AuthorId int `json:"author_id" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func GetDocumentVersions(documentId int, startIdx int, num int) ([]*DocumentVersion, int64, error) {
query := DB.Model(&DocumentVersion{}).Where("document_id = ?", documentId)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var versions []*DocumentVersion
if err := query.Order("id DESC").Offset(startIdx).Limit(num).Find(&versions).Error; err != nil {
return nil, 0, err
}
return versions, total, nil
}
func CreateDocumentVersion(version *DocumentVersion) error {
return DB.Create(version).Error
}
+6
View File
@@ -281,6 +281,9 @@ func migrateDB() error {
&CustomOAuthProvider{},
&UserOAuthBinding{},
&PerfMetric{},
&DocumentCategory{},
&Document{},
&DocumentVersion{},
)
if err != nil {
return err
@@ -330,6 +333,9 @@ func migrateDBFast() error {
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&PerfMetric{}, "PerfMetric"},
{&DocumentCategory{}, "DocumentCategory"},
{&Document{}, "Document"},
{&DocumentVersion{}, "DocumentVersion"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
+22
View File
@@ -346,6 +346,28 @@ func SetApiRouter(router *gin.Engine) {
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
}
// Document routes (public)
docsPublic := apiRouter.Group("/docs")
docsPublic.Use(middleware.TryUserAuth())
{
docsPublic.GET("/categories", controller.GetCategories)
docsPublic.GET("/", controller.GetDocuments)
docsPublic.GET("/:slug", controller.GetDocument)
}
// Document routes (admin)
docsAdmin := apiRouter.Group("/docs")
docsAdmin.Use(middleware.AdminAuth())
{
docsAdmin.POST("/categories", controller.CreateCategory)
docsAdmin.PUT("/categories/:id", controller.UpdateCategory)
docsAdmin.DELETE("/categories/:id", controller.DeleteCategory)
docsAdmin.POST("/", controller.CreateDocument)
docsAdmin.PUT("/:id", controller.UpdateDocument)
docsAdmin.DELETE("/:id", controller.DeleteDocument)
docsAdmin.GET("/:id/versions", controller.GetDocumentVersions)
}
vendorRoute := apiRouter.Group("/vendors")
vendorRoute.Use(middleware.AdminAuth())
{
+10 -4
View File
@@ -13,18 +13,21 @@ import (
"github.com/gin-gonic/gin"
)
// ThemeAssets holds the embedded frontend assets for both themes.
// ThemeAssets holds the embedded frontend assets for all themes.
type ThemeAssets struct {
DefaultBuildFS embed.FS
DefaultIndexPage []byte
ClassicBuildFS embed.FS
ClassicIndexPage []byte
DaisyBuildFS embed.FS
DaisyIndexPage []byte
}
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
daisyFS := common.EmbedFolder(assets.DaisyBuildFS, "web/daisy/dist")
themeFS := common.NewThemeAwareFS(defaultFS, classicFS, daisyFS)
router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
@@ -37,9 +40,12 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
return
}
c.Header("Cache-Control", "no-cache")
if common.GetTheme() == "classic" {
switch common.GetTheme() {
case "classic":
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
} else {
case "daisy":
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DaisyIndexPage)
default:
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
}
})
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+57
View File
@@ -0,0 +1,57 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
extends: [
// other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN" data-theme="business">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ModelsToken</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6545
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
{
"name": "modelstoken-daisy",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"check": "tsc -b --noEmit"
},
"dependencies": {
"axios": "^1.9.0",
"clsx": "^2.1.1",
"daisyui": "^5.0.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"lucide-react": "^0.511.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.56.4",
"react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0",
"recharts": "^2.15.3",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"@tanstack/react-query": "^5.77.0",
"tailwind-merge": "^3.0.2",
"zod": "^3.25.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.0",
"@types/node": "^22.15.30",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#0A0B0D"/>
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+112
View File
@@ -0,0 +1,112 @@
import { Routes, Route } from 'react-router-dom'
import AppLayout from '@/components/layout/AppLayout'
// Public pages
import Home from '@/pages/public/Home'
import Login from '@/pages/public/Login'
import Register from '@/pages/public/Register'
import ForgotPassword from '@/pages/public/ForgotPassword'
import ResetPassword from '@/pages/public/ResetPassword'
import Setup from '@/pages/public/Setup'
import Pricing from '@/pages/public/Pricing'
import About from '@/pages/public/About'
import UserAgreement from '@/pages/public/UserAgreement'
import PrivacyPolicy from '@/pages/public/PrivacyPolicy'
import OAuthCallback from '@/pages/public/OAuthCallback'
import NotFound from '@/pages/NotFound'
// User pages
import Dashboard from '@/pages/dashboard/Dashboard'
import TokenList from '@/pages/tokens/TokenList'
import Wallet from '@/pages/wallet/Wallet'
import MySubscription from '@/pages/subscriptions/MySubscription'
import LogList from '@/pages/logs/LogList'
import MidjourneyLog from '@/pages/logs/MidjourneyLog'
import TaskLog from '@/pages/logs/TaskLog'
import Profile from '@/pages/profile/Profile'
import DocCenter from '@/pages/docs/DocCenter'
import DocViewer from '@/pages/docs/DocViewer'
import DocEditor from '@/pages/docs/DocEditor'
import Playground from '@/pages/playground/Playground'
// Admin pages
import ChannelList from '@/pages/admin/ChannelList'
import UserList from '@/pages/admin/UserList'
import RedemptionList from '@/pages/admin/RedemptionList'
import ModelList from '@/pages/admin/ModelList'
import VendorList from '@/pages/admin/VendorList'
import DeploymentList from '@/pages/admin/DeploymentList'
import SubscriptionAdmin from '@/pages/admin/SubscriptionAdmin'
// Settings pages
import SettingsLayout from '@/pages/settings/SettingsLayout'
import SiteSettings from '@/pages/settings/SiteSettings'
import AuthSettings from '@/pages/settings/AuthSettings'
import BillingSettings from '@/pages/settings/BillingSettings'
import ContentSettings from '@/pages/settings/ContentSettings'
import ModelSettings from '@/pages/settings/ModelSettings'
import OperationsSettings from '@/pages/settings/OperationsSettings'
import SecuritySettings from '@/pages/settings/SecuritySettings'
import DocSettings from '@/pages/settings/DocSettings'
function App() {
return (
<Routes>
{/* Public routes */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup" element={<Setup />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/about" element={<About />} />
<Route path="/user-agreement" element={<UserAgreement />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
{/* Authenticated routes */}
<Route element={<AppLayout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tokens" element={<TokenList />} />
<Route path="/wallet" element={<Wallet />} />
<Route path="/subscriptions" element={<MySubscription />} />
<Route path="/logs" element={<LogList />} />
<Route path="/logs/midjourney" element={<MidjourneyLog />} />
<Route path="/logs/tasks" element={<TaskLog />} />
<Route path="/profile" element={<Profile />} />
<Route path="/playground" element={<Playground />} />
<Route path="/docs" element={<DocCenter />} />
<Route path="/docs/new/edit" element={<DocEditor />} />
<Route path="/docs/:slug" element={<DocViewer />} />
<Route path="/docs/:slug/edit" element={<DocEditor />} />
{/* Admin routes */}
<Route path="/admin/channels" element={<ChannelList />} />
<Route path="/admin/users" element={<UserList />} />
<Route path="/admin/redemptions" element={<RedemptionList />} />
<Route path="/admin/models" element={<ModelList />} />
<Route path="/admin/vendors" element={<VendorList />} />
<Route path="/admin/deployments" element={<DeploymentList />} />
<Route path="/admin/subscriptions" element={<SubscriptionAdmin />} />
{/* Root routes - Settings */}
<Route path="/settings" element={<SettingsLayout />}>
<Route path="site" element={<SiteSettings />} />
<Route path="auth" element={<AuthSettings />} />
<Route path="billing" element={<BillingSettings />} />
<Route path="content" element={<ContentSettings />} />
<Route path="models" element={<ModelSettings />} />
<Route path="operations" element={<OperationsSettings />} />
<Route path="security" element={<SecuritySettings />} />
<Route path="docs" element={<DocSettings />} />
</Route>
</Route>
{/* 404 */}
<Route path="*" element={<NotFound />} />
</Routes>
)
}
export default App
+27
View File
@@ -0,0 +1,27 @@
import { get, post, put, del } from './client'
import type { SubscriptionPlan, SubscriptionOrder } from '@/types/subscription'
import type { PaginatedResponse } from '@/types/api'
export function getAdminPlans() {
return get<SubscriptionPlan[]>('/subscription/admin/plans')
}
export function createPlan(data: Partial<SubscriptionPlan>) {
return post<SubscriptionPlan>('/subscription/admin/plans', data)
}
export function updatePlan(data: Partial<SubscriptionPlan>) {
return put<SubscriptionPlan>(`/subscription/admin/plans/${data.id}`, data)
}
export function deletePlan(id: number) {
return del<{ success: boolean }>(`/subscription/admin/plans/${id}`)
}
export function getUserSubscriptions(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<SubscriptionOrder>>('/subscription/admin/subscriptions', params)
}
export function bindSubscription(data: { user_id: number; plan_id: number }) {
return post<{ success: boolean; message: string }>('/subscription/admin/bind', data)
}
+35
View File
@@ -0,0 +1,35 @@
import { get, post, put, del } from './client'
import type { User } from '@/types/user'
import type { PaginatedResponse } from '@/types/api'
export function getUsers(page = 1, size = 10, search = '') {
return get<PaginatedResponse<User>>('/user/', {
page,
page_size: size,
keyword: search,
})
}
export function searchUsers(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<User>>('/user/search', params)
}
export function getUser(id: number) {
return get<User>(`/user/${id}`)
}
export function createUser(data: Partial<User> & { password: string }) {
return post<User>('/user/', data)
}
export function updateUser(data: Partial<User>) {
return put<User>(`/user/${data.id}`, data)
}
export function deleteUser(id: number) {
return del<{ success: boolean }>(`/user/${id}`)
}
export function manageUser(id: number, action: string) {
return post<{ success: boolean; message: string }>(`/user/manage`, { id, action })
}
+74
View File
@@ -0,0 +1,74 @@
import { get, post } from '@/api/client'
import type { ApiResponse } from '@/types/api'
export interface LoginRequest {
username: string
password: string
}
export interface RegisterRequest {
username: string
password: string
email: string
aff_code?: string
}
export interface SetupRequest {
username: string
password: string
server_address?: string
}
export interface SystemStatus {
success: boolean
message: string
data: {
system_name: string
logo: string
footer_html: string
version: string
registration_enabled: boolean
password_login_enabled: boolean
password_register_enabled: boolean
email_verification_enabled: boolean
github_oauth_enabled: boolean
discord_oauth_enabled: boolean
oidc_enabled: boolean
wechat_enabled: boolean
telegram_enabled: boolean
linux_do_enabled: boolean
turnstile_check_enabled: boolean
turnstile_site_key: string
aff_enabled: boolean
setup: boolean
}
}
export async function login(username: string, password: string) {
return post<ApiResponse>('/user/login', { username, password })
}
export async function register(username: string, email: string, password: string, affCode?: string) {
return post<ApiResponse>('/user/register', {
username,
password,
email,
aff_code: affCode,
})
}
export async function sendResetEmail(email: string) {
return get<ApiResponse>('/reset_password', { email })
}
export async function resetPassword(token: string, password: string) {
return post<ApiResponse>('/user/reset', { token, password })
}
export async function getSystemStatus() {
return get<SystemStatus>('/status')
}
export async function setup(data: SetupRequest) {
return post<ApiResponse>('/setup', data)
}
+51
View File
@@ -0,0 +1,51 @@
import { get, post, put, del } from './client'
import type { Channel } from '@/types/channel'
import type { PaginatedResponse } from '@/types/api'
export function getChannels(page = 1, size = 10, search = '') {
return get<PaginatedResponse<Channel>>('/channel/', {
page,
page_size: size,
keyword: search,
})
}
export function searchChannels(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<Channel>>('/channel/search', params)
}
export function getChannel(id: number) {
return get<Channel>(`/channel/${id}`)
}
export function createChannel(data: Partial<Channel>) {
return post<Channel>('/channel/', data)
}
export function updateChannel(data: Partial<Channel>) {
return put<Channel>(`/channel/${data.id}`, data)
}
export function deleteChannel(id: number) {
return del<{ success: boolean }>(`/channel/${id}`)
}
export function testChannel(id: number) {
return get<{ success: boolean; message: string; time: number }>(`/channel/test/${id}`)
}
export function updateChannelBalance(id: number) {
return get<{ success: boolean; balance: number }>(`/channel/update_balance/${id}`)
}
export function fetchChannelModels(id: number) {
return get<{ success: boolean; models: string[] }>(`/channel/fetch_models/${id}`)
}
export function batchDeleteChannels(ids: number[]) {
return post<{ success: boolean }>('/channel/batch_delete', { ids })
}
export function batchTagChannels(ids: number[], tag: string) {
return post<{ success: boolean }>('/channel/batch_tag', { ids, tag })
}
+60
View File
@@ -0,0 +1,60 @@
import axios from 'axios'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
client.interceptors.request.use(
(config) => {
const uid = localStorage.getItem('uid')
if (uid) {
config.headers['New-Api-User'] = uid
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
client.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('uid')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export async function get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
const response = await client.get<T>(url, { params })
return response.data
}
export async function post<T>(url: string, data?: unknown): Promise<T> {
const response = await client.post<T>(url, data)
return response.data
}
export async function put<T>(url: string, data?: unknown): Promise<T> {
const response = await client.put<T>(url, data)
return response.data
}
export async function del<T>(url: string): Promise<T> {
const response = await client.delete<T>(url)
return response.data
}
export default client
+44
View File
@@ -0,0 +1,44 @@
import { get, post, put, del } from './client'
import type { PaginatedResponse } from '@/types/api'
export interface Deployment {
id: number
name: string
status: string
hardware: string
location: string
created_at: number
updated_at: number
}
export function getDeployments(page = 1, size = 10, search = '') {
return get<PaginatedResponse<Deployment>>('/deployments/', {
page,
page_size: size,
keyword: search,
})
}
export function searchDeployments(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<Deployment>>('/deployments/search', params)
}
export function createDeployment(data: Partial<Deployment>) {
return post<Deployment>('/deployments/', data)
}
export function updateDeployment(data: Partial<Deployment>) {
return put<Deployment>(`/deployments/${data.id}`, data)
}
export function deleteDeployment(id: number) {
return del<{ success: boolean }>(`/deployments/${id}`)
}
export function getDeploymentLogs(id: number) {
return get<{ logs: string[] }>(`/deployments/${id}/logs`)
}
export function getDeploymentContainers(id: number) {
return get<{ containers: Record<string, unknown>[] }>(`/deployments/${id}/containers`)
}
+43
View File
@@ -0,0 +1,43 @@
import { get, post, put, del } from './client'
import type { Doc, DocCategory, DocVersion } from '@/types/doc'
import type { PaginatedResponse } from '@/types/api'
export function getCategories() {
return get<DocCategory[]>('/docs/categories')
}
export function createCategory(data: Partial<DocCategory>) {
return post<DocCategory>('/docs/categories', data)
}
export function updateCategory(id: number, data: Partial<DocCategory>) {
return put<DocCategory>(`/docs/categories/${id}`, data)
}
export function deleteCategory(id: number) {
return del<{ success: boolean }>(`/docs/categories/${id}`)
}
export function getDocs(params?: { category?: string; search?: string; page?: number; page_size?: number }) {
return get<PaginatedResponse<Doc>>('/docs/', params)
}
export function getDoc(slug: string) {
return get<Doc>(`/docs/${slug}`)
}
export function createDoc(data: Partial<Doc>) {
return post<Doc>('/docs/', data)
}
export function updateDoc(id: number, data: Partial<Doc>) {
return put<Doc>(`/docs/${id}`, data)
}
export function deleteDoc(id: number) {
return del<{ success: boolean }>(`/docs/${id}`)
}
export function getDocVersions(id: number) {
return get<DocVersion[]>(`/docs/${id}/versions`)
}
+47
View File
@@ -0,0 +1,47 @@
import { get } from './client'
import type { Log } from '@/types/log'
import type { PaginatedResponse } from '@/types/api'
export interface LogSearchParams {
[key: string]: unknown
page?: number
page_size?: number
keyword?: string
start_time?: number
end_time?: number
model_name?: string
token_name?: string
type?: number
}
export interface LogStat {
quota: number
count: number
token_used: number
}
export interface LogStatData {
days: string[]
quota_data: number[]
count_data: number[]
}
export function getSelfLogs(params: LogSearchParams = {}) {
return get<PaginatedResponse<Log>>('/log/self/', params)
}
export function searchSelfLogs(params: LogSearchParams = {}) {
return get<PaginatedResponse<Log>>('/log/self/search', params)
}
export function getSelfLogStat() {
return get<LogStatData>('/log/self/stat')
}
export function getMidjourneyLogs(params: { page?: number; page_size?: number } = {}) {
return get<PaginatedResponse<Record<string, unknown>>>('/mj/self', params)
}
export function getTaskLogs(params: { page?: number; page_size?: number } = {}) {
return get<PaginatedResponse<Record<string, unknown>>>('/task/self', params)
}
+45
View File
@@ -0,0 +1,45 @@
import { get, post, put, del } from './client'
import type { PaginatedResponse } from '@/types/api'
export interface ModelMeta {
id: number
model_id: string
owner: string
enabled: boolean
input_price: number
output_price: number
description: string
created_at: number
}
export function getModels(page = 1, size = 10, search = '') {
return get<PaginatedResponse<ModelMeta>>('/models/', {
page,
page_size: size,
keyword: search,
})
}
export function searchModels(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<ModelMeta>>('/models/search', params)
}
export function createModel(data: Partial<ModelMeta>) {
return post<ModelMeta>('/models/', data)
}
export function updateModel(data: Partial<ModelMeta>) {
return put<ModelMeta>(`/models/${data.id}`, data)
}
export function deleteModel(id: number) {
return del<{ success: boolean }>(`/models/${id}`)
}
export function syncModelsFromUpstream() {
return post<{ success: boolean; count: number }>('/models/sync')
}
export function getMissingModels() {
return get<{ models: string[] }>('/models/missing')
}
+10
View File
@@ -0,0 +1,10 @@
import { get, put } from './client'
import type { OptionGroup } from '@/types/option'
export function getOptions() {
return get<OptionGroup>('/option/')
}
export function updateOption(data: OptionGroup) {
return put<{ success: boolean; message: string }>('/option/', data)
}
+52
View File
@@ -0,0 +1,52 @@
import { get } from '@/api/client'
export interface PricingModel {
model_name: string
model_owner: string
input_price: number
output_price: number
context_length: number
channel_type: number
group: string
}
export interface PricingResponse {
success: boolean
message: string
data: PricingModel[]
}
export interface AboutResponse {
success: boolean
message: string
data: {
version: string
commit_hash: string
build_time: string
license: string
repository: string
based_on: string
}
}
export interface ContentResponse {
success: boolean
message: string
data: string
}
export async function getPricing() {
return get<PricingResponse>('/pricing')
}
export async function getAbout() {
return get<AboutResponse>('/about')
}
export async function getUserAgreement() {
return get<ContentResponse>('/user-agreement')
}
export async function getPrivacyPolicy() {
return get<ContentResponse>('/privacy-policy')
}
+41
View File
@@ -0,0 +1,41 @@
import { get, post, put, del } from './client'
import type { PaginatedResponse } from '@/types/api'
export interface Redemption {
id: number
name: string
key: string
quota: number
used_count: number
status: number
created_time: number
redeemed_time: number
}
export function getRedemptions(page = 1, size = 10, search = '') {
return get<PaginatedResponse<Redemption>>('/redemption/', {
page,
page_size: size,
keyword: search,
})
}
export function searchRedemptions(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<Redemption>>('/redemption/search', params)
}
export function createRedemption(data: Partial<Redemption>) {
return post<Redemption>('/redemption/', data)
}
export function updateRedemption(data: Partial<Redemption>) {
return put<Redemption>(`/redemption/${data.id}`, data)
}
export function deleteRedemption(id: number) {
return del<{ success: boolean }>(`/redemption/${id}`)
}
export function batchDeleteInvalidRedemptions() {
return post<{ success: boolean; count: number }>('/redemption/batch_delete_invalid')
}
+34
View File
@@ -0,0 +1,34 @@
import { get, post } from './client'
import type { SubscriptionPlan, SubscriptionOrder } from '@/types/subscription'
export function getPlans() {
return get<SubscriptionPlan[]>('/subscription/plans')
}
export function getSelfSubscription() {
return get<SubscriptionOrder[]>('/subscription/self')
}
export function updatePreference(preference: string) {
return post<{ success: boolean }>('/subscription/preference', { preference })
}
export function balancePay(planId: number) {
return post<{ success: boolean; message: string }>('/subscription/pay/balance', { plan_id: planId })
}
export function epayPay(planId: number, method: string) {
return post<{ url: string }>('/subscription/pay/epay', { plan_id: planId, method })
}
export function stripePay(planId: number) {
return post<{ url: string }>('/subscription/pay/stripe', { plan_id: planId })
}
export function creemPay(planId: number) {
return post<{ url: string }>('/subscription/pay/creem', { plan_id: planId })
}
export function waffoPancakePay(planId: number) {
return post<{ url: string }>('/subscription/pay/waffo-pancake', { plan_id: planId })
}
+35
View File
@@ -0,0 +1,35 @@
import { get, post, put, del } from './client'
import type { Token } from '@/types/token'
import type { PaginatedResponse } from '@/types/api'
export function getTokens(page = 1, size = 10, search = '') {
return get<PaginatedResponse<Token>>('/token/', {
page,
page_size: size,
keyword: search,
})
}
export function getToken(id: number) {
return get<Token>(`/token/${id}`)
}
export function createToken(data: Partial<Token>) {
return post<Token>('/token/', data)
}
export function updateToken(data: Partial<Token>) {
return put<Token>(`/token/${data.id}`, data)
}
export function deleteToken(id: number) {
return del<{ success: boolean }>(`/token/${id}`)
}
export function batchDelete(ids: number[]) {
return del<{ success: boolean }>('/token/batch', )
}
export function getTokenKey(id: number) {
return get<{ key: string }>(`/token/${id}/key`)
}
+38
View File
@@ -0,0 +1,38 @@
import { get, put, del, post } from './client'
import type { User } from '@/types/user'
export function getSelf() {
return get<User>('/user/self')
}
export function updateSelf(data: Partial<User> & { password?: string; old_password?: string }) {
return put<{ success: boolean; message: string }>('/user/self', data)
}
export function deleteSelf() {
return del<{ success: boolean; message: string }>('/user/self')
}
export function get2FAStatus() {
return get<{ enabled: boolean }>('/user/2fa/status')
}
export function setup2FA() {
return get<{ secret: string; url: string; qr_code: string }>('/user/2fa/setup')
}
export function enable2FA(code: string) {
return post<{ success: boolean; message: string }>('/user/2fa/enable', { code })
}
export function disable2FA(code: string) {
return post<{ success: boolean; message: string }>('/user/2fa/disable', { code })
}
export function regenerateBackupCodes() {
return post<{ codes: string[] }>('/user/2fa/backup-codes/regenerate')
}
export function generateAccessToken() {
return post<{ token: string }>('/user/token')
}
+33
View File
@@ -0,0 +1,33 @@
import { get, post, put, del } from './client'
import type { PaginatedResponse } from '@/types/api'
export interface Vendor {
id: number
name: string
description: string
created_at: number
}
export function getVendors(page = 1, size = 10, search = '') {
return get<PaginatedResponse<Vendor>>('/vendors/', {
page,
page_size: size,
keyword: search,
})
}
export function searchVendors(params: Record<string, unknown> = {}) {
return get<PaginatedResponse<Vendor>>('/vendors/search', params)
}
export function createVendor(data: Partial<Vendor>) {
return post<Vendor>('/vendors/', data)
}
export function updateVendor(data: Partial<Vendor>) {
return put<Vendor>(`/vendors/${data.id}`, data)
}
export function deleteVendor(id: number) {
return del<{ success: boolean }>(`/vendors/${id}`)
}
+52
View File
@@ -0,0 +1,52 @@
import { get, post } from './client'
export interface TopUpInfo {
epay_enabled: boolean
stripe_enabled: boolean
creem_enabled: boolean
waffo_enabled: boolean
checkin_enabled: boolean
min_amount: number
discount: number
discount_msg: string
}
export function getTopUpInfo() {
return get<TopUpInfo>('/user/topup/info')
}
export function redeemCode(code: string) {
return post<{ success: boolean; message: string }>('/user/topup', { key: code })
}
export function epayPay(amount: number, method: string) {
return post<{ url: string }>('/user/topup/epay', { amount, method })
}
export function stripePay(amount: number) {
return post<{ url: string }>('/user/topup/stripe', { amount })
}
export function creemPay(amount: number) {
return post<{ url: string }>('/user/topup/creem', { amount })
}
export function waffoPay(amount: number) {
return post<{ url: string }>('/user/topup/waffo', { amount })
}
export function waffoPancakePay(amount: number) {
return post<{ url: string }>('/user/topup/waffo-pancake', { amount })
}
export function checkIn() {
return post<{ success: boolean; message: string; quota: number }>('/user/checkin')
}
export function getCheckInStatus() {
return get<{ checked_in: boolean; quota: number }>('/user/checkin/status')
}
export function transferAffQuota(quota: number) {
return post<{ success: boolean; message: string }>('/user/aff_quota', { quota })
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+49
View File
@@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
interface ConfirmDialogProps {
open: boolean
title?: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'error' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({
open,
title,
message,
confirmLabel,
cancelLabel,
variant = 'error',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation()
if (!open) return null
const btnClass = variant === 'error' ? 'btn-error' : variant === 'warning' ? 'btn-warning' : 'btn-primary'
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold">{title || t('common.confirm')}</h3>
<p className="py-4">{message}</p>
<div className="modal-action">
<button className="btn btn-sm" onClick={onCancel}>
{cancelLabel || t('common.cancel')}
</button>
<button className={`btn btn-sm ${btnClass}`} onClick={onConfirm}>
{confirmLabel || t('common.confirm')}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}>close</button>
</form>
</dialog>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import LoadingSpinner from './LoadingSpinner'
export interface Column<T> {
key: string
header: React.ReactNode
render?: (row: T) => React.ReactNode
sortable?: boolean
width?: string
align?: 'left' | 'center' | 'right'
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
loading?: boolean
emptyText?: string
total?: number
page?: number
pageSize?: number
onPageChange?: (page: number) => void
onPageSizeChange?: (size: number) => void
onSort?: (key: string, order: 'asc' | 'desc') => void
rowKey?: (row: T) => string | number
className?: string
}
export default function DataTable<T extends Record<string, unknown>>({
columns,
data,
loading = false,
emptyText,
total = 0,
page = 1,
pageSize = 10,
onPageChange,
onPageSizeChange,
onSort,
rowKey,
className,
}: DataTableProps<T>) {
const { t } = useTranslation()
const [sortKey, setSortKey] = useState<string>('')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const totalPages = Math.ceil(total / pageSize)
const handleSort = (key: string) => {
const newOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc'
setSortKey(key)
setSortOrder(newOrder)
onSort?.(key, newOrder)
}
const getSortIcon = (key: string) => {
if (sortKey !== key) return <ChevronsUpDown size={14} className="opacity-30" />
return sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
return (
<div className={cn('overflow-x-auto', className)}>
<table className="table table-zebra table-sm">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={cn(
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right',
col.sortable && 'cursor-pointer select-none hover:bg-base-200'
)}
onClick={() => col.sortable && handleSort(col.key)}
>
<span className="flex items-center gap-1">
{col.header}
{col.sortable && getSortIcon(col.key)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} className="text-center py-12">
<LoadingSpinner size="md" />
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center py-12 text-base-content/50">
{emptyText || t('common.noData')}
</td>
</tr>
) : (
data.map((row, index) => (
<tr key={rowKey ? rowKey(row) : index}>
{columns.map((col) => (
<td
key={col.key}
className={cn(
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right'
)}
>
{col.render ? col.render(row) : (row[col.key] as React.ReactNode) ?? '-'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{/* Pagination */}
{total > 0 && (
<div className="flex items-center justify-between px-2 py-3">
<div className="text-sm text-base-content/60">
{t('common.total')} {total} {t('common.items')}
</div>
<div className="flex items-center gap-2">
<select
className="select select-bordered select-sm"
value={pageSize}
onChange={(e) => onPageSizeChange?.(Number(e.target.value))}
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
{size} {t('common.items')}/{t('common.page')}
</option>
))}
</select>
<div className="join">
<button
className="join-item btn btn-sm"
disabled={page <= 1}
onClick={() => onPageChange?.(page - 1)}
>
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (page <= 3) {
pageNum = i + 1
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = page - 2 + i
}
return (
<button
key={pageNum}
className={cn('join-item btn btn-sm', page === pageNum && 'btn-active')}
onClick={() => onPageChange?.(pageNum)}
>
{pageNum}
</button>
)
})}
<button
className="join-item btn btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange?.(page + 1)}
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils'
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
text?: string
}
const sizeClasses = {
sm: 'loading-sm',
md: 'loading-md',
lg: 'loading-lg',
}
export default function LoadingSpinner({ size = 'md', className, text }: LoadingSpinnerProps) {
return (
<div className={cn('flex flex-col items-center justify-center gap-2', className)}>
<span className={cn('loading loading-spinner', sizeClasses[size])} />
{text && <span className="text-sm text-base-content/60">{text}</span>}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
actions?: React.ReactNode
className?: string
}
export default function PageHeader({ title, description, actions, className }: PageHeaderProps) {
return (
<div className={cn('flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6', className)}>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
{description && <p className="text-base-content/60 mt-1">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { renderQuota } from '@/lib/quota'
import { cn } from '@/lib/utils'
interface QuotaDisplayProps {
quota: number
displayInCurrency?: boolean
className?: string
showUnit?: boolean
}
export default function QuotaDisplay({
quota,
displayInCurrency = true,
className,
showUnit = false,
}: QuotaDisplayProps) {
const formatted = renderQuota(quota, displayInCurrency)
return (
<span className={cn('font-mono tabular-nums', className)}>
{formatted}
{showUnit && !displayInCurrency && <span className="ml-1 text-base-content/60">quota</span>}
</span>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
export default function SearchInput({
value,
onChange,
placeholder,
className,
}: SearchInputProps) {
const { t } = useTranslation()
return (
<label className={cn('input input-bordered input-sm flex items-center gap-2', className)}>
<Search size={14} className="opacity-50" />
<input
type="text"
className="grow"
placeholder={placeholder || t('common.search')}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils'
type BadgeVariant = 'success' | 'warning' | 'error' | 'neutral' | 'info'
interface StatusBadgeProps {
variant: BadgeVariant
label: string
className?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'badge-success',
warning: 'badge-warning',
error: 'badge-error',
neutral: 'badge-ghost',
info: 'badge-info',
}
export default function StatusBadge({ variant, label, className, size = 'sm' }: StatusBadgeProps) {
return (
<span
className={cn(
'badge',
variantClasses[variant],
size === 'xs' && 'badge-xs',
size === 'sm' && 'badge-sm',
size === 'md' && 'badge-md',
size === 'lg' && 'badge-lg',
className
)}
>
{label}
</span>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Outlet } from 'react-router-dom'
import Navbar from './Navbar'
import Sidebar from './Sidebar'
import { useUIStore } from '@/stores/ui'
export default function AppLayout() {
const { sidebarOpen, setSidebarOpen, sidebarCollapsed } = useUIStore()
return (
<div className="min-h-screen bg-base-200">
<div className="drawer">
<input
id="sidebar-drawer"
type="checkbox"
className="drawer-toggle"
checked={sidebarOpen}
onChange={(e) => setSidebarOpen(e.target.checked)}
/>
<div className="drawer-content flex flex-col">
<Navbar />
<main className="flex-1 p-4 md:p-6">
<Outlet />
</main>
</div>
<div className="drawer-side z-40">
<label htmlFor="sidebar-drawer" aria-label="close sidebar" className="drawer-overlay" />
<Sidebar />
</div>
</div>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Sun, Moon, Bell, Search, LogOut, User, Settings } from 'lucide-react'
import { useUIStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { usePermission } from '@/hooks/usePermission'
export default function Navbar() {
const { t } = useTranslation()
const navigate = useNavigate()
const { theme, toggleTheme } = useUIStore()
const { user } = useAuthStore()
const { isAdmin, isRoot } = usePermission()
const handleLogout = async () => {
await useAuthStore.getState().logout()
navigate('/login')
}
return (
<div className="navbar bg-base-100 border-b border-base-300 sticky top-0 z-30">
<div className="flex-none lg:hidden">
<label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</label>
</div>
<div className="flex-1 gap-2">
<span className="text-lg font-bold text-primary hidden sm:inline">ModelsToken</span>
</div>
<div className="flex-none gap-2">
{/* Search */}
<div className="form-control hidden md:block">
<label className="input input-bordered input-sm flex items-center gap-2">
<Search size={14} className="opacity-50" />
<input type="text" className="grow" placeholder={t('common.search')} />
</label>
</div>
{/* Theme toggle */}
<button className="btn btn-ghost btn-circle btn-sm" onClick={toggleTheme}>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
{/* Notifications */}
<button className="btn btn-ghost btn-circle btn-sm indicator">
<Bell size={18} />
<span className="badge badge-xs badge-primary indicator-item"></span>
</button>
{/* User menu */}
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm avatar">
<div className="w-8 rounded-full bg-primary text-primary-content flex items-center justify-center">
<span className="text-xs font-bold">
{user?.display_name?.[0] || user?.username?.[0] || 'U'}
</span>
</div>
</div>
<ul
tabIndex={0}
className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
>
<li className="menu-title">
<span>{user?.display_name || user?.username}</span>
</li>
<li>
<a onClick={() => navigate('/profile')}>
<User size={16} />
{t('nav.profile')}
</a>
</li>
{(isAdmin || isRoot) && (
<li>
<a onClick={() => navigate('/settings/site')}>
<Settings size={16} />
{t('nav.settings')}
</a>
</li>
)}
<div className="divider my-0"></div>
<li>
<a onClick={handleLogout} className="text-error">
<LogOut size={16} />
{t('nav.logout')}
</a>
</li>
</ul>
</div>
</div>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
LayoutDashboard,
Key,
Wallet,
CreditCard,
ScrollText,
Image,
ListTodo,
User,
Terminal,
BookOpen,
Settings,
Server,
Users,
Gift,
Cpu,
Truck,
Layers,
} from 'lucide-react'
import { usePermission } from '@/hooks/usePermission'
import { cn } from '@/lib/utils'
import { useUIStore } from '@/stores/ui'
interface NavItem {
label: string
path: string
icon: React.ReactNode
minRole?: number
}
export default function Sidebar() {
const { t } = useTranslation()
const location = useLocation()
const { isUser, isAdmin, isRoot } = usePermission()
const { setSidebarOpen, sidebarCollapsed } = useUIStore()
const userNavItems: NavItem[] = [
{ label: t('nav.dashboard'), path: '/dashboard', icon: <LayoutDashboard size={18} /> },
{ label: t('nav.tokens'), path: '/tokens', icon: <Key size={18} /> },
{ label: t('nav.wallet'), path: '/wallet', icon: <Wallet size={18} /> },
{ label: t('nav.subscriptions'), path: '/subscriptions', icon: <CreditCard size={18} /> },
{ label: t('nav.logs'), path: '/logs', icon: <ScrollText size={18} /> },
{ label: t('nav.midjourney'), path: '/logs/midjourney', icon: <Image size={18} /> },
{ label: t('nav.tasks'), path: '/logs/tasks', icon: <ListTodo size={18} /> },
{ label: t('nav.profile'), path: '/profile', icon: <User size={18} /> },
{ label: t('nav.playground'), path: '/playground', icon: <Terminal size={18} /> },
{ label: t('nav.docs'), path: '/docs', icon: <BookOpen size={18} /> },
]
const adminNavItems: NavItem[] = [
{ label: t('nav.channels'), path: '/admin/channels', icon: <Server size={18} />, minRole: 10 },
{ label: t('nav.users'), path: '/admin/users', icon: <Users size={18} />, minRole: 10 },
{ label: t('nav.redemptions'), path: '/admin/redemptions', icon: <Gift size={18} />, minRole: 10 },
{ label: t('nav.models'), path: '/admin/models', icon: <Cpu size={18} />, minRole: 10 },
{ label: t('nav.vendors'), path: '/admin/vendors', icon: <Truck size={18} />, minRole: 10 },
{ label: t('nav.deployments'), path: '/admin/deployments', icon: <Layers size={18} />, minRole: 10 },
{ label: t('nav.subscriptions'), path: '/admin/subscriptions', icon: <CreditCard size={18} />, minRole: 10 },
]
const rootNavItems: NavItem[] = [
{ label: t('nav.settings'), path: '/settings/site', icon: <Settings size={18} />, minRole: 100 },
]
const isActive = (path: string) => {
if (path === '/logs') return location.pathname === '/logs'
return location.pathname.startsWith(path)
}
const handleNavClick = () => {
setSidebarOpen(false)
}
const filterByRole = (items: NavItem[]) =>
items.filter((item) => {
if (!item.minRole) return true
if (item.minRole <= 1) return isUser
if (item.minRole <= 10) return isAdmin
return isRoot
})
return (
<aside
className={cn(
'bg-base-100 h-full flex flex-col',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
<div className="p-4 border-b border-base-300">
<h1 className="text-xl font-bold text-primary">ModelsToken</h1>
</div>
<nav className="flex-1 overflow-y-auto">
<ul className="menu menu-md p-2 gap-1">
{filterByRole(userNavItems).map((item) => (
<li key={item.path}>
<a
href={item.path}
onClick={(e) => {
e.preventDefault()
handleNavClick()
window.history.pushState({}, '', item.path)
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className={cn(
'flex items-center gap-3 rounded-lg',
isActive(item.path) && 'active'
)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
</a>
</li>
))}
{filterByRole(adminNavItems).length > 0 && (
<>
<li className="menu-title mt-2">
{!sidebarCollapsed && <span>{t('nav.channels')}</span>}
</li>
{filterByRole(adminNavItems).map((item) => (
<li key={item.path}>
<a
href={item.path}
onClick={(e) => {
e.preventDefault()
handleNavClick()
window.history.pushState({}, '', item.path)
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className={cn(
'flex items-center gap-3 rounded-lg',
isActive(item.path) && 'active'
)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
</a>
</li>
))}
</>
)}
{isRoot && (
<>
<li className="menu-title mt-2">
{!sidebarCollapsed && <span>{t('nav.settings')}</span>}
</li>
{filterByRole(rootNavItems).map((item) => (
<li key={item.path}>
<a
href={item.path}
onClick={(e) => {
e.preventDefault()
handleNavClick()
window.history.pushState({}, '', item.path)
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className={cn(
'flex items-center gap-3 rounded-lg',
isActive(item.path) && 'active'
)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
</a>
</li>
))}
</>
)}
</ul>
</nav>
</aside>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { useAuthStore } from '@/stores/auth'
import type { User } from '@/types/user'
export function useAuth() {
const { user, isAuthenticated, isAdmin, isRoot, login, logout, register, fetchUser, setUser } =
useAuthStore()
return {
user: user as User | null,
isAuthenticated,
isAdmin,
isRoot,
login,
logout,
register,
fetchUser,
setUser,
}
}
+16
View File
@@ -0,0 +1,16 @@
import { useAuthStore } from '@/stores/auth'
import { ROLE_USER, ROLE_ADMIN, ROLE_ROOT } from '@/lib/constants'
export function usePermission() {
const { user, isAuthenticated, isAdmin, isRoot } = useAuthStore()
const role = user?.role ?? 0
return {
isAuthenticated,
isUser: role >= ROLE_USER,
isAdmin: role >= ROLE_ADMIN,
isRoot: role >= ROLE_ROOT,
role,
}
}
+11
View File
@@ -0,0 +1,11 @@
import { renderQuota, quotaToCurrency, currencyToQuota } from '@/lib/quota'
import { QUOTA_PER_UNIT } from '@/lib/constants'
export function useQuota() {
return {
renderQuota,
quotaToCurrency,
currencyToQuota,
quotaPerUnit: QUOTA_PER_UNIT,
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { getOptions, updateOption } from '@/api/option'
import type { OptionGroup } from '@/types/option'
export function useSettings() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [original, setOriginal] = useState<OptionGroup>({})
const { data, isLoading } = useQuery({
queryKey: ['options'],
queryFn: getOptions,
})
useEffect(() => {
if (data) {
setOriginal(data)
}
}, [data])
const saveMut = useMutation({
mutationFn: updateOption,
onSuccess: () => {
toast.success(t('common.saveSuccess'))
queryClient.invalidateQueries({ queryKey: ['options'] })
},
onError: () => {
toast.error(t('common.operationFailed'))
},
})
const save = useCallback(
(changed: Record<string, string>) => {
// Only send changed keys
const diff: Record<string, string> = {}
for (const key of Object.keys(changed)) {
if (changed[key] !== original[key]) {
diff[key] = changed[key]
}
}
if (Object.keys(diff).length === 0) {
toast(t('settings.noChanges'), { icon: '️' })
return
}
saveMut.mutate(diff)
},
[original, saveMut, t]
)
return {
options: data ?? {},
original,
isLoading,
save,
isSaving: saveMut.isPending,
}
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './locales/en.json'
import zh from './locales/zh.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
zh: { translation: zh },
},
fallbackLng: 'zh',
lng: 'zh',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage'],
},
})
export default i18n
+625
View File
@@ -0,0 +1,625 @@
{
"common": {
"appName": "ModelsToken",
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"refresh": "Refresh",
"reset": "Reset",
"submit": "Submit",
"back": "Back",
"next": "Next",
"previous": "Previous",
"close": "Close",
"export": "Export",
"import": "Import",
"download": "Download",
"upload": "Upload",
"copy": "Copy",
"copied": "Copied!",
"more": "More",
"actions": "Actions",
"status": "Status",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No",
"all": "All",
"none": "None",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info",
"noData": "No data",
"total": "Total",
"items": "items",
"rowsPerPage": "Rows per page",
"page": "Page",
"of": "of",
"sortBy": "Sort by",
"ascending": "Ascending",
"descending": "Descending",
"selected": "Selected",
"bulkActions": "Bulk Actions",
"deleteConfirm": "Are you sure you want to delete?",
"deleteSuccess": "Deleted successfully",
"saveSuccess": "Saved successfully",
"operationSuccess": "Operation successful",
"operationFailed": "Operation failed",
"networkError": "Network error",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"notFound": "Not found",
"serverError": "Server error",
"timeout": "Request timeout",
"leaveEmpty": "Leave empty",
"select": "Select",
"used": "Used",
"createdAt": "Created At",
"sortOrder": "Sort Order"
},
"nav": {
"dashboard": "Dashboard",
"tokens": "Tokens",
"channels": "Channels",
"users": "Users",
"logs": "Logs",
"wallet": "Wallet",
"subscriptions": "Subscriptions",
"redemptions": "Redemptions",
"models": "Models",
"vendors": "Vendors",
"deployments": "Deployments",
"playground": "Playground",
"docs": "Documentation",
"profile": "Profile",
"settings": "Settings",
"logout": "Logout",
"login": "Login",
"register": "Register",
"about": "About",
"pricing": "Pricing",
"midjourney": "Midjourney",
"tasks": "Tasks"
},
"auth": {
"loginTitle": "Sign In",
"registerTitle": "Sign Up",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"email": "Email",
"forgotPassword": "Forgot Password?",
"resetPassword": "Reset Password",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"signUp": "Sign Up",
"signIn": "Sign In",
"affCode": "Invitation Code",
"verificationCode": "Verification Code",
"sendCode": "Send Code",
"loginSuccess": "Login successful",
"registerSuccess": "Registration successful",
"logoutSuccess": "Logged out successfully",
"passwordMismatch": "Passwords do not match",
"invalidCredentials": "Invalid username or password",
"rememberMe": "Remember me",
"orContinueWith": "Or continue with",
"invalidEmail": "Invalid email format",
"passwordTooShort": "Password must be at least 8 characters",
"resetEmailSent": "Reset email sent",
"resetEmailSentDesc": "A password reset link has been sent to your email",
"forgotPasswordDesc": "Enter your email address and we'll send you a reset link",
"sendResetEmail": "Send Reset Email",
"invalidResetToken": "Invalid reset token",
"resetPasswordSuccess": "Password reset successfully"
},
"user": {
"id": "ID",
"username": "Username",
"displayName": "Display Name",
"email": "Email",
"role": "Role",
"group": "Group",
"quota": "Quota",
"usedQuota": "Used Quota",
"requestCount": "Request Count",
"status": "Status",
"createdAt": "Created At",
"lastLoginAt": "Last Login",
"affCode": "Invitation Code",
"affCount": "Invitation Count",
"affQuota": "Invitation Quota",
"remark": "Remark",
"roleUser": "User",
"roleAdmin": "Admin",
"roleRoot": "Root"
},
"token": {
"name": "Token Name",
"key": "Key",
"status": "Status",
"quota": "Quota",
"usedQuota": "Used Quota",
"remainQuota": "Remaining Quota",
"unlimitedQuota": "Unlimited",
"expiredTime": "Expiration",
"createdTime": "Created",
"accessedTime": "Last Accessed",
"modelLimits": "Model Limits",
"modelLimitsEnabled": "Model Limits Enabled",
"allowIps": "Allowed IPs",
"neverExpire": "Never Expire",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"statusExpired": "Expired",
"statusExhausted": "Exhausted",
"createToken": "Create Token",
"copyKey": "Copy Key",
"regenerateKey": "Regenerate Key"
},
"channel": {
"name": "Channel Name",
"type": "Channel Type",
"status": "Status",
"priority": "Priority",
"weight": "Weight",
"models": "Models",
"group": "Group",
"baseUrl": "Base URL",
"key": "Key",
"testModel": "Test Model",
"responseTime": "Response Time",
"balance": "Balance",
"usedQuota": "Used Quota",
"createdTime": "Created",
"testTime": "Last Test",
"autoBan": "Auto Ban",
"tag": "Tag",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"statusAutoDisabled": "Auto Disabled",
"testChannel": "Test Channel",
"updateBalance": "Update Balance",
"batchTest": "Batch Test",
"fetchModels": "Fetch Models",
"modelMapping": "Model Mapping"
},
"redemption": {
"name": "Name",
"key": "Redemption Key",
"quota": "Quota",
"usedCount": "Used Count",
"count": "Count",
"batchDeleteInvalid": "Delete Invalid Codes"
},
"model": {
"modelId": "Model ID",
"owner": "Owner",
"inputPrice": "Input Price",
"outputPrice": "Output Price",
"syncUpstream": "Sync Upstream",
"missingModels": "Missing Models"
},
"vendor": {
"name": "Name",
"description": "Description"
},
"deployment": {
"name": "Name",
"hardware": "Hardware",
"location": "Location",
"statusRunning": "Running",
"statusStopped": "Stopped",
"statusError": "Error",
"viewLogs": "View Logs",
"viewContainers": "View Containers"
},
"log": {
"type": "Type",
"time": "Time",
"user": "User",
"token": "Token",
"model": "Model",
"quota": "Quota",
"promptTokens": "Prompt Tokens",
"completionTokens": "Completion Tokens",
"channel": "Channel",
"duration": "Duration",
"isStream": "Stream",
"group": "Group",
"ip": "IP",
"requestId": "Request ID",
"typeTopup": "Top Up",
"typeConsume": "Consume",
"typeManage": "Manage",
"typeSystem": "System",
"typeError": "Error",
"typeRefund": "Refund",
"totalQuota": "Total Quota Used",
"totalRequests": "Total Requests"
},
"subscription": {
"title": "Subscription",
"plan": "Plan",
"price": "Price",
"duration": "Duration",
"status": "Status",
"quota": "Quota",
"resetPeriod": "Reset Period",
"purchaseTime": "Purchase Time",
"expireTime": "Expire Time",
"active": "Active",
"expired": "Expired",
"cancelled": "Cancelled",
"durationDay": "Day",
"durationMonth": "Month",
"durationYear": "Year",
"resetNever": "Never",
"resetDaily": "Daily",
"resetWeekly": "Weekly",
"resetMonthly": "Monthly",
"currentPlan": "Current Plan",
"availablePlans": "Available Plans",
"balancePay": "Pay with Balance",
"orderHistory": "Order History",
"bindSubscription": "Bind Subscription",
"userSubscriptions": "User Subscriptions",
"sortOrder": "Sort Order"
},
"dashboard": {
"quotaTrend": "Quota Usage Trend",
"apiInfo": "API Information",
"serverAddress": "Server Address",
"affCode": "Affiliate Code",
"announcements": "Announcements",
"quickActions": "Quick Actions"
},
"wallet": {
"title": "Wallet",
"balance": "Balance",
"topUp": "Top Up",
"history": "History",
"redeemCode": "Redeem Code",
"transfer": "Transfer",
"amount": "Amount",
"paymentMethod": "Payment Method",
"transactionId": "Transaction ID",
"onlinePay": "Online Payment",
"checkIn": "Check In",
"checkedInToday": "Checked in today",
"notCheckedIn": "Not checked in today",
"affiliate": "Affiliate",
"transferAmount": "Transfer Amount"
},
"settings": {
"site": "Site Settings",
"auth": "Authentication",
"billing": "Billing",
"content": "Content",
"models": "Models",
"operations": "Operations",
"security": "Security",
"docs": "Documentation",
"general": "General",
"systemName": "System Name",
"logo": "Logo",
"logoUrl": "Logo URL",
"footer": "Footer HTML",
"theme": "Theme",
"language": "Language",
"noChanges": "No changes",
"basicInfo": "Basic Info",
"contentInfo": "Content",
"legal": "Legal",
"navigation": "Navigation",
"serverAddress": "Server Address",
"notice": "System Notice",
"about": "About",
"homePageContent": "Home Page Content",
"userAgreement": "User Agreement",
"privacyPolicy": "Privacy Policy",
"headerNavModules": "Header Nav Modules",
"sidebarModules": "Sidebar Modules",
"passwordAndReg": "Password & Registration",
"passwordLogin": "Allow Password Login",
"passwordRegister": "Allow Password Registration",
"emailVerification": "Email Verification",
"emailDomainWhitelist": "Email Domain Whitelist",
"emailAlias": "Allow Email Alias",
"minTrustLevel": "Min Trust Level",
"wechatOAuth": "WeChat OAuth",
"quotaConfig": "Quota Config",
"quotaForNewUser": "Quota for New User",
"preConsumedQuota": "Pre-consumed Quota",
"quotaForInviter": "Quota for Inviter",
"quotaForInvitee": "Quota for Invitee",
"freeModelPreConsumed": "Free Model Pre-consumed",
"links": "Links",
"topUpLink": "Top Up Link",
"docLink": "Doc Link",
"currencyConfig": "Currency Config",
"quotaUnit": "Quota Unit",
"quotaExchangeRate": "Exchange Rate",
"currencyDisplay": "Currency Display",
"modelRatio": "Model Ratio",
"modelRatioDesc": "Model ratio config (JSON format)",
"groupRatio": "Group Ratio",
"groupRatioDesc": "Group ratio config (JSON format)",
"epayAddress": "Pay Address",
"epayId": "Merchant ID",
"epayKey": "Merchant Key",
"otherPayment": "Other Payment",
"checkIn": "Check-in",
"checkInEnabled": "Enable Check-in",
"checkInMinQuota": "Min Check-in Quota",
"checkInMaxQuota": "Max Check-in Quota",
"consoleConfig": "Console Config",
"consoleAPIInfoPanel": "API Info Panel",
"noticeAndFAQ": "Notice & FAQ",
"noticeConfig": "Notice Config",
"faqConfig": "FAQ Config",
"uptimeKuma": "Uptime Kuma",
"uptimeKumaMonitorGroups": "Monitor Groups",
"featureToggle": "Feature Toggle",
"chatEnabled": "Enable Chat",
"drawingEnabled": "Enable Drawing",
"midjourneyConfig": "Midjourney Config",
"globalModelConfig": "Global Model Config",
"globalPassthrough": "Global Passthrough",
"thinkingModelBlacklist": "Thinking Model Blacklist",
"chatToResponsesStrategy": "Chat to Responses Strategy",
"pingInterval": "Ping Interval",
"geminiSafety": "Safety Setting",
"geminiVersion": "Version",
"geminiImageModel": "Image Model",
"geminiThinkingAdapter": "Thinking Adapter",
"geminiFunctionCalling": "Function Calling",
"claudeModelHeader": "Model Header",
"claudeDefaultMaxTokens": "Default Max Tokens",
"claudeThinkingAdapter": "Thinking Adapter",
"grokViolationDeduction": "Violation Deduction",
"opsGeneral": "General Operations",
"retryCount": "Retry Count",
"defaultCollapseSidebar": "Default Collapse Sidebar",
"demoMode": "Demo Mode",
"selfUseMode": "Self-use Mode",
"channelAutoMgmt": "Channel Auto Management",
"channelAutoDisable": "Auto Disable Channel",
"channelAutoEnable": "Auto Enable Channel",
"channelDisableThreshold": "Disable Threshold",
"channelDisableKeywords": "Disable Keywords",
"channelDisableStatusCodes": "Disable Status Codes",
"autoRetryStatusCodes": "Auto Retry Status Codes",
"autoTestChannels": "Auto Test Channels",
"smtpConfig": "SMTP Config",
"smtpServer": "SMTP Server",
"smtpPort": "Port",
"smtpAccount": "Account",
"smtpToken": "Token/Password",
"smtpFrom": "From",
"workerConfig": "Worker Config",
"workerURL": "Worker URL",
"workerKey": "Worker Key",
"loggingAndCache": "Logging & Cache",
"logConsume": "Log Consumption",
"diskCacheConfig": "Disk Cache Config",
"performanceMonitorConfig": "Performance Monitor Config",
"rateLimit": "Rate Limit",
"modelRequestRateLimit": "Model Request Rate Limit",
"sensitiveWordCheck": "Sensitive Word Check",
"sensitiveWordCheckEnabled": "Enable Sensitive Word Check",
"sensitiveWordCheckOnPrompt": "Check on Prompt",
"sensitiveWordCheckOnOutput": "Check on Output",
"networkProtection": "Network Protection",
"ssrfProtection": "SSRF Protection",
"privateIPFilter": "Private IP Filter",
"domainIPFilter": "Domain/IP Filter",
"domainFilterMode": "Domain Filter Mode",
"ipFilterMode": "IP Filter Mode",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"domainFilterList": "Domain Filter List",
"ipFilterList": "IP Filter List",
"portWhitelist": "Port Whitelist",
"portWhitelistDesc": "Allowed ports, comma separated",
"docCategoryMgmt": "Doc Category Management",
"docCategoryConfig": "Category Config",
"docVisibility": "Doc Visibility",
"docDefaultVisibility": "Default Visibility for New Docs",
"docPublic": "Public",
"docPrivate": "Private",
"docUnlisted": "Unlisted",
"docPlaceholder": "Placeholder",
"docPlaceholderContent": "Placeholder Content",
"frontendTheme": "Frontend Theme",
"themeDefault": "Default (New Frontend)",
"themeClassic": "Classic (Legacy Frontend)",
"themeDaisy": "Daisy (DaisyUI Frontend)"
},
"quota": {
"unit": "Quota",
"unlimited": "Unlimited",
"insufficient": "Insufficient quota",
"display": "Display"
},
"public": {
"heroSubtitle": "All-in-one AI API management platform, aggregating multiple providers with unified API access",
"getStarted": "Get Started",
"viewPricing": "View Pricing",
"featuresTitle": "Core Features",
"quickStartTitle": "Quick Start",
"allRightsReserved": "All rights reserved",
"feature": {
"apiGateway": "API Gateway",
"apiGatewayDesc": "Unified API entry point, compatible with OpenAI format, seamless switching",
"multiChannel": "Multi-Channel",
"multiChannelDesc": "Support 40+ AI providers with intelligent load balancing and failover",
"keyManagement": "Key Management",
"keyManagementDesc": "Flexible API key management with model limits and quota control",
"usageBilling": "Usage Billing",
"usageBillingDesc": "Precise token usage tracking and flexible billing plans",
"subscription": "Subscription",
"subscriptionDesc": "Multiple subscription plans to meet different scale requirements",
"documentation": "Documentation",
"documentationDesc": "Complete API documentation and guides for quick onboarding"
},
"pricing": {
"title": "Model Pricing",
"searchPlaceholder": "Search model name...",
"modelName": "Model Name",
"inputPrice": "Input Price",
"outputPrice": "Output Price",
"cachePrice": "Cache Price",
"contextLength": "Context Length",
"provider": "Provider",
"group": "Group",
"perToken": "/ 1K Token",
"noPricingData": "No pricing data available"
},
"about": {
"title": "About",
"version": "Version",
"commitHash": "Commit Hash",
"buildTime": "Build Time",
"license": "License",
"repository": "Repository",
"basedOn": "Based On"
},
"setup": {
"title": "Initial Setup",
"step": "Step",
"adminAccount": "Admin Account",
"systemConfig": "System Configuration",
"adminUsername": "Admin Username",
"adminPassword": "Admin Password",
"confirmAdminPassword": "Confirm Admin Password",
"systemName": "System Name",
"serverAddress": "Server Address",
"setupComplete": "Setup Complete",
"setupCompleteDesc": "System initialization complete, redirecting to login page",
"initializing": "Initializing system..."
},
"oauth": {
"callback": "OAuth Callback",
"processing": "Processing login...",
"success": "Login successful, redirecting...",
"error": "OAuth login failed",
"invalidProvider": "Invalid OAuth provider",
"backToLogin": "Back to Login"
},
"notFound": {
"title": "Page Not Found",
"description": "The page you are looking for does not exist",
"backHome": "Back to Home"
},
"userAgreement": {
"title": "User Agreement"
},
"privacyPolicy": {
"title": "Privacy Policy"
}
},
"time": {
"justNow": "Just now",
"minutesAgo": "{{count}} minutes ago",
"hoursAgo": "{{count}} hours ago",
"daysAgo": "{{count}} days ago",
"weeksAgo": "{{count}} weeks ago",
"monthsAgo": "{{count}} months ago",
"yearsAgo": "{{count}} years ago",
"never": "Never",
"permanent": "Permanent"
},
"profile": {
"profileInfo": "Profile Info",
"security": "Security",
"oauthBindings": "OAuth Bindings",
"changePassword": "Change Password",
"oldPassword": "Current Password",
"newPassword": "New Password",
"passwordTooShort": "Password must be at least 8 characters",
"twoFactor": "Two-Factor Auth",
"twoFactorEnabled": "Two-factor authentication enabled",
"twoFactorDisabled": "Two-factor authentication not enabled",
"setup2FA": "Set Up 2FA",
"enable2FA": "Enable 2FA",
"disable2FA": "Disable 2FA",
"qrCodeGenerated": "QR code generated",
"scanQRCode": "Scan the QR code with your authenticator app",
"accessToken": "Access Token",
"generateToken": "Generate Access Token",
"deleteAccount": "Delete Account",
"deleteAccountWarning": "Account data cannot be recovered after deletion. Please proceed with caution!",
"accountDeleted": "Account deleted",
"linked": "Linked",
"notLinked": "Not Linked"
},
"doc": {
"title": "Documentation",
"description": "Browse and manage documents",
"categories": "Categories",
"allDocs": "All Documents",
"createDoc": "Create Document",
"editDoc": "Edit Document",
"manageCategories": "Manage Categories",
"addCategory": "Add Category",
"categoryName": "Category Name",
"categoryLabel": "Category Label",
"parentCategory": "Parent Category",
"searchPlaceholder": "Search documents...",
"slug": "Slug",
"category": "Category",
"visibility": "Visibility",
"visibilityPublic": "Public",
"visibilityAuth": "Auth Required",
"visibilityAdmin": "Admin Only",
"published": "Published",
"content": "Content",
"contentPlaceholder": "Enter Markdown content here...",
"edit": "Edit",
"preview": "Preview",
"tableOfContents": "Table of Contents"
},
"playground": {
"title": "API Playground",
"description": "Test API calls online",
"apiKey": "API Key",
"selectApiKey": "Select API Key",
"model": "Model",
"modelPlaceholder": "Enter or select a model",
"systemPrompt": "System Prompt",
"systemPromptPlaceholder": "Enter system prompt...",
"userMessage": "User Message",
"userMessagePlaceholder": "Enter message content...",
"parameters": "Parameters",
"send": "Send",
"stop": "Stop",
"response": "Response",
"rawJson": "Raw JSON",
"noResponse": "Response will appear here after sending a request",
"promptTokens": "Prompt Tokens",
"completionTokens": "Completion Tokens",
"totalTokens": "Total Tokens",
"codeExamples": "Code Examples",
"fillRequired": "Please fill in model and message",
"selectApiKeyFirst": "Please select an API key first"
},
"mj": {
"progress": "Progress",
"image": "Image",
"viewImage": "View Image",
"failReason": "Fail Reason"
},
"task": {
"progress": "Progress",
"failReason": "Fail Reason",
"finishTime": "Finish Time"
}
}
+625
View File
@@ -0,0 +1,625 @@
{
"common": {
"appName": "ModelsToken",
"loading": "加载中...",
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"create": "创建",
"search": "搜索",
"filter": "筛选",
"refresh": "刷新",
"reset": "重置",
"submit": "提交",
"back": "返回",
"next": "下一步",
"previous": "上一步",
"close": "关闭",
"export": "导出",
"import": "导入",
"download": "下载",
"upload": "上传",
"copy": "复制",
"copied": "已复制!",
"more": "更多",
"actions": "操作",
"status": "状态",
"enabled": "已启用",
"disabled": "已禁用",
"yes": "是",
"no": "否",
"all": "全部",
"none": "无",
"success": "成功",
"error": "错误",
"warning": "警告",
"info": "提示",
"noData": "暂无数据",
"total": "共",
"items": "条",
"rowsPerPage": "每页条数",
"page": "页",
"of": "/",
"sortBy": "排序",
"ascending": "升序",
"descending": "降序",
"selected": "已选择",
"bulkActions": "批量操作",
"deleteConfirm": "确定要删除吗?",
"deleteSuccess": "删除成功",
"saveSuccess": "保存成功",
"operationSuccess": "操作成功",
"operationFailed": "操作失败",
"networkError": "网络错误",
"unauthorized": "未授权",
"forbidden": "无权限",
"notFound": "未找到",
"serverError": "服务器错误",
"timeout": "请求超时",
"leaveEmpty": "留空",
"select": "请选择",
"used": "已使用",
"createdAt": "创建时间",
"sortOrder": "排序"
},
"nav": {
"dashboard": "仪表盘",
"tokens": "令牌",
"channels": "渠道",
"users": "用户",
"logs": "日志",
"wallet": "钱包",
"subscriptions": "订阅",
"redemptions": "兑换码",
"models": "模型",
"vendors": "供应商",
"deployments": "部署",
"playground": "Playground",
"docs": "文档",
"profile": "个人中心",
"settings": "系统设置",
"logout": "退出登录",
"login": "登录",
"register": "注册",
"about": "关于",
"pricing": "定价",
"midjourney": "Midjourney",
"tasks": "任务"
},
"auth": {
"loginTitle": "登录",
"registerTitle": "注册",
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"email": "邮箱",
"forgotPassword": "忘记密码?",
"resetPassword": "重置密码",
"noAccount": "没有账号?",
"hasAccount": "已有账号?",
"signUp": "注册",
"signIn": "登录",
"affCode": "邀请码",
"verificationCode": "验证码",
"sendCode": "发送验证码",
"loginSuccess": "登录成功",
"registerSuccess": "注册成功",
"logoutSuccess": "已退出登录",
"passwordMismatch": "两次密码不一致",
"invalidCredentials": "用户名或密码错误",
"rememberMe": "记住我",
"orContinueWith": "或通过以下方式登录",
"invalidEmail": "邮箱格式不正确",
"passwordTooShort": "密码至少8个字符",
"resetEmailSent": "重置邮件已发送",
"resetEmailSentDesc": "重置密码的邮件已发送到您的邮箱,请查收",
"forgotPasswordDesc": "请输入您的邮箱地址,我们将发送重置密码的链接",
"sendResetEmail": "发送重置邮件",
"invalidResetToken": "无效的重置令牌",
"resetPasswordSuccess": "密码重置成功"
},
"user": {
"id": "ID",
"username": "用户名",
"displayName": "显示名称",
"email": "邮箱",
"role": "角色",
"group": "分组",
"quota": "额度",
"usedQuota": "已用额度",
"requestCount": "请求次数",
"status": "状态",
"createdAt": "创建时间",
"lastLoginAt": "最后登录",
"affCode": "邀请码",
"affCount": "邀请人数",
"affQuota": "邀请额度",
"remark": "备注",
"roleUser": "普通用户",
"roleAdmin": "管理员",
"roleRoot": "超级管理员"
},
"token": {
"name": "令牌名称",
"key": "密钥",
"status": "状态",
"quota": "额度",
"usedQuota": "已用额度",
"remainQuota": "剩余额度",
"unlimitedQuota": "无限额度",
"expiredTime": "过期时间",
"createdTime": "创建时间",
"accessedTime": "访问时间",
"modelLimits": "模型限制",
"modelLimitsEnabled": "启用模型限制",
"allowIps": "允许IP",
"neverExpire": "永不过期",
"statusEnabled": "已启用",
"statusDisabled": "已禁用",
"statusExpired": "已过期",
"statusExhausted": "已耗尽",
"createToken": "创建令牌",
"copyKey": "复制密钥",
"regenerateKey": "重新生成密钥"
},
"channel": {
"name": "渠道名称",
"type": "渠道类型",
"status": "状态",
"priority": "优先级",
"weight": "权重",
"models": "模型",
"group": "分组",
"baseUrl": "基础URL",
"key": "密钥",
"testModel": "测试模型",
"responseTime": "响应时间",
"balance": "余额",
"usedQuota": "已用额度",
"createdTime": "创建时间",
"testTime": "测试时间",
"autoBan": "自动禁用",
"tag": "标签",
"statusEnabled": "已启用",
"statusDisabled": "已禁用",
"statusAutoDisabled": "自动禁用",
"testChannel": "测试渠道",
"updateBalance": "更新余额",
"batchTest": "批量测试",
"fetchModels": "获取模型",
"modelMapping": "模型映射"
},
"redemption": {
"name": "名称",
"key": "兑换码",
"quota": "额度",
"usedCount": "已使用次数",
"count": "生成数量",
"batchDeleteInvalid": "清理无效兑换码"
},
"model": {
"modelId": "模型ID",
"owner": "所有者",
"inputPrice": "输入价格",
"outputPrice": "输出价格",
"syncUpstream": "上游同步",
"missingModels": "缺失模型"
},
"vendor": {
"name": "名称",
"description": "描述"
},
"deployment": {
"name": "名称",
"hardware": "硬件",
"location": "位置",
"statusRunning": "运行中",
"statusStopped": "已停止",
"statusError": "错误",
"viewLogs": "查看日志",
"viewContainers": "查看容器"
},
"log": {
"type": "类型",
"time": "时间",
"user": "用户",
"token": "令牌",
"model": "模型",
"quota": "额度",
"promptTokens": "提示Token",
"completionTokens": "完成Token",
"channel": "渠道",
"duration": "耗时",
"isStream": "流式",
"group": "分组",
"ip": "IP",
"requestId": "请求ID",
"typeTopup": "充值",
"typeConsume": "消费",
"typeManage": "管理",
"typeSystem": "系统",
"typeError": "错误",
"typeRefund": "退款",
"totalQuota": "总消费额度",
"totalRequests": "总请求数"
},
"subscription": {
"title": "订阅",
"plan": "套餐",
"price": "价格",
"duration": "时长",
"status": "状态",
"quota": "额度",
"resetPeriod": "重置周期",
"purchaseTime": "购买时间",
"expireTime": "到期时间",
"active": "生效中",
"expired": "已过期",
"cancelled": "已取消",
"durationDay": "天",
"durationMonth": "月",
"durationYear": "年",
"resetNever": "不重置",
"resetDaily": "每天",
"resetWeekly": "每周",
"resetMonthly": "每月",
"currentPlan": "当前套餐",
"availablePlans": "可选套餐",
"balancePay": "余额支付",
"orderHistory": "订单记录",
"bindSubscription": "绑定订阅",
"userSubscriptions": "用户订阅",
"sortOrder": "排序"
},
"dashboard": {
"quotaTrend": "额度使用趋势",
"apiInfo": "API 信息",
"serverAddress": "服务器地址",
"affCode": "邀请码",
"announcements": "系统公告",
"quickActions": "快捷操作"
},
"wallet": {
"title": "钱包",
"balance": "余额",
"topUp": "充值",
"history": "充值记录",
"redeemCode": "兑换码充值",
"transfer": "额度转移",
"amount": "金额",
"paymentMethod": "支付方式",
"transactionId": "交易号",
"onlinePay": "在线支付",
"checkIn": "签到",
"checkedInToday": "今日已签到",
"notCheckedIn": "今日未签到",
"affiliate": "推广返利",
"transferAmount": "转移额度"
},
"settings": {
"site": "站点设置",
"auth": "认证设置",
"billing": "计费设置",
"content": "内容设置",
"models": "模型设置",
"operations": "运营设置",
"security": "安全设置",
"docs": "文档设置",
"general": "通用",
"systemName": "系统名称",
"logo": "Logo",
"logoUrl": "Logo URL",
"footer": "页脚 HTML",
"theme": "主题",
"language": "语言",
"noChanges": "没有变更",
"basicInfo": "基本信息",
"contentInfo": "内容信息",
"legal": "法律条款",
"navigation": "导航配置",
"serverAddress": "服务器地址",
"notice": "系统公告",
"about": "关于",
"homePageContent": "首页内容",
"userAgreement": "用户协议",
"privacyPolicy": "隐私政策",
"headerNavModules": "顶部导航模块",
"sidebarModules": "侧边栏模块",
"passwordAndReg": "密码与注册",
"passwordLogin": "允许密码登录",
"passwordRegister": "允许密码注册",
"emailVerification": "邮箱验证",
"emailDomainWhitelist": "邮箱域名白名单",
"emailAlias": "允许邮箱别名",
"minTrustLevel": "最低信任等级",
"wechatOAuth": "微信 OAuth",
"quotaConfig": "额度配置",
"quotaForNewUser": "新用户额度",
"preConsumedQuota": "预扣额度",
"quotaForInviter": "邀请者额度",
"quotaForInvitee": "被邀请者额度",
"freeModelPreConsumed": "免费模型预扣额度",
"links": "链接配置",
"topUpLink": "充值链接",
"docLink": "文档链接",
"currencyConfig": "货币配置",
"quotaUnit": "额度单位",
"quotaExchangeRate": "汇率",
"currencyDisplay": "货币显示",
"modelRatio": "模型倍率",
"modelRatioDesc": "模型倍率配置(JSON 格式)",
"groupRatio": "分组倍率",
"groupRatioDesc": "分组倍率配置(JSON 格式)",
"epayAddress": "支付地址",
"epayId": "商户 ID",
"epayKey": "商户密钥",
"otherPayment": "其他支付",
"checkIn": "签到配置",
"checkInEnabled": "启用签到",
"checkInMinQuota": "签到最小额度",
"checkInMaxQuota": "签到最大额度",
"consoleConfig": "控制台配置",
"consoleAPIInfoPanel": "API 信息面板",
"noticeAndFAQ": "公告与 FAQ",
"noticeConfig": "公告配置",
"faqConfig": "FAQ 配置",
"uptimeKuma": "Uptime Kuma",
"uptimeKumaMonitorGroups": "监控分组",
"featureToggle": "功能开关",
"chatEnabled": "启用聊天",
"drawingEnabled": "启用绘图",
"midjourneyConfig": "Midjourney 配置",
"globalModelConfig": "全局模型配置",
"globalPassthrough": "全局透传模式",
"thinkingModelBlacklist": "思考模型黑名单",
"chatToResponsesStrategy": "Chat 转 Responses 策略",
"pingInterval": "Ping 间隔",
"geminiSafety": "安全设置",
"geminiVersion": "版本",
"geminiImageModel": "图片模型",
"geminiThinkingAdapter": "思考适配器",
"geminiFunctionCalling": "函数调用",
"claudeModelHeader": "模型请求头",
"claudeDefaultMaxTokens": "默认 Max Tokens",
"claudeThinkingAdapter": "思考适配器",
"grokViolationDeduction": "违规扣费",
"opsGeneral": "通用运营配置",
"retryCount": "重试次数",
"defaultCollapseSidebar": "默认折叠侧边栏",
"demoMode": "演示模式",
"selfUseMode": "自用模式",
"channelAutoMgmt": "渠道自动管理",
"channelAutoDisable": "自动禁用渠道",
"channelAutoEnable": "自动启用渠道",
"channelDisableThreshold": "禁用阈值",
"channelDisableKeywords": "禁用关键词",
"channelDisableStatusCodes": "禁用状态码",
"autoRetryStatusCodes": "自动重试状态码",
"autoTestChannels": "自动测试渠道",
"smtpConfig": "SMTP 配置",
"smtpServer": "SMTP 服务器",
"smtpPort": "端口",
"smtpAccount": "账号",
"smtpToken": "令牌/密码",
"smtpFrom": "发件人",
"workerConfig": "Worker 配置",
"workerURL": "Worker URL",
"workerKey": "Worker Key",
"loggingAndCache": "日志与缓存",
"logConsume": "记录消费日志",
"diskCacheConfig": "磁盘缓存配置",
"performanceMonitorConfig": "性能监控配置",
"rateLimit": "速率限制",
"modelRequestRateLimit": "模型请求速率限制",
"sensitiveWordCheck": "敏感词检测",
"sensitiveWordCheckEnabled": "启用敏感词检测",
"sensitiveWordCheckOnPrompt": "检测提示词",
"sensitiveWordCheckOnOutput": "检测输出",
"networkProtection": "网络防护",
"ssrfProtection": "SSRF 防护",
"privateIPFilter": "私有 IP 过滤",
"domainIPFilter": "域名/IP 过滤",
"domainFilterMode": "域名过滤模式",
"ipFilterMode": "IP 过滤模式",
"whitelist": "白名单",
"blacklist": "黑名单",
"domainFilterList": "域名过滤列表",
"ipFilterList": "IP 过滤列表",
"portWhitelist": "端口白名单",
"portWhitelistDesc": "允许的端口列表,逗号分隔",
"docCategoryMgmt": "文档分类管理",
"docCategoryConfig": "分类配置",
"docVisibility": "文档可见性",
"docDefaultVisibility": "新文档默认可见性",
"docPublic": "公开",
"docPrivate": "私有",
"docUnlisted": "未列出",
"docPlaceholder": "占位内容",
"docPlaceholderContent": "占位内容配置",
"frontendTheme": "前端主题",
"themeDefault": "Default(新版前端)",
"themeClassic": "Classic(经典前端)",
"themeDaisy": "DaisyDaisyUI 前端)"
},
"quota": {
"unit": "额度",
"unlimited": "无限",
"insufficient": "额度不足",
"display": "显示"
},
"public": {
"heroSubtitle": "一站式 AI API 管理平台,聚合多家供应商,统一接口调用",
"getStarted": "立即开始",
"viewPricing": "查看定价",
"featuresTitle": "核心功能",
"quickStartTitle": "快速开始",
"allRightsReserved": "保留所有权利",
"feature": {
"apiGateway": "API 网关",
"apiGatewayDesc": "统一 API 入口,兼容 OpenAI 接口格式,无缝切换",
"multiChannel": "多渠道聚合",
"multiChannelDesc": "支持 40+ AI 供应商,智能负载均衡与故障转移",
"keyManagement": "密钥管理",
"keyManagementDesc": "灵活的 API 密钥管理,支持模型限制与额度控制",
"usageBilling": "用量计费",
"usageBillingDesc": "精确的 Token 用量统计与灵活的计费方案",
"subscription": "订阅套餐",
"subscriptionDesc": "多种订阅方案,满足不同规模的使用需求",
"documentation": "接口文档",
"documentationDesc": "完整的 API 文档与使用指南,快速上手"
},
"pricing": {
"title": "模型定价",
"searchPlaceholder": "搜索模型名称...",
"modelName": "模型名称",
"inputPrice": "输入价格",
"outputPrice": "输出价格",
"cachePrice": "缓存价格",
"contextLength": "上下文长度",
"provider": "供应商",
"group": "分组",
"perToken": "/ 1K Token",
"noPricingData": "暂无定价数据"
},
"about": {
"title": "关于",
"version": "版本",
"commitHash": "提交哈希",
"buildTime": "构建时间",
"license": "许可证",
"repository": "代码仓库",
"basedOn": "基于"
},
"setup": {
"title": "初始设置",
"step": "步骤",
"adminAccount": "管理员账号",
"systemConfig": "系统配置",
"adminUsername": "管理员用户名",
"adminPassword": "管理员密码",
"confirmAdminPassword": "确认管理员密码",
"systemName": "系统名称",
"serverAddress": "服务器地址",
"setupComplete": "设置完成",
"setupCompleteDesc": "系统初始化完成,即将跳转到登录页面",
"initializing": "正在初始化系统..."
},
"oauth": {
"callback": "OAuth 回调",
"processing": "正在处理登录...",
"success": "登录成功,正在跳转...",
"error": "OAuth 登录失败",
"invalidProvider": "无效的 OAuth 提供商",
"backToLogin": "返回登录"
},
"notFound": {
"title": "页面未找到",
"description": "您访问的页面不存在",
"backHome": "返回首页"
},
"userAgreement": {
"title": "用户协议"
},
"privacyPolicy": {
"title": "隐私政策"
}
},
"time": {
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前",
"weeksAgo": "{{count}} 周前",
"monthsAgo": "{{count}} 月前",
"yearsAgo": "{{count}} 年前",
"never": "永不过期",
"permanent": "永久"
},
"profile": {
"profileInfo": "个人信息",
"security": "安全设置",
"oauthBindings": "第三方绑定",
"changePassword": "修改密码",
"oldPassword": "当前密码",
"newPassword": "新密码",
"passwordTooShort": "密码至少8个字符",
"twoFactor": "两步验证",
"twoFactorEnabled": "两步验证已启用",
"twoFactorDisabled": "两步验证未启用",
"setup2FA": "设置两步验证",
"enable2FA": "启用两步验证",
"disable2FA": "关闭两步验证",
"qrCodeGenerated": "二维码已生成",
"scanQRCode": "请使用验证器应用扫描二维码",
"accessToken": "访问令牌",
"generateToken": "生成访问令牌",
"deleteAccount": "注销账户",
"deleteAccountWarning": "注销后账户数据将无法恢复,请谨慎操作!",
"accountDeleted": "账户已注销",
"linked": "已绑定",
"notLinked": "未绑定"
},
"doc": {
"title": "文档中心",
"description": "浏览和管理文档",
"categories": "分类",
"allDocs": "全部文档",
"createDoc": "创建文档",
"editDoc": "编辑文档",
"manageCategories": "管理分类",
"addCategory": "添加分类",
"categoryName": "分类名称",
"categoryLabel": "分类显示名",
"parentCategory": "父分类",
"searchPlaceholder": "搜索文档...",
"slug": "Slug",
"category": "分类",
"visibility": "可见性",
"visibilityPublic": "公开",
"visibilityAuth": "登录可见",
"visibilityAdmin": "管理员可见",
"published": "已发布",
"content": "内容",
"contentPlaceholder": "在此输入 Markdown 内容...",
"edit": "编辑",
"preview": "预览",
"tableOfContents": "目录"
},
"playground": {
"title": "API Playground",
"description": "在线测试 API 调用",
"apiKey": "API 密钥",
"selectApiKey": "选择 API 密钥",
"model": "模型",
"modelPlaceholder": "输入或选择模型",
"systemPrompt": "系统提示词",
"systemPromptPlaceholder": "输入系统提示词...",
"userMessage": "用户消息",
"userMessagePlaceholder": "输入消息内容...",
"parameters": "参数设置",
"send": "发送",
"stop": "停止",
"response": "响应",
"rawJson": "原始 JSON",
"noResponse": "发送请求后响应将显示在此",
"promptTokens": "提示 Token",
"completionTokens": "完成 Token",
"totalTokens": "总 Token",
"codeExamples": "代码示例",
"fillRequired": "请填写模型和消息",
"selectApiKeyFirst": "请先选择 API 密钥"
},
"mj": {
"progress": "进度",
"image": "图片",
"viewImage": "查看图片",
"failReason": "失败原因"
},
"task": {
"progress": "进度",
"failReason": "失败原因",
"finishTime": "完成时间"
}
}
+19
View File
@@ -0,0 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@plugin "daisyui" {
themes: business --default, dark --prefersdark;
}
:root {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
+178
View File
@@ -0,0 +1,178 @@
// Channel type definitions matching backend constant/channel.go
export const ChannelType = {
Unknown: 0,
OpenAI: 1,
Midjourney: 2,
Azure: 3,
Ollama: 4,
MidjourneyPlus: 5,
OpenAIMax: 6,
OhMyGPT: 7,
Custom: 8,
AILS: 9,
AIProxy: 10,
PaLM: 11,
API2GPT: 12,
AIGC2D: 13,
Anthropic: 14,
Baidu: 15,
Zhipu: 16,
Ali: 17,
Xunfei: 18,
'360': 19,
OpenRouter: 20,
AIProxyLibrary: 21,
FastGPT: 22,
Tencent: 23,
Gemini: 24,
Moonshot: 25,
ZhipuV4: 26,
Perplexity: 27,
LingYiWanWu: 31,
Aws: 33,
Cohere: 34,
MiniMax: 35,
SunoAPI: 36,
Dify: 37,
Jina: 38,
Cloudflare: 39,
SiliconFlow: 40,
VertexAi: 41,
Mistral: 42,
DeepSeek: 43,
MokaAI: 44,
VolcEngine: 45,
BaiduV2: 46,
Xinference: 47,
Xai: 48,
Coze: 49,
Kling: 50,
Jimeng: 51,
Vidu: 52,
Submodel: 53,
DoubaoVideo: 54,
Sora: 55,
Replicate: 56,
Codex: 57,
} as const
export type ChannelTypeValue = (typeof ChannelType)[keyof typeof ChannelType]
export const channelTypeNames: Record<number, string> = {
[ChannelType.Unknown]: 'Unknown',
[ChannelType.OpenAI]: 'OpenAI',
[ChannelType.Midjourney]: 'Midjourney',
[ChannelType.Azure]: 'Azure',
[ChannelType.Ollama]: 'Ollama',
[ChannelType.MidjourneyPlus]: 'MidjourneyPlus',
[ChannelType.OpenAIMax]: 'OpenAIMax',
[ChannelType.OhMyGPT]: 'OhMyGPT',
[ChannelType.Custom]: 'Custom',
[ChannelType.AILS]: 'AILS',
[ChannelType.AIProxy]: 'AIProxy',
[ChannelType.PaLM]: 'PaLM',
[ChannelType.API2GPT]: 'API2GPT',
[ChannelType.AIGC2D]: 'AIGC2D',
[ChannelType.Anthropic]: 'Anthropic',
[ChannelType.Baidu]: 'Baidu',
[ChannelType.Zhipu]: 'Zhipu',
[ChannelType.Ali]: 'Ali',
[ChannelType.Xunfei]: 'Xunfei',
[ChannelType['360']]: '360',
[ChannelType.OpenRouter]: 'OpenRouter',
[ChannelType.AIProxyLibrary]: 'AIProxyLibrary',
[ChannelType.FastGPT]: 'FastGPT',
[ChannelType.Tencent]: 'Tencent',
[ChannelType.Gemini]: 'Gemini',
[ChannelType.Moonshot]: 'Moonshot',
[ChannelType.ZhipuV4]: 'ZhipuV4',
[ChannelType.Perplexity]: 'Perplexity',
[ChannelType.LingYiWanWu]: 'LingYiWanWu',
[ChannelType.Aws]: 'AWS',
[ChannelType.Cohere]: 'Cohere',
[ChannelType.MiniMax]: 'MiniMax',
[ChannelType.SunoAPI]: 'SunoAPI',
[ChannelType.Dify]: 'Dify',
[ChannelType.Jina]: 'Jina',
[ChannelType.Cloudflare]: 'Cloudflare',
[ChannelType.SiliconFlow]: 'SiliconFlow',
[ChannelType.VertexAi]: 'VertexAI',
[ChannelType.Mistral]: 'Mistral',
[ChannelType.DeepSeek]: 'DeepSeek',
[ChannelType.MokaAI]: 'MokaAI',
[ChannelType.VolcEngine]: 'VolcEngine',
[ChannelType.BaiduV2]: 'BaiduV2',
[ChannelType.Xinference]: 'Xinference',
[ChannelType.Xai]: 'xAI',
[ChannelType.Coze]: 'Coze',
[ChannelType.Kling]: 'Kling',
[ChannelType.Jimeng]: 'Jimeng',
[ChannelType.Vidu]: 'Vidu',
[ChannelType.Submodel]: 'Submodel',
[ChannelType.DoubaoVideo]: 'DoubaoVideo',
[ChannelType.Sora]: 'Sora',
[ChannelType.Replicate]: 'Replicate',
[ChannelType.Codex]: 'Codex',
}
export function getChannelTypeName(type: number): string {
return channelTypeNames[type] || 'Unknown'
}
export const channelBaseURLs: Record<number, string> = {
[ChannelType.Unknown]: '',
[ChannelType.OpenAI]: 'https://api.openai.com',
[ChannelType.Midjourney]: 'https://oa.api2d.net',
[ChannelType.Azure]: '',
[ChannelType.Ollama]: 'http://localhost:11434',
[ChannelType.MidjourneyPlus]: 'https://api.openai-sb.com',
[ChannelType.OpenAIMax]: 'https://api.openaimax.com',
[ChannelType.OhMyGPT]: 'https://api.ohmygpt.com',
[ChannelType.Custom]: '',
[ChannelType.AILS]: 'https://api.caipacity.com',
[ChannelType.AIProxy]: 'https://api.aiproxy.io',
[ChannelType.PaLM]: '',
[ChannelType.API2GPT]: 'https://api.api2gpt.com',
[ChannelType.AIGC2D]: 'https://api.aigc2d.com',
[ChannelType.Anthropic]: 'https://api.anthropic.com',
[ChannelType.Baidu]: 'https://aip.baidubce.com',
[ChannelType.Zhipu]: 'https://open.bigmodel.cn',
[ChannelType.Ali]: 'https://dashscope.aliyuncs.com',
[ChannelType.Xunfei]: '',
[ChannelType['360']]: 'https://api.360.cn',
[ChannelType.OpenRouter]: 'https://openrouter.ai/api',
[ChannelType.AIProxyLibrary]: 'https://api.aiproxy.io',
[ChannelType.FastGPT]: 'https://fastgpt.run/api/openapi',
[ChannelType.Tencent]: 'https://hunyuan.tencentcloudapi.com',
[ChannelType.Gemini]: 'https://generativelanguage.googleapis.com',
[ChannelType.Moonshot]: 'https://api.moonshot.cn',
[ChannelType.ZhipuV4]: 'https://open.bigmodel.cn',
[ChannelType.Perplexity]: 'https://api.perplexity.ai',
[ChannelType.LingYiWanWu]: 'https://api.lingyiwanwu.com',
[ChannelType.Aws]: '',
[ChannelType.Cohere]: 'https://api.cohere.ai',
[ChannelType.MiniMax]: 'https://api.minimax.chat',
[ChannelType.SunoAPI]: '',
[ChannelType.Dify]: 'https://api.dify.ai',
[ChannelType.Jina]: 'https://api.jina.ai',
[ChannelType.Cloudflare]: 'https://api.cloudflare.com',
[ChannelType.SiliconFlow]: 'https://api.siliconflow.cn',
[ChannelType.VertexAi]: '',
[ChannelType.Mistral]: 'https://api.mistral.ai',
[ChannelType.DeepSeek]: 'https://api.deepseek.com',
[ChannelType.MokaAI]: 'https://api.moka.ai',
[ChannelType.VolcEngine]: 'https://ark.cn-beijing.volces.com',
[ChannelType.BaiduV2]: 'https://qianfan.baidubce.com',
[ChannelType.Xinference]: '',
[ChannelType.Xai]: 'https://api.x.ai',
[ChannelType.Coze]: 'https://api.coze.cn',
[ChannelType.Kling]: 'https://api.klingai.com',
[ChannelType.Jimeng]: 'https://visual.volcengineapi.com',
[ChannelType.Vidu]: 'https://api.vidu.cn',
[ChannelType.Submodel]: 'https://llm.submodel.ai',
[ChannelType.DoubaoVideo]: 'https://ark.cn-beijing.volces.com',
[ChannelType.Sora]: 'https://api.openai.com',
[ChannelType.Replicate]: 'https://api.replicate.com',
[ChannelType.Codex]: 'https://chatgpt.com',
}
+96
View File
@@ -0,0 +1,96 @@
// Role constants matching backend common/constants.go
export const ROLE_GUEST = 0
export const ROLE_USER = 1
export const ROLE_ADMIN = 10
export const ROLE_ROOT = 100
// User status
export const USER_STATUS_ENABLED = 1
export const USER_STATUS_DISABLED = 2
// Token status
export const TOKEN_STATUS_ENABLED = 1
export const TOKEN_STATUS_DISABLED = 2
export const TOKEN_STATUS_EXPIRED = 3
export const TOKEN_STATUS_EXHAUSTED = 4
// Channel status
export const CHANNEL_STATUS_UNKNOWN = 0
export const CHANNEL_STATUS_ENABLED = 1
export const CHANNEL_STATUS_MANUALLY_DISABLED = 2
export const CHANNEL_STATUS_AUTO_DISABLED = 3
// Redemption code status
export const REDEMPTION_STATUS_ENABLED = 1
export const REDEMPTION_STATUS_DISABLED = 2
export const REDEMPTION_STATUS_USED = 3
// Log types
export const LOG_TYPE_UNKNOWN = 0
export const LOG_TYPE_TOPUP = 1
export const LOG_TYPE_CONSUME = 2
export const LOG_TYPE_MANAGE = 3
export const LOG_TYPE_SYSTEM = 4
export const LOG_TYPE_ERROR = 5
export const LOG_TYPE_REFUND = 6
// Channel type names map (matching backend constant/channel.go)
export const CHANNEL_TYPE_NAMES: Record<number, string> = {
0: 'Unknown',
1: 'OpenAI',
2: 'Midjourney',
3: 'Azure',
4: 'Ollama',
5: 'MidjourneyPlus',
6: 'OpenAIMax',
7: 'OhMyGPT',
8: 'Custom',
9: 'AILS',
10: 'AIProxy',
11: 'PaLM',
12: 'API2GPT',
13: 'AIGC2D',
14: 'Anthropic',
15: 'Baidu',
16: 'Zhipu',
17: 'Ali',
18: 'Xunfei',
19: '360',
20: 'OpenRouter',
21: 'AIProxyLibrary',
22: 'FastGPT',
23: 'Tencent',
24: 'Gemini',
25: 'Moonshot',
26: 'ZhipuV4',
27: 'Perplexity',
31: 'LingYiWanWu',
33: 'AWS',
34: 'Cohere',
35: 'MiniMax',
36: 'SunoAPI',
37: 'Dify',
38: 'Jina',
39: 'Cloudflare',
40: 'SiliconFlow',
41: 'VertexAI',
42: 'Mistral',
43: 'DeepSeek',
44: 'MokaAI',
45: 'VolcEngine',
46: 'BaiduV2',
47: 'Xinference',
48: 'xAI',
49: 'Coze',
50: 'Kling',
51: 'Jimeng',
52: 'Vidu',
53: 'Submodel',
54: 'DoubaoVideo',
55: 'Sora',
56: 'Replicate',
57: 'Codex',
}
// Quota unit: 500000 per dollar (matching backend QuotaPerUnit)
export const QUOTA_PER_UNIT = 500000
+24
View File
@@ -0,0 +1,24 @@
// Quota per unit: 500000 = $1.00 (matching backend QuotaPerUnit)
export const quotaPerUnit = 500000
export function renderQuota(quota: number, displayInCurrency = true): string {
if (quota <= 0) return displayInCurrency ? '$0.00' : '0'
if (!displayInCurrency) return quota.toLocaleString()
const value = quota / quotaPerUnit
if (value >= 1) {
return '$' + value.toFixed(2)
}
// For very small amounts, show more precision
if (value >= 0.01) {
return '$' + value.toFixed(4)
}
return '$' + value.toFixed(6)
}
export function quotaToCurrency(quota: number): number {
return quota / quotaPerUnit
}
export function currencyToQuota(currency: number): number {
return Math.round(currency * quotaPerUnit)
}
+48
View File
@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatQuota(quota: number, displayInCurrency = true): string {
if (quota <= 0) return '$0.00'
if (!displayInCurrency) return quota.toLocaleString()
return '$' + (quota / 500000).toFixed(2)
}
export function formatDate(timestamp: number | string | null | undefined): string {
if (!timestamp) return '-'
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
if (isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './i18n'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)
+21
View File
@@ -0,0 +1,21 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Home, FileQuestion } from 'lucide-react'
export default function NotFound() {
const { t } = useTranslation()
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="text-center">
<FileQuestion size={64} className="mx-auto text-base-content/30 mb-6" />
<h1 className="text-4xl font-bold mb-2">{t('public.notFound.title')}</h1>
<p className="text-base-content/60 mb-8">{t('public.notFound.description')}</p>
<Link to="/" className="btn btn-primary gap-2">
<Home size={18} />
{t('public.notFound.backHome')}
</Link>
</div>
</div>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Zap } from 'lucide-react'
import { createChannel, updateChannel, testChannel } from '@/api/channel'
import type { Channel } from '@/types/channel'
import { channelTypeNames, channelBaseURLs } from '@/lib/channel-types'
interface ChannelFormProps {
channel: Channel | null
onClose: () => void
onSuccess: () => void
}
const openAIBaseURLs = [
{ label: 'Official', value: 'https://api.openai.com' },
{ label: 'Azure', value: '' },
{ label: 'Custom', value: '' },
]
export default function ChannelForm({ channel, onClose, onSuccess }: ChannelFormProps) {
const { t } = useTranslation()
const isEdit = !!channel
const [testing, setTesting] = useState(false)
const [form, setForm] = useState({
type: channel?.type ?? 1,
name: channel?.name ?? '',
base_url: channel?.base_url ?? '',
key: channel?.key ?? '',
models: channel?.models ?? '',
model_mapping: channel?.model_mapping ?? '',
group: channel?.group ?? 'default',
priority: channel?.priority ?? 0,
weight: channel?.weight ?? 1,
tag: channel?.tag ?? '',
})
const saveMut = useMutation({
mutationFn: (data: Partial<Channel>) => isEdit ? updateChannel({ ...data, id: channel!.id }) : createChannel(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleTest = async () => {
setTesting(true)
try {
if (channel?.id) {
const res = await testChannel(channel.id)
toast.success(res.message || t('common.success'))
}
} catch {
toast.error(t('common.operationFailed'))
} finally {
setTesting(false)
}
}
const handleTypeChange = (type: number) => {
setForm((prev) => ({
...prev,
type,
base_url: prev.base_url || channelBaseURLs[type] || '',
}))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form as unknown as Partial<Channel>)
}
const typeOptions = Object.entries(channelTypeNames).filter(([k]) => Number(k) > 0)
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-2xl">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.channels')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('channel.type')}</label>
<select className="select select-bordered select-sm w-full" value={form.type} onChange={(e) => handleTypeChange(Number(e.target.value))}>
{typeOptions.map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.name')}</label>
<input type="text" className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.baseUrl')}</label>
{form.type === 1 && (
<div className="flex gap-1 mb-1">
{openAIBaseURLs.map((opt) => (
<button key={opt.label} type="button" className={`btn btn-xs ${form.base_url === opt.value ? 'btn-primary' : 'btn-outline'}`} onClick={() => setForm({ ...form, base_url: opt.value })}>
{opt.label}
</button>
))}
</div>
)}
<input type="text" className="input input-bordered input-sm" value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.key')}</label>
<textarea className="textarea textarea-bordered textarea-sm h-16" value={form.key} onChange={(e) => setForm({ ...form, key: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.models')}</label>
<textarea className="textarea textarea-bordered textarea-sm h-20" value={form.models} onChange={(e) => setForm({ ...form, models: e.target.value })} placeholder="gpt-4,gpt-3.5-turbo" />
</div>
<div className="form-control">
<label className="label text-xs">Model Mapping</label>
<textarea className="textarea textarea-bordered textarea-sm h-16" value={form.model_mapping} onChange={(e) => setForm({ ...form, model_mapping: e.target.value })} placeholder='{"gpt-4": "gpt-4-0613"}' />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="form-control">
<label className="label text-xs">{t('channel.group')}</label>
<input type="text" className="input input-bordered input-sm" value={form.group} onChange={(e) => setForm({ ...form, group: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.priority')}</label>
<input type="number" className="input input-bordered input-sm" value={form.priority} onChange={(e) => setForm({ ...form, priority: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.weight')}</label>
<input type="number" className="input input-bordered input-sm" value={form.weight} onChange={(e) => setForm({ ...form, weight: Number(e.target.value) })} />
</div>
</div>
<div className="form-control">
<label className="label text-xs">{t('channel.tag')}</label>
<input type="text" className="input input-bordered input-sm" value={form.tag} onChange={(e) => setForm({ ...form, tag: e.target.value })} />
</div>
<div className="modal-action">
{isEdit && (
<button type="button" className="btn btn-sm btn-outline" onClick={handleTest} disabled={testing}>
<Zap size={14} /> {testing ? t('common.loading') : t('channel.testChannel')}
</button>
)}
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+227
View File
@@ -0,0 +1,227 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, Copy, Zap, RefreshCw, Tag, Download } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import QuotaDisplay from '@/components/common/QuotaDisplay'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getChannels, deleteChannel, testChannel, updateChannelBalance, fetchChannelModels, batchDeleteChannels, batchTagChannels } from '@/api/channel'
import { ChannelStatus } from '@/types/channel'
import type { Channel } from '@/types/channel'
import { getChannelTypeName, channelTypeNames, channelBaseURLs } from '@/lib/channel-types'
import { formatDate } from '@/lib/utils'
import ChannelForm from './ChannelForm'
export default function ChannelList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [formOpen, setFormOpen] = useState(false)
const [editChannel, setEditChannel] = useState<Channel | null>(null)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [tagFilter, setTagFilter] = useState('')
const [batchTagValue, setBatchTagValue] = useState('')
const [showBatchTag, setShowBatchTag] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['channels', page, pageSize, search, tagFilter],
queryFn: () => getChannels(page, pageSize, search),
})
const deleteMut = useMutation({
mutationFn: deleteChannel,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['channels'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const testMut = useMutation({
mutationFn: testChannel,
onSuccess: (res) => {
toast.success(res.message || t('common.success'))
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const balanceMut = useMutation({
mutationFn: updateChannelBalance,
onSuccess: () => {
toast.success(t('common.operationSuccess'))
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const fetchModelsMut = useMutation({
mutationFn: fetchChannelModels,
onSuccess: (res) => {
toast.success(`${t('common.success')}: ${res.models?.join(', ') || '0'}`)
},
onError: () => toast.error(t('common.operationFailed')),
})
const batchDeleteMut = useMutation({
mutationFn: batchDeleteChannels,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
setSelectedIds(new Set())
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const batchTagMut = useMutation({
mutationFn: ({ ids, tag }: { ids: number[]; tag: string }) => batchTagChannels(ids, tag),
onSuccess: () => {
toast.success(t('common.operationSuccess'))
setSelectedIds(new Set())
setShowBatchTag(false)
setBatchTagValue('')
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleAll = () => {
if (selectedIds.size === data?.data?.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(data?.data?.map((c) => c.id) ?? []))
}
}
const getStatusBadge = (status: number) => {
switch (status) {
case ChannelStatus.Enabled: return <StatusBadge variant="success" label={t('channel.statusEnabled')} />
case ChannelStatus.ManuallyDisabled: return <StatusBadge variant="neutral" label={t('channel.statusDisabled')} />
case ChannelStatus.AutoDisabled: return <StatusBadge variant="warning" label={t('channel.statusAutoDisabled')} />
default: return <StatusBadge variant="neutral" label={String(status)} />
}
}
const columns: Column<Channel & Record<string, unknown>>[] = [
{
key: '_select', header: '', width: '40px', render: (row) => (
<input type="checkbox" className="checkbox checkbox-xs" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)} />
),
},
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'name', header: t('channel.name'), render: (row) => <span className="font-medium">{row.name}</span> },
{ key: 'type', header: t('channel.type'), render: (row) => <span className="text-xs">{getChannelTypeName(row.type)}</span> },
{ key: 'base_url', header: t('channel.baseUrl'), render: (row) => <span className="text-xs truncate max-w-[150px] inline-block">{row.base_url || '-'}</span> },
{ key: 'models', header: t('channel.models'), render: (row) => {
const models = row.models?.split(',').slice(0, 3).join(', ')
const total = row.models?.split(',').length ?? 0
return <span className="text-xs">{models}{total > 3 ? ` +${total - 3}` : ''}</span>
}},
{ key: 'status', header: t('channel.status'), render: (row) => getStatusBadge(row.status) },
{ key: 'priority', header: t('channel.priority'), width: '70px', align: 'center' },
{ key: 'response_time', header: t('channel.responseTime'), width: '90px', align: 'right', render: (row) => row.response_time > 0 ? `${(row.response_time / 1000).toFixed(1)}s` : '-' },
{ key: 'balance', header: t('channel.balance'), align: 'right', render: (row) => <QuotaDisplay quota={row.balance} /> },
{
key: 'actions', header: t('common.actions'), width: '180px', render: (row) => (
<div className="flex items-center gap-0.5">
<button className="btn btn-ghost btn-xs" onClick={() => testMut.mutate(row.id)} title={t('channel.testChannel')}><Zap size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => balanceMut.mutate(row.id)} title={t('channel.updateBalance')}><RefreshCw size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => fetchModelsMut.mutate(row.id)} title="Fetch Models"><Download size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => { setEditChannel(row as unknown as Channel); setFormOpen(true) }}><Pencil size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => { navigator.clipboard.writeText(JSON.stringify(row)); toast.success(t('common.copied')) }}><Copy size={13} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={13} /></button>
</div>
),
},
]
const tags = [...new Set(data?.data?.map((c) => c.tag).filter(Boolean) ?? [])]
return (
<div>
<PageHeader title={t('nav.channels')} actions={
<button className="btn btn-primary btn-sm" onClick={() => { setEditChannel(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
} />
<div className="flex flex-wrap items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
{tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag size={14} className="opacity-50" />
<select className="select select-bordered select-sm" value={tagFilter} onChange={(e) => setTagFilter(e.target.value)}>
<option value="">{t('common.all')}</option>
{tags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
</select>
</div>
)}
{selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<button className="btn btn-error btn-sm btn-outline" onClick={() => batchDeleteMut.mutate([...selectedIds])}>
<Trash2 size={14} /> {t('common.delete')} ({selectedIds.size})
</button>
<button className="btn btn-sm btn-outline" onClick={() => setShowBatchTag(true)}>
<Tag size={14} /> {t('channel.tag')}
</button>
</div>
)}
</div>
{showBatchTag && (
<div className="flex items-center gap-2 mb-4">
<input type="text" className="input input-bordered input-sm" placeholder={t('channel.tag')} value={batchTagValue} onChange={(e) => setBatchTagValue(e.target.value)} />
<button className="btn btn-primary btn-sm" onClick={() => batchTagMut.mutate({ ids: [...selectedIds], tag: batchTagValue })}>{t('common.confirm')}</button>
<button className="btn btn-sm" onClick={() => setShowBatchTag(false)}>{t('common.cancel')}</button>
</div>
)}
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (Channel & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<ChannelForm
channel={editChannel}
onClose={() => { setFormOpen(false); setEditChannel(null) }}
onSuccess={() => { setFormOpen(false); setEditChannel(null); queryClient.invalidateQueries({ queryKey: ['channels'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, FileText, Container } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getDeployments, createDeployment, deleteDeployment, getDeploymentLogs, getDeploymentContainers } from '@/api/deployment'
import type { Deployment } from '@/api/deployment'
import { formatDate } from '@/lib/utils'
export default function DeploymentList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editItem, setEditItem] = useState<Deployment | null>(null)
const [logsOpen, setLogsOpen] = useState<number | null>(null)
const [containersOpen, setContainersOpen] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['deployments', page, pageSize, search],
queryFn: () => getDeployments(page, pageSize, search),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['deployment-logs', logsOpen],
queryFn: () => getDeploymentLogs(logsOpen!),
enabled: logsOpen !== null,
})
const { data: containersData, isLoading: containersLoading } = useQuery({
queryKey: ['deployment-containers', containersOpen],
queryFn: () => getDeploymentContainers(containersOpen!),
enabled: containersOpen !== null,
})
const deleteMut = useMutation({
mutationFn: deleteDeployment,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['deployments'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const getStatusBadge = (status: string) => {
switch (status) {
case 'running': return <StatusBadge variant="success" label={t('deployment.statusRunning')} />
case 'stopped': return <StatusBadge variant="neutral" label={t('deployment.statusStopped')} />
case 'error': return <StatusBadge variant="error" label={t('deployment.statusError')} />
default: return <StatusBadge variant="neutral" label={status} />
}
}
const columns: Column<Deployment & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'name', header: t('deployment.name'), render: (row) => <span className="font-medium">{row.name}</span> },
{ key: 'status', header: t('common.status'), render: (row) => getStatusBadge(row.status) },
{ key: 'hardware', header: t('deployment.hardware'), render: (row) => <span className="text-xs">{row.hardware || '-'}</span> },
{ key: 'location', header: t('deployment.location'), render: (row) => <span className="text-xs">{row.location || '-'}</span> },
{
key: 'actions', header: t('common.actions'), width: '180px', render: (row) => (
<div className="flex items-center gap-0.5">
<button className="btn btn-ghost btn-xs" onClick={() => setLogsOpen(row.id)} title={t('deployment.viewLogs')}><FileText size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => setContainersOpen(row.id)} title={t('deployment.viewContainers')}><Container size={13} /></button>
<button className="btn btn-ghost btn-xs" onClick={() => { setEditItem(row as unknown as Deployment); setFormOpen(true) }}><Pencil size={13} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={13} /></button>
</div>
),
},
]
return (
<div>
<PageHeader title={t('nav.deployments')} actions={
<button className="btn btn-primary btn-sm" onClick={() => { setEditItem(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
} />
<div className="flex items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (Deployment & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<DeploymentForm
item={editItem}
onClose={() => { setFormOpen(false); setEditItem(null) }}
onSuccess={() => { setFormOpen(false); setEditItem(null); queryClient.invalidateQueries({ queryKey: ['deployments'] }) }}
/>
)}
{/* Logs Modal */}
{logsOpen !== null && (
<dialog className="modal modal-open">
<div className="modal-box max-w-3xl">
<h3 className="text-lg font-bold mb-4">{t('deployment.viewLogs')}</h3>
<div className="bg-base-200 rounded p-3 max-h-[400px] overflow-auto">
{logsLoading ? <span className="loading loading-spinner loading-sm" /> : (
<pre className="text-xs font-mono whitespace-pre-wrap">
{logsData?.logs?.join('\n') || t('common.noData')}
</pre>
)}
</div>
<div className="modal-action">
<button className="btn btn-sm" onClick={() => setLogsOpen(null)}>{t('common.close')}</button>
</div>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={() => setLogsOpen(null)}>close</button></form>
</dialog>
)}
{/* Containers Modal */}
{containersOpen !== null && (
<dialog className="modal modal-open">
<div className="modal-box max-w-3xl">
<h3 className="text-lg font-bold mb-4">{t('deployment.viewContainers')}</h3>
<div className="bg-base-200 rounded p-3 max-h-[400px] overflow-auto">
{containersLoading ? <span className="loading loading-spinner loading-sm" /> : (
<pre className="text-xs font-mono whitespace-pre-wrap">
{JSON.stringify(containersData?.containers ?? [], null, 2) || t('common.noData')}
</pre>
)}
</div>
<div className="modal-action">
<button className="btn btn-sm" onClick={() => setContainersOpen(null)}>{t('common.close')}</button>
</div>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={() => setContainersOpen(null)}>close</button></form>
</dialog>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
function DeploymentForm({ item, onClose, onSuccess }: { item: Deployment | null; onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const isEdit = !!item
const [form, setForm] = useState({
name: item?.name ?? '',
hardware: item?.hardware ?? '',
location: item?.location ?? '',
})
const saveMut = useMutation({
mutationFn: (data: Partial<Deployment>) => isEdit ? { id: item!.id, ...data } as any : createDeployment(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.deployments')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="form-control">
<label className="label text-xs">{t('deployment.name')}</label>
<input type="text" className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('deployment.hardware')}</label>
<input type="text" className="input input-bordered input-sm" value={form.hardware} onChange={(e) => setForm({ ...form, hardware: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('deployment.location')}</label>
<input type="text" className="input input-bordered input-sm" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} />
</div>
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, RefreshCw, AlertTriangle } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getModels, createModel, updateModel, deleteModel, syncModelsFromUpstream, getMissingModels } from '@/api/model'
import type { ModelMeta } from '@/api/model'
import QuotaDisplay from '@/components/common/QuotaDisplay'
export default function ModelList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editItem, setEditItem] = useState<ModelMeta | null>(null)
const [showMissing, setShowMissing] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin-models', page, pageSize, search],
queryFn: () => getModels(page, pageSize, search),
})
const { data: missingData } = useQuery({
queryKey: ['missing-models'],
queryFn: getMissingModels,
enabled: showMissing,
})
const deleteMut = useMutation({
mutationFn: deleteModel,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['admin-models'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const syncMut = useMutation({
mutationFn: syncModelsFromUpstream,
onSuccess: (res) => {
toast.success(`${t('common.success')}: ${res.count ?? 0}`)
queryClient.invalidateQueries({ queryKey: ['admin-models'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const columns: Column<ModelMeta & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'model_id', header: t('model.modelId'), render: (row) => <span className="font-mono text-xs font-medium">{row.model_id}</span> },
{ key: 'owner', header: t('model.owner'), render: (row) => <span className="text-xs">{row.owner || '-'}</span> },
{ key: 'enabled', header: t('common.status'), render: (row) => row.enabled ? <StatusBadge variant="success" label={t('common.enabled')} /> : <StatusBadge variant="neutral" label={t('common.disabled')} /> },
{ key: 'input_price', header: t('model.inputPrice'), align: 'right', render: (row) => <QuotaDisplay quota={row.input_price} /> },
{ key: 'output_price', header: t('model.outputPrice'), align: 'right', render: (row) => <QuotaDisplay quota={row.output_price} /> },
{
key: 'actions', header: t('common.actions'), width: '120px', render: (row) => (
<div className="flex items-center gap-1">
<button className="btn btn-ghost btn-xs" onClick={() => { setEditItem(row as unknown as ModelMeta); setFormOpen(true) }}><Pencil size={14} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={14} /></button>
</div>
),
},
]
return (
<div>
<PageHeader title={t('nav.models')} actions={
<div className="flex gap-2">
<button className="btn btn-outline btn-sm" onClick={() => syncMut.mutate()} disabled={syncMut.isPending}>
<RefreshCw size={16} className={syncMut.isPending ? 'animate-spin' : ''} /> {t('model.syncUpstream')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowMissing(!showMissing)}>
<AlertTriangle size={16} /> {t('model.missingModels')}
</button>
<button className="btn btn-primary btn-sm" onClick={() => { setEditItem(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
</div>
} />
{showMissing && missingData?.models && missingData.models.length > 0 && (
<div className="alert alert-warning mb-4">
<AlertTriangle size={16} />
<span className="text-sm">{t('model.missingModels')}: {missingData.models.join(', ')}</span>
</div>
)}
<div className="flex items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (ModelMeta & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<ModelForm
item={editItem}
onClose={() => { setFormOpen(false); setEditItem(null) }}
onSuccess={() => { setFormOpen(false); setEditItem(null); queryClient.invalidateQueries({ queryKey: ['admin-models'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
function ModelForm({ item, onClose, onSuccess }: { item: ModelMeta | null; onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const isEdit = !!item
const [form, setForm] = useState({
model_id: item?.model_id ?? '',
owner: item?.owner ?? '',
enabled: item?.enabled ?? true,
input_price: item?.input_price ?? 0,
output_price: item?.output_price ?? 0,
})
const saveMut = useMutation({
mutationFn: (data: Partial<ModelMeta>) => isEdit ? updateModel({ ...data, id: item!.id }) : createModel(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.models')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="form-control">
<label className="label text-xs">{t('model.modelId')}</label>
<input type="text" className="input input-bordered input-sm" value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} required disabled={isEdit} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('model.owner')}</label>
<input type="text" className="input input-bordered input-sm" value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('common.status')}</label>
<select className="select select-bordered select-sm w-full" value={form.enabled ? 1 : 0} onChange={(e) => setForm({ ...form, enabled: Number(e.target.value) === 1 })}>
<option value={1}>{t('common.enabled')}</option>
<option value={0}>{t('common.disabled')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('model.inputPrice')}</label>
<input type="number" className="input input-bordered input-sm" value={form.input_price} onChange={(e) => setForm({ ...form, input_price: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('model.outputPrice')}</label>
<input type="number" className="input input-bordered input-sm" value={form.output_price} onChange={(e) => setForm({ ...form, output_price: Number(e.target.value) })} />
</div>
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+192
View File
@@ -0,0 +1,192 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, Copy, XCircle } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import QuotaDisplay from '@/components/common/QuotaDisplay'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getRedemptions, createRedemption, updateRedemption, deleteRedemption, batchDeleteInvalidRedemptions } from '@/api/redemption'
import type { Redemption } from '@/api/redemption'
import { formatDate, copyToClipboard } from '@/lib/utils'
import { REDEMPTION_STATUS_ENABLED, REDEMPTION_STATUS_DISABLED, REDEMPTION_STATUS_USED } from '@/lib/constants'
export default function RedemptionList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editItem, setEditItem] = useState<Redemption | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['redemptions', page, pageSize, search],
queryFn: () => getRedemptions(page, pageSize, search),
})
const deleteMut = useMutation({
mutationFn: deleteRedemption,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['redemptions'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const batchDeleteInvalidMut = useMutation({
mutationFn: batchDeleteInvalidRedemptions,
onSuccess: () => {
toast.success(t('common.operationSuccess'))
queryClient.invalidateQueries({ queryKey: ['redemptions'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const getStatusBadge = (status: number) => {
switch (status) {
case REDEMPTION_STATUS_ENABLED: return <StatusBadge variant="success" label={t('common.enabled')} />
case REDEMPTION_STATUS_DISABLED: return <StatusBadge variant="neutral" label={t('common.disabled')} />
case REDEMPTION_STATUS_USED: return <StatusBadge variant="info" label={t('common.used')} />
default: return <StatusBadge variant="neutral" label={String(status)} />
}
}
const handleCopyKey = (key: string) => {
copyToClipboard(key)
toast.success(t('common.copied'))
}
const columns: Column<Redemption & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'name', header: t('redemption.name'), render: (row) => <span className="font-medium">{row.name}</span> },
{ key: 'key', header: t('redemption.key'), render: (row) => (
<div className="flex items-center gap-1">
<code className="text-xs bg-base-200 px-1.5 py-0.5 rounded">{row.key.slice(0, 8)}...</code>
<button className="btn btn-ghost btn-xs" onClick={() => handleCopyKey(row.key)}><Copy size={12} /></button>
</div>
)},
{ key: 'quota', header: t('redemption.quota'), align: 'right', render: (row) => <QuotaDisplay quota={row.quota} /> },
{ key: 'used_count', header: t('redemption.usedCount'), align: 'center', width: '80px' },
{ key: 'status', header: t('common.status'), render: (row) => getStatusBadge(row.status) },
{ key: 'created_time', header: t('common.createdAt'), render: (row) => <span className="text-xs">{formatDate(row.created_time)}</span> },
{
key: 'actions', header: t('common.actions'), width: '120px', render: (row) => (
<div className="flex items-center gap-1">
<button className="btn btn-ghost btn-xs" onClick={() => { setEditItem(row as unknown as Redemption); setFormOpen(true) }}><Pencil size={14} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={14} /></button>
</div>
),
},
]
return (
<div>
<PageHeader title={t('nav.redemptions')} actions={
<div className="flex gap-2">
<button className="btn btn-outline btn-sm" onClick={() => batchDeleteInvalidMut.mutate()} disabled={batchDeleteInvalidMut.isPending}>
<XCircle size={16} /> {t('redemption.batchDeleteInvalid')}
</button>
<button className="btn btn-primary btn-sm" onClick={() => { setEditItem(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
</div>
} />
<div className="flex items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (Redemption & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<RedemptionForm
item={editItem}
onClose={() => { setFormOpen(false); setEditItem(null) }}
onSuccess={() => { setFormOpen(false); setEditItem(null); queryClient.invalidateQueries({ queryKey: ['redemptions'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
function RedemptionForm({ item, onClose, onSuccess }: { item: Redemption | null; onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const isEdit = !!item
const [form, setForm] = useState({ name: item?.name ?? '', quota: item?.quota ?? 0, count: 1 })
const saveMut = useMutation({
mutationFn: (data: Partial<Redemption> & { count?: number }) => isEdit ? updateRedemption({ ...data, id: item!.id }) : createRedemption(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.redemptions')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="form-control">
<label className="label text-xs">{t('redemption.name')}</label>
<input type="text" className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('redemption.quota')}</label>
<input type="number" className="input input-bordered input-sm" value={form.quota} onChange={(e) => setForm({ ...form, quota: Number(e.target.value) })} required />
</div>
{!isEdit && (
<div className="form-control">
<label className="label text-xs">{t('redemption.count')}</label>
<input type="number" className="input input-bordered input-sm" value={form.count} onChange={(e) => setForm({ ...form, count: Number(e.target.value) })} min={1} max={100} />
</div>
)}
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+312
View File
@@ -0,0 +1,312 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, UserPlus } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getAdminPlans, createPlan, updatePlan, deletePlan, getUserSubscriptions, bindSubscription } from '@/api/admin-subscription'
import type { SubscriptionPlan, SubscriptionOrder } from '@/types/subscription'
import QuotaDisplay from '@/components/common/QuotaDisplay'
import { formatDate } from '@/lib/utils'
type TabKey = 'plans' | 'users'
export default function SubscriptionAdmin() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [tab, setTab] = useState<TabKey>('plans')
const [deleteId, setDeleteId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editPlan, setEditPlan] = useState<SubscriptionPlan | null>(null)
const [bindOpen, setBindOpen] = useState(false)
// Plans
const { data: plans, isLoading: plansLoading } = useQuery({
queryKey: ['admin-plans'],
queryFn: getAdminPlans,
})
// User subscriptions
const [subPage, setSubPage] = useState(1)
const [subSearch, setSubSearch] = useState('')
const { data: subs, isLoading: subsLoading } = useQuery({
queryKey: ['admin-subs', subPage, subSearch],
queryFn: () => getUserSubscriptions({ page: subPage, page_size: 10, keyword: subSearch }),
enabled: tab === 'users',
})
const deletePlanMut = useMutation({
mutationFn: deletePlan,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['admin-plans'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const planColumns: Column<SubscriptionPlan & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px' },
{ key: 'title', header: t('subscription.plan'), render: (row) => <span className="font-medium">{row.title}</span> },
{ key: 'price_amount', header: t('subscription.price'), align: 'right', render: (row) => `$${row.price_amount}` },
{ key: 'duration_value', header: t('subscription.duration'), render: (row) => `${row.duration_value} ${row.duration_unit}` },
{ key: 'enabled', header: t('common.status'), render: (row) => row.enabled ? <StatusBadge variant="success" label={t('common.enabled')} /> : <StatusBadge variant="neutral" label={t('common.disabled')} /> },
{ key: 'sort_order', header: t('subscription.sortOrder'), align: 'center', width: '80px' },
{
key: 'actions', header: t('common.actions'), width: '120px', render: (row) => (
<div className="flex items-center gap-1">
<button className="btn btn-ghost btn-xs" onClick={() => { setEditPlan(row as unknown as SubscriptionPlan); setFormOpen(true) }}><Pencil size={14} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={14} /></button>
</div>
),
},
]
const subColumns: Column<SubscriptionOrder & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px' },
{ key: 'user_id', header: t('user.id'), width: '70px' },
{ key: 'plan_title', header: t('subscription.plan'), render: (row) => <span className="font-medium">{row.plan_title}</span> },
{ key: 'status', header: t('common.status'), render: (row) => {
const variant = row.status === 'success' ? 'success' : row.status === 'pending' ? 'warning' : row.status === 'expired' ? 'neutral' : 'error'
return <StatusBadge variant={variant} label={row.status} />
}},
{ key: 'price_amount', header: t('subscription.price'), align: 'right', render: (row) => `$${row.price_amount}` },
{ key: 'expire_time', header: t('subscription.expireTime'), render: (row) => <span className="text-xs">{formatDate(row.expire_time)}</span> },
]
return (
<div>
<PageHeader title={t('nav.subscriptions')} actions={
<div className="flex gap-2">
{tab === 'plans' && (
<button className="btn btn-primary btn-sm" onClick={() => { setEditPlan(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
)}
{tab === 'users' && (
<button className="btn btn-primary btn-sm" onClick={() => setBindOpen(true)}>
<UserPlus size={16} /> {t('subscription.bindSubscription')}
</button>
)}
</div>
} />
<div role="tablist" className="tabs tabs-bordered mb-4">
<button role="tab" className={`tab ${tab === 'plans' ? 'tab-active' : ''}`} onClick={() => setTab('plans')}>{t('subscription.plan')}</button>
<button role="tab" className={`tab ${tab === 'users' ? 'tab-active' : ''}`} onClick={() => setTab('users')}>{t('subscription.userSubscriptions')}</button>
</div>
{tab === 'plans' && (
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={planColumns}
data={(plans ?? []) as (SubscriptionPlan & Record<string, unknown>)[]}
loading={plansLoading}
total={plans?.length ?? 0}
page={1}
pageSize={100}
onPageChange={() => {}}
rowKey={(row) => row.id}
/>
</div>
</div>
)}
{tab === 'users' && (
<>
<div className="flex items-center gap-3 mb-4">
<SearchInput value={subSearch} onChange={(v) => { setSubSearch(v); setSubPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={subColumns}
data={(subs?.data ?? []) as (SubscriptionOrder & Record<string, unknown>)[]}
loading={subsLoading}
total={subs?.total ?? 0}
page={subPage}
pageSize={10}
onPageChange={setSubPage}
rowKey={(row) => row.id}
/>
</div>
</div>
</>
)}
{formOpen && (
<PlanForm
plan={editPlan}
onClose={() => { setFormOpen(false); setEditPlan(null) }}
onSuccess={() => { setFormOpen(false); setEditPlan(null); queryClient.invalidateQueries({ queryKey: ['admin-plans'] }) }}
/>
)}
{bindOpen && (
<BindSubscriptionForm
onClose={() => setBindOpen(false)}
onSuccess={() => { setBindOpen(false); queryClient.invalidateQueries({ queryKey: ['admin-subs'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deletePlanMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
function PlanForm({ plan, onClose, onSuccess }: { plan: SubscriptionPlan | null; onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const isEdit = !!plan
const [form, setForm] = useState({
title: plan?.title ?? '',
subtitle: plan?.subtitle ?? '',
price_amount: plan?.price_amount ?? 0,
currency: plan?.currency ?? 'usd',
duration_unit: plan?.duration_unit ?? 'month',
duration_value: plan?.duration_value ?? 1,
enabled: plan?.enabled ?? true,
sort_order: plan?.sort_order ?? 0,
upgrade_group: plan?.upgrade_group ?? '',
total_amount: plan?.total_amount ?? 0,
quota_reset_period: plan?.quota_reset_period ?? 'never',
})
const saveMut = useMutation({
mutationFn: (data: Partial<SubscriptionPlan>) => isEdit ? updatePlan({ ...data, id: plan!.id }) : createPlan(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-2xl">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('subscription.plan')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('subscription.plan')} {t('common.create')}</label>
<input type="text" className="input input-bordered input-sm" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required />
</div>
<div className="form-control">
<label className="label text-xs">Subtitle</label>
<input type="text" className="input input-bordered input-sm" value={form.subtitle} onChange={(e) => setForm({ ...form, subtitle: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="form-control">
<label className="label text-xs">{t('subscription.price')}</label>
<input type="number" step="0.01" className="input input-bordered input-sm" value={form.price_amount} onChange={(e) => setForm({ ...form, price_amount: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('subscription.duration')}</label>
<div className="flex gap-1">
<input type="number" className="input input-bordered input-sm w-20" value={form.duration_value} onChange={(e) => setForm({ ...form, duration_value: Number(e.target.value) })} />
<select className="select select-bordered select-sm" value={form.duration_unit} onChange={(e) => setForm({ ...form, duration_unit: e.target.value })}>
<option value="day">{t('subscription.durationDay')}</option>
<option value="month">{t('subscription.durationMonth')}</option>
<option value="year">{t('subscription.durationYear')}</option>
</select>
</div>
</div>
<div className="form-control">
<label className="label text-xs">{t('common.status')}</label>
<select className="select select-bordered select-sm w-full" value={form.enabled ? 1 : 0} onChange={(e) => setForm({ ...form, enabled: Number(e.target.value) === 1 })}>
<option value={1}>{t('common.enabled')}</option>
<option value={0}>{t('common.disabled')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('channel.group')}</label>
<input type="text" className="input input-bordered input-sm" value={form.upgrade_group} onChange={(e) => setForm({ ...form, upgrade_group: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('subscription.resetPeriod')}</label>
<select className="select select-bordered select-sm w-full" value={form.quota_reset_period} onChange={(e) => setForm({ ...form, quota_reset_period: e.target.value })}>
<option value="never">{t('subscription.resetNever')}</option>
<option value="daily">{t('subscription.resetDaily')}</option>
<option value="weekly">{t('subscription.resetWeekly')}</option>
<option value="monthly">{t('subscription.resetMonthly')}</option>
</select>
</div>
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={onClose}>close</button></form>
</dialog>
)
}
function BindSubscriptionForm({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const [form, setForm] = useState({ user_id: 0, plan_id: 0 })
const { data: plans } = useQuery({ queryKey: ['admin-plans'], queryFn: getAdminPlans })
const bindMut = useMutation({
mutationFn: bindSubscription,
onSuccess: () => {
toast.success(t('common.operationSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
bindMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{t('subscription.bindSubscription')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="form-control">
<label className="label text-xs">{t('user.id')}</label>
<input type="number" className="input input-bordered input-sm" value={form.user_id || ''} onChange={(e) => setForm({ ...form, user_id: Number(e.target.value) })} required />
</div>
<div className="form-control">
<label className="label text-xs">{t('subscription.plan')}</label>
<select className="select select-bordered select-sm w-full" value={form.plan_id} onChange={(e) => setForm({ ...form, plan_id: Number(e.target.value) })} required>
<option value={0}>{t('common.select')}</option>
{plans?.map((p) => <option key={p.id} value={p.id}>{p.title} - ${p.price_amount}</option>)}
</select>
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={bindMut.isPending}>
{bindMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.confirm')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={onClose}>close</button></form>
</dialog>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { createUser, updateUser } from '@/api/admin-user'
import type { User } from '@/types/user'
import { UserRole } from '@/types/user'
interface UserFormProps {
user: User | null
onClose: () => void
onSuccess: () => void
}
export default function UserForm({ user, onClose, onSuccess }: UserFormProps) {
const { t } = useTranslation()
const isEdit = !!user
const [form, setForm] = useState({
username: user?.username ?? '',
display_name: user?.display_name ?? '',
email: user?.email ?? '',
password: '',
group: user?.group ?? 'default',
quota: user?.quota ?? 0,
role: user?.role ?? UserRole.User,
})
const saveMut = useMutation({
mutationFn: (data: Partial<User> & { password?: string }) => isEdit ? updateUser({ ...data, id: user!.id } as Partial<User>) : createUser(data as Partial<User> & { password: string }),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!isEdit && !form.password) {
toast.error(t('auth.password'))
return
}
saveMut.mutate(form)
}
const roleOptions = [
{ value: UserRole.User, label: t('user.roleUser') },
{ value: UserRole.Admin, label: t('user.roleAdmin') },
{ value: UserRole.Root, label: t('user.roleRoot') },
]
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.users')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('user.username')}</label>
<input type="text" className="input input-bordered input-sm" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required disabled={isEdit} />
</div>
<div className="form-control">
<label className="label text-xs">{t('user.displayName')}</label>
<input type="text" className="input input-bordered input-sm" value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label text-xs">{t('user.email')}</label>
<input type="email" className="input input-bordered input-sm" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('auth.password')}{isEdit ? ' (留空不修改)' : ''}</label>
<input type="password" className="input input-bordered input-sm" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required={!isEdit} />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="form-control">
<label className="label text-xs">{t('user.group')}</label>
<input type="text" className="input input-bordered input-sm" value={form.group} onChange={(e) => setForm({ ...form, group: e.target.value })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('user.quota')}</label>
<input type="number" className="input input-bordered input-sm" value={form.quota} onChange={(e) => setForm({ ...form, quota: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label text-xs">{t('user.role')}</label>
<select className="select select-bordered select-sm w-full" value={form.role} onChange={(e) => setForm({ ...form, role: Number(e.target.value) })}>
{roleOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil, Shield, ShieldOff, UserCheck, UserX } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import StatusBadge from '@/components/common/StatusBadge'
import QuotaDisplay from '@/components/common/QuotaDisplay'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getUsers, deleteUser, manageUser } from '@/api/admin-user'
import { UserStatus, UserRole } from '@/types/user'
import type { User } from '@/types/user'
import { formatDate } from '@/lib/utils'
import UserForm from './UserForm'
export default function UserList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [formOpen, setFormOpen] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null)
const [deleteId, setDeleteId] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['admin-users', page, pageSize, search],
queryFn: () => getUsers(page, pageSize, search),
})
const deleteMut = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const manageMut = useMutation({
mutationFn: ({ id, action }: { id: number; action: string }) => manageUser(id, action),
onSuccess: () => {
toast.success(t('common.operationSuccess'))
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
},
onError: () => toast.error(t('common.operationFailed')),
})
const getStatusBadge = (status: number) => {
switch (status) {
case UserStatus.Enabled: return <StatusBadge variant="success" label={t('common.enabled')} />
case UserStatus.Disabled: return <StatusBadge variant="error" label={t('common.disabled')} />
default: return <StatusBadge variant="neutral" label={String(status)} />
}
}
const getRoleBadge = (role: number) => {
switch (role) {
case UserRole.Root: return <StatusBadge variant="error" label={t('user.roleRoot')} />
case UserRole.Admin: return <StatusBadge variant="warning" label={t('user.roleAdmin')} />
case UserRole.User: return <StatusBadge variant="info" label={t('user.roleUser')} />
default: return <StatusBadge variant="neutral" label={String(role)} />
}
}
const columns: Column<User & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'username', header: t('user.username'), render: (row) => <span className="font-medium">{row.username}</span> },
{ key: 'email', header: t('user.email'), render: (row) => <span className="text-xs">{row.email || '-'}</span> },
{ key: 'group', header: t('user.group'), width: '80px' },
{ key: 'quota', header: t('user.quota'), align: 'right', render: (row) => <QuotaDisplay quota={row.quota} /> },
{ key: 'used_quota', header: t('user.usedQuota'), align: 'right', render: (row) => <QuotaDisplay quota={row.used_quota} /> },
{ key: 'request_count', header: t('user.requestCount'), align: 'right', width: '90px' },
{ key: 'status', header: t('user.status'), render: (row) => getStatusBadge(row.status) },
{ key: 'role', header: t('user.role'), render: (row) => getRoleBadge(row.role) },
{
key: 'actions', header: t('common.actions'), width: '180px', render: (row) => (
<div className="flex items-center gap-0.5">
<button className="btn btn-ghost btn-xs" onClick={() => { setEditUser(row as unknown as User); setFormOpen(true) }}><Pencil size={13} /></button>
{row.status === UserStatus.Enabled ? (
<button className="btn btn-ghost btn-xs text-warning" onClick={() => manageMut.mutate({ id: row.id, action: 'disable' })} title={t('common.disabled')}><UserX size={13} /></button>
) : (
<button className="btn btn-ghost btn-xs text-success" onClick={() => manageMut.mutate({ id: row.id, action: 'enable' })} title={t('common.enabled')}><UserCheck size={13} /></button>
)}
{row.role < UserRole.Root && (
row.role < UserRole.Admin ? (
<button className="btn btn-ghost btn-xs" onClick={() => manageMut.mutate({ id: row.id, action: 'promote' })} title="Promote"><Shield size={13} /></button>
) : (
<button className="btn btn-ghost btn-xs" onClick={() => manageMut.mutate({ id: row.id, action: 'demote' })} title="Demote"><ShieldOff size={13} /></button>
)
)}
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={13} /></button>
</div>
),
},
]
return (
<div>
<PageHeader title={t('nav.users')} actions={
<button className="btn btn-primary btn-sm" onClick={() => { setEditUser(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
} />
<div className="flex items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (User & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<UserForm
user={editUser}
onClose={() => { setFormOpen(false); setEditUser(null) }}
onSuccess={() => { setFormOpen(false); setEditUser(null); queryClient.invalidateQueries({ queryKey: ['admin-users'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Plus, Trash2, Pencil } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import DataTable, { Column } from '@/components/common/DataTable'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getVendors, createVendor, updateVendor, deleteVendor } from '@/api/vendor'
import type { Vendor } from '@/api/vendor'
import { formatDate } from '@/lib/utils'
export default function VendorList() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editItem, setEditItem] = useState<Vendor | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['vendors', page, pageSize, search],
queryFn: () => getVendors(page, pageSize, search),
})
const deleteMut = useMutation({
mutationFn: deleteVendor,
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['vendors'] })
setDeleteId(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const columns: Column<Vendor & Record<string, unknown>>[] = [
{ key: 'id', header: 'ID', width: '60px', sortable: true },
{ key: 'name', header: t('vendor.name'), render: (row) => <span className="font-medium">{row.name}</span> },
{ key: 'description', header: t('vendor.description'), render: (row) => <span className="text-xs">{row.description || '-'}</span> },
{ key: 'created_at', header: t('common.createdAt'), render: (row) => <span className="text-xs">{formatDate(row.created_at)}</span> },
{
key: 'actions', header: t('common.actions'), width: '120px', render: (row) => (
<div className="flex items-center gap-1">
<button className="btn btn-ghost btn-xs" onClick={() => { setEditItem(row as unknown as Vendor); setFormOpen(true) }}><Pencil size={14} /></button>
<button className="btn btn-ghost btn-xs text-error" onClick={() => setDeleteId(row.id)}><Trash2 size={14} /></button>
</div>
),
},
]
return (
<div>
<PageHeader title={t('nav.vendors')} actions={
<button className="btn btn-primary btn-sm" onClick={() => { setEditItem(null); setFormOpen(true) }}>
<Plus size={16} /> {t('common.create')}
</button>
} />
<div className="flex items-center gap-3 mb-4">
<SearchInput value={search} onChange={(v) => { setSearch(v); setPage(1) }} />
</div>
<div className="card bg-base-100 shadow">
<div className="card-body p-0">
<DataTable
columns={columns}
data={(data?.data ?? []) as (Vendor & Record<string, unknown>)[]}
loading={isLoading}
total={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
rowKey={(row) => row.id}
/>
</div>
</div>
{formOpen && (
<VendorForm
item={editItem}
onClose={() => { setFormOpen(false); setEditItem(null) }}
onSuccess={() => { setFormOpen(false); setEditItem(null); queryClient.invalidateQueries({ queryKey: ['vendors'] }) }}
/>
)}
<ConfirmDialog
open={deleteId !== null}
message={t('common.deleteConfirm')}
onConfirm={() => deleteId && deleteMut.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
/>
</div>
)
}
function VendorForm({ item, onClose, onSuccess }: { item: Vendor | null; onClose: () => void; onSuccess: () => void }) {
const { t } = useTranslation()
const isEdit = !!item
const [form, setForm] = useState({ name: item?.name ?? '', description: item?.description ?? '' })
const saveMut = useMutation({
mutationFn: (data: Partial<Vendor>) => isEdit ? updateVendor({ ...data, id: item!.id }) : createVendor(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
onSuccess()
},
onError: () => toast.error(t('common.operationFailed')),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
saveMut.mutate(form)
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold mb-4">{isEdit ? t('common.edit') : t('common.create')} {t('nav.vendors')}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="form-control">
<label className="label text-xs">{t('vendor.name')}</label>
<input type="text" className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="form-control">
<label className="label text-xs">{t('vendor.description')}</label>
<textarea className="textarea textarea-bordered textarea-sm h-20" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="modal-action">
<button type="button" className="btn btn-sm" onClick={onClose}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saveMut.isPending}>
{saveMut.isPending ? <span className="loading loading-spinner loading-xs" /> : t('common.save')}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Wallet, FileText, TrendingUp, Megaphone, Copy, Plus, CreditCard } from 'lucide-react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import toast from 'react-hot-toast'
import { useAuthStore } from '@/stores/auth'
import { getSelfLogStat } from '@/api/log'
import { get } from '@/api/client'
import { renderQuota } from '@/lib/quota'
import { copyToClipboard } from '@/lib/utils'
import QuotaDisplay from '@/components/common/QuotaDisplay'
interface Notice {
id: number
title: string
content: string
created_at: number
}
export default function Dashboard() {
const { t } = useTranslation()
const navigate = useNavigate()
const { user } = useAuthStore()
const { data: statData } = useQuery({
queryKey: ['log-stat'],
queryFn: getSelfLogStat,
})
const { data: notices } = useQuery({
queryKey: ['notices'],
queryFn: () => get<Notice[]>('/notice'),
})
const chartData = statData?.days?.map((day, i) => ({
day,
quota: statData.quota_data?.[i] ?? 0,
count: statData.count_data?.[i] ?? 0,
})) ?? []
const stats = [
{ label: t('user.quota'), value: <QuotaDisplay quota={user?.quota ?? 0} />, icon: Wallet, color: 'text-success' },
{ label: t('user.usedQuota'), value: <QuotaDisplay quota={user?.used_quota ?? 0} />, icon: TrendingUp, color: 'text-warning' },
{ label: t('user.requestCount'), value: user?.request_count?.toLocaleString() ?? '0', icon: FileText, color: 'text-info' },
{ label: t('wallet.balance'), value: renderQuota((user?.quota ?? 0) - (user?.used_quota ?? 0)), icon: CreditCard, color: 'text-primary' },
]
const handleCopyKey = () => {
if (user?.aff_code) {
copyToClipboard(user.aff_code)
toast.success(t('common.copied'))
}
}
return (
<div className="space-y-6">
{/* Stat Cards */}
<div className="stats stats-vertical md:stats-horizontal shadow w-full">
{stats.map((s) => (
<div key={s.label} className="stat">
<div className="stat-figure"><s.icon className={s.color} size={28} /></div>
<div className="stat-title">{s.label}</div>
<div className="stat-value text-xl">{s.value}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Quota Trend Chart */}
<div className="lg:col-span-2 card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base">{t('dashboard.quotaTrend')}</h2>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Line type="monotone" dataKey="quota" stroke="#22c55e" strokeWidth={2} name={t('log.quota')} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[260px] text-base-content/40">
{t('common.noData')}
</div>
)}
</div>
</div>
{/* API Info */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base">{t('dashboard.apiInfo')}</h2>
<div className="space-y-3">
<div>
<div className="text-sm text-base-content/60">{t('dashboard.serverAddress')}</div>
<div className="font-mono text-sm mt-1">{window.location.origin}</div>
</div>
<div>
<div className="text-sm text-base-content/60">{t('dashboard.affCode')}</div>
<div className="flex items-center gap-2 mt-1">
<code className="text-sm bg-base-200 px-2 py-0.5 rounded">{user?.aff_code || '-'}</code>
<button className="btn btn-ghost btn-xs" onClick={handleCopyKey}>
<Copy size={12} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Announcements */}
<div className="lg:col-span-2 card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base">
<Megaphone size={18} /> {t('dashboard.announcements')}
</h2>
<div className="space-y-3">
{notices && notices.length > 0 ? notices.map((n) => (
<div key={n.id} className="border-l-4 border-primary pl-3 py-1">
<div className="font-medium text-sm">{n.title}</div>
<div className="text-xs text-base-content/60 mt-1">{n.content}</div>
</div>
)) : (
<div className="text-base-content/40 text-sm">{t('common.noData')}</div>
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base">{t('dashboard.quickActions')}</h2>
<div className="flex flex-col gap-2">
<button className="btn btn-primary btn-sm" onClick={() => navigate('/tokens')}>
<Plus size={16} /> {t('token.createToken')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/wallet')}>
<CreditCard size={16} /> {t('wallet.topUp')}
</button>
<button className="btn btn-outline btn-sm" onClick={() => navigate('/logs')}>
<FileText size={16} /> {t('nav.logs')}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit, Trash2, GripVertical } from 'lucide-react'
import toast from 'react-hot-toast'
import LoadingSpinner from '@/components/common/LoadingSpinner'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/doc'
import type { DocCategory } from '@/types/doc'
interface Props {
open: boolean
onClose: () => void
}
export default function DocCategoryManager({ open, onClose }: Props) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editCat, setEditCat] = useState<DocCategory | null>(null)
const [showForm, setShowForm] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<DocCategory | null>(null)
const [form, setForm] = useState({ name: '', label: '', parent_id: 0, sort_order: 0 })
const { data: categories = [], isLoading } = useQuery({
queryKey: ['doc-categories'],
queryFn: getCategories,
enabled: open,
})
const createMutation = useMutation({
mutationFn: (data: Partial<DocCategory>) => createCategory(data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
queryClient.invalidateQueries({ queryKey: ['doc-categories'] })
closeForm()
},
onError: () => toast.error(t('common.operationFailed')),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<DocCategory> }) => updateCategory(id, data),
onSuccess: () => {
toast.success(t('common.saveSuccess'))
queryClient.invalidateQueries({ queryKey: ['doc-categories'] })
closeForm()
},
onError: () => toast.error(t('common.operationFailed')),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['doc-categories'] })
setDeleteTarget(null)
},
onError: () => toast.error(t('common.operationFailed')),
})
const openEdit = (cat: DocCategory) => {
setEditCat(cat)
setForm({ name: cat.name, label: cat.label, parent_id: cat.parent_id ?? 0, sort_order: cat.sort_order })
setShowForm(true)
}
const openCreate = () => {
setEditCat(null)
setForm({ name: '', label: '', parent_id: 0, sort_order: 0 })
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditCat(null)
}
const handleSave = () => {
if (!form.name.trim() || !form.label.trim()) {
toast.error(t('common.error'))
return
}
const data = { ...form, parent_id: form.parent_id || undefined }
if (editCat) {
updateMutation.mutate({ id: editCat.id, data })
} else {
createMutation.mutate(data)
}
}
if (!open) return null
const topCategories = categories.filter((c) => !c.parent_id)
const getChildren = (parentId: number) => categories.filter((c) => c.parent_id === parentId)
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold">{t('doc.manageCategories')}</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose}>{t('common.close')}</button>
</div>
{isLoading ? (
<LoadingSpinner size="sm" />
) : (
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{topCategories.map((cat) => (
<div key={cat.id}>
<div className="flex items-center gap-2 p-2 rounded hover:bg-base-200">
<GripVertical size={14} className="text-base-content/30" />
<span className="flex-1 font-medium text-sm">{cat.label}</span>
<span className="text-xs text-base-content/50">{cat.name}</span>
<button className="btn btn-xs btn-ghost" onClick={() => openEdit(cat)}>
<Edit size={12} />
</button>
<button className="btn btn-xs btn-ghost text-error" onClick={() => setDeleteTarget(cat)}>
<Trash2 size={12} />
</button>
</div>
{getChildren(cat.id).map((child) => (
<div key={child.id} className="flex items-center gap-2 p-2 pl-8 rounded hover:bg-base-200">
<GripVertical size={14} className="text-base-content/30" />
<span className="flex-1 text-sm">{child.label}</span>
<span className="text-xs text-base-content/50">{child.name}</span>
<button className="btn btn-xs btn-ghost" onClick={() => openEdit(child)}>
<Edit size={12} />
</button>
<button className="btn btn-xs btn-ghost text-error" onClick={() => setDeleteTarget(child)}>
<Trash2 size={12} />
</button>
</div>
))}
</div>
))}
{categories.length === 0 && (
<p className="text-center text-base-content/50 py-4">{t('common.noData')}</p>
)}
</div>
)}
<div className="mt-4">
<button className="btn btn-sm btn-primary" onClick={openCreate}>
<Plus size={16} /> {t('doc.addCategory')}
</button>
</div>
{/* Add/Edit form */}
{showForm && (
<div className="mt-4 p-4 bg-base-200 rounded-box space-y-3">
<div className="form-control">
<label className="label"><span className="label-text text-xs">{t('doc.categoryName')}</span></label>
<input
type="text"
className="input input-bordered input-sm"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. getting-started"
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text text-xs">{t('doc.categoryLabel')}</span></label>
<input
type="text"
className="input input-bordered input-sm"
value={form.label}
onChange={(e) => setForm((p) => ({ ...p, label: e.target.value }))}
placeholder={t('doc.categoryLabel')}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text text-xs">{t('doc.parentCategory')}</span></label>
<select
className="select select-bordered select-sm"
value={form.parent_id}
onChange={(e) => setForm((p) => ({ ...p, parent_id: Number(e.target.value) }))}
>
<option value={0}>{t('common.none')}</option>
{topCategories
.filter((c) => c.id !== editCat?.id)
.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text text-xs">{t('common.sortOrder')}</span></label>
<input
type="number"
className="input input-bordered input-sm"
value={form.sort_order}
onChange={(e) => setForm((p) => ({ ...p, sort_order: Number(e.target.value) }))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<button className="btn btn-xs btn-ghost" onClick={closeForm}>{t('common.cancel')}</button>
<button className="btn btn-xs btn-primary" onClick={handleSave} disabled={createMutation.isPending || updateMutation.isPending}>
{t('common.save')}
</button>
</div>
</div>
)}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
<ConfirmDialog
open={!!deleteTarget}
message={t('common.deleteConfirm')}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
/>
</dialog>
)
}
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { BookOpen, ChevronRight, ChevronDown, Plus, Settings, Menu } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import SearchInput from '@/components/common/SearchInput'
import LoadingSpinner from '@/components/common/LoadingSpinner'
import { usePermission } from '@/hooks/usePermission'
import { getCategories, getDocs } from '@/api/doc'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import DocCategoryManager from './DocCategoryManager'
export default function DocCenter() {
const { t } = useTranslation()
const navigate = useNavigate()
const { isAdmin } = usePermission()
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
const [drawerOpen, setDrawerOpen] = useState(false)
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false)
const { data: categories = [], isLoading: catLoading } = useQuery({
queryKey: ['doc-categories'],
queryFn: getCategories,
})
const { data: docsData, isLoading: docsLoading } = useQuery({
queryKey: ['docs', selectedCategory, search],
queryFn: () => getDocs({ category: selectedCategory || undefined, search: search || undefined }),
})
const toggleExpand = (name: string) => {
setExpandedCats((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
const docs = docsData?.data ?? []
const topCategories = categories.filter((c) => !c.parent_id)
const getChildren = (parentId: number) => categories.filter((c) => c.parent_id === parentId)
return (
<div className="flex flex-col h-full">
<PageHeader
title={t('doc.title')}
description={t('doc.description')}
actions={
isAdmin ? (
<div className="flex gap-2">
<button className="btn btn-sm btn-primary" onClick={() => navigate('/docs/new/edit')}>
<Plus size={16} /> {t('doc.createDoc')}
</button>
<button className="btn btn-sm btn-ghost" onClick={() => setCategoryManagerOpen(true)}>
<Settings size={16} /> {t('doc.manageCategories')}
</button>
</div>
) : undefined
}
/>
<div className="flex flex-1 gap-4 min-h-0">
{/* Mobile drawer toggle */}
<button
className="btn btn-sm btn-ghost lg:hidden fixed top-20 left-4 z-40"
onClick={() => setDrawerOpen(!drawerOpen)}
>
<Menu size={18} />
</button>
{/* Sidebar */}
<aside
className={cn(
'w-64 shrink-0 bg-base-200 rounded-box p-4 overflow-y-auto',
'fixed inset-y-0 left-0 z-30 transition-transform lg:relative lg:translate-x-0',
drawerOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-sm">{t('doc.categories')}</h3>
<button className="btn btn-xs btn-ghost lg:hidden" onClick={() => setDrawerOpen(false)}>
{t('common.close')}
</button>
</div>
{catLoading ? (
<LoadingSpinner size="sm" />
) : (
<ul className="menu menu-sm gap-0.5">
<li>
<button
className={cn(!selectedCategory && 'active')}
onClick={() => { setSelectedCategory(null); setDrawerOpen(false) }}
>
<BookOpen size={14} /> {t('doc.allDocs')}
</button>
</li>
{topCategories.map((cat) => {
const children = getChildren(cat.id)
const expanded = expandedCats.has(cat.name)
return (
<li key={cat.id}>
<button
className={cn(selectedCategory === cat.name && 'active')}
onClick={() => { setSelectedCategory(cat.name); setDrawerOpen(false) }}
>
{children.length > 0 ? (
<span className="cursor-pointer" onClick={(e) => { e.stopPropagation(); toggleExpand(cat.name) }}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
) : (
<BookOpen size={14} />
)}
{cat.label}
</button>
{expanded && children.length > 0 && (
<ul>
{children.map((child) => (
<li key={child.id}>
<button
className={cn(selectedCategory === child.name && 'active')}
onClick={() => { setSelectedCategory(child.name); setDrawerOpen(false) }}
>
<BookOpen size={12} /> {child.label}
</button>
</li>
))}
</ul>
)}
</li>
)
})}
</ul>
)}
</aside>
{/* Overlay for mobile drawer */}
{drawerOpen && (
<div className="fixed inset-0 bg-black/30 z-20 lg:hidden" onClick={() => setDrawerOpen(false)} />
)}
{/* Content */}
<main className="flex-1 min-w-0">
<div className="mb-4">
<SearchInput value={search} onChange={setSearch} placeholder={t('doc.searchPlaceholder')} />
</div>
{docsLoading ? (
<LoadingSpinner text={t('common.loading')} />
) : docs.length === 0 ? (
<div className="text-center py-16 text-base-content/50">
<BookOpen size={48} className="mx-auto mb-4 opacity-30" />
<p>{t('common.noData')}</p>
</div>
) : (
<div className="grid gap-3">
{docs.map((doc) => (
<div
key={doc.id}
className="card card-compact bg-base-200 hover:bg-base-300 cursor-pointer transition-colors"
onClick={() => navigate(`/docs/${doc.slug}`)}
>
<div className="card-body">
<h3 className="card-title text-base">{doc.title}</h3>
{doc.excerpt && (
<p className="text-sm text-base-content/60 line-clamp-2">{doc.excerpt}</p>
)}
<div className="flex items-center gap-3 text-xs text-base-content/50 mt-1">
{doc.category && (
<span className="badge badge-sm badge-ghost">{doc.category}</span>
)}
<span>{formatDate(doc.updated_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</main>
</div>
<DocCategoryManager open={categoryManagerOpen} onClose={() => setCategoryManagerOpen(false)} />
</div>
)
}
+250
View File
@@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { Eye, Code, Save } from 'lucide-react'
import toast from 'react-hot-toast'
import PageHeader from '@/components/common/PageHeader'
import LoadingSpinner from '@/components/common/LoadingSpinner'
import { getDoc, getCategories, createDoc, updateDoc } from '@/api/doc'
import type { Doc } from '@/types/doc'
const DRAFT_KEY = 'doc-editor-draft'
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
export default function DocEditor() {
const { t } = useTranslation()
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const isEditing = slug && slug !== 'new'
const [form, setForm] = useState({
title: '',
slug: '',
category: '',
content: '',
visibility: 'public' as Doc['visibility'],
sort_order: 0,
is_published: true,
})
const [showPreview, setShowPreview] = useState(false)
const { data: existingDoc, isLoading: docLoading } = useQuery({
queryKey: ['doc', slug],
queryFn: () => getDoc(slug!),
enabled: !!isEditing,
})
const { data: categories = [] } = useQuery({
queryKey: ['doc-categories'],
queryFn: getCategories,
})
useEffect(() => {
if (existingDoc) {
setForm({
title: existingDoc.title,
slug: existingDoc.slug,
category: existingDoc.category,
content: existingDoc.content,
visibility: existingDoc.visibility,
sort_order: existingDoc.sort_order,
is_published: existingDoc.is_published,
})
} else if (!isEditing) {
const draft = localStorage.getItem(DRAFT_KEY)
if (draft) {
try { setForm(JSON.parse(draft)) } catch { /* ignore */ }
}
}
}, [existingDoc, isEditing])
const autoSave = useCallback(() => {
if (!isEditing) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(form))
}
}, [form, isEditing])
useEffect(() => {
const timer = setTimeout(autoSave, 1000)
return () => clearTimeout(timer)
}, [autoSave])
const saveMutation = useMutation({
mutationFn: () => {
const data = { ...form }
if (isEditing && existingDoc) {
return updateDoc(existingDoc.id, data)
}
return createDoc(data)
},
onSuccess: (result) => {
toast.success(t('common.saveSuccess'))
localStorage.removeItem(DRAFT_KEY)
queryClient.invalidateQueries({ queryKey: ['docs'] })
if (!isEditing) {
navigate(`/docs/${result.slug}`)
} else {
navigate(`/docs/${form.slug}`)
}
},
onError: () => toast.error(t('common.operationFailed')),
})
const updateField = <K extends keyof typeof form>(key: K, value: typeof form[K]) => {
setForm((prev) => {
const next = { ...prev, [key]: value }
if (key === 'title' && !isEditing) {
next.slug = slugify(value as string)
}
return next
})
}
if (isEditing && docLoading) return <LoadingSpinner text={t('common.loading')} className="min-h-[60vh]" />
return (
<div>
<PageHeader
title={isEditing ? t('doc.editDoc') : t('doc.createDoc')}
actions={
<div className="flex gap-2">
<button className="btn btn-sm btn-ghost" onClick={() => navigate(-1)}>
{t('common.cancel')}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
<Save size={16} /> {t('common.save')}
</button>
</div>
}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Form fields */}
<div className="space-y-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('doc.title')}</span></label>
<input
type="text"
className="input input-bordered input-sm"
value={form.title}
onChange={(e) => updateField('title', e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('doc.slug')}</span></label>
<input
type="text"
className="input input-bordered input-sm font-mono"
value={form.slug}
onChange={(e) => updateField('slug', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('doc.category')}</span></label>
<select
className="select select-bordered select-sm"
value={form.category}
onChange={(e) => updateField('category', e.target.value)}
>
<option value="">{t('common.select')}</option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.label}</option>
))}
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('doc.visibility')}</span></label>
<select
className="select select-bordered select-sm"
value={form.visibility}
onChange={(e) => updateField('visibility', e.target.value as Doc['visibility'])}
>
<option value="public">{t('doc.visibilityPublic')}</option>
<option value="auth">{t('doc.visibilityAuth')}</option>
<option value="admin">{t('doc.visibilityAdmin')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('common.sortOrder')}</span></label>
<input
type="number"
className="input input-bordered input-sm"
value={form.sort_order}
onChange={(e) => updateField('sort_order', Number(e.target.value))}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('doc.published')}</span></label>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary mt-2"
checked={form.is_published}
onChange={(e) => updateField('is_published', e.target.checked)}
/>
</div>
</div>
</div>
{/* Right: Content editor / preview */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="label-text font-semibold">{t('doc.content')}</label>
<div className="join">
<button
className={`btn btn-xs join-item ${!showPreview ? 'btn-active' : ''}`}
onClick={() => setShowPreview(false)}
>
<Code size={14} /> {t('doc.edit')}
</button>
<button
className={`btn btn-xs join-item ${showPreview ? 'btn-active' : ''}`}
onClick={() => setShowPreview(true)}
>
<Eye size={14} /> {t('doc.preview')}
</button>
</div>
</div>
{showPreview ? (
<div className="prose prose-sm max-w-none dark:prose-invert bg-base-200 rounded-box p-4 min-h-[400px] max-h-[600px] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{form.content}
</ReactMarkdown>
</div>
) : (
<textarea
className="textarea textarea-bordered font-mono text-sm w-full min-h-[400px] max-h-[600px]"
value={form.content}
onChange={(e) => updateField('content', e.target.value)}
placeholder={t('doc.contentPlaceholder')}
/>
)}
</div>
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { ChevronRight, Edit, Trash2, ArrowLeft, ArrowRight } from 'lucide-react'
import toast from 'react-hot-toast'
import LoadingSpinner from '@/components/common/LoadingSpinner'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { usePermission } from '@/hooks/usePermission'
import { getDoc, getDocs, deleteDoc } from '@/api/doc'
import { formatDate, cn } from '@/lib/utils'
import { useState } from 'react'
export default function DocViewer() {
const { t } = useTranslation()
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const { isAdmin } = usePermission()
const queryClient = useQueryClient()
const [deleteConfirm, setDeleteConfirm] = useState(false)
const { data: doc, isLoading, error } = useQuery({
queryKey: ['doc', slug],
queryFn: () => getDoc(slug!),
enabled: !!slug,
})
const { data: docsData } = useQuery({
queryKey: ['docs-nav'],
queryFn: () => getDocs({ category: doc?.category }),
enabled: !!doc,
})
const deleteMutation = useMutation({
mutationFn: () => deleteDoc(doc!.id),
onSuccess: () => {
toast.success(t('common.deleteSuccess'))
queryClient.invalidateQueries({ queryKey: ['docs'] })
navigate('/docs')
},
onError: () => toast.error(t('common.operationFailed')),
})
const toc = useMemo(() => {
if (!doc?.content) return []
const headingRegex = /^(#{2,3})\s+(.+)$/gm
const items: { level: number; text: string; id: string }[] = []
let match
while ((match = headingRegex.exec(doc.content)) !== null) {
const level = match[1].length
const text = match[2].trim()
const id = text.toLowerCase().replace(/[^\w\u4e00-\u9fff]+/g, '-')
items.push({ level, text, id })
}
return items
}, [doc?.content])
const { prevDoc, nextDoc } = useMemo(() => {
if (!docsData?.data || !doc) return {}
const list = docsData.data
const idx = list.findIndex((d) => d.id === doc.id)
return { prevDoc: idx > 0 ? list[idx - 1] : undefined, nextDoc: idx < list.length - 1 ? list[idx + 1] : undefined }
}, [docsData, doc])
if (isLoading) return <LoadingSpinner text={t('common.loading')} className="min-h-[60vh]" />
if (error || !doc) {
return (
<div className="text-center py-16">
<p className="text-base-content/50">{t('common.notFound')}</p>
<button className="btn btn-sm btn-ghost mt-4" onClick={() => navigate('/docs')}>{t('common.back')}</button>
</div>
)
}
return (
<div className="flex gap-6">
{/* Main content */}
<article className="flex-1 min-w-0">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm text-base-content/50 mb-4">
<button className="hover:text-primary" onClick={() => navigate('/docs')}>{t('doc.title')}</button>
{doc.category && (
<>
<ChevronRight size={14} />
<span>{doc.category}</span>
</>
)}
<ChevronRight size={14} />
<span className="text-base-content">{doc.title}</span>
</div>
{/* Header */}
<div className="mb-6">
<h1 className="text-3xl font-bold mb-3">{doc.title}</h1>
<div className="flex items-center gap-4 text-sm text-base-content/50">
{doc.author && <span>{doc.author}</span>}
<span>{formatDate(doc.updated_at)}</span>
{doc.category && <span className="badge badge-sm badge-ghost">{doc.category}</span>}
{isAdmin && (
<div className="flex gap-2 ml-auto">
<button className="btn btn-xs btn-ghost" onClick={() => navigate(`/docs/${doc.slug}/edit`)}>
<Edit size={14} /> {t('common.edit')}
</button>
<button className="btn btn-xs btn-ghost text-error" onClick={() => setDeleteConfirm(true)}>
<Trash2 size={14} /> {t('common.delete')}
</button>
</div>
)}
</div>
</div>
{/* Markdown content */}
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{doc.content}
</ReactMarkdown>
</div>
{/* Prev/Next navigation */}
<div className="flex justify-between mt-8 pt-4 border-t border-base-300">
{prevDoc ? (
<button className="btn btn-sm btn-ghost" onClick={() => navigate(`/docs/${prevDoc.slug}`)}>
<ArrowLeft size={14} /> {prevDoc.title}
</button>
) : <div />}
{nextDoc ? (
<button className="btn btn-sm btn-ghost" onClick={() => navigate(`/docs/${nextDoc.slug}`)}>
{nextDoc.title} <ArrowRight size={14} />
</button>
) : <div />}
</div>
</article>
{/* TOC sidebar */}
{toc.length > 0 && (
<aside className="hidden xl:block w-56 shrink-0">
<div className="sticky top-20">
<h4 className="text-sm font-semibold mb-2">{t('doc.tableOfContents')}</h4>
<ul className="menu menu-sm gap-0.5">
{toc.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
className={cn(item.level === 3 && 'pl-6')}
onClick={(e) => {
e.preventDefault()
document.getElementById(item.id)?.scrollIntoView({ behavior: 'smooth' })
}}
>
{item.text}
</a>
</li>
))}
</ul>
</div>
</aside>
)}
<ConfirmDialog
open={deleteConfirm}
message={t('common.deleteConfirm')}
onConfirm={() => deleteMutation.mutate()}
onCancel={() => setDeleteConfirm(false)}
/>
</div>
)
}
+370
View File
@@ -0,0 +1,370 @@
import { useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { Send, Copy, Code } from 'lucide-react'
import toast from 'react-hot-toast'
import PageHeader from '@/components/common/PageHeader'
import { getTokens } from '@/api/token'
import { copyToClipboard } from '@/lib/utils'
interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
interface Usage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
export default function Playground() {
const { t } = useTranslation()
const [model, setModel] = useState('')
const [systemPrompt, setSystemPrompt] = useState('')
const [userMessage, setUserMessage] = useState('')
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2048)
const [topP, setTopP] = useState(1)
const [frequencyPenalty, setFrequencyPenalty] = useState(0)
const [presencePenalty, setPresencePenalty] = useState(0)
const [stream, setStream] = useState(true)
const [apiKey, setApiKey] = useState('')
const [response, setResponse] = useState('')
const [rawJson, setRawJson] = useState('')
const [usage, setUsage] = useState<Usage | null>(null)
const [loading, setLoading] = useState(false)
const [showRaw, setShowRaw] = useState(false)
const [showCode, setShowCode] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const { data: tokensData } = useQuery({
queryKey: ['tokens-playground'],
queryFn: () => getTokens(1, 100),
})
const { data: modelsData } = useQuery({
queryKey: ['user-models'],
queryFn: async () => {
const res = await fetch('/api/user/models', { credentials: 'include' })
const data = await res.json()
return data.data || data || []
},
})
const models: string[] = Array.isArray(modelsData) ? modelsData : []
const tokens = tokensData?.data ?? []
const sendRequest = useCallback(async () => {
if (!model || !userMessage.trim()) {
toast.error(t('playground.fillRequired'))
return
}
if (!apiKey.trim()) {
toast.error(t('playground.selectApiKey'))
return
}
setLoading(true)
setResponse('')
setRawJson('')
setUsage(null)
abortRef.current = new AbortController()
const messages: ChatMessage[] = []
if (systemPrompt.trim()) messages.push({ role: 'system', content: systemPrompt })
messages.push({ role: 'user', content: userMessage })
const body = {
model,
messages,
temperature,
max_tokens: maxTokens,
top_p: topP,
frequency_penalty: frequencyPenalty,
presence_penalty: presencePenalty,
stream,
}
const baseUrl = window.location.origin
try {
if (stream) {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: abortRef.current.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: { message: res.statusText } }))
throw new Error(err.error?.message || `HTTP ${res.status}`)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let fullText = ''
while (reader) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]\n') continue
try {
const json = JSON.parse(line.slice(6))
const delta = json.choices?.[0]?.delta?.content
if (delta) {
fullText += delta
setResponse(fullText)
}
if (json.usage) setUsage(json.usage)
} catch { /* skip */ }
}
}
setRawJson(JSON.stringify({ ...body, response: fullText }, null, 2))
} else {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: abortRef.current.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: { message: res.statusText } }))
throw new Error(err.error?.message || `HTTP ${res.status}`)
}
const json = await res.json()
const content = json.choices?.[0]?.message?.content || ''
setResponse(content)
setUsage(json.usage || null)
setRawJson(JSON.stringify(json, null, 2))
}
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
toast.error(err.message || t('common.networkError'))
}
} finally {
setLoading(false)
}
}, [model, userMessage, systemPrompt, apiKey, temperature, maxTokens, topP, frequencyPenalty, presencePenalty, stream, t])
const stopRequest = () => {
abortRef.current?.abort()
setLoading(false)
}
const codeSnippets = {
curl: `curl ${window.location.origin}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
-d '${JSON.stringify({ model, messages: [{ role: 'user', content: userMessage }], temperature, stream }, null, 2)}'`,
python: `import openai
client = openai.OpenAI(
api_key="${apiKey || 'YOUR_API_KEY'}",
base_url="${window.location.origin}/v1"
)
response = client.chat.completions.create(
model="${model}",
messages=[{"role": "user", "content": "${userMessage.replace(/"/g, '\\"')}"}],
temperature=${temperature},
stream=${stream}
)`,
node: `import OpenAI from 'openai';
const client = new OpenAI({
apiKey: '${apiKey || 'YOUR_API_KEY'}',
baseURL: '${window.location.origin}/v1',
});
const response = await client.chat.completions.create({
model: '${model}',
messages: [{ role: 'user', content: '${userMessage.replace(/'/g, "\\'")}' }],
temperature: ${temperature},
stream: ${stream},
});`,
}
return (
<div>
<PageHeader title={t('playground.title')} description={t('playground.description')} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Request config */}
<div className="space-y-4">
{/* API Key */}
<div className="form-control">
<label className="label"><span className="label-text text-xs font-semibold">{t('playground.apiKey')}</span></label>
<select className="select select-bordered select-sm w-full" value={apiKey} onChange={(e) => setApiKey(e.target.value)}>
<option value="">{t('playground.selectApiKey')}</option>
{tokens.map((tk) => (
<option key={tk.id} value={tk.key}>{tk.name} ({tk.key.slice(0, 8)}...)</option>
))}
</select>
</div>
{/* Model */}
<div className="form-control">
<label className="label"><span className="label-text text-xs font-semibold">{t('playground.model')}</span></label>
<input
type="text"
className="input input-bordered input-sm w-full"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={t('playground.modelPlaceholder')}
list="playground-models"
/>
<datalist id="playground-models">
{models.map((m: string) => <option key={m} value={m} />)}
</datalist>
</div>
{/* System prompt */}
<div className="form-control">
<label className="label"><span className="label-text text-xs font-semibold">{t('playground.systemPrompt')}</span></label>
<textarea
className="textarea textarea-bordered text-sm min-h-[60px]"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder={t('playground.systemPromptPlaceholder')}
/>
</div>
{/* User message */}
<div className="form-control">
<label className="label"><span className="label-text text-xs font-semibold">{t('playground.userMessage')}</span></label>
<textarea
className="textarea textarea-bordered text-sm min-h-[80px]"
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
placeholder={t('playground.userMessagePlaceholder')}
/>
</div>
{/* Parameters */}
<div className="collapse collapse-arrow bg-base-200">
<input type="checkbox" />
<div className="collapse-title text-sm font-semibold">{t('playground.parameters')}</div>
<div className="collapse-content space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>Temperature</span><span>{temperature}</span>
</div>
<input type="range" min={0} max={2} step={0.1} className="range range-xs range-primary" value={temperature} onChange={(e) => setTemperature(Number(e.target.value))} />
</div>
<div className="form-control">
<label className="label"><span className="label-text text-xs">Max Tokens</span></label>
<input type="number" className="input input-bordered input-xs w-full" value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>Top P</span><span>{topP}</span>
</div>
<input type="range" min={0} max={1} step={0.05} className="range range-xs range-primary" value={topP} onChange={(e) => setTopP(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>Frequency Penalty</span><span>{frequencyPenalty}</span>
</div>
<input type="range" min={-2} max={2} step={0.1} className="range range-xs range-primary" value={frequencyPenalty} onChange={(e) => setFrequencyPenalty(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>Presence Penalty</span><span>{presencePenalty}</span>
</div>
<input type="range" min={-2} max={2} step={0.1} className="range range-xs range-primary" value={presencePenalty} onChange={(e) => setPresencePenalty(Number(e.target.value))} />
</div>
<div className="form-control">
<label className="label cursor-pointer gap-2">
<span className="label-text text-xs">Stream</span>
<input type="checkbox" className="toggle toggle-xs toggle-primary" checked={stream} onChange={(e) => setStream(e.target.checked)} />
</label>
</div>
</div>
</div>
{/* Send button */}
<button
className={`btn btn-primary btn-sm w-full ${loading ? 'btn-disabled' : ''}`}
onClick={loading ? stopRequest : sendRequest}
>
{loading ? t('playground.stop') : <><Send size={16} /> {t('playground.send')}</>}
</button>
</div>
{/* Right: Response */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">{t('playground.response')}</h3>
<div className="flex gap-1">
<button
className="btn btn-xs btn-ghost"
onClick={() => { copyToClipboard(response); toast.success(t('common.copied')) }}
disabled={!response}
>
<Copy size={12} /> {t('common.copy')}
</button>
<button
className={`btn btn-xs btn-ghost ${showRaw ? 'btn-active' : ''}`}
onClick={() => setShowRaw(!showRaw)}
>
{t('playground.rawJson')}
</button>
</div>
</div>
<div className="bg-base-200 rounded-box p-4 min-h-[300px] max-h-[500px] overflow-y-auto">
{showRaw ? (
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{rawJson || t('common.noData')}</pre>
) : response ? (
<div className="whitespace-pre-wrap text-sm">{response}</div>
) : (
<p className="text-base-content/30 text-sm text-center py-16">{t('playground.noResponse')}</p>
)}
</div>
{/* Usage stats */}
{usage && (
<div className="flex gap-3">
<div className="stat bg-base-200 rounded-box p-2 flex-1">
<div className="stat-title text-xs">{t('playground.promptTokens')}</div>
<div className="stat-value text-base">{usage.prompt_tokens}</div>
</div>
<div className="stat bg-base-200 rounded-box p-2 flex-1">
<div className="stat-title text-xs">{t('playground.completionTokens')}</div>
<div className="stat-value text-base">{usage.completion_tokens}</div>
</div>
<div className="stat bg-base-200 rounded-box p-2 flex-1">
<div className="stat-title text-xs">{t('playground.totalTokens')}</div>
<div className="stat-value text-base">{usage.total_tokens}</div>
</div>
</div>
)}
{/* Code examples */}
<div className="collapse collapse-arrow bg-base-200">
<input type="checkbox" checked={showCode} onChange={(e) => setShowCode(e.target.checked)} />
<div className="collapse-title text-sm font-semibold flex items-center gap-2">
<Code size={14} /> {t('playground.codeExamples')}
</div>
<div className="collapse-content">
<div role="tablist" className="tabs tabs-boxed tabs-xs mb-2">
{Object.keys(codeSnippets).map((lang) => (
<a key={lang} role="tab" className="tab">{lang}</a>
))}
</div>
{Object.entries(codeSnippets).map(([lang, code]) => (
<div key={lang} className="relative">
<pre className="text-xs font-mono bg-base-300 p-3 rounded overflow-x-auto whitespace-pre-wrap">{code}</pre>
<button
className="btn btn-xs btn-ghost absolute top-1 right-1"
onClick={() => { copyToClipboard(code); toast.success(t('common.copied')) }}
>
<Copy size={10} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
+307
View File
@@ -0,0 +1,307 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { User, Lock, Shield, Key, Trash2, Copy, Globe, Link2 } from 'lucide-react'
import PageHeader from '@/components/common/PageHeader'
import ConfirmDialog from '@/components/common/ConfirmDialog'
import { useAuthStore } from '@/stores/auth'
import { updateSelf, deleteSelf, get2FAStatus, setup2FA, enable2FA, disable2FA, generateAccessToken } from '@/api/user'
import { copyToClipboard } from '@/lib/utils'
export default function Profile() {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const { user, fetchUser } = useAuthStore()
// Profile form
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [email, setEmail] = useState(user?.email ?? '')
// Password form
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
// 2FA
const [twoFACode, setTwoFACode] = useState('')
const [disable2FACode, setDisable2FACode] = useState('')
// Delete account
const [deleteOpen, setDeleteOpen] = useState(false)
// Access token
const [accessToken, setAccessToken] = useState('')
// Active tab
const [activeTab, setActiveTab] = useState('profile')
const { data: twoFAStatus } = useQuery({ queryKey: ['2fa-status'], queryFn: get2FAStatus })
const updateMut = useMutation({
mutationFn: updateSelf,
onSuccess: () => { toast.success(t('common.saveSuccess')); fetchUser() },
onError: () => toast.error(t('common.operationFailed')),
})
const passwordMut = useMutation({
mutationFn: (data: { old_password: string; password: string }) => updateSelf(data),
onSuccess: () => { toast.success(t('common.saveSuccess')); setOldPassword(''); setNewPassword(''); setConfirmPassword('') },
onError: () => toast.error(t('common.operationFailed')),
})
const setup2FAMut = useMutation({
mutationFn: setup2FA,
onSuccess: () => toast.success(t('profile.qrCodeGenerated')),
onError: () => toast.error(t('common.operationFailed')),
})
const enable2FAMut = useMutation({
mutationFn: enable2FA,
onSuccess: (data) => {
if (data.success) { toast.success(t('common.saveSuccess')); queryClient.invalidateQueries({ queryKey: ['2fa-status'] }) }
else toast.error(data.message)
},
onError: () => toast.error(t('common.operationFailed')),
})
const disable2FAMut = useMutation({
mutationFn: disable2FA,
onSuccess: (data) => {
if (data.success) { toast.success(t('common.saveSuccess')); setDisable2FACode(''); queryClient.invalidateQueries({ queryKey: ['2fa-status'] }) }
else toast.error(data.message)
},
onError: () => toast.error(t('common.operationFailed')),
})
const deleteMut = useMutation({
mutationFn: deleteSelf,
onSuccess: () => { toast.success(t('profile.accountDeleted')); useAuthStore.getState().logout() },
onError: () => toast.error(t('common.operationFailed')),
})
const tokenMut = useMutation({
mutationFn: generateAccessToken,
onSuccess: (data) => { setAccessToken(data.token); toast.success(t('common.success')) },
onError: () => toast.error(t('common.operationFailed')),
})
const handleProfileSave = () => {
updateMut.mutate({ display_name: displayName, email })
}
const handlePasswordSave = () => {
if (newPassword !== confirmPassword) { toast.error(t('auth.passwordMismatch')); return }
if (newPassword.length < 8) { toast.error(t('profile.passwordTooShort')); return }
passwordMut.mutate({ old_password: oldPassword, password: newPassword })
}
const oauthProviders = [
{ key: 'github_id', label: 'GitHub', field: user?.github_id },
{ key: 'discord_id', label: 'Discord', field: user?.discord_id },
{ key: 'oidc_id', label: 'OIDC', field: user?.oidc_id },
{ key: 'wechat_id', label: 'WeChat', field: user?.wechat_id },
{ key: 'telegram_id', label: 'Telegram', field: user?.telegram_id },
{ key: 'linux_do_id', label: 'LinuxDo', field: user?.linux_do_id },
]
const tabs = [
{ key: 'profile', label: t('profile.profileInfo'), icon: User },
{ key: 'security', label: t('profile.security'), icon: Lock },
{ key: 'oauth', label: t('profile.oauthBindings'), icon: Link2 },
]
return (
<div className="space-y-6">
<PageHeader title={t('nav.profile')} />
{/* Tabs */}
<div role="tablist" className="tabs tabs-bordered">
{tabs.map((tab) => (
<button
key={tab.key}
role="tab"
className={activeTab === tab.key ? 'tab tab-active' : 'tab'}
onClick={() => setActiveTab(tab.key)}
>
<tab.icon size={14} className="mr-1" /> {tab.label}
</button>
))}
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base"><User size={18} /> {t('profile.profileInfo')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<div className="form-control">
<label className="label"><span className="label-text">{t('user.username')}</span></label>
<input className="input input-bordered input-sm" value={user?.username ?? ''} disabled />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('user.displayName')}</span></label>
<input className="input input-bordered input-sm" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('user.email')}</span></label>
<input className="input input-bordered input-sm" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.language')}</span></label>
<select
className="select select-bordered select-sm"
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
<option value="zh"></option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="card-actions justify-end mt-4">
<button className="btn btn-primary btn-sm" disabled={updateMut.isPending} onClick={handleProfileSave}>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
{/* Security Tab */}
{activeTab === 'security' && (
<div className="space-y-6">
{/* Password */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base"><Lock size={18} /> {t('profile.changePassword')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="form-control">
<label className="label"><span className="label-text">{t('profile.oldPassword')}</span></label>
<input type="password" className="input input-bordered input-sm" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('profile.newPassword')}</span></label>
<input type="password" className="input input-bordered input-sm" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.confirmPassword')}</span></label>
<input type="password" className="input input-bordered input-sm" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
</div>
</div>
<div className="card-actions justify-end mt-4">
<button className="btn btn-primary btn-sm" disabled={passwordMut.isPending} onClick={handlePasswordSave}>
{t('common.save')}
</button>
</div>
</div>
</div>
{/* 2FA */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base"><Shield size={18} /> {t('profile.twoFactor')}</h2>
{twoFAStatus?.enabled ? (
<div className="mt-3">
<p className="text-sm text-success mb-3">{t('profile.twoFactorEnabled')}</p>
<div className="flex items-center gap-2">
<input className="input input-bordered input-sm w-40" placeholder={t('auth.verificationCode')} value={disable2FACode} onChange={(e) => setDisable2FACode(e.target.value)} />
<button className="btn btn-error btn-sm" disabled={!disable2FACode || disable2FAMut.isPending} onClick={() => disable2FAMut.mutate(disable2FACode)}>
{t('profile.disable2FA')}
</button>
</div>
</div>
) : (
<div className="mt-3 space-y-3">
<p className="text-sm text-base-content/60">{t('profile.twoFactorDisabled')}</p>
{!setup2FAMut.data ? (
<button className="btn btn-outline btn-sm" onClick={() => setup2FAMut.mutate()}>
{t('profile.setup2FA')}
</button>
) : (
<div className="space-y-3">
<div className="bg-base-200 p-4 rounded-lg text-center">
<p className="text-sm mb-2">{t('profile.scanQRCode')}</p>
<code className="text-xs break-all">{setup2FAMut.data.url}</code>
</div>
<div className="flex items-center gap-2">
<input className="input input-bordered input-sm w-40" placeholder={t('auth.verificationCode')} value={twoFACode} onChange={(e) => setTwoFACode(e.target.value)} />
<button className="btn btn-primary btn-sm" disabled={!twoFACode || enable2FAMut.isPending} onClick={() => enable2FAMut.mutate(twoFACode)}>
{t('profile.enable2FA')}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Access Token */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base"><Key size={18} /> {t('profile.accessToken')}</h2>
<div className="mt-3">
<button className="btn btn-outline btn-sm" disabled={tokenMut.isPending} onClick={() => tokenMut.mutate()}>
{t('profile.generateToken')}
</button>
{accessToken && (
<div className="flex items-center gap-2 mt-3">
<code className="text-xs bg-base-200 px-2 py-1 rounded break-all">{accessToken}</code>
<button className="btn btn-ghost btn-xs" onClick={() => { copyToClipboard(accessToken); toast.success(t('common.copied')) }}>
<Copy size={12} />
</button>
</div>
)}
</div>
</div>
</div>
{/* Delete Account */}
<div className="card bg-base-100 shadow border border-error/30">
<div className="card-body">
<h2 className="card-title text-base text-error"><Trash2 size={18} /> {t('profile.deleteAccount')}</h2>
<p className="text-sm text-base-content/60 mt-1">{t('profile.deleteAccountWarning')}</p>
<div className="card-actions justify-end mt-3">
<button className="btn btn-error btn-sm btn-outline" onClick={() => setDeleteOpen(true)}>
{t('profile.deleteAccount')}
</button>
</div>
</div>
</div>
</div>
)}
{/* OAuth Tab */}
{activeTab === 'oauth' && (
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-base"><Link2 size={18} /> {t('profile.oauthBindings')}</h2>
<div className="space-y-3 mt-3">
{oauthProviders.map((p) => (
<div key={p.key} className="flex items-center justify-between py-2 border-b border-base-200 last:border-0">
<div className="flex items-center gap-3">
<Globe size={16} className="text-base-content/50" />
<span className="font-medium text-sm">{p.label}</span>
</div>
<span className={`text-sm ${p.field ? 'text-success' : 'text-base-content/40'}`}>
{p.field ? t('profile.linked') : t('profile.notLinked')}
</span>
</div>
))}
</div>
</div>
</div>
)}
<ConfirmDialog
open={deleteOpen}
title={t('profile.deleteAccount')}
message={t('profile.deleteAccountWarning')}
variant="error"
onConfirm={() => deleteMut.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Info, ExternalLink } from 'lucide-react'
import { getAbout, type AboutResponse } from '@/api/public'
import LoadingSpinner from '@/components/common/LoadingSpinner'
export default function About() {
const { t } = useTranslation()
const [about, setAbout] = useState<AboutResponse['data'] | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
getAbout()
.then((res) => {
if (res.success) {
setAbout(res.data)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const infoItems = about
? [
{ label: t('public.about.version'), value: about.version },
{ label: t('public.about.commitHash'), value: about.commit_hash },
{ label: t('public.about.buildTime'), value: about.build_time },
{ label: t('public.about.license'), value: about.license },
{ label: t('public.about.basedOn'), value: about.based_on },
]
: []
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8">
{t('public.about.title')}
</h1>
{loading ? (
<LoadingSpinner size="lg" text={t('common.loading')} />
) : about ? (
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<Info size={24} className="text-primary" />
</div>
<div>
<h2 className="text-xl font-bold">ModelsToken</h2>
<p className="text-sm text-base-content/60">
{t('public.about.version')}: {about.version}
</p>
</div>
</div>
<div className="divide-y divide-base-200">
{infoItems.map((item) => (
<div key={item.label} className="flex justify-between py-3">
<span className="text-base-content/60">{item.label}</span>
<span className="font-mono text-sm">{item.value || '-'}</span>
</div>
))}
</div>
{about.repository && (
<div className="mt-6">
<a
href={about.repository}
target="_blank"
rel="noopener noreferrer"
className="btn btn-outline btn-sm gap-2"
>
<ExternalLink size={14} />
{t('public.about.repository')}
</a>
</div>
)}
</div>
</div>
) : (
<div className="text-center text-base-content/50">
{t('common.noData')}
</div>
)}
</div>
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { Mail, ArrowLeft } from 'lucide-react'
import { sendResetEmail } from '@/api/auth'
interface ForgotForm {
email: string
}
export default function ForgotPassword() {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm<ForgotForm>()
const onSubmit = async (data: ForgotForm) => {
setLoading(true)
try {
const res = await sendResetEmail(data.email)
if (res.success) {
setSent(true)
toast.success(t('auth.resetEmailSent'))
} else {
toast.error(res.message || t('common.operationFailed'))
}
} catch {
toast.error(t('common.networkError'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title text-2xl justify-center mb-4">
{t('auth.resetPassword')}
</h2>
{sent ? (
<div className="text-center space-y-4">
<Mail size={48} className="mx-auto text-primary" />
<p className="text-base-content/70">{t('auth.resetEmailSentDesc')}</p>
<Link to="/login" className="btn btn-primary w-full">
{t('auth.signIn')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<p className="text-base-content/70 text-sm text-center">
{t('auth.forgotPasswordDesc')}
</p>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.email')}</span></label>
<input
type="email"
className="input input-bordered w-full"
{...register('email', {
required: t('auth.email'),
pattern: { value: /^\S+@\S+$/i, message: t('auth.invalidEmail') },
})}
/>
{errors.email && (
<label className="label"><span className="label-text-alt text-error">{errors.email.message}</span></label>
)}
</div>
<button type="submit" className={`btn btn-primary w-full ${loading ? 'btn-disabled' : ''}`}>
{loading ? <span className="loading loading-spinner loading-sm" /> : t('auth.sendResetEmail')}
</button>
</form>
)}
<div className="text-center mt-4">
<Link to="/login" className="link link-hover text-sm inline-flex items-center gap-1">
<ArrowLeft size={14} /> {t('auth.signIn')}
</Link>
</div>
</div>
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
Network, Key, BarChart3, Layers, CreditCard, BookOpen,
ArrowRight, Terminal,
} from 'lucide-react'
const features = [
{ icon: Network, key: 'apiGateway' },
{ icon: Key, key: 'keyManagement' },
{ icon: BarChart3, key: 'usageTracking' },
{ icon: Layers, key: 'multiChannel' },
{ icon: CreditCard, key: 'billing' },
{ icon: BookOpen, key: 'documentation' },
]
export default function Home() {
const { t } = useTranslation()
return (
<div className="min-h-screen bg-base-200">
{/* Hero */}
<div className="hero min-h-[70vh] bg-gradient-to-br from-primary/10 via-base-200 to-secondary/10">
<div className="hero-content text-center flex-col gap-6 py-20">
<div className="flex items-center gap-2 text-primary">
<Terminal size={40} />
</div>
<h1 className="text-5xl font-bold tracking-tight">
{t('common.appName')}
</h1>
<p className="text-xl text-base-content/70 max-w-2xl">
{t('public.heroSubtitle')}
</p>
<div className="flex gap-4 mt-4">
<Link to="/register" className="btn btn-primary btn-lg gap-2">
{t('public.getStarted')} <ArrowRight size={18} />
</Link>
<Link to="/pricing" className="btn btn-outline btn-lg">
{t('public.viewPricing')}
</Link>
</div>
</div>
</div>
{/* Features */}
<div className="container mx-auto px-4 py-20">
<h2 className="text-3xl font-bold text-center mb-12">
{t('public.featuresTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{features.map(({ icon: Icon, key }) => (
<div key={key} className="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
<div className="card-body items-center text-center">
<Icon size={32} className="text-primary mb-2" />
<h3 className="card-title text-lg">{t(`public.feature.${key}`)}</h3>
<p className="text-base-content/60 text-sm">{t(`public.feature.${key}Desc`)}</p>
</div>
</div>
))}
</div>
</div>
{/* Quick Start */}
<div className="bg-base-100 py-20">
<div className="container mx-auto px-4 max-w-3xl">
<h2 className="text-3xl font-bold text-center mb-8">
{t('public.quickStartTitle')}
</h2>
<div className="mockup-code bg-neutral text-neutral-content">
<pre><code>{`curl ${window.location.origin}/v1/chat/completions \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"model": "gpt-4",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'`}</code></pre>
</div>
</div>
</div>
{/* Footer */}
<footer className="footer footer-center p-8 bg-base-200 text-base-content/60">
<p>&copy; {new Date().getFullYear()} ModelsToken. {t('public.allRightsReserved')}</p>
</footer>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { Eye, EyeOff, Github, MessageCircle, Globe, Terminal, MessageSquare } from 'lucide-react'
import { useAuthStore } from '@/stores/auth'
import { getSystemStatus, type SystemStatus } from '@/api/auth'
interface LoginForm {
username: string
password: string
remember: boolean
}
export default function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
const { login, fetchUser } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState<SystemStatus | null>(null)
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>()
useEffect(() => {
getSystemStatus().then(setStatus).catch(() => {})
}, [])
const onSubmit = async (data: LoginForm) => {
setLoading(true)
try {
await login(data.username, data.password)
await fetchUser()
toast.success(t('auth.loginSuccess'))
navigate('/dashboard')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t('auth.invalidCredentials')
if (msg.includes('2FA') || msg.includes('two-factor')) {
navigate('/login/2fa', { state: { username: data.username } })
} else {
toast.error(msg)
}
} finally {
setLoading(false)
}
}
const oauthProviders = [
{ key: 'github_oauth_enabled', name: 'GitHub', icon: Github, href: '/api/oauth/github' },
{ key: 'discord_oauth_enabled', name: 'Discord', icon: MessageCircle, href: '/api/oauth/discord' },
{ key: 'oidc_enabled', name: 'OIDC', icon: Globe, href: '/api/oauth/oidc' },
{ key: 'linux_do_enabled', name: 'LinuxDO', icon: Terminal, href: '/api/oauth/linuxdo' },
{ key: 'wechat_enabled', name: 'WeChat', icon: MessageSquare, href: '/api/oauth/wechat' },
{ key: 'telegram_enabled', name: 'Telegram', icon: MessageCircle, href: '/api/oauth/telegram' },
]
const enabledProviders = oauthProviders.filter(
(p) => status?.data?.[p.key as keyof typeof status.data]
)
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title text-2xl justify-center mb-4">
{t('auth.loginTitle')}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">{t('auth.username')}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
{...register('username', { required: t('auth.username') })}
/>
{errors.username && (
<label className="label"><span className="label-text-alt text-error">{errors.username.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('auth.password')}</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="input input-bordered w-full pr-10"
{...register('password', { required: t('auth.password') })}
/>
<button
type="button"
className="btn btn-ghost btn-sm btn-circle absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{errors.password && (
<label className="label"><span className="label-text-alt text-error">{errors.password.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="checkbox checkbox-sm" {...register('remember')} />
<span className="label-text">{t('auth.rememberMe')}</span>
</label>
</div>
<button type="submit" className={`btn btn-primary w-full ${loading ? 'btn-disabled' : ''}`}>
{loading ? <span className="loading loading-spinner loading-sm" /> : t('auth.signIn')}
</button>
</form>
{enabledProviders.length > 0 && (
<>
<div className="divider text-sm">{t('auth.orContinueWith')}</div>
<div className="grid grid-cols-2 gap-2">
{enabledProviders.map((provider) => (
<a
key={provider.key}
href={provider.href}
className="btn btn-outline btn-sm gap-2"
>
<provider.icon size={16} />
{provider.name}
</a>
))}
</div>
</>
)}
<div className="flex justify-between text-sm mt-4">
<Link to="/forgot-password" className="link link-hover text-primary">
{t('auth.forgotPassword')}
</Link>
<Link to="/register" className="link link-hover text-primary">
{t('auth.noAccount')} {t('auth.signUp')}
</Link>
</div>
</div>
</div>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react'
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { CheckCircle, XCircle, Loader2 } from 'lucide-react'
import { get, post } from '@/api/client'
import type { ApiResponse } from '@/types/api'
import { useAuthStore } from '@/stores/auth'
const OAUTH_ENDPOINTS: Record<string, string> = {
github: '/oauth/github',
discord: '/oauth/discord',
oidc: '/oauth/oidc',
linuxdo: '/oauth/linuxdo',
wechat: '/oauth/wechat',
telegram: '/oauth/telegram',
}
export default function OAuthCallback() {
const { t } = useTranslation()
const { provider } = useParams<{ provider: string }>()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { fetchUser } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
if (!provider || !OAUTH_ENDPOINTS[provider]) {
setStatus('error')
setErrorMsg(t('public.oauth.invalidProvider'))
return
}
if (!code) {
setStatus('error')
setErrorMsg(t('public.oauth.error'))
return
}
const endpoint = OAUTH_ENDPOINTS[provider]
const params = new URLSearchParams({ code, state: state || '' })
post<ApiResponse>(`${endpoint}?${params.toString()}`)
.then(async (res) => {
if (res.success) {
setStatus('success')
toast.success(t('public.oauth.success'))
await fetchUser()
setTimeout(() => navigate('/dashboard', { replace: true }), 1000)
} else {
setStatus('error')
setErrorMsg(res.message || t('public.oauth.error'))
}
})
.catch(() => {
setStatus('error')
setErrorMsg(t('common.networkError'))
})
}, [provider, searchParams, navigate, fetchUser, t])
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body items-center text-center">
{status === 'loading' && (
<>
<Loader2 size={48} className="text-primary animate-spin" />
<h2 className="card-title text-xl mt-4">
{t('public.oauth.processing')}
</h2>
</>
)}
{status === 'success' && (
<>
<CheckCircle size={48} className="text-success" />
<h2 className="card-title text-xl mt-4">
{t('public.oauth.success')}
</h2>
</>
)}
{status === 'error' && (
<>
<XCircle size={48} className="text-error" />
<h2 className="card-title text-xl mt-4">
{t('public.oauth.error')}
</h2>
<p className="text-base-content/60">{errorMsg}</p>
<Link to="/login" className="btn btn-primary mt-4">
{t('public.oauth.backToLogin')}
</Link>
</>
)}
</div>
</div>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Search, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { getPricing, type PricingModel } from '@/api/public'
import { CHANNEL_TYPE_NAMES } from '@/lib/constants'
import PageHeader from '@/components/common/PageHeader'
import LoadingSpinner from '@/components/common/LoadingSpinner'
function formatPrice(price: number): string {
if (price <= 0) return '-'
return '$' + price.toFixed(6)
}
type SortKey = 'model_name' | 'model_owner' | 'input_price' | 'output_price' | 'group'
type SortOrder = 'asc' | 'desc'
export default function Pricing() {
const { t } = useTranslation()
const [models, setModels] = useState<PricingModel[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sortKey, setSortKey] = useState<SortKey>('model_name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
useEffect(() => {
setLoading(true)
getPricing()
.then((res) => {
if (res.success) {
setModels(res.data || [])
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const filtered = useMemo(() => {
if (!search) return models
const q = search.toLowerCase()
return models.filter(
(m) =>
m.model_name.toLowerCase().includes(q) ||
m.model_owner.toLowerCase().includes(q) ||
m.group.toLowerCase().includes(q)
)
}, [models, search])
const sorted = useMemo(() => {
const arr = [...filtered]
arr.sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
if (typeof av === 'number' && typeof bv === 'number') {
return sortOrder === 'asc' ? av - bv : bv - av
}
const sa = String(av).toLowerCase()
const sb = String(bv).toLowerCase()
return sortOrder === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa)
})
return arr
}, [filtered, sortKey, sortOrder])
const totalPages = Math.ceil(sorted.length / pageSize)
const paged = useMemo(() => {
const start = (page - 1) * pageSize
return sorted.slice(start, start + pageSize)
}, [sorted, page, pageSize])
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortOrder('asc')
}
setPage(1)
}
const SortIcon = ({ col }: { col: SortKey }) => {
if (sortKey !== col) return <ChevronsUpDown size={12} className="opacity-30" />
return sortOrder === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
}
const sortableHeaders: { key: SortKey; label: string }[] = [
{ key: 'model_name', label: t('public.pricing.modelName') },
{ key: 'model_owner', label: t('public.pricing.provider') },
{ key: 'input_price', label: t('public.pricing.inputPrice') },
{ key: 'output_price', label: t('public.pricing.outputPrice') },
{ key: 'group', label: t('public.pricing.group') },
]
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto px-4 py-8">
<PageHeader title={t('public.pricing.title')} />
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
<label className="input input-bordered input-sm flex items-center gap-2 flex-1 max-w-md w-full">
<Search size={14} className="opacity-50" />
<input
type="text"
className="grow"
placeholder={t('public.pricing.searchPlaceholder')}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</label>
<span className="text-sm text-base-content/60">
{t('common.total')} {filtered.length} {t('common.items')}
</span>
</div>
{loading ? (
<LoadingSpinner size="lg" text={t('common.loading')} />
) : filtered.length === 0 ? (
<div className="text-center py-12 text-base-content/50">
{t('public.pricing.noPricingData')}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
{sortableHeaders.map((h) => (
<th
key={h.key}
className={`cursor-pointer select-none hover:bg-base-200 ${h.key === 'input_price' || h.key === 'output_price' ? 'text-right' : ''}`}
onClick={() => handleSort(h.key)}
>
<span className="flex items-center gap-1">
{h.label}
<SortIcon col={h.key} />
</span>
</th>
))}
</tr>
</thead>
<tbody>
{paged.map((m) => (
<tr key={m.model_name}>
<td>
<span className="font-mono text-sm">{m.model_name}</span>
</td>
<td>
<span>{CHANNEL_TYPE_NAMES[m.channel_type] || m.model_owner}</span>
</td>
<td className="text-right">{formatPrice(m.input_price)}</td>
<td className="text-right">{formatPrice(m.output_price)}</td>
<td>
<span className="badge badge-ghost badge-sm">{m.group}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-2 py-3">
<div className="text-sm text-base-content/60">
{t('common.total')} {sorted.length} {t('common.items')}
</div>
<div className="flex items-center gap-2">
<select
className="select select-bordered select-sm"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
{size} {t('common.items')}/{t('common.page')}
</option>
))}
</select>
<div className="join">
<button
className="join-item btn btn-sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (page <= 3) {
pageNum = i + 1
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = page - 2 + i
}
return (
<button
key={pageNum}
className={`join-item btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
onClick={() => setPage(pageNum)}
>
{pageNum}
</button>
)
})}
<button
className="join-item btn btn-sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
</button>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getPrivacyPolicy } from '@/api/public'
import LoadingSpinner from '@/components/common/LoadingSpinner'
export default function PrivacyPolicy() {
const { t } = useTranslation()
const [content, setContent] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getPrivacyPolicy()
.then((res) => {
if (res.success) {
setContent(res.data || '')
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8">
{t('public.privacyPolicy.title')}
</h1>
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
{loading ? (
<LoadingSpinner size="lg" text={t('common.loading')} />
) : (
<div className="prose max-w-none dark:prose-invert">
<Markdown remarkPlugins={[remarkGfm]}>
{content}
</Markdown>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { Eye, EyeOff, Github, MessageCircle, Globe, Terminal, MessageSquare } from 'lucide-react'
import { useAuthStore } from '@/stores/auth'
import { getSystemStatus, type SystemStatus } from '@/api/auth'
interface RegisterForm {
username: string
email: string
password: string
confirmPassword: string
aff_code?: string
}
export default function Register() {
const { t } = useTranslation()
const navigate = useNavigate()
const { login, fetchUser } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState<SystemStatus | null>(null)
const { register, handleSubmit, watch, formState: { errors } } = useForm<RegisterForm>()
useEffect(() => {
getSystemStatus().then(setStatus).catch(() => {})
}, [])
const password = watch('password')
const onSubmit = async (data: RegisterForm) => {
if (data.password !== data.confirmPassword) {
toast.error(t('auth.passwordMismatch'))
return
}
setLoading(true)
try {
await useAuthStore.getState().register(data.username, data.password, data.email, data.aff_code)
toast.success(t('auth.registerSuccess'))
await login(data.username, data.password)
await fetchUser()
navigate('/dashboard')
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.operationFailed'))
} finally {
setLoading(false)
}
}
const oauthProviders = [
{ key: 'github_oauth_enabled', name: 'GitHub', icon: Github, href: '/api/oauth/github' },
{ key: 'discord_oauth_enabled', name: 'Discord', icon: MessageCircle, href: '/api/oauth/discord' },
{ key: 'oidc_enabled', name: 'OIDC', icon: Globe, href: '/api/oauth/oidc' },
{ key: 'linux_do_enabled', name: 'LinuxDO', icon: Terminal, href: '/api/oauth/linuxdo' },
{ key: 'wechat_enabled', name: 'WeChat', icon: MessageSquare, href: '/api/oauth/wechat' },
{ key: 'telegram_enabled', name: 'Telegram', icon: MessageCircle, href: '/api/oauth/telegram' },
]
const enabledProviders = oauthProviders.filter(
(p) => status?.data?.[p.key as keyof typeof status.data]
)
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title text-2xl justify-center mb-4">
{t('auth.registerTitle')}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.username')}</span></label>
<input
type="text"
className="input input-bordered w-full"
{...register('username', { required: t('auth.username') })}
/>
{errors.username && (
<label className="label"><span className="label-text-alt text-error">{errors.username.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.email')}</span></label>
<input
type="email"
className="input input-bordered w-full"
{...register('email', {
required: t('auth.email'),
pattern: { value: /^\S+@\S+$/i, message: t('auth.invalidEmail') },
})}
/>
{errors.email && (
<label className="label"><span className="label-text-alt text-error">{errors.email.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.password')}</span></label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="input input-bordered w-full pr-10"
{...register('password', { required: t('auth.password'), minLength: { value: 8, message: t('auth.passwordTooShort') } })}
/>
<button
type="button"
className="btn btn-ghost btn-sm btn-circle absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{errors.password && (
<label className="label"><span className="label-text-alt text-error">{errors.password.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.confirmPassword')}</span></label>
<input
type="password"
className="input input-bordered w-full"
{...register('confirmPassword', {
required: t('auth.confirmPassword'),
validate: (v) => v === password || t('auth.passwordMismatch'),
})}
/>
{errors.confirmPassword && (
<label className="label"><span className="label-text-alt text-error">{errors.confirmPassword.message}</span></label>
)}
</div>
{status?.data?.aff_enabled && (
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.affCode')}</span></label>
<input
type="text"
className="input input-bordered w-full"
{...register('aff_code')}
/>
</div>
)}
{status?.data?.turnstile_check_enabled && (
<div id="turnstile" className="flex justify-center" />
)}
<button type="submit" className={`btn btn-primary w-full ${loading ? 'btn-disabled' : ''}`}>
{loading ? <span className="loading loading-spinner loading-sm" /> : t('auth.signUp')}
</button>
</form>
{enabledProviders.length > 0 && (
<>
<div className="divider text-sm">{t('auth.orContinueWith')}</div>
<div className="grid grid-cols-2 gap-2">
{enabledProviders.map((provider) => (
<a
key={provider.key}
href={provider.href}
className="btn btn-outline btn-sm gap-2"
>
<provider.icon size={16} />
{provider.name}
</a>
))}
</div>
</>
)}
<div className="text-center text-sm mt-4">
{t('auth.hasAccount')}{' '}
<Link to="/login" className="link link-hover text-primary">{t('auth.signIn')}</Link>
</div>
</div>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { useSearchParams, useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { Eye, EyeOff, ArrowLeft } from 'lucide-react'
import { resetPassword } from '@/api/auth'
interface ResetForm {
password: string
confirmPassword: string
}
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const token = searchParams.get('token') || ''
const { register, handleSubmit, watch, formState: { errors } } = useForm<ResetForm>()
const password = watch('password')
const onSubmit = async (data: ResetForm) => {
if (data.password !== data.confirmPassword) {
toast.error(t('auth.passwordMismatch'))
return
}
if (!token) {
toast.error(t('auth.invalidResetToken'))
return
}
setLoading(true)
try {
const res = await resetPassword(token, data.password)
if (res.success) {
toast.success(t('auth.resetPasswordSuccess'))
navigate('/login')
} else {
toast.error(res.message || t('common.operationFailed'))
}
} catch {
toast.error(t('common.networkError'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title text-2xl justify-center mb-4">
{t('auth.resetPassword')}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.password')}</span></label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="input input-bordered w-full pr-10"
{...register('password', { required: t('auth.password'), minLength: { value: 8, message: t('auth.passwordTooShort') } })}
/>
<button
type="button"
className="btn btn-ghost btn-sm btn-circle absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{errors.password && (
<label className="label"><span className="label-text-alt text-error">{errors.password.message}</span></label>
)}
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('auth.confirmPassword')}</span></label>
<input
type="password"
className="input input-bordered w-full"
{...register('confirmPassword', {
required: t('auth.confirmPassword'),
validate: (v) => v === password || t('auth.passwordMismatch'),
})}
/>
{errors.confirmPassword && (
<label className="label"><span className="label-text-alt text-error">{errors.confirmPassword.message}</span></label>
)}
</div>
<button type="submit" className={`btn btn-primary w-full ${loading ? 'btn-disabled' : ''}`}>
{loading ? <span className="loading loading-spinner loading-sm" /> : t('auth.resetPassword')}
</button>
</form>
<div className="text-center mt-4">
<Link to="/login" className="link link-hover text-sm inline-flex items-center gap-1">
<ArrowLeft size={14} /> {t('auth.signIn')}
</Link>
</div>
</div>
</div>
</div>
)
}
+241
View File
@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import toast from 'react-hot-toast'
import { Eye, EyeOff, Settings, User, Server } from 'lucide-react'
import { setup, getSystemStatus } from '@/api/auth'
interface SetupForm {
username: string
password: string
confirmPassword: string
systemName: string
serverAddress: string
}
export default function Setup() {
const { t } = useTranslation()
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [checking, setChecking] = useState(true)
const { register, handleSubmit, watch, formState: { errors } } = useForm<SetupForm>()
const password = watch('password')
useEffect(() => {
getSystemStatus()
.then((status) => {
if (status.data?.setup) {
navigate('/login', { replace: true })
}
})
.catch(() => {})
.finally(() => setChecking(false))
}, [navigate])
const onSubmit = async (data: SetupForm) => {
if (data.password !== data.confirmPassword) {
toast.error(t('auth.passwordMismatch'))
return
}
setLoading(true)
try {
const res = await setup({
username: data.username,
password: data.password,
server_address: data.serverAddress,
})
if (res.success) {
toast.success(t('public.setup.setupComplete'))
navigate('/login')
} else {
toast.error(res.message || t('common.operationFailed'))
}
} catch {
toast.error(t('common.networkError'))
} finally {
setLoading(false)
}
}
if (checking) {
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center">
<span className="loading loading-spinner loading-lg" />
</div>
)
}
const steps = [
{ num: 1, icon: User, label: t('public.setup.adminAccount') },
{ num: 2, icon: Settings, label: t('public.setup.systemConfig') },
]
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="w-full max-w-lg">
<h1 className="text-3xl font-bold text-center mb-8">
{t('public.setup.title')}
</h1>
{/* Steps indicator */}
<ul className="steps steps-horizontal w-full mb-8">
{steps.map((s) => (
<li
key={s.num}
className={`step ${step >= s.num ? 'step-primary' : ''}`}
>
{s.label}
</li>
))}
</ul>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{step === 1 && (
<>
<div className="flex items-center gap-2 mb-4">
<User size={20} className="text-primary" />
<h2 className="text-lg font-semibold">
{t('public.setup.adminAccount')}
</h2>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('public.setup.adminUsername')}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
{...register('username', { required: t('public.setup.adminUsername') })}
/>
{errors.username && (
<label className="label">
<span className="label-text-alt text-error">{errors.username.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('public.setup.adminPassword')}</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="input input-bordered w-full pr-10"
{...register('password', {
required: t('public.setup.adminPassword'),
minLength: { value: 8, message: t('auth.passwordTooShort') },
})}
/>
<button
type="button"
className="btn btn-ghost btn-sm btn-circle absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{errors.password && (
<label className="label">
<span className="label-text-alt text-error">{errors.password.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('public.setup.confirmAdminPassword')}</span>
</label>
<input
type="password"
className="input input-bordered w-full"
{...register('confirmPassword', {
required: t('public.setup.confirmAdminPassword'),
validate: (v) => v === password || t('auth.passwordMismatch'),
})}
/>
{errors.confirmPassword && (
<label className="label">
<span className="label-text-alt text-error">{errors.confirmPassword.message}</span>
</label>
)}
</div>
<button
type="button"
className="btn btn-primary w-full"
onClick={() => setStep(2)}
>
{t('common.next')}
</button>
</>
)}
{step === 2 && (
<>
<div className="flex items-center gap-2 mb-4">
<Server size={20} className="text-primary" />
<h2 className="text-lg font-semibold">
{t('public.setup.systemConfig')}
</h2>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('public.setup.systemName')}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
placeholder="ModelsToken"
{...register('systemName')}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">{t('public.setup.serverAddress')}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
placeholder={window.location.origin}
{...register('serverAddress')}
/>
</div>
<div className="flex gap-3">
<button
type="button"
className="btn btn-outline flex-1"
onClick={() => setStep(1)}
>
{t('common.previous')}
</button>
<button
type="submit"
className={`btn btn-primary flex-1 ${loading ? 'btn-disabled' : ''}`}
>
{loading ? (
<span className="loading loading-spinner loading-sm" />
) : (
t('common.submit')
)}
</button>
</div>
</>
)}
</form>
</div>
</div>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getUserAgreement } from '@/api/public'
import LoadingSpinner from '@/components/common/LoadingSpinner'
export default function UserAgreement() {
const { t } = useTranslation()
const [content, setContent] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getUserAgreement()
.then((res) => {
if (res.success) {
setContent(res.data || '')
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8">
{t('public.userAgreement.title')}
</h1>
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
{loading ? (
<LoadingSpinner size="lg" text={t('common.loading')} />
) : (
<div className="prose max-w-none dark:prose-invert">
<Markdown remarkPlugins={[remarkGfm]}>
{content}
</Markdown>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface AuthForm {
PasswordLoginEnabled: string
PasswordRegisterEnabled: string
EmailVerificationEnabled: string
EmailDomainWhitelist: string
EmailAliasEnabled: string
GitHubClientId: string
GitHubClientSecret: string
DiscordClientId: string
DiscordClientSecret: string
OIDCWellKnown: string
OIDCClientId: string
OIDCClientSecret: string
LinuxDOClientId: string
LinuxDOClientSecret: string
LinuxDOMinTrustLevel: string
TelegramBotToken: string
WeChatAppId: string
WeChatAppSecret: string
TurnstileSiteKey: string
TurnstileSecretKey: string
PasskeyRPName: string
PasskeyRPID: string
PasskeyOrigins: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function AuthSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<AuthForm>({
values: options as unknown as AuthForm,
})
const onSubmit = (data: AuthForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.auth')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Password & Registration */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.passwordAndReg')}</legend>
<div className="space-y-3">
<ToggleField name="PasswordLoginEnabled" register={register} label={t('settings.passwordLogin')} />
<ToggleField name="PasswordRegisterEnabled" register={register} label={t('settings.passwordRegister')} />
<ToggleField name="EmailVerificationEnabled" register={register} label={t('settings.emailVerification')} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.emailDomainWhitelist')}</span></label>
<input {...register('EmailDomainWhitelist')} className="input input-bordered" placeholder="gmail.com,qq.com" />
</div>
<ToggleField name="EmailAliasEnabled" register={register} label={t('settings.emailAlias')} />
</div>
</fieldset>
{/* GitHub OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">GitHub OAuth</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Client ID</span></label>
<input {...register('GitHubClientId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Client Secret</span></label>
<input {...register('GitHubClientSecret')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Discord OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Discord OAuth</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Client ID</span></label>
<input {...register('DiscordClientId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Client Secret</span></label>
<input {...register('DiscordClientSecret')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* OIDC OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">OIDC OAuth</legend>
<div className="grid grid-cols-1 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Well-known URL</span></label>
<input {...register('OIDCWellKnown')} className="input input-bordered" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Client ID</span></label>
<input {...register('OIDCClientId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Client Secret</span></label>
<input {...register('OIDCClientSecret')} type="password" className="input input-bordered" />
</div>
</div>
</div>
</fieldset>
{/* LinuxDO OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">LinuxDO OAuth</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Client ID</span></label>
<input {...register('LinuxDOClientId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Client Secret</span></label>
<input {...register('LinuxDOClientSecret')} type="password" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.minTrustLevel')}</span></label>
<input {...register('LinuxDOMinTrustLevel')} type="number" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Telegram OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Telegram OAuth</legend>
<div className="form-control">
<label className="label"><span className="label-text">Bot Token</span></label>
<input {...register('TelegramBotToken')} type="password" className="input input-bordered" />
</div>
</fieldset>
{/* WeChat OAuth */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.wechatOAuth')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">App ID</span></label>
<input {...register('WeChatAppId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">App Secret</span></label>
<input {...register('WeChatAppSecret')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Turnstile */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Turnstile</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Site Key</span></label>
<input {...register('TurnstileSiteKey')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Secret Key</span></label>
<input {...register('TurnstileSecretKey')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Passkey */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Passkey</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">RP Name</span></label>
<input {...register('PasskeyRPName')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">RP ID</span></label>
<input {...register('PasskeyRPID')} className="input input-bordered" />
</div>
<div className="form-control md:col-span-2">
<label className="label"><span className="label-text">Origins</span></label>
<input {...register('PasskeyOrigins')} className="input input-bordered" placeholder="https://example.com,https://api.example.com" />
</div>
</div>
</fieldset>
</form>
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface BillingForm {
QuotaForNewUser: string
PreConsumedQuota: string
QuotaForInviter: string
QuotaForInvitee: string
TopUpLink: string
DocLink: string
FreeModelPreConsumedEnabled: string
QuotaUnit: string
QuotaExchangeRate: string
CurrencyDisplay: string
ModelRatio: string
GroupRatio: string
EpayAddress: string
EpayId: string
EpayKey: string
StripeWebhookSecret: string
StripeApiKey: string
CreemConfig: string
WaffoConfig: string
CheckInEnabled: string
CheckInMinQuota: string
CheckInMaxQuota: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function BillingSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<BillingForm>({
values: options as unknown as BillingForm,
})
const onSubmit = (data: BillingForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.billing')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Quota */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.quotaConfig')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.quotaForNewUser')}</span></label>
<input {...register('QuotaForNewUser')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.preConsumedQuota')}</span></label>
<input {...register('PreConsumedQuota')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.quotaForInviter')}</span></label>
<input {...register('QuotaForInviter')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.quotaForInvitee')}</span></label>
<input {...register('QuotaForInvitee')} type="number" className="input input-bordered" />
</div>
</div>
<div className="mt-4">
<ToggleField name="FreeModelPreConsumedEnabled" register={register} label={t('settings.freeModelPreConsumed')} />
</div>
</fieldset>
{/* Links */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.links')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.topUpLink')}</span></label>
<input {...register('TopUpLink')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.docLink')}</span></label>
<input {...register('DocLink')} className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Currency */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.currencyConfig')}</legend>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.quotaUnit')}</span></label>
<input {...register('QuotaUnit')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.quotaExchangeRate')}</span></label>
<input {...register('QuotaExchangeRate')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.currencyDisplay')}</span></label>
<input {...register('CurrencyDisplay')} className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Model Ratio */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.modelRatio')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.modelRatioDesc')}</span></label>
<textarea {...register('ModelRatio')} className="textarea textarea-bordered h-40 font-mono text-sm" />
</div>
</fieldset>
{/* Group Ratio */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.groupRatio')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.groupRatioDesc')}</span></label>
<textarea {...register('GroupRatio')} className="textarea textarea-bordered h-32 font-mono text-sm" />
</div>
</fieldset>
{/* Epay */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Epay</legend>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.epayAddress')}</span></label>
<input {...register('EpayAddress')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.epayId')}</span></label>
<input {...register('EpayId')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.epayKey')}</span></label>
<input {...register('EpayKey')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Stripe */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Stripe</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">Webhook Secret</span></label>
<input {...register('StripeWebhookSecret')} type="password" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">API Key</span></label>
<input {...register('StripeApiKey')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Creem & Waffo */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.otherPayment')}</legend>
<div className="space-y-4">
<div className="form-control">
<label className="label"><span className="label-text">Creem</span></label>
<textarea {...register('CreemConfig')} className="textarea textarea-bordered h-20 font-mono text-sm" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Waffo</span></label>
<textarea {...register('WaffoConfig')} className="textarea textarea-bordered h-20 font-mono text-sm" />
</div>
</div>
</fieldset>
{/* Check-in */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.checkIn')}</legend>
<ToggleField name="CheckInEnabled" register={register} label={t('settings.checkInEnabled')} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.checkInMinQuota')}</span></label>
<input {...register('CheckInMinQuota')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.checkInMaxQuota')}</span></label>
<input {...register('CheckInMaxQuota')} type="number" className="input input-bordered" />
</div>
</div>
</fieldset>
</form>
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface ContentForm {
ConsoleAPIInfoPanel: string
NoticeConfig: string
FAQConfig: string
UptimeKumaMonitorGroups: string
ChatEnabled: string
DrawingEnabled: string
MidjourneyConfig: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function ContentSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<ContentForm>({
values: options as unknown as ContentForm,
})
const onSubmit = (data: ContentForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.content')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Console */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.consoleConfig')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.consoleAPIInfoPanel')}</span></label>
<textarea {...register('ConsoleAPIInfoPanel')} className="textarea textarea-bordered h-24 font-mono text-sm" />
</div>
</fieldset>
{/* Notice & FAQ */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.noticeAndFAQ')}</legend>
<div className="space-y-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.noticeConfig')}</span></label>
<textarea {...register('NoticeConfig')} className="textarea textarea-bordered h-24" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.faqConfig')}</span></label>
<textarea {...register('FAQConfig')} className="textarea textarea-bordered h-32" />
</div>
</div>
</fieldset>
{/* Uptime Kuma */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.uptimeKuma')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.uptimeKumaMonitorGroups')}</span></label>
<textarea {...register('UptimeKumaMonitorGroups')} className="textarea textarea-bordered h-24 font-mono text-sm" />
</div>
</fieldset>
{/* Features */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.featureToggle')}</legend>
<div className="space-y-3">
<ToggleField name="ChatEnabled" register={register} label={t('settings.chatEnabled')} />
<ToggleField name="DrawingEnabled" register={register} label={t('settings.drawingEnabled')} />
</div>
</fieldset>
{/* Midjourney */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Midjourney</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.midjourneyConfig')}</span></label>
<textarea {...register('MidjourneyConfig')} className="textarea textarea-bordered h-32 font-mono text-sm" />
</div>
</fieldset>
</form>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface DocForm {
DocCategoryConfig: string
DocDefaultVisibility: string
DocPlaceholderContent: string
}
export default function DocSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<DocForm>({
values: options as unknown as DocForm,
})
const onSubmit = (data: DocForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.docs')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Category */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.docCategoryMgmt')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.docCategoryConfig')}</span></label>
<textarea {...register('DocCategoryConfig')} className="textarea textarea-bordered h-24 font-mono text-sm" />
</div>
</fieldset>
{/* Visibility */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.docVisibility')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.docDefaultVisibility')}</span></label>
<select {...register('DocDefaultVisibility')} className="select select-bordered">
<option value="public">{t('settings.docPublic')}</option>
<option value="private">{t('settings.docPrivate')}</option>
<option value="unlisted">{t('settings.docUnlisted')}</option>
</select>
</div>
</fieldset>
{/* Placeholder */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.docPlaceholder')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.docPlaceholderContent')}</span></label>
<textarea {...register('DocPlaceholderContent')} className="textarea textarea-bordered h-32" />
</div>
</fieldset>
</form>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface ModelForm {
GlobalPassthroughEnabled: string
ThinkingModelBlacklist: string
ChatToResponsesStrategy: string
PingInterval: string
GeminiSafety: string
GeminiVersion: string
GeminiImageModel: string
GeminiThinkingAdapter: string
GeminiFunctionCalling: string
ClaudeModelHeader: string
ClaudeDefaultMaxTokens: string
ClaudeThinkingAdapter: string
GrokViolationDeduction: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function ModelSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<ModelForm>({
values: options as unknown as ModelForm,
})
const onSubmit = (data: ModelForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.models')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Global */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.globalModelConfig')}</legend>
<div className="space-y-4">
<ToggleField name="GlobalPassthroughEnabled" register={register} label={t('settings.globalPassthrough')} />
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.thinkingModelBlacklist')}</span></label>
<textarea {...register('ThinkingModelBlacklist')} className="textarea textarea-bordered h-20" placeholder="model1,model2" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.chatToResponsesStrategy')}</span></label>
<select {...register('ChatToResponsesStrategy')} className="select select-bordered">
<option value="">-</option>
<option value="auto">Auto</option>
<option value="always">Always</option>
<option value="never">Never</option>
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.pingInterval')}</span></label>
<input {...register('PingInterval')} type="number" className="input input-bordered" />
</div>
</div>
</div>
</fieldset>
{/* Gemini */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Gemini</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.geminiSafety')}</span></label>
<select {...register('GeminiSafety')} className="select select-bordered">
<option value="">Default</option>
<option value="BLOCK_NONE">BLOCK_NONE</option>
<option value="BLOCK_ONLY_HIGH">BLOCK_ONLY_HIGH</option>
<option value="BLOCK_MEDIUM_AND_ABOVE">BLOCK_MEDIUM_AND_ABOVE</option>
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.geminiVersion')}</span></label>
<input {...register('GeminiVersion')} className="input input-bordered" placeholder="v1" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.geminiImageModel')}</span></label>
<input {...register('GeminiImageModel')} className="input input-bordered" />
</div>
<ToggleField name="GeminiThinkingAdapter" register={register} label={t('settings.geminiThinkingAdapter')} />
<ToggleField name="GeminiFunctionCalling" register={register} label={t('settings.geminiFunctionCalling')} />
</div>
</fieldset>
{/* Claude */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Claude</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.claudeModelHeader')}</span></label>
<input {...register('ClaudeModelHeader')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.claudeDefaultMaxTokens')}</span></label>
<input {...register('ClaudeDefaultMaxTokens')} type="number" className="input input-bordered" />
</div>
<ToggleField name="ClaudeThinkingAdapter" register={register} label={t('settings.claudeThinkingAdapter')} />
</div>
</fieldset>
{/* Grok */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">Grok</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.grokViolationDeduction')}</span></label>
<input {...register('GrokViolationDeduction')} type="number" className="input input-bordered" />
</div>
</fieldset>
</form>
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface OperationsForm {
RetryCount: string
DefaultCollapseSidebar: string
DemoModeEnabled: string
SelfUseModeEnabled: string
ChannelAutoDisableEnabled: string
ChannelAutoEnableEnabled: string
ChannelDisableThreshold: string
ChannelDisableKeywords: string
ChannelDisableStatusCodes: string
AutoRetryStatusCodes: string
AutoTestChannels: string
SMTPServer: string
SMTPPort: string
SMTPAccount: string
SMTPToken: string
SMTPFrom: string
WorkerURL: string
WorkerKey: string
LogConsumeEnabled: string
DiskCacheConfig: string
PerformanceMonitorConfig: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function OperationsSettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<OperationsForm>({
values: options as unknown as OperationsForm,
})
const onSubmit = (data: OperationsForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.operations')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* General */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.opsGeneral')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.retryCount')}</span></label>
<input {...register('RetryCount')} type="number" className="input input-bordered" />
</div>
</div>
<div className="space-y-3 mt-4">
<ToggleField name="DefaultCollapseSidebar" register={register} label={t('settings.defaultCollapseSidebar')} />
<ToggleField name="DemoModeEnabled" register={register} label={t('settings.demoMode')} />
<ToggleField name="SelfUseModeEnabled" register={register} label={t('settings.selfUseMode')} />
</div>
</fieldset>
{/* Channel Auto Management */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.channelAutoMgmt')}</legend>
<div className="space-y-3">
<ToggleField name="ChannelAutoDisableEnabled" register={register} label={t('settings.channelAutoDisable')} />
<ToggleField name="ChannelAutoEnableEnabled" register={register} label={t('settings.channelAutoEnable')} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.channelDisableThreshold')}</span></label>
<input {...register('ChannelDisableThreshold')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.channelDisableKeywords')}</span></label>
<input {...register('ChannelDisableKeywords')} className="input input-bordered" placeholder="keyword1,keyword2" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.channelDisableStatusCodes')}</span></label>
<input {...register('ChannelDisableStatusCodes')} className="input input-bordered" placeholder="400,429,500" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.autoRetryStatusCodes')}</span></label>
<input {...register('AutoRetryStatusCodes')} className="input input-bordered" placeholder="429,500,502" />
</div>
</div>
<div className="mt-4">
<ToggleField name="AutoTestChannels" register={register} label={t('settings.autoTestChannels')} />
</div>
</fieldset>
{/* SMTP */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.smtpConfig')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.smtpServer')}</span></label>
<input {...register('SMTPServer')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.smtpPort')}</span></label>
<input {...register('SMTPPort')} type="number" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.smtpAccount')}</span></label>
<input {...register('SMTPAccount')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.smtpToken')}</span></label>
<input {...register('SMTPToken')} type="password" className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.smtpFrom')}</span></label>
<input {...register('SMTPFrom')} className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Worker */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.workerConfig')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.workerURL')}</span></label>
<input {...register('WorkerURL')} className="input input-bordered" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.workerKey')}</span></label>
<input {...register('WorkerKey')} type="password" className="input input-bordered" />
</div>
</div>
</fieldset>
{/* Logging & Cache */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.loggingAndCache')}</legend>
<ToggleField name="LogConsumeEnabled" register={register} label={t('settings.logConsume')} />
<div className="space-y-4 mt-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.diskCacheConfig')}</span></label>
<textarea {...register('DiskCacheConfig')} className="textarea textarea-bordered h-20 font-mono text-sm" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.performanceMonitorConfig')}</span></label>
<textarea {...register('PerformanceMonitorConfig')} className="textarea textarea-bordered h-20 font-mono text-sm" />
</div>
</div>
</fieldset>
</form>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Save, Loader2 } from 'lucide-react'
import { useSettings } from '@/hooks/useSettings'
import PageHeader from '@/components/common/PageHeader'
interface SecurityForm {
ModelRequestRateLimit: string
SensitiveWordCheckEnabled: string
SensitiveWordCheckOnPrompt: string
SensitiveWordCheckOnOutput: string
SSRFProtectionEnabled: string
PrivateIPFilterEnabled: string
DomainFilterMode: string
DomainFilterList: string
IPFilterMode: string
IPFilterList: string
PortWhitelist: string
}
function ToggleField({ name, register, label }: { name: string; register: any; label: string }) {
return (
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" value="true"
{...register(name, { setValueAs: (v: any) => v ? 'true' : 'false' })} />
<span className="label-text">{label}</span>
</label>
</div>
)
}
export default function SecuritySettings() {
const { t } = useTranslation()
const { options, isLoading, save, isSaving } = useSettings()
const { register, handleSubmit } = useForm<SecurityForm>({
values: options as unknown as SecurityForm,
})
const onSubmit = (data: SecurityForm) => save(data as unknown as Record<string, string>)
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
)
}
return (
<div>
<PageHeader
title={t('settings.security')}
actions={
<button className="btn btn-primary btn-sm" disabled={isSaving} onClick={handleSubmit(onSubmit)}>
{isSaving ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
{t('common.save')}
</button>
}
/>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Rate Limit */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.rateLimit')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.modelRequestRateLimit')}</span></label>
<input {...register('ModelRequestRateLimit')} type="number" className="input input-bordered" />
</div>
</fieldset>
{/* Sensitive Words */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.sensitiveWordCheck')}</legend>
<div className="space-y-3">
<ToggleField name="SensitiveWordCheckEnabled" register={register} label={t('settings.sensitiveWordCheckEnabled')} />
<ToggleField name="SensitiveWordCheckOnPrompt" register={register} label={t('settings.sensitiveWordCheckOnPrompt')} />
<ToggleField name="SensitiveWordCheckOnOutput" register={register} label={t('settings.sensitiveWordCheckOnOutput')} />
</div>
</fieldset>
{/* Network Protection */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.networkProtection')}</legend>
<div className="space-y-3">
<ToggleField name="SSRFProtectionEnabled" register={register} label={t('settings.ssrfProtection')} />
<ToggleField name="PrivateIPFilterEnabled" register={register} label={t('settings.privateIPFilter')} />
</div>
</fieldset>
{/* Domain/IP Filter */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.domainIPFilter')}</legend>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.domainFilterMode')}</span></label>
<select {...register('DomainFilterMode')} className="select select-bordered">
<option value="">-</option>
<option value="whitelist">{t('settings.whitelist')}</option>
<option value="blacklist">{t('settings.blacklist')}</option>
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.ipFilterMode')}</span></label>
<select {...register('IPFilterMode')} className="select select-bordered">
<option value="">-</option>
<option value="whitelist">{t('settings.whitelist')}</option>
<option value="blacklist">{t('settings.blacklist')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.domainFilterList')}</span></label>
<textarea {...register('DomainFilterList')} className="textarea textarea-bordered h-24 font-mono text-sm" placeholder="example.com\napi.example.com" />
</div>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.ipFilterList')}</span></label>
<textarea {...register('IPFilterList')} className="textarea textarea-bordered h-24 font-mono text-sm" placeholder="1.2.3.4\n10.0.0.0/8" />
</div>
</div>
</fieldset>
{/* Port Whitelist */}
<fieldset className="fieldset bg-base-100 border border-base-300 rounded-box p-4">
<legend className="fieldset-legend">{t('settings.portWhitelist')}</legend>
<div className="form-control">
<label className="label"><span className="label-text">{t('settings.portWhitelistDesc')}</span></label>
<input {...register('PortWhitelist')} className="input input-bordered" placeholder="80,443,8080" />
</div>
</fieldset>
</form>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
Globe, ShieldCheck, CreditCard, FileText, Cpu,
Settings2, Lock, BookOpen,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const categories = [
{ key: 'site', path: '/settings/site', icon: Globe },
{ key: 'auth', path: '/settings/auth', icon: ShieldCheck },
{ key: 'billing', path: '/settings/billing', icon: CreditCard },
{ key: 'content', path: '/settings/content', icon: FileText },
{ key: 'models', path: '/settings/models', icon: Cpu },
{ key: 'operations', path: '/settings/operations', icon: Settings2 },
{ key: 'security', path: '/settings/security', icon: Lock },
{ key: 'docs', path: '/settings/docs', icon: BookOpen },
]
export default function SettingsLayout() {
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
return (
<div className="flex gap-6">
{/* Sidebar */}
<aside className="w-56 shrink-0 hidden lg:block">
<ul className="menu bg-base-100 rounded-box shadow-sm p-2 gap-1 sticky top-4">
{categories.map(({ key, path, icon: Icon }) => (
<li key={key}>
<button
className={cn(
'flex items-center gap-3',
location.pathname === path && 'active'
)}
onClick={() => navigate(path)}
>
<Icon size={16} />
{t(`settings.${key}`)}
</button>
</li>
))}
</ul>
</aside>
{/* Mobile tabs */}
<div className="lg:hidden w-full">
<div className="tabs tabs-boxed bg-base-100 shadow-sm mb-4 flex-wrap gap-1">
{categories.map(({ key, path }) => (
<button
key={key}
className={cn('tab', location.pathname === path && 'tab-active')}
onClick={() => navigate(path)}
>
{t(`settings.${key}`)}
</button>
))}
</div>
<Outlet />
</div>
{/* Content */}
<div className="flex-1 min-w-0 hidden lg:block">
<Outlet />
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More