feat: add DaisyUI frontend theme and document management system
Docker Build / Build and Push Docker Image (push) Failing after 1m35s
Docker Build / Build and Push Docker Image (push) Failing after 1m35s
This commit is contained in:
@@ -10,6 +10,7 @@ build
|
||||
logs
|
||||
web/default/dist
|
||||
web/classic/dist
|
||||
web/daisy/dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
|
||||
@@ -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 | 搜索 | 通知 | 用户菜单 | 主题切换 │
|
||||
├──────┬───────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧边 │ 主内容区 │
|
||||
│ 导航 │ │
|
||||
│ 栏 │ ┌─────────────────────────────────┐ │
|
||||
│ │ │ 面包屑 + 页面标题 + 操作按钮 │ │
|
||||
│ 仪表盘│ ├─────────────────────────────────┤ │
|
||||
│ 密钥 │ │ │ │
|
||||
│ 渠道 │ │ 页面内容 │ │
|
||||
│ 用户 │ │ │ │
|
||||
│ 日志 │ │ │ │
|
||||
│ 钱包 │ └─────────────────────────────────┘ │
|
||||
│ 订阅 │ │
|
||||
│ 文档 │ │
|
||||
│ 设置 │ │
|
||||
│ │ │
|
||||
└──────┴───────────────────────────────────────┘
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(经典前端)、daisy(DaisyUI 前端)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+24
@@ -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?
|
||||
Vendored
+57
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
Vendored
+28
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
Vendored
+13
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -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 |
Vendored
+112
@@ -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
@@ -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)
|
||||
}
|
||||
Vendored
+35
@@ -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 })
|
||||
}
|
||||
Vendored
+74
@@ -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)
|
||||
}
|
||||
Vendored
+51
@@ -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 })
|
||||
}
|
||||
Vendored
+60
@@ -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
|
||||
Vendored
+44
@@ -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`)
|
||||
}
|
||||
Vendored
+43
@@ -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`)
|
||||
}
|
||||
Vendored
+47
@@ -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)
|
||||
}
|
||||
Vendored
+45
@@ -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')
|
||||
}
|
||||
Vendored
+10
@@ -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)
|
||||
}
|
||||
Vendored
+52
@@ -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')
|
||||
}
|
||||
Vendored
+41
@@ -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')
|
||||
}
|
||||
Vendored
+34
@@ -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 })
|
||||
}
|
||||
Vendored
+35
@@ -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`)
|
||||
}
|
||||
Vendored
+38
@@ -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')
|
||||
}
|
||||
Vendored
+33
@@ -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}`)
|
||||
}
|
||||
Vendored
+52
@@ -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 })
|
||||
}
|
||||
Vendored
+1
@@ -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 |
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+19
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+60
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -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
|
||||
Vendored
+625
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+625
@@ -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": "Daisy(DaisyUI 前端)"
|
||||
},
|
||||
"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": "完成时间"
|
||||
}
|
||||
}
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
Vendored
+178
@@ -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',
|
||||
}
|
||||
Vendored
+96
@@ -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
|
||||
Vendored
+24
@@ -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)
|
||||
}
|
||||
Vendored
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -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>,
|
||||
)
|
||||
Vendored
+21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+91
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+89
@@ -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>© {new Date().getFullYear()} ModelsToken. {t('public.allRightsReserved')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+150
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+241
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user