3v324v23 commited on
Commit
4c2a557
·
1 Parent(s): 41c77a5
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +12 -0
  2. .env +0 -9
  3. .env.example +21 -0
  4. .eslintrc.json +3 -0
  5. .gitattributes +0 -35
  6. .gitignore +42 -0
  7. Dockerfile +39 -1
  8. LICENSE +21 -0
  9. app/api/config/key/route.ts +15 -0
  10. app/api/config/route.ts +23 -0
  11. app/api/users/[id]/balance/route.ts +42 -0
  12. app/api/users/[id]/route.ts +48 -0
  13. app/api/users/route.ts +67 -0
  14. app/api/v1/inlet/route.ts +66 -0
  15. app/api/v1/models/price/route.ts +114 -0
  16. app/api/v1/models/route.ts +146 -0
  17. app/api/v1/models/sync-all-prices/route.ts +112 -0
  18. app/api/v1/models/sync-price/route.ts +117 -0
  19. app/api/v1/models/test/route.ts +82 -0
  20. app/api/v1/outlet/route.ts +216 -0
  21. app/api/v1/panel/database/export/route.ts +56 -0
  22. app/api/v1/panel/database/import/route.ts +97 -0
  23. app/api/v1/panel/records/export/route.ts +70 -0
  24. app/api/v1/panel/records/route.ts +89 -0
  25. app/api/v1/panel/usage/route.ts +90 -0
  26. app/apple-icon.png +0 -0
  27. app/fonts/GeistMonoVF.woff +0 -0
  28. app/fonts/GeistVF.woff +0 -0
  29. app/globals.css +278 -0
  30. app/icon.png +0 -0
  31. app/layout.tsx +44 -0
  32. app/models/page.tsx +1051 -0
  33. app/page.tsx +287 -0
  34. app/panel/page.tsx +528 -0
  35. app/records/page.tsx +332 -0
  36. app/token/page.tsx +247 -0
  37. app/users/page.tsx +980 -0
  38. components.json +21 -0
  39. components/AuthCheck.tsx +58 -0
  40. components/DatabaseBackup.tsx +228 -0
  41. components/Header.tsx +543 -0
  42. components/I18nProvider.tsx +31 -0
  43. components/editable-cell.tsx +211 -0
  44. components/models/TestProgress.tsx +226 -0
  45. components/panel/ModelDistributionChart.tsx +314 -0
  46. components/panel/TimeRangeSelector.tsx +313 -0
  47. components/panel/UsageRecordsTable.tsx +268 -0
  48. components/panel/UserRankingChart.tsx +301 -0
  49. components/ui/accordion.tsx +57 -0
  50. 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
- FROM ghcr.io/variantconst/openwebui-monitor:latest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }