Spaces:
Sleeping
Sleeping
all
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +12 -0
- .env +0 -9
- .env.example +21 -0
- .eslintrc.json +3 -0
- .gitattributes +0 -35
- .gitignore +42 -0
- Dockerfile +39 -1
- LICENSE +21 -0
- app/api/config/key/route.ts +15 -0
- app/api/config/route.ts +23 -0
- app/api/users/[id]/balance/route.ts +42 -0
- app/api/users/[id]/route.ts +48 -0
- app/api/users/route.ts +67 -0
- app/api/v1/inlet/route.ts +66 -0
- app/api/v1/models/price/route.ts +114 -0
- app/api/v1/models/route.ts +146 -0
- app/api/v1/models/sync-all-prices/route.ts +112 -0
- app/api/v1/models/sync-price/route.ts +117 -0
- app/api/v1/models/test/route.ts +82 -0
- app/api/v1/outlet/route.ts +216 -0
- app/api/v1/panel/database/export/route.ts +56 -0
- app/api/v1/panel/database/import/route.ts +97 -0
- app/api/v1/panel/records/export/route.ts +70 -0
- app/api/v1/panel/records/route.ts +89 -0
- app/api/v1/panel/usage/route.ts +90 -0
- app/apple-icon.png +0 -0
- app/fonts/GeistMonoVF.woff +0 -0
- app/fonts/GeistVF.woff +0 -0
- app/globals.css +278 -0
- app/icon.png +0 -0
- app/layout.tsx +44 -0
- app/models/page.tsx +1051 -0
- app/page.tsx +287 -0
- app/panel/page.tsx +528 -0
- app/records/page.tsx +332 -0
- app/token/page.tsx +247 -0
- app/users/page.tsx +980 -0
- components.json +21 -0
- components/AuthCheck.tsx +58 -0
- components/DatabaseBackup.tsx +228 -0
- components/Header.tsx +543 -0
- components/I18nProvider.tsx +31 -0
- components/editable-cell.tsx +211 -0
- components/models/TestProgress.tsx +226 -0
- components/panel/ModelDistributionChart.tsx +314 -0
- components/panel/TimeRangeSelector.tsx +313 -0
- components/panel/UsageRecordsTable.tsx +268 -0
- components/panel/UserRankingChart.tsx +301 -0
- components/ui/accordion.tsx +57 -0
- components/ui/alert-dialog.tsx +141 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.env*
|
| 5 |
+
npm-debug.log*
|
| 6 |
+
pnpm-debug.log*
|
| 7 |
+
.pnpm-store
|
| 8 |
+
README.md
|
| 9 |
+
.gitignore
|
| 10 |
+
.dockerignore
|
| 11 |
+
Dockerfile
|
| 12 |
+
docker-compose.yml
|
.env
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
psql -h db.ttzfpnlvvfthtatadsrr.supabase.co -p 5432 -d postgres -U postgres
|
| 2 |
-
|
| 3 |
-
# POSTGRES_HOST=
|
| 4 |
-
# POSTGRES_PORT=
|
| 5 |
-
# POSTGRES_USER=
|
| 6 |
-
# POSTGRES_PASSWORD= udh4Lo8wXKzriqzC
|
| 7 |
-
# POSTGRES_DATABASE=
|
| 8 |
-
|
| 9 |
-
NZRn4OW6azoR1YOewock6BZfXY7rRgq6F4SngcM9RicjPJ3xaX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.env.example
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenWebUI Configuration
|
| 2 |
+
OPENWEBUI_DOMAIN=your_openwebui_domain # OpenWebUI domain, e.g. https://chat.example.com
|
| 3 |
+
OPENWEBUI_API_KEY=your_api_key # OpenWebUI API key for fetching model list
|
| 4 |
+
|
| 5 |
+
# Access Control
|
| 6 |
+
ACCESS_TOKEN=your-access-token-here # Used for Monitor page login
|
| 7 |
+
API_KEY=your-api-key-here # Used for authentication when sending requests to Monitor
|
| 8 |
+
|
| 9 |
+
# Price Configuration (Optional, $/million tokens)
|
| 10 |
+
# DEFAULT_MODEL_INPUT_PRICE=60 # Default input price for models
|
| 11 |
+
# DEFAULT_MODEL_OUTPUT_PRICE=60 # Default output price for models
|
| 12 |
+
# DEFAULT_MODEL_PER_MSG_PRICE=-1 # Default price per message for models, -1 means charging by tokens
|
| 13 |
+
# INIT_BALANCE=0 # Initial balance for users, optional
|
| 14 |
+
# COST_ON_INLET=0 # Pre-deduction amount on inlet, can be a fixed number (e.g. 0.1) or model-specific (e.g. gpt-4:0.32,gpt-3.5:0.01)
|
| 15 |
+
|
| 16 |
+
# PostgreSQL Database Configuration (Optional, configure these if using external database)
|
| 17 |
+
# POSTGRES_HOST=
|
| 18 |
+
# POSTGRES_PORT=
|
| 19 |
+
# POSTGRES_USER=
|
| 20 |
+
# POSTGRES_PASSWORD=
|
| 21 |
+
# POSTGRES_DATABASE=
|
.eslintrc.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": ["next/core-web-vitals", "next/typescript"]
|
| 3 |
+
}
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
|
| 32 |
+
# env files (can opt-in for committing if needed)
|
| 33 |
+
.env*
|
| 34 |
+
!.env.example
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
| 42 |
+
.env*.local
|
Dockerfile
CHANGED
|
@@ -1 +1,39 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用 Node.js 官方镜像作为基础镜像
|
| 2 |
+
FROM node:18-alpine
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 安装必要的系统依赖
|
| 8 |
+
RUN apk add --no-cache \
|
| 9 |
+
python3 \
|
| 10 |
+
make \
|
| 11 |
+
g++ \
|
| 12 |
+
gcc \
|
| 13 |
+
libc-dev \
|
| 14 |
+
netcat-openbsd \
|
| 15 |
+
postgresql-client
|
| 16 |
+
|
| 17 |
+
# 全局安装 pnpm
|
| 18 |
+
RUN npm install -g pnpm
|
| 19 |
+
|
| 20 |
+
# 复制 package.json 和 pnpm-lock.yaml
|
| 21 |
+
COPY package.json pnpm-lock.yaml ./
|
| 22 |
+
|
| 23 |
+
# 安装依赖
|
| 24 |
+
RUN pnpm install --no-frozen-lockfile
|
| 25 |
+
|
| 26 |
+
# 复制项目文件
|
| 27 |
+
COPY . .
|
| 28 |
+
|
| 29 |
+
# 添加执行权限到启动脚本
|
| 30 |
+
RUN chmod +x start.sh
|
| 31 |
+
|
| 32 |
+
# 构建应用
|
| 33 |
+
RUN pnpm build
|
| 34 |
+
|
| 35 |
+
# 暴露端口
|
| 36 |
+
EXPOSE 3000
|
| 37 |
+
|
| 38 |
+
# 使用启动脚本
|
| 39 |
+
CMD ["./start.sh"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 VariantConst
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
app/api/config/key/route.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { cookies } from "next/headers";
|
| 3 |
+
|
| 4 |
+
export async function GET() {
|
| 5 |
+
const apiKey = process.env.API_KEY;
|
| 6 |
+
|
| 7 |
+
if (!apiKey) {
|
| 8 |
+
return NextResponse.json(
|
| 9 |
+
{ error: "API Key Not Configured" },
|
| 10 |
+
{ status: 500 }
|
| 11 |
+
);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
return NextResponse.json({ apiKey });
|
| 15 |
+
}
|
app/api/config/route.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { headers } from "next/headers";
|
| 3 |
+
|
| 4 |
+
export async function GET() {
|
| 5 |
+
const headersList = headers();
|
| 6 |
+
const token = headersList.get("authorization")?.split(" ")[1];
|
| 7 |
+
const expectedToken = process.env.ACCESS_TOKEN;
|
| 8 |
+
|
| 9 |
+
if (!token || token !== expectedToken) {
|
| 10 |
+
return NextResponse.json(
|
| 11 |
+
{
|
| 12 |
+
apiKey: "Unauthorized",
|
| 13 |
+
status: 401,
|
| 14 |
+
},
|
| 15 |
+
{ status: 401 }
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return NextResponse.json({
|
| 20 |
+
apiKey: process.env.API_KEY || "Unconfigured",
|
| 21 |
+
status: 200,
|
| 22 |
+
});
|
| 23 |
+
}
|
app/api/users/[id]/balance/route.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { query } from "@/lib/db/client";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
|
| 4 |
+
export async function PUT(
|
| 5 |
+
req: Request,
|
| 6 |
+
{ params }: { params: { id: string } }
|
| 7 |
+
) {
|
| 8 |
+
try {
|
| 9 |
+
const { balance } = await req.json();
|
| 10 |
+
const userId = params.id;
|
| 11 |
+
|
| 12 |
+
if (typeof balance !== "number") {
|
| 13 |
+
return NextResponse.json(
|
| 14 |
+
{ error: "Balance must be positive" },
|
| 15 |
+
{ status: 400 }
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const result = await query(
|
| 20 |
+
`UPDATE users
|
| 21 |
+
SET balance = $1
|
| 22 |
+
WHERE id = $2
|
| 23 |
+
RETURNING id, email, balance`,
|
| 24 |
+
[balance, userId]
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
if (result.rows.length === 0) {
|
| 28 |
+
return NextResponse.json(
|
| 29 |
+
{ error: "User does not exist" },
|
| 30 |
+
{ status: 404 }
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return NextResponse.json(result.rows[0]);
|
| 35 |
+
} catch (error) {
|
| 36 |
+
console.error("Fail to update user balance:", error);
|
| 37 |
+
return NextResponse.json(
|
| 38 |
+
{ error: "Fail to update user balance" },
|
| 39 |
+
{ status: 500 }
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
}
|
app/api/users/[id]/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { deleteUser } from "@/lib/db/users";
|
| 3 |
+
import { query } from "@/lib/db/client";
|
| 4 |
+
|
| 5 |
+
export async function DELETE(
|
| 6 |
+
req: NextRequest,
|
| 7 |
+
{ params }: { params: { id: string } }
|
| 8 |
+
) {
|
| 9 |
+
try {
|
| 10 |
+
await deleteUser(params.id);
|
| 11 |
+
return NextResponse.json({ success: true });
|
| 12 |
+
} catch (error) {
|
| 13 |
+
console.error("Fail to delete user:", error);
|
| 14 |
+
return NextResponse.json({ error: "Fail to delete user" }, { status: 500 });
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function PATCH(
|
| 19 |
+
req: NextRequest,
|
| 20 |
+
{ params }: { params: { id: string } }
|
| 21 |
+
) {
|
| 22 |
+
try {
|
| 23 |
+
const { deleted } = await req.json();
|
| 24 |
+
|
| 25 |
+
const result = await query(
|
| 26 |
+
`UPDATE users
|
| 27 |
+
SET deleted = $1
|
| 28 |
+
WHERE id = $2
|
| 29 |
+
RETURNING *`,
|
| 30 |
+
[deleted, params.id]
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
if (result.rowCount === 0) {
|
| 34 |
+
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return NextResponse.json({
|
| 38 |
+
success: true,
|
| 39 |
+
user: result.rows[0],
|
| 40 |
+
});
|
| 41 |
+
} catch (error) {
|
| 42 |
+
console.error("Failed to update user:", error);
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: "Failed to update user" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
}
|
app/api/users/route.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { query } from "@/lib/db/client";
|
| 3 |
+
import { ensureUserTableExists } from "@/lib/db/users";
|
| 4 |
+
|
| 5 |
+
export async function GET(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
// 确保表结构正确
|
| 8 |
+
await ensureUserTableExists();
|
| 9 |
+
|
| 10 |
+
const { searchParams } = new URL(req.url);
|
| 11 |
+
const page = parseInt(searchParams.get("page") || "1");
|
| 12 |
+
const pageSize = parseInt(searchParams.get("pageSize") || "20");
|
| 13 |
+
const sortField = searchParams.get("sortField");
|
| 14 |
+
const sortOrder = searchParams.get("sortOrder");
|
| 15 |
+
const search = searchParams.get("search");
|
| 16 |
+
const deleted = searchParams.get("deleted") === "true";
|
| 17 |
+
|
| 18 |
+
// 构建查询条件
|
| 19 |
+
const conditions = [`deleted = ${deleted}`];
|
| 20 |
+
const params = [];
|
| 21 |
+
let paramIndex = 1;
|
| 22 |
+
|
| 23 |
+
if (search) {
|
| 24 |
+
conditions.push(
|
| 25 |
+
`(LOWER(name) LIKE $${paramIndex} OR LOWER(email) LIKE $${paramIndex})`
|
| 26 |
+
);
|
| 27 |
+
params.push(`%${search.toLowerCase()}%`);
|
| 28 |
+
paramIndex++;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const whereClause = `WHERE ${conditions.join(" AND ")}`;
|
| 32 |
+
|
| 33 |
+
// 获取总记录数
|
| 34 |
+
const countResult = await query(
|
| 35 |
+
`SELECT COUNT(*) FROM users ${whereClause}`,
|
| 36 |
+
params
|
| 37 |
+
);
|
| 38 |
+
const total = parseInt(countResult.rows[0].count);
|
| 39 |
+
|
| 40 |
+
// 获取分页数据
|
| 41 |
+
const result = await query(
|
| 42 |
+
`SELECT id, email, name, role, balance, deleted, created_at
|
| 43 |
+
FROM users
|
| 44 |
+
${whereClause}
|
| 45 |
+
${
|
| 46 |
+
sortField
|
| 47 |
+
? `ORDER BY ${sortField} ${sortOrder === "descend" ? "DESC" : "ASC"}`
|
| 48 |
+
: "ORDER BY created_at DESC"
|
| 49 |
+
}
|
| 50 |
+
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
| 51 |
+
[...params, pageSize, (page - 1) * pageSize]
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({
|
| 55 |
+
users: result.rows,
|
| 56 |
+
total,
|
| 57 |
+
page,
|
| 58 |
+
pageSize,
|
| 59 |
+
});
|
| 60 |
+
} catch (error) {
|
| 61 |
+
console.error("Failed to fetch users:", error);
|
| 62 |
+
return NextResponse.json(
|
| 63 |
+
{ error: "Failed to fetch users" },
|
| 64 |
+
{ status: 500 }
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
}
|
app/api/v1/inlet/route.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { getOrCreateUser } from "@/lib/db/users";
|
| 3 |
+
import { query } from "@/lib/db/client";
|
| 4 |
+
import { getModelInletCost } from "@/lib/utils/inlet-cost";
|
| 5 |
+
|
| 6 |
+
export async function POST(req: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const data = await req.json();
|
| 9 |
+
const user = await getOrCreateUser(data.user);
|
| 10 |
+
const modelId = data.body?.model;
|
| 11 |
+
|
| 12 |
+
// 如果用户被拉黑,返回余额为 -1
|
| 13 |
+
if (user.deleted) {
|
| 14 |
+
return NextResponse.json({
|
| 15 |
+
success: true,
|
| 16 |
+
balance: -1,
|
| 17 |
+
message: "Request successful",
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// 获取预扣费金额
|
| 22 |
+
const inletCost = getModelInletCost(modelId);
|
| 23 |
+
|
| 24 |
+
// 预扣费
|
| 25 |
+
if (inletCost > 0) {
|
| 26 |
+
const userResult = await query(
|
| 27 |
+
`UPDATE users
|
| 28 |
+
SET balance = LEAST(
|
| 29 |
+
balance - CAST($1 AS DECIMAL(16,4)),
|
| 30 |
+
999999.9999
|
| 31 |
+
)
|
| 32 |
+
WHERE id = $2 AND NOT deleted
|
| 33 |
+
RETURNING balance`,
|
| 34 |
+
[inletCost, user.id]
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
if (userResult.rows.length === 0) {
|
| 38 |
+
throw new Error("Failed to update user balance");
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return NextResponse.json({
|
| 42 |
+
success: true,
|
| 43 |
+
balance: Number(userResult.rows[0].balance),
|
| 44 |
+
inlet_cost: inletCost,
|
| 45 |
+
message: "Request successful",
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return NextResponse.json({
|
| 50 |
+
success: true,
|
| 51 |
+
balance: Number(user.balance),
|
| 52 |
+
message: "Request successful",
|
| 53 |
+
});
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error("Inlet error:", error);
|
| 56 |
+
return NextResponse.json(
|
| 57 |
+
{
|
| 58 |
+
success: false,
|
| 59 |
+
error:
|
| 60 |
+
error instanceof Error ? error.message : "Error dealing with request",
|
| 61 |
+
error_type: error instanceof Error ? error.name : "UNKNOWN_ERROR",
|
| 62 |
+
},
|
| 63 |
+
{ status: 500 }
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
}
|
app/api/v1/models/price/route.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { updateModelPrice } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
interface PriceUpdate {
|
| 5 |
+
id: string;
|
| 6 |
+
input_price: number;
|
| 7 |
+
output_price: number;
|
| 8 |
+
per_msg_price: number;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function POST(request: NextRequest) {
|
| 12 |
+
try {
|
| 13 |
+
const data = await request.json();
|
| 14 |
+
console.log("Raw data received:", data);
|
| 15 |
+
|
| 16 |
+
// 从对象中提取模型数组
|
| 17 |
+
const updates = data.updates || data;
|
| 18 |
+
if (!Array.isArray(updates)) {
|
| 19 |
+
console.error("Invalid data format - expected array:", updates);
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: "Invalid data format" },
|
| 22 |
+
{ status: 400 }
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 验证并转换数据格式
|
| 27 |
+
const validUpdates = updates
|
| 28 |
+
.map((update: any) => ({
|
| 29 |
+
id: update.id,
|
| 30 |
+
input_price: Number(update.input_price),
|
| 31 |
+
output_price: Number(update.output_price),
|
| 32 |
+
per_msg_price: Number(update.per_msg_price ?? -1),
|
| 33 |
+
}))
|
| 34 |
+
.filter((update: PriceUpdate) => {
|
| 35 |
+
const isValidPrice = (price: number) =>
|
| 36 |
+
!isNaN(price) && isFinite(price);
|
| 37 |
+
|
| 38 |
+
if (
|
| 39 |
+
!update.id ||
|
| 40 |
+
!isValidPrice(update.input_price) ||
|
| 41 |
+
!isValidPrice(update.output_price) ||
|
| 42 |
+
!isValidPrice(update.per_msg_price)
|
| 43 |
+
) {
|
| 44 |
+
console.log("Skipping invalid data:", update);
|
| 45 |
+
return false;
|
| 46 |
+
}
|
| 47 |
+
return true;
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
console.log("Update data after processing:", validUpdates);
|
| 51 |
+
console.log(
|
| 52 |
+
`Successfully verified price updating requests of ${validUpdates.length} models`
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
// 执行批量更新并收集结果
|
| 56 |
+
const results = await Promise.all(
|
| 57 |
+
validUpdates.map(async (update: PriceUpdate) => {
|
| 58 |
+
try {
|
| 59 |
+
console.log("Updating model prices:", {
|
| 60 |
+
id: update.id,
|
| 61 |
+
input_price: update.input_price,
|
| 62 |
+
output_price: update.output_price,
|
| 63 |
+
per_msg_price: update.per_msg_price,
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const result = await updateModelPrice(
|
| 67 |
+
update.id,
|
| 68 |
+
update.input_price,
|
| 69 |
+
update.output_price,
|
| 70 |
+
update.per_msg_price
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
console.log("Update results:", {
|
| 74 |
+
id: update.id,
|
| 75 |
+
success: !!result,
|
| 76 |
+
result,
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
id: update.id,
|
| 81 |
+
success: !!result,
|
| 82 |
+
data: result,
|
| 83 |
+
};
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error("Fail to update:", {
|
| 86 |
+
id: update.id,
|
| 87 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
| 88 |
+
});
|
| 89 |
+
return {
|
| 90 |
+
id: update.id,
|
| 91 |
+
success: false,
|
| 92 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
| 93 |
+
};
|
| 94 |
+
}
|
| 95 |
+
})
|
| 96 |
+
);
|
| 97 |
+
|
| 98 |
+
const successCount = results.filter((r) => r.success).length;
|
| 99 |
+
console.log(`Successfully updated prices of ${successCount} models`);
|
| 100 |
+
|
| 101 |
+
return NextResponse.json({
|
| 102 |
+
success: true,
|
| 103 |
+
message: `Successfully updated prices of ${successCount} models`,
|
| 104 |
+
results,
|
| 105 |
+
});
|
| 106 |
+
} catch (error) {
|
| 107 |
+
console.error("Batch update failed:", error);
|
| 108 |
+
return NextResponse.json({ error: "Batch update failed" }, { status: 500 });
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export async function OPTIONS() {
|
| 113 |
+
return NextResponse.json({}, { status: 200 });
|
| 114 |
+
}
|
app/api/v1/models/route.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { ensureTablesExist, getOrCreateModelPrices } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
interface ModelInfo {
|
| 5 |
+
id: string;
|
| 6 |
+
base_model_id: string;
|
| 7 |
+
name: string;
|
| 8 |
+
params: {
|
| 9 |
+
system: string;
|
| 10 |
+
};
|
| 11 |
+
meta: {
|
| 12 |
+
profile_image_url: string;
|
| 13 |
+
};
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface ModelResponse {
|
| 17 |
+
data: {
|
| 18 |
+
id: string;
|
| 19 |
+
name: string;
|
| 20 |
+
info: ModelInfo;
|
| 21 |
+
}[];
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export async function GET() {
|
| 25 |
+
try {
|
| 26 |
+
// Ensure database is initialized
|
| 27 |
+
await ensureTablesExist();
|
| 28 |
+
|
| 29 |
+
const domain = process.env.OPENWEBUI_DOMAIN;
|
| 30 |
+
if (!domain) {
|
| 31 |
+
throw new Error("OPENWEBUI_DOMAIN environment variable is not set.");
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Normalize API URL
|
| 35 |
+
const apiUrl = domain.replace(/\/+$/, "") + "/api/models";
|
| 36 |
+
|
| 37 |
+
const response = await fetch(apiUrl, {
|
| 38 |
+
headers: {
|
| 39 |
+
Authorization: `Bearer ${process.env.OPENWEBUI_API_KEY}`,
|
| 40 |
+
Accept: "application/json",
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
if (!response.ok) {
|
| 45 |
+
console.error("API response status:", response.status);
|
| 46 |
+
console.error("API response text:", await response.text());
|
| 47 |
+
throw new Error(`Failed to fetch models: ${response.status}`);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Get response text for debugging
|
| 51 |
+
const responseText = await response.text();
|
| 52 |
+
// console.log("API response:", responseText);
|
| 53 |
+
|
| 54 |
+
let data: ModelResponse;
|
| 55 |
+
try {
|
| 56 |
+
data = JSON.parse(responseText);
|
| 57 |
+
} catch (error) {
|
| 58 |
+
console.error("Failed to parse JSON:", error);
|
| 59 |
+
throw new Error("Invalid JSON response from API");
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
console.log("data:", data);
|
| 63 |
+
|
| 64 |
+
if (!data || !Array.isArray(data.data)) {
|
| 65 |
+
console.error("Unexpected API response structure:", data);
|
| 66 |
+
throw new Error("Unexpected API response structure");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Get price information for all models
|
| 70 |
+
const modelsWithPrices = await getOrCreateModelPrices(
|
| 71 |
+
data.data.map((item) => {
|
| 72 |
+
// 处理形如 gemini_search.gemini-2.0-flash 的派生模型ID
|
| 73 |
+
let baseModelId = item.info?.base_model_id;
|
| 74 |
+
|
| 75 |
+
// 如果没有明确的base_model_id,尝试从ID中提取
|
| 76 |
+
if (!baseModelId && item.id) {
|
| 77 |
+
const idParts = String(item.id).split(".");
|
| 78 |
+
if (idParts.length > 1) {
|
| 79 |
+
baseModelId = idParts[idParts.length - 1];
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
id: String(item.id),
|
| 85 |
+
name: String(item.name),
|
| 86 |
+
base_model_id: baseModelId,
|
| 87 |
+
};
|
| 88 |
+
})
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
const validModels = data.data.map((item, index) => {
|
| 92 |
+
// 处理形如 gemini_search.gemini-2.0-flash 的派生模型ID
|
| 93 |
+
let baseModelId = item.info?.base_model_id || "";
|
| 94 |
+
|
| 95 |
+
// 如果没有明确的base_model_id,尝试从ID中提取
|
| 96 |
+
if (!baseModelId && item.id) {
|
| 97 |
+
const idParts = String(item.id).split(".");
|
| 98 |
+
if (idParts.length > 1) {
|
| 99 |
+
baseModelId = idParts[idParts.length - 1];
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
id: modelsWithPrices[index].id,
|
| 105 |
+
base_model_id: baseModelId,
|
| 106 |
+
name: modelsWithPrices[index].name,
|
| 107 |
+
imageUrl: item.info?.meta?.profile_image_url || "/static/favicon.png",
|
| 108 |
+
system_prompt: item.info?.params?.system || "",
|
| 109 |
+
input_price: modelsWithPrices[index].input_price,
|
| 110 |
+
output_price: modelsWithPrices[index].output_price,
|
| 111 |
+
per_msg_price: modelsWithPrices[index].per_msg_price,
|
| 112 |
+
updated_at: modelsWithPrices[index].updated_at,
|
| 113 |
+
};
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
return NextResponse.json(validModels);
|
| 117 |
+
} catch (error) {
|
| 118 |
+
console.error("Error fetching models:", error);
|
| 119 |
+
return NextResponse.json(
|
| 120 |
+
{
|
| 121 |
+
error:
|
| 122 |
+
error instanceof Error ? error.message : "Failed to fetch models",
|
| 123 |
+
},
|
| 124 |
+
{ status: 500 }
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Add inlet endpoint
|
| 130 |
+
export async function POST(req: Request) {
|
| 131 |
+
const data = await req.json();
|
| 132 |
+
|
| 133 |
+
return new Response("Inlet placeholder response", {
|
| 134 |
+
headers: { "Content-Type": "application/json" },
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Add outlet endpoint
|
| 139 |
+
export async function PUT(req: Request) {
|
| 140 |
+
const data = await req.json();
|
| 141 |
+
// console.log("Outlet received:", JSON.stringify(data, null, 2));
|
| 142 |
+
|
| 143 |
+
return new Response("Outlet placeholder response", {
|
| 144 |
+
headers: { "Content-Type": "application/json" },
|
| 145 |
+
});
|
| 146 |
+
}
|
app/api/v1/models/sync-all-prices/route.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { pool } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const client = await pool.connect();
|
| 7 |
+
try {
|
| 8 |
+
// 1. 获取所有有效的派生模型(base_model_id 存在且在数据库中有对应记录)
|
| 9 |
+
const derivedModelsResult = await client.query(`
|
| 10 |
+
SELECT d.id, d.name, d.base_model_id
|
| 11 |
+
FROM model_prices d
|
| 12 |
+
JOIN model_prices b ON d.base_model_id = b.id
|
| 13 |
+
WHERE d.base_model_id IS NOT NULL
|
| 14 |
+
`);
|
| 15 |
+
|
| 16 |
+
if (derivedModelsResult.rows.length === 0) {
|
| 17 |
+
return NextResponse.json({
|
| 18 |
+
success: true,
|
| 19 |
+
message: "No derived models found",
|
| 20 |
+
syncedModels: [],
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const derivedModels = derivedModelsResult.rows;
|
| 25 |
+
const syncResults = [];
|
| 26 |
+
|
| 27 |
+
// 2. 为每个派生模型同步价格
|
| 28 |
+
for (const derivedModel of derivedModels) {
|
| 29 |
+
try {
|
| 30 |
+
// 获取上游模型价格
|
| 31 |
+
const baseModelResult = await client.query(
|
| 32 |
+
`SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`,
|
| 33 |
+
[derivedModel.base_model_id]
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
if (baseModelResult.rows.length === 0) {
|
| 37 |
+
syncResults.push({
|
| 38 |
+
id: derivedModel.id,
|
| 39 |
+
name: derivedModel.name,
|
| 40 |
+
success: false,
|
| 41 |
+
error: "Base model not found",
|
| 42 |
+
});
|
| 43 |
+
continue;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const baseModel = baseModelResult.rows[0];
|
| 47 |
+
|
| 48 |
+
// 更新派生模型价格
|
| 49 |
+
const updateResult = await client.query(
|
| 50 |
+
`UPDATE model_prices
|
| 51 |
+
SET
|
| 52 |
+
input_price = $2,
|
| 53 |
+
output_price = $3,
|
| 54 |
+
per_msg_price = $4,
|
| 55 |
+
updated_at = CURRENT_TIMESTAMP
|
| 56 |
+
WHERE id = $1
|
| 57 |
+
RETURNING *`,
|
| 58 |
+
[
|
| 59 |
+
derivedModel.id,
|
| 60 |
+
baseModel.input_price,
|
| 61 |
+
baseModel.output_price,
|
| 62 |
+
baseModel.per_msg_price,
|
| 63 |
+
]
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
const updatedModel = updateResult.rows[0];
|
| 67 |
+
|
| 68 |
+
syncResults.push({
|
| 69 |
+
id: updatedModel.id,
|
| 70 |
+
name: updatedModel.name,
|
| 71 |
+
base_model_id: derivedModel.base_model_id,
|
| 72 |
+
success: true,
|
| 73 |
+
input_price: Number(updatedModel.input_price),
|
| 74 |
+
output_price: Number(updatedModel.output_price),
|
| 75 |
+
per_msg_price: Number(updatedModel.per_msg_price),
|
| 76 |
+
});
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error(`Error syncing model ${derivedModel.id}:`, error);
|
| 79 |
+
syncResults.push({
|
| 80 |
+
id: derivedModel.id,
|
| 81 |
+
name: derivedModel.name,
|
| 82 |
+
success: false,
|
| 83 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const successCount = syncResults.filter((r) => r.success).length;
|
| 89 |
+
|
| 90 |
+
return NextResponse.json({
|
| 91 |
+
success: true,
|
| 92 |
+
message: `Successfully synced ${successCount} of ${derivedModels.length} derived models`,
|
| 93 |
+
syncedModels: syncResults,
|
| 94 |
+
});
|
| 95 |
+
} finally {
|
| 96 |
+
client.release();
|
| 97 |
+
}
|
| 98 |
+
} catch (error) {
|
| 99 |
+
console.error("Sync all prices failed:", error);
|
| 100 |
+
return NextResponse.json(
|
| 101 |
+
{
|
| 102 |
+
error: "Sync all prices failed",
|
| 103 |
+
message: error instanceof Error ? error.message : "Unknown error",
|
| 104 |
+
},
|
| 105 |
+
{ status: 500 }
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
export async function OPTIONS() {
|
| 111 |
+
return NextResponse.json({}, { status: 200 });
|
| 112 |
+
}
|
app/api/v1/models/sync-price/route.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { pool } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const data = await request.json();
|
| 7 |
+
const { modelId } = data;
|
| 8 |
+
|
| 9 |
+
if (!modelId) {
|
| 10 |
+
return NextResponse.json(
|
| 11 |
+
{ error: "Model ID is required" },
|
| 12 |
+
{ status: 400 }
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const client = await pool.connect();
|
| 17 |
+
try {
|
| 18 |
+
// 1. 获取派生模型信息
|
| 19 |
+
const derivedModelResult = await client.query(
|
| 20 |
+
`SELECT id, name, base_model_id FROM model_prices WHERE id = $1`,
|
| 21 |
+
[modelId]
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
if (derivedModelResult.rows.length === 0) {
|
| 25 |
+
return NextResponse.json({ error: "Model not found" }, { status: 404 });
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const derivedModel = derivedModelResult.rows[0];
|
| 29 |
+
let baseModelId = derivedModel.base_model_id;
|
| 30 |
+
|
| 31 |
+
// 如果数据库中没有base_model_id,尝试从ID中提取
|
| 32 |
+
if (!baseModelId) {
|
| 33 |
+
const idParts = modelId.split(".");
|
| 34 |
+
if (idParts.length > 1) {
|
| 35 |
+
baseModelId = idParts[idParts.length - 1];
|
| 36 |
+
|
| 37 |
+
// 更新数据库中的base_model_id
|
| 38 |
+
await client.query(
|
| 39 |
+
`UPDATE model_prices SET base_model_id = $2 WHERE id = $1`,
|
| 40 |
+
[modelId, baseModelId]
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if (!baseModelId) {
|
| 46 |
+
return NextResponse.json(
|
| 47 |
+
{ error: "Model does not have a base model" },
|
| 48 |
+
{ status: 400 }
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// 2. 获取上游模型价格
|
| 53 |
+
const baseModelResult = await client.query(
|
| 54 |
+
`SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`,
|
| 55 |
+
[baseModelId]
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
if (baseModelResult.rows.length === 0) {
|
| 59 |
+
return NextResponse.json(
|
| 60 |
+
{ error: "Base model not found" },
|
| 61 |
+
{ status: 404 }
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const baseModel = baseModelResult.rows[0];
|
| 66 |
+
|
| 67 |
+
// 3. 更新派生模型价格
|
| 68 |
+
const updateResult = await client.query(
|
| 69 |
+
`UPDATE model_prices
|
| 70 |
+
SET
|
| 71 |
+
input_price = $2,
|
| 72 |
+
output_price = $3,
|
| 73 |
+
per_msg_price = $4,
|
| 74 |
+
updated_at = CURRENT_TIMESTAMP
|
| 75 |
+
WHERE id = $1
|
| 76 |
+
RETURNING *`,
|
| 77 |
+
[
|
| 78 |
+
modelId,
|
| 79 |
+
baseModel.input_price,
|
| 80 |
+
baseModel.output_price,
|
| 81 |
+
baseModel.per_msg_price,
|
| 82 |
+
]
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
const updatedModel = updateResult.rows[0];
|
| 86 |
+
|
| 87 |
+
return NextResponse.json({
|
| 88 |
+
success: true,
|
| 89 |
+
message: `Successfully synced prices from ${baseModelId} to ${modelId}`,
|
| 90 |
+
data: {
|
| 91 |
+
id: updatedModel.id,
|
| 92 |
+
name: updatedModel.name,
|
| 93 |
+
base_model_id: baseModelId,
|
| 94 |
+
input_price: Number(updatedModel.input_price),
|
| 95 |
+
output_price: Number(updatedModel.output_price),
|
| 96 |
+
per_msg_price: Number(updatedModel.per_msg_price),
|
| 97 |
+
updated_at: updatedModel.updated_at,
|
| 98 |
+
},
|
| 99 |
+
});
|
| 100 |
+
} finally {
|
| 101 |
+
client.release();
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error("Sync price failed:", error);
|
| 105 |
+
return NextResponse.json(
|
| 106 |
+
{
|
| 107 |
+
error: "Sync price failed",
|
| 108 |
+
message: error instanceof Error ? error.message : "Unknown error",
|
| 109 |
+
},
|
| 110 |
+
{ status: 500 }
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export async function OPTIONS() {
|
| 116 |
+
return NextResponse.json({}, { status: 200 });
|
| 117 |
+
}
|
app/api/v1/models/test/route.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export async function POST(req: Request) {
|
| 4 |
+
try {
|
| 5 |
+
const { modelId } = await req.json();
|
| 6 |
+
|
| 7 |
+
if (!modelId) {
|
| 8 |
+
return NextResponse.json({
|
| 9 |
+
success: false,
|
| 10 |
+
message: "Model ID cannot be empty",
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const domain = process.env.OPENWEBUI_DOMAIN;
|
| 15 |
+
const apiKey = process.env.OPENWEBUI_API_KEY;
|
| 16 |
+
|
| 17 |
+
if (!domain || !apiKey) {
|
| 18 |
+
return NextResponse.json({
|
| 19 |
+
success: false,
|
| 20 |
+
message: "Environment variables not configured correctly",
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const apiUrl = domain.replace(/\/+$/, "") + "/api/chat/completions";
|
| 25 |
+
|
| 26 |
+
const response = await fetch(apiUrl, {
|
| 27 |
+
method: "POST",
|
| 28 |
+
headers: {
|
| 29 |
+
Authorization: `Bearer ${apiKey}`,
|
| 30 |
+
"Content-Type": "application/json",
|
| 31 |
+
},
|
| 32 |
+
body: JSON.stringify({
|
| 33 |
+
model: modelId,
|
| 34 |
+
messages: [
|
| 35 |
+
{
|
| 36 |
+
role: "user",
|
| 37 |
+
content: "test, just say hi",
|
| 38 |
+
},
|
| 39 |
+
],
|
| 40 |
+
}),
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
const responseText = await response.text();
|
| 44 |
+
let data;
|
| 45 |
+
|
| 46 |
+
try {
|
| 47 |
+
data = JSON.parse(responseText);
|
| 48 |
+
} catch (e) {
|
| 49 |
+
return NextResponse.json({
|
| 50 |
+
success: false,
|
| 51 |
+
message: `Fail to resolve response: ${responseText}`,
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (!response.ok) {
|
| 56 |
+
return NextResponse.json({
|
| 57 |
+
success: false,
|
| 58 |
+
message:
|
| 59 |
+
data.error ||
|
| 60 |
+
`API request failed: ${response.status} ${response.statusText}`,
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (!data.choices?.[0]?.message?.content) {
|
| 65 |
+
return NextResponse.json({
|
| 66 |
+
success: false,
|
| 67 |
+
message: "Invalid response format",
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return NextResponse.json({
|
| 72 |
+
success: true,
|
| 73 |
+
message: "Test successful",
|
| 74 |
+
response: data.choices[0].message.content,
|
| 75 |
+
});
|
| 76 |
+
} catch (error) {
|
| 77 |
+
return NextResponse.json({
|
| 78 |
+
success: false,
|
| 79 |
+
message: error instanceof Error ? error.message : "Unknown error",
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
}
|
app/api/v1/outlet/route.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { encode } from "gpt-tokenizer/model/gpt-4";
|
| 3 |
+
import { Pool, PoolClient } from "pg";
|
| 4 |
+
import { createClient } from "@vercel/postgres";
|
| 5 |
+
import { query, getClient } from "@/lib/db/client";
|
| 6 |
+
import { getModelInletCost } from "@/lib/utils/inlet-cost";
|
| 7 |
+
|
| 8 |
+
const isVercel = process.env.VERCEL === "1";
|
| 9 |
+
|
| 10 |
+
interface Message {
|
| 11 |
+
role: string;
|
| 12 |
+
content: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface ModelPrice {
|
| 16 |
+
id: string;
|
| 17 |
+
name: string;
|
| 18 |
+
input_price: number;
|
| 19 |
+
output_price: number;
|
| 20 |
+
per_msg_price: number;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type DbClient = ReturnType<typeof createClient> | Pool | PoolClient;
|
| 24 |
+
|
| 25 |
+
async function getModelPrice(modelId: string): Promise<ModelPrice | null> {
|
| 26 |
+
const result = await query(
|
| 27 |
+
`SELECT id, name, input_price, output_price, per_msg_price
|
| 28 |
+
FROM model_prices
|
| 29 |
+
WHERE id = $1`,
|
| 30 |
+
[modelId]
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
if (result.rows[0]) {
|
| 34 |
+
return result.rows[0];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// If no price is found in the database, use the default price
|
| 38 |
+
const defaultInputPrice = parseFloat(
|
| 39 |
+
process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
|
| 40 |
+
);
|
| 41 |
+
const defaultOutputPrice = parseFloat(
|
| 42 |
+
process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
// Verify that the default price is a valid non-negative number
|
| 46 |
+
if (
|
| 47 |
+
isNaN(defaultInputPrice) ||
|
| 48 |
+
defaultInputPrice < 0 ||
|
| 49 |
+
isNaN(defaultOutputPrice) ||
|
| 50 |
+
defaultOutputPrice < 0
|
| 51 |
+
) {
|
| 52 |
+
return null;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
id: modelId,
|
| 57 |
+
name: modelId,
|
| 58 |
+
input_price: defaultInputPrice,
|
| 59 |
+
output_price: defaultOutputPrice,
|
| 60 |
+
per_msg_price: -1, // Default to token-based pricing
|
| 61 |
+
};
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export async function POST(req: Request) {
|
| 65 |
+
const client = (await getClient()) as DbClient;
|
| 66 |
+
let pgClient: DbClient | null = null;
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
// Get a dedicated transaction client
|
| 70 |
+
if (isVercel) {
|
| 71 |
+
pgClient = client;
|
| 72 |
+
} else {
|
| 73 |
+
pgClient = await (client as Pool).connect();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const data = await req.json();
|
| 77 |
+
console.log("请求数据:", JSON.stringify(data, null, 2));
|
| 78 |
+
const modelId = data.body.model;
|
| 79 |
+
const userId = data.user.id;
|
| 80 |
+
const userName = data.user.name || "Unknown User";
|
| 81 |
+
|
| 82 |
+
// Start a transaction
|
| 83 |
+
await query("BEGIN");
|
| 84 |
+
|
| 85 |
+
// Get model price
|
| 86 |
+
const modelPrice = await getModelPrice(modelId);
|
| 87 |
+
if (!modelPrice) {
|
| 88 |
+
throw new Error(`Fail to fetch price info of model ${modelId}`);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Calculate tokens
|
| 92 |
+
const lastMessage = data.body.messages[data.body.messages.length - 1];
|
| 93 |
+
|
| 94 |
+
let inputTokens: number;
|
| 95 |
+
let outputTokens: number;
|
| 96 |
+
if (
|
| 97 |
+
lastMessage.usage &&
|
| 98 |
+
lastMessage.usage.prompt_tokens &&
|
| 99 |
+
lastMessage.usage.completion_tokens
|
| 100 |
+
) {
|
| 101 |
+
inputTokens = lastMessage.usage.prompt_tokens;
|
| 102 |
+
outputTokens = lastMessage.usage.completion_tokens;
|
| 103 |
+
} else {
|
| 104 |
+
outputTokens = encode(lastMessage.content).length;
|
| 105 |
+
const totalTokens = data.body.messages.reduce(
|
| 106 |
+
(sum: number, msg: Message) => sum + encode(msg.content).length,
|
| 107 |
+
0
|
| 108 |
+
);
|
| 109 |
+
inputTokens = totalTokens - outputTokens;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Calculate total cost
|
| 113 |
+
let totalCost: number;
|
| 114 |
+
if (outputTokens === 0) {
|
| 115 |
+
// If output tokens are 0, no charge
|
| 116 |
+
totalCost = 0;
|
| 117 |
+
console.log("No charge for zero output tokens");
|
| 118 |
+
} else if (modelPrice.per_msg_price >= 0) {
|
| 119 |
+
// If fixed pricing is set, use it directly
|
| 120 |
+
totalCost = Number(modelPrice.per_msg_price);
|
| 121 |
+
console.log(
|
| 122 |
+
`Using fixed pricing: ${totalCost} (${modelPrice.per_msg_price} per message)`
|
| 123 |
+
);
|
| 124 |
+
} else {
|
| 125 |
+
// Otherwise, calculate price by token quantity
|
| 126 |
+
const inputCost = (inputTokens / 1_000_000) * modelPrice.input_price;
|
| 127 |
+
const outputCost = (outputTokens / 1_000_000) * modelPrice.output_price;
|
| 128 |
+
totalCost = inputCost + outputCost;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Get the pre-deducted cost when getting inlet
|
| 132 |
+
const inletCost = getModelInletCost(modelId);
|
| 133 |
+
|
| 134 |
+
// The actual cost to be deducted = total cost - pre-deducted cost
|
| 135 |
+
const actualCost = totalCost - inletCost;
|
| 136 |
+
|
| 137 |
+
// Get and update user balance
|
| 138 |
+
const userResult = await query(
|
| 139 |
+
`UPDATE users
|
| 140 |
+
SET balance = LEAST(
|
| 141 |
+
balance - CAST($1 AS DECIMAL(16,4)),
|
| 142 |
+
999999.9999
|
| 143 |
+
)
|
| 144 |
+
WHERE id = $2
|
| 145 |
+
RETURNING balance`,
|
| 146 |
+
[actualCost, userId]
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
if (userResult.rows.length === 0) {
|
| 150 |
+
throw new Error("User does not exist");
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const newBalance = Number(userResult.rows[0].balance);
|
| 154 |
+
|
| 155 |
+
if (newBalance > 999999.9999) {
|
| 156 |
+
throw new Error("Balance exceeds maximum allowed value");
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Record usage
|
| 160 |
+
await query(
|
| 161 |
+
`INSERT INTO user_usage_records (
|
| 162 |
+
user_id, nickname, model_name,
|
| 163 |
+
input_tokens, output_tokens,
|
| 164 |
+
cost, balance_after
|
| 165 |
+
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
| 166 |
+
[
|
| 167 |
+
userId,
|
| 168 |
+
userName,
|
| 169 |
+
modelId,
|
| 170 |
+
inputTokens,
|
| 171 |
+
outputTokens,
|
| 172 |
+
totalCost,
|
| 173 |
+
newBalance,
|
| 174 |
+
]
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
await query("COMMIT");
|
| 178 |
+
|
| 179 |
+
console.log(
|
| 180 |
+
JSON.stringify({
|
| 181 |
+
success: true,
|
| 182 |
+
inputTokens,
|
| 183 |
+
outputTokens,
|
| 184 |
+
totalCost,
|
| 185 |
+
newBalance,
|
| 186 |
+
message: "Request successful",
|
| 187 |
+
})
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
return NextResponse.json({
|
| 191 |
+
success: true,
|
| 192 |
+
inputTokens,
|
| 193 |
+
outputTokens,
|
| 194 |
+
totalCost,
|
| 195 |
+
newBalance,
|
| 196 |
+
message: "Request successful",
|
| 197 |
+
});
|
| 198 |
+
} catch (error) {
|
| 199 |
+
await query("ROLLBACK");
|
| 200 |
+
console.error("Outlet error:", error);
|
| 201 |
+
return NextResponse.json(
|
| 202 |
+
{
|
| 203 |
+
success: false,
|
| 204 |
+
error:
|
| 205 |
+
error instanceof Error ? error.message : "Error processing request",
|
| 206 |
+
error_type: error instanceof Error ? error.name : "UNKNOWN_ERROR",
|
| 207 |
+
},
|
| 208 |
+
{ status: 500 }
|
| 209 |
+
);
|
| 210 |
+
} finally {
|
| 211 |
+
// Only release connection in non-Vercel environment
|
| 212 |
+
if (!isVercel && pgClient && "release" in pgClient) {
|
| 213 |
+
pgClient.release();
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
app/api/v1/panel/database/export/route.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pool } from "@/lib/db";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { PoolClient } from "pg";
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
let client: PoolClient | null = null;
|
| 7 |
+
|
| 8 |
+
try {
|
| 9 |
+
// 获取数据库连接
|
| 10 |
+
client = await pool.connect();
|
| 11 |
+
|
| 12 |
+
// 获取所有表的数据
|
| 13 |
+
const users = await client.query("SELECT * FROM users ORDER BY id");
|
| 14 |
+
const modelPrices = await client.query(
|
| 15 |
+
"SELECT * FROM model_prices ORDER BY id"
|
| 16 |
+
);
|
| 17 |
+
const records = await client.query(
|
| 18 |
+
"SELECT * FROM user_usage_records ORDER BY id"
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
// 构建导出数据结构
|
| 22 |
+
const exportData = {
|
| 23 |
+
version: "1.0",
|
| 24 |
+
timestamp: new Date().toISOString(),
|
| 25 |
+
data: {
|
| 26 |
+
users: users.rows,
|
| 27 |
+
model_prices: modelPrices.rows,
|
| 28 |
+
user_usage_records: records.rows,
|
| 29 |
+
},
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
// 设置响应头
|
| 33 |
+
const headers = new Headers();
|
| 34 |
+
headers.set("Content-Type", "application/json");
|
| 35 |
+
headers.set(
|
| 36 |
+
"Content-Disposition",
|
| 37 |
+
`attachment; filename=openwebui_monitor_backup_${
|
| 38 |
+
new Date().toISOString().split("T")[0]
|
| 39 |
+
}.json`
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
return new Response(JSON.stringify(exportData, null, 2), {
|
| 43 |
+
headers,
|
| 44 |
+
});
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Fail to export database:", error);
|
| 47 |
+
return NextResponse.json(
|
| 48 |
+
{ error: "Fail to export database" },
|
| 49 |
+
{ status: 500 }
|
| 50 |
+
);
|
| 51 |
+
} finally {
|
| 52 |
+
if (client) {
|
| 53 |
+
client.release();
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
app/api/v1/panel/database/import/route.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pool } from "@/lib/db";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { PoolClient } from "pg";
|
| 4 |
+
|
| 5 |
+
export async function POST(req: Request) {
|
| 6 |
+
let client: PoolClient | null = null;
|
| 7 |
+
|
| 8 |
+
try {
|
| 9 |
+
const data = await req.json();
|
| 10 |
+
|
| 11 |
+
// 验证数据格式
|
| 12 |
+
if (!data.version || !data.data) {
|
| 13 |
+
throw new Error("Invalid import data format");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// 获取数据库连接
|
| 17 |
+
client = await pool.connect();
|
| 18 |
+
|
| 19 |
+
// 开启事务
|
| 20 |
+
await client.query("BEGIN");
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
// 清空现有数据
|
| 24 |
+
await client.query("TRUNCATE TABLE user_usage_records CASCADE");
|
| 25 |
+
await client.query("TRUNCATE TABLE model_prices CASCADE");
|
| 26 |
+
await client.query("TRUNCATE TABLE users CASCADE");
|
| 27 |
+
|
| 28 |
+
// 导入用户数据
|
| 29 |
+
if (data.data.users?.length) {
|
| 30 |
+
for (const user of data.data.users) {
|
| 31 |
+
await client.query(
|
| 32 |
+
`INSERT INTO users (id, email, name, role, balance)
|
| 33 |
+
VALUES ($1, $2, $3, $4, $5)`,
|
| 34 |
+
[user.id, user.email, user.name, user.role, user.balance]
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// 导入模型价格
|
| 40 |
+
if (data.data.model_prices?.length) {
|
| 41 |
+
for (const price of data.data.model_prices) {
|
| 42 |
+
await client.query(
|
| 43 |
+
`INSERT INTO model_prices (id, name, input_price, output_price)
|
| 44 |
+
VALUES ($1, $2, $3, $4)`,
|
| 45 |
+
[price.id, price.name, price.input_price, price.output_price]
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// 导入使用记录
|
| 51 |
+
if (data.data.user_usage_records?.length) {
|
| 52 |
+
for (const record of data.data.user_usage_records) {
|
| 53 |
+
await client.query(
|
| 54 |
+
`INSERT INTO user_usage_records (
|
| 55 |
+
user_id, nickname, use_time, model_name,
|
| 56 |
+
input_tokens, output_tokens, cost, balance_after
|
| 57 |
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
| 58 |
+
[
|
| 59 |
+
record.user_id,
|
| 60 |
+
record.nickname,
|
| 61 |
+
record.use_time,
|
| 62 |
+
record.model_name,
|
| 63 |
+
record.input_tokens,
|
| 64 |
+
record.output_tokens,
|
| 65 |
+
record.cost,
|
| 66 |
+
record.balance_after,
|
| 67 |
+
]
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
await client.query("COMMIT");
|
| 73 |
+
|
| 74 |
+
return NextResponse.json({
|
| 75 |
+
success: true,
|
| 76 |
+
message: "Data import successful",
|
| 77 |
+
});
|
| 78 |
+
} catch (error) {
|
| 79 |
+
await client.query("ROLLBACK");
|
| 80 |
+
throw error;
|
| 81 |
+
}
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error("Fail to import database:", error);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{
|
| 86 |
+
success: false,
|
| 87 |
+
error:
|
| 88 |
+
error instanceof Error ? error.message : "Fail to import database",
|
| 89 |
+
},
|
| 90 |
+
{ status: 500 }
|
| 91 |
+
);
|
| 92 |
+
} finally {
|
| 93 |
+
if (client) {
|
| 94 |
+
client.release();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
app/api/v1/panel/records/export/route.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pool } from "@/lib/db";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { PoolClient } from "pg";
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
let client: PoolClient | null = null;
|
| 7 |
+
try {
|
| 8 |
+
client = await pool.connect();
|
| 9 |
+
|
| 10 |
+
const records = await client.query(`
|
| 11 |
+
SELECT
|
| 12 |
+
nickname,
|
| 13 |
+
use_time,
|
| 14 |
+
model_name,
|
| 15 |
+
input_tokens,
|
| 16 |
+
output_tokens,
|
| 17 |
+
cost,
|
| 18 |
+
balance_after
|
| 19 |
+
FROM user_usage_records
|
| 20 |
+
ORDER BY use_time DESC
|
| 21 |
+
`);
|
| 22 |
+
|
| 23 |
+
// 生成 CSV 内容
|
| 24 |
+
const csvHeaders = [
|
| 25 |
+
"User",
|
| 26 |
+
"Time",
|
| 27 |
+
"Model",
|
| 28 |
+
"Input tokens",
|
| 29 |
+
"Output tokens",
|
| 30 |
+
"Cost",
|
| 31 |
+
"Balance",
|
| 32 |
+
];
|
| 33 |
+
const rows = records.rows.map((record) => [
|
| 34 |
+
record.nickname,
|
| 35 |
+
new Date(record.use_time).toLocaleString(),
|
| 36 |
+
record.model_name,
|
| 37 |
+
record.input_tokens,
|
| 38 |
+
record.output_tokens,
|
| 39 |
+
Number(record.cost).toFixed(4),
|
| 40 |
+
Number(record.balance_after).toFixed(4),
|
| 41 |
+
]);
|
| 42 |
+
|
| 43 |
+
const csvContent = [
|
| 44 |
+
csvHeaders.join(","),
|
| 45 |
+
...rows.map((row) => row.join(",")),
|
| 46 |
+
].join("\n");
|
| 47 |
+
|
| 48 |
+
// 设置响应头
|
| 49 |
+
const responseHeaders = new Headers();
|
| 50 |
+
responseHeaders.set("Content-Type", "text/csv; charset=utf-8");
|
| 51 |
+
responseHeaders.set(
|
| 52 |
+
"Content-Disposition",
|
| 53 |
+
"attachment; filename=usage_records.csv"
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
return new Response(csvContent, {
|
| 57 |
+
headers: responseHeaders,
|
| 58 |
+
});
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.error("Fail to export records:", error);
|
| 61 |
+
return NextResponse.json(
|
| 62 |
+
{ error: "Fail to export records" },
|
| 63 |
+
{ status: 500 }
|
| 64 |
+
);
|
| 65 |
+
} finally {
|
| 66 |
+
if (client) {
|
| 67 |
+
client.release();
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
app/api/v1/panel/records/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pool } from "@/lib/db";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
import { PoolClient } from "pg";
|
| 4 |
+
|
| 5 |
+
export async function GET(req: Request) {
|
| 6 |
+
let client: PoolClient | null = null;
|
| 7 |
+
try {
|
| 8 |
+
const { searchParams } = new URL(req.url);
|
| 9 |
+
const page = parseInt(searchParams.get("page") || "1");
|
| 10 |
+
const pageSize = parseInt(searchParams.get("pageSize") || "10");
|
| 11 |
+
const sortField = searchParams.get("sortField");
|
| 12 |
+
const sortOrder = searchParams.get("sortOrder");
|
| 13 |
+
const users = searchParams.get("users")?.split(",") || [];
|
| 14 |
+
const models = searchParams.get("models")?.split(",") || [];
|
| 15 |
+
|
| 16 |
+
client = await pool.connect();
|
| 17 |
+
|
| 18 |
+
// 构建查询条件
|
| 19 |
+
const conditions = [];
|
| 20 |
+
const params = [];
|
| 21 |
+
let paramIndex = 1;
|
| 22 |
+
|
| 23 |
+
if (users.length > 0) {
|
| 24 |
+
conditions.push(`nickname = ANY($${paramIndex})`);
|
| 25 |
+
params.push(users);
|
| 26 |
+
paramIndex++;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (models.length > 0) {
|
| 30 |
+
conditions.push(`model_name = ANY($${paramIndex})`);
|
| 31 |
+
params.push(models);
|
| 32 |
+
paramIndex++;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const whereClause =
|
| 36 |
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
| 37 |
+
|
| 38 |
+
// 构建排序
|
| 39 |
+
const orderClause = sortField
|
| 40 |
+
? `ORDER BY ${sortField} ${sortOrder === "descend" ? "DESC" : "ASC"}`
|
| 41 |
+
: "ORDER BY use_time DESC";
|
| 42 |
+
|
| 43 |
+
// 获取总记录数
|
| 44 |
+
const countQuery = `
|
| 45 |
+
SELECT COUNT(*)
|
| 46 |
+
FROM user_usage_records
|
| 47 |
+
${whereClause}
|
| 48 |
+
`;
|
| 49 |
+
const countResult = await client.query(countQuery, params);
|
| 50 |
+
|
| 51 |
+
// 获取分页数据
|
| 52 |
+
const offset = (page - 1) * pageSize;
|
| 53 |
+
const dataQuery = `
|
| 54 |
+
SELECT
|
| 55 |
+
user_id,
|
| 56 |
+
nickname,
|
| 57 |
+
use_time,
|
| 58 |
+
model_name,
|
| 59 |
+
input_tokens,
|
| 60 |
+
output_tokens,
|
| 61 |
+
cost,
|
| 62 |
+
balance_after
|
| 63 |
+
FROM user_usage_records
|
| 64 |
+
${whereClause}
|
| 65 |
+
${orderClause}
|
| 66 |
+
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
| 67 |
+
`;
|
| 68 |
+
|
| 69 |
+
const dataParams = [...params, pageSize, offset];
|
| 70 |
+
const records = await client.query(dataQuery, dataParams);
|
| 71 |
+
|
| 72 |
+
const total = parseInt(countResult.rows[0].count);
|
| 73 |
+
|
| 74 |
+
return NextResponse.json({
|
| 75 |
+
records: records.rows,
|
| 76 |
+
total,
|
| 77 |
+
});
|
| 78 |
+
} catch (error) {
|
| 79 |
+
console.error("Fail to fetch usage records:", error);
|
| 80 |
+
return NextResponse.json(
|
| 81 |
+
{ error: "Fail to fetch usage records" },
|
| 82 |
+
{ status: 500 }
|
| 83 |
+
);
|
| 84 |
+
} finally {
|
| 85 |
+
if (client) {
|
| 86 |
+
client.release();
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
app/api/v1/panel/usage/route.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { pool } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
export async function GET(request: Request) {
|
| 5 |
+
try {
|
| 6 |
+
const { searchParams } = new URL(request.url);
|
| 7 |
+
const startTime = searchParams.get("startTime");
|
| 8 |
+
const endTime = searchParams.get("endTime");
|
| 9 |
+
|
| 10 |
+
const timeFilter =
|
| 11 |
+
startTime && endTime ? `WHERE use_time >= $1 AND use_time <= $2` : "";
|
| 12 |
+
|
| 13 |
+
const params = startTime && endTime ? [startTime, endTime] : [];
|
| 14 |
+
|
| 15 |
+
const [modelResult, userResult, timeRangeResult, statsResult] =
|
| 16 |
+
await Promise.all([
|
| 17 |
+
pool.query(
|
| 18 |
+
`
|
| 19 |
+
SELECT
|
| 20 |
+
model_name,
|
| 21 |
+
COUNT(*) as total_count,
|
| 22 |
+
COALESCE(SUM(cost), 0) as total_cost
|
| 23 |
+
FROM user_usage_records
|
| 24 |
+
${timeFilter}
|
| 25 |
+
GROUP BY model_name
|
| 26 |
+
ORDER BY total_cost DESC
|
| 27 |
+
`,
|
| 28 |
+
params
|
| 29 |
+
),
|
| 30 |
+
pool.query(
|
| 31 |
+
`
|
| 32 |
+
SELECT
|
| 33 |
+
nickname,
|
| 34 |
+
COUNT(*) as total_count,
|
| 35 |
+
COALESCE(SUM(cost), 0) as total_cost
|
| 36 |
+
FROM user_usage_records
|
| 37 |
+
${timeFilter}
|
| 38 |
+
GROUP BY nickname
|
| 39 |
+
ORDER BY total_cost DESC
|
| 40 |
+
`,
|
| 41 |
+
params
|
| 42 |
+
),
|
| 43 |
+
pool.query(`
|
| 44 |
+
SELECT
|
| 45 |
+
MIN(use_time) as min_time,
|
| 46 |
+
MAX(use_time) as max_time
|
| 47 |
+
FROM user_usage_records
|
| 48 |
+
`),
|
| 49 |
+
pool.query(
|
| 50 |
+
`
|
| 51 |
+
SELECT
|
| 52 |
+
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
| 53 |
+
COUNT(*) as total_calls
|
| 54 |
+
FROM user_usage_records
|
| 55 |
+
${timeFilter}
|
| 56 |
+
`,
|
| 57 |
+
params
|
| 58 |
+
),
|
| 59 |
+
]);
|
| 60 |
+
|
| 61 |
+
const formattedData = {
|
| 62 |
+
models: modelResult.rows.map((row) => ({
|
| 63 |
+
model_name: row.model_name,
|
| 64 |
+
total_count: parseInt(row.total_count),
|
| 65 |
+
total_cost: parseFloat(row.total_cost),
|
| 66 |
+
})),
|
| 67 |
+
users: userResult.rows.map((row) => ({
|
| 68 |
+
nickname: row.nickname,
|
| 69 |
+
total_count: parseInt(row.total_count),
|
| 70 |
+
total_cost: parseFloat(row.total_cost),
|
| 71 |
+
})),
|
| 72 |
+
timeRange: {
|
| 73 |
+
minTime: timeRangeResult.rows[0].min_time,
|
| 74 |
+
maxTime: timeRangeResult.rows[0].max_time,
|
| 75 |
+
},
|
| 76 |
+
stats: {
|
| 77 |
+
totalTokens: parseInt(statsResult.rows[0].total_tokens),
|
| 78 |
+
totalCalls: parseInt(statsResult.rows[0].total_calls),
|
| 79 |
+
},
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
return NextResponse.json(formattedData);
|
| 83 |
+
} catch (error) {
|
| 84 |
+
console.error("Fail to fetch usage records:", error);
|
| 85 |
+
return NextResponse.json(
|
| 86 |
+
{ error: "Fail to fetch usage records" },
|
| 87 |
+
{ status: 500 }
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
}
|
app/apple-icon.png
ADDED
|
|
app/fonts/GeistMonoVF.woff
ADDED
|
Binary file (67.9 kB). View file
|
|
|
app/fonts/GeistVF.woff
ADDED
|
Binary file (66.3 kB). View file
|
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
body {
|
| 6 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
| 7 |
+
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@layer utilities {
|
| 11 |
+
@keyframes fadeIn {
|
| 12 |
+
from {
|
| 13 |
+
opacity: 0;
|
| 14 |
+
transform: translateY(10px);
|
| 15 |
+
}
|
| 16 |
+
to {
|
| 17 |
+
opacity: 1;
|
| 18 |
+
transform: translateY(0);
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.animate-fade-in {
|
| 23 |
+
animation: fadeIn 0.6s ease-out forwards;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.animate-fade-in-delay {
|
| 27 |
+
animation: fadeIn 0.6s ease-out 0.2s forwards;
|
| 28 |
+
opacity: 0;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
@keyframes slide-up {
|
| 32 |
+
from {
|
| 33 |
+
opacity: 0;
|
| 34 |
+
transform: translateY(1rem);
|
| 35 |
+
}
|
| 36 |
+
to {
|
| 37 |
+
opacity: 1;
|
| 38 |
+
transform: translateY(0);
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.animate-slide-up {
|
| 43 |
+
animation: slide-up 0.3s ease-out;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.custom-modal .ant-modal-content {
|
| 48 |
+
padding: 24px;
|
| 49 |
+
border-radius: 16px;
|
| 50 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.custom-modal .ant-modal-header {
|
| 54 |
+
margin-bottom: 0;
|
| 55 |
+
padding: 0;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.custom-modal .ant-modal-body {
|
| 59 |
+
padding: 0;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.custom-modal .ant-modal-close {
|
| 63 |
+
top: 20px;
|
| 64 |
+
right: 20px;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.custom-modal .ant-btn {
|
| 68 |
+
height: 40px;
|
| 69 |
+
border-radius: 8px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.custom-tooltip-simple .ant-tooltip-inner {
|
| 73 |
+
padding: 0;
|
| 74 |
+
border-radius: 8px;
|
| 75 |
+
min-width: 120px;
|
| 76 |
+
border: 1px solid rgba(0, 0, 0, 0.04);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.custom-tooltip-simple .ant-tooltip-arrow {
|
| 80 |
+
display: none;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* 更新模态框样式 */
|
| 84 |
+
.update-modal .ant-modal-content {
|
| 85 |
+
padding: 24px;
|
| 86 |
+
border-radius: 16px;
|
| 87 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.update-modal .ant-modal-footer {
|
| 91 |
+
margin-top: 24px;
|
| 92 |
+
border-top: none;
|
| 93 |
+
padding: 0;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.update-modal .ant-modal-footer .ant-btn {
|
| 97 |
+
height: 38px;
|
| 98 |
+
padding: 0 24px;
|
| 99 |
+
border-radius: 8px;
|
| 100 |
+
font-weight: 500;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.update-modal .ant-modal-footer .ant-btn-primary {
|
| 104 |
+
background: rgb(59 130 246);
|
| 105 |
+
border-color: rgb(59 130 246);
|
| 106 |
+
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.update-modal .ant-modal-footer .ant-btn-primary:hover {
|
| 110 |
+
background: rgb(37 99 235);
|
| 111 |
+
border-color: rgb(37 99 235);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.update-modal .ant-modal-footer .ant-btn-default {
|
| 115 |
+
border-color: #e5e7eb;
|
| 116 |
+
color: #6b7280;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.update-modal .ant-modal-footer .ant-btn-default:hover {
|
| 120 |
+
border-color: #d1d5db;
|
| 121 |
+
color: #4b5563;
|
| 122 |
+
background: #f9fafb;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
@layer base {
|
| 126 |
+
:root {
|
| 127 |
+
--background: 0 0% 100%;
|
| 128 |
+
--foreground: 240 10% 3.9%;
|
| 129 |
+
--card: 0 0% 100%;
|
| 130 |
+
--card-foreground: 240 10% 3.9%;
|
| 131 |
+
--popover: 0 0% 100%;
|
| 132 |
+
--popover-foreground: 240 10% 3.9%;
|
| 133 |
+
--primary: 240 5.9% 10%;
|
| 134 |
+
--primary-foreground: 0 0% 98%;
|
| 135 |
+
--secondary: 240 4.8% 95.9%;
|
| 136 |
+
--secondary-foreground: 240 5.9% 10%;
|
| 137 |
+
--muted: 240 4.8% 95.9%;
|
| 138 |
+
--muted-foreground: 240 3.8% 46.1%;
|
| 139 |
+
--accent: 240 4.8% 95.9%;
|
| 140 |
+
--accent-foreground: 240 5.9% 10%;
|
| 141 |
+
--destructive: 0 84.2% 60.2%;
|
| 142 |
+
--destructive-foreground: 0 0% 98%;
|
| 143 |
+
--border: 240 5.9% 90%;
|
| 144 |
+
--input: 240 5.9% 90%;
|
| 145 |
+
--ring: 240 10% 3.9%;
|
| 146 |
+
--chart-1: 12 76% 61%;
|
| 147 |
+
--chart-2: 173 58% 39%;
|
| 148 |
+
--chart-3: 197 37% 24%;
|
| 149 |
+
--chart-4: 43 74% 66%;
|
| 150 |
+
--chart-5: 27 87% 67%;
|
| 151 |
+
--radius: 0.5rem;
|
| 152 |
+
--sidebar-background: 0 0% 98%;
|
| 153 |
+
--sidebar-foreground: 240 5.3% 26.1%;
|
| 154 |
+
--sidebar-primary: 240 5.9% 10%;
|
| 155 |
+
--sidebar-primary-foreground: 0 0% 98%;
|
| 156 |
+
--sidebar-accent: 240 4.8% 95.9%;
|
| 157 |
+
--sidebar-accent-foreground: 240 5.9% 10%;
|
| 158 |
+
--sidebar-border: 220 13% 91%;
|
| 159 |
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 160 |
+
}
|
| 161 |
+
.dark {
|
| 162 |
+
--background: 240 10% 3.9%;
|
| 163 |
+
--foreground: 0 0% 98%;
|
| 164 |
+
--card: 240 10% 3.9%;
|
| 165 |
+
--card-foreground: 0 0% 98%;
|
| 166 |
+
--popover: 240 10% 3.9%;
|
| 167 |
+
--popover-foreground: 0 0% 98%;
|
| 168 |
+
--primary: 0 0% 98%;
|
| 169 |
+
--primary-foreground: 240 5.9% 10%;
|
| 170 |
+
--secondary: 240 3.7% 15.9%;
|
| 171 |
+
--secondary-foreground: 0 0% 98%;
|
| 172 |
+
--muted: 240 3.7% 15.9%;
|
| 173 |
+
--muted-foreground: 240 5% 64.9%;
|
| 174 |
+
--accent: 240 3.7% 15.9%;
|
| 175 |
+
--accent-foreground: 0 0% 98%;
|
| 176 |
+
--destructive: 0 62.8% 30.6%;
|
| 177 |
+
--destructive-foreground: 0 0% 98%;
|
| 178 |
+
--border: 240 3.7% 15.9%;
|
| 179 |
+
--input: 240 3.7% 15.9%;
|
| 180 |
+
--ring: 240 4.9% 83.9%;
|
| 181 |
+
--chart-1: 220 70% 50%;
|
| 182 |
+
--chart-2: 160 60% 45%;
|
| 183 |
+
--chart-3: 30 80% 55%;
|
| 184 |
+
--chart-4: 280 65% 60%;
|
| 185 |
+
--chart-5: 340 75% 55%;
|
| 186 |
+
--sidebar-background: 240 5.9% 10%;
|
| 187 |
+
--sidebar-foreground: 240 4.8% 95.9%;
|
| 188 |
+
--sidebar-primary: 224.3 76.3% 48%;
|
| 189 |
+
--sidebar-primary-foreground: 0 0% 100%;
|
| 190 |
+
--sidebar-accent: 240 3.7% 15.9%;
|
| 191 |
+
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
| 192 |
+
--sidebar-border: 240 3.7% 15.9%;
|
| 193 |
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
@layer base {
|
| 198 |
+
* {
|
| 199 |
+
border-color: hsl(var(--border));
|
| 200 |
+
}
|
| 201 |
+
body {
|
| 202 |
+
background-color: hsl(var(--background));
|
| 203 |
+
color: hsl(var(--foreground));
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.bg-dot-pattern {
|
| 208 |
+
background-image: radial-gradient(
|
| 209 |
+
circle at center,
|
| 210 |
+
hsl(var(--foreground) / 0.1) 1px,
|
| 211 |
+
transparent 1px
|
| 212 |
+
);
|
| 213 |
+
background-size: 24px 24px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.bg-dot-pattern-dark {
|
| 217 |
+
background-image: radial-gradient(
|
| 218 |
+
circle at center,
|
| 219 |
+
hsl(var(--foreground) / 0.2) 1px,
|
| 220 |
+
transparent 1px
|
| 221 |
+
);
|
| 222 |
+
background-size: 24px 24px;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* 添加以下样式 */
|
| 226 |
+
@media (max-width: 640px) {
|
| 227 |
+
.toaster-group {
|
| 228 |
+
--viewport-padding: 16px;
|
| 229 |
+
right: var(--viewport-padding);
|
| 230 |
+
left: var(--viewport-padding);
|
| 231 |
+
width: calc(100% - var(--viewport-padding) * 2);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.toast {
|
| 235 |
+
--viewport-padding: 16px;
|
| 236 |
+
width: 100%;
|
| 237 |
+
border-radius: 12px;
|
| 238 |
+
margin-left: 0;
|
| 239 |
+
margin-right: 0;
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/* 自定义日期选择器样式 */
|
| 244 |
+
.custom-date-picker {
|
| 245 |
+
border-radius: 0.5rem;
|
| 246 |
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
| 247 |
+
border: 1px solid hsl(var(--border));
|
| 248 |
+
background-color: hsl(var(--background));
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.custom-date-picker .ant-picker-panel {
|
| 252 |
+
background-color: hsl(var(--background));
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.custom-date-picker
|
| 256 |
+
.ant-picker-cell-in-view.ant-picker-cell-selected
|
| 257 |
+
.ant-picker-cell-inner {
|
| 258 |
+
background-color: hsl(var(--primary));
|
| 259 |
+
color: hsl(var(--primary-foreground));
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.custom-date-picker
|
| 263 |
+
.ant-picker-cell-in-view.ant-picker-cell-today
|
| 264 |
+
.ant-picker-cell-inner::before {
|
| 265 |
+
border-color: hsl(var(--primary));
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.custom-date-picker
|
| 269 |
+
.ant-picker-time-panel-column
|
| 270 |
+
> li.ant-picker-time-panel-cell-selected
|
| 271 |
+
.ant-picker-time-panel-cell-inner {
|
| 272 |
+
background-color: hsl(var(--primary) / 0.1);
|
| 273 |
+
color: hsl(var(--primary));
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.custom-date-picker .ant-picker-ranges {
|
| 277 |
+
border-top: 1px solid hsl(var(--border));
|
| 278 |
+
}
|
app/icon.png
ADDED
|
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import localFont from "next/font/local";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import Header from "@/components/Header";
|
| 5 |
+
import AuthCheck from "@/components/AuthCheck";
|
| 6 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 7 |
+
import I18nProvider from "@/components/I18nProvider";
|
| 8 |
+
|
| 9 |
+
const geistSans = localFont({
|
| 10 |
+
src: "./fonts/GeistVF.woff",
|
| 11 |
+
variable: "--font-geist-sans",
|
| 12 |
+
weight: "100 900",
|
| 13 |
+
});
|
| 14 |
+
const geistMono = localFont({
|
| 15 |
+
src: "./fonts/GeistMonoVF.woff",
|
| 16 |
+
variable: "--font-geist-mono",
|
| 17 |
+
weight: "100 900",
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export const metadata: Metadata = {
|
| 21 |
+
title: "OpenWebUI Monitor",
|
| 22 |
+
description: "Monitor and analyze your OpenWebUI usage data",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export default function RootLayout({
|
| 26 |
+
children,
|
| 27 |
+
}: {
|
| 28 |
+
children: React.ReactNode;
|
| 29 |
+
}) {
|
| 30 |
+
return (
|
| 31 |
+
<html lang="zh-CN">
|
| 32 |
+
<body>
|
| 33 |
+
<div id="modal-root" className="relative z-[100]" />
|
| 34 |
+
<I18nProvider>
|
| 35 |
+
<AuthCheck>
|
| 36 |
+
<Header />
|
| 37 |
+
{children}
|
| 38 |
+
</AuthCheck>
|
| 39 |
+
<Toaster />
|
| 40 |
+
</I18nProvider>
|
| 41 |
+
</body>
|
| 42 |
+
</html>
|
| 43 |
+
);
|
| 44 |
+
}
|
app/models/page.tsx
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Table, message, Tooltip } from "antd";
|
| 5 |
+
import {
|
| 6 |
+
DownloadOutlined,
|
| 7 |
+
ExperimentOutlined,
|
| 8 |
+
CheckOutlined,
|
| 9 |
+
CloseOutlined,
|
| 10 |
+
InfoCircleOutlined,
|
| 11 |
+
UpOutlined,
|
| 12 |
+
} from "@ant-design/icons";
|
| 13 |
+
import type { ColumnsType } from "antd/es/table";
|
| 14 |
+
import Image from "next/image";
|
| 15 |
+
import { Button } from "@/components/ui/button";
|
| 16 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 17 |
+
import { useTranslation } from "react-i18next";
|
| 18 |
+
import { Progress } from "antd";
|
| 19 |
+
import { toast, Toaster } from "sonner";
|
| 20 |
+
import { EditableCell } from "@/components/editable-cell";
|
| 21 |
+
|
| 22 |
+
interface ModelResponse {
|
| 23 |
+
id: string;
|
| 24 |
+
name: string;
|
| 25 |
+
base_model_id: string;
|
| 26 |
+
system_prompt: string;
|
| 27 |
+
imageUrl: string;
|
| 28 |
+
input_price: number;
|
| 29 |
+
output_price: number;
|
| 30 |
+
per_msg_price: number;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface Model {
|
| 34 |
+
id: string;
|
| 35 |
+
name: string;
|
| 36 |
+
base_model_id: string;
|
| 37 |
+
system_prompt: string;
|
| 38 |
+
imageUrl: string;
|
| 39 |
+
input_price: number;
|
| 40 |
+
output_price: number;
|
| 41 |
+
per_msg_price: number;
|
| 42 |
+
testStatus?: "success" | "error" | "testing";
|
| 43 |
+
syncStatus?: "syncing" | "success" | "error";
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const TestStatusIndicator = ({ status }: { status: Model["testStatus"] }) => {
|
| 47 |
+
if (!status) return null;
|
| 48 |
+
|
| 49 |
+
const variants = {
|
| 50 |
+
testing: {
|
| 51 |
+
container: "bg-blue-100",
|
| 52 |
+
icon: "w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin",
|
| 53 |
+
},
|
| 54 |
+
success: {
|
| 55 |
+
container: "bg-green-100",
|
| 56 |
+
icon: "text-[10px] text-green-500",
|
| 57 |
+
},
|
| 58 |
+
error: {
|
| 59 |
+
container: "bg-red-100",
|
| 60 |
+
icon: "text-[10px] text-red-500",
|
| 61 |
+
},
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const variant = variants[status];
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<motion.div
|
| 68 |
+
initial={{ scale: 0 }}
|
| 69 |
+
animate={{ scale: 1 }}
|
| 70 |
+
className={`w-4 h-4 rounded-full ${variant.container} flex items-center justify-center`}
|
| 71 |
+
>
|
| 72 |
+
{status === "testing" ? (
|
| 73 |
+
<div className={variant.icon} />
|
| 74 |
+
) : status === "success" ? (
|
| 75 |
+
<CheckOutlined className={variant.icon} />
|
| 76 |
+
) : (
|
| 77 |
+
<CloseOutlined className={variant.icon} />
|
| 78 |
+
)}
|
| 79 |
+
</motion.div>
|
| 80 |
+
);
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const TestProgressPanel = ({
|
| 84 |
+
isVisible,
|
| 85 |
+
models,
|
| 86 |
+
isComplete,
|
| 87 |
+
t,
|
| 88 |
+
}: {
|
| 89 |
+
isVisible: boolean;
|
| 90 |
+
models: Model[];
|
| 91 |
+
isComplete: boolean;
|
| 92 |
+
t: (key: string) => string;
|
| 93 |
+
}) => {
|
| 94 |
+
const [isExpanded, setIsExpanded] = useState(true);
|
| 95 |
+
const successCount = models.filter((m) => m.testStatus === "success").length;
|
| 96 |
+
const errorCount = models.filter((m) => m.testStatus === "error").length;
|
| 97 |
+
const testingCount = models.filter((m) => m.testStatus === "testing").length;
|
| 98 |
+
const totalCount = models.length;
|
| 99 |
+
const progress = Math.round(((successCount + errorCount) / totalCount) * 100);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
if (testingCount > 0) {
|
| 103 |
+
setIsExpanded(true);
|
| 104 |
+
}
|
| 105 |
+
}, [testingCount]);
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<AnimatePresence>
|
| 109 |
+
{isVisible && (
|
| 110 |
+
<motion.div
|
| 111 |
+
initial={{ opacity: 0, y: 20 }}
|
| 112 |
+
animate={{ opacity: 1, y: 0 }}
|
| 113 |
+
exit={{ opacity: 0, y: -20 }}
|
| 114 |
+
className="rounded-xl bg-card border shadow-sm overflow-hidden"
|
| 115 |
+
>
|
| 116 |
+
<div className="p-6 space-y-6">
|
| 117 |
+
<div
|
| 118 |
+
className="flex items-center justify-between cursor-pointer"
|
| 119 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 120 |
+
>
|
| 121 |
+
<div className="flex items-center gap-3">
|
| 122 |
+
<h3 className="text-lg font-semibold">
|
| 123 |
+
{isComplete
|
| 124 |
+
? t("models.testComplete")
|
| 125 |
+
: t("models.testingModels")}
|
| 126 |
+
</h3>
|
| 127 |
+
<TestStatusIndicator
|
| 128 |
+
status={
|
| 129 |
+
testingCount > 0
|
| 130 |
+
? "testing"
|
| 131 |
+
: isComplete
|
| 132 |
+
? "success"
|
| 133 |
+
: "error"
|
| 134 |
+
}
|
| 135 |
+
/>
|
| 136 |
+
</div>
|
| 137 |
+
<motion.div
|
| 138 |
+
animate={{ rotate: isExpanded ? 180 : 0 }}
|
| 139 |
+
transition={{ duration: 0.2 }}
|
| 140 |
+
>
|
| 141 |
+
<UpOutlined className="text-lg text-muted-foreground" />
|
| 142 |
+
</motion.div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<AnimatePresence initial={false}>
|
| 146 |
+
{isExpanded && (
|
| 147 |
+
<motion.div
|
| 148 |
+
initial={{ height: 0, opacity: 0 }}
|
| 149 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 150 |
+
exit={{ height: 0, opacity: 0 }}
|
| 151 |
+
transition={{ duration: 0.2 }}
|
| 152 |
+
className="space-y-6 overflow-hidden"
|
| 153 |
+
>
|
| 154 |
+
<div className="space-y-4">
|
| 155 |
+
<Progress
|
| 156 |
+
percent={progress}
|
| 157 |
+
strokeColor={{
|
| 158 |
+
"0%": "#4F46E5",
|
| 159 |
+
"100%": "#10B981",
|
| 160 |
+
}}
|
| 161 |
+
trailColor="#E5E7EB"
|
| 162 |
+
className="!m-0"
|
| 163 |
+
/>
|
| 164 |
+
|
| 165 |
+
<div className="grid grid-cols-3 gap-4">
|
| 166 |
+
<div className="space-y-1">
|
| 167 |
+
<div className="text-2xl font-semibold text-green-500">
|
| 168 |
+
{successCount}
|
| 169 |
+
</div>
|
| 170 |
+
<div className="text-sm text-muted-foreground">
|
| 171 |
+
{t("models.testSuccess")}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
<div className="space-y-1">
|
| 175 |
+
<div className="text-2xl font-semibold text-red-500">
|
| 176 |
+
{errorCount}
|
| 177 |
+
</div>
|
| 178 |
+
<div className="text-sm text-muted-foreground">
|
| 179 |
+
{t("models.testFailed")}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div className="space-y-1">
|
| 183 |
+
<div className="text-2xl font-semibold text-blue-500">
|
| 184 |
+
{testingCount}
|
| 185 |
+
</div>
|
| 186 |
+
<div className="text-sm text-muted-foreground">
|
| 187 |
+
{t("models.testing")}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<motion.div
|
| 194 |
+
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3"
|
| 195 |
+
initial="hidden"
|
| 196 |
+
animate="visible"
|
| 197 |
+
variants={{
|
| 198 |
+
visible: {
|
| 199 |
+
transition: {
|
| 200 |
+
staggerChildren: 0.05,
|
| 201 |
+
},
|
| 202 |
+
},
|
| 203 |
+
}}
|
| 204 |
+
>
|
| 205 |
+
{models.map((model) => (
|
| 206 |
+
<motion.div
|
| 207 |
+
key={model.id}
|
| 208 |
+
variants={{
|
| 209 |
+
hidden: { opacity: 0, y: 20 },
|
| 210 |
+
visible: { opacity: 1, y: 0 },
|
| 211 |
+
}}
|
| 212 |
+
className="flex items-center gap-2 p-2 rounded-lg bg-muted/50"
|
| 213 |
+
>
|
| 214 |
+
<Image
|
| 215 |
+
src={model.imageUrl}
|
| 216 |
+
alt={model.name}
|
| 217 |
+
width={24}
|
| 218 |
+
height={24}
|
| 219 |
+
className="rounded-full"
|
| 220 |
+
/>
|
| 221 |
+
<div className="flex-1 min-w-0">
|
| 222 |
+
<div className="text-sm font-medium truncate">
|
| 223 |
+
{model.name}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
<TestStatusIndicator status={model.testStatus} />
|
| 227 |
+
</motion.div>
|
| 228 |
+
))}
|
| 229 |
+
</motion.div>
|
| 230 |
+
</motion.div>
|
| 231 |
+
)}
|
| 232 |
+
</AnimatePresence>
|
| 233 |
+
</div>
|
| 234 |
+
</motion.div>
|
| 235 |
+
)}
|
| 236 |
+
</AnimatePresence>
|
| 237 |
+
);
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
const LoadingState = ({ t }: { t: (key: string) => string }) => (
|
| 241 |
+
<div className="flex flex-col items-center justify-center py-12 px-4">
|
| 242 |
+
<div className="h-12 w-12 rounded-full border-4 border-primary/10 border-t-primary animate-spin mb-4" />
|
| 243 |
+
<h3 className="text-lg font-medium text-foreground/70">
|
| 244 |
+
{t("models.loading")}
|
| 245 |
+
</h3>
|
| 246 |
+
</div>
|
| 247 |
+
);
|
| 248 |
+
|
| 249 |
+
export default function ModelsPage() {
|
| 250 |
+
const { t } = useTranslation("common");
|
| 251 |
+
const [models, setModels] = useState<Model[]>([]);
|
| 252 |
+
const [loading, setLoading] = useState(true);
|
| 253 |
+
const [error, setError] = useState<string | null>(null);
|
| 254 |
+
const [editingCell, setEditingCell] = useState<{
|
| 255 |
+
id: string;
|
| 256 |
+
field: "input_price" | "output_price" | "per_msg_price";
|
| 257 |
+
} | null>(null);
|
| 258 |
+
const [testing, setTesting] = useState(false);
|
| 259 |
+
const [apiKey, setApiKey] = useState<string | null>(null);
|
| 260 |
+
const [isTestComplete, setIsTestComplete] = useState(false);
|
| 261 |
+
const [syncing, setSyncing] = useState(false);
|
| 262 |
+
|
| 263 |
+
useEffect(() => {
|
| 264 |
+
const fetchModels = async () => {
|
| 265 |
+
try {
|
| 266 |
+
const response = await fetch("/api/v1/models");
|
| 267 |
+
if (!response.ok) {
|
| 268 |
+
throw new Error(t("error.model.failToFetchModels"));
|
| 269 |
+
}
|
| 270 |
+
const data = (await response.json()) as ModelResponse[];
|
| 271 |
+
setModels(
|
| 272 |
+
data.map((model: ModelResponse) => ({
|
| 273 |
+
...model,
|
| 274 |
+
input_price: model.input_price ?? 60,
|
| 275 |
+
output_price: model.output_price ?? 60,
|
| 276 |
+
per_msg_price: model.per_msg_price ?? -1,
|
| 277 |
+
}))
|
| 278 |
+
);
|
| 279 |
+
} catch (err) {
|
| 280 |
+
setError(
|
| 281 |
+
err instanceof Error ? err.message : t("error.model.unknownError")
|
| 282 |
+
);
|
| 283 |
+
} finally {
|
| 284 |
+
setLoading(false);
|
| 285 |
+
}
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
fetchModels();
|
| 289 |
+
}, []);
|
| 290 |
+
|
| 291 |
+
useEffect(() => {
|
| 292 |
+
const fetchApiKey = async () => {
|
| 293 |
+
try {
|
| 294 |
+
const response = await fetch("/api/config/key");
|
| 295 |
+
if (!response.ok) {
|
| 296 |
+
throw new Error(
|
| 297 |
+
`${t("error.model.failToFetchApiKey")}: ${response.status}`
|
| 298 |
+
);
|
| 299 |
+
}
|
| 300 |
+
const data = await response.json();
|
| 301 |
+
if (!data.apiKey) {
|
| 302 |
+
throw new Error(t("error.model.ApiKeyNotConfigured"));
|
| 303 |
+
}
|
| 304 |
+
setApiKey(data.apiKey);
|
| 305 |
+
} catch (error) {
|
| 306 |
+
console.error(t("error.model.failToFetchApiKey"), error);
|
| 307 |
+
message.error(
|
| 308 |
+
error instanceof Error
|
| 309 |
+
? error.message
|
| 310 |
+
: t("error.model.failToFetchApiKey")
|
| 311 |
+
);
|
| 312 |
+
}
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
fetchApiKey();
|
| 316 |
+
}, []);
|
| 317 |
+
|
| 318 |
+
const handlePriceUpdate = async (
|
| 319 |
+
id: string,
|
| 320 |
+
field: "input_price" | "output_price" | "per_msg_price",
|
| 321 |
+
value: number
|
| 322 |
+
): Promise<void> => {
|
| 323 |
+
try {
|
| 324 |
+
const model = models.find((m) => m.id === id);
|
| 325 |
+
if (!model) return;
|
| 326 |
+
|
| 327 |
+
const validValue = Number(value);
|
| 328 |
+
if (
|
| 329 |
+
field !== "per_msg_price" &&
|
| 330 |
+
(!isFinite(validValue) || validValue < 0)
|
| 331 |
+
) {
|
| 332 |
+
throw new Error(t("error.model.nonePositiveNumber"));
|
| 333 |
+
}
|
| 334 |
+
if (field === "per_msg_price" && !isFinite(validValue)) {
|
| 335 |
+
throw new Error(t("error.model.invalidNumber"));
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
const input_price =
|
| 339 |
+
field === "input_price" ? validValue : model.input_price;
|
| 340 |
+
const output_price =
|
| 341 |
+
field === "output_price" ? validValue : model.output_price;
|
| 342 |
+
const per_msg_price =
|
| 343 |
+
field === "per_msg_price" ? validValue : model.per_msg_price;
|
| 344 |
+
|
| 345 |
+
const response = await fetch("/api/v1/models/price", {
|
| 346 |
+
method: "POST",
|
| 347 |
+
headers: { "Content-Type": "application/json" },
|
| 348 |
+
body: JSON.stringify({
|
| 349 |
+
updates: [
|
| 350 |
+
{
|
| 351 |
+
id,
|
| 352 |
+
input_price: Number(input_price),
|
| 353 |
+
output_price: Number(output_price),
|
| 354 |
+
per_msg_price: Number(per_msg_price),
|
| 355 |
+
},
|
| 356 |
+
],
|
| 357 |
+
}),
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
const data = await response.json();
|
| 361 |
+
if (!response.ok)
|
| 362 |
+
throw new Error(data.error || t("error.model.priceUpdateFail"));
|
| 363 |
+
|
| 364 |
+
if (data.results && data.results[0]?.success) {
|
| 365 |
+
setModels((prevModels) =>
|
| 366 |
+
prevModels.map((model) =>
|
| 367 |
+
model.id === id
|
| 368 |
+
? {
|
| 369 |
+
...model,
|
| 370 |
+
input_price: Number(data.results[0].data.input_price),
|
| 371 |
+
output_price: Number(data.results[0].data.output_price),
|
| 372 |
+
per_msg_price: Number(data.results[0].data.per_msg_price),
|
| 373 |
+
}
|
| 374 |
+
: model
|
| 375 |
+
)
|
| 376 |
+
);
|
| 377 |
+
toast.success(t("error.model.priceUpdateSuccess"));
|
| 378 |
+
} else {
|
| 379 |
+
throw new Error(
|
| 380 |
+
data.results[0]?.error || t("error.model.priceUpdateFail")
|
| 381 |
+
);
|
| 382 |
+
}
|
| 383 |
+
} catch (err) {
|
| 384 |
+
toast.error(
|
| 385 |
+
err instanceof Error ? err.message : t("error.model.priceUpdateFail")
|
| 386 |
+
);
|
| 387 |
+
throw err;
|
| 388 |
+
}
|
| 389 |
+
};
|
| 390 |
+
|
| 391 |
+
const handleTestSingleModel = async (model: Model) => {
|
| 392 |
+
try {
|
| 393 |
+
setModels((prev) =>
|
| 394 |
+
prev.map((m) =>
|
| 395 |
+
m.id === model.id ? { ...m, testStatus: "testing" } : m
|
| 396 |
+
)
|
| 397 |
+
);
|
| 398 |
+
|
| 399 |
+
const result = await testModel(model);
|
| 400 |
+
|
| 401 |
+
setModels((prev) =>
|
| 402 |
+
prev.map((m) =>
|
| 403 |
+
m.id === model.id
|
| 404 |
+
? { ...m, testStatus: result.success ? "success" : "error" }
|
| 405 |
+
: m
|
| 406 |
+
)
|
| 407 |
+
);
|
| 408 |
+
} catch (error) {
|
| 409 |
+
setModels((prev) =>
|
| 410 |
+
prev.map((m) => (m.id === model.id ? { ...m, testStatus: "error" } : m))
|
| 411 |
+
);
|
| 412 |
+
}
|
| 413 |
+
};
|
| 414 |
+
|
| 415 |
+
const handleSyncAllDerivedModels = async () => {
|
| 416 |
+
try {
|
| 417 |
+
setSyncing(true);
|
| 418 |
+
|
| 419 |
+
const response = await fetch("/api/v1/models/sync-all-prices", {
|
| 420 |
+
method: "POST",
|
| 421 |
+
headers: {
|
| 422 |
+
"Content-Type": "application/json",
|
| 423 |
+
},
|
| 424 |
+
});
|
| 425 |
+
|
| 426 |
+
const data = await response.json();
|
| 427 |
+
|
| 428 |
+
if (!response.ok) {
|
| 429 |
+
throw new Error(data.error || t("models.syncFail"));
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
// 更新模型数据
|
| 433 |
+
if (data.syncedModels && data.syncedModels.length > 0) {
|
| 434 |
+
setModels((prev) =>
|
| 435 |
+
prev.map((model) => {
|
| 436 |
+
const syncedModel = data.syncedModels.find(
|
| 437 |
+
(m: any) => m.id === model.id && m.success
|
| 438 |
+
);
|
| 439 |
+
if (syncedModel) {
|
| 440 |
+
return {
|
| 441 |
+
...model,
|
| 442 |
+
input_price: syncedModel.input_price,
|
| 443 |
+
output_price: syncedModel.output_price,
|
| 444 |
+
per_msg_price: syncedModel.per_msg_price,
|
| 445 |
+
};
|
| 446 |
+
}
|
| 447 |
+
return model;
|
| 448 |
+
})
|
| 449 |
+
);
|
| 450 |
+
|
| 451 |
+
if (data.syncedModels.every((m: any) => m.success)) {
|
| 452 |
+
toast.success(t("models.syncAllSuccess"));
|
| 453 |
+
} else {
|
| 454 |
+
toast.warning(t("models.syncAllFail"));
|
| 455 |
+
}
|
| 456 |
+
} else {
|
| 457 |
+
toast.info(t("models.noDerivedModels"));
|
| 458 |
+
}
|
| 459 |
+
} catch (error) {
|
| 460 |
+
console.error("Sync all derived models failed:", error);
|
| 461 |
+
toast.error(
|
| 462 |
+
error instanceof Error ? error.message : t("models.syncFail")
|
| 463 |
+
);
|
| 464 |
+
} finally {
|
| 465 |
+
setSyncing(false);
|
| 466 |
+
}
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
const columns: ColumnsType<Model> = [
|
| 470 |
+
{
|
| 471 |
+
title: t("models.table.name"),
|
| 472 |
+
key: "model",
|
| 473 |
+
width: 200,
|
| 474 |
+
render: (_, record) => (
|
| 475 |
+
<div className="flex items-center gap-3 relative">
|
| 476 |
+
<div
|
| 477 |
+
className="relative cursor-pointer"
|
| 478 |
+
onClick={() => handleTestSingleModel(record)}
|
| 479 |
+
>
|
| 480 |
+
{record.imageUrl && (
|
| 481 |
+
<Image
|
| 482 |
+
src={record.imageUrl}
|
| 483 |
+
alt={record.name}
|
| 484 |
+
width={32}
|
| 485 |
+
height={32}
|
| 486 |
+
className="rounded-full object-cover"
|
| 487 |
+
/>
|
| 488 |
+
)}
|
| 489 |
+
{record.testStatus && (
|
| 490 |
+
<div className="absolute -top-1 -right-1">
|
| 491 |
+
{record.testStatus === "testing" && (
|
| 492 |
+
<div className="w-4 h-4 rounded-full bg-blue-100 flex items-center justify-center">
|
| 493 |
+
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
|
| 494 |
+
</div>
|
| 495 |
+
)}
|
| 496 |
+
{record.testStatus === "success" && (
|
| 497 |
+
<div className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center">
|
| 498 |
+
<CheckOutlined className="text-[10px] text-green-500" />
|
| 499 |
+
</div>
|
| 500 |
+
)}
|
| 501 |
+
{record.testStatus === "error" && (
|
| 502 |
+
<div className="w-4 h-4 rounded-full bg-red-100 flex items-center justify-center">
|
| 503 |
+
<CloseOutlined className="text-[10px] text-red-500" />
|
| 504 |
+
</div>
|
| 505 |
+
)}
|
| 506 |
+
</div>
|
| 507 |
+
)}
|
| 508 |
+
</div>
|
| 509 |
+
<div className="font-medium min-w-0 flex-1">
|
| 510 |
+
<div className="truncate">{record.name}</div>
|
| 511 |
+
<div className="text-xs text-gray-500 truncate opacity-60">
|
| 512 |
+
{record.id}
|
| 513 |
+
</div>
|
| 514 |
+
</div>
|
| 515 |
+
</div>
|
| 516 |
+
),
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
title: t("models.table.inputPrice"),
|
| 520 |
+
key: "input_price",
|
| 521 |
+
width: 150,
|
| 522 |
+
dataIndex: "input_price",
|
| 523 |
+
sorter: (a, b) => a.input_price - b.input_price,
|
| 524 |
+
sortDirections: ["descend", "ascend", "descend"],
|
| 525 |
+
render: (_, record) => renderPriceCell("input_price", record, true),
|
| 526 |
+
},
|
| 527 |
+
{
|
| 528 |
+
title: t("models.table.outputPrice"),
|
| 529 |
+
key: "output_price",
|
| 530 |
+
width: 150,
|
| 531 |
+
dataIndex: "output_price",
|
| 532 |
+
sorter: (a, b) => a.output_price - b.output_price,
|
| 533 |
+
sortDirections: ["descend", "ascend", "descend"],
|
| 534 |
+
render: (_, record) => renderPriceCell("output_price", record, true),
|
| 535 |
+
},
|
| 536 |
+
{
|
| 537 |
+
title: (
|
| 538 |
+
<span>
|
| 539 |
+
{t("models.table.perMsgPrice")}{" "}
|
| 540 |
+
<Tooltip title={t("models.table.perMsgPriceTooltip")}>
|
| 541 |
+
<InfoCircleOutlined className="text-gray-400 cursor-help" />
|
| 542 |
+
</Tooltip>
|
| 543 |
+
</span>
|
| 544 |
+
),
|
| 545 |
+
key: "per_msg_price",
|
| 546 |
+
width: 150,
|
| 547 |
+
dataIndex: "per_msg_price",
|
| 548 |
+
sorter: (a, b) => a.per_msg_price - b.per_msg_price,
|
| 549 |
+
sortDirections: ["descend", "ascend", "descend"],
|
| 550 |
+
render: (_, record) => renderPriceCell("per_msg_price", record, true),
|
| 551 |
+
},
|
| 552 |
+
];
|
| 553 |
+
|
| 554 |
+
const handleExportPrices = () => {
|
| 555 |
+
const priceData = models.map((model) => ({
|
| 556 |
+
id: model.id,
|
| 557 |
+
input_price: model.input_price,
|
| 558 |
+
output_price: model.output_price,
|
| 559 |
+
per_msg_price: model.per_msg_price,
|
| 560 |
+
}));
|
| 561 |
+
|
| 562 |
+
const blob = new Blob([JSON.stringify(priceData, null, 2)], {
|
| 563 |
+
type: "application/json",
|
| 564 |
+
});
|
| 565 |
+
const url = URL.createObjectURL(blob);
|
| 566 |
+
const a = document.createElement("a");
|
| 567 |
+
a.href = url;
|
| 568 |
+
a.download = `model_prices_${new Date().toISOString().split("T")[0]}.json`;
|
| 569 |
+
document.body.appendChild(a);
|
| 570 |
+
a.click();
|
| 571 |
+
document.body.removeChild(a);
|
| 572 |
+
URL.revokeObjectURL(url);
|
| 573 |
+
};
|
| 574 |
+
|
| 575 |
+
const handleImportPrices = (file: File) => {
|
| 576 |
+
const reader = new FileReader();
|
| 577 |
+
reader.onload = async (e) => {
|
| 578 |
+
try {
|
| 579 |
+
const importedData = JSON.parse(e.target?.result as string);
|
| 580 |
+
|
| 581 |
+
if (!Array.isArray(importedData)) {
|
| 582 |
+
throw new Error(t("error.model.invalidImportFormat"));
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
const validUpdates = importedData.filter((item) =>
|
| 586 |
+
models.some((model) => model.id === item.id)
|
| 587 |
+
);
|
| 588 |
+
|
| 589 |
+
const response = await fetch("/api/v1/models/price", {
|
| 590 |
+
method: "POST",
|
| 591 |
+
headers: { "Content-Type": "application/json" },
|
| 592 |
+
body: JSON.stringify({
|
| 593 |
+
updates: validUpdates,
|
| 594 |
+
}),
|
| 595 |
+
});
|
| 596 |
+
|
| 597 |
+
if (!response.ok) {
|
| 598 |
+
throw new Error(t("error.model.batchPriceUpdateFail"));
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
const data = await response.json();
|
| 602 |
+
console.log(t("error.model.serverResponse"), data);
|
| 603 |
+
|
| 604 |
+
if (data.results) {
|
| 605 |
+
setModels((prevModels) =>
|
| 606 |
+
prevModels.map((model) => {
|
| 607 |
+
const update = data.results.find(
|
| 608 |
+
(r: any) => r.id === model.id && r.success && r.data
|
| 609 |
+
);
|
| 610 |
+
if (update) {
|
| 611 |
+
return {
|
| 612 |
+
...model,
|
| 613 |
+
input_price: Number(update.data.input_price),
|
| 614 |
+
output_price: Number(update.data.output_price),
|
| 615 |
+
per_msg_price: Number(update.data.per_msg_price),
|
| 616 |
+
};
|
| 617 |
+
}
|
| 618 |
+
return model;
|
| 619 |
+
})
|
| 620 |
+
);
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
message.success(
|
| 624 |
+
`${t("error.model.updateSuccess")} ${
|
| 625 |
+
data.results.filter((r: any) => r.success).length
|
| 626 |
+
} ${t("error.model.numberOfModelPrice")}`
|
| 627 |
+
);
|
| 628 |
+
} catch (err) {
|
| 629 |
+
console.error(t("error.model.failToImport"), err);
|
| 630 |
+
message.error(
|
| 631 |
+
err instanceof Error ? err.message : t("error.model.failToImport")
|
| 632 |
+
);
|
| 633 |
+
}
|
| 634 |
+
};
|
| 635 |
+
reader.readAsText(file);
|
| 636 |
+
return false;
|
| 637 |
+
};
|
| 638 |
+
|
| 639 |
+
const testModel = async (
|
| 640 |
+
model: Model
|
| 641 |
+
): Promise<{
|
| 642 |
+
id: string;
|
| 643 |
+
success: boolean;
|
| 644 |
+
error?: string;
|
| 645 |
+
}> => {
|
| 646 |
+
if (!apiKey) {
|
| 647 |
+
return {
|
| 648 |
+
id: model.id,
|
| 649 |
+
success: false,
|
| 650 |
+
error: t("error.model.ApiKeyNotFetched"),
|
| 651 |
+
};
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
const controller = new AbortController();
|
| 655 |
+
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
| 656 |
+
|
| 657 |
+
try {
|
| 658 |
+
const response = await fetch("/api/v1/models/test", {
|
| 659 |
+
method: "POST",
|
| 660 |
+
headers: {
|
| 661 |
+
"Content-Type": "application/json",
|
| 662 |
+
Authorization: `Bearer ${apiKey}`,
|
| 663 |
+
},
|
| 664 |
+
body: JSON.stringify({
|
| 665 |
+
modelId: model.id,
|
| 666 |
+
}),
|
| 667 |
+
signal: controller.signal,
|
| 668 |
+
});
|
| 669 |
+
|
| 670 |
+
clearTimeout(timeoutId);
|
| 671 |
+
const data = await response.json();
|
| 672 |
+
|
| 673 |
+
if (!response.ok || !data.success) {
|
| 674 |
+
throw new Error(data.error || t("error.model.failToTest"));
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
return {
|
| 678 |
+
id: model.id,
|
| 679 |
+
success: true,
|
| 680 |
+
};
|
| 681 |
+
} catch (error) {
|
| 682 |
+
clearTimeout(timeoutId);
|
| 683 |
+
return {
|
| 684 |
+
id: model.id,
|
| 685 |
+
success: false,
|
| 686 |
+
error:
|
| 687 |
+
error instanceof Error
|
| 688 |
+
? error.message
|
| 689 |
+
: t("error.model.unknownError"),
|
| 690 |
+
};
|
| 691 |
+
}
|
| 692 |
+
};
|
| 693 |
+
|
| 694 |
+
const handleTestModels = async () => {
|
| 695 |
+
if (!apiKey) {
|
| 696 |
+
message.error(t("error.model.failToTestWithoutApiKey"));
|
| 697 |
+
return;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
try {
|
| 701 |
+
setModels((prev) => prev.map((m) => ({ ...m, testStatus: "testing" })));
|
| 702 |
+
setTesting(true);
|
| 703 |
+
setIsTestComplete(false);
|
| 704 |
+
|
| 705 |
+
const testPromises = models.map((model) =>
|
| 706 |
+
testModel(model).then((result) => {
|
| 707 |
+
setModels((prev) =>
|
| 708 |
+
prev.map((m) =>
|
| 709 |
+
m.id === model.id
|
| 710 |
+
? { ...m, testStatus: result.success ? "success" : "error" }
|
| 711 |
+
: m
|
| 712 |
+
)
|
| 713 |
+
);
|
| 714 |
+
return result;
|
| 715 |
+
})
|
| 716 |
+
);
|
| 717 |
+
|
| 718 |
+
await Promise.all(testPromises);
|
| 719 |
+
setIsTestComplete(true);
|
| 720 |
+
} catch (error) {
|
| 721 |
+
console.error(t("error.model.failToTest"), error);
|
| 722 |
+
message.error(t("error.model.failToTest"));
|
| 723 |
+
} finally {
|
| 724 |
+
setTesting(false);
|
| 725 |
+
}
|
| 726 |
+
};
|
| 727 |
+
|
| 728 |
+
// 修改表格样式
|
| 729 |
+
const tableClassName = `
|
| 730 |
+
[&_.ant-table]:!border-b-0
|
| 731 |
+
[&_.ant-table-container]:!rounded-xl
|
| 732 |
+
[&_.ant-table-container]:!border-hidden
|
| 733 |
+
[&_.ant-table-cell]:!border-border/40
|
| 734 |
+
[&_.ant-table-thead_.ant-table-cell]:!bg-muted/30
|
| 735 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-muted-foreground
|
| 736 |
+
[&_.ant-table-thead_.ant-table-cell]:!font-medium
|
| 737 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-sm
|
| 738 |
+
[&_.ant-table-thead]:!border-b
|
| 739 |
+
[&_.ant-table-thead]:border-border/40
|
| 740 |
+
[&_.ant-table-row]:!transition-colors
|
| 741 |
+
[&_.ant-table-row:hover>*]:!bg-muted/60
|
| 742 |
+
[&_.ant-table-tbody_.ant-table-row]:!cursor-pointer
|
| 743 |
+
[&_.ant-table-tbody_.ant-table-cell]:!py-4
|
| 744 |
+
[&_.ant-table-row:last-child>td]:!border-b-0
|
| 745 |
+
[&_.ant-table-cell:first-child]:!pl-6
|
| 746 |
+
[&_.ant-table-cell:last-child]:!pr-6
|
| 747 |
+
`;
|
| 748 |
+
|
| 749 |
+
// 修改移动端卡片组件
|
| 750 |
+
const MobileCard = ({ record }: { record: Model }) => {
|
| 751 |
+
const isPerMsgEnabled = record.per_msg_price >= 0;
|
| 752 |
+
|
| 753 |
+
return (
|
| 754 |
+
<div
|
| 755 |
+
className="p-4 sm:p-6 bg-card rounded-xl border border-border/40
|
| 756 |
+
shadow-sm hover:shadow-md transition-all duration-200 space-y-4"
|
| 757 |
+
>
|
| 758 |
+
<div className="flex items-center gap-3">
|
| 759 |
+
<div
|
| 760 |
+
className="relative cursor-pointer group shrink-0"
|
| 761 |
+
onClick={() => handleTestSingleModel(record)}
|
| 762 |
+
>
|
| 763 |
+
<div className="relative">
|
| 764 |
+
{record.imageUrl && (
|
| 765 |
+
<Image
|
| 766 |
+
src={record.imageUrl}
|
| 767 |
+
alt={record.name}
|
| 768 |
+
width={40}
|
| 769 |
+
height={40}
|
| 770 |
+
className="rounded-xl object-cover transition-transform group-hover:scale-105"
|
| 771 |
+
/>
|
| 772 |
+
)}
|
| 773 |
+
<div className="absolute inset-0 rounded-xl ring-1 ring-inset ring-black/5"></div>
|
| 774 |
+
</div>
|
| 775 |
+
{record.testStatus && (
|
| 776 |
+
<div className="absolute -top-1 -right-1 z-10">
|
| 777 |
+
<TestStatusIndicator status={record.testStatus} />
|
| 778 |
+
</div>
|
| 779 |
+
)}
|
| 780 |
+
</div>
|
| 781 |
+
<div className="flex-1 min-w-0">
|
| 782 |
+
<h3 className="text-base font-semibold tracking-tight truncate">
|
| 783 |
+
{record.name}
|
| 784 |
+
</h3>
|
| 785 |
+
<p className="text-xs text-muted-foreground/80 truncate font-mono">
|
| 786 |
+
{record.id}
|
| 787 |
+
</p>
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
|
| 791 |
+
<div className="grid grid-cols-3 gap-2 sm:gap-4">
|
| 792 |
+
{[
|
| 793 |
+
{
|
| 794 |
+
label: t("models.table.mobile.inputPrice"),
|
| 795 |
+
field: "input_price" as const,
|
| 796 |
+
disabled: isPerMsgEnabled,
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
label: t("models.table.mobile.outputPrice"),
|
| 800 |
+
field: "output_price" as const,
|
| 801 |
+
disabled: isPerMsgEnabled,
|
| 802 |
+
},
|
| 803 |
+
{
|
| 804 |
+
label: t("models.table.mobile.perMsgPrice"),
|
| 805 |
+
field: "per_msg_price" as const,
|
| 806 |
+
disabled: false,
|
| 807 |
+
},
|
| 808 |
+
].map(({ label, field, disabled }) => (
|
| 809 |
+
<div
|
| 810 |
+
key={field}
|
| 811 |
+
className={`space-y-1.5 ${disabled ? "opacity-50" : ""}`}
|
| 812 |
+
>
|
| 813 |
+
<span className="text-xs text-muted-foreground/80 block truncate">
|
| 814 |
+
{label}
|
| 815 |
+
</span>
|
| 816 |
+
{renderPriceCell(field, record, false)}
|
| 817 |
+
</div>
|
| 818 |
+
))}
|
| 819 |
+
</div>
|
| 820 |
+
</div>
|
| 821 |
+
);
|
| 822 |
+
};
|
| 823 |
+
|
| 824 |
+
// 将 renderPriceCell 修改为接收一个额外的 showTooltip 参数
|
| 825 |
+
const renderPriceCell = (
|
| 826 |
+
field: "input_price" | "output_price" | "per_msg_price",
|
| 827 |
+
record: Model,
|
| 828 |
+
showTooltip: boolean = true
|
| 829 |
+
) => {
|
| 830 |
+
const isEditing =
|
| 831 |
+
editingCell?.id === record.id && editingCell?.field === field;
|
| 832 |
+
const currentValue = Number(record[field]);
|
| 833 |
+
const isDisabled = field !== "per_msg_price" && record.per_msg_price >= 0;
|
| 834 |
+
|
| 835 |
+
return (
|
| 836 |
+
<EditableCell
|
| 837 |
+
value={currentValue}
|
| 838 |
+
isEditing={isEditing}
|
| 839 |
+
onEdit={() => setEditingCell({ id: record.id, field })}
|
| 840 |
+
onSubmit={async (value) => {
|
| 841 |
+
try {
|
| 842 |
+
await handlePriceUpdate(record.id, field, value);
|
| 843 |
+
setEditingCell(null);
|
| 844 |
+
} catch {
|
| 845 |
+
// 错误已经在 handlePriceUpdate 中处理
|
| 846 |
+
}
|
| 847 |
+
}}
|
| 848 |
+
t={t}
|
| 849 |
+
disabled={isDisabled}
|
| 850 |
+
onCancel={() => setEditingCell(null)}
|
| 851 |
+
tooltipText={
|
| 852 |
+
showTooltip && isDisabled
|
| 853 |
+
? t("models.table.priceOverriddenByPerMsg")
|
| 854 |
+
: undefined
|
| 855 |
+
}
|
| 856 |
+
placeholder={t("models.table.enterPrice")}
|
| 857 |
+
validateValue={(value) => ({
|
| 858 |
+
isValid:
|
| 859 |
+
field === "per_msg_price"
|
| 860 |
+
? isFinite(value)
|
| 861 |
+
: isFinite(value) && value >= 0,
|
| 862 |
+
errorMessage:
|
| 863 |
+
field === "per_msg_price"
|
| 864 |
+
? t("models.table.invalidNumber")
|
| 865 |
+
: t("models.table.nonePositiveNumber"),
|
| 866 |
+
})}
|
| 867 |
+
isPerMsgPrice={field === "per_msg_price"}
|
| 868 |
+
/>
|
| 869 |
+
);
|
| 870 |
+
};
|
| 871 |
+
|
| 872 |
+
if (error) {
|
| 873 |
+
return <div className="p-4 text-red-500">错误: {error}</div>;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
return (
|
| 877 |
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 space-y-8">
|
| 878 |
+
{/* 添加 Toaster 组件 */}
|
| 879 |
+
<Toaster
|
| 880 |
+
richColors
|
| 881 |
+
position="top-center"
|
| 882 |
+
theme="light"
|
| 883 |
+
expand
|
| 884 |
+
duration={1500}
|
| 885 |
+
/>
|
| 886 |
+
|
| 887 |
+
{/* 页面标题部分 */}
|
| 888 |
+
<div className="space-y-4">
|
| 889 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 890 |
+
{t("models.title")}
|
| 891 |
+
</h1>
|
| 892 |
+
<p className="text-muted-foreground">{t("models.description")}</p>
|
| 893 |
+
</div>
|
| 894 |
+
|
| 895 |
+
{/* 操作按钮组 */}
|
| 896 |
+
<div className="flex flex-wrap gap-4">
|
| 897 |
+
<Button
|
| 898 |
+
variant="default"
|
| 899 |
+
size="default"
|
| 900 |
+
onClick={handleTestModels}
|
| 901 |
+
className="relative flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white shadow-sm hover:shadow-md transition-all duration-200"
|
| 902 |
+
disabled={testing && !isTestComplete}
|
| 903 |
+
>
|
| 904 |
+
<motion.div
|
| 905 |
+
animate={testing ? { rotate: 360 } : { rotate: 0 }}
|
| 906 |
+
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
| 907 |
+
>
|
| 908 |
+
<ExperimentOutlined className="h-4 w-4" />
|
| 909 |
+
</motion.div>
|
| 910 |
+
{testing ? t("models.testing") : t("models.testAll")}
|
| 911 |
+
<Tooltip title={t("models.testTooltip")}>
|
| 912 |
+
<InfoCircleOutlined className="h-3.5 w-3.5 text-white/80 hover:text-white" />
|
| 913 |
+
</Tooltip>
|
| 914 |
+
</Button>
|
| 915 |
+
|
| 916 |
+
<Button
|
| 917 |
+
variant="default"
|
| 918 |
+
size="default"
|
| 919 |
+
onClick={handleSyncAllDerivedModels}
|
| 920 |
+
className="relative flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white shadow-sm hover:shadow-md transition-all duration-200"
|
| 921 |
+
disabled={syncing}
|
| 922 |
+
>
|
| 923 |
+
<motion.div
|
| 924 |
+
animate={syncing ? { rotate: 360 } : { rotate: 0 }}
|
| 925 |
+
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
| 926 |
+
>
|
| 927 |
+
<svg
|
| 928 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 929 |
+
className="h-4 w-4"
|
| 930 |
+
fill="none"
|
| 931 |
+
viewBox="0 0 24 24"
|
| 932 |
+
stroke="currentColor"
|
| 933 |
+
>
|
| 934 |
+
<path
|
| 935 |
+
strokeLinecap="round"
|
| 936 |
+
strokeLinejoin="round"
|
| 937 |
+
strokeWidth={2}
|
| 938 |
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
| 939 |
+
/>
|
| 940 |
+
</svg>
|
| 941 |
+
</motion.div>
|
| 942 |
+
{syncing ? t("models.syncing") : t("models.syncAllDerivedModels")}
|
| 943 |
+
<Tooltip title={t("models.syncTooltip")}>
|
| 944 |
+
<InfoCircleOutlined className="h-3.5 w-3.5 text-white/80 hover:text-white" />
|
| 945 |
+
</Tooltip>
|
| 946 |
+
</Button>
|
| 947 |
+
|
| 948 |
+
<Button
|
| 949 |
+
variant="outline"
|
| 950 |
+
size="default"
|
| 951 |
+
onClick={handleExportPrices}
|
| 952 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-zinc-800 border border-zinc-200 shadow-sm hover:shadow-md transition-all duration-200"
|
| 953 |
+
>
|
| 954 |
+
<svg
|
| 955 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 956 |
+
className="h-4 w-4"
|
| 957 |
+
fill="none"
|
| 958 |
+
viewBox="0 0 24 24"
|
| 959 |
+
stroke="currentColor"
|
| 960 |
+
strokeWidth={2}
|
| 961 |
+
>
|
| 962 |
+
<path
|
| 963 |
+
strokeLinecap="round"
|
| 964 |
+
strokeLinejoin="round"
|
| 965 |
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
| 966 |
+
/>
|
| 967 |
+
</svg>
|
| 968 |
+
{t("models.exportConfig")}
|
| 969 |
+
</Button>
|
| 970 |
+
|
| 971 |
+
<Button
|
| 972 |
+
variant="outline"
|
| 973 |
+
size="default"
|
| 974 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-zinc-800 border border-zinc-200 shadow-sm hover:shadow-md transition-all duration-200"
|
| 975 |
+
onClick={() => document.getElementById("import-input")?.click()}
|
| 976 |
+
>
|
| 977 |
+
<svg
|
| 978 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 979 |
+
className="h-4 w-4"
|
| 980 |
+
fill="none"
|
| 981 |
+
viewBox="0 0 24 24"
|
| 982 |
+
stroke="currentColor"
|
| 983 |
+
strokeWidth={2}
|
| 984 |
+
>
|
| 985 |
+
<path
|
| 986 |
+
strokeLinecap="round"
|
| 987 |
+
strokeLinejoin="round"
|
| 988 |
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
| 989 |
+
/>
|
| 990 |
+
</svg>
|
| 991 |
+
{t("models.importConfig")}
|
| 992 |
+
</Button>
|
| 993 |
+
<input
|
| 994 |
+
id="import-input"
|
| 995 |
+
type="file"
|
| 996 |
+
accept=".json"
|
| 997 |
+
className="hidden"
|
| 998 |
+
onChange={(e) => {
|
| 999 |
+
const file = e.target.files?.[0];
|
| 1000 |
+
if (file) {
|
| 1001 |
+
handleImportPrices(file);
|
| 1002 |
+
}
|
| 1003 |
+
e.target.value = "";
|
| 1004 |
+
}}
|
| 1005 |
+
/>
|
| 1006 |
+
</div>
|
| 1007 |
+
|
| 1008 |
+
{/* 替换原有的TestProgress组件 */}
|
| 1009 |
+
<TestProgressPanel
|
| 1010 |
+
isVisible={testing || isTestComplete}
|
| 1011 |
+
models={models}
|
| 1012 |
+
isComplete={isTestComplete}
|
| 1013 |
+
t={t}
|
| 1014 |
+
/>
|
| 1015 |
+
|
| 1016 |
+
{/* 桌面端表格视图 */}
|
| 1017 |
+
<div className="hidden sm:block">
|
| 1018 |
+
<div className="rounded-xl border border-border/40 bg-card shadow-sm overflow-hidden">
|
| 1019 |
+
{loading ? (
|
| 1020 |
+
<LoadingState t={t} />
|
| 1021 |
+
) : (
|
| 1022 |
+
<Table
|
| 1023 |
+
columns={columns}
|
| 1024 |
+
dataSource={models}
|
| 1025 |
+
rowKey="id"
|
| 1026 |
+
loading={false}
|
| 1027 |
+
pagination={false}
|
| 1028 |
+
size="middle"
|
| 1029 |
+
className={tableClassName}
|
| 1030 |
+
scroll={{ x: 500 }}
|
| 1031 |
+
rowClassName={() => "group"}
|
| 1032 |
+
/>
|
| 1033 |
+
)}
|
| 1034 |
+
</div>
|
| 1035 |
+
</div>
|
| 1036 |
+
|
| 1037 |
+
{/* 移动端卡片视图 */}
|
| 1038 |
+
<div className="sm:hidden">
|
| 1039 |
+
{loading ? (
|
| 1040 |
+
<LoadingState t={t} />
|
| 1041 |
+
) : (
|
| 1042 |
+
<div className="grid gap-4">
|
| 1043 |
+
{models.map((model) => (
|
| 1044 |
+
<MobileCard key={model.id} record={model} />
|
| 1045 |
+
))}
|
| 1046 |
+
</div>
|
| 1047 |
+
)}
|
| 1048 |
+
</div>
|
| 1049 |
+
</div>
|
| 1050 |
+
);
|
| 1051 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { FiDatabase, FiUsers, FiBarChart2, FiGithub } from "react-icons/fi";
|
| 6 |
+
import { CloseOutlined } from "@ant-design/icons";
|
| 7 |
+
import { APP_VERSION } from "@/lib/version";
|
| 8 |
+
import { message } from "antd";
|
| 9 |
+
import { useTranslation } from "react-i18next";
|
| 10 |
+
import { motion } from "framer-motion";
|
| 11 |
+
import { AnimatedGridPattern } from "@/components/ui/animated-grid-pattern";
|
| 12 |
+
import { cn } from "@/lib/utils";
|
| 13 |
+
|
| 14 |
+
export default function HomePage() {
|
| 15 |
+
const { t } = useTranslation("common");
|
| 16 |
+
const [isUpdateVisible, setIsUpdateVisible] = useState(false);
|
| 17 |
+
const [latestVersion, setLatestVersion] = useState("");
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const checkUpdate = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const response = await fetch(
|
| 23 |
+
"https://api.github.com/repos/variantconst/openwebui-monitor/releases/latest"
|
| 24 |
+
);
|
| 25 |
+
const data = await response.json();
|
| 26 |
+
const latestVer = data.tag_name;
|
| 27 |
+
if (!latestVer) {
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const currentVer = APP_VERSION.replace(/^v/, "");
|
| 32 |
+
const newVer = latestVer.replace(/^v/, "");
|
| 33 |
+
|
| 34 |
+
// 检查是否有更新且用户未禁用该版本的提示
|
| 35 |
+
const ignoredVersion = localStorage.getItem("ignoredVersion");
|
| 36 |
+
if (currentVer !== newVer && ignoredVersion !== latestVer) {
|
| 37 |
+
setLatestVersion(latestVer);
|
| 38 |
+
setIsUpdateVisible(true);
|
| 39 |
+
}
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error(t("header.messages.updateCheckFailed"), error);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
checkUpdate();
|
| 46 |
+
}, [t]);
|
| 47 |
+
|
| 48 |
+
const handleUpdate = () => {
|
| 49 |
+
window.open(
|
| 50 |
+
"https://github.com/VariantConst/OpenWebUI-Monitor/releases/latest",
|
| 51 |
+
"_blank"
|
| 52 |
+
);
|
| 53 |
+
setIsUpdateVisible(false);
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleIgnore = () => {
|
| 57 |
+
localStorage.setItem("ignoredVersion", latestVersion);
|
| 58 |
+
setIsUpdateVisible(false);
|
| 59 |
+
message.success(t("update.ignore"));
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<main className="relative min-h-screen w-full overflow-hidden bg-gradient-to-br from-rose-50 via-slate-50 to-teal-50 pt-16">
|
| 64 |
+
{/* 新增动态网格背景 */}
|
| 65 |
+
<AnimatedGridPattern
|
| 66 |
+
numSquares={30}
|
| 67 |
+
maxOpacity={0.03}
|
| 68 |
+
duration={3}
|
| 69 |
+
repeatDelay={1}
|
| 70 |
+
className={cn(
|
| 71 |
+
"[mask-image:radial-gradient(1200px_circle_at_center,white,transparent)]",
|
| 72 |
+
"absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-12 z-0"
|
| 73 |
+
)}
|
| 74 |
+
/>
|
| 75 |
+
|
| 76 |
+
{/* 装饰性背景模糊圆 */}
|
| 77 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[1000px] h-[1000px] bg-gradient-to-br from-rose-100/20 via-slate-100/20 to-teal-100/20 rounded-full blur-3xl opacity-40" />
|
| 78 |
+
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-gradient-to-br from-pink-100/10 to-indigo-100/10 rounded-full blur-3xl opacity-30" />
|
| 79 |
+
<div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] bg-gradient-to-br from-teal-100/10 to-slate-100/10 rounded-full blur-3xl opacity-30" />
|
| 80 |
+
|
| 81 |
+
{/* 修改主要内容容器,使用flex布局固定GitHub在底部 */}
|
| 82 |
+
<motion.div
|
| 83 |
+
initial={{ opacity: 0 }}
|
| 84 |
+
animate={{ opacity: 1 }}
|
| 85 |
+
className="min-h-[calc(100vh-4rem)] flex flex-col relative z-10"
|
| 86 |
+
>
|
| 87 |
+
{/* 标题区域保持不变 */}
|
| 88 |
+
<motion.div className="flex-1 flex flex-col items-center justify-center">
|
| 89 |
+
<motion.div
|
| 90 |
+
initial={{ opacity: 0, y: 20 }}
|
| 91 |
+
animate={{ opacity: 1, y: 0 }}
|
| 92 |
+
transition={{ duration: 0.5 }}
|
| 93 |
+
className="w-full space-y-6 sm:space-y-8 mb-12 sm:mb-16 px-4"
|
| 94 |
+
>
|
| 95 |
+
<div className="text-center space-y-2">
|
| 96 |
+
<motion.h1
|
| 97 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 98 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 99 |
+
className="text-3xl sm:text-4xl md:text-5xl font-bold bg-gradient-to-r from-slate-800 via-slate-700 to-slate-800 bg-clip-text text-transparent mb-2 sm:mb-3 tracking-tight"
|
| 100 |
+
>
|
| 101 |
+
{t("common.appName")}
|
| 102 |
+
</motion.h1>
|
| 103 |
+
<motion.p
|
| 104 |
+
initial={{ opacity: 0 }}
|
| 105 |
+
animate={{ opacity: 1 }}
|
| 106 |
+
transition={{ delay: 0.2 }}
|
| 107 |
+
className="text-sm sm:text-base md:text-lg text-slate-600/90 max-w-2xl mx-auto font-light"
|
| 108 |
+
>
|
| 109 |
+
{t("common.description")}
|
| 110 |
+
</motion.p>
|
| 111 |
+
</div>
|
| 112 |
+
</motion.div>
|
| 113 |
+
|
| 114 |
+
{/* 重新设计的导航区域 */}
|
| 115 |
+
<div className="w-full max-w-2xl px-4 sm:px-6">
|
| 116 |
+
<div className="relative">
|
| 117 |
+
{/* 装饰性背景 */}
|
| 118 |
+
<div className="absolute inset-0 bg-gradient-to-r from-rose-100/20 via-slate-100/20 to-teal-100/20 blur-3xl -z-10" />
|
| 119 |
+
|
| 120 |
+
{/* 新的垂直导航设计 */}
|
| 121 |
+
<div className="space-y-4">
|
| 122 |
+
{[
|
| 123 |
+
{
|
| 124 |
+
path: "/models",
|
| 125 |
+
icon: <FiDatabase className="w-6 h-6" />,
|
| 126 |
+
title: t("home.features.models.title"),
|
| 127 |
+
desc: t("home.features.models.description"),
|
| 128 |
+
gradient: "from-blue-500/80 to-indigo-500/80",
|
| 129 |
+
lightColor: "bg-blue-50/50",
|
| 130 |
+
borderColor: "border-blue-200/20",
|
| 131 |
+
iconColor: "text-blue-500/70",
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
path: "/users",
|
| 135 |
+
icon: <FiUsers className="w-6 h-6" />,
|
| 136 |
+
title: t("home.features.users.title"),
|
| 137 |
+
desc: t("home.features.users.description"),
|
| 138 |
+
gradient: "from-rose-500/80 to-pink-500/80",
|
| 139 |
+
lightColor: "bg-rose-50/50",
|
| 140 |
+
borderColor: "border-rose-200/20",
|
| 141 |
+
iconColor: "text-rose-500/70",
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
path: "/panel",
|
| 145 |
+
icon: <FiBarChart2 className="w-6 h-6" />,
|
| 146 |
+
title: t("home.features.stats.title"),
|
| 147 |
+
desc: t("home.features.stats.description"),
|
| 148 |
+
gradient: "from-emerald-500/80 to-teal-500/80",
|
| 149 |
+
lightColor: "bg-emerald-50/50",
|
| 150 |
+
borderColor: "border-emerald-200/20",
|
| 151 |
+
iconColor: "text-emerald-500/70",
|
| 152 |
+
},
|
| 153 |
+
].map((item, index) => (
|
| 154 |
+
<Link
|
| 155 |
+
key={item.path}
|
| 156 |
+
href={item.path}
|
| 157 |
+
className="group block"
|
| 158 |
+
>
|
| 159 |
+
<motion.div
|
| 160 |
+
initial={{ opacity: 0, y: 20 }}
|
| 161 |
+
animate={{ opacity: 1, y: 0 }}
|
| 162 |
+
transition={{ delay: index * 0.1 }}
|
| 163 |
+
className="relative bg-white rounded-2xl overflow-hidden transition-all duration-500
|
| 164 |
+
shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)]
|
| 165 |
+
hover:shadow-[0_8px_30px_-4px_rgba(0,0,0,0.12)]"
|
| 166 |
+
>
|
| 167 |
+
{/* 移除边框设计,只保留渐变背景 */}
|
| 168 |
+
<div
|
| 169 |
+
className={`absolute inset-0 bg-gradient-to-r ${item.gradient} opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
| 170 |
+
/>
|
| 171 |
+
|
| 172 |
+
{/* 内容区域 */}
|
| 173 |
+
<div className="relative p-6">
|
| 174 |
+
<div className="flex items-center gap-4">
|
| 175 |
+
{/* 图标容器 - 使用投影替代边框 */}
|
| 176 |
+
<div
|
| 177 |
+
className={cn(
|
| 178 |
+
"p-3 rounded-xl transition-all duration-500",
|
| 179 |
+
item.lightColor,
|
| 180 |
+
item.iconColor,
|
| 181 |
+
"shadow-[0_2px_10px_-2px_rgba(0,0,0,0.05)]",
|
| 182 |
+
"group-hover:bg-white/10 group-hover:text-white group-hover:shadow-none"
|
| 183 |
+
)}
|
| 184 |
+
>
|
| 185 |
+
{item.icon}
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* 文字内容 */}
|
| 189 |
+
<div className="flex-1 min-w-0">
|
| 190 |
+
<h3 className="text-lg font-medium text-slate-800 group-hover:text-white transition-colors mb-1">
|
| 191 |
+
{item.title}
|
| 192 |
+
</h3>
|
| 193 |
+
<p className="text-sm text-slate-600 group-hover:text-white/90 transition-colors">
|
| 194 |
+
{item.desc}
|
| 195 |
+
</p>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* 箭头 */}
|
| 199 |
+
<div
|
| 200 |
+
className={cn(
|
| 201 |
+
"transform transition-all duration-300",
|
| 202 |
+
item.iconColor,
|
| 203 |
+
"group-hover:text-white group-hover:translate-x-1"
|
| 204 |
+
)}
|
| 205 |
+
>
|
| 206 |
+
<svg
|
| 207 |
+
className="w-5 h-5"
|
| 208 |
+
fill="none"
|
| 209 |
+
viewBox="0 0 24 24"
|
| 210 |
+
stroke="currentColor"
|
| 211 |
+
>
|
| 212 |
+
<path
|
| 213 |
+
strokeLinecap="round"
|
| 214 |
+
strokeLinejoin="round"
|
| 215 |
+
strokeWidth={1.5}
|
| 216 |
+
d="M9 5l7 7-7 7"
|
| 217 |
+
/>
|
| 218 |
+
</svg>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</motion.div>
|
| 223 |
+
</Link>
|
| 224 |
+
))}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</motion.div>
|
| 229 |
+
|
| 230 |
+
{/* GitHub 图标固定在底部 */}
|
| 231 |
+
<motion.div
|
| 232 |
+
initial={{ opacity: 0 }}
|
| 233 |
+
animate={{ opacity: 1 }}
|
| 234 |
+
transition={{ delay: 0.6 }}
|
| 235 |
+
className="py-8 text-center"
|
| 236 |
+
>
|
| 237 |
+
<a
|
| 238 |
+
href="https://github.com/VariantConst/OpenWebUI-Monitor"
|
| 239 |
+
target="_blank"
|
| 240 |
+
rel="noopener noreferrer"
|
| 241 |
+
className="inline-flex p-2 text-slate-400 hover:text-slate-600 transition-colors"
|
| 242 |
+
>
|
| 243 |
+
<FiGithub className="w-6 h-6" />
|
| 244 |
+
</a>
|
| 245 |
+
</motion.div>
|
| 246 |
+
</motion.div>
|
| 247 |
+
|
| 248 |
+
{/* 更新提示框样式修改 */}
|
| 249 |
+
{isUpdateVisible && (
|
| 250 |
+
<motion.div
|
| 251 |
+
initial={{ opacity: 0, y: 20 }}
|
| 252 |
+
animate={{ opacity: 1, y: 0 }}
|
| 253 |
+
className="fixed bottom-4 right-4 z-50 w-auto max-w-[calc(100%-2rem)]"
|
| 254 |
+
>
|
| 255 |
+
<div className="bg-white rounded-lg shadow-2xl p-3 border border-gray-200 w-fit">
|
| 256 |
+
<div className="flex items-center gap-2 text-gray-600 text-sm">
|
| 257 |
+
<span className="whitespace-nowrap">
|
| 258 |
+
{t("update.newVersion")} {latestVersion}
|
| 259 |
+
</span>
|
| 260 |
+
<div className="flex gap-2">
|
| 261 |
+
<button
|
| 262 |
+
onClick={handleIgnore}
|
| 263 |
+
className="text-xs hover:text-gray-900 transition-colors"
|
| 264 |
+
>
|
| 265 |
+
{t("update.ignore")}
|
| 266 |
+
</button>
|
| 267 |
+
<span className="text-gray-300">|</span>
|
| 268 |
+
<button
|
| 269 |
+
onClick={handleUpdate}
|
| 270 |
+
className="text-xs text-blue-500 hover:text-blue-600 transition-colors"
|
| 271 |
+
>
|
| 272 |
+
{t("update.update")}
|
| 273 |
+
</button>
|
| 274 |
+
</div>
|
| 275 |
+
<button
|
| 276 |
+
onClick={() => setIsUpdateVisible(false)}
|
| 277 |
+
className="text-gray-400 hover:text-gray-500 ml-1"
|
| 278 |
+
>
|
| 279 |
+
<CloseOutlined className="text-[10px]" />
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</motion.div>
|
| 284 |
+
)}
|
| 285 |
+
</main>
|
| 286 |
+
);
|
| 287 |
+
}
|
app/panel/page.tsx
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import Head from "next/head";
|
| 5 |
+
import dayjs from "dayjs";
|
| 6 |
+
import type { TablePaginationConfig } from "antd/es/table";
|
| 7 |
+
import type { SorterResult } from "antd/es/table/interface";
|
| 8 |
+
import type { FilterValue } from "antd/es/table/interface";
|
| 9 |
+
import { message } from "antd";
|
| 10 |
+
import TimeRangeSelector, {
|
| 11 |
+
TimeRangeType,
|
| 12 |
+
} from "@/components/panel/TimeRangeSelector";
|
| 13 |
+
import ModelDistributionChart from "@/components/panel/ModelDistributionChart";
|
| 14 |
+
import UserRankingChart from "@/components/panel/UserRankingChart";
|
| 15 |
+
import UsageRecordsTable from "@/components/panel/UsageRecordsTable";
|
| 16 |
+
import { useTranslation } from "react-i18next";
|
| 17 |
+
import { motion } from "framer-motion";
|
| 18 |
+
import { toast, Toaster } from "sonner";
|
| 19 |
+
import { Card } from "@/components/ui/card";
|
| 20 |
+
import {
|
| 21 |
+
BarChartOutlined,
|
| 22 |
+
PieChartOutlined,
|
| 23 |
+
TableOutlined,
|
| 24 |
+
} from "@ant-design/icons";
|
| 25 |
+
import { Crown, Trophy } from "lucide-react";
|
| 26 |
+
|
| 27 |
+
interface ModelUsage {
|
| 28 |
+
model_name: string;
|
| 29 |
+
total_cost: number;
|
| 30 |
+
total_count: number;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface UserUsage {
|
| 34 |
+
nickname: string;
|
| 35 |
+
total_cost: number;
|
| 36 |
+
total_count: number;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
interface UsageData {
|
| 40 |
+
models: ModelUsage[];
|
| 41 |
+
users: UserUsage[];
|
| 42 |
+
timeRange: {
|
| 43 |
+
minTime: string;
|
| 44 |
+
maxTime: string;
|
| 45 |
+
};
|
| 46 |
+
stats?: {
|
| 47 |
+
totalTokens: number;
|
| 48 |
+
totalCalls: number;
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
interface UsageRecord {
|
| 53 |
+
id: number;
|
| 54 |
+
nickname: string;
|
| 55 |
+
use_time: string;
|
| 56 |
+
model_name: string;
|
| 57 |
+
input_tokens: number;
|
| 58 |
+
output_tokens: number;
|
| 59 |
+
cost: number;
|
| 60 |
+
balance_after: number;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
interface TableParams {
|
| 64 |
+
pagination: TablePaginationConfig;
|
| 65 |
+
sortField?: string;
|
| 66 |
+
sortOrder?: "ascend" | "descend" | undefined;
|
| 67 |
+
filters?: Record<string, FilterValue | null>;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export default function PanelPage() {
|
| 71 |
+
const { t } = useTranslation("common");
|
| 72 |
+
const [loading, setLoading] = useState(true);
|
| 73 |
+
const [tableLoading, setTableLoading] = useState(true);
|
| 74 |
+
const [dateRange, setDateRange] = useState<[Date, Date]>([
|
| 75 |
+
new Date(), // 将在加载数据后更新
|
| 76 |
+
new Date(),
|
| 77 |
+
]);
|
| 78 |
+
const [availableTimeRange, setAvailableTimeRange] = useState<{
|
| 79 |
+
minTime: Date;
|
| 80 |
+
maxTime: Date;
|
| 81 |
+
}>({
|
| 82 |
+
minTime: new Date(),
|
| 83 |
+
maxTime: new Date(),
|
| 84 |
+
});
|
| 85 |
+
const [usageData, setUsageData] = useState<UsageData>({
|
| 86 |
+
models: [],
|
| 87 |
+
users: [],
|
| 88 |
+
timeRange: {
|
| 89 |
+
minTime: "",
|
| 90 |
+
maxTime: "",
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
const [pieMetric, setPieMetric] = useState<"cost" | "count">("cost");
|
| 94 |
+
const [barMetric, setBarMetric] = useState<"cost" | "count">("cost");
|
| 95 |
+
const [records, setRecords] = useState<UsageRecord[]>([]);
|
| 96 |
+
const [tableParams, setTableParams] = useState<TableParams>({
|
| 97 |
+
pagination: {
|
| 98 |
+
current: 1,
|
| 99 |
+
pageSize: 10,
|
| 100 |
+
total: 0,
|
| 101 |
+
},
|
| 102 |
+
filters: {
|
| 103 |
+
nickname: null,
|
| 104 |
+
model_name: null,
|
| 105 |
+
},
|
| 106 |
+
});
|
| 107 |
+
const [timeRangeType, setTimeRangeType] = useState<TimeRangeType>("all");
|
| 108 |
+
|
| 109 |
+
const fetchUsageData = async (range: [Date, Date]) => {
|
| 110 |
+
setLoading(true);
|
| 111 |
+
try {
|
| 112 |
+
const startTime = dayjs(range[0]).startOf("day").toISOString();
|
| 113 |
+
const endTime = dayjs(range[1]).endOf("day").toISOString();
|
| 114 |
+
|
| 115 |
+
const url = `/api/v1/panel/usage?startTime=${startTime}&endTime=${endTime}`;
|
| 116 |
+
const response = await fetch(url);
|
| 117 |
+
|
| 118 |
+
if (!response.ok) throw new Error("Failed to fetch data");
|
| 119 |
+
|
| 120 |
+
const data = await response.json();
|
| 121 |
+
setUsageData(data);
|
| 122 |
+
|
| 123 |
+
// 如果是全部时间范围,更新可用时间范围
|
| 124 |
+
if (timeRangeType === "all") {
|
| 125 |
+
const minTime = new Date(data.timeRange.minTime);
|
| 126 |
+
const maxTime = new Date(data.timeRange.maxTime);
|
| 127 |
+
setAvailableTimeRange({ minTime, maxTime });
|
| 128 |
+
}
|
| 129 |
+
} catch (error) {
|
| 130 |
+
console.error(t("error.panel.fetchUsageDataFail"), error);
|
| 131 |
+
} finally {
|
| 132 |
+
setLoading(false);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const fetchRecords = async (params: TableParams, range: [Date, Date]) => {
|
| 137 |
+
setTableLoading(true);
|
| 138 |
+
try {
|
| 139 |
+
const searchParams = new URLSearchParams();
|
| 140 |
+
searchParams.append("page", params.pagination.current?.toString() || "1");
|
| 141 |
+
searchParams.append(
|
| 142 |
+
"pageSize",
|
| 143 |
+
params.pagination.pageSize?.toString() || "10"
|
| 144 |
+
);
|
| 145 |
+
|
| 146 |
+
// 添加排序和过滤参数
|
| 147 |
+
if (params.sortField) {
|
| 148 |
+
searchParams.append("sortField", params.sortField);
|
| 149 |
+
searchParams.append("sortOrder", params.sortOrder || "ascend");
|
| 150 |
+
}
|
| 151 |
+
if (params.filters?.nickname?.length) {
|
| 152 |
+
searchParams.append("users", params.filters.nickname.join(","));
|
| 153 |
+
}
|
| 154 |
+
if (params.filters?.model_name?.length) {
|
| 155 |
+
searchParams.append("models", params.filters.model_name.join(","));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// 添加日期范围
|
| 159 |
+
searchParams.append(
|
| 160 |
+
"startDate",
|
| 161 |
+
dayjs(range[0]).startOf("day").format("YYYY-MM-DD")
|
| 162 |
+
);
|
| 163 |
+
searchParams.append(
|
| 164 |
+
"endDate",
|
| 165 |
+
dayjs(range[1]).endOf("day").format("YYYY-MM-DD")
|
| 166 |
+
);
|
| 167 |
+
|
| 168 |
+
const response = await fetch(
|
| 169 |
+
`/api/v1/panel/records?${searchParams.toString()}`
|
| 170 |
+
);
|
| 171 |
+
const data = await response.json();
|
| 172 |
+
|
| 173 |
+
setRecords(data.records);
|
| 174 |
+
setTableParams({
|
| 175 |
+
...params,
|
| 176 |
+
pagination: {
|
| 177 |
+
...params.pagination,
|
| 178 |
+
total: data.total,
|
| 179 |
+
},
|
| 180 |
+
});
|
| 181 |
+
} catch (error) {
|
| 182 |
+
message.error(t("error.panel.fetchUsageDataFail"));
|
| 183 |
+
} finally {
|
| 184 |
+
setTableLoading(false);
|
| 185 |
+
}
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
useEffect(() => {
|
| 189 |
+
const loadInitialData = async () => {
|
| 190 |
+
// 获取全部时间范围的数据
|
| 191 |
+
const response = await fetch("/api/v1/panel/usage");
|
| 192 |
+
const data = await response.json();
|
| 193 |
+
|
| 194 |
+
const minTime = dayjs(data.timeRange.minTime).startOf("day").toDate();
|
| 195 |
+
const maxTime = dayjs(data.timeRange.maxTime).endOf("day").toDate();
|
| 196 |
+
setAvailableTimeRange({ minTime, maxTime });
|
| 197 |
+
|
| 198 |
+
// 设置为全部时间范围
|
| 199 |
+
const allTimeRange: [Date, Date] = [minTime, maxTime];
|
| 200 |
+
setDateRange(allTimeRange);
|
| 201 |
+
setTimeRangeType("all");
|
| 202 |
+
|
| 203 |
+
await fetchUsageData(allTimeRange);
|
| 204 |
+
await fetchRecords(tableParams, allTimeRange);
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
loadInitialData();
|
| 208 |
+
}, []);
|
| 209 |
+
|
| 210 |
+
const handleTimeRangeChange = async (
|
| 211 |
+
range: [Date, Date],
|
| 212 |
+
type: TimeRangeType
|
| 213 |
+
) => {
|
| 214 |
+
setTimeRangeType(type);
|
| 215 |
+
setDateRange(range);
|
| 216 |
+
await fetchUsageData(range);
|
| 217 |
+
await fetchRecords(tableParams, range);
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
const getReportTitle = (type: TimeRangeType, t: (key: string) => string) => {
|
| 221 |
+
switch (type) {
|
| 222 |
+
case "today":
|
| 223 |
+
return t("panel.report.daily");
|
| 224 |
+
case "week":
|
| 225 |
+
return t("panel.report.weekly");
|
| 226 |
+
case "month":
|
| 227 |
+
return t("panel.report.monthly");
|
| 228 |
+
case "30days":
|
| 229 |
+
return t("panel.report.thirtyDays");
|
| 230 |
+
case "all":
|
| 231 |
+
return t("panel.report.overall");
|
| 232 |
+
case "custom":
|
| 233 |
+
return t("panel.report.custom");
|
| 234 |
+
default:
|
| 235 |
+
return "";
|
| 236 |
+
}
|
| 237 |
+
};
|
| 238 |
+
|
| 239 |
+
const renderDateRangeLabel = () => {
|
| 240 |
+
// 如果是同一天,只显示一个日期
|
| 241 |
+
if (dayjs(dateRange[0]).isSame(dateRange[1], "day")) {
|
| 242 |
+
return dayjs(dateRange[0]).format("YYYY-MM-DD");
|
| 243 |
+
}
|
| 244 |
+
// 否则显示日期范围
|
| 245 |
+
return `${dayjs(dateRange[0]).format("YYYY-MM-DD")} ~ ${dayjs(
|
| 246 |
+
dateRange[1]
|
| 247 |
+
).format("YYYY-MM-DD")}`;
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
const formatNumber = (num: number): string => {
|
| 251 |
+
if (num >= 1_000_000_000) {
|
| 252 |
+
return (num / 1_000_000_000).toFixed(1) + "B";
|
| 253 |
+
}
|
| 254 |
+
if (num >= 1_000_000) {
|
| 255 |
+
return (num / 1_000_000).toFixed(1) + "M";
|
| 256 |
+
}
|
| 257 |
+
if (num >= 1_000) {
|
| 258 |
+
return (num / 1_000).toFixed(1) + "K";
|
| 259 |
+
}
|
| 260 |
+
return num.toLocaleString();
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
const handleTableChange = (
|
| 264 |
+
pagination: TablePaginationConfig,
|
| 265 |
+
filters: Record<string, FilterValue | null>,
|
| 266 |
+
sorter: SorterResult<UsageRecord> | SorterResult<UsageRecord>[]
|
| 267 |
+
) => {
|
| 268 |
+
const processedFilters = Object.fromEntries(
|
| 269 |
+
Object.entries(filters).map(([key, value]) => [
|
| 270 |
+
key,
|
| 271 |
+
Array.isArray(value) && value.length === 0 ? null : value,
|
| 272 |
+
])
|
| 273 |
+
);
|
| 274 |
+
|
| 275 |
+
const newParams: TableParams = {
|
| 276 |
+
pagination,
|
| 277 |
+
filters: processedFilters,
|
| 278 |
+
sortField: Array.isArray(sorter) ? undefined : sorter.field?.toString(),
|
| 279 |
+
sortOrder: Array.isArray(sorter)
|
| 280 |
+
? undefined
|
| 281 |
+
: (sorter.order as "ascend" | "descend" | undefined),
|
| 282 |
+
};
|
| 283 |
+
setTableParams(newParams);
|
| 284 |
+
fetchRecords(newParams, dateRange);
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
return (
|
| 288 |
+
<>
|
| 289 |
+
<Head>
|
| 290 |
+
<title>{t("panel.header")}</title>
|
| 291 |
+
</Head>
|
| 292 |
+
|
| 293 |
+
<Toaster
|
| 294 |
+
richColors
|
| 295 |
+
position="top-center"
|
| 296 |
+
theme="light"
|
| 297 |
+
expand
|
| 298 |
+
duration={1500}
|
| 299 |
+
/>
|
| 300 |
+
|
| 301 |
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 space-y-8">
|
| 302 |
+
<div className="space-y-4 pt-8">
|
| 303 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 304 |
+
{t("panel.title")}
|
| 305 |
+
</h1>
|
| 306 |
+
<p className="text-muted-foreground">{t("panel.description")}</p>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<TimeRangeSelector
|
| 310 |
+
timeRange={dateRange}
|
| 311 |
+
timeRangeType={timeRangeType}
|
| 312 |
+
availableTimeRange={availableTimeRange}
|
| 313 |
+
onTimeRangeChange={handleTimeRangeChange}
|
| 314 |
+
/>
|
| 315 |
+
|
| 316 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 317 |
+
<motion.div
|
| 318 |
+
initial={{ opacity: 0, y: 20 }}
|
| 319 |
+
animate={{ opacity: 1, y: 0 }}
|
| 320 |
+
transition={{ delay: 0.1 }}
|
| 321 |
+
className="col-span-full bg-gradient-to-br from-card to-card/95 text-card-foreground rounded-xl border shadow-sm overflow-hidden"
|
| 322 |
+
>
|
| 323 |
+
<div className="relative">
|
| 324 |
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-transparent to-primary/5 pointer-events-none" />
|
| 325 |
+
|
| 326 |
+
<div className="relative p-6 space-y-6">
|
| 327 |
+
<div className="flex items-center gap-3">
|
| 328 |
+
<div className="w-12 h-12 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center rounded-xl shrink-0">
|
| 329 |
+
<BarChartOutlined className="text-xl text-primary" />
|
| 330 |
+
</div>
|
| 331 |
+
<div className="space-y-1">
|
| 332 |
+
<h3 className="text-2xl font-semibold bg-gradient-to-br from-foreground to-foreground/80 bg-clip-text text-transparent">
|
| 333 |
+
{getReportTitle(timeRangeType, t)}
|
| 334 |
+
</h3>
|
| 335 |
+
<p className="text-sm text-muted-foreground">
|
| 336 |
+
{renderDateRangeLabel()}
|
| 337 |
+
</p>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
| 342 |
+
<div className="col-span-2 md:col-span-1 space-y-2">
|
| 343 |
+
<p className="text-sm font-medium text-muted-foreground">
|
| 344 |
+
{t("panel.overview.totalCost")}
|
| 345 |
+
</p>
|
| 346 |
+
<p className="text-2xl font-bold text-primary">
|
| 347 |
+
{loading
|
| 348 |
+
? "-"
|
| 349 |
+
: `${t("common.currency")}${usageData.models
|
| 350 |
+
.reduce((sum, model) => sum + model.total_cost, 0)
|
| 351 |
+
.toFixed(2)}`}
|
| 352 |
+
</p>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<div className="space-y-2">
|
| 356 |
+
<p className="text-sm font-medium text-muted-foreground">
|
| 357 |
+
{t("panel.overview.totalCalls")}
|
| 358 |
+
</p>
|
| 359 |
+
<p className="text-2xl font-bold text-emerald-600">
|
| 360 |
+
{loading
|
| 361 |
+
? "-"
|
| 362 |
+
: formatNumber(usageData.stats?.totalCalls || 0)}
|
| 363 |
+
</p>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<div className="space-y-2">
|
| 367 |
+
<p className="text-sm font-medium text-muted-foreground">
|
| 368 |
+
{t("panel.overview.totalTokens")}
|
| 369 |
+
</p>
|
| 370 |
+
<p className="text-2xl font-bold text-emerald-600">
|
| 371 |
+
{loading
|
| 372 |
+
? "-"
|
| 373 |
+
: formatNumber(usageData.stats?.totalTokens || 0)}
|
| 374 |
+
</p>
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
<div className="space-y-2">
|
| 378 |
+
<p className="text-sm font-medium text-muted-foreground">
|
| 379 |
+
{t("panel.overview.totalUsers")}
|
| 380 |
+
</p>
|
| 381 |
+
<p className="text-2xl font-bold text-amber-600">
|
| 382 |
+
{loading ? "-" : usageData.users.length}
|
| 383 |
+
</p>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
+
<div className="space-y-2">
|
| 387 |
+
<p className="text-sm font-medium text-muted-foreground">
|
| 388 |
+
{t("panel.overview.totalModels")}
|
| 389 |
+
</p>
|
| 390 |
+
<p className="text-2xl font-bold text-violet-600">
|
| 391 |
+
{loading ? "-" : usageData.models.length}
|
| 392 |
+
</p>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 pt-2">
|
| 397 |
+
<div className="space-y-4">
|
| 398 |
+
<div className="flex items-center gap-2">
|
| 399 |
+
<Crown className="w-4 h-4 text-amber-500" />
|
| 400 |
+
<h4 className="font-medium">
|
| 401 |
+
{t("panel.report.mostUsedModel")}
|
| 402 |
+
</h4>
|
| 403 |
+
</div>
|
| 404 |
+
<div className="space-y-1">
|
| 405 |
+
<p className="text-lg font-medium">
|
| 406 |
+
{loading
|
| 407 |
+
? "-"
|
| 408 |
+
: usageData.models.length > 0
|
| 409 |
+
? usageData.models.reduce((prev, current) =>
|
| 410 |
+
current.total_count > prev.total_count
|
| 411 |
+
? current
|
| 412 |
+
: prev
|
| 413 |
+
).model_name
|
| 414 |
+
: "-"}
|
| 415 |
+
</p>
|
| 416 |
+
<p className="text-sm text-muted-foreground">
|
| 417 |
+
{loading
|
| 418 |
+
? "-"
|
| 419 |
+
: usageData.models.length > 0
|
| 420 |
+
? t("panel.report.usageCount", {
|
| 421 |
+
count: usageData.models.reduce((prev, current) =>
|
| 422 |
+
current.total_count > prev.total_count
|
| 423 |
+
? current
|
| 424 |
+
: prev
|
| 425 |
+
).total_count,
|
| 426 |
+
})
|
| 427 |
+
: "-"}
|
| 428 |
+
</p>
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
|
| 432 |
+
<div className="space-y-4">
|
| 433 |
+
<div className="flex items-center gap-2">
|
| 434 |
+
<Trophy className="w-4 h-4 text-rose-500" />
|
| 435 |
+
<h4 className="font-medium">
|
| 436 |
+
{t("panel.report.topUser")}
|
| 437 |
+
</h4>
|
| 438 |
+
</div>
|
| 439 |
+
<div className="space-y-1">
|
| 440 |
+
<p className="text-lg font-medium">
|
| 441 |
+
{loading
|
| 442 |
+
? "-"
|
| 443 |
+
: usageData.users.length > 0
|
| 444 |
+
? usageData.users.reduce((prev, current) =>
|
| 445 |
+
current.total_cost > prev.total_cost
|
| 446 |
+
? current
|
| 447 |
+
: prev
|
| 448 |
+
).nickname
|
| 449 |
+
: "-"}
|
| 450 |
+
</p>
|
| 451 |
+
<p className="text-sm text-muted-foreground">
|
| 452 |
+
{loading
|
| 453 |
+
? "-"
|
| 454 |
+
: usageData.users.length > 0
|
| 455 |
+
? t("panel.report.spentAmount", {
|
| 456 |
+
amount: usageData.users
|
| 457 |
+
.reduce((prev, current) =>
|
| 458 |
+
current.total_cost > prev.total_cost
|
| 459 |
+
? current
|
| 460 |
+
: prev
|
| 461 |
+
)
|
| 462 |
+
.total_cost.toFixed(2),
|
| 463 |
+
})
|
| 464 |
+
: "-"}
|
| 465 |
+
</p>
|
| 466 |
+
</div>
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
</motion.div>
|
| 472 |
+
</div>
|
| 473 |
+
|
| 474 |
+
<motion.div
|
| 475 |
+
initial={{ opacity: 0, y: 20 }}
|
| 476 |
+
animate={{ opacity: 1, y: 0 }}
|
| 477 |
+
transition={{ delay: 0.2 }}
|
| 478 |
+
className="py-6 bg-card text-card-foreground"
|
| 479 |
+
>
|
| 480 |
+
<ModelDistributionChart
|
| 481 |
+
loading={loading}
|
| 482 |
+
models={usageData.models}
|
| 483 |
+
metric={pieMetric}
|
| 484 |
+
onMetricChange={setPieMetric}
|
| 485 |
+
/>
|
| 486 |
+
</motion.div>
|
| 487 |
+
|
| 488 |
+
<motion.div
|
| 489 |
+
initial={{ opacity: 0, y: 20 }}
|
| 490 |
+
animate={{ opacity: 1, y: 0 }}
|
| 491 |
+
transition={{ delay: 0.3 }}
|
| 492 |
+
className="py-6 bg-card text-card-foreground"
|
| 493 |
+
>
|
| 494 |
+
<UserRankingChart
|
| 495 |
+
loading={loading}
|
| 496 |
+
users={usageData.users}
|
| 497 |
+
metric={barMetric}
|
| 498 |
+
onMetricChange={setBarMetric}
|
| 499 |
+
/>
|
| 500 |
+
</motion.div>
|
| 501 |
+
|
| 502 |
+
<motion.div
|
| 503 |
+
initial={{ opacity: 0, y: 20 }}
|
| 504 |
+
animate={{ opacity: 1, y: 0 }}
|
| 505 |
+
transition={{ delay: 0.4 }}
|
| 506 |
+
className="py-6 bg-card text-card-foreground"
|
| 507 |
+
>
|
| 508 |
+
<div className="space-y-6">
|
| 509 |
+
<div className="flex items-center justify-between">
|
| 510 |
+
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
| 511 |
+
<TableOutlined className="text-primary" />
|
| 512 |
+
{t("panel.usageDetails.title")}
|
| 513 |
+
</h2>
|
| 514 |
+
</div>
|
| 515 |
+
<UsageRecordsTable
|
| 516 |
+
loading={tableLoading}
|
| 517 |
+
records={records}
|
| 518 |
+
tableParams={tableParams}
|
| 519 |
+
models={usageData.models}
|
| 520 |
+
users={usageData.users}
|
| 521 |
+
onTableChange={handleTableChange}
|
| 522 |
+
/>
|
| 523 |
+
</div>
|
| 524 |
+
</motion.div>
|
| 525 |
+
</div>
|
| 526 |
+
</>
|
| 527 |
+
);
|
| 528 |
+
}
|
app/records/page.tsx
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Table, DatePicker, Space } from "antd";
|
| 5 |
+
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
| 6 |
+
import type { FilterValue, SorterResult } from "antd/es/table/interface";
|
| 7 |
+
import {
|
| 8 |
+
DownloadOutlined,
|
| 9 |
+
CalendarOutlined,
|
| 10 |
+
TableOutlined,
|
| 11 |
+
} from "@ant-design/icons";
|
| 12 |
+
import type { RangePickerProps } from "antd/es/date-picker";
|
| 13 |
+
import zhCN from "antd/lib/locale/zh_CN";
|
| 14 |
+
import React from "react";
|
| 15 |
+
import { useTranslation } from "react-i18next";
|
| 16 |
+
import { motion } from "framer-motion";
|
| 17 |
+
import { toast, Toaster } from "sonner";
|
| 18 |
+
import { Card } from "@/components/ui/card";
|
| 19 |
+
import { Button } from "@/components/ui/button";
|
| 20 |
+
|
| 21 |
+
const { RangePicker } = DatePicker;
|
| 22 |
+
|
| 23 |
+
interface UsageRecord {
|
| 24 |
+
id: number;
|
| 25 |
+
nickname: string;
|
| 26 |
+
use_time: string;
|
| 27 |
+
model_name: string;
|
| 28 |
+
input_tokens: number;
|
| 29 |
+
output_tokens: number;
|
| 30 |
+
cost: number;
|
| 31 |
+
balance_after: number;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface TableParams {
|
| 35 |
+
pagination: TablePaginationConfig;
|
| 36 |
+
sortField?: string;
|
| 37 |
+
sortOrder?: string;
|
| 38 |
+
filters?: Record<string, FilterValue | null>;
|
| 39 |
+
dateRange?: RangePickerProps["value"];
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export default function RecordsPage() {
|
| 43 |
+
const { t } = useTranslation("common");
|
| 44 |
+
const [loading, setLoading] = useState(true);
|
| 45 |
+
const [records, setRecords] = useState<UsageRecord[]>([]);
|
| 46 |
+
const [users, setUsers] = useState<string[]>([]);
|
| 47 |
+
const [models, setModels] = useState<string[]>([]);
|
| 48 |
+
const [tableParams, setTableParams] = useState<TableParams>({
|
| 49 |
+
pagination: {
|
| 50 |
+
current: 1,
|
| 51 |
+
pageSize: 10,
|
| 52 |
+
total: 0,
|
| 53 |
+
},
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
const columns: ColumnsType<UsageRecord> = [
|
| 57 |
+
{
|
| 58 |
+
title: t("records.columns.user"),
|
| 59 |
+
dataIndex: "nickname",
|
| 60 |
+
key: "nickname",
|
| 61 |
+
filters: users.map((user) => ({ text: user, value: user })),
|
| 62 |
+
filterMode: "tree",
|
| 63 |
+
filterSearch: true,
|
| 64 |
+
width: 200,
|
| 65 |
+
render: (text) => <div className="font-medium">{text}</div>,
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
title: t("records.columns.time"),
|
| 69 |
+
dataIndex: "use_time",
|
| 70 |
+
key: "use_time",
|
| 71 |
+
render: (text) => (
|
| 72 |
+
<div className="text-muted-foreground">
|
| 73 |
+
{new Date(text).toLocaleString()}
|
| 74 |
+
</div>
|
| 75 |
+
),
|
| 76 |
+
sorter: true,
|
| 77 |
+
width: 180,
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
title: t("records.columns.model"),
|
| 81 |
+
dataIndex: "model_name",
|
| 82 |
+
key: "model_name",
|
| 83 |
+
filters: models.map((model) => ({ text: model, value: model })),
|
| 84 |
+
filterMode: "tree",
|
| 85 |
+
filterSearch: true,
|
| 86 |
+
width: 200,
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
title: t("records.columns.tokens"),
|
| 90 |
+
dataIndex: "input_tokens",
|
| 91 |
+
key: "input_tokens",
|
| 92 |
+
align: "right",
|
| 93 |
+
sorter: true,
|
| 94 |
+
width: 120,
|
| 95 |
+
render: (input, record) => (
|
| 96 |
+
<div className="space-y-1">
|
| 97 |
+
<div className="text-xs text-muted-foreground">输入: {input}</div>
|
| 98 |
+
<div className="text-xs text-muted-foreground">
|
| 99 |
+
输出: {record.output_tokens}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
),
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
title: t("records.columns.cost"),
|
| 106 |
+
dataIndex: "cost",
|
| 107 |
+
key: "cost",
|
| 108 |
+
align: "right",
|
| 109 |
+
width: 120,
|
| 110 |
+
render: (value) => (
|
| 111 |
+
<div className="font-medium text-primary">
|
| 112 |
+
{t("common.currency")}
|
| 113 |
+
{Number(value).toFixed(4)}
|
| 114 |
+
</div>
|
| 115 |
+
),
|
| 116 |
+
sorter: true,
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
title: t("records.columns.balance"),
|
| 120 |
+
dataIndex: "balance_after",
|
| 121 |
+
key: "balance_after",
|
| 122 |
+
align: "right",
|
| 123 |
+
width: 120,
|
| 124 |
+
render: (value) => (
|
| 125 |
+
<div className="font-medium">
|
| 126 |
+
{t("common.currency")}
|
| 127 |
+
{Number(value).toFixed(4)}
|
| 128 |
+
</div>
|
| 129 |
+
),
|
| 130 |
+
sorter: true,
|
| 131 |
+
},
|
| 132 |
+
];
|
| 133 |
+
|
| 134 |
+
const fetchRecords = async (params: TableParams) => {
|
| 135 |
+
setLoading(true);
|
| 136 |
+
try {
|
| 137 |
+
const searchParams = new URLSearchParams();
|
| 138 |
+
searchParams.append("page", params.pagination.current?.toString() || "1");
|
| 139 |
+
searchParams.append(
|
| 140 |
+
"pageSize",
|
| 141 |
+
params.pagination.pageSize?.toString() || "10"
|
| 142 |
+
);
|
| 143 |
+
|
| 144 |
+
if (params.sortField) {
|
| 145 |
+
searchParams.append("sortField", params.sortField);
|
| 146 |
+
searchParams.append("sortOrder", params.sortOrder || "ascend");
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
if (params.dateRange) {
|
| 150 |
+
const [start, end] = params.dateRange;
|
| 151 |
+
if (start && end) {
|
| 152 |
+
searchParams.append("startDate", start.format("YYYY-MM-DD"));
|
| 153 |
+
searchParams.append("endDate", end.format("YYYY-MM-DD"));
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if (params.filters) {
|
| 158 |
+
if (params.filters.nickname) {
|
| 159 |
+
searchParams.append("user", params.filters.nickname[0] as string);
|
| 160 |
+
}
|
| 161 |
+
if (params.filters.model_name) {
|
| 162 |
+
searchParams.append("model", params.filters.model_name[0] as string);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const response = await fetch(
|
| 167 |
+
`/api/v1/panel/records?${searchParams.toString()}`
|
| 168 |
+
);
|
| 169 |
+
const data = await response.json();
|
| 170 |
+
|
| 171 |
+
setRecords(data.records);
|
| 172 |
+
setTableParams({
|
| 173 |
+
...params,
|
| 174 |
+
pagination: {
|
| 175 |
+
...params.pagination,
|
| 176 |
+
total: data.total,
|
| 177 |
+
},
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
// 设置筛选选项
|
| 181 |
+
setUsers(data.users as string[]);
|
| 182 |
+
setModels(data.models as string[]);
|
| 183 |
+
} catch (error) {
|
| 184 |
+
toast.error(t("error.panel.fetchUsageDataFail"));
|
| 185 |
+
} finally {
|
| 186 |
+
setLoading(false);
|
| 187 |
+
}
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
useEffect(() => {
|
| 191 |
+
fetchRecords(tableParams);
|
| 192 |
+
}, []);
|
| 193 |
+
|
| 194 |
+
const handleTableChange = (
|
| 195 |
+
pagination: TablePaginationConfig,
|
| 196 |
+
filters: Record<string, FilterValue | null>,
|
| 197 |
+
sorter: SorterResult<UsageRecord> | SorterResult<UsageRecord>[]
|
| 198 |
+
) => {
|
| 199 |
+
const newParams: TableParams = {
|
| 200 |
+
pagination,
|
| 201 |
+
filters,
|
| 202 |
+
sortField: Array.isArray(sorter) ? undefined : sorter.field?.toString(),
|
| 203 |
+
sortOrder: Array.isArray(sorter)
|
| 204 |
+
? undefined
|
| 205 |
+
: sorter.order === null
|
| 206 |
+
? undefined
|
| 207 |
+
: sorter.order,
|
| 208 |
+
dateRange: tableParams.dateRange,
|
| 209 |
+
};
|
| 210 |
+
setTableParams(newParams);
|
| 211 |
+
fetchRecords(newParams);
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const handleExport = async () => {
|
| 215 |
+
try {
|
| 216 |
+
const response = await fetch("/api/v1/panel/records/export");
|
| 217 |
+
const blob = await response.blob();
|
| 218 |
+
const url = window.URL.createObjectURL(blob);
|
| 219 |
+
const a = document.createElement("a");
|
| 220 |
+
a.href = url;
|
| 221 |
+
a.download = `usage_records_${
|
| 222 |
+
new Date().toISOString().split("T")[0]
|
| 223 |
+
}.csv`;
|
| 224 |
+
document.body.appendChild(a);
|
| 225 |
+
a.click();
|
| 226 |
+
window.URL.revokeObjectURL(url);
|
| 227 |
+
document.body.removeChild(a);
|
| 228 |
+
} catch (error) {
|
| 229 |
+
toast.error(t("error.model.failToExport"));
|
| 230 |
+
}
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
// 修改表格样式
|
| 234 |
+
const tableClassName = `
|
| 235 |
+
[&_.ant-table]:!border-b-0
|
| 236 |
+
[&_.ant-table-container]:!rounded-xl
|
| 237 |
+
[&_.ant-table-container]:!border-hidden
|
| 238 |
+
[&_.ant-table-cell]:!border-border/40
|
| 239 |
+
[&_.ant-table-thead_.ant-table-cell]:!bg-muted/30
|
| 240 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-muted-foreground
|
| 241 |
+
[&_.ant-table-thead_.ant-table-cell]:!font-medium
|
| 242 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-sm
|
| 243 |
+
[&_.ant-table-thead]:!border-b
|
| 244 |
+
[&_.ant-table-thead]:border-border/40
|
| 245 |
+
[&_.ant-table-row]:!transition-colors
|
| 246 |
+
[&_.ant-table-row:hover>*]:!bg-muted/60
|
| 247 |
+
[&_.ant-table-tbody_.ant-table-row]:!cursor-pointer
|
| 248 |
+
[&_.ant-table-tbody_.ant-table-cell]:!py-4
|
| 249 |
+
[&_.ant-table-row:last-child>td]:!border-b-0
|
| 250 |
+
[&_.ant-table-cell:first-child]:!pl-6
|
| 251 |
+
[&_.ant-table-cell:last-child]:!pr-6
|
| 252 |
+
`;
|
| 253 |
+
|
| 254 |
+
return (
|
| 255 |
+
<>
|
| 256 |
+
<Toaster
|
| 257 |
+
richColors
|
| 258 |
+
position="top-center"
|
| 259 |
+
theme="light"
|
| 260 |
+
expand
|
| 261 |
+
duration={1500}
|
| 262 |
+
/>
|
| 263 |
+
|
| 264 |
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 space-y-8">
|
| 265 |
+
<div className="space-y-4">
|
| 266 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 267 |
+
{t("records.title")}
|
| 268 |
+
</h1>
|
| 269 |
+
<p className="text-muted-foreground">{t("records.description")}</p>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<motion.div
|
| 273 |
+
initial={{ opacity: 0, y: 20 }}
|
| 274 |
+
animate={{ opacity: 1, y: 0 }}
|
| 275 |
+
transition={{ delay: 0.1 }}
|
| 276 |
+
>
|
| 277 |
+
<Card className="p-6">
|
| 278 |
+
<div className="space-y-6">
|
| 279 |
+
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
| 280 |
+
<div className="flex items-center gap-2">
|
| 281 |
+
<CalendarOutlined className="text-primary" />
|
| 282 |
+
<span className="font-medium">{t("records.dateRange")}</span>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="flex flex-col sm:flex-row gap-4">
|
| 285 |
+
<RangePicker
|
| 286 |
+
locale={zhCN.DatePicker}
|
| 287 |
+
className="!w-full sm:!w-auto"
|
| 288 |
+
onChange={(dates) => {
|
| 289 |
+
const newParams = {
|
| 290 |
+
...tableParams,
|
| 291 |
+
dateRange: dates,
|
| 292 |
+
pagination: { ...tableParams.pagination, current: 1 },
|
| 293 |
+
};
|
| 294 |
+
setTableParams(newParams);
|
| 295 |
+
fetchRecords(newParams);
|
| 296 |
+
}}
|
| 297 |
+
/>
|
| 298 |
+
<Button
|
| 299 |
+
variant="outline"
|
| 300 |
+
className="flex items-center gap-2"
|
| 301 |
+
onClick={handleExport}
|
| 302 |
+
>
|
| 303 |
+
<DownloadOutlined />
|
| 304 |
+
{t("records.export")}
|
| 305 |
+
</Button>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div className="overflow-x-auto">
|
| 310 |
+
<Table
|
| 311 |
+
columns={columns}
|
| 312 |
+
dataSource={records}
|
| 313 |
+
rowKey="id"
|
| 314 |
+
pagination={{
|
| 315 |
+
...tableParams.pagination,
|
| 316 |
+
className: "!justify-end",
|
| 317 |
+
showTotal: (total) =>
|
| 318 |
+
`${t("common.total")} ${total} ${t("common.count")}`,
|
| 319 |
+
}}
|
| 320 |
+
loading={loading}
|
| 321 |
+
onChange={handleTableChange}
|
| 322 |
+
className={tableClassName}
|
| 323 |
+
scroll={{ x: 800 }}
|
| 324 |
+
/>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</Card>
|
| 328 |
+
</motion.div>
|
| 329 |
+
</div>
|
| 330 |
+
</>
|
| 331 |
+
);
|
| 332 |
+
}
|
app/token/page.tsx
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { toast, Toaster } from "sonner";
|
| 5 |
+
import { motion } from "framer-motion";
|
| 6 |
+
import { useTranslation } from "react-i18next";
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Input } from "@/components/ui/input";
|
| 9 |
+
import { Label } from "@/components/ui/label";
|
| 10 |
+
import { Checkbox } from "@/components/ui/checkbox";
|
| 11 |
+
import {
|
| 12 |
+
Tooltip,
|
| 13 |
+
TooltipContent,
|
| 14 |
+
TooltipProvider,
|
| 15 |
+
TooltipTrigger,
|
| 16 |
+
} from "@/components/ui/tooltip";
|
| 17 |
+
import { HelpCircle, Loader2, Key, LockKeyhole } from "lucide-react";
|
| 18 |
+
import { cn } from "@/lib/utils";
|
| 19 |
+
import { AnimatedGridPattern } from "@/components/ui/animated-grid-pattern";
|
| 20 |
+
|
| 21 |
+
export default function TokenPage() {
|
| 22 |
+
const [token, setToken] = useState("");
|
| 23 |
+
const [loading, setLoading] = useState(false);
|
| 24 |
+
const [showToken, setShowToken] = useState(false);
|
| 25 |
+
const { t } = useTranslation("common");
|
| 26 |
+
|
| 27 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 28 |
+
e.preventDefault();
|
| 29 |
+
if (!token.trim()) {
|
| 30 |
+
toast.error(t("auth.accessTokenRequired"));
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
setLoading(true);
|
| 35 |
+
try {
|
| 36 |
+
localStorage.setItem("access_token", token);
|
| 37 |
+
const res = await fetch("/api/config", {
|
| 38 |
+
headers: {
|
| 39 |
+
Authorization: `Bearer ${token}`,
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
if (res.ok) {
|
| 44 |
+
toast.success(t("auth.loginSuccess"));
|
| 45 |
+
window.location.href = "/";
|
| 46 |
+
} else {
|
| 47 |
+
toast.error(t("auth.invalidToken"));
|
| 48 |
+
localStorage.removeItem("access_token");
|
| 49 |
+
}
|
| 50 |
+
} catch (error) {
|
| 51 |
+
toast.error(t("auth.verificationFailed"));
|
| 52 |
+
localStorage.removeItem("access_token");
|
| 53 |
+
} finally {
|
| 54 |
+
setLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 via-slate-50 to-teal-50 relative overflow-hidden">
|
| 60 |
+
<Toaster
|
| 61 |
+
richColors
|
| 62 |
+
position="top-center"
|
| 63 |
+
theme="light"
|
| 64 |
+
expand
|
| 65 |
+
duration={1500}
|
| 66 |
+
/>
|
| 67 |
+
|
| 68 |
+
<AnimatedGridPattern
|
| 69 |
+
numSquares={30}
|
| 70 |
+
maxOpacity={0.03}
|
| 71 |
+
duration={3}
|
| 72 |
+
repeatDelay={1}
|
| 73 |
+
className={cn(
|
| 74 |
+
"[mask-image:radial-gradient(1200px_circle_at_center,white,transparent)]",
|
| 75 |
+
"absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-12 z-0"
|
| 76 |
+
)}
|
| 77 |
+
/>
|
| 78 |
+
|
| 79 |
+
{/* 装饰性背景模糊圆 */}
|
| 80 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[1000px] h-[1000px] bg-gradient-to-br from-rose-100/20 via-slate-100/20 to-teal-100/20 rounded-full blur-3xl opacity-40" />
|
| 81 |
+
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-gradient-to-br from-pink-100/10 to-indigo-100/10 rounded-full blur-3xl opacity-30" />
|
| 82 |
+
<div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] bg-gradient-to-br from-teal-100/10 to-slate-100/10 rounded-full blur-3xl opacity-30" />
|
| 83 |
+
|
| 84 |
+
<motion.div
|
| 85 |
+
initial={{ opacity: 0, y: 20 }}
|
| 86 |
+
animate={{ opacity: 1, y: 0 }}
|
| 87 |
+
transition={{ duration: 0.5 }}
|
| 88 |
+
className="w-full max-w-md space-y-10 z-10 px-6"
|
| 89 |
+
>
|
| 90 |
+
<motion.div
|
| 91 |
+
initial={{ scale: 0.5, opacity: 0 }}
|
| 92 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 93 |
+
transition={{ duration: 0.5, delay: 0.2 }}
|
| 94 |
+
className="text-center space-y-6"
|
| 95 |
+
>
|
| 96 |
+
<div className="mx-auto w-20 h-20 flex items-center justify-center">
|
| 97 |
+
<img
|
| 98 |
+
src="/icon.png"
|
| 99 |
+
alt="Logo"
|
| 100 |
+
className="w-20 h-20 object-contain drop-shadow-xl"
|
| 101 |
+
/>
|
| 102 |
+
</div>
|
| 103 |
+
<h1 className="text-3xl font-bold bg-gradient-to-br from-slate-800 via-slate-700 to-slate-800 bg-clip-text text-transparent">
|
| 104 |
+
{t("common.appName")}
|
| 105 |
+
</h1>
|
| 106 |
+
</motion.div>
|
| 107 |
+
|
| 108 |
+
<motion.div
|
| 109 |
+
initial={{ opacity: 0, y: 20 }}
|
| 110 |
+
animate={{ opacity: 1, y: 0 }}
|
| 111 |
+
transition={{ duration: 0.5, delay: 0.3 }}
|
| 112 |
+
className="backdrop-blur-[20px] bg-white/[0.08] p-10 rounded-[2.5rem] border border-white/20 shadow-2xl relative overflow-hidden
|
| 113 |
+
hover:shadow-[0_8px_60px_rgba(120,119,198,0.15)] transition-shadow duration-300 group"
|
| 114 |
+
>
|
| 115 |
+
{/* 新增流光边框效果 */}
|
| 116 |
+
<div
|
| 117 |
+
className="absolute inset-0 rounded-[2.5rem] p-[2px]
|
| 118 |
+
bg-gradient-to-br from-white/30 via-transparent to-transparent
|
| 119 |
+
[mask:linear-gradient(black,black)_content-box,linear-gradient(black,black)]
|
| 120 |
+
[mask-composite:xor] opacity-30 group-hover:opacity-50 transition-opacity"
|
| 121 |
+
></div>
|
| 122 |
+
|
| 123 |
+
{/* 新增动态粒子背景 */}
|
| 124 |
+
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-100/5 via-transparent to-transparent opacity-20" />
|
| 125 |
+
|
| 126 |
+
<motion.form
|
| 127 |
+
onSubmit={handleSubmit}
|
| 128 |
+
className="space-y-8 relative"
|
| 129 |
+
initial="hidden"
|
| 130 |
+
animate="visible"
|
| 131 |
+
variants={{
|
| 132 |
+
hidden: { opacity: 0 },
|
| 133 |
+
visible: {
|
| 134 |
+
opacity: 1,
|
| 135 |
+
transition: {
|
| 136 |
+
staggerChildren: 0.1,
|
| 137 |
+
delayChildren: 0.3,
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
}}
|
| 141 |
+
>
|
| 142 |
+
<motion.div
|
| 143 |
+
className="space-y-6"
|
| 144 |
+
variants={{
|
| 145 |
+
hidden: { opacity: 0, y: 20 },
|
| 146 |
+
visible: { opacity: 1, y: 0 },
|
| 147 |
+
}}
|
| 148 |
+
>
|
| 149 |
+
<div className="flex flex-col gap-4">
|
| 150 |
+
<div className="flex items-center gap-3">
|
| 151 |
+
<Key className="h-5 w-5 text-slate-500/80" />
|
| 152 |
+
<Label
|
| 153 |
+
htmlFor="token"
|
| 154 |
+
className="text-sm font-medium text-slate-600/90 tracking-wide"
|
| 155 |
+
>
|
| 156 |
+
{t("auth.accessToken")}
|
| 157 |
+
</Label>
|
| 158 |
+
<TooltipProvider delayDuration={300}>
|
| 159 |
+
<Tooltip>
|
| 160 |
+
<TooltipTrigger asChild>
|
| 161 |
+
<HelpCircle className="h-4 w-4 text-slate-400 hover:text-slate-600 cursor-pointer transition-colors" />
|
| 162 |
+
</TooltipTrigger>
|
| 163 |
+
<TooltipContent
|
| 164 |
+
side="right"
|
| 165 |
+
className="max-w-[260px] text-xs"
|
| 166 |
+
>
|
| 167 |
+
{t("auth.accessTokenHelp")}
|
| 168 |
+
</TooltipContent>
|
| 169 |
+
</Tooltip>
|
| 170 |
+
</TooltipProvider>
|
| 171 |
+
</div>
|
| 172 |
+
<div className="relative">
|
| 173 |
+
<Input
|
| 174 |
+
id="token"
|
| 175 |
+
type={showToken ? "text" : "password"}
|
| 176 |
+
value={token}
|
| 177 |
+
onChange={(e) => setToken(e.target.value)}
|
| 178 |
+
placeholder={t("auth.accessTokenPlaceholder")}
|
| 179 |
+
className="w-full bg-white/20 border-2 border-slate-300/30 hover:border-slate-400/50
|
| 180 |
+
focus:border-slate-500/80 focus:ring-2 focus:ring-slate-400/20
|
| 181 |
+
transition-all duration-300 placeholder:text-slate-500/70
|
| 182 |
+
shadow-sm hover:shadow-md focus:shadow-lg pl-12
|
| 183 |
+
[&:focus]:bg-white/30 backdrop-blur-sm rounded-xl h-14 text-base"
|
| 184 |
+
autoComplete="off"
|
| 185 |
+
/>
|
| 186 |
+
<LockKeyhole className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-500/70" />
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</motion.div>
|
| 190 |
+
|
| 191 |
+
<motion.div
|
| 192 |
+
className="flex items-center space-x-2"
|
| 193 |
+
variants={{
|
| 194 |
+
hidden: { opacity: 0, y: 20 },
|
| 195 |
+
visible: { opacity: 1, y: 0 },
|
| 196 |
+
}}
|
| 197 |
+
>
|
| 198 |
+
<Checkbox
|
| 199 |
+
id="showToken"
|
| 200 |
+
checked={showToken}
|
| 201 |
+
onCheckedChange={(checked) => setShowToken(checked as boolean)}
|
| 202 |
+
className="text-slate-600 focus:ring-slate-500 border-2 border-slate-300/50 data-[state=checked]:border-slate-600 data-[state=checked]:bg-slate-600/90"
|
| 203 |
+
/>
|
| 204 |
+
<Label
|
| 205 |
+
htmlFor="showToken"
|
| 206 |
+
className="text-sm font-medium text-slate-600 cursor-pointer select-none"
|
| 207 |
+
>
|
| 208 |
+
{t("auth.showToken")}
|
| 209 |
+
</Label>
|
| 210 |
+
</motion.div>
|
| 211 |
+
|
| 212 |
+
<motion.div
|
| 213 |
+
initial={{ opacity: 0, y: 20 }}
|
| 214 |
+
animate={{ opacity: 1, y: 0 }}
|
| 215 |
+
transition={{ duration: 0.3, delay: 0.5 }}
|
| 216 |
+
>
|
| 217 |
+
<Button
|
| 218 |
+
onClick={handleSubmit}
|
| 219 |
+
className="w-full h-14 font-semibold bg-gradient-to-r from-slate-700/90 to-slate-800/90
|
| 220 |
+
hover:from-slate-800 hover:to-slate-900 text-white/95
|
| 221 |
+
shadow-xl shadow-slate-500/10 hover:shadow-slate-500/20
|
| 222 |
+
transition-all duration-300 rounded-xl
|
| 223 |
+
hover:scale-[0.98] transform-gpu
|
| 224 |
+
border border-white/10
|
| 225 |
+
hover:after:opacity-100
|
| 226 |
+
relative overflow-hidden
|
| 227 |
+
after:absolute after:inset-0 after:bg-[radial-gradient(200px_circle_at_center,_rgba(255,255,255,0.15)_0%,_transparent_80%)]
|
| 228 |
+
after:opacity-0 after:transition-opacity after:duration-300"
|
| 229 |
+
disabled={loading}
|
| 230 |
+
size="lg"
|
| 231 |
+
>
|
| 232 |
+
{loading ? (
|
| 233 |
+
<span className="flex items-center justify-center gap-2">
|
| 234 |
+
<Loader2 className="h-5 w-5 animate-spin" />
|
| 235 |
+
{t("auth.verifying")}
|
| 236 |
+
</span>
|
| 237 |
+
) : (
|
| 238 |
+
<span className="tracking-wide">{t("common.confirm")}</span>
|
| 239 |
+
)}
|
| 240 |
+
</Button>
|
| 241 |
+
</motion.div>
|
| 242 |
+
</motion.form>
|
| 243 |
+
</motion.div>
|
| 244 |
+
</motion.div>
|
| 245 |
+
</div>
|
| 246 |
+
);
|
| 247 |
+
}
|
app/users/page.tsx
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Table, Input, message, Modal } from "antd";
|
| 5 |
+
import type { ColumnsType } from "antd/es/table";
|
| 6 |
+
import {
|
| 7 |
+
Dialog,
|
| 8 |
+
DialogContent,
|
| 9 |
+
DialogHeader,
|
| 10 |
+
DialogTitle,
|
| 11 |
+
} from "@/components/ui/dialog";
|
| 12 |
+
import { useTranslation } from "react-i18next";
|
| 13 |
+
import {
|
| 14 |
+
AlertDialog,
|
| 15 |
+
AlertDialogAction,
|
| 16 |
+
AlertDialogCancel,
|
| 17 |
+
AlertDialogContent,
|
| 18 |
+
AlertDialogDescription,
|
| 19 |
+
AlertDialogFooter,
|
| 20 |
+
AlertDialogHeader,
|
| 21 |
+
AlertDialogTitle,
|
| 22 |
+
} from "@/components/ui/alert-dialog";
|
| 23 |
+
import { Trash2, Search, X, Unlock, Lock } from "lucide-react";
|
| 24 |
+
import { EditableCell } from "@/components/editable-cell";
|
| 25 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 26 |
+
import { createPortal } from "react-dom";
|
| 27 |
+
import { toast, Toaster } from "sonner";
|
| 28 |
+
|
| 29 |
+
interface User {
|
| 30 |
+
id: string;
|
| 31 |
+
email: string;
|
| 32 |
+
name: string;
|
| 33 |
+
role: string;
|
| 34 |
+
balance: number;
|
| 35 |
+
deleted: boolean;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface TFunction {
|
| 39 |
+
(key: string): string;
|
| 40 |
+
(key: string, options: { name: string }): string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const TABLE_STYLES = `
|
| 44 |
+
[&_.ant-table]:!border-b-0
|
| 45 |
+
[&_.ant-table-container]:!rounded-xl
|
| 46 |
+
[&_.ant-table-container]:!border-hidden
|
| 47 |
+
[&_.ant-table-cell]:!border-border/40
|
| 48 |
+
[&_.ant-table-thead_.ant-table-cell]:!bg-muted/30
|
| 49 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-muted-foreground
|
| 50 |
+
[&_.ant-table-thead_.ant-table-cell]:!font-medium
|
| 51 |
+
[&_.ant-table-thead_.ant-table-cell]:!text-sm
|
| 52 |
+
[&_.ant-table-thead]:!border-b
|
| 53 |
+
[&_.ant-table-thead]:border-border/40
|
| 54 |
+
[&_.ant-table-row]:!transition-colors
|
| 55 |
+
[&_.ant-table-row:hover>*]:!bg-muted/60
|
| 56 |
+
[&_.ant-table-tbody_.ant-table-row]:!cursor-pointer
|
| 57 |
+
[&_.ant-table-tbody_.ant-table-cell]:!py-4
|
| 58 |
+
[&_.ant-table-row:last-child>td]:!border-b-0
|
| 59 |
+
[&_.ant-table-cell:first-child]:!pl-6
|
| 60 |
+
[&_.ant-table-cell:last-child]:!pr-6
|
| 61 |
+
[&_.ant-pagination]:!px-6
|
| 62 |
+
[&_.ant-pagination]:!py-4
|
| 63 |
+
[&_.ant-pagination]:!border-t
|
| 64 |
+
[&_.ant-pagination]:border-border/40
|
| 65 |
+
[&_.ant-pagination-item]:!rounded-lg
|
| 66 |
+
[&_.ant-pagination-item]:!border-border/40
|
| 67 |
+
[&_.ant-pagination-item-active]:!bg-primary/10
|
| 68 |
+
[&_.ant-pagination-item-active]:!border-primary/30
|
| 69 |
+
[&_.ant-pagination-item-active>a]:!text-primary
|
| 70 |
+
[&_.ant-pagination-prev_.ant-pagination-item-link]:!rounded-lg
|
| 71 |
+
[&_.ant-pagination-next_.ant-pagination-item-link]:!rounded-lg
|
| 72 |
+
[&_.ant-pagination-prev_.ant-pagination-item-link]:!border-border/40
|
| 73 |
+
[&_.ant-pagination-next_.ant-pagination-item-link]:!border-border/40
|
| 74 |
+
`;
|
| 75 |
+
|
| 76 |
+
const formatBalance = (balance: number | string) => {
|
| 77 |
+
const num = typeof balance === "number" ? balance : Number(balance);
|
| 78 |
+
return isFinite(num) ? num.toFixed(4) : "0.0000";
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const UserDetailsModal = ({
|
| 82 |
+
user,
|
| 83 |
+
onClose,
|
| 84 |
+
t,
|
| 85 |
+
}: {
|
| 86 |
+
user: User | null;
|
| 87 |
+
onClose: () => void;
|
| 88 |
+
t: TFunction;
|
| 89 |
+
}) => {
|
| 90 |
+
if (!user) return null;
|
| 91 |
+
|
| 92 |
+
return createPortal(
|
| 93 |
+
<motion.div
|
| 94 |
+
initial={{ opacity: 0 }}
|
| 95 |
+
animate={{ opacity: 1 }}
|
| 96 |
+
exit={{ opacity: 0 }}
|
| 97 |
+
className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm flex items-center justify-center"
|
| 98 |
+
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0 }}
|
| 99 |
+
onClick={onClose}
|
| 100 |
+
>
|
| 101 |
+
<motion.div
|
| 102 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 103 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 104 |
+
exit={{ scale: 0.95, opacity: 0 }}
|
| 105 |
+
transition={{ type: "spring", duration: 0.3 }}
|
| 106 |
+
className="w-full max-w-lg mx-auto px-4"
|
| 107 |
+
onClick={(e) => e.stopPropagation()}
|
| 108 |
+
>
|
| 109 |
+
<div className="bg-card rounded-2xl border border-border/50 shadow-xl overflow-hidden">
|
| 110 |
+
<div className="relative px-6 pt-6">
|
| 111 |
+
<motion.button
|
| 112 |
+
initial={{ opacity: 0 }}
|
| 113 |
+
animate={{ opacity: 1 }}
|
| 114 |
+
className="absolute right-4 top-4 p-2 rounded-full hover:bg-muted/80 transition-colors"
|
| 115 |
+
onClick={onClose}
|
| 116 |
+
>
|
| 117 |
+
<X className="w-4 h-4 text-muted-foreground" />
|
| 118 |
+
</motion.button>
|
| 119 |
+
<div className="flex items-center gap-4 mb-6">
|
| 120 |
+
<div className="h-16 w-16 rounded-2xl bg-primary/10 flex items-center justify-center text-primary font-medium text-2xl">
|
| 121 |
+
{user.name.charAt(0).toUpperCase()}
|
| 122 |
+
</div>
|
| 123 |
+
<div>
|
| 124 |
+
<h3 className="text-lg font-semibold mb-1">{user.name}</h3>
|
| 125 |
+
<span className="px-2.5 py-1 text-xs rounded-full bg-primary/10 text-primary font-medium">
|
| 126 |
+
{user.role}
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div className="px-6 pb-6">
|
| 133 |
+
<motion.div
|
| 134 |
+
initial={{ opacity: 0, y: 20 }}
|
| 135 |
+
animate={{ opacity: 1, y: 0 }}
|
| 136 |
+
transition={{ delay: 0.1 }}
|
| 137 |
+
className="space-y-6"
|
| 138 |
+
>
|
| 139 |
+
<div className="space-y-4">
|
| 140 |
+
<div className="space-y-2">
|
| 141 |
+
<div className="text-sm text-muted-foreground">
|
| 142 |
+
{t("users.email")}
|
| 143 |
+
</div>
|
| 144 |
+
<div className="p-3 bg-muted/50 rounded-lg text-sm break-all">
|
| 145 |
+
{user.email}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
<div className="space-y-2">
|
| 149 |
+
<div className="text-sm text-muted-foreground">
|
| 150 |
+
{t("users.id")}
|
| 151 |
+
</div>
|
| 152 |
+
<div className="p-3 bg-muted/50 rounded-lg text-sm font-mono break-all">
|
| 153 |
+
{user.id}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="space-y-2">
|
| 157 |
+
<div className="text-sm text-muted-foreground">
|
| 158 |
+
{t("users.balance")}
|
| 159 |
+
</div>
|
| 160 |
+
<div className="p-3 bg-muted/50 rounded-lg text-sm">
|
| 161 |
+
{formatBalance(user.balance)}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</motion.div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</motion.div>
|
| 169 |
+
</motion.div>,
|
| 170 |
+
document.getElementById("modal-root") || document.body
|
| 171 |
+
);
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const BlockConfirmModal = ({
|
| 175 |
+
user,
|
| 176 |
+
onClose,
|
| 177 |
+
onConfirm,
|
| 178 |
+
t,
|
| 179 |
+
}: {
|
| 180 |
+
user: User | null;
|
| 181 |
+
onClose: () => void;
|
| 182 |
+
onConfirm: () => void;
|
| 183 |
+
t: TFunction;
|
| 184 |
+
}) => {
|
| 185 |
+
if (!user) return null;
|
| 186 |
+
|
| 187 |
+
return createPortal(
|
| 188 |
+
<motion.div
|
| 189 |
+
initial={{ opacity: 0 }}
|
| 190 |
+
animate={{ opacity: 1 }}
|
| 191 |
+
exit={{ opacity: 0 }}
|
| 192 |
+
className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm flex items-center justify-center"
|
| 193 |
+
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0 }}
|
| 194 |
+
onClick={onClose}
|
| 195 |
+
>
|
| 196 |
+
<motion.div
|
| 197 |
+
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
| 198 |
+
animate={{ scale: 1, opacity: 1, y: 0 }}
|
| 199 |
+
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
| 200 |
+
transition={{ type: "spring", duration: 0.3 }}
|
| 201 |
+
className="w-full max-w-md mx-auto px-4"
|
| 202 |
+
onClick={(e) => e.stopPropagation()}
|
| 203 |
+
>
|
| 204 |
+
<div className="bg-card rounded-2xl border border-border/50 shadow-xl overflow-hidden">
|
| 205 |
+
<div className="p-6">
|
| 206 |
+
<div className="flex items-center gap-4 mb-6">
|
| 207 |
+
<div
|
| 208 |
+
className={`
|
| 209 |
+
h-12 w-12 rounded-full flex items-center justify-center
|
| 210 |
+
${
|
| 211 |
+
user.deleted
|
| 212 |
+
? "bg-primary/10 text-primary"
|
| 213 |
+
: "bg-destructive/10 text-destructive"
|
| 214 |
+
}
|
| 215 |
+
`}
|
| 216 |
+
>
|
| 217 |
+
{user.deleted ? (
|
| 218 |
+
<Unlock className="w-6 h-6" />
|
| 219 |
+
) : (
|
| 220 |
+
<Lock className="w-6 h-6" />
|
| 221 |
+
)}
|
| 222 |
+
</div>
|
| 223 |
+
<div className="flex-1">
|
| 224 |
+
<h3 className="text-lg font-semibold mb-1">
|
| 225 |
+
{user.deleted
|
| 226 |
+
? t("users.blacklist.unblockConfirm.title")
|
| 227 |
+
: t("users.blacklist.blockConfirm.title")}
|
| 228 |
+
</h3>
|
| 229 |
+
<p className="text-sm text-muted-foreground">
|
| 230 |
+
{user.deleted
|
| 231 |
+
? t("users.blacklist.unblockConfirm.description", {
|
| 232 |
+
name: user.name,
|
| 233 |
+
})
|
| 234 |
+
: t("users.blacklist.blockConfirm.description", {
|
| 235 |
+
name: user.name,
|
| 236 |
+
})}
|
| 237 |
+
</p>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<div className="flex gap-3">
|
| 242 |
+
<motion.button
|
| 243 |
+
whileHover={{ scale: 1.02 }}
|
| 244 |
+
whileTap={{ scale: 0.98 }}
|
| 245 |
+
onClick={onClose}
|
| 246 |
+
className="flex-1 px-4 py-2 rounded-xl bg-muted/60 hover:bg-muted/80
|
| 247 |
+
text-muted-foreground font-medium transition-colors"
|
| 248 |
+
>
|
| 249 |
+
{t("common.cancel")}
|
| 250 |
+
</motion.button>
|
| 251 |
+
<motion.button
|
| 252 |
+
whileHover={{ scale: 1.02 }}
|
| 253 |
+
whileTap={{ scale: 0.98 }}
|
| 254 |
+
onClick={onConfirm}
|
| 255 |
+
className={`
|
| 256 |
+
flex-1 px-4 py-2 rounded-xl font-medium text-white
|
| 257 |
+
transition-colors
|
| 258 |
+
${
|
| 259 |
+
user.deleted
|
| 260 |
+
? "bg-primary hover:bg-primary/90"
|
| 261 |
+
: "bg-destructive hover:bg-destructive/90"
|
| 262 |
+
}
|
| 263 |
+
`}
|
| 264 |
+
>
|
| 265 |
+
{user.deleted
|
| 266 |
+
? t("users.blacklist.unblock")
|
| 267 |
+
: t("users.blacklist.block")}
|
| 268 |
+
</motion.button>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
</motion.div>
|
| 273 |
+
</motion.div>,
|
| 274 |
+
document.getElementById("modal-root") || document.body
|
| 275 |
+
);
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
// 修改 LoadingState 组件,添加 t 参数
|
| 279 |
+
const LoadingState = ({ t }: { t: TFunction }) => (
|
| 280 |
+
<div className="flex flex-col items-center justify-center py-12 px-4">
|
| 281 |
+
<div className="h-12 w-12 rounded-full border-4 border-primary/10 border-t-primary animate-spin mb-4" />
|
| 282 |
+
<h3 className="text-lg font-medium text-foreground/70">
|
| 283 |
+
{t("users.loading")}
|
| 284 |
+
</h3>
|
| 285 |
+
</div>
|
| 286 |
+
);
|
| 287 |
+
|
| 288 |
+
export default function UsersPage() {
|
| 289 |
+
const { t } = useTranslation("common");
|
| 290 |
+
const [users, setUsers] = useState<User[]>([]);
|
| 291 |
+
const [blacklistUsers, setBlacklistUsers] = useState<User[]>([]);
|
| 292 |
+
const [loading, setLoading] = useState(false);
|
| 293 |
+
const [total, setTotal] = useState(0);
|
| 294 |
+
const [currentPage, setCurrentPage] = useState(1);
|
| 295 |
+
const [editingKey, setEditingKey] = useState<string>("");
|
| 296 |
+
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
| 297 |
+
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
| 298 |
+
const [sortInfo, setSortInfo] = useState<{
|
| 299 |
+
field: string | null;
|
| 300 |
+
order: "ascend" | "descend" | null;
|
| 301 |
+
}>({
|
| 302 |
+
field: null,
|
| 303 |
+
order: null,
|
| 304 |
+
});
|
| 305 |
+
const [searchText, setSearchText] = useState("");
|
| 306 |
+
const [showBlacklist, setShowBlacklist] = useState(false);
|
| 307 |
+
const [blacklistCurrentPage, setBlacklistCurrentPage] = useState(1);
|
| 308 |
+
const [blacklistTotal, setBlacklistTotal] = useState(0);
|
| 309 |
+
|
| 310 |
+
const fetchUsers = async (page: number, isBlacklist: boolean = false) => {
|
| 311 |
+
setLoading(true);
|
| 312 |
+
try {
|
| 313 |
+
let url = `/api/users?page=${page}&deleted=${isBlacklist}`;
|
| 314 |
+
if (sortInfo.field && sortInfo.order) {
|
| 315 |
+
url += `&sortField=${sortInfo.field}&sortOrder=${sortInfo.order}`;
|
| 316 |
+
}
|
| 317 |
+
if (searchText) {
|
| 318 |
+
url += `&search=${encodeURIComponent(searchText)}`;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const res = await fetch(url);
|
| 322 |
+
const data = await res.json();
|
| 323 |
+
if (!res.ok) throw new Error(data.error);
|
| 324 |
+
|
| 325 |
+
if (isBlacklist) {
|
| 326 |
+
setBlacklistUsers(data.users);
|
| 327 |
+
setBlacklistTotal(data.total);
|
| 328 |
+
} else {
|
| 329 |
+
setUsers(data.users);
|
| 330 |
+
setTotal(data.total);
|
| 331 |
+
}
|
| 332 |
+
} catch (err) {
|
| 333 |
+
console.error(err);
|
| 334 |
+
message.error(t("users.message.fetchError"));
|
| 335 |
+
} finally {
|
| 336 |
+
setLoading(false);
|
| 337 |
+
}
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
const fetchBlacklistTotal = async () => {
|
| 341 |
+
try {
|
| 342 |
+
const res = await fetch(`/api/users?page=1&deleted=true&pageSize=1`);
|
| 343 |
+
const data = await res.json();
|
| 344 |
+
if (!res.ok) throw new Error(data.error);
|
| 345 |
+
setBlacklistTotal(data.total);
|
| 346 |
+
} catch (err) {
|
| 347 |
+
console.error(err);
|
| 348 |
+
}
|
| 349 |
+
};
|
| 350 |
+
|
| 351 |
+
useEffect(() => {
|
| 352 |
+
fetchUsers(currentPage, false);
|
| 353 |
+
fetchBlacklistTotal();
|
| 354 |
+
}, [currentPage, sortInfo, searchText]);
|
| 355 |
+
|
| 356 |
+
useEffect(() => {
|
| 357 |
+
if (showBlacklist) {
|
| 358 |
+
fetchUsers(blacklistCurrentPage, true);
|
| 359 |
+
}
|
| 360 |
+
}, [blacklistCurrentPage, showBlacklist, sortInfo, searchText]);
|
| 361 |
+
|
| 362 |
+
const handleUpdateBalance = async (userId: string, newBalance: number) => {
|
| 363 |
+
try {
|
| 364 |
+
const res = await fetch(`/api/users/${userId}/balance`, {
|
| 365 |
+
method: "PUT",
|
| 366 |
+
headers: { "Content-Type": "application/json" },
|
| 367 |
+
body: JSON.stringify({ balance: newBalance }),
|
| 368 |
+
});
|
| 369 |
+
|
| 370 |
+
const data = await res.json();
|
| 371 |
+
if (!res.ok) throw new Error(data.error);
|
| 372 |
+
|
| 373 |
+
// 立即更新本地数据
|
| 374 |
+
setUsers(
|
| 375 |
+
users.map((user) =>
|
| 376 |
+
user.id === userId ? { ...user, balance: newBalance } : user
|
| 377 |
+
)
|
| 378 |
+
);
|
| 379 |
+
|
| 380 |
+
// 然后再显示成功提示并清除编辑状态
|
| 381 |
+
toast.success(t("users.message.updateBalance.success"));
|
| 382 |
+
setEditingKey("");
|
| 383 |
+
|
| 384 |
+
// 最后再重新获取完整列表
|
| 385 |
+
fetchUsers(currentPage, false);
|
| 386 |
+
} catch (err) {
|
| 387 |
+
toast.error(
|
| 388 |
+
err instanceof Error
|
| 389 |
+
? err.message
|
| 390 |
+
: t("users.message.updateBalance.error")
|
| 391 |
+
);
|
| 392 |
+
}
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
const handleDeleteUser = async () => {
|
| 396 |
+
if (!userToDelete) return;
|
| 397 |
+
|
| 398 |
+
try {
|
| 399 |
+
const res = await fetch(`/api/users/${userToDelete.id}`, {
|
| 400 |
+
method: "PATCH",
|
| 401 |
+
headers: {
|
| 402 |
+
"Content-Type": "application/json",
|
| 403 |
+
},
|
| 404 |
+
body: JSON.stringify({
|
| 405 |
+
deleted: !userToDelete.deleted,
|
| 406 |
+
}),
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
if (!res.ok) {
|
| 410 |
+
const error = await res.json();
|
| 411 |
+
throw new Error(error.message);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
if (!userToDelete.deleted) {
|
| 415 |
+
const newTotal = total - 1;
|
| 416 |
+
const maxPage = Math.ceil(newTotal / 20);
|
| 417 |
+
if (currentPage > maxPage && maxPage > 0) {
|
| 418 |
+
setCurrentPage(maxPage);
|
| 419 |
+
} else {
|
| 420 |
+
fetchUsers(currentPage, false);
|
| 421 |
+
}
|
| 422 |
+
setBlacklistTotal((prev) => prev + 1);
|
| 423 |
+
} else {
|
| 424 |
+
const newBlacklistTotal = blacklistTotal - 1;
|
| 425 |
+
const maxBlacklistPage = Math.ceil(newBlacklistTotal / 20);
|
| 426 |
+
if (blacklistCurrentPage > maxBlacklistPage && maxBlacklistPage > 0) {
|
| 427 |
+
setBlacklistCurrentPage(maxBlacklistPage);
|
| 428 |
+
} else {
|
| 429 |
+
fetchUsers(blacklistCurrentPage, true);
|
| 430 |
+
}
|
| 431 |
+
setBlacklistTotal((prev) => prev - 1);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
if (userToDelete.deleted) {
|
| 435 |
+
fetchUsers(currentPage, false);
|
| 436 |
+
} else if (showBlacklist) {
|
| 437 |
+
fetchUsers(blacklistCurrentPage, true);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
toast.success(
|
| 441 |
+
userToDelete.deleted
|
| 442 |
+
? t("users.message.unblockSuccess")
|
| 443 |
+
: t("users.message.blockSuccess")
|
| 444 |
+
);
|
| 445 |
+
} catch (err) {
|
| 446 |
+
toast.error(
|
| 447 |
+
userToDelete.deleted
|
| 448 |
+
? t("users.message.unblockError")
|
| 449 |
+
: t("users.message.blockError")
|
| 450 |
+
);
|
| 451 |
+
} finally {
|
| 452 |
+
setUserToDelete(null);
|
| 453 |
+
}
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
const UserCard = ({ record }: { record: User }) => {
|
| 457 |
+
return (
|
| 458 |
+
<div
|
| 459 |
+
className="p-4 sm:p-6 bg-card rounded-xl border border-border/40
|
| 460 |
+
shadow-sm hover:shadow-md transition-all duration-200"
|
| 461 |
+
>
|
| 462 |
+
<div className="flex items-start gap-4">
|
| 463 |
+
<div
|
| 464 |
+
className="flex-1 min-w-0 flex items-start gap-4 cursor-pointer"
|
| 465 |
+
onClick={() => setSelectedUser(record)}
|
| 466 |
+
>
|
| 467 |
+
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary font-medium text-lg shrink-0">
|
| 468 |
+
{record.name.charAt(0).toUpperCase()}
|
| 469 |
+
</div>
|
| 470 |
+
<div className="flex-1 min-w-0 space-y-1">
|
| 471 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 472 |
+
<h3 className="text-base font-semibold tracking-tight max-w-[160px] truncate">
|
| 473 |
+
{record.name}
|
| 474 |
+
</h3>
|
| 475 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-primary/10 text-primary font-medium">
|
| 476 |
+
{record.role}
|
| 477 |
+
</span>
|
| 478 |
+
</div>
|
| 479 |
+
<p className="text-sm text-muted-foreground max-w-[240px] truncate">
|
| 480 |
+
{record.email}
|
| 481 |
+
</p>
|
| 482 |
+
</div>
|
| 483 |
+
</div>
|
| 484 |
+
|
| 485 |
+
<button
|
| 486 |
+
onClick={(e) => {
|
| 487 |
+
e.stopPropagation();
|
| 488 |
+
setUserToDelete(record);
|
| 489 |
+
}}
|
| 490 |
+
className={`
|
| 491 |
+
shrink-0
|
| 492 |
+
p-2
|
| 493 |
+
rounded-md
|
| 494 |
+
transition-colors
|
| 495 |
+
${
|
| 496 |
+
record.deleted
|
| 497 |
+
? "text-muted-foreground/60 hover:text-muted-foreground"
|
| 498 |
+
: "text-muted-foreground/60 hover:text-muted-foreground"
|
| 499 |
+
}
|
| 500 |
+
`}
|
| 501 |
+
>
|
| 502 |
+
{record.deleted ? (
|
| 503 |
+
<svg
|
| 504 |
+
className="w-4 h-4"
|
| 505 |
+
fill="none"
|
| 506 |
+
stroke="currentColor"
|
| 507 |
+
viewBox="0 0 24 24"
|
| 508 |
+
>
|
| 509 |
+
<path
|
| 510 |
+
strokeLinecap="round"
|
| 511 |
+
strokeLinejoin="round"
|
| 512 |
+
strokeWidth={2}
|
| 513 |
+
d="M12 15l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
| 514 |
+
/>
|
| 515 |
+
</svg>
|
| 516 |
+
) : (
|
| 517 |
+
<svg
|
| 518 |
+
className="w-4 h-4"
|
| 519 |
+
fill="none"
|
| 520 |
+
stroke="currentColor"
|
| 521 |
+
viewBox="0 0 24 24"
|
| 522 |
+
>
|
| 523 |
+
<path
|
| 524 |
+
strokeLinecap="round"
|
| 525 |
+
strokeLinejoin="round"
|
| 526 |
+
strokeWidth={2}
|
| 527 |
+
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
| 528 |
+
/>
|
| 529 |
+
</svg>
|
| 530 |
+
)}
|
| 531 |
+
</button>
|
| 532 |
+
</div>
|
| 533 |
+
|
| 534 |
+
<div className="mt-6">
|
| 535 |
+
<div className="flex items-center justify-between">
|
| 536 |
+
<span className="text-sm text-muted-foreground">
|
| 537 |
+
{t("users.balance")}
|
| 538 |
+
</span>
|
| 539 |
+
<div className="flex-1 max-w-[200px]">
|
| 540 |
+
<EditableCell
|
| 541 |
+
value={record.balance}
|
| 542 |
+
isEditing={record.id === editingKey}
|
| 543 |
+
onEdit={() => setEditingKey(record.id)}
|
| 544 |
+
onSubmit={(value) => handleUpdateBalance(record.id, value)}
|
| 545 |
+
onCancel={() => setEditingKey("")}
|
| 546 |
+
t={t}
|
| 547 |
+
validateValue={(value) => ({
|
| 548 |
+
isValid: isFinite(value),
|
| 549 |
+
errorMessage: t("error.invalidNumber"),
|
| 550 |
+
maxValue: 999999.9999,
|
| 551 |
+
})}
|
| 552 |
+
/>
|
| 553 |
+
</div>
|
| 554 |
+
</div>
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
);
|
| 558 |
+
};
|
| 559 |
+
|
| 560 |
+
const getColumns = (isBlacklist: boolean = false): ColumnsType<User> => {
|
| 561 |
+
const baseColumns: ColumnsType<User> = [
|
| 562 |
+
{
|
| 563 |
+
title: t("users.userInfo"),
|
| 564 |
+
key: "userInfo",
|
| 565 |
+
width: "65%",
|
| 566 |
+
render: (_, record) => (
|
| 567 |
+
<div
|
| 568 |
+
className="flex items-center gap-4 cursor-pointer py-1"
|
| 569 |
+
onClick={() => setSelectedUser(record)}
|
| 570 |
+
>
|
| 571 |
+
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary font-medium">
|
| 572 |
+
{record.name.charAt(0).toUpperCase()}
|
| 573 |
+
</div>
|
| 574 |
+
<div className="flex-1 min-w-0">
|
| 575 |
+
<div className="flex items-center gap-2 mb-1">
|
| 576 |
+
<span className="font-medium max-w-[200px] truncate">
|
| 577 |
+
{record.name}
|
| 578 |
+
</span>
|
| 579 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-primary/10 text-primary font-medium">
|
| 580 |
+
{record.role}
|
| 581 |
+
</span>
|
| 582 |
+
</div>
|
| 583 |
+
<div className="text-sm text-muted-foreground max-w-[280px] truncate">
|
| 584 |
+
{record.email}
|
| 585 |
+
</div>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
),
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
title: t("users.balance"),
|
| 592 |
+
dataIndex: "balance",
|
| 593 |
+
key: "balance",
|
| 594 |
+
width: "35%",
|
| 595 |
+
align: "left",
|
| 596 |
+
sorter: {
|
| 597 |
+
compare: (a, b) => a.balance - b.balance,
|
| 598 |
+
multiple: 1,
|
| 599 |
+
},
|
| 600 |
+
render: (balance: number, record) => {
|
| 601 |
+
const isEditing = record.id === editingKey;
|
| 602 |
+
|
| 603 |
+
return (
|
| 604 |
+
<div className="flex items-center gap-4">
|
| 605 |
+
<div className="flex-1">
|
| 606 |
+
<EditableCell
|
| 607 |
+
value={balance}
|
| 608 |
+
isEditing={isEditing}
|
| 609 |
+
onEdit={() => setEditingKey(record.id)}
|
| 610 |
+
onSubmit={(value) => handleUpdateBalance(record.id, value)}
|
| 611 |
+
onCancel={() => setEditingKey("")}
|
| 612 |
+
t={t}
|
| 613 |
+
validateValue={(value) => ({
|
| 614 |
+
isValid: isFinite(value),
|
| 615 |
+
errorMessage: t("error.invalidNumber"),
|
| 616 |
+
maxValue: 999999.9999,
|
| 617 |
+
})}
|
| 618 |
+
/>
|
| 619 |
+
</div>
|
| 620 |
+
</div>
|
| 621 |
+
);
|
| 622 |
+
},
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
title: t("users.actions"),
|
| 626 |
+
key: "actions",
|
| 627 |
+
width: "48px",
|
| 628 |
+
align: "center",
|
| 629 |
+
render: (_, record) => (
|
| 630 |
+
<button
|
| 631 |
+
onClick={(e) => {
|
| 632 |
+
e.stopPropagation();
|
| 633 |
+
setUserToDelete(record);
|
| 634 |
+
}}
|
| 635 |
+
className={`
|
| 636 |
+
p-2
|
| 637 |
+
rounded-md
|
| 638 |
+
transition-colors
|
| 639 |
+
${
|
| 640 |
+
record.deleted
|
| 641 |
+
? "text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40"
|
| 642 |
+
: "text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40"
|
| 643 |
+
}
|
| 644 |
+
`}
|
| 645 |
+
>
|
| 646 |
+
{record.deleted ? (
|
| 647 |
+
<svg
|
| 648 |
+
className="w-4 h-4"
|
| 649 |
+
fill="none"
|
| 650 |
+
stroke="currentColor"
|
| 651 |
+
viewBox="0 0 24 24"
|
| 652 |
+
>
|
| 653 |
+
<path
|
| 654 |
+
strokeLinecap="round"
|
| 655 |
+
strokeLinejoin="round"
|
| 656 |
+
strokeWidth={2}
|
| 657 |
+
d="M12 15l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
| 658 |
+
/>
|
| 659 |
+
</svg>
|
| 660 |
+
) : (
|
| 661 |
+
<svg
|
| 662 |
+
className="w-4 h-4"
|
| 663 |
+
fill="none"
|
| 664 |
+
stroke="currentColor"
|
| 665 |
+
viewBox="0 0 24 24"
|
| 666 |
+
>
|
| 667 |
+
<path
|
| 668 |
+
strokeLinecap="round"
|
| 669 |
+
strokeLinejoin="round"
|
| 670 |
+
strokeWidth={2}
|
| 671 |
+
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
| 672 |
+
/>
|
| 673 |
+
</svg>
|
| 674 |
+
)}
|
| 675 |
+
</button>
|
| 676 |
+
),
|
| 677 |
+
},
|
| 678 |
+
];
|
| 679 |
+
|
| 680 |
+
return baseColumns;
|
| 681 |
+
};
|
| 682 |
+
|
| 683 |
+
const SearchBar = () => {
|
| 684 |
+
const [isFocused, setIsFocused] = useState(false);
|
| 685 |
+
const [searchValue, setSearchValue] = useState(searchText);
|
| 686 |
+
|
| 687 |
+
const handleSearch = () => {
|
| 688 |
+
setSearchText(searchValue);
|
| 689 |
+
};
|
| 690 |
+
|
| 691 |
+
const handleClear = () => {
|
| 692 |
+
setSearchValue("");
|
| 693 |
+
setTimeout(() => {
|
| 694 |
+
setSearchText("");
|
| 695 |
+
}, 0);
|
| 696 |
+
};
|
| 697 |
+
|
| 698 |
+
return (
|
| 699 |
+
<motion.div
|
| 700 |
+
initial={false}
|
| 701 |
+
animate={{
|
| 702 |
+
boxShadow: isFocused
|
| 703 |
+
? "0 4px 24px rgba(0, 0, 0, 0.08)"
|
| 704 |
+
: "0 2px 8px rgba(0, 0, 0, 0.04)",
|
| 705 |
+
}}
|
| 706 |
+
className="relative w-full rounded-2xl bg-card border border-border/40 overflow-hidden"
|
| 707 |
+
>
|
| 708 |
+
<motion.div
|
| 709 |
+
initial={false}
|
| 710 |
+
animate={{
|
| 711 |
+
height: "100%",
|
| 712 |
+
width: "3px",
|
| 713 |
+
left: 0,
|
| 714 |
+
opacity: isFocused ? 1 : 0,
|
| 715 |
+
}}
|
| 716 |
+
className="absolute top-0 bg-primary"
|
| 717 |
+
style={{ originY: 0 }}
|
| 718 |
+
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
| 719 |
+
/>
|
| 720 |
+
|
| 721 |
+
<div className="relative flex items-center">
|
| 722 |
+
<div className="absolute left-4 text-muted-foreground/60 pointer-events-none z-10">
|
| 723 |
+
<Search className="h-4 w-4" />
|
| 724 |
+
</div>
|
| 725 |
+
|
| 726 |
+
<Input
|
| 727 |
+
type="search"
|
| 728 |
+
autoComplete="off"
|
| 729 |
+
spellCheck="false"
|
| 730 |
+
placeholder={t("users.searchPlaceholder")}
|
| 731 |
+
value={searchValue}
|
| 732 |
+
onChange={(e) => setSearchValue(e.target.value)}
|
| 733 |
+
onFocus={() => setIsFocused(true)}
|
| 734 |
+
onBlur={() => setIsFocused(false)}
|
| 735 |
+
onKeyDown={(e) => {
|
| 736 |
+
if (e.key === "Enter") {
|
| 737 |
+
handleSearch();
|
| 738 |
+
}
|
| 739 |
+
}}
|
| 740 |
+
className="
|
| 741 |
+
w-full
|
| 742 |
+
pl-12
|
| 743 |
+
pr-24
|
| 744 |
+
py-3
|
| 745 |
+
h-12
|
| 746 |
+
leading-normal
|
| 747 |
+
bg-transparent
|
| 748 |
+
border-0
|
| 749 |
+
ring-0
|
| 750 |
+
focus:ring-0
|
| 751 |
+
placeholder:text-muted-foreground/50
|
| 752 |
+
text-base
|
| 753 |
+
[&::-webkit-search-cancel-button]:hidden
|
| 754 |
+
"
|
| 755 |
+
allowClear={{
|
| 756 |
+
clearIcon: searchValue ? (
|
| 757 |
+
<motion.button
|
| 758 |
+
initial={{ scale: 0.5, opacity: 0 }}
|
| 759 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 760 |
+
exit={{ scale: 0.5, opacity: 0 }}
|
| 761 |
+
className="p-1.5 hover:bg-muted/80 rounded-full transition-colors z-10
|
| 762 |
+
bg-muted/60 text-muted-foreground/70"
|
| 763 |
+
onClick={handleClear}
|
| 764 |
+
>
|
| 765 |
+
<X className="h-3 w-3" />
|
| 766 |
+
</motion.button>
|
| 767 |
+
) : null,
|
| 768 |
+
}}
|
| 769 |
+
/>
|
| 770 |
+
|
| 771 |
+
<div className="absolute right-4 flex items-center pointer-events-auto z-20">
|
| 772 |
+
<button
|
| 773 |
+
onClick={handleSearch}
|
| 774 |
+
className="text-xs bg-primary/10 text-primary hover:bg-primary/20
|
| 775 |
+
transition-colors px-3 py-1.5 rounded-full font-medium"
|
| 776 |
+
>
|
| 777 |
+
{t("users.search")}
|
| 778 |
+
</button>
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
</motion.div>
|
| 782 |
+
);
|
| 783 |
+
};
|
| 784 |
+
|
| 785 |
+
// 添加空状态组件
|
| 786 |
+
const EmptyState = ({ searchText }: { searchText: string }) => (
|
| 787 |
+
<div className="flex flex-col items-center justify-center py-12 px-4">
|
| 788 |
+
<div className="h-12 w-12 rounded-full bg-muted/40 flex items-center justify-center mb-4">
|
| 789 |
+
<Search className="h-6 w-6 text-muted-foreground/50" />
|
| 790 |
+
</div>
|
| 791 |
+
<h3 className="text-lg font-medium text-foreground mb-2">
|
| 792 |
+
{t("users.noResults.title")}
|
| 793 |
+
</h3>
|
| 794 |
+
<p className="text-sm text-muted-foreground text-center max-w-[300px]">
|
| 795 |
+
{searchText
|
| 796 |
+
? t("users.noResults.withFilter", { filter: searchText })
|
| 797 |
+
: t("users.noResults.default")}
|
| 798 |
+
</p>
|
| 799 |
+
</div>
|
| 800 |
+
);
|
| 801 |
+
|
| 802 |
+
return (
|
| 803 |
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 space-y-8">
|
| 804 |
+
<Toaster
|
| 805 |
+
richColors
|
| 806 |
+
position="top-center"
|
| 807 |
+
theme="light"
|
| 808 |
+
expand
|
| 809 |
+
duration={1500}
|
| 810 |
+
/>
|
| 811 |
+
<div className="space-y-4">
|
| 812 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 813 |
+
{t("users.title")}
|
| 814 |
+
</h1>
|
| 815 |
+
<p className="text-muted-foreground">{t("users.description")}</p>
|
| 816 |
+
</div>
|
| 817 |
+
|
| 818 |
+
<SearchBar />
|
| 819 |
+
|
| 820 |
+
<div className="hidden sm:block">
|
| 821 |
+
<div className="rounded-xl border border-border/40 bg-card shadow-sm overflow-hidden">
|
| 822 |
+
{loading ? (
|
| 823 |
+
<LoadingState t={t} />
|
| 824 |
+
) : users.filter((user) => !user.deleted).length > 0 ? (
|
| 825 |
+
<Table
|
| 826 |
+
columns={getColumns(false)}
|
| 827 |
+
dataSource={users
|
| 828 |
+
.filter((user) => !user.deleted)
|
| 829 |
+
.map((user) => ({
|
| 830 |
+
key: user.id,
|
| 831 |
+
...user,
|
| 832 |
+
balance: Number(user.balance),
|
| 833 |
+
}))}
|
| 834 |
+
rowKey="id"
|
| 835 |
+
loading={false}
|
| 836 |
+
className={TABLE_STYLES}
|
| 837 |
+
pagination={{
|
| 838 |
+
total,
|
| 839 |
+
pageSize: 20,
|
| 840 |
+
current: currentPage,
|
| 841 |
+
onChange: (page) => {
|
| 842 |
+
setCurrentPage(page);
|
| 843 |
+
setEditingKey("");
|
| 844 |
+
},
|
| 845 |
+
showTotal: (total) => (
|
| 846 |
+
<span className="text-sm text-muted-foreground">
|
| 847 |
+
{t("users.total")} {total} {t("users.totalRecords")}
|
| 848 |
+
</span>
|
| 849 |
+
),
|
| 850 |
+
}}
|
| 851 |
+
scroll={{ x: 500 }}
|
| 852 |
+
onChange={(pagination, filters, sorter) => {
|
| 853 |
+
if (Array.isArray(sorter)) return;
|
| 854 |
+
setSortInfo({
|
| 855 |
+
field: sorter.columnKey as string,
|
| 856 |
+
order: sorter.order || null,
|
| 857 |
+
});
|
| 858 |
+
}}
|
| 859 |
+
/>
|
| 860 |
+
) : (
|
| 861 |
+
<EmptyState searchText={searchText} />
|
| 862 |
+
)}
|
| 863 |
+
</div>
|
| 864 |
+
</div>
|
| 865 |
+
|
| 866 |
+
<div className="sm:hidden">
|
| 867 |
+
<div className="grid gap-4">
|
| 868 |
+
{loading ? (
|
| 869 |
+
<LoadingState t={t} />
|
| 870 |
+
) : users.filter((user) => !user.deleted).length > 0 ? (
|
| 871 |
+
users
|
| 872 |
+
.filter((user) => !user.deleted)
|
| 873 |
+
.map((user) => <UserCard key={user.id} record={user} />)
|
| 874 |
+
) : (
|
| 875 |
+
<EmptyState searchText={searchText} />
|
| 876 |
+
)}
|
| 877 |
+
</div>
|
| 878 |
+
</div>
|
| 879 |
+
|
| 880 |
+
<div className="space-y-4">
|
| 881 |
+
<button
|
| 882 |
+
onClick={() => setShowBlacklist(!showBlacklist)}
|
| 883 |
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
| 884 |
+
>
|
| 885 |
+
<svg
|
| 886 |
+
className={`w-4 h-4 transition-transform ${
|
| 887 |
+
showBlacklist ? "rotate-180" : ""
|
| 888 |
+
}`}
|
| 889 |
+
fill="none"
|
| 890 |
+
stroke="currentColor"
|
| 891 |
+
viewBox="0 0 24 24"
|
| 892 |
+
>
|
| 893 |
+
<path
|
| 894 |
+
strokeLinecap="round"
|
| 895 |
+
strokeLinejoin="round"
|
| 896 |
+
strokeWidth={2}
|
| 897 |
+
d="M19 9l-7 7-7-7"
|
| 898 |
+
/>
|
| 899 |
+
</svg>
|
| 900 |
+
{t("users.blacklist.title")} ({blacklistTotal})
|
| 901 |
+
</button>
|
| 902 |
+
|
| 903 |
+
{showBlacklist && (
|
| 904 |
+
<div className="space-y-4">
|
| 905 |
+
<div className="hidden sm:block">
|
| 906 |
+
<div className="rounded-xl border border-border/40 bg-card shadow-sm overflow-hidden">
|
| 907 |
+
{loading ? (
|
| 908 |
+
<LoadingState t={t} />
|
| 909 |
+
) : blacklistUsers.length > 0 ? (
|
| 910 |
+
<Table
|
| 911 |
+
columns={getColumns(true)}
|
| 912 |
+
dataSource={blacklistUsers.map((user) => ({
|
| 913 |
+
key: user.id,
|
| 914 |
+
...user,
|
| 915 |
+
balance: Number(user.balance),
|
| 916 |
+
}))}
|
| 917 |
+
rowKey="id"
|
| 918 |
+
loading={false}
|
| 919 |
+
className={TABLE_STYLES}
|
| 920 |
+
pagination={{
|
| 921 |
+
total: blacklistTotal,
|
| 922 |
+
pageSize: 20,
|
| 923 |
+
current: blacklistCurrentPage,
|
| 924 |
+
onChange: (page) => {
|
| 925 |
+
setBlacklistCurrentPage(page);
|
| 926 |
+
setEditingKey("");
|
| 927 |
+
},
|
| 928 |
+
showTotal: (total) => (
|
| 929 |
+
<span className="text-sm text-muted-foreground">
|
| 930 |
+
{t("users.total")} {total} {t("users.totalRecords")}
|
| 931 |
+
</span>
|
| 932 |
+
),
|
| 933 |
+
}}
|
| 934 |
+
/>
|
| 935 |
+
) : (
|
| 936 |
+
<EmptyState searchText={searchText} />
|
| 937 |
+
)}
|
| 938 |
+
</div>
|
| 939 |
+
</div>
|
| 940 |
+
|
| 941 |
+
<div className="sm:hidden">
|
| 942 |
+
<div className="grid gap-4">
|
| 943 |
+
{loading ? (
|
| 944 |
+
<LoadingState t={t} />
|
| 945 |
+
) : blacklistUsers.length > 0 ? (
|
| 946 |
+
blacklistUsers.map((user) => (
|
| 947 |
+
<UserCard key={user.id} record={user} />
|
| 948 |
+
))
|
| 949 |
+
) : (
|
| 950 |
+
<EmptyState searchText={searchText} />
|
| 951 |
+
)}
|
| 952 |
+
</div>
|
| 953 |
+
</div>
|
| 954 |
+
</div>
|
| 955 |
+
)}
|
| 956 |
+
</div>
|
| 957 |
+
|
| 958 |
+
<AnimatePresence>
|
| 959 |
+
{selectedUser && (
|
| 960 |
+
<UserDetailsModal
|
| 961 |
+
user={selectedUser}
|
| 962 |
+
onClose={() => setSelectedUser(null)}
|
| 963 |
+
t={t}
|
| 964 |
+
/>
|
| 965 |
+
)}
|
| 966 |
+
</AnimatePresence>
|
| 967 |
+
|
| 968 |
+
<AnimatePresence>
|
| 969 |
+
{userToDelete && (
|
| 970 |
+
<BlockConfirmModal
|
| 971 |
+
user={userToDelete}
|
| 972 |
+
onClose={() => setUserToDelete(null)}
|
| 973 |
+
onConfirm={handleDeleteUser}
|
| 974 |
+
t={t}
|
| 975 |
+
/>
|
| 976 |
+
)}
|
| 977 |
+
</AnimatePresence>
|
| 978 |
+
</div>
|
| 979 |
+
);
|
| 980 |
+
}
|
components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "zinc",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
components/AuthCheck.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { usePathname, useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
export default function AuthCheck({ children }: { children: React.ReactNode }) {
|
| 7 |
+
const [isAuthorized, setIsAuthorized] = useState(false);
|
| 8 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 9 |
+
const router = useRouter();
|
| 10 |
+
const pathname = usePathname();
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const checkAuth = async () => {
|
| 14 |
+
// 如果已经在token页面,不需要检查
|
| 15 |
+
if (pathname === "/token") {
|
| 16 |
+
setIsLoading(false);
|
| 17 |
+
setIsAuthorized(true);
|
| 18 |
+
return;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const token = localStorage.getItem("access_token");
|
| 22 |
+
if (!token) {
|
| 23 |
+
router.push("/token");
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const res = await fetch("/api/config", {
|
| 29 |
+
headers: {
|
| 30 |
+
Authorization: `Bearer ${token}`,
|
| 31 |
+
},
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
if (!res.ok) {
|
| 35 |
+
localStorage.removeItem("access_token");
|
| 36 |
+
router.push("/token");
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
setIsAuthorized(true);
|
| 41 |
+
} catch (error) {
|
| 42 |
+
localStorage.removeItem("access_token");
|
| 43 |
+
router.push("/token");
|
| 44 |
+
} finally {
|
| 45 |
+
setIsLoading(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
checkAuth();
|
| 50 |
+
}, [router, pathname]);
|
| 51 |
+
|
| 52 |
+
// 显示加载状态或空白页面
|
| 53 |
+
if (isLoading || !isAuthorized) {
|
| 54 |
+
return null;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return <>{children}</>;
|
| 58 |
+
}
|
components/DatabaseBackup.tsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useState } from "react";
|
| 4 |
+
import { toast, Toaster } from "sonner";
|
| 5 |
+
import { useTranslation } from "react-i18next";
|
| 6 |
+
import {
|
| 7 |
+
Dialog,
|
| 8 |
+
DialogContent,
|
| 9 |
+
DialogHeader,
|
| 10 |
+
DialogTitle,
|
| 11 |
+
DialogDescription,
|
| 12 |
+
} from "@/components/ui/dialog";
|
| 13 |
+
import { DatabaseIcon, DownloadIcon, UploadIcon, Loader2 } from "lucide-react";
|
| 14 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 15 |
+
import { cn } from "@/lib/utils";
|
| 16 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 17 |
+
|
| 18 |
+
interface DatabaseBackupProps {
|
| 19 |
+
open: boolean;
|
| 20 |
+
onClose: () => void;
|
| 21 |
+
token?: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export default function DatabaseBackup({
|
| 25 |
+
open,
|
| 26 |
+
onClose,
|
| 27 |
+
token,
|
| 28 |
+
}: DatabaseBackupProps) {
|
| 29 |
+
const { t } = useTranslation("common");
|
| 30 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 31 |
+
const [isExporting, setIsExporting] = useState(false);
|
| 32 |
+
const [isImporting, setIsImporting] = useState(false);
|
| 33 |
+
|
| 34 |
+
const handleExport = async () => {
|
| 35 |
+
if (!token) {
|
| 36 |
+
toast.error(t("auth.unauthorized"));
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
setIsExporting(true);
|
| 41 |
+
try {
|
| 42 |
+
const response = await fetch("/api/v1/panel/database/export", {
|
| 43 |
+
headers: {
|
| 44 |
+
Authorization: `Bearer ${token}`,
|
| 45 |
+
},
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
if (!response.ok) {
|
| 49 |
+
throw new Error(t("backup.export.error"));
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const blob = await response.blob();
|
| 53 |
+
const url = window.URL.createObjectURL(blob);
|
| 54 |
+
const a = document.createElement("a");
|
| 55 |
+
a.href = url;
|
| 56 |
+
a.download = `openwebui_monitor_backup_${
|
| 57 |
+
new Date().toISOString().split("T")[0]
|
| 58 |
+
}.json`;
|
| 59 |
+
document.body.appendChild(a);
|
| 60 |
+
a.click();
|
| 61 |
+
window.URL.revokeObjectURL(url);
|
| 62 |
+
document.body.removeChild(a);
|
| 63 |
+
toast.success(t("backup.export.success"));
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error(t("backup.export.error"), error);
|
| 66 |
+
toast.error(t("backup.export.error"));
|
| 67 |
+
} finally {
|
| 68 |
+
setIsExporting(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 73 |
+
const file = event.target.files?.[0];
|
| 74 |
+
if (!file) return;
|
| 75 |
+
|
| 76 |
+
if (!token) {
|
| 77 |
+
toast.error(t("auth.unauthorized"));
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
setIsImporting(true);
|
| 82 |
+
try {
|
| 83 |
+
const content = await file.text();
|
| 84 |
+
const data = JSON.parse(content);
|
| 85 |
+
|
| 86 |
+
const response = await fetch("/api/v1/panel/database/import", {
|
| 87 |
+
method: "POST",
|
| 88 |
+
headers: {
|
| 89 |
+
"Content-Type": "application/json",
|
| 90 |
+
Authorization: `Bearer ${token}`,
|
| 91 |
+
},
|
| 92 |
+
body: JSON.stringify(data),
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
if (!response.ok) {
|
| 96 |
+
throw new Error(t("backup.import.error"));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const result = await response.json();
|
| 100 |
+
|
| 101 |
+
if (result.success) {
|
| 102 |
+
toast.success(t("backup.import.success"));
|
| 103 |
+
if (fileInputRef.current) {
|
| 104 |
+
fileInputRef.current.value = "";
|
| 105 |
+
}
|
| 106 |
+
} else {
|
| 107 |
+
throw new Error(result.error || t("backup.import.error"));
|
| 108 |
+
}
|
| 109 |
+
} catch (err) {
|
| 110 |
+
console.error(t("backup.import.error"), err);
|
| 111 |
+
toast.error(
|
| 112 |
+
err instanceof Error ? err.message : t("backup.import.error")
|
| 113 |
+
);
|
| 114 |
+
} finally {
|
| 115 |
+
setIsImporting(false);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
<AnimatePresence>
|
| 121 |
+
<Toaster
|
| 122 |
+
richColors
|
| 123 |
+
position="top-center"
|
| 124 |
+
theme="light"
|
| 125 |
+
expand
|
| 126 |
+
duration={1500}
|
| 127 |
+
/>
|
| 128 |
+
{open && (
|
| 129 |
+
<Dialog open={open} onOpenChange={onClose}>
|
| 130 |
+
<DialogContent className="w-[calc(100%-2rem)] !max-w-[400px] rounded-lg backdrop-blur-lg bg-white/90 border border-white/20 shadow-xl md:px-6">
|
| 131 |
+
<motion.div
|
| 132 |
+
initial={{ opacity: 0, y: 20 }}
|
| 133 |
+
animate={{ opacity: 1, y: 0 }}
|
| 134 |
+
exit={{ opacity: 0, y: 20 }}
|
| 135 |
+
>
|
| 136 |
+
<DialogHeader>
|
| 137 |
+
<div className="flex items-center gap-2">
|
| 138 |
+
<motion.div
|
| 139 |
+
initial={{ scale: 0.8, opacity: 0 }}
|
| 140 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 141 |
+
transition={{ delay: 0.1 }}
|
| 142 |
+
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-primary/10"
|
| 143 |
+
>
|
| 144 |
+
<DatabaseIcon className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
|
| 145 |
+
</motion.div>
|
| 146 |
+
<DialogTitle className="text-base sm:text-lg">
|
| 147 |
+
{t("backup.title")}
|
| 148 |
+
</DialogTitle>
|
| 149 |
+
</div>
|
| 150 |
+
<DialogDescription className="pt-2 text-sm">
|
| 151 |
+
{t("backup.description")}
|
| 152 |
+
</DialogDescription>
|
| 153 |
+
</DialogHeader>
|
| 154 |
+
|
| 155 |
+
<motion.div
|
| 156 |
+
initial={{ opacity: 0 }}
|
| 157 |
+
animate={{ opacity: 1 }}
|
| 158 |
+
transition={{ delay: 0.2 }}
|
| 159 |
+
className="grid grid-cols-1 gap-3 sm:gap-4 py-3 sm:py-4"
|
| 160 |
+
>
|
| 161 |
+
<Card
|
| 162 |
+
className={cn(
|
| 163 |
+
"cursor-pointer transition-all duration-300 hover:bg-accent/5",
|
| 164 |
+
"group relative overflow-hidden backdrop-blur-sm bg-white/50 border-white/20",
|
| 165 |
+
"hover:shadow-lg hover:scale-[1.02] transform-gpu"
|
| 166 |
+
)}
|
| 167 |
+
onClick={handleExport}
|
| 168 |
+
>
|
| 169 |
+
<CardContent className="flex items-center gap-3 sm:gap-4 p-4 sm:p-6">
|
| 170 |
+
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors duration-300">
|
| 171 |
+
{isExporting ? (
|
| 172 |
+
<Loader2 className="h-5 w-5 sm:h-6 sm:w-6 text-primary animate-spin" />
|
| 173 |
+
) : (
|
| 174 |
+
<DownloadIcon className="h-5 w-5 sm:h-6 sm:w-6 text-primary" />
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
<div className="space-y-0.5 sm:space-y-1">
|
| 178 |
+
<h3 className="font-medium leading-none text-sm sm:text-base group-hover:text-primary transition-colors duration-300">
|
| 179 |
+
{t("backup.export.title")}
|
| 180 |
+
</h3>
|
| 181 |
+
<p className="text-xs sm:text-sm text-muted-foreground group-hover:text-primary/80 transition-colors duration-300">
|
| 182 |
+
{t("backup.export.description")}
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
</CardContent>
|
| 186 |
+
</Card>
|
| 187 |
+
|
| 188 |
+
<Card
|
| 189 |
+
className={cn(
|
| 190 |
+
"cursor-pointer transition-all duration-300 hover:bg-accent/5",
|
| 191 |
+
"group relative overflow-hidden backdrop-blur-sm bg-white/50 border-white/20",
|
| 192 |
+
"hover:shadow-lg hover:scale-[1.02] transform-gpu"
|
| 193 |
+
)}
|
| 194 |
+
onClick={() => fileInputRef.current?.click()}
|
| 195 |
+
>
|
| 196 |
+
<input
|
| 197 |
+
ref={fileInputRef}
|
| 198 |
+
type="file"
|
| 199 |
+
accept=".json"
|
| 200 |
+
onChange={handleImport}
|
| 201 |
+
className="hidden"
|
| 202 |
+
/>
|
| 203 |
+
<CardContent className="flex items-center gap-3 sm:gap-4 p-4 sm:p-6">
|
| 204 |
+
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors duration-300">
|
| 205 |
+
{isImporting ? (
|
| 206 |
+
<Loader2 className="h-5 w-5 sm:h-6 sm:w-6 text-primary animate-spin" />
|
| 207 |
+
) : (
|
| 208 |
+
<UploadIcon className="h-5 w-5 sm:h-6 sm:w-6 text-primary" />
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
<div className="space-y-0.5 sm:space-y-1">
|
| 212 |
+
<h3 className="font-medium leading-none text-sm sm:text-base group-hover:text-primary transition-colors duration-300">
|
| 213 |
+
{t("backup.import.title")}
|
| 214 |
+
</h3>
|
| 215 |
+
<p className="text-xs sm:text-sm text-muted-foreground group-hover:text-primary/80 transition-colors duration-300">
|
| 216 |
+
{t("backup.import.description")}
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
</CardContent>
|
| 220 |
+
</Card>
|
| 221 |
+
</motion.div>
|
| 222 |
+
</motion.div>
|
| 223 |
+
</DialogContent>
|
| 224 |
+
</Dialog>
|
| 225 |
+
)}
|
| 226 |
+
</AnimatePresence>
|
| 227 |
+
);
|
| 228 |
+
}
|
components/Header.tsx
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useState, useEffect } from "react";
|
| 5 |
+
import { Dropdown, Modal } from "antd";
|
| 6 |
+
import { toast, Toaster } from "sonner";
|
| 7 |
+
import type { MenuProps } from "antd";
|
| 8 |
+
import {
|
| 9 |
+
Copy,
|
| 10 |
+
LogOut,
|
| 11 |
+
Database,
|
| 12 |
+
Github,
|
| 13 |
+
Menu,
|
| 14 |
+
Globe,
|
| 15 |
+
X,
|
| 16 |
+
Settings,
|
| 17 |
+
ChevronDown,
|
| 18 |
+
} from "lucide-react";
|
| 19 |
+
import DatabaseBackup from "./DatabaseBackup";
|
| 20 |
+
import { APP_VERSION } from "@/lib/version";
|
| 21 |
+
import { usePathname, useRouter } from "next/navigation";
|
| 22 |
+
import {
|
| 23 |
+
Dialog,
|
| 24 |
+
DialogContent,
|
| 25 |
+
DialogHeader,
|
| 26 |
+
DialogTitle,
|
| 27 |
+
DialogFooter,
|
| 28 |
+
} from "@/components/ui/dialog";
|
| 29 |
+
import { Button } from "@/components/ui/button";
|
| 30 |
+
import { createRoot } from "react-dom/client";
|
| 31 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 32 |
+
import { useTranslation } from "react-i18next";
|
| 33 |
+
import { FiDatabase, FiUsers, FiBarChart2 } from "react-icons/fi";
|
| 34 |
+
|
| 35 |
+
export default function Header() {
|
| 36 |
+
const { t, i18n } = useTranslation("common");
|
| 37 |
+
const pathname = usePathname();
|
| 38 |
+
const router = useRouter();
|
| 39 |
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
| 40 |
+
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
| 41 |
+
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
| 42 |
+
const [accessToken, setAccessToken] = useState<string | null>(null);
|
| 43 |
+
const [isSettingsExpanded, setIsSettingsExpanded] = useState(false);
|
| 44 |
+
|
| 45 |
+
// 将函数声明移到前面
|
| 46 |
+
const handleLanguageChange = async (newLang: string) => {
|
| 47 |
+
await i18n.changeLanguage(newLang);
|
| 48 |
+
localStorage.setItem("language", newLang);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
// 如果是token页面,只显示语言切换按钮
|
| 52 |
+
const isTokenPage = pathname === "/token";
|
| 53 |
+
|
| 54 |
+
if (isTokenPage) {
|
| 55 |
+
return (
|
| 56 |
+
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
| 57 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16">
|
| 58 |
+
<div className="h-full flex items-center justify-between">
|
| 59 |
+
<div className="text-xl font-semibold bg-gradient-to-r from-gray-900 via-indigo-800 to-gray-900 bg-clip-text text-transparent">
|
| 60 |
+
{t("common.appName")}
|
| 61 |
+
</div>
|
| 62 |
+
<button
|
| 63 |
+
className="p-2 rounded-lg hover:bg-gray-50/80 transition-colors relative group"
|
| 64 |
+
onClick={() =>
|
| 65 |
+
handleLanguageChange(i18n.language === "zh" ? "en" : "zh")
|
| 66 |
+
}
|
| 67 |
+
>
|
| 68 |
+
<Globe className="w-5 h-5 text-gray-600 group-hover:text-blue-500 transition-colors" />
|
| 69 |
+
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] text-[10px] font-medium bg-gray-100 text-gray-600 rounded-full border border-gray-200 shadow-sm px-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 70 |
+
{i18n.language === "zh"
|
| 71 |
+
? t("header.language.zh")
|
| 72 |
+
: t("header.language.en")}
|
| 73 |
+
</span>
|
| 74 |
+
</button>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</header>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const [apiKey, setApiKey] = useState(t("common.loading"));
|
| 82 |
+
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
const token = localStorage.getItem("access_token");
|
| 85 |
+
setAccessToken(token);
|
| 86 |
+
|
| 87 |
+
if (!token) {
|
| 88 |
+
router.push("/token");
|
| 89 |
+
return;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// 验证token的有效性
|
| 93 |
+
fetch("/api/config", {
|
| 94 |
+
headers: {
|
| 95 |
+
Authorization: `Bearer ${token}`,
|
| 96 |
+
},
|
| 97 |
+
})
|
| 98 |
+
.then((res) => {
|
| 99 |
+
if (!res.ok) {
|
| 100 |
+
// 如果token无效,清除token并重定向
|
| 101 |
+
localStorage.removeItem("access_token");
|
| 102 |
+
router.push("/token");
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
return res.json();
|
| 106 |
+
})
|
| 107 |
+
.then((data) => {
|
| 108 |
+
if (data) {
|
| 109 |
+
setApiKey(data.apiKey);
|
| 110 |
+
}
|
| 111 |
+
})
|
| 112 |
+
.catch(() => {
|
| 113 |
+
setApiKey(t("common.error"));
|
| 114 |
+
// 发生错误时也清除token并重定向
|
| 115 |
+
localStorage.removeItem("access_token");
|
| 116 |
+
router.push("/token");
|
| 117 |
+
});
|
| 118 |
+
}, [router, t]);
|
| 119 |
+
|
| 120 |
+
const handleCopyApiKey = () => {
|
| 121 |
+
const token = localStorage.getItem("access_token");
|
| 122 |
+
if (!token) {
|
| 123 |
+
toast.error(t("header.messages.unauthorized"));
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
navigator.clipboard.writeText(apiKey);
|
| 127 |
+
toast.success(t("header.messages.apiKeyCopied"));
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const handleLogout = () => {
|
| 131 |
+
localStorage.removeItem("access_token");
|
| 132 |
+
window.location.href = "/token";
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const checkUpdate = async () => {
|
| 136 |
+
const token = localStorage.getItem("access_token");
|
| 137 |
+
if (!token) {
|
| 138 |
+
toast.error(t("header.messages.unauthorized"));
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
setIsCheckingUpdate(true);
|
| 143 |
+
try {
|
| 144 |
+
const response = await fetch(
|
| 145 |
+
"https://api.github.com/repos/variantconst/openwebui-monitor/releases/latest"
|
| 146 |
+
);
|
| 147 |
+
const data = await response.json();
|
| 148 |
+
const latestVersion = data.tag_name;
|
| 149 |
+
|
| 150 |
+
if (!latestVersion) {
|
| 151 |
+
throw new Error(t("header.messages.getVersionFailed"));
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const currentVer = APP_VERSION.replace(/^v/, "");
|
| 155 |
+
const latestVer = latestVersion.replace(/^v/, "");
|
| 156 |
+
|
| 157 |
+
if (currentVer === latestVer) {
|
| 158 |
+
toast.success(`${t("header.messages.latestVersion")} v${APP_VERSION}`);
|
| 159 |
+
} else {
|
| 160 |
+
return new Promise((resolve) => {
|
| 161 |
+
const dialog = document.createElement("div");
|
| 162 |
+
document.body.appendChild(dialog);
|
| 163 |
+
|
| 164 |
+
const DialogComponent = () => {
|
| 165 |
+
const [open, setOpen] = useState(true);
|
| 166 |
+
|
| 167 |
+
const handleClose = () => {
|
| 168 |
+
setOpen(false);
|
| 169 |
+
document.body.removeChild(dialog);
|
| 170 |
+
resolve(null);
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const handleUpdate = () => {
|
| 174 |
+
window.open(
|
| 175 |
+
"https://github.com/VariantConst/OpenWebUI-Monitor/releases/latest",
|
| 176 |
+
"_blank"
|
| 177 |
+
);
|
| 178 |
+
handleClose();
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<Dialog open={open} onOpenChange={handleClose}>
|
| 183 |
+
<DialogContent className="w-[calc(100%-2rem)] !max-w-[70vw] sm:max-w-[425px] rounded-lg">
|
| 184 |
+
<DialogHeader>
|
| 185 |
+
<div className="flex items-center gap-2">
|
| 186 |
+
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-primary/10">
|
| 187 |
+
<Github className="w-4 h-4 text-gray-500" />
|
| 188 |
+
</div>
|
| 189 |
+
<DialogTitle className="text-base sm:text-lg">
|
| 190 |
+
{t("header.update.newVersion")}
|
| 191 |
+
</DialogTitle>
|
| 192 |
+
</div>
|
| 193 |
+
</DialogHeader>
|
| 194 |
+
<div className="flex flex-col gap-3 sm:gap-4 py-3 sm:py-4">
|
| 195 |
+
<div className="flex justify-between items-center">
|
| 196 |
+
<span className="text-sm sm:text-base text-muted-foreground">
|
| 197 |
+
{t("header.update.currentVersion")}
|
| 198 |
+
</span>
|
| 199 |
+
<span className="font-mono text-sm sm:text-base">
|
| 200 |
+
v{APP_VERSION}
|
| 201 |
+
</span>
|
| 202 |
+
</div>
|
| 203 |
+
<div className="flex justify-between items-center">
|
| 204 |
+
<span className="text-sm sm:text-base text-muted-foreground">
|
| 205 |
+
{t("header.update.latestVersion")}
|
| 206 |
+
</span>
|
| 207 |
+
<span className="font-mono text-sm sm:text-base text-primary">
|
| 208 |
+
{latestVersion}
|
| 209 |
+
</span>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
<DialogFooter className="gap-2 sm:gap-3">
|
| 213 |
+
<Button
|
| 214 |
+
variant="outline"
|
| 215 |
+
onClick={handleClose}
|
| 216 |
+
className="h-8 sm:h-10 text-sm sm:text-base"
|
| 217 |
+
>
|
| 218 |
+
{t("header.update.skipUpdate")}
|
| 219 |
+
</Button>
|
| 220 |
+
<Button
|
| 221 |
+
onClick={handleUpdate}
|
| 222 |
+
className="h-8 sm:h-10 text-sm sm:text-base bg-primary hover:bg-primary/90 text-primary-foreground"
|
| 223 |
+
>
|
| 224 |
+
{t("header.update.goToUpdate")}
|
| 225 |
+
</Button>
|
| 226 |
+
</DialogFooter>
|
| 227 |
+
</DialogContent>
|
| 228 |
+
</Dialog>
|
| 229 |
+
);
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
createRoot(dialog).render(<DialogComponent />);
|
| 233 |
+
});
|
| 234 |
+
}
|
| 235 |
+
} catch (error) {
|
| 236 |
+
toast.error(t("header.messages.updateCheckFailed"));
|
| 237 |
+
console.error(t("header.messages.updateCheckFailed"), error);
|
| 238 |
+
} finally {
|
| 239 |
+
setIsCheckingUpdate(false);
|
| 240 |
+
}
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
const navigationItems = [
|
| 244 |
+
{
|
| 245 |
+
path: "/models",
|
| 246 |
+
icon: <FiDatabase className="w-5 h-5" />,
|
| 247 |
+
label: t("home.features.models.title"),
|
| 248 |
+
color: "from-blue-500/10 to-indigo-500/10",
|
| 249 |
+
hoverColor: "group-hover:text-blue-600",
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
path: "/users",
|
| 253 |
+
icon: <FiUsers className="w-5 h-5" />,
|
| 254 |
+
label: t("home.features.users.title"),
|
| 255 |
+
color: "from-rose-500/10 to-pink-500/10",
|
| 256 |
+
hoverColor: "group-hover:text-rose-600",
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
path: "/panel",
|
| 260 |
+
icon: <FiBarChart2 className="w-5 h-5" />,
|
| 261 |
+
label: t("home.features.stats.title"),
|
| 262 |
+
color: "from-emerald-500/10 to-teal-500/10",
|
| 263 |
+
hoverColor: "group-hover:text-emerald-600",
|
| 264 |
+
},
|
| 265 |
+
];
|
| 266 |
+
|
| 267 |
+
const settingsItems = [
|
| 268 |
+
{
|
| 269 |
+
icon: <Copy className="w-5 h-5" />,
|
| 270 |
+
label: t("header.menu.copyApiKey"),
|
| 271 |
+
onClick: handleCopyApiKey,
|
| 272 |
+
color: "from-blue-500/20 to-indigo-500/20",
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
icon: <Database className="w-5 h-5" />,
|
| 276 |
+
label: t("header.menu.dataBackup"),
|
| 277 |
+
onClick: () => setIsBackupModalOpen(true),
|
| 278 |
+
color: "from-rose-500/20 to-pink-500/20",
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
icon: <Github className="w-5 h-5" />,
|
| 282 |
+
label: t("header.menu.checkUpdate"),
|
| 283 |
+
onClick: checkUpdate,
|
| 284 |
+
color: "from-emerald-500/20 to-teal-500/20",
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
icon: <LogOut className="w-5 h-5" />,
|
| 288 |
+
label: t("header.menu.logout"),
|
| 289 |
+
onClick: handleLogout,
|
| 290 |
+
color: "from-orange-500/20 to-red-500/20",
|
| 291 |
+
},
|
| 292 |
+
];
|
| 293 |
+
|
| 294 |
+
const menuItems = [
|
| 295 |
+
// 在小屏幕上将导航项添加到菜单中,但需要特殊处理
|
| 296 |
+
...(!isTokenPage
|
| 297 |
+
? navigationItems.map((item) => ({
|
| 298 |
+
...item,
|
| 299 |
+
onClick: () => router.push(item.path), // 为导航项添加 onClick 处理
|
| 300 |
+
}))
|
| 301 |
+
: []),
|
| 302 |
+
{
|
| 303 |
+
icon: <Copy className="w-5 h-5" />,
|
| 304 |
+
label: t("header.menu.copyApiKey"),
|
| 305 |
+
onClick: handleCopyApiKey,
|
| 306 |
+
color: "from-blue-500/20 to-indigo-500/20",
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
icon: <Database className="w-5 h-5" />,
|
| 310 |
+
label: t("header.menu.dataBackup"),
|
| 311 |
+
onClick: () => setIsBackupModalOpen(true),
|
| 312 |
+
color: "from-rose-500/20 to-pink-500/20",
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
icon: <Github className="w-5 h-5" />,
|
| 316 |
+
label: t("header.menu.checkUpdate"),
|
| 317 |
+
onClick: checkUpdate,
|
| 318 |
+
color: "from-emerald-500/20 to-teal-500/20",
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
icon: <LogOut className="w-5 h-5" />,
|
| 322 |
+
label: t("header.menu.logout"),
|
| 323 |
+
onClick: handleLogout,
|
| 324 |
+
color: "from-orange-500/20 to-red-500/20",
|
| 325 |
+
},
|
| 326 |
+
];
|
| 327 |
+
|
| 328 |
+
// 在navigationItems数组后添加
|
| 329 |
+
const actionItems = [
|
| 330 |
+
{
|
| 331 |
+
icon: <Globe className="w-5 h-5" />,
|
| 332 |
+
label: i18n.language === "zh" ? "简体中文" : "English",
|
| 333 |
+
onClick: () => handleLanguageChange(i18n.language === "zh" ? "en" : "zh"),
|
| 334 |
+
color: "from-gray-100 to-gray-50",
|
| 335 |
+
hoverColor: "group-hover:text-gray-900",
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
icon: <Settings className="w-5 h-5" />,
|
| 339 |
+
label: t("header.menu.settings"),
|
| 340 |
+
onClick: () => setIsMenuOpen(true),
|
| 341 |
+
color: "from-gray-100 to-gray-50",
|
| 342 |
+
hoverColor: "group-hover:text-gray-900",
|
| 343 |
+
},
|
| 344 |
+
];
|
| 345 |
+
|
| 346 |
+
return (
|
| 347 |
+
<>
|
| 348 |
+
<Toaster
|
| 349 |
+
richColors
|
| 350 |
+
position="top-center"
|
| 351 |
+
theme="light"
|
| 352 |
+
expand
|
| 353 |
+
duration={1500}
|
| 354 |
+
/>
|
| 355 |
+
<motion.header
|
| 356 |
+
initial={{ y: -20, opacity: 0 }}
|
| 357 |
+
animate={{ y: 0, opacity: 1 }}
|
| 358 |
+
className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100 shadow-sm"
|
| 359 |
+
>
|
| 360 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16">
|
| 361 |
+
<div className="h-full flex items-center justify-between">
|
| 362 |
+
<motion.div
|
| 363 |
+
initial={{ opacity: 0, x: -20 }}
|
| 364 |
+
animate={{ opacity: 1, x: 0 }}
|
| 365 |
+
transition={{ delay: 0.1 }}
|
| 366 |
+
>
|
| 367 |
+
<Link
|
| 368 |
+
href="/"
|
| 369 |
+
className="text-xl font-semibold bg-gradient-to-r from-gray-900 via-indigo-800 to-gray-900 bg-clip-text text-transparent"
|
| 370 |
+
>
|
| 371 |
+
{t("common.appName")}
|
| 372 |
+
</Link>
|
| 373 |
+
</motion.div>
|
| 374 |
+
|
| 375 |
+
{/* 右侧内容 */}
|
| 376 |
+
<div className="flex items-center gap-4">
|
| 377 |
+
{/* 导航项 - 仅在大屏幕显示 */}
|
| 378 |
+
{!isTokenPage && (
|
| 379 |
+
<div className="hidden md:flex items-center gap-3">
|
| 380 |
+
{navigationItems.map((item) => (
|
| 381 |
+
<Link
|
| 382 |
+
key={item.path}
|
| 383 |
+
href={item.path}
|
| 384 |
+
className="group relative"
|
| 385 |
+
>
|
| 386 |
+
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r hover:bg-gradient-to-br transition-all duration-300 relative">
|
| 387 |
+
<div
|
| 388 |
+
className={`absolute inset-0 bg-gradient-to-r ${item.color} rounded-xl opacity-0 group-hover:opacity-100 transition-all duration-300`}
|
| 389 |
+
/>
|
| 390 |
+
<span
|
| 391 |
+
className={`relative z-10 ${item.hoverColor} transition-colors duration-300`}
|
| 392 |
+
>
|
| 393 |
+
{item.icon}
|
| 394 |
+
</span>
|
| 395 |
+
<span className="relative z-10 text-sm font-medium text-gray-600 group-hover:text-gray-900 transition-colors duration-300">
|
| 396 |
+
{item.label}
|
| 397 |
+
</span>
|
| 398 |
+
</div>
|
| 399 |
+
</Link>
|
| 400 |
+
))}
|
| 401 |
+
</div>
|
| 402 |
+
)}
|
| 403 |
+
|
| 404 |
+
{/* 语言切换和菜单按钮 */}
|
| 405 |
+
<div className="flex items-center gap-3">
|
| 406 |
+
{actionItems.map((item, index) => (
|
| 407 |
+
<button
|
| 408 |
+
key={index}
|
| 409 |
+
onClick={item.onClick}
|
| 410 |
+
className="group relative"
|
| 411 |
+
>
|
| 412 |
+
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r hover:bg-gradient-to-br transition-all duration-300 relative">
|
| 413 |
+
<div
|
| 414 |
+
className={`absolute inset-0 bg-gradient-to-r ${item.color} rounded-xl opacity-0 group-hover:opacity-100 transition-all duration-300`}
|
| 415 |
+
/>
|
| 416 |
+
<span
|
| 417 |
+
className={`relative z-10 ${item.hoverColor} transition-colors duration-300`}
|
| 418 |
+
>
|
| 419 |
+
{item.icon}
|
| 420 |
+
</span>
|
| 421 |
+
<span className="relative z-10 hidden md:block text-sm font-medium text-gray-600 group-hover:text-gray-900 transition-colors duration-300">
|
| 422 |
+
{item.label}
|
| 423 |
+
</span>
|
| 424 |
+
</div>
|
| 425 |
+
</button>
|
| 426 |
+
))}
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
</motion.header>
|
| 432 |
+
|
| 433 |
+
<AnimatePresence>
|
| 434 |
+
{isMenuOpen && (
|
| 435 |
+
<>
|
| 436 |
+
{/* 背景遮罩 */}
|
| 437 |
+
<motion.div
|
| 438 |
+
initial={{ opacity: 0 }}
|
| 439 |
+
animate={{ opacity: 1 }}
|
| 440 |
+
exit={{ opacity: 0 }}
|
| 441 |
+
transition={{ duration: 0.3 }}
|
| 442 |
+
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 md:bg-black/10"
|
| 443 |
+
onClick={() => setIsMenuOpen(false)}
|
| 444 |
+
/>
|
| 445 |
+
|
| 446 |
+
{/* 菜单面板 - 响应式布局 */}
|
| 447 |
+
<motion.div
|
| 448 |
+
initial={{ x: "100%" }}
|
| 449 |
+
animate={{ x: 0 }}
|
| 450 |
+
exit={{ x: "100%" }}
|
| 451 |
+
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
| 452 |
+
className="fixed top-0 right-0 h-full bg-white/95 backdrop-blur-xl z-50 w-full max-w-[480px] md:top-[calc(4rem+0.5rem)] md:h-auto md:mr-6 md:rounded-2xl md:border md:shadow-xl md:max-h-[calc(100vh-5rem)] overflow-hidden shadow-lg border-l border-gray-100/50"
|
| 453 |
+
>
|
| 454 |
+
{/* 内容容器 */}
|
| 455 |
+
<div className="relative h-full flex flex-col">
|
| 456 |
+
{/* 顶部栏 */}
|
| 457 |
+
<div className="flex items-center justify-between p-4 border-b border-gray-100/50">
|
| 458 |
+
<h2 className="text-lg font-medium bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 bg-clip-text text-transparent">
|
| 459 |
+
{t("header.menu.settings")}
|
| 460 |
+
</h2>
|
| 461 |
+
<button
|
| 462 |
+
onClick={() => setIsMenuOpen(false)}
|
| 463 |
+
className="p-1.5 rounded-lg hover:bg-gray-100/80 transition-all duration-200"
|
| 464 |
+
>
|
| 465 |
+
<X className="w-5 h-5 text-gray-500" />
|
| 466 |
+
</button>
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
{/* 菜单项列表 */}
|
| 470 |
+
<div className="flex-1 overflow-y-auto p-2">
|
| 471 |
+
<div className="space-y-1">
|
| 472 |
+
{/* 导航项 - 仅在移动端显示 */}
|
| 473 |
+
<div className="md:hidden space-y-1">
|
| 474 |
+
{navigationItems.map((item, index) => (
|
| 475 |
+
<motion.button
|
| 476 |
+
key={item.path}
|
| 477 |
+
initial={{ opacity: 0, x: 20 }}
|
| 478 |
+
animate={{ opacity: 1, x: 0 }}
|
| 479 |
+
transition={{ delay: index * 0.05 }}
|
| 480 |
+
onClick={() => {
|
| 481 |
+
setIsMenuOpen(false);
|
| 482 |
+
router.push(item.path);
|
| 483 |
+
}}
|
| 484 |
+
className="w-full group"
|
| 485 |
+
>
|
| 486 |
+
<div className="flex items-center gap-3 p-3 rounded-xl hover:bg-gradient-to-r hover:from-gray-50/50 hover:to-gray-100/50 transition-all duration-300">
|
| 487 |
+
<span
|
| 488 |
+
className={`${item.hoverColor} transition-colors duration-300`}
|
| 489 |
+
>
|
| 490 |
+
{item.icon}
|
| 491 |
+
</span>
|
| 492 |
+
<span className="text-sm font-medium text-gray-600 group-hover:text-gray-900 transition-colors duration-300">
|
| 493 |
+
{item.label}
|
| 494 |
+
</span>
|
| 495 |
+
</div>
|
| 496 |
+
</motion.button>
|
| 497 |
+
))}
|
| 498 |
+
</div>
|
| 499 |
+
|
| 500 |
+
{/* 设置选项 - 直接显示所有选项 */}
|
| 501 |
+
<div className="border-t border-gray-100/50 pt-2 md:border-t-0 md:pt-0">
|
| 502 |
+
{settingsItems.map((item, index) => (
|
| 503 |
+
<motion.button
|
| 504 |
+
key={item.label}
|
| 505 |
+
initial={{ opacity: 0, x: 20 }}
|
| 506 |
+
animate={{ opacity: 1, x: 0 }}
|
| 507 |
+
transition={{
|
| 508 |
+
delay:
|
| 509 |
+
(index + navigationItems.length * 0.05) * 0.05,
|
| 510 |
+
}}
|
| 511 |
+
onClick={() => {
|
| 512 |
+
setIsMenuOpen(false);
|
| 513 |
+
item.onClick();
|
| 514 |
+
}}
|
| 515 |
+
className="w-full group"
|
| 516 |
+
>
|
| 517 |
+
<div className="flex items-center gap-3 p-3 rounded-xl hover:bg-gradient-to-r hover:from-gray-50/50 hover:to-gray-100/50 transition-all duration-300">
|
| 518 |
+
<span className="text-gray-500 group-hover:text-gray-900 transition-colors duration-300">
|
| 519 |
+
{item.icon}
|
| 520 |
+
</span>
|
| 521 |
+
<span className="text-sm font-medium text-gray-600 group-hover:text-gray-900 transition-colors duration-300">
|
| 522 |
+
{item.label}
|
| 523 |
+
</span>
|
| 524 |
+
</div>
|
| 525 |
+
</motion.button>
|
| 526 |
+
))}
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
</motion.div>
|
| 532 |
+
</>
|
| 533 |
+
)}
|
| 534 |
+
</AnimatePresence>
|
| 535 |
+
|
| 536 |
+
<DatabaseBackup
|
| 537 |
+
open={isBackupModalOpen}
|
| 538 |
+
onClose={() => setIsBackupModalOpen(false)}
|
| 539 |
+
token={accessToken || undefined}
|
| 540 |
+
/>
|
| 541 |
+
</>
|
| 542 |
+
);
|
| 543 |
+
}
|
components/I18nProvider.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { PropsWithChildren, useEffect } from "react";
|
| 4 |
+
import i18next from "i18next";
|
| 5 |
+
import { I18nextProvider, initReactI18next } from "react-i18next";
|
| 6 |
+
import LanguageDetector from "i18next-browser-languagedetector";
|
| 7 |
+
|
| 8 |
+
import enCommon from "@/locales/en/common.json";
|
| 9 |
+
import zhCommon from "@/locales/zh/common.json";
|
| 10 |
+
|
| 11 |
+
const i18n = i18next
|
| 12 |
+
.use(LanguageDetector)
|
| 13 |
+
.use(initReactI18next)
|
| 14 |
+
.init({
|
| 15 |
+
resources: {
|
| 16 |
+
en: {
|
| 17 |
+
common: enCommon,
|
| 18 |
+
},
|
| 19 |
+
zh: {
|
| 20 |
+
common: zhCommon,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
fallbackLng: "zh",
|
| 24 |
+
interpolation: {
|
| 25 |
+
escapeValue: false,
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
export default function I18nProvider({ children }: PropsWithChildren) {
|
| 30 |
+
return <I18nextProvider i18n={i18next}>{children}</I18nextProvider>;
|
| 31 |
+
}
|
components/editable-cell.tsx
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Input } from "antd";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { CheckOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
| 7 |
+
import { Tooltip } from "antd";
|
| 8 |
+
import { toast } from "sonner";
|
| 9 |
+
|
| 10 |
+
interface EditableCellProps {
|
| 11 |
+
value: number;
|
| 12 |
+
isEditing: boolean;
|
| 13 |
+
onEdit: () => void;
|
| 14 |
+
onSubmit: (value: number) => Promise<void>;
|
| 15 |
+
onCancel: () => void;
|
| 16 |
+
t: (key: string, options?: { max?: number }) => string;
|
| 17 |
+
disabled?: boolean;
|
| 18 |
+
tooltipText?: string;
|
| 19 |
+
placeholder?: string;
|
| 20 |
+
validateValue?: (value: number) => {
|
| 21 |
+
isValid: boolean;
|
| 22 |
+
errorMessage?: string;
|
| 23 |
+
maxValue?: number;
|
| 24 |
+
};
|
| 25 |
+
isPerMsgPrice?: boolean;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function EditableCell({
|
| 29 |
+
value,
|
| 30 |
+
isEditing,
|
| 31 |
+
onEdit,
|
| 32 |
+
onSubmit,
|
| 33 |
+
onCancel,
|
| 34 |
+
t,
|
| 35 |
+
disabled = false,
|
| 36 |
+
tooltipText,
|
| 37 |
+
placeholder,
|
| 38 |
+
validateValue = (value) => ({ isValid: true }),
|
| 39 |
+
isPerMsgPrice = false,
|
| 40 |
+
}: EditableCellProps) {
|
| 41 |
+
const numericValue = typeof value === "number" ? value : Number(value);
|
| 42 |
+
const originalValue = numericValue >= 0 ? numericValue.toFixed(4) : "";
|
| 43 |
+
const [inputValue, setInputValue] = useState(originalValue);
|
| 44 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 45 |
+
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
if (isEditing) {
|
| 48 |
+
setInputValue(originalValue);
|
| 49 |
+
}
|
| 50 |
+
}, [isEditing, originalValue]);
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (isEditing) {
|
| 54 |
+
const handleClickOutside = (e: MouseEvent) => {
|
| 55 |
+
const target = e.target as HTMLElement;
|
| 56 |
+
if (!target.closest(".editable-cell-input")) {
|
| 57 |
+
onCancel();
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
document.addEventListener("mousedown", handleClickOutside);
|
| 62 |
+
return () => {
|
| 63 |
+
document.removeEventListener("mousedown", handleClickOutside);
|
| 64 |
+
};
|
| 65 |
+
}
|
| 66 |
+
}, [isEditing, onCancel]);
|
| 67 |
+
|
| 68 |
+
const handleSubmit = async () => {
|
| 69 |
+
try {
|
| 70 |
+
setIsSaving(true);
|
| 71 |
+
const numValue = Number(inputValue);
|
| 72 |
+
const validation = validateValue(numValue);
|
| 73 |
+
|
| 74 |
+
if (!validation.isValid) {
|
| 75 |
+
toast.error(validation.errorMessage || t("error.invalidInput"));
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (validation.maxValue !== undefined && numValue > validation.maxValue) {
|
| 80 |
+
toast.error(t("error.exceedsMaxValue", { max: validation.maxValue }));
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
await onSubmit(numValue);
|
| 85 |
+
} catch (err) {
|
| 86 |
+
// 错误已在父组件中处理
|
| 87 |
+
} finally {
|
| 88 |
+
setIsSaving(false);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<div className={`relative ${disabled ? "opacity-50" : ""}`}>
|
| 94 |
+
{isEditing ? (
|
| 95 |
+
<div className="relative editable-cell-input flex items-center gap-1.5">
|
| 96 |
+
<Input
|
| 97 |
+
value={inputValue}
|
| 98 |
+
onChange={(e) => setInputValue(e.target.value)}
|
| 99 |
+
className="
|
| 100 |
+
!w-[calc(100%-32px)]
|
| 101 |
+
!border
|
| 102 |
+
!border-slate-200
|
| 103 |
+
focus:!border-slate-300
|
| 104 |
+
!bg-white
|
| 105 |
+
!shadow-sm
|
| 106 |
+
hover:!shadow
|
| 107 |
+
focus:!shadow-md
|
| 108 |
+
!px-2
|
| 109 |
+
!py-1
|
| 110 |
+
!h-7
|
| 111 |
+
flex-1
|
| 112 |
+
!rounded-lg
|
| 113 |
+
!text-slate-600
|
| 114 |
+
!text-sm
|
| 115 |
+
!font-medium
|
| 116 |
+
placeholder:!text-slate-400/70
|
| 117 |
+
transition-all
|
| 118 |
+
duration-200
|
| 119 |
+
focus:!ring-2
|
| 120 |
+
focus:!ring-slate-200/50
|
| 121 |
+
focus:!ring-offset-0
|
| 122 |
+
"
|
| 123 |
+
placeholder={placeholder || t("common.enterValue")}
|
| 124 |
+
onPressEnter={handleSubmit}
|
| 125 |
+
autoFocus
|
| 126 |
+
disabled={isSaving}
|
| 127 |
+
/>
|
| 128 |
+
<Button
|
| 129 |
+
size="sm"
|
| 130 |
+
variant="ghost"
|
| 131 |
+
className={`
|
| 132 |
+
h-7 w-7
|
| 133 |
+
flex-shrink-0
|
| 134 |
+
bg-gradient-to-r from-slate-500/80 to-slate-600/80
|
| 135 |
+
hover:from-slate-600 hover:to-slate-700
|
| 136 |
+
text-white/90
|
| 137 |
+
shadow-sm
|
| 138 |
+
rounded-lg
|
| 139 |
+
transition-all
|
| 140 |
+
duration-200
|
| 141 |
+
hover:scale-105
|
| 142 |
+
active:scale-95
|
| 143 |
+
p-0
|
| 144 |
+
flex
|
| 145 |
+
items-center
|
| 146 |
+
justify-center
|
| 147 |
+
${isSaving ? "cursor-not-allowed opacity-70" : ""}
|
| 148 |
+
`}
|
| 149 |
+
onClick={(e) => {
|
| 150 |
+
e.stopPropagation();
|
| 151 |
+
handleSubmit();
|
| 152 |
+
}}
|
| 153 |
+
disabled={isSaving}
|
| 154 |
+
>
|
| 155 |
+
{isSaving ? (
|
| 156 |
+
<div className="w-3 h-3 rounded-full border-2 border-white/90 border-t-transparent animate-spin" />
|
| 157 |
+
) : (
|
| 158 |
+
<CheckOutlined className="text-xs" />
|
| 159 |
+
)}
|
| 160 |
+
</Button>
|
| 161 |
+
</div>
|
| 162 |
+
) : (
|
| 163 |
+
<div
|
| 164 |
+
onClick={disabled ? undefined : onEdit}
|
| 165 |
+
className={`
|
| 166 |
+
group
|
| 167 |
+
px-2
|
| 168 |
+
py-1
|
| 169 |
+
rounded-lg
|
| 170 |
+
transition-colors
|
| 171 |
+
duration-200
|
| 172 |
+
${
|
| 173 |
+
disabled
|
| 174 |
+
? "cursor-not-allowed line-through"
|
| 175 |
+
: "cursor-pointer hover:bg-primary/5"
|
| 176 |
+
}
|
| 177 |
+
`}
|
| 178 |
+
>
|
| 179 |
+
<span
|
| 180 |
+
className={`
|
| 181 |
+
font-medium
|
| 182 |
+
text-sm
|
| 183 |
+
transition-colors
|
| 184 |
+
duration-200
|
| 185 |
+
${
|
| 186 |
+
disabled
|
| 187 |
+
? "text-muted-foreground/60"
|
| 188 |
+
: "text-primary/80 group-hover:text-primary"
|
| 189 |
+
}
|
| 190 |
+
`}
|
| 191 |
+
>
|
| 192 |
+
{isPerMsgPrice && numericValue < 0 ? (
|
| 193 |
+
<span className="text-muted-foreground/60">
|
| 194 |
+
{t("common.notSet")}
|
| 195 |
+
</span>
|
| 196 |
+
) : (
|
| 197 |
+
<>
|
| 198 |
+
{numericValue.toFixed(4)}
|
| 199 |
+
{tooltipText && (
|
| 200 |
+
<Tooltip title={tooltipText}>
|
| 201 |
+
<InfoCircleOutlined className="ml-1 text-muted-foreground/60" />
|
| 202 |
+
</Tooltip>
|
| 203 |
+
)}
|
| 204 |
+
</>
|
| 205 |
+
)}
|
| 206 |
+
</span>
|
| 207 |
+
</div>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
components/models/TestProgress.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 4 |
+
import { Progress } from "@/components/ui/progress";
|
| 5 |
+
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
| 6 |
+
import { useState } from "react";
|
| 7 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 8 |
+
import { cn } from "@/lib/utils";
|
| 9 |
+
import { useTranslation } from "react-i18next";
|
| 10 |
+
|
| 11 |
+
interface TestProgressProps {
|
| 12 |
+
isVisible: boolean;
|
| 13 |
+
models: Array<{
|
| 14 |
+
id: string;
|
| 15 |
+
name: string;
|
| 16 |
+
testStatus?: "success" | "error" | "testing";
|
| 17 |
+
}>;
|
| 18 |
+
isComplete: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function TestProgress({
|
| 22 |
+
isVisible,
|
| 23 |
+
models,
|
| 24 |
+
isComplete,
|
| 25 |
+
}: TestProgressProps) {
|
| 26 |
+
const { t } = useTranslation("common");
|
| 27 |
+
const [expandedSection, setExpandedSection] = useState<
|
| 28 |
+
"success" | "error" | null
|
| 29 |
+
>(null);
|
| 30 |
+
|
| 31 |
+
const totalModels = models.length;
|
| 32 |
+
const testedModels = models.filter(
|
| 33 |
+
(m) => m.testStatus && m.testStatus !== "testing"
|
| 34 |
+
).length;
|
| 35 |
+
const successModels = models.filter((m) => m.testStatus === "success");
|
| 36 |
+
const failedModels = isComplete
|
| 37 |
+
? models.filter((m) => m.testStatus !== "success")
|
| 38 |
+
: models.filter((m) => m.testStatus === "error");
|
| 39 |
+
const pendingModels = models.filter(
|
| 40 |
+
(m) => !m.testStatus || m.testStatus === "testing"
|
| 41 |
+
);
|
| 42 |
+
const progress = (testedModels / totalModels) * 100;
|
| 43 |
+
|
| 44 |
+
const StatusSection = ({
|
| 45 |
+
type,
|
| 46 |
+
models,
|
| 47 |
+
count,
|
| 48 |
+
icon,
|
| 49 |
+
color,
|
| 50 |
+
label,
|
| 51 |
+
}: {
|
| 52 |
+
type: "success" | "error" | "pending";
|
| 53 |
+
models: typeof successModels;
|
| 54 |
+
count: number;
|
| 55 |
+
icon: JSX.Element;
|
| 56 |
+
color: string;
|
| 57 |
+
label: string;
|
| 58 |
+
}) => (
|
| 59 |
+
<div
|
| 60 |
+
className={cn(
|
| 61 |
+
"rounded-lg p-4 transition-colors duration-200",
|
| 62 |
+
color,
|
| 63 |
+
type !== "pending" &&
|
| 64 |
+
expandedSection === type &&
|
| 65 |
+
"ring-2 ring-primary ring-offset-2",
|
| 66 |
+
type !== "pending" && "hover:bg-opacity-80 cursor-pointer"
|
| 67 |
+
)}
|
| 68 |
+
onClick={() =>
|
| 69 |
+
type !== "pending" &&
|
| 70 |
+
setExpandedSection(
|
| 71 |
+
expandedSection === type ? null : (type as "success" | "error")
|
| 72 |
+
)
|
| 73 |
+
}
|
| 74 |
+
>
|
| 75 |
+
<div className="flex items-center gap-2">
|
| 76 |
+
<div className="flex-shrink-0">{icon}</div>
|
| 77 |
+
<div>
|
| 78 |
+
<div className="text-sm font-medium">{count}</div>
|
| 79 |
+
<div className="text-xs text-muted-foreground">{label}</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<AnimatePresence mode="wait">
|
| 87 |
+
{isVisible && (
|
| 88 |
+
<motion.div
|
| 89 |
+
initial={{ height: 0, opacity: 0 }}
|
| 90 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 91 |
+
exit={{ height: 0, opacity: 0 }}
|
| 92 |
+
transition={{ duration: 0.3 }}
|
| 93 |
+
className="overflow-hidden"
|
| 94 |
+
>
|
| 95 |
+
<div className="bg-card border rounded-lg p-4 mb-6">
|
| 96 |
+
<div className="space-y-4">
|
| 97 |
+
{isComplete ? (
|
| 98 |
+
<>
|
| 99 |
+
<motion.div
|
| 100 |
+
initial={{ opacity: 0 }}
|
| 101 |
+
animate={{ opacity: 1 }}
|
| 102 |
+
className="text-sm text-left pb-2"
|
| 103 |
+
>
|
| 104 |
+
<span className="text-muted-foreground">
|
| 105 |
+
{t("models.test.result.complete")}
|
| 106 |
+
</span>
|
| 107 |
+
</motion.div>
|
| 108 |
+
<div className="grid grid-cols-2 gap-4">
|
| 109 |
+
<div className="col-span-1">
|
| 110 |
+
<StatusSection
|
| 111 |
+
type="success"
|
| 112 |
+
models={successModels}
|
| 113 |
+
count={successModels.length}
|
| 114 |
+
icon={
|
| 115 |
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
| 116 |
+
}
|
| 117 |
+
color="bg-green-50"
|
| 118 |
+
label={t("models.test.status.valid")}
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
<div className="col-span-1">
|
| 122 |
+
<StatusSection
|
| 123 |
+
type="error"
|
| 124 |
+
models={failedModels}
|
| 125 |
+
count={failedModels.length}
|
| 126 |
+
icon={<XCircle className="w-5 h-5 text-red-500" />}
|
| 127 |
+
color="bg-red-50"
|
| 128 |
+
label={t("models.test.status.invalid")}
|
| 129 |
+
/>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</>
|
| 133 |
+
) : (
|
| 134 |
+
<>
|
| 135 |
+
<div className="grid grid-cols-3 gap-4">
|
| 136 |
+
<StatusSection
|
| 137 |
+
type="success"
|
| 138 |
+
models={successModels}
|
| 139 |
+
count={successModels.length}
|
| 140 |
+
icon={<CheckCircle2 className="w-5 h-5 text-green-500" />}
|
| 141 |
+
color="bg-green-50"
|
| 142 |
+
label={t("models.test.status.valid")}
|
| 143 |
+
/>
|
| 144 |
+
<StatusSection
|
| 145 |
+
type="error"
|
| 146 |
+
models={failedModels}
|
| 147 |
+
count={failedModels.length}
|
| 148 |
+
icon={<XCircle className="w-5 h-5 text-red-500" />}
|
| 149 |
+
color="bg-red-50"
|
| 150 |
+
label={t("models.test.status.invalid")}
|
| 151 |
+
/>
|
| 152 |
+
<StatusSection
|
| 153 |
+
type="pending"
|
| 154 |
+
models={pendingModels}
|
| 155 |
+
count={pendingModels.length}
|
| 156 |
+
icon={<Clock className="w-5 h-5 text-blue-500" />}
|
| 157 |
+
color="bg-blue-50"
|
| 158 |
+
label={t("models.test.status.pending")}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div className="space-y-2">
|
| 163 |
+
<div className="flex items-center justify-between text-sm">
|
| 164 |
+
<span className="text-muted-foreground">
|
| 165 |
+
{t("models.test.progress.title")}
|
| 166 |
+
</span>
|
| 167 |
+
<span className="font-medium whitespace-nowrap ml-4">
|
| 168 |
+
{testedModels} / {totalModels}
|
| 169 |
+
</span>
|
| 170 |
+
</div>
|
| 171 |
+
<Progress value={progress} className="h-2" />
|
| 172 |
+
</div>
|
| 173 |
+
</>
|
| 174 |
+
)}
|
| 175 |
+
|
| 176 |
+
<AnimatePresence mode="wait">
|
| 177 |
+
{expandedSection && (
|
| 178 |
+
<motion.div
|
| 179 |
+
key={expandedSection}
|
| 180 |
+
initial={{ height: 0, opacity: 0 }}
|
| 181 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 182 |
+
exit={{ height: 0, opacity: 0 }}
|
| 183 |
+
transition={{ duration: 0.2 }}
|
| 184 |
+
className="overflow-hidden"
|
| 185 |
+
>
|
| 186 |
+
<div className="pt-3 border-t">
|
| 187 |
+
<div className="text-sm font-medium mb-3">
|
| 188 |
+
{expandedSection === "success"
|
| 189 |
+
? t("models.test.result.success")
|
| 190 |
+
: t("models.test.result.failed")}
|
| 191 |
+
</div>
|
| 192 |
+
<ScrollArea className="h-[200px] pr-4">
|
| 193 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
| 194 |
+
{(expandedSection === "success"
|
| 195 |
+
? successModels
|
| 196 |
+
: failedModels
|
| 197 |
+
).map((model) => (
|
| 198 |
+
<motion.div
|
| 199 |
+
key={model.id}
|
| 200 |
+
initial={{ x: -20, opacity: 0 }}
|
| 201 |
+
animate={{ x: 0, opacity: 1 }}
|
| 202 |
+
className="flex items-center gap-2 p-2 rounded-md bg-gray-50/80"
|
| 203 |
+
>
|
| 204 |
+
{expandedSection === "success" ? (
|
| 205 |
+
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
|
| 206 |
+
) : (
|
| 207 |
+
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
| 208 |
+
)}
|
| 209 |
+
<span className="text-sm text-muted-foreground truncate">
|
| 210 |
+
{model.name}
|
| 211 |
+
</span>
|
| 212 |
+
</motion.div>
|
| 213 |
+
))}
|
| 214 |
+
</div>
|
| 215 |
+
</ScrollArea>
|
| 216 |
+
</div>
|
| 217 |
+
</motion.div>
|
| 218 |
+
)}
|
| 219 |
+
</AnimatePresence>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</motion.div>
|
| 223 |
+
)}
|
| 224 |
+
</AnimatePresence>
|
| 225 |
+
);
|
| 226 |
+
}
|
components/panel/ModelDistributionChart.tsx
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect } from "react";
|
| 4 |
+
import ReactECharts from "echarts-for-react";
|
| 5 |
+
import type { ECharts } from "echarts";
|
| 6 |
+
import { Card as ShadcnCard } from "@/components/ui/card";
|
| 7 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
| 8 |
+
import { Label } from "@/components/ui/label";
|
| 9 |
+
import { Skeleton } from "@/components/ui/skeleton";
|
| 10 |
+
import { MetricToggle } from "@/components/ui/metric-toggle";
|
| 11 |
+
import { useTranslation } from "react-i18next";
|
| 12 |
+
import { PieChartOutlined } from "@ant-design/icons";
|
| 13 |
+
import { motion } from "framer-motion";
|
| 14 |
+
|
| 15 |
+
interface ModelUsage {
|
| 16 |
+
model_name: string;
|
| 17 |
+
total_cost: number;
|
| 18 |
+
total_count: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface ModelDistributionChartProps {
|
| 22 |
+
loading: boolean;
|
| 23 |
+
models: ModelUsage[];
|
| 24 |
+
metric: "cost" | "count";
|
| 25 |
+
onMetricChange: (metric: "cost" | "count") => void;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const getPieOption = (
|
| 29 |
+
models: ModelUsage[],
|
| 30 |
+
metric: "cost" | "count",
|
| 31 |
+
t: (key: string) => string
|
| 32 |
+
) => {
|
| 33 |
+
const pieData = models
|
| 34 |
+
.map((item) => ({
|
| 35 |
+
type: item.model_name,
|
| 36 |
+
value: metric === "cost" ? Number(item.total_cost) : item.total_count,
|
| 37 |
+
}))
|
| 38 |
+
.filter((item) => item.value > 0);
|
| 39 |
+
|
| 40 |
+
const total = pieData.reduce((sum, item) => sum + item.value, 0);
|
| 41 |
+
|
| 42 |
+
const sortedData = [...pieData]
|
| 43 |
+
.sort((a, b) => b.value - a.value)
|
| 44 |
+
.reduce((acc, curr) => {
|
| 45 |
+
const percentage = (curr.value / total) * 100;
|
| 46 |
+
if (percentage < 5) {
|
| 47 |
+
const otherIndex = acc.findIndex(
|
| 48 |
+
(item) => item.name === t("panel.modelUsage.others")
|
| 49 |
+
);
|
| 50 |
+
if (otherIndex >= 0) {
|
| 51 |
+
acc[otherIndex].value += curr.value;
|
| 52 |
+
} else {
|
| 53 |
+
acc.push({
|
| 54 |
+
name: t("panel.modelUsage.others"),
|
| 55 |
+
value: curr.value,
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
} else {
|
| 59 |
+
acc.push({
|
| 60 |
+
name: curr.type,
|
| 61 |
+
value: curr.value,
|
| 62 |
+
});
|
| 63 |
+
}
|
| 64 |
+
return acc;
|
| 65 |
+
}, [] as { name: string; value: number }[]);
|
| 66 |
+
|
| 67 |
+
const isSmallScreen = window.innerWidth < 640;
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
tooltip: {
|
| 71 |
+
show: true,
|
| 72 |
+
trigger: "item",
|
| 73 |
+
backgroundColor: "rgba(255, 255, 255, 0.98)",
|
| 74 |
+
borderColor: "rgba(0, 0, 0, 0.05)",
|
| 75 |
+
borderWidth: 1,
|
| 76 |
+
padding: [14, 18],
|
| 77 |
+
textStyle: {
|
| 78 |
+
color: "#333",
|
| 79 |
+
fontSize: 13,
|
| 80 |
+
lineHeight: 20,
|
| 81 |
+
},
|
| 82 |
+
formatter: (params: any) => {
|
| 83 |
+
const percentage = ((params.value / total) * 100).toFixed(1);
|
| 84 |
+
return `
|
| 85 |
+
<div class="flex flex-col gap-1.5">
|
| 86 |
+
<div class="font-medium text-gray-800">${params.name}</div>
|
| 87 |
+
<div class="flex items-center gap-2">
|
| 88 |
+
<span class="inline-block w-2 h-2 rounded-full" style="background-color: ${
|
| 89 |
+
params.color
|
| 90 |
+
}"></span>
|
| 91 |
+
<span class="text-sm">
|
| 92 |
+
${metric === "cost" ? t("panel.byAmount") : t("panel.byCount")}
|
| 93 |
+
</span>
|
| 94 |
+
<span class="font-mono text-sm font-medium text-gray-900">
|
| 95 |
+
${
|
| 96 |
+
metric === "cost"
|
| 97 |
+
? `${t("common.currency")}${params.value.toFixed(4)}`
|
| 98 |
+
: `${params.value} ${t("common.count")}`
|
| 99 |
+
}
|
| 100 |
+
</span>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="text-xs text-gray-500">
|
| 103 |
+
占 <span class="font-medium text-gray-700">${percentage}%</span>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
`;
|
| 107 |
+
},
|
| 108 |
+
extraCssText:
|
| 109 |
+
"box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); border-radius: 8px;",
|
| 110 |
+
},
|
| 111 |
+
legend: {
|
| 112 |
+
show: true,
|
| 113 |
+
orient: "horizontal",
|
| 114 |
+
bottom: isSmallScreen ? 20 : 10,
|
| 115 |
+
type: "scroll",
|
| 116 |
+
itemWidth: 16,
|
| 117 |
+
itemHeight: 16,
|
| 118 |
+
itemGap: 20,
|
| 119 |
+
textStyle: {
|
| 120 |
+
fontSize: 13,
|
| 121 |
+
color: "#555",
|
| 122 |
+
padding: [0, 0, 0, 4],
|
| 123 |
+
},
|
| 124 |
+
pageIconSize: 12,
|
| 125 |
+
pageTextStyle: {
|
| 126 |
+
color: "#666",
|
| 127 |
+
},
|
| 128 |
+
},
|
| 129 |
+
series: [
|
| 130 |
+
{
|
| 131 |
+
name: metric === "cost" ? t("panel.byAmount") : t("panel.byCount"),
|
| 132 |
+
type: "pie",
|
| 133 |
+
radius: isSmallScreen ? ["35%", "65%"] : ["45%", "75%"],
|
| 134 |
+
center: ["50%", "45%"],
|
| 135 |
+
avoidLabelOverlap: false,
|
| 136 |
+
itemStyle: {
|
| 137 |
+
borderRadius: 6,
|
| 138 |
+
borderWidth: 2,
|
| 139 |
+
borderColor: "#fff",
|
| 140 |
+
shadowBlur: 8,
|
| 141 |
+
shadowColor: "rgba(0, 0, 0, 0.1)",
|
| 142 |
+
},
|
| 143 |
+
label: {
|
| 144 |
+
show: !isSmallScreen,
|
| 145 |
+
position: "outside",
|
| 146 |
+
alignTo: "labelLine",
|
| 147 |
+
margin: 6,
|
| 148 |
+
formatter: (params: any) => {
|
| 149 |
+
const percentage = ((params.value / total) * 100).toFixed(1);
|
| 150 |
+
return [
|
| 151 |
+
`{name|${params.name}}`,
|
| 152 |
+
`{value|${
|
| 153 |
+
metric === "cost"
|
| 154 |
+
? `${t("common.currency")}${params.value.toFixed(4)}`
|
| 155 |
+
: `${params.value} ${t("common.count")}`
|
| 156 |
+
}}`,
|
| 157 |
+
`{per|${percentage}%}`,
|
| 158 |
+
].join("\n");
|
| 159 |
+
},
|
| 160 |
+
rich: {
|
| 161 |
+
name: {
|
| 162 |
+
fontSize: 13,
|
| 163 |
+
color: "#444",
|
| 164 |
+
padding: [0, 0, 3, 0],
|
| 165 |
+
fontWeight: 500,
|
| 166 |
+
width: 120,
|
| 167 |
+
overflow: "break",
|
| 168 |
+
},
|
| 169 |
+
value: {
|
| 170 |
+
fontSize: 12,
|
| 171 |
+
color: "#666",
|
| 172 |
+
padding: [3, 0],
|
| 173 |
+
fontFamily: "monospace",
|
| 174 |
+
},
|
| 175 |
+
per: {
|
| 176 |
+
fontSize: 12,
|
| 177 |
+
color: "#888",
|
| 178 |
+
padding: [2, 0, 0, 0],
|
| 179 |
+
},
|
| 180 |
+
},
|
| 181 |
+
lineHeight: 16,
|
| 182 |
+
},
|
| 183 |
+
labelLayout: {
|
| 184 |
+
hideOverlap: true,
|
| 185 |
+
moveOverlap: "shiftY",
|
| 186 |
+
},
|
| 187 |
+
labelLine: {
|
| 188 |
+
show: !isSmallScreen,
|
| 189 |
+
length: 20,
|
| 190 |
+
length2: 20,
|
| 191 |
+
minTurnAngle: 90,
|
| 192 |
+
maxSurfaceAngle: 90,
|
| 193 |
+
smooth: true,
|
| 194 |
+
},
|
| 195 |
+
data: sortedData,
|
| 196 |
+
zlevel: 0,
|
| 197 |
+
padAngle: 2,
|
| 198 |
+
emphasis: {
|
| 199 |
+
scale: true,
|
| 200 |
+
scaleSize: 8,
|
| 201 |
+
focus: "self",
|
| 202 |
+
itemStyle: {
|
| 203 |
+
shadowBlur: 16,
|
| 204 |
+
shadowOffsetX: 0,
|
| 205 |
+
shadowColor: "rgba(0, 0, 0, 0.15)",
|
| 206 |
+
},
|
| 207 |
+
label: {
|
| 208 |
+
show: !isSmallScreen,
|
| 209 |
+
},
|
| 210 |
+
labelLine: {
|
| 211 |
+
show: !isSmallScreen,
|
| 212 |
+
lineStyle: {
|
| 213 |
+
width: 2,
|
| 214 |
+
},
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
select: {
|
| 218 |
+
disabled: true,
|
| 219 |
+
},
|
| 220 |
+
},
|
| 221 |
+
],
|
| 222 |
+
graphic: [
|
| 223 |
+
{
|
| 224 |
+
type: "text",
|
| 225 |
+
left: "center",
|
| 226 |
+
top: "40%",
|
| 227 |
+
style: {
|
| 228 |
+
text:
|
| 229 |
+
metric === "cost"
|
| 230 |
+
? `${t("common.total")}\n${t("common.currency")}${total.toFixed(
|
| 231 |
+
2
|
| 232 |
+
)}`
|
| 233 |
+
: `${t("common.total")}\n${total}${t("common.count")}`,
|
| 234 |
+
textAlign: "center",
|
| 235 |
+
fontSize: 15,
|
| 236 |
+
fontWeight: "500",
|
| 237 |
+
lineHeight: 22,
|
| 238 |
+
fill: "#333",
|
| 239 |
+
},
|
| 240 |
+
zlevel: 2,
|
| 241 |
+
},
|
| 242 |
+
],
|
| 243 |
+
animation: true,
|
| 244 |
+
animationDuration: 500,
|
| 245 |
+
universalTransition: true,
|
| 246 |
+
};
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
export default function ModelDistributionChart({
|
| 250 |
+
loading,
|
| 251 |
+
models,
|
| 252 |
+
metric,
|
| 253 |
+
onMetricChange,
|
| 254 |
+
}: ModelDistributionChartProps) {
|
| 255 |
+
const chartRef = useRef<ECharts>();
|
| 256 |
+
const { t } = useTranslation("common");
|
| 257 |
+
|
| 258 |
+
useEffect(() => {
|
| 259 |
+
const handleResize = () => {
|
| 260 |
+
if (chartRef.current) {
|
| 261 |
+
chartRef.current.resize();
|
| 262 |
+
chartRef.current.setOption(getPieOption(models, metric, t));
|
| 263 |
+
}
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
window.addEventListener("resize", handleResize);
|
| 267 |
+
return () => window.removeEventListener("resize", handleResize);
|
| 268 |
+
}, [metric, models, t]);
|
| 269 |
+
|
| 270 |
+
return (
|
| 271 |
+
<motion.div
|
| 272 |
+
initial={{ opacity: 0, y: 20 }}
|
| 273 |
+
animate={{ opacity: 1, y: 0 }}
|
| 274 |
+
transition={{ delay: 0.2 }}
|
| 275 |
+
className="col-span-full bg-gradient-to-br from-card to-card/95 text-card-foreground rounded-xl border shadow-sm overflow-hidden"
|
| 276 |
+
>
|
| 277 |
+
<div className="relative">
|
| 278 |
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-transparent to-primary/5 pointer-events-none" />
|
| 279 |
+
|
| 280 |
+
<div className="relative p-6 space-y-6">
|
| 281 |
+
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
| 282 |
+
<div className="flex items-center gap-3 flex-1">
|
| 283 |
+
<div className="w-12 h-12 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center rounded-xl shrink-0">
|
| 284 |
+
<PieChartOutlined className="text-xl text-primary" />
|
| 285 |
+
</div>
|
| 286 |
+
<div className="space-y-1">
|
| 287 |
+
<h3 className="text-2xl font-semibold bg-gradient-to-br from-foreground to-foreground/80 bg-clip-text text-transparent">
|
| 288 |
+
{t("panel.modelUsage.title")}
|
| 289 |
+
</h3>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
<div className="sm:ml-auto">
|
| 293 |
+
<MetricToggle value={metric} onChange={onMetricChange} />
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
{loading ? (
|
| 298 |
+
<div className="h-[350px] sm:h-[450px] flex items-center justify-center">
|
| 299 |
+
<Skeleton className="w-full h-full rounded-lg" />
|
| 300 |
+
</div>
|
| 301 |
+
) : (
|
| 302 |
+
<div className="h-[350px] sm:h-[450px] transition-all duration-300">
|
| 303 |
+
<ReactECharts
|
| 304 |
+
option={getPieOption(models, metric, t)}
|
| 305 |
+
style={{ height: "100%", width: "100%" }}
|
| 306 |
+
onChartReady={(instance) => (chartRef.current = instance)}
|
| 307 |
+
/>
|
| 308 |
+
</div>
|
| 309 |
+
)}
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</motion.div>
|
| 313 |
+
);
|
| 314 |
+
}
|
components/panel/TimeRangeSelector.tsx
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import dayjs from "dayjs";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { useTranslation } from "react-i18next";
|
| 7 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 8 |
+
import {
|
| 9 |
+
Calendar as CalendarIcon,
|
| 10 |
+
Clock,
|
| 11 |
+
Sun,
|
| 12 |
+
CalendarDays,
|
| 13 |
+
CalendarRange,
|
| 14 |
+
CalendarClock,
|
| 15 |
+
CalendarCheck,
|
| 16 |
+
} from "lucide-react";
|
| 17 |
+
import { Calendar } from "@/components/ui/calendar";
|
| 18 |
+
import {
|
| 19 |
+
Popover,
|
| 20 |
+
PopoverContent,
|
| 21 |
+
PopoverTrigger,
|
| 22 |
+
} from "@/components/ui/popover";
|
| 23 |
+
import { cn } from "@/lib/utils";
|
| 24 |
+
import { format } from "date-fns";
|
| 25 |
+
import { zhCN } from "date-fns/locale";
|
| 26 |
+
|
| 27 |
+
export type TimeRangeType =
|
| 28 |
+
| "today"
|
| 29 |
+
| "week"
|
| 30 |
+
| "month"
|
| 31 |
+
| "30days"
|
| 32 |
+
| "all"
|
| 33 |
+
| "custom";
|
| 34 |
+
|
| 35 |
+
interface TimeRangeSelectorProps {
|
| 36 |
+
timeRange: [Date, Date];
|
| 37 |
+
timeRangeType: TimeRangeType;
|
| 38 |
+
availableTimeRange: {
|
| 39 |
+
minTime: Date;
|
| 40 |
+
maxTime: Date;
|
| 41 |
+
};
|
| 42 |
+
onTimeRangeChange: (range: [Date, Date], type: TimeRangeType) => void;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const checkTimeRangeType = (
|
| 46 |
+
startTime: dayjs.Dayjs,
|
| 47 |
+
endTime: dayjs.Dayjs,
|
| 48 |
+
availableTimeRange: { minTime: Date; maxTime: Date }
|
| 49 |
+
): TimeRangeType => {
|
| 50 |
+
if (
|
| 51 |
+
dayjs(startTime).isSame(availableTimeRange.minTime, "hour") &&
|
| 52 |
+
dayjs(endTime).isSame(availableTimeRange.maxTime, "hour")
|
| 53 |
+
) {
|
| 54 |
+
return "all";
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const now = dayjs();
|
| 58 |
+
const isToday = startTime.isSame(now.startOf("day")) && endTime.isSame(now);
|
| 59 |
+
const isWeek = startTime.isSame(now.startOf("week")) && endTime.isSame(now);
|
| 60 |
+
const isMonth = startTime.isSame(now.startOf("month")) && endTime.isSame(now);
|
| 61 |
+
const is30Days =
|
| 62 |
+
startTime.isSame(now.subtract(30, "day"), "hour") && endTime.isSame(now);
|
| 63 |
+
|
| 64 |
+
if (isToday) return "today";
|
| 65 |
+
if (isWeek) return "week";
|
| 66 |
+
if (isMonth) return "month";
|
| 67 |
+
if (is30Days) return "30days";
|
| 68 |
+
|
| 69 |
+
return "custom";
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export default function TimeRangeSelector({
|
| 73 |
+
timeRange,
|
| 74 |
+
timeRangeType,
|
| 75 |
+
availableTimeRange,
|
| 76 |
+
onTimeRangeChange,
|
| 77 |
+
}: TimeRangeSelectorProps) {
|
| 78 |
+
const { t, i18n } = useTranslation("common");
|
| 79 |
+
const [isCustomOpen, setIsCustomOpen] = useState(false);
|
| 80 |
+
const [startOpen, setStartOpen] = useState(false);
|
| 81 |
+
const [endOpen, setEndOpen] = useState(false);
|
| 82 |
+
|
| 83 |
+
const [startDate, setStartDate] = useState<Date>(timeRange[0]);
|
| 84 |
+
const [endDate, setEndDate] = useState<Date>(timeRange[1]);
|
| 85 |
+
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
setStartDate(timeRange[0]);
|
| 88 |
+
setEndDate(timeRange[1]);
|
| 89 |
+
}, [timeRange]);
|
| 90 |
+
|
| 91 |
+
const timeOptions = [
|
| 92 |
+
{
|
| 93 |
+
id: "today",
|
| 94 |
+
type: "today" as TimeRangeType,
|
| 95 |
+
label: t("panel.timeRange.timeOptions.day"),
|
| 96 |
+
icon: Sun,
|
| 97 |
+
getRange: () =>
|
| 98 |
+
[dayjs().startOf("day").toDate(), dayjs().endOf("day").toDate()] as [
|
| 99 |
+
Date,
|
| 100 |
+
Date
|
| 101 |
+
],
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
id: "week",
|
| 105 |
+
type: "week" as TimeRangeType,
|
| 106 |
+
label: t("panel.timeRange.timeOptions.week"),
|
| 107 |
+
icon: CalendarDays,
|
| 108 |
+
getRange: () =>
|
| 109 |
+
[dayjs().startOf("week").toDate(), dayjs().endOf("week").toDate()] as [
|
| 110 |
+
Date,
|
| 111 |
+
Date
|
| 112 |
+
],
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
id: "month",
|
| 116 |
+
type: "month" as TimeRangeType,
|
| 117 |
+
label: t("panel.timeRange.timeOptions.month"),
|
| 118 |
+
icon: CalendarRange,
|
| 119 |
+
getRange: () =>
|
| 120 |
+
[
|
| 121 |
+
dayjs().startOf("month").toDate(),
|
| 122 |
+
dayjs().endOf("month").toDate(),
|
| 123 |
+
] as [Date, Date],
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
id: "30days",
|
| 127 |
+
type: "30days" as TimeRangeType,
|
| 128 |
+
label: t("panel.timeRange.timeOptions.30Days"),
|
| 129 |
+
icon: CalendarClock,
|
| 130 |
+
getRange: () =>
|
| 131 |
+
[
|
| 132 |
+
dayjs().subtract(29, "days").startOf("day").toDate(),
|
| 133 |
+
dayjs().endOf("day").toDate(),
|
| 134 |
+
] as [Date, Date],
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
id: "all",
|
| 138 |
+
type: "all" as TimeRangeType,
|
| 139 |
+
label: t("panel.timeRange.timeOptions.all"),
|
| 140 |
+
icon: CalendarCheck,
|
| 141 |
+
getRange: () =>
|
| 142 |
+
[
|
| 143 |
+
dayjs(availableTimeRange.minTime).startOf("day").toDate(),
|
| 144 |
+
dayjs(availableTimeRange.maxTime).endOf("day").toDate(),
|
| 145 |
+
] as [Date, Date],
|
| 146 |
+
},
|
| 147 |
+
];
|
| 148 |
+
|
| 149 |
+
const handleTimeOptionClick = (type: TimeRangeType) => {
|
| 150 |
+
const option = timeOptions.find((opt) => opt.type === type);
|
| 151 |
+
if (!option) return;
|
| 152 |
+
|
| 153 |
+
const range = option.getRange();
|
| 154 |
+
setStartDate(range[0]);
|
| 155 |
+
setEndDate(range[1]);
|
| 156 |
+
setIsCustomOpen(false);
|
| 157 |
+
onTimeRangeChange(range, type);
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const handleCustomButtonClick = () => {
|
| 161 |
+
const isOpening = !isCustomOpen;
|
| 162 |
+
setIsCustomOpen(isOpening);
|
| 163 |
+
|
| 164 |
+
if (isOpening) {
|
| 165 |
+
onTimeRangeChange([startDate, endDate], "custom");
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const handleDateChange = (start?: Date, end?: Date) => {
|
| 170 |
+
if (!start || !end) return;
|
| 171 |
+
|
| 172 |
+
const newStart = dayjs(start).startOf("day").toDate();
|
| 173 |
+
const newEnd = dayjs(end).endOf("day").toDate();
|
| 174 |
+
|
| 175 |
+
setStartDate(newStart);
|
| 176 |
+
setEndDate(newEnd);
|
| 177 |
+
onTimeRangeChange([newStart, newEnd], "custom");
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const formatDate = (date?: Date) => {
|
| 181 |
+
if (!date) return t("panel.timeRange.selectDate");
|
| 182 |
+
return format(date, "yyyy-MM-dd", {
|
| 183 |
+
locale: i18n.language === "zh" ? zhCN : undefined,
|
| 184 |
+
});
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div className="space-y-6">
|
| 189 |
+
<div className="flex items-center gap-2 text-lg font-medium">
|
| 190 |
+
<Clock className="w-5 h-5 text-primary" />
|
| 191 |
+
<h3>{t("panel.timeRange.title")}</h3>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div className="space-y-4">
|
| 195 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
| 196 |
+
{timeOptions.map(({ id, type, label, icon: Icon }) => (
|
| 197 |
+
<motion.div
|
| 198 |
+
key={id}
|
| 199 |
+
whileHover={{ scale: 1.02 }}
|
| 200 |
+
whileTap={{ scale: 0.98 }}
|
| 201 |
+
>
|
| 202 |
+
<Button
|
| 203 |
+
variant={timeRangeType === type ? "default" : "outline"}
|
| 204 |
+
className="w-full h-full min-h-[52px] flex flex-col gap-1.5 items-center justify-center"
|
| 205 |
+
onClick={() => handleTimeOptionClick(type)}
|
| 206 |
+
>
|
| 207 |
+
<Icon className="w-4 h-4" />
|
| 208 |
+
<span className="text-sm">{label}</span>
|
| 209 |
+
</Button>
|
| 210 |
+
</motion.div>
|
| 211 |
+
))}
|
| 212 |
+
|
| 213 |
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
| 214 |
+
<Button
|
| 215 |
+
variant={timeRangeType === "custom" ? "default" : "outline"}
|
| 216 |
+
className="w-full h-full min-h-[52px] flex flex-col gap-1.5 items-center justify-center"
|
| 217 |
+
onClick={handleCustomButtonClick}
|
| 218 |
+
>
|
| 219 |
+
<CalendarIcon className="w-4 h-4" />
|
| 220 |
+
<span className="text-sm">
|
| 221 |
+
{t("panel.timeRange.timeOptions.custom")}
|
| 222 |
+
</span>
|
| 223 |
+
</Button>
|
| 224 |
+
</motion.div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<AnimatePresence>
|
| 228 |
+
{isCustomOpen && (
|
| 229 |
+
<motion.div
|
| 230 |
+
initial={{ opacity: 0, height: 0 }}
|
| 231 |
+
animate={{ opacity: 1, height: "auto" }}
|
| 232 |
+
exit={{ opacity: 0, height: 0 }}
|
| 233 |
+
className="space-y-3"
|
| 234 |
+
>
|
| 235 |
+
<div className="text-sm text-muted-foreground">
|
| 236 |
+
{t("panel.timeRange.customRange")}
|
| 237 |
+
</div>
|
| 238 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 239 |
+
<Popover open={startOpen} onOpenChange={setStartOpen}>
|
| 240 |
+
<PopoverTrigger asChild>
|
| 241 |
+
<Button
|
| 242 |
+
variant="secondary"
|
| 243 |
+
className={cn(
|
| 244 |
+
"justify-start text-left font-normal w-full sm:w-[240px]",
|
| 245 |
+
!startDate && "text-muted-foreground"
|
| 246 |
+
)}
|
| 247 |
+
>
|
| 248 |
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
| 249 |
+
{formatDate(startDate)}
|
| 250 |
+
</Button>
|
| 251 |
+
</PopoverTrigger>
|
| 252 |
+
<PopoverContent className="w-auto p-0" align="start">
|
| 253 |
+
<Calendar
|
| 254 |
+
mode="single"
|
| 255 |
+
selected={startDate}
|
| 256 |
+
defaultMonth={startDate}
|
| 257 |
+
onSelect={(date) => {
|
| 258 |
+
if (date) {
|
| 259 |
+
handleDateChange(date, endDate);
|
| 260 |
+
setStartOpen(false);
|
| 261 |
+
}
|
| 262 |
+
}}
|
| 263 |
+
disabled={(date) =>
|
| 264 |
+
endDate ? dayjs(date).isAfter(endDate, "day") : false
|
| 265 |
+
}
|
| 266 |
+
initialFocus
|
| 267 |
+
locale={i18n.language === "zh" ? zhCN : undefined}
|
| 268 |
+
/>
|
| 269 |
+
</PopoverContent>
|
| 270 |
+
</Popover>
|
| 271 |
+
|
| 272 |
+
<Popover open={endOpen} onOpenChange={setEndOpen}>
|
| 273 |
+
<PopoverTrigger asChild>
|
| 274 |
+
<Button
|
| 275 |
+
variant="secondary"
|
| 276 |
+
className={cn(
|
| 277 |
+
"justify-start text-left font-normal w-full sm:w-[240px]",
|
| 278 |
+
!endDate && "text-muted-foreground"
|
| 279 |
+
)}
|
| 280 |
+
>
|
| 281 |
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
| 282 |
+
{formatDate(endDate)}
|
| 283 |
+
</Button>
|
| 284 |
+
</PopoverTrigger>
|
| 285 |
+
<PopoverContent className="w-auto p-0" align="start">
|
| 286 |
+
<Calendar
|
| 287 |
+
mode="single"
|
| 288 |
+
selected={endDate}
|
| 289 |
+
defaultMonth={endDate}
|
| 290 |
+
onSelect={(date) => {
|
| 291 |
+
if (date) {
|
| 292 |
+
handleDateChange(startDate, date);
|
| 293 |
+
setEndOpen(false);
|
| 294 |
+
}
|
| 295 |
+
}}
|
| 296 |
+
disabled={(date) =>
|
| 297 |
+
startDate
|
| 298 |
+
? dayjs(date).isBefore(startDate, "day")
|
| 299 |
+
: false
|
| 300 |
+
}
|
| 301 |
+
initialFocus
|
| 302 |
+
locale={i18n.language === "zh" ? zhCN : undefined}
|
| 303 |
+
/>
|
| 304 |
+
</PopoverContent>
|
| 305 |
+
</Popover>
|
| 306 |
+
</div>
|
| 307 |
+
</motion.div>
|
| 308 |
+
)}
|
| 309 |
+
</AnimatePresence>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
);
|
| 313 |
+
}
|
components/panel/UsageRecordsTable.tsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Table, TablePaginationConfig, Select } from "antd";
|
| 5 |
+
import type { FilterValue } from "antd/es/table/interface";
|
| 6 |
+
import type { SorterResult } from "antd/es/table/interface";
|
| 7 |
+
import dayjs from "dayjs";
|
| 8 |
+
import { useTranslation } from "react-i18next";
|
| 9 |
+
interface UsageRecord {
|
| 10 |
+
id: number;
|
| 11 |
+
nickname: string;
|
| 12 |
+
use_time: string;
|
| 13 |
+
model_name: string;
|
| 14 |
+
input_tokens: number;
|
| 15 |
+
output_tokens: number;
|
| 16 |
+
cost: number;
|
| 17 |
+
balance_after: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface TableParams {
|
| 21 |
+
pagination: TablePaginationConfig;
|
| 22 |
+
sortField?: string;
|
| 23 |
+
sortOrder?: string;
|
| 24 |
+
filters?: Record<string, FilterValue | null>;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface Props {
|
| 28 |
+
loading: boolean;
|
| 29 |
+
records: UsageRecord[];
|
| 30 |
+
tableParams: TableParams;
|
| 31 |
+
models: { model_name: string }[];
|
| 32 |
+
users: { nickname: string }[];
|
| 33 |
+
onTableChange: (
|
| 34 |
+
pagination: TablePaginationConfig,
|
| 35 |
+
filters: Record<string, FilterValue | null>,
|
| 36 |
+
sorter: SorterResult<UsageRecord> | SorterResult<UsageRecord>[]
|
| 37 |
+
) => void;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const MobileCard = ({
|
| 41 |
+
record,
|
| 42 |
+
t,
|
| 43 |
+
}: {
|
| 44 |
+
record: UsageRecord;
|
| 45 |
+
t: (key: string) => string;
|
| 46 |
+
}) => {
|
| 47 |
+
return (
|
| 48 |
+
<div className="p-4 bg-white rounded-xl border border-gray-100/80 shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-200/80">
|
| 49 |
+
<div className="flex justify-between items-start mb-4">
|
| 50 |
+
<div className="space-y-1">
|
| 51 |
+
<div className="font-medium text-gray-900">{record.nickname}</div>
|
| 52 |
+
<div className="text-xs text-gray-500 flex items-center gap-1.5">
|
| 53 |
+
<div className="w-1 h-1 rounded-full bg-gray-300" />
|
| 54 |
+
{dayjs(record.use_time).format("YYYY-MM-DD HH:mm:ss")}
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div className="text-right">
|
| 58 |
+
<div className="font-medium text-primary">
|
| 59 |
+
¥{Number(record.cost).toFixed(4)}
|
| 60 |
+
</div>
|
| 61 |
+
<div className="text-xs text-gray-500 mt-1">
|
| 62 |
+
{t("panel.usageDetails.table.balance")}: ¥
|
| 63 |
+
{Number(record.balance_after).toFixed(4)}
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="flex items-center gap-4 bg-gray-50/70 rounded-lg p-3">
|
| 69 |
+
<div className="flex-1 min-w-0">
|
| 70 |
+
<div className="text-xs text-gray-500 mb-1.5">
|
| 71 |
+
{t("panel.usageDetails.table.model")}
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-sm text-gray-700 font-medium truncate">
|
| 74 |
+
{record.model_name}
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="shrink-0">
|
| 78 |
+
<div className="text-xs text-gray-500 mb-1.5">Tokens</div>
|
| 79 |
+
<div className="text-sm text-gray-700 font-medium tabular-nums">
|
| 80 |
+
{(record.input_tokens + record.output_tokens).toLocaleString()}
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export default function UsageRecordsTable({
|
| 89 |
+
loading,
|
| 90 |
+
records,
|
| 91 |
+
tableParams,
|
| 92 |
+
models,
|
| 93 |
+
users,
|
| 94 |
+
onTableChange,
|
| 95 |
+
}: Props) {
|
| 96 |
+
const { t } = useTranslation("common");
|
| 97 |
+
|
| 98 |
+
const [filters, setFilters] = useState<Record<string, FilterValue | null>>(
|
| 99 |
+
tableParams.filters || {}
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
const handleFilterChange = (field: string, value: string[] | null) => {
|
| 103 |
+
const newFilters = {
|
| 104 |
+
...filters,
|
| 105 |
+
[field]: value,
|
| 106 |
+
};
|
| 107 |
+
setFilters(newFilters);
|
| 108 |
+
onTableChange(tableParams.pagination, newFilters, {});
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const columns = [
|
| 112 |
+
{
|
| 113 |
+
title: t("panel.usageDetails.table.user"),
|
| 114 |
+
dataIndex: "nickname",
|
| 115 |
+
key: "nickname",
|
| 116 |
+
width: 120,
|
| 117 |
+
filters: users.map((user) => ({
|
| 118 |
+
text: user.nickname,
|
| 119 |
+
value: user.nickname,
|
| 120 |
+
})),
|
| 121 |
+
filterMode: "menu" as const,
|
| 122 |
+
filtered: filters.nickname ? filters.nickname.length > 0 : false,
|
| 123 |
+
filteredValue: filters.nickname || null,
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
title: t("panel.usageDetails.table.time"),
|
| 127 |
+
dataIndex: "use_time",
|
| 128 |
+
key: "use_time",
|
| 129 |
+
width: 180,
|
| 130 |
+
sorter: true,
|
| 131 |
+
render: (time: string) => dayjs(time).format("YYYY-MM-DD HH:mm:ss"),
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
title: t("panel.usageDetails.table.model"),
|
| 135 |
+
dataIndex: "model_name",
|
| 136 |
+
key: "model_name",
|
| 137 |
+
width: 150,
|
| 138 |
+
filters: models.map((model) => ({
|
| 139 |
+
text: model.model_name,
|
| 140 |
+
value: model.model_name,
|
| 141 |
+
})),
|
| 142 |
+
filterMode: "menu" as const,
|
| 143 |
+
filtered: filters.model_name ? filters.model_name.length > 0 : false,
|
| 144 |
+
filteredValue: filters.model_name || null,
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
title: t("panel.usageDetails.table.tokens"),
|
| 148 |
+
key: "tokens",
|
| 149 |
+
width: 120,
|
| 150 |
+
sorter: true,
|
| 151 |
+
render: (_: unknown, record: UsageRecord) =>
|
| 152 |
+
(record.input_tokens + record.output_tokens).toLocaleString(),
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
title: t("panel.usageDetails.table.cost"),
|
| 156 |
+
dataIndex: "cost",
|
| 157 |
+
key: "cost",
|
| 158 |
+
width: 100,
|
| 159 |
+
sorter: true,
|
| 160 |
+
render: (_: unknown, record: UsageRecord) =>
|
| 161 |
+
`${t("common.currency")}${Number(record.cost).toFixed(4)}`,
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
title: t("panel.usageDetails.table.balance"),
|
| 165 |
+
dataIndex: "balance_after",
|
| 166 |
+
key: "balance_after",
|
| 167 |
+
width: 100,
|
| 168 |
+
sorter: true,
|
| 169 |
+
render: (_: unknown, record: UsageRecord) =>
|
| 170 |
+
`${t("common.currency")}${Number(record.balance_after).toFixed(2)}`,
|
| 171 |
+
},
|
| 172 |
+
];
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<div className="space-y-4">
|
| 176 |
+
<div className="sm:hidden space-y-3">
|
| 177 |
+
<Select
|
| 178 |
+
mode="multiple"
|
| 179 |
+
placeholder={t("panel.usageDetails.table.user")}
|
| 180 |
+
className="w-full"
|
| 181 |
+
value={filters.nickname as string[]}
|
| 182 |
+
onChange={(value) => handleFilterChange("nickname", value)}
|
| 183 |
+
options={users.map((user) => ({
|
| 184 |
+
label: user.nickname,
|
| 185 |
+
value: user.nickname,
|
| 186 |
+
}))}
|
| 187 |
+
maxTagCount="responsive"
|
| 188 |
+
/>
|
| 189 |
+
<Select
|
| 190 |
+
mode="multiple"
|
| 191 |
+
placeholder={t("panel.usageDetails.table.model")}
|
| 192 |
+
className="w-full"
|
| 193 |
+
value={filters.model_name as string[]}
|
| 194 |
+
onChange={(value) => handleFilterChange("model_name", value)}
|
| 195 |
+
options={models.map((model) => ({
|
| 196 |
+
label: model.model_name,
|
| 197 |
+
value: model.model_name,
|
| 198 |
+
}))}
|
| 199 |
+
maxTagCount="responsive"
|
| 200 |
+
/>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* 桌面设备表格 */}
|
| 204 |
+
<div className="hidden sm:block">
|
| 205 |
+
<Table
|
| 206 |
+
columns={columns}
|
| 207 |
+
dataSource={records}
|
| 208 |
+
loading={loading}
|
| 209 |
+
onChange={onTableChange}
|
| 210 |
+
pagination={{
|
| 211 |
+
...tableParams.pagination,
|
| 212 |
+
className: "px-2",
|
| 213 |
+
showTotal: (total) => `${t("common.total")} ${total}`,
|
| 214 |
+
itemRender: (page, type, originalElement) => {
|
| 215 |
+
if (type === "prev") {
|
| 216 |
+
return (
|
| 217 |
+
<button className="px-2 py-0.5 hover:text-primary">
|
| 218 |
+
{t("common.prev")}
|
| 219 |
+
</button>
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
if (type === "next") {
|
| 223 |
+
return (
|
| 224 |
+
<button className="px-2 py-0.5 hover:text-primary">
|
| 225 |
+
{t("common.next")}
|
| 226 |
+
</button>
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
return originalElement;
|
| 230 |
+
},
|
| 231 |
+
}}
|
| 232 |
+
rowKey="id"
|
| 233 |
+
scroll={{ x: 800 }}
|
| 234 |
+
className="bg-background rounded-md border [&_.ant-table-thead]:bg-muted [&_.ant-table-thead>tr>th]:bg-transparent [&_.ant-table-thead>tr>th]:text-muted-foreground [&_.ant-table-tbody>tr>td]:border-muted [&_.ant-table-tbody>tr:last-child>td]:border-b-0 [&_.ant-table-tbody>tr:hover>td]:bg-muted/50 [&_.ant-pagination]:flex [&_.ant-pagination]:items-center [&_.ant-pagination]:px-2 [&_.ant-pagination]:py-4 [&_.ant-pagination]:border-t [&_.ant-pagination]:border-muted [&_.ant-pagination-item]:border-muted [&_.ant-pagination-item]:bg-transparent [&_.ant-pagination-item]:hover:border-primary [&_.ant-pagination-item]:hover:text-primary [&_.ant-pagination-item-active]:border-primary [&_.ant-pagination-item-active]:text-primary [&_.ant-pagination-item-active]:bg-transparent [&_.ant-pagination-prev]:hover:text-primary [&_.ant-pagination-next]:hover:text-primary [&_.ant-pagination-prev>button]:hover:border-primary [&_.ant-pagination-next>button]:hover:border-primary [&_.ant-pagination-options]:ml-auto [&_.ant-select]:border-muted [&_.ant-select]:hover:border-primary [&_.ant-select-focused]:border-primary"
|
| 235 |
+
/>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
{/* 移动设备卡片列表 */}
|
| 239 |
+
<div className="sm:hidden space-y-4">
|
| 240 |
+
{loading ? (
|
| 241 |
+
<div className="flex justify-center py-8">
|
| 242 |
+
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary animate-spin rounded-full" />
|
| 243 |
+
</div>
|
| 244 |
+
) : (
|
| 245 |
+
<>
|
| 246 |
+
<div className="space-y-3">
|
| 247 |
+
{records.map((record) => (
|
| 248 |
+
<MobileCard key={record.id} record={record} t={t} />
|
| 249 |
+
))}
|
| 250 |
+
</div>
|
| 251 |
+
<Table
|
| 252 |
+
dataSource={[]}
|
| 253 |
+
loading={loading}
|
| 254 |
+
onChange={onTableChange}
|
| 255 |
+
pagination={{
|
| 256 |
+
...tableParams.pagination,
|
| 257 |
+
size: "small",
|
| 258 |
+
className:
|
| 259 |
+
"flex justify-center [&_.ant-pagination-options]:hidden",
|
| 260 |
+
}}
|
| 261 |
+
className="[&_.ant-pagination]:!mt-0 [&_.ant-table]:hidden [&_.ant-pagination-item]:!bg-white"
|
| 262 |
+
/>
|
| 263 |
+
</>
|
| 264 |
+
)}
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
);
|
| 268 |
+
}
|
components/panel/UserRankingChart.tsx
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect } from "react";
|
| 4 |
+
import { Spin, Skeleton } from "antd";
|
| 5 |
+
import ReactECharts from "echarts-for-react";
|
| 6 |
+
import type { ECharts } from "echarts";
|
| 7 |
+
import { MetricToggle } from "@/components/ui/metric-toggle";
|
| 8 |
+
import { useTranslation } from "react-i18next";
|
| 9 |
+
import { BarChartOutlined } from "@ant-design/icons";
|
| 10 |
+
import { Card as ShadcnCard } from "@/components/ui/card";
|
| 11 |
+
import { motion } from "framer-motion";
|
| 12 |
+
|
| 13 |
+
interface UserUsage {
|
| 14 |
+
nickname: string;
|
| 15 |
+
total_cost: number;
|
| 16 |
+
total_count: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface UserRankingChartProps {
|
| 20 |
+
loading: boolean;
|
| 21 |
+
users: UserUsage[];
|
| 22 |
+
metric: "cost" | "count";
|
| 23 |
+
onMetricChange: (metric: "cost" | "count") => void;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const getBarOption = (
|
| 27 |
+
users: UserUsage[],
|
| 28 |
+
metric: "cost" | "count",
|
| 29 |
+
t: (key: string) => string
|
| 30 |
+
) => {
|
| 31 |
+
const columnData = users
|
| 32 |
+
.map((item) => ({
|
| 33 |
+
nickname: item.nickname,
|
| 34 |
+
value: metric === "cost" ? Number(item.total_cost) : item.total_count,
|
| 35 |
+
}))
|
| 36 |
+
.sort((a, b) => b.value - a.value);
|
| 37 |
+
|
| 38 |
+
const isSmallScreen = window.innerWidth < 640;
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
tooltip: {
|
| 42 |
+
show: false,
|
| 43 |
+
},
|
| 44 |
+
grid: {
|
| 45 |
+
top: isSmallScreen ? "8%" : "4%",
|
| 46 |
+
bottom: isSmallScreen ? "2%" : "1%",
|
| 47 |
+
left: "4%",
|
| 48 |
+
right: "4%",
|
| 49 |
+
containLabel: true,
|
| 50 |
+
},
|
| 51 |
+
xAxis: {
|
| 52 |
+
type: "category",
|
| 53 |
+
data: columnData.map((item) =>
|
| 54 |
+
item.nickname.length > 12
|
| 55 |
+
? item.nickname.slice(0, 10) + "..."
|
| 56 |
+
: item.nickname
|
| 57 |
+
),
|
| 58 |
+
axisLabel: {
|
| 59 |
+
inside: false,
|
| 60 |
+
color: "#555",
|
| 61 |
+
fontSize: 12,
|
| 62 |
+
rotate: 35,
|
| 63 |
+
interval: 0,
|
| 64 |
+
hideOverlap: true,
|
| 65 |
+
padding: [0, 0, 0, 0],
|
| 66 |
+
verticalAlign: "middle",
|
| 67 |
+
align: "right",
|
| 68 |
+
margin: 8,
|
| 69 |
+
},
|
| 70 |
+
axisTick: {
|
| 71 |
+
show: false,
|
| 72 |
+
},
|
| 73 |
+
axisLine: {
|
| 74 |
+
show: true,
|
| 75 |
+
lineStyle: {
|
| 76 |
+
color: "#eee",
|
| 77 |
+
width: 2,
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
z: 10,
|
| 81 |
+
},
|
| 82 |
+
yAxis: {
|
| 83 |
+
type: "value",
|
| 84 |
+
name: "",
|
| 85 |
+
nameTextStyle: {
|
| 86 |
+
color: "#666",
|
| 87 |
+
fontSize: 13,
|
| 88 |
+
padding: [0, 0, 0, 0],
|
| 89 |
+
},
|
| 90 |
+
axisLine: {
|
| 91 |
+
show: true,
|
| 92 |
+
lineStyle: {
|
| 93 |
+
color: "#eee",
|
| 94 |
+
width: 2,
|
| 95 |
+
},
|
| 96 |
+
},
|
| 97 |
+
axisTick: {
|
| 98 |
+
show: true,
|
| 99 |
+
lineStyle: {
|
| 100 |
+
color: "#eee",
|
| 101 |
+
},
|
| 102 |
+
},
|
| 103 |
+
splitLine: {
|
| 104 |
+
show: true,
|
| 105 |
+
lineStyle: {
|
| 106 |
+
color: "#f5f5f5",
|
| 107 |
+
width: 2,
|
| 108 |
+
},
|
| 109 |
+
},
|
| 110 |
+
axisLabel: {
|
| 111 |
+
color: "#666",
|
| 112 |
+
fontSize: 12,
|
| 113 |
+
formatter: (value: number) => {
|
| 114 |
+
if (metric === "cost") {
|
| 115 |
+
return `¥${value.toFixed(1)}`;
|
| 116 |
+
}
|
| 117 |
+
return `${value}次`;
|
| 118 |
+
},
|
| 119 |
+
},
|
| 120 |
+
},
|
| 121 |
+
dataZoom: [
|
| 122 |
+
{
|
| 123 |
+
type: "inside",
|
| 124 |
+
start: 0,
|
| 125 |
+
end: Math.min(100, Math.max(100 * (15 / columnData.length), 40)),
|
| 126 |
+
zoomLock: true,
|
| 127 |
+
moveOnMouseMove: true,
|
| 128 |
+
},
|
| 129 |
+
],
|
| 130 |
+
series: [
|
| 131 |
+
{
|
| 132 |
+
type: "bar",
|
| 133 |
+
itemStyle: {
|
| 134 |
+
color: {
|
| 135 |
+
type: "linear",
|
| 136 |
+
x: 0,
|
| 137 |
+
y: 0,
|
| 138 |
+
x2: 0,
|
| 139 |
+
y2: 1,
|
| 140 |
+
colorStops: [
|
| 141 |
+
{
|
| 142 |
+
offset: 0,
|
| 143 |
+
color: "rgba(99, 133, 255, 0.85)",
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
offset: 1,
|
| 147 |
+
color: "rgba(99, 133, 255, 0.4)",
|
| 148 |
+
},
|
| 149 |
+
],
|
| 150 |
+
},
|
| 151 |
+
borderRadius: [8, 8, 0, 0],
|
| 152 |
+
},
|
| 153 |
+
emphasis: {
|
| 154 |
+
itemStyle: {
|
| 155 |
+
color: {
|
| 156 |
+
type: "linear",
|
| 157 |
+
x: 0,
|
| 158 |
+
y: 0,
|
| 159 |
+
x2: 0,
|
| 160 |
+
y2: 1,
|
| 161 |
+
colorStops: [
|
| 162 |
+
{
|
| 163 |
+
offset: 0,
|
| 164 |
+
color: "rgba(99, 133, 255, 0.95)",
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
offset: 1,
|
| 168 |
+
color: "rgba(99, 133, 255, 0.5)",
|
| 169 |
+
},
|
| 170 |
+
],
|
| 171 |
+
},
|
| 172 |
+
shadowBlur: 10,
|
| 173 |
+
shadowColor: "rgba(99, 133, 255, 0.2)",
|
| 174 |
+
},
|
| 175 |
+
},
|
| 176 |
+
barWidth: "35%",
|
| 177 |
+
data: columnData.map((item) => item.value),
|
| 178 |
+
showBackground: true,
|
| 179 |
+
backgroundStyle: {
|
| 180 |
+
color: "rgba(180, 180, 180, 0.08)",
|
| 181 |
+
borderRadius: [8, 8, 0, 0],
|
| 182 |
+
},
|
| 183 |
+
label: {
|
| 184 |
+
show: !isSmallScreen,
|
| 185 |
+
position: "top",
|
| 186 |
+
formatter: (params: any) => {
|
| 187 |
+
return metric === "cost"
|
| 188 |
+
? `${params.value.toFixed(2)}`
|
| 189 |
+
: `${params.value}`;
|
| 190 |
+
},
|
| 191 |
+
fontSize: 11,
|
| 192 |
+
color: "#666",
|
| 193 |
+
distance: 2,
|
| 194 |
+
fontFamily: "monospace",
|
| 195 |
+
},
|
| 196 |
+
},
|
| 197 |
+
],
|
| 198 |
+
animation: true,
|
| 199 |
+
animationDuration: 800,
|
| 200 |
+
animationEasing: "cubicOut" as const,
|
| 201 |
+
};
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
export default function UserRankingChart({
|
| 205 |
+
loading,
|
| 206 |
+
users,
|
| 207 |
+
metric,
|
| 208 |
+
onMetricChange,
|
| 209 |
+
}: UserRankingChartProps) {
|
| 210 |
+
const { t } = useTranslation("common");
|
| 211 |
+
const chartRef = useRef<ECharts>();
|
| 212 |
+
|
| 213 |
+
useEffect(() => {
|
| 214 |
+
const handleResize = () => {
|
| 215 |
+
if (chartRef.current) {
|
| 216 |
+
chartRef.current.resize();
|
| 217 |
+
chartRef.current.setOption(getBarOption(users, metric, t));
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
window.addEventListener("resize", handleResize);
|
| 222 |
+
return () => window.removeEventListener("resize", handleResize);
|
| 223 |
+
}, [metric, users, t]);
|
| 224 |
+
|
| 225 |
+
const onChartReady = (instance: ECharts) => {
|
| 226 |
+
chartRef.current = instance;
|
| 227 |
+
const zoomSize = 6;
|
| 228 |
+
let isZoomed = false; // 增加一个状态变量
|
| 229 |
+
|
| 230 |
+
instance.on("click", (params) => {
|
| 231 |
+
const dataLength = users.length;
|
| 232 |
+
|
| 233 |
+
if (!isZoomed) {
|
| 234 |
+
// 第一次点击,放大区域
|
| 235 |
+
instance.dispatchAction({
|
| 236 |
+
type: "dataZoom",
|
| 237 |
+
startValue:
|
| 238 |
+
users[Math.max(params.dataIndex - zoomSize / 2, 0)].nickname,
|
| 239 |
+
endValue:
|
| 240 |
+
users[Math.min(params.dataIndex + zoomSize / 2, dataLength - 1)]
|
| 241 |
+
.nickname,
|
| 242 |
+
});
|
| 243 |
+
isZoomed = true;
|
| 244 |
+
} else {
|
| 245 |
+
// 第二次点击,还原缩放
|
| 246 |
+
instance.dispatchAction({
|
| 247 |
+
type: "dataZoom",
|
| 248 |
+
start: 0,
|
| 249 |
+
end: 100,
|
| 250 |
+
});
|
| 251 |
+
isZoomed = false;
|
| 252 |
+
}
|
| 253 |
+
});
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
return (
|
| 257 |
+
<motion.div
|
| 258 |
+
initial={{ opacity: 0, y: 20 }}
|
| 259 |
+
animate={{ opacity: 1, y: 0 }}
|
| 260 |
+
transition={{ delay: 0.3 }}
|
| 261 |
+
className="col-span-full bg-gradient-to-br from-card to-card/95 text-card-foreground rounded-xl border shadow-sm overflow-hidden"
|
| 262 |
+
>
|
| 263 |
+
<div className="relative">
|
| 264 |
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-transparent to-primary/5 pointer-events-none" />
|
| 265 |
+
|
| 266 |
+
<div className="relative p-6 space-y-6">
|
| 267 |
+
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
| 268 |
+
<div className="flex items-center gap-3 flex-1">
|
| 269 |
+
<div className="w-12 h-12 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center rounded-xl shrink-0">
|
| 270 |
+
<BarChartOutlined className="text-xl text-primary" />
|
| 271 |
+
</div>
|
| 272 |
+
<div className="space-y-1">
|
| 273 |
+
<h3 className="text-2xl font-semibold bg-gradient-to-br from-foreground to-foreground/80 bg-clip-text text-transparent">
|
| 274 |
+
{t("panel.userUsageChart.title")}
|
| 275 |
+
</h3>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
<div className="sm:ml-auto">
|
| 279 |
+
<MetricToggle value={metric} onChange={onMetricChange} />
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
{loading ? (
|
| 284 |
+
<div className="h-[350px] sm:h-[450px] flex items-center justify-center">
|
| 285 |
+
<Skeleton className="w-full h-full rounded-lg" />
|
| 286 |
+
</div>
|
| 287 |
+
) : (
|
| 288 |
+
<div className="h-[350px] sm:h-[450px] transition-all duration-300">
|
| 289 |
+
<ReactECharts
|
| 290 |
+
option={getBarOption(users, metric, t)}
|
| 291 |
+
style={{ height: "100%", width: "100%" }}
|
| 292 |
+
onChartReady={onChartReady}
|
| 293 |
+
className="bar-chart"
|
| 294 |
+
/>
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</motion.div>
|
| 300 |
+
);
|
| 301 |
+
}
|
components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
| 5 |
+
import { ChevronDown } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
const Accordion = AccordionPrimitive.Root
|
| 10 |
+
|
| 11 |
+
const AccordionItem = React.forwardRef<
|
| 12 |
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
| 13 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
| 14 |
+
>(({ className, ...props }, ref) => (
|
| 15 |
+
<AccordionPrimitive.Item
|
| 16 |
+
ref={ref}
|
| 17 |
+
className={cn("border-b", className)}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
))
|
| 21 |
+
AccordionItem.displayName = "AccordionItem"
|
| 22 |
+
|
| 23 |
+
const AccordionTrigger = React.forwardRef<
|
| 24 |
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
| 25 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
| 26 |
+
>(({ className, children, ...props }, ref) => (
|
| 27 |
+
<AccordionPrimitive.Header className="flex">
|
| 28 |
+
<AccordionPrimitive.Trigger
|
| 29 |
+
ref={ref}
|
| 30 |
+
className={cn(
|
| 31 |
+
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
| 32 |
+
className
|
| 33 |
+
)}
|
| 34 |
+
{...props}
|
| 35 |
+
>
|
| 36 |
+
{children}
|
| 37 |
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
| 38 |
+
</AccordionPrimitive.Trigger>
|
| 39 |
+
</AccordionPrimitive.Header>
|
| 40 |
+
))
|
| 41 |
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
| 42 |
+
|
| 43 |
+
const AccordionContent = React.forwardRef<
|
| 44 |
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
| 45 |
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
| 46 |
+
>(({ className, children, ...props }, ref) => (
|
| 47 |
+
<AccordionPrimitive.Content
|
| 48 |
+
ref={ref}
|
| 49 |
+
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
| 50 |
+
{...props}
|
| 51 |
+
>
|
| 52 |
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
| 53 |
+
</AccordionPrimitive.Content>
|
| 54 |
+
))
|
| 55 |
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
| 56 |
+
|
| 57 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { buttonVariants } from "@/components/ui/button"
|
| 8 |
+
|
| 9 |
+
const AlertDialog = AlertDialogPrimitive.Root
|
| 10 |
+
|
| 11 |
+
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
| 12 |
+
|
| 13 |
+
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
| 14 |
+
|
| 15 |
+
const AlertDialogOverlay = React.forwardRef<
|
| 16 |
+
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
| 17 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
| 18 |
+
>(({ className, ...props }, ref) => (
|
| 19 |
+
<AlertDialogPrimitive.Overlay
|
| 20 |
+
className={cn(
|
| 21 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 22 |
+
className
|
| 23 |
+
)}
|
| 24 |
+
{...props}
|
| 25 |
+
ref={ref}
|
| 26 |
+
/>
|
| 27 |
+
))
|
| 28 |
+
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
| 29 |
+
|
| 30 |
+
const AlertDialogContent = React.forwardRef<
|
| 31 |
+
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
| 32 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
| 33 |
+
>(({ className, ...props }, ref) => (
|
| 34 |
+
<AlertDialogPortal>
|
| 35 |
+
<AlertDialogOverlay />
|
| 36 |
+
<AlertDialogPrimitive.Content
|
| 37 |
+
ref={ref}
|
| 38 |
+
className={cn(
|
| 39 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
| 40 |
+
className
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
</AlertDialogPortal>
|
| 45 |
+
))
|
| 46 |
+
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
| 47 |
+
|
| 48 |
+
const AlertDialogHeader = ({
|
| 49 |
+
className,
|
| 50 |
+
...props
|
| 51 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 52 |
+
<div
|
| 53 |
+
className={cn(
|
| 54 |
+
"flex flex-col space-y-2 text-center sm:text-left",
|
| 55 |
+
className
|
| 56 |
+
)}
|
| 57 |
+
{...props}
|
| 58 |
+
/>
|
| 59 |
+
)
|
| 60 |
+
AlertDialogHeader.displayName = "AlertDialogHeader"
|
| 61 |
+
|
| 62 |
+
const AlertDialogFooter = ({
|
| 63 |
+
className,
|
| 64 |
+
...props
|
| 65 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 66 |
+
<div
|
| 67 |
+
className={cn(
|
| 68 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
| 69 |
+
className
|
| 70 |
+
)}
|
| 71 |
+
{...props}
|
| 72 |
+
/>
|
| 73 |
+
)
|
| 74 |
+
AlertDialogFooter.displayName = "AlertDialogFooter"
|
| 75 |
+
|
| 76 |
+
const AlertDialogTitle = React.forwardRef<
|
| 77 |
+
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
| 78 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
| 79 |
+
>(({ className, ...props }, ref) => (
|
| 80 |
+
<AlertDialogPrimitive.Title
|
| 81 |
+
ref={ref}
|
| 82 |
+
className={cn("text-lg font-semibold", className)}
|
| 83 |
+
{...props}
|
| 84 |
+
/>
|
| 85 |
+
))
|
| 86 |
+
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
| 87 |
+
|
| 88 |
+
const AlertDialogDescription = React.forwardRef<
|
| 89 |
+
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
| 90 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
| 91 |
+
>(({ className, ...props }, ref) => (
|
| 92 |
+
<AlertDialogPrimitive.Description
|
| 93 |
+
ref={ref}
|
| 94 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 95 |
+
{...props}
|
| 96 |
+
/>
|
| 97 |
+
))
|
| 98 |
+
AlertDialogDescription.displayName =
|
| 99 |
+
AlertDialogPrimitive.Description.displayName
|
| 100 |
+
|
| 101 |
+
const AlertDialogAction = React.forwardRef<
|
| 102 |
+
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
| 103 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
| 104 |
+
>(({ className, ...props }, ref) => (
|
| 105 |
+
<AlertDialogPrimitive.Action
|
| 106 |
+
ref={ref}
|
| 107 |
+
className={cn(buttonVariants(), className)}
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
))
|
| 111 |
+
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
| 112 |
+
|
| 113 |
+
const AlertDialogCancel = React.forwardRef<
|
| 114 |
+
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
| 115 |
+
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
| 116 |
+
>(({ className, ...props }, ref) => (
|
| 117 |
+
<AlertDialogPrimitive.Cancel
|
| 118 |
+
ref={ref}
|
| 119 |
+
className={cn(
|
| 120 |
+
buttonVariants({ variant: "outline" }),
|
| 121 |
+
"mt-2 sm:mt-0",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
))
|
| 127 |
+
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
| 128 |
+
|
| 129 |
+
export {
|
| 130 |
+
AlertDialog,
|
| 131 |
+
AlertDialogPortal,
|
| 132 |
+
AlertDialogOverlay,
|
| 133 |
+
AlertDialogTrigger,
|
| 134 |
+
AlertDialogContent,
|
| 135 |
+
AlertDialogHeader,
|
| 136 |
+
AlertDialogFooter,
|
| 137 |
+
AlertDialogTitle,
|
| 138 |
+
AlertDialogDescription,
|
| 139 |
+
AlertDialogAction,
|
| 140 |
+
AlertDialogCancel,
|
| 141 |
+
}
|