luowuyin commited on
Commit
a572854
·
1 Parent(s): 4c2a557

25:05:05 10:41:39 v0.3.7

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .github/workflows/docker-publish.yml +80 -0
  3. VERSION +1 -0
  4. app/api/config/route.ts +0 -23
  5. app/api/init/route.ts +22 -0
  6. app/api/{config → v1/config}/key/route.ts +7 -2
  7. app/api/v1/config/route.ts +14 -0
  8. app/api/v1/inlet/route.ts +0 -3
  9. app/api/v1/models/price/route.ts +7 -4
  10. app/api/v1/models/route.ts +65 -35
  11. app/api/v1/models/sync-all-prices/route.ts +7 -5
  12. app/api/v1/models/sync-price/route.ts +7 -6
  13. app/api/v1/models/test/route.ts +6 -0
  14. app/api/v1/outlet/route.ts +2 -17
  15. app/api/v1/panel/database/export/route.ts +10 -21
  16. app/api/v1/panel/database/import/route.ts +16 -26
  17. app/api/v1/panel/records/export/route.ts +9 -13
  18. app/api/v1/panel/records/route.ts +20 -15
  19. app/api/v1/panel/usage/route.ts +16 -5
  20. app/api/{users → v1/users}/[id]/balance/route.ts +11 -1
  21. app/api/{users → v1/users}/[id]/route.ts +11 -0
  22. app/api/{users → v1/users}/route.ts +6 -4
  23. app/apple-icon.png +0 -0
  24. app/globals.css +0 -3
  25. app/icon.png +0 -0
  26. app/models/page.tsx +22 -17
  27. app/page.tsx +0 -15
  28. app/panel/page.tsx +29 -16
  29. app/records/page.tsx +0 -2
  30. app/token/page.tsx +1 -4
  31. app/users/page.tsx +34 -12
  32. components/AuthCheck.tsx +13 -3
  33. components/Header.tsx +2 -20
  34. components/editable-cell.tsx +0 -1
  35. components/panel/TimeRangeSelector.tsx +1 -1
  36. components/panel/UsageRecordsTable.tsx +1 -3
  37. components/panel/UserRankingChart.tsx +1 -3
  38. components/ui/animated-grid-pattern.tsx +0 -4
  39. components/ui/chart.tsx +71 -78
  40. components/ui/sidebar.tsx +0 -10
  41. hooks/use-toast.ts +62 -68
  42. lib/auth.ts +23 -0
  43. lib/dayjs.ts +19 -0
  44. lib/db.ts +0 -352
  45. lib/db/client.ts +348 -8
  46. lib/db/index.ts +5 -38
  47. lib/utils/inlet-cost.ts +0 -2
  48. lib/version.ts +0 -1
  49. middleware.ts +19 -13
  50. package.json +1 -1
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build and Publish Docker Image
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ env:
10
+ REGISTRY: ghcr.io
11
+ IMAGE_NAME: ${{ github.repository }}
12
+ DOCKERHUB_REGISTRY: docker.io
13
+ DOCKERHUB_IMAGE_NAME: variantconst/openwebui-monitor
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ permissions:
19
+ contents: read
20
+ packages: write
21
+
22
+ steps:
23
+ # 检出代码
24
+ - name: Checkout repository
25
+ uses: actions/checkout@v4
26
+
27
+ # 设置 QEMU(支持多平台)
28
+ - name: Set up QEMU
29
+ uses: docker/setup-qemu-action@v3
30
+
31
+ # 设置 Docker Buildx(支持多平台构建)
32
+ - name: Set up Docker Buildx
33
+ uses: docker/setup-buildx-action@v3
34
+
35
+ # 登录到 GitHub Container Registry (GHCR)
36
+ - name: Log into GHCR
37
+ if: github.event_name != 'pull_request'
38
+ uses: docker/login-action@v3
39
+ with:
40
+ registry: ${{ env.REGISTRY }}
41
+ username: ${{ github.actor }}
42
+ password: ${{ secrets.GITHUB_TOKEN }}
43
+
44
+ # 登录到 Docker Hub
45
+ - name: Log into Docker Hub
46
+ if: github.event_name != 'pull_request'
47
+ uses: docker/login-action@v3
48
+ with:
49
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
50
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
51
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
52
+
53
+ # 提取 Docker 元数据
54
+ - name: Extract Docker metadata
55
+ id: meta
56
+ uses: docker/metadata-action@v5
57
+ with:
58
+ images: |
59
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
60
+ ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
61
+ tags: |
62
+ type=ref,event=branch
63
+ type=ref,event=pr
64
+ type=semver,pattern={{version}}
65
+ type=semver,pattern={{major}}.{{minor}}
66
+ type=sha
67
+ type=raw,value=latest,enable={{is_default_branch}}
68
+
69
+ # 构建并推送到 GHCR 和 Docker Hub
70
+ - name: Build and push Docker image
71
+ uses: docker/build-push-action@v5
72
+ with:
73
+ context: .
74
+ platforms: linux/amd64,linux/arm64
75
+ push: ${{ github.event_name != 'pull_request' }}
76
+ tags: ${{ steps.meta.outputs.tags }}
77
+ labels: ${{ steps.meta.outputs.labels }}
78
+ cache-from: type=gha
79
+ cache-to: type=gha,mode=max
80
+ provenance: false
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ v0.3.7
app/api/config/route.ts DELETED
@@ -1,23 +0,0 @@
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/init/route.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { initDatabase } from "@/lib/db/client";
3
+
4
+ let initialized = false;
5
+
6
+ export async function GET() {
7
+ if (!initialized) {
8
+ try {
9
+ await initDatabase();
10
+ initialized = true;
11
+ return NextResponse.json({ success: true, message: "数据库初始化成功" });
12
+ } catch (error) {
13
+ console.error("数据库初始化失败:", error);
14
+ return NextResponse.json(
15
+ { success: false, error: "数据库初始化失败" },
16
+ { status: 500 }
17
+ );
18
+ }
19
+ } else {
20
+ return NextResponse.json({ success: true, message: "数据库已初始化" });
21
+ }
22
+ }
app/api/{config → v1/config}/key/route.ts RENAMED
@@ -1,7 +1,12 @@
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) {
 
1
  import { NextResponse } from "next/server";
2
+ import { verifyApiToken } from "@/lib/auth";
3
+
4
+ export async function GET(req: Request) {
5
+ const authError = verifyApiToken(req);
6
+ if (authError) {
7
+ return authError;
8
+ }
9
 
 
10
  const apiKey = process.env.API_KEY;
11
 
12
  if (!apiKey) {
app/api/v1/config/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { verifyApiToken } from "@/lib/auth";
3
+
4
+ export async function GET(req: Request) {
5
+ const authError = verifyApiToken(req);
6
+ if (authError) {
7
+ return authError;
8
+ }
9
+
10
+ return NextResponse.json({
11
+ apiKey: process.env.API_KEY || "Unconfigured",
12
+ status: 200,
13
+ });
14
+ }
app/api/v1/inlet/route.ts CHANGED
@@ -9,7 +9,6 @@ export async function POST(req: Request) {
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,
@@ -18,10 +17,8 @@ export async function POST(req: Request) {
18
  });
19
  }
20
 
21
- // 获取预扣费金额
22
  const inletCost = getModelInletCost(modelId);
23
 
24
- // 预扣费
25
  if (inletCost > 0) {
26
  const userResult = await query(
27
  `UPDATE users
 
9
  const user = await getOrCreateUser(data.user);
10
  const modelId = data.body?.model;
11
 
 
12
  if (user.deleted) {
13
  return NextResponse.json({
14
  success: true,
 
17
  });
18
  }
19
 
 
20
  const inletCost = getModelInletCost(modelId);
21
 
 
22
  if (inletCost > 0) {
23
  const userResult = await query(
24
  `UPDATE users
app/api/v1/models/price/route.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { updateModelPrice } from "@/lib/db";
 
3
 
4
  interface PriceUpdate {
5
  id: string;
@@ -9,11 +10,15 @@ interface PriceUpdate {
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);
@@ -23,7 +28,6 @@ export async function POST(request: NextRequest) {
23
  );
24
  }
25
 
26
- // 验证并转换数据格式
27
  const validUpdates = updates
28
  .map((update: any) => ({
29
  id: update.id,
@@ -52,7 +56,6 @@ export async function POST(request: NextRequest) {
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 {
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { updateModelPrice } from "@/lib/db/client";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  interface PriceUpdate {
6
  id: string;
 
10
  }
11
 
12
  export async function POST(request: NextRequest) {
13
+ const authError = verifyApiToken(request);
14
+ if (authError) {
15
+ return authError;
16
+ }
17
+
18
  try {
19
  const data = await request.json();
20
  console.log("Raw data received:", data);
21
 
 
22
  const updates = data.updates || data;
23
  if (!Array.isArray(updates)) {
24
  console.error("Invalid data format - expected array:", updates);
 
28
  );
29
  }
30
 
 
31
  const validUpdates = updates
32
  .map((update: any) => ({
33
  id: update.id,
 
56
  `Successfully verified price updating requests of ${validUpdates.length} models`
57
  );
58
 
 
59
  const results = await Promise.all(
60
  validUpdates.map(async (update: PriceUpdate) => {
61
  try {
app/api/v1/models/route.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { NextResponse } from "next/server";
2
- import { ensureTablesExist, getOrCreateModelPrices } from "@/lib/db";
 
3
 
4
  interface ModelInfo {
5
  id: string;
@@ -21,9 +22,13 @@ interface ModelResponse {
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;
@@ -31,7 +36,6 @@ export async function GET() {
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, {
@@ -47,9 +51,7 @@ export async function GET() {
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 {
@@ -59,20 +61,25 @@ export async function GET() {
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) {
@@ -88,30 +95,46 @@ export async function GET() {
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) {
@@ -126,8 +149,12 @@ export async function GET() {
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", {
@@ -135,10 +162,13 @@ export async function POST(req: Request) {
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" },
 
1
  import { NextResponse } from "next/server";
2
+ import { ensureTablesExist, getOrCreateModelPrices } from "@/lib/db/client";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  interface ModelInfo {
6
  id: string;
 
22
  }[];
23
  }
24
 
25
+ export async function GET(req: Request) {
26
+ const authError = verifyApiToken(req);
27
+ if (authError) {
28
+ return authError;
29
+ }
30
+
31
  try {
 
32
  await ensureTablesExist();
33
 
34
  const domain = process.env.OPENWEBUI_DOMAIN;
 
36
  throw new Error("OPENWEBUI_DOMAIN environment variable is not set.");
37
  }
38
 
 
39
  const apiUrl = domain.replace(/\/+$/, "") + "/api/models";
40
 
41
  const response = await fetch(apiUrl, {
 
51
  throw new Error(`Failed to fetch models: ${response.status}`);
52
  }
53
 
 
54
  const responseText = await response.text();
 
55
 
56
  let data: ModelResponse;
57
  try {
 
61
  throw new Error("Invalid JSON response from API");
62
  }
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
+ const apiModelsMap = new Map();
70
+ data.data.forEach((item) => {
71
+ apiModelsMap.set(String(item.id), {
72
+ name: String(item.name),
73
+ base_model_id: item.info?.base_model_id || "",
74
+ imageUrl: item.info?.meta?.profile_image_url || "/static/favicon.png",
75
+ system_prompt: item.info?.params?.system || "",
76
+ });
77
+ });
78
+
79
  const modelsWithPrices = await getOrCreateModelPrices(
80
  data.data.map((item) => {
 
81
  let baseModelId = item.info?.base_model_id;
82
 
 
83
  if (!baseModelId && item.id) {
84
  const idParts = String(item.id).split(".");
85
  if (idParts.length > 1) {
 
95
  })
96
  );
97
 
98
+ const dbModelsMap = new Map();
99
+ modelsWithPrices.forEach((model) => {
100
+ dbModelsMap.set(model.id, {
101
+ input_price: model.input_price,
102
+ output_price: model.output_price,
103
+ per_msg_price: model.per_msg_price,
104
+ updated_at: model.updated_at,
105
+ });
106
+ });
107
+
108
+ const validModels = Array.from(apiModelsMap.entries()).map(
109
+ ([id, apiModel]) => {
110
+ const dbModel = dbModelsMap.get(id) || {
111
+ input_price: 60,
112
+ output_price: 60,
113
+ per_msg_price: -1,
114
+ updated_at: new Date(),
115
+ };
116
 
117
+ let baseModelId = apiModel.base_model_id;
118
+ if (!baseModelId && id) {
119
+ const idParts = String(id).split(".");
120
+ if (idParts.length > 1) {
121
+ baseModelId = idParts[idParts.length - 1];
122
+ }
123
  }
 
124
 
125
+ return {
126
+ id: id,
127
+ base_model_id: baseModelId,
128
+ name: apiModel.name,
129
+ imageUrl: apiModel.imageUrl,
130
+ system_prompt: apiModel.system_prompt,
131
+ input_price: dbModel.input_price,
132
+ output_price: dbModel.output_price,
133
+ per_msg_price: dbModel.per_msg_price,
134
+ updated_at: dbModel.updated_at,
135
+ };
136
+ }
137
+ );
138
 
139
  return NextResponse.json(validModels);
140
  } catch (error) {
 
149
  }
150
  }
151
 
 
152
  export async function POST(req: Request) {
153
+ const authError = verifyApiToken(req);
154
+ if (authError) {
155
+ return authError;
156
+ }
157
+
158
  const data = await req.json();
159
 
160
  return new Response("Inlet placeholder response", {
 
162
  });
163
  }
164
 
 
165
  export async function PUT(req: Request) {
166
+ const authError = verifyApiToken(req);
167
+ if (authError) {
168
+ return authError;
169
+ }
170
+
171
  const data = await req.json();
 
172
 
173
  return new Response("Outlet placeholder response", {
174
  headers: { "Content-Type": "application/json" },
app/api/v1/models/sync-all-prices/route.ts CHANGED
@@ -1,11 +1,16 @@
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
@@ -24,10 +29,8 @@ export async function POST(request: NextRequest) {
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]
@@ -45,7 +48,6 @@ export async function POST(request: NextRequest) {
45
 
46
  const baseModel = baseModelResult.rows[0];
47
 
48
- // 更新派生模型价格
49
  const updateResult = await client.query(
50
  `UPDATE model_prices
51
  SET
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { pool } from "@/lib/db/client";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function POST(request: NextRequest) {
6
+ const authError = verifyApiToken(request);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
+
11
  try {
12
  const client = await pool.connect();
13
  try {
 
14
  const derivedModelsResult = await client.query(`
15
  SELECT d.id, d.name, d.base_model_id
16
  FROM model_prices d
 
29
  const derivedModels = derivedModelsResult.rows;
30
  const syncResults = [];
31
 
 
32
  for (const derivedModel of derivedModels) {
33
  try {
 
34
  const baseModelResult = await client.query(
35
  `SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`,
36
  [derivedModel.base_model_id]
 
48
 
49
  const baseModel = baseModelResult.rows[0];
50
 
 
51
  const updateResult = await client.query(
52
  `UPDATE model_prices
53
  SET
app/api/v1/models/sync-price/route.ts CHANGED
@@ -1,7 +1,13 @@
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;
@@ -15,7 +21,6 @@ export async function POST(request: NextRequest) {
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]
@@ -28,13 +33,11 @@ export async function POST(request: NextRequest) {
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]
@@ -49,7 +52,6 @@ export async function POST(request: NextRequest) {
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]
@@ -64,7 +66,6 @@ export async function POST(request: NextRequest) {
64
 
65
  const baseModel = baseModelResult.rows[0];
66
 
67
- // 3. 更新派生模型价格
68
  const updateResult = await client.query(
69
  `UPDATE model_prices
70
  SET
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { pool } from "@/lib/db/client";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function POST(request: NextRequest) {
6
+ const authError = verifyApiToken(request);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
+
11
  try {
12
  const data = await request.json();
13
  const { modelId } = data;
 
21
 
22
  const client = await pool.connect();
23
  try {
 
24
  const derivedModelResult = await client.query(
25
  `SELECT id, name, base_model_id FROM model_prices WHERE id = $1`,
26
  [modelId]
 
33
  const derivedModel = derivedModelResult.rows[0];
34
  let baseModelId = derivedModel.base_model_id;
35
 
 
36
  if (!baseModelId) {
37
  const idParts = modelId.split(".");
38
  if (idParts.length > 1) {
39
  baseModelId = idParts[idParts.length - 1];
40
 
 
41
  await client.query(
42
  `UPDATE model_prices SET base_model_id = $2 WHERE id = $1`,
43
  [modelId, baseModelId]
 
52
  );
53
  }
54
 
 
55
  const baseModelResult = await client.query(
56
  `SELECT input_price, output_price, per_msg_price FROM model_prices WHERE id = $1`,
57
  [baseModelId]
 
66
 
67
  const baseModel = baseModelResult.rows[0];
68
 
 
69
  const updateResult = await client.query(
70
  `UPDATE model_prices
71
  SET
app/api/v1/models/test/route.ts CHANGED
@@ -1,6 +1,12 @@
1
  import { NextResponse } from "next/server";
 
2
 
3
  export async function POST(req: Request) {
 
 
 
 
 
4
  try {
5
  const { modelId } = await req.json();
6
 
 
1
  import { NextResponse } from "next/server";
2
+ import { verifyApiToken } from "@/lib/auth";
3
 
4
  export async function POST(req: Request) {
5
+ const authError = verifyApiToken(req);
6
+ if (authError) {
7
+ return authError;
8
+ }
9
+
10
  try {
11
  const { modelId } = await req.json();
12
 
app/api/v1/outlet/route.ts CHANGED
@@ -34,7 +34,6 @@ async function getModelPrice(modelId: string): Promise<ModelPrice | null> {
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
  );
@@ -42,7 +41,6 @@ async function getModelPrice(modelId: string): Promise<ModelPrice | null> {
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 ||
@@ -57,7 +55,7 @@ async function getModelPrice(modelId: string): Promise<ModelPrice | null> {
57
  name: modelId,
58
  input_price: defaultInputPrice,
59
  output_price: defaultOutputPrice,
60
- per_msg_price: -1, // Default to token-based pricing
61
  };
62
  }
63
 
@@ -66,7 +64,6 @@ export async function POST(req: Request) {
66
  let pgClient: DbClient | null = null;
67
 
68
  try {
69
- // Get a dedicated transaction client
70
  if (isVercel) {
71
  pgClient = client;
72
  } else {
@@ -74,21 +71,18 @@ export async function POST(req: Request) {
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;
@@ -109,32 +103,25 @@ export async function POST(req: Request) {
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(
@@ -156,7 +143,6 @@ export async function POST(req: Request) {
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,
@@ -208,7 +194,6 @@ export async function POST(req: Request) {
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
  }
 
34
  return result.rows[0];
35
  }
36
 
 
37
  const defaultInputPrice = parseFloat(
38
  process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
39
  );
 
41
  process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
42
  );
43
 
 
44
  if (
45
  isNaN(defaultInputPrice) ||
46
  defaultInputPrice < 0 ||
 
55
  name: modelId,
56
  input_price: defaultInputPrice,
57
  output_price: defaultOutputPrice,
58
+ per_msg_price: -1,
59
  };
60
  }
61
 
 
64
  let pgClient: DbClient | null = null;
65
 
66
  try {
 
67
  if (isVercel) {
68
  pgClient = client;
69
  } else {
 
71
  }
72
 
73
  const data = await req.json();
74
+ console.log("Request data:", JSON.stringify(data, null, 2));
75
  const modelId = data.body.model;
76
  const userId = data.user.id;
77
  const userName = data.user.name || "Unknown User";
78
 
 
79
  await query("BEGIN");
80
 
 
81
  const modelPrice = await getModelPrice(modelId);
82
  if (!modelPrice) {
83
  throw new Error(`Fail to fetch price info of model ${modelId}`);
84
  }
85
 
 
86
  const lastMessage = data.body.messages[data.body.messages.length - 1];
87
 
88
  let inputTokens: number;
 
103
  inputTokens = totalTokens - outputTokens;
104
  }
105
 
 
106
  let totalCost: number;
107
  if (outputTokens === 0) {
 
108
  totalCost = 0;
109
  console.log("No charge for zero output tokens");
110
  } else if (modelPrice.per_msg_price >= 0) {
 
111
  totalCost = Number(modelPrice.per_msg_price);
112
  console.log(
113
  `Using fixed pricing: ${totalCost} (${modelPrice.per_msg_price} per message)`
114
  );
115
  } else {
 
116
  const inputCost = (inputTokens / 1_000_000) * modelPrice.input_price;
117
  const outputCost = (outputTokens / 1_000_000) * modelPrice.output_price;
118
  totalCost = inputCost + outputCost;
119
  }
120
 
 
121
  const inletCost = getModelInletCost(modelId);
122
 
 
123
  const actualCost = totalCost - inletCost;
124
 
 
125
  const userResult = await query(
126
  `UPDATE users
127
  SET balance = LEAST(
 
143
  throw new Error("Balance exceeds maximum allowed value");
144
  }
145
 
 
146
  await query(
147
  `INSERT INTO user_usage_records (
148
  user_id, nickname, model_name,
 
194
  { status: 500 }
195
  );
196
  } finally {
 
197
  if (!isVercel && pgClient && "release" in pgClient) {
198
  pgClient.release();
199
  }
app/api/v1/panel/database/export/route.ts CHANGED
@@ -1,24 +1,18 @@
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(),
@@ -29,7 +23,6 @@ export async function GET() {
29
  },
30
  };
31
 
32
- // 设置响应头
33
  const headers = new Headers();
34
  headers.set("Content-Type", "application/json");
35
  headers.set(
@@ -48,9 +41,5 @@ export async function GET() {
48
  { error: "Fail to export database" },
49
  { status: 500 }
50
  );
51
- } finally {
52
- if (client) {
53
- client.release();
54
- }
55
  }
56
  }
 
1
+ import { query } from "@/lib/db/client";
2
  import { NextResponse } from "next/server";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
+ export async function GET(req: Request) {
6
+ const authError = verifyApiToken(req);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
 
11
  try {
12
+ const users = await query("SELECT * FROM users ORDER BY id");
13
+ const modelPrices = await query("SELECT * FROM model_prices ORDER BY id");
14
+ const records = await query("SELECT * FROM user_usage_records ORDER BY id");
 
 
 
 
 
 
 
 
15
 
 
16
  const exportData = {
17
  version: "1.0",
18
  timestamp: new Date().toISOString(),
 
23
  },
24
  };
25
 
 
26
  const headers = new Headers();
27
  headers.set("Content-Type", "application/json");
28
  headers.set(
 
41
  { error: "Fail to export database" },
42
  { status: 500 }
43
  );
 
 
 
 
44
  }
45
  }
app/api/v1/panel/database/import/route.ts CHANGED
@@ -1,34 +1,30 @@
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]
@@ -36,10 +32,9 @@ export async function POST(req: Request) {
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]
@@ -47,10 +42,9 @@ export async function POST(req: Request) {
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
@@ -69,14 +63,14 @@ export async function POST(req: Request) {
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) {
@@ -89,9 +83,5 @@ export async function POST(req: Request) {
89
  },
90
  { status: 500 }
91
  );
92
- } finally {
93
- if (client) {
94
- client.release();
95
- }
96
  }
97
  }
 
1
+ import { query } from "@/lib/db/client";
2
  import { NextResponse } from "next/server";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function POST(req: Request) {
6
+ const authError = verifyApiToken(req);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
 
11
  try {
12
  const data = await req.json();
13
 
 
14
  if (!data.version || !data.data) {
15
  throw new Error("Invalid import data format");
16
  }
17
 
 
 
 
 
 
 
18
  try {
19
+ await query("BEGIN");
20
+
21
+ await query("TRUNCATE TABLE user_usage_records CASCADE");
22
+ await query("TRUNCATE TABLE model_prices CASCADE");
23
+ await query("TRUNCATE TABLE users CASCADE");
24
 
 
25
  if (data.data.users?.length) {
26
  for (const user of data.data.users) {
27
+ await query(
28
  `INSERT INTO users (id, email, name, role, balance)
29
  VALUES ($1, $2, $3, $4, $5)`,
30
  [user.id, user.email, user.name, user.role, user.balance]
 
32
  }
33
  }
34
 
 
35
  if (data.data.model_prices?.length) {
36
  for (const price of data.data.model_prices) {
37
+ await query(
38
  `INSERT INTO model_prices (id, name, input_price, output_price)
39
  VALUES ($1, $2, $3, $4)`,
40
  [price.id, price.name, price.input_price, price.output_price]
 
42
  }
43
  }
44
 
 
45
  if (data.data.user_usage_records?.length) {
46
  for (const record of data.data.user_usage_records) {
47
+ await query(
48
  `INSERT INTO user_usage_records (
49
  user_id, nickname, use_time, model_name,
50
  input_tokens, output_tokens, cost, balance_after
 
63
  }
64
  }
65
 
66
+ await query("COMMIT");
67
 
68
  return NextResponse.json({
69
  success: true,
70
  message: "Data import successful",
71
  });
72
  } catch (error) {
73
+ await query("ROLLBACK");
74
  throw error;
75
  }
76
  } catch (error) {
 
83
  },
84
  { status: 500 }
85
  );
 
 
 
 
86
  }
87
  }
app/api/v1/panel/records/export/route.ts CHANGED
@@ -1,13 +1,15 @@
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,
@@ -20,7 +22,6 @@ export async function GET() {
20
  ORDER BY use_time DESC
21
  `);
22
 
23
- // 生成 CSV 内容
24
  const csvHeaders = [
25
  "User",
26
  "Time",
@@ -45,7 +46,6 @@ export async function GET() {
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(
@@ -62,9 +62,5 @@ export async function GET() {
62
  { error: "Fail to export records" },
63
  { status: 500 }
64
  );
65
- } finally {
66
- if (client) {
67
- client.release();
68
- }
69
  }
70
  }
 
1
+ import { query } from "@/lib/db/client";
2
  import { NextResponse } from "next/server";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
+ export async function GET(req: Request) {
6
+ const authError = verifyApiToken(req);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
 
11
+ try {
12
+ const records = await query(`
13
  SELECT
14
  nickname,
15
  use_time,
 
22
  ORDER BY use_time DESC
23
  `);
24
 
 
25
  const csvHeaders = [
26
  "User",
27
  "Time",
 
46
  ...rows.map((row) => row.join(",")),
47
  ].join("\n");
48
 
 
49
  const responseHeaders = new Headers();
50
  responseHeaders.set("Content-Type", "text/csv; charset=utf-8");
51
  responseHeaders.set(
 
62
  { error: "Fail to export records" },
63
  { status: 500 }
64
  );
 
 
 
 
65
  }
66
  }
app/api/v1/panel/records/route.ts CHANGED
@@ -1,9 +1,13 @@
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");
@@ -12,10 +16,9 @@ export async function GET(req: Request) {
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;
@@ -32,23 +35,29 @@ export async function GET(req: Request) {
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
@@ -67,7 +76,7 @@ export async function GET(req: Request) {
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
 
@@ -81,9 +90,5 @@ export async function GET(req: Request) {
81
  { error: "Fail to fetch usage records" },
82
  { status: 500 }
83
  );
84
- } finally {
85
- if (client) {
86
- client.release();
87
- }
88
  }
89
  }
 
1
+ import { query } from "@/lib/db/client";
2
  import { NextResponse } from "next/server";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function GET(req: Request) {
6
+ const authError = verifyApiToken(req);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
+
11
  try {
12
  const { searchParams } = new URL(req.url);
13
  const page = parseInt(searchParams.get("page") || "1");
 
16
  const sortOrder = searchParams.get("sortOrder");
17
  const users = searchParams.get("users")?.split(",") || [];
18
  const models = searchParams.get("models")?.split(",") || [];
19
+ const startDate = searchParams.get("startDate");
20
+ const endDate = searchParams.get("endDate");
21
 
 
 
 
22
  const conditions = [];
23
  const params = [];
24
  let paramIndex = 1;
 
35
  paramIndex++;
36
  }
37
 
38
+ if (startDate && endDate) {
39
+ conditions.push(
40
+ `use_time >= $${paramIndex} AND use_time <= $${paramIndex + 1}`
41
+ );
42
+ params.push(startDate);
43
+ params.push(endDate);
44
+ paramIndex += 2;
45
+ }
46
+
47
  const whereClause =
48
  conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
49
 
 
50
  const orderClause = sortField
51
  ? `ORDER BY ${sortField} ${sortOrder === "descend" ? "DESC" : "ASC"}`
52
  : "ORDER BY use_time DESC";
53
 
 
54
  const countQuery = `
55
  SELECT COUNT(*)
56
  FROM user_usage_records
57
  ${whereClause}
58
  `;
59
+ const countResult = await query(countQuery, params);
60
 
 
61
  const offset = (page - 1) * pageSize;
62
  const dataQuery = `
63
  SELECT
 
76
  `;
77
 
78
  const dataParams = [...params, pageSize, offset];
79
+ const records = await query(dataQuery, dataParams);
80
 
81
  const total = parseInt(countResult.rows[0].count);
82
 
 
90
  { error: "Fail to fetch usage records" },
91
  { status: 500 }
92
  );
 
 
 
 
93
  }
94
  }
app/api/v1/panel/usage/route.ts CHANGED
@@ -1,12 +1,20 @@
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
 
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
14
 
15
  const [modelResult, userResult, timeRangeResult, statsResult] =
16
  await Promise.all([
17
- pool.query(
18
  `
19
  SELECT
20
  model_name,
@@ -27,7 +35,7 @@ export async function GET(request: Request) {
27
  `,
28
  params
29
  ),
30
- pool.query(
31
  `
32
  SELECT
33
  nickname,
@@ -40,13 +48,13 @@ export async function GET(request: Request) {
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,
@@ -82,6 +90,9 @@ export async function GET(request: Request) {
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 }
 
1
  import { NextResponse } from "next/server";
2
+ import { query } from "@/lib/db/client";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function GET(request: Request) {
6
+ const authError = verifyApiToken(request);
7
+ if (authError) {
8
+ return authError;
9
+ }
10
+
11
  try {
12
  const { searchParams } = new URL(request.url);
13
  const startTime = searchParams.get("startTime");
14
  const endTime = searchParams.get("endTime");
15
 
16
+ console.log("Query params:", [startTime, endTime]);
17
+
18
  const timeFilter =
19
  startTime && endTime ? `WHERE use_time >= $1 AND use_time <= $2` : "";
20
 
 
22
 
23
  const [modelResult, userResult, timeRangeResult, statsResult] =
24
  await Promise.all([
25
+ query(
26
  `
27
  SELECT
28
  model_name,
 
35
  `,
36
  params
37
  ),
38
+ query(
39
  `
40
  SELECT
41
  nickname,
 
48
  `,
49
  params
50
  ),
51
+ query(`
52
  SELECT
53
  MIN(use_time) as min_time,
54
  MAX(use_time) as max_time
55
  FROM user_usage_records
56
  `),
57
+ query(
58
  `
59
  SELECT
60
  COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
 
90
  return NextResponse.json(formattedData);
91
  } catch (error) {
92
  console.error("Fail to fetch usage records:", error);
93
+ if (error instanceof Error) {
94
+ console.error("[DB Query Error]", error);
95
+ }
96
  return NextResponse.json(
97
  { error: "Fail to fetch usage records" },
98
  { status: 500 }
app/api/{users → v1/users}/[id]/balance/route.ts RENAMED
@@ -1,17 +1,25 @@
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
  }
@@ -24,6 +32,8 @@ export async function PUT(
24
  [balance, userId]
25
  );
26
 
 
 
27
  if (result.rows.length === 0) {
28
  return NextResponse.json(
29
  { error: "User does not exist" },
 
1
  import { query } from "@/lib/db/client";
2
  import { NextResponse } from "next/server";
3
+ import { verifyApiToken } from "@/lib/auth";
4
 
5
  export async function PUT(
6
  req: Request,
7
  { params }: { params: { id: string } }
8
  ) {
9
+ const authError = verifyApiToken(req);
10
+ if (authError) {
11
+ return authError;
12
+ }
13
+
14
  try {
15
  const { balance } = await req.json();
16
  const userId = params.id;
17
 
18
+ console.log(`Updating balance for user ${userId} to ${balance}`);
19
+
20
  if (typeof balance !== "number") {
21
  return NextResponse.json(
22
+ { error: "Balance must be a number" },
23
  { status: 400 }
24
  );
25
  }
 
32
  [balance, userId]
33
  );
34
 
35
+ console.log(`Update result:`, result);
36
+
37
  if (result.rows.length === 0) {
38
  return NextResponse.json(
39
  { error: "User does not exist" },
app/api/{users → v1/users}/[id]/route.ts RENAMED
@@ -1,11 +1,17 @@
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 });
@@ -19,6 +25,11 @@ export async function PATCH(
19
  req: NextRequest,
20
  { params }: { params: { id: string } }
21
  ) {
 
 
 
 
 
22
  try {
23
  const { deleted } = await req.json();
24
 
 
1
  import { NextRequest, NextResponse } from "next/server";
2
  import { deleteUser } from "@/lib/db/users";
3
  import { query } from "@/lib/db/client";
4
+ import { verifyApiToken } from "@/lib/auth";
5
 
6
  export async function DELETE(
7
  req: NextRequest,
8
  { params }: { params: { id: string } }
9
  ) {
10
+ const authError = verifyApiToken(req);
11
+ if (authError) {
12
+ return authError;
13
+ }
14
+
15
  try {
16
  await deleteUser(params.id);
17
  return NextResponse.json({ success: true });
 
25
  req: NextRequest,
26
  { params }: { params: { id: string } }
27
  ) {
28
+ const authError = verifyApiToken(req);
29
+ if (authError) {
30
+ return authError;
31
+ }
32
+
33
  try {
34
  const { deleted } = await req.json();
35
 
app/api/{users → v1/users}/route.ts RENAMED
@@ -1,10 +1,15 @@
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);
@@ -15,7 +20,6 @@ export async function GET(req: NextRequest) {
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;
@@ -30,14 +34,12 @@ export async function GET(req: NextRequest) {
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
 
1
  import { NextRequest, NextResponse } from "next/server";
2
  import { query } from "@/lib/db/client";
3
  import { ensureUserTableExists } from "@/lib/db/users";
4
+ import { verifyApiToken } from "@/lib/auth";
5
 
6
  export async function GET(req: NextRequest) {
7
+ const authError = verifyApiToken(req);
8
+ if (authError) {
9
+ return authError;
10
+ }
11
+
12
  try {
 
13
  await ensureUserTableExists();
14
 
15
  const { searchParams } = new URL(req.url);
 
20
  const search = searchParams.get("search");
21
  const deleted = searchParams.get("deleted") === "true";
22
 
 
23
  const conditions = [`deleted = ${deleted}`];
24
  const params = [];
25
  let paramIndex = 1;
 
34
 
35
  const whereClause = `WHERE ${conditions.join(" AND ")}`;
36
 
 
37
  const countResult = await query(
38
  `SELECT COUNT(*) FROM users ${whereClause}`,
39
  params
40
  );
41
  const total = parseInt(countResult.rows[0].count);
42
 
 
43
  const result = await query(
44
  `SELECT id, email, name, role, balance, deleted, created_at
45
  FROM users
app/apple-icon.png CHANGED

Git LFS Details

  • SHA256: 8ff4f856d636599b80820376b4eee2720758f2c3cbad14fbb096f3e1cea9c279
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
app/globals.css CHANGED
@@ -80,7 +80,6 @@ body {
80
  display: none;
81
  }
82
 
83
- /* 更新模态框样式 */
84
  .update-modal .ant-modal-content {
85
  padding: 24px;
86
  border-radius: 16px;
@@ -222,7 +221,6 @@ body {
222
  background-size: 24px 24px;
223
  }
224
 
225
- /* 添加以下样式 */
226
  @media (max-width: 640px) {
227
  .toaster-group {
228
  --viewport-padding: 16px;
@@ -240,7 +238,6 @@ body {
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);
 
80
  display: none;
81
  }
82
 
 
83
  .update-modal .ant-modal-content {
84
  padding: 24px;
85
  border-radius: 16px;
 
221
  background-size: 24px 24px;
222
  }
223
 
 
224
  @media (max-width: 640px) {
225
  .toaster-group {
226
  --viewport-padding: 16px;
 
238
  }
239
  }
240
 
 
241
  .custom-date-picker {
242
  border-radius: 0.5rem;
243
  box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
app/icon.png CHANGED

Git LFS Details

  • SHA256: 8ff4f856d636599b80820376b4eee2720758f2c3cbad14fbb096f3e1cea9c279
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
app/models/page.tsx CHANGED
@@ -263,7 +263,12 @@ export default function ModelsPage() {
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
  }
@@ -291,7 +296,12 @@ export default function ModelsPage() {
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}`
@@ -342,9 +352,13 @@ export default function ModelsPage() {
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
  {
@@ -416,10 +430,12 @@ export default function ModelsPage() {
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
 
@@ -429,7 +445,6 @@ export default function ModelsPage() {
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) => {
@@ -655,11 +670,12 @@ export default function ModelsPage() {
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,
@@ -725,7 +741,6 @@ export default function ModelsPage() {
725
  }
726
  };
727
 
728
- // 修改表格样式
729
  const tableClassName = `
730
  [&_.ant-table]:!border-b-0
731
  [&_.ant-table-container]:!rounded-xl
@@ -746,7 +761,6 @@ export default function ModelsPage() {
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
 
@@ -821,7 +835,6 @@ export default function ModelsPage() {
821
  );
822
  };
823
 
824
- // 将 renderPriceCell 修改为接收一个额外的 showTooltip 参数
825
  const renderPriceCell = (
826
  field: "input_price" | "output_price" | "per_msg_price",
827
  record: Model,
@@ -841,9 +854,7 @@ export default function ModelsPage() {
841
  try {
842
  await handlePriceUpdate(record.id, field, value);
843
  setEditingCell(null);
844
- } catch {
845
- // 错误已经在 handlePriceUpdate 中处理
846
- }
847
  }}
848
  t={t}
849
  disabled={isDisabled}
@@ -875,7 +886,6 @@ export default function ModelsPage() {
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"
@@ -884,7 +894,6 @@ export default function ModelsPage() {
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")}
@@ -892,7 +901,6 @@ export default function ModelsPage() {
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"
@@ -1005,7 +1013,6 @@ export default function ModelsPage() {
1005
  />
1006
  </div>
1007
 
1008
- {/* 替换原有的TestProgress组件 */}
1009
  <TestProgressPanel
1010
  isVisible={testing || isTestComplete}
1011
  models={models}
@@ -1013,7 +1020,6 @@ export default function ModelsPage() {
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 ? (
@@ -1034,7 +1040,6 @@ export default function ModelsPage() {
1034
  </div>
1035
  </div>
1036
 
1037
- {/* 移动端卡片视图 */}
1038
  <div className="sm:hidden">
1039
  {loading ? (
1040
  <LoadingState t={t} />
 
263
  useEffect(() => {
264
  const fetchModels = async () => {
265
  try {
266
+ const token = localStorage.getItem("access_token");
267
+ const response = await fetch("/api/v1/models", {
268
+ headers: {
269
+ Authorization: `Bearer ${token}`,
270
+ },
271
+ });
272
  if (!response.ok) {
273
  throw new Error(t("error.model.failToFetchModels"));
274
  }
 
296
  useEffect(() => {
297
  const fetchApiKey = async () => {
298
  try {
299
+ const token = localStorage.getItem("access_token");
300
+ const response = await fetch("/api/v1/config/key", {
301
+ headers: {
302
+ Authorization: `Bearer ${token}`,
303
+ },
304
+ });
305
  if (!response.ok) {
306
  throw new Error(
307
  `${t("error.model.failToFetchApiKey")}: ${response.status}`
 
352
  const per_msg_price =
353
  field === "per_msg_price" ? validValue : model.per_msg_price;
354
 
355
+ const token = localStorage.getItem("access_token");
356
  const response = await fetch("/api/v1/models/price", {
357
  method: "POST",
358
+ headers: {
359
+ "Content-Type": "application/json",
360
+ Authorization: `Bearer ${token}`,
361
+ },
362
  body: JSON.stringify({
363
  updates: [
364
  {
 
430
  try {
431
  setSyncing(true);
432
 
433
+ const token = localStorage.getItem("access_token");
434
  const response = await fetch("/api/v1/models/sync-all-prices", {
435
  method: "POST",
436
  headers: {
437
  "Content-Type": "application/json",
438
+ Authorization: `Bearer ${token}`,
439
  },
440
  });
441
 
 
445
  throw new Error(data.error || t("models.syncFail"));
446
  }
447
 
 
448
  if (data.syncedModels && data.syncedModels.length > 0) {
449
  setModels((prev) =>
450
  prev.map((model) => {
 
670
  const timeoutId = setTimeout(() => controller.abort(), 30000);
671
 
672
  try {
673
+ const token = localStorage.getItem("access_token");
674
  const response = await fetch("/api/v1/models/test", {
675
  method: "POST",
676
  headers: {
677
  "Content-Type": "application/json",
678
+ Authorization: `Bearer ${token}`,
679
  },
680
  body: JSON.stringify({
681
  modelId: model.id,
 
741
  }
742
  };
743
 
 
744
  const tableClassName = `
745
  [&_.ant-table]:!border-b-0
746
  [&_.ant-table-container]:!rounded-xl
 
761
  [&_.ant-table-cell:last-child]:!pr-6
762
  `;
763
 
 
764
  const MobileCard = ({ record }: { record: Model }) => {
765
  const isPerMsgEnabled = record.per_msg_price >= 0;
766
 
 
835
  );
836
  };
837
 
 
838
  const renderPriceCell = (
839
  field: "input_price" | "output_price" | "per_msg_price",
840
  record: Model,
 
854
  try {
855
  await handlePriceUpdate(record.id, field, value);
856
  setEditingCell(null);
857
+ } catch {}
 
 
858
  }}
859
  t={t}
860
  disabled={isDisabled}
 
886
 
887
  return (
888
  <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 space-y-8">
 
889
  <Toaster
890
  richColors
891
  position="top-center"
 
894
  duration={1500}
895
  />
896
 
 
897
  <div className="space-y-4">
898
  <h1 className="text-3xl font-bold tracking-tight">
899
  {t("models.title")}
 
901
  <p className="text-muted-foreground">{t("models.description")}</p>
902
  </div>
903
 
 
904
  <div className="flex flex-wrap gap-4">
905
  <Button
906
  variant="default"
 
1013
  />
1014
  </div>
1015
 
 
1016
  <TestProgressPanel
1017
  isVisible={testing || isTestComplete}
1018
  models={models}
 
1020
  t={t}
1021
  />
1022
 
 
1023
  <div className="hidden sm:block">
1024
  <div className="rounded-xl border border-border/40 bg-card shadow-sm overflow-hidden">
1025
  {loading ? (
 
1040
  </div>
1041
  </div>
1042
 
 
1043
  <div className="sm:hidden">
1044
  {loading ? (
1045
  <LoadingState t={t} />
app/page.tsx CHANGED
@@ -31,7 +31,6 @@ export default function HomePage() {
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);
@@ -61,7 +60,6 @@ export default function HomePage() {
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}
@@ -73,18 +71,15 @@ export default function HomePage() {
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 }}
@@ -111,13 +106,10 @@ export default function HomePage() {
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
  {
@@ -164,15 +156,12 @@ export default function HomePage() {
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",
@@ -185,7 +174,6 @@ export default function HomePage() {
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}
@@ -195,7 +183,6 @@ export default function HomePage() {
195
  </p>
196
  </div>
197
 
198
- {/* 箭头 */}
199
  <div
200
  className={cn(
201
  "transform transition-all duration-300",
@@ -227,7 +214,6 @@ export default function HomePage() {
227
  </div>
228
  </motion.div>
229
 
230
- {/* GitHub 图标固定在底部 */}
231
  <motion.div
232
  initial={{ opacity: 0 }}
233
  animate={{ opacity: 1 }}
@@ -245,7 +231,6 @@ export default function HomePage() {
245
  </motion.div>
246
  </motion.div>
247
 
248
- {/* 更新提示框样式修改 */}
249
  {isUpdateVisible && (
250
  <motion.div
251
  initial={{ opacity: 0, y: 20 }}
 
31
  const currentVer = APP_VERSION.replace(/^v/, "");
32
  const newVer = latestVer.replace(/^v/, "");
33
 
 
34
  const ignoredVersion = localStorage.getItem("ignoredVersion");
35
  if (currentVer !== newVer && ignoredVersion !== latestVer) {
36
  setLatestVersion(latestVer);
 
60
 
61
  return (
62
  <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">
 
63
  <AnimatedGridPattern
64
  numSquares={30}
65
  maxOpacity={0.03}
 
71
  )}
72
  />
73
 
 
74
  <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" />
75
  <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" />
76
  <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" />
77
 
 
78
  <motion.div
79
  initial={{ opacity: 0 }}
80
  animate={{ opacity: 1 }}
81
  className="min-h-[calc(100vh-4rem)] flex flex-col relative z-10"
82
  >
 
83
  <motion.div className="flex-1 flex flex-col items-center justify-center">
84
  <motion.div
85
  initial={{ opacity: 0, y: 20 }}
 
106
  </div>
107
  </motion.div>
108
 
 
109
  <div className="w-full max-w-2xl px-4 sm:px-6">
110
  <div className="relative">
 
111
  <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" />
112
 
 
113
  <div className="space-y-4">
114
  {[
115
  {
 
156
  shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)]
157
  hover:shadow-[0_8px_30px_-4px_rgba(0,0,0,0.12)]"
158
  >
 
159
  <div
160
  className={`absolute inset-0 bg-gradient-to-r ${item.gradient} opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
161
  />
162
 
 
163
  <div className="relative p-6">
164
  <div className="flex items-center gap-4">
 
165
  <div
166
  className={cn(
167
  "p-3 rounded-xl transition-all duration-500",
 
174
  {item.icon}
175
  </div>
176
 
 
177
  <div className="flex-1 min-w-0">
178
  <h3 className="text-lg font-medium text-slate-800 group-hover:text-white transition-colors mb-1">
179
  {item.title}
 
183
  </p>
184
  </div>
185
 
 
186
  <div
187
  className={cn(
188
  "transform transition-all duration-300",
 
214
  </div>
215
  </motion.div>
216
 
 
217
  <motion.div
218
  initial={{ opacity: 0 }}
219
  animate={{ opacity: 1 }}
 
231
  </motion.div>
232
  </motion.div>
233
 
 
234
  {isUpdateVisible && (
235
  <motion.div
236
  initial={{ opacity: 0, y: 20 }}
app/panel/page.tsx CHANGED
@@ -2,7 +2,7 @@
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";
@@ -72,7 +72,7 @@ export default function PanelPage() {
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<{
@@ -109,18 +109,26 @@ export default function PanelPage() {
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);
@@ -143,7 +151,6 @@ export default function PanelPage() {
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");
@@ -155,18 +162,23 @@ export default function PanelPage() {
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
 
@@ -187,15 +199,18 @@ export default function PanelPage() {
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");
@@ -237,11 +252,9 @@ export default function PanelPage() {
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")}`;
 
2
 
3
  import { useState, useEffect } from "react";
4
  import Head from "next/head";
5
+ import dayjs from "@/lib/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";
 
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<{
 
109
  const fetchUsageData = async (range: [Date, Date]) => {
110
  setLoading(true);
111
  try {
112
+ const startTime = dayjs(range[0])
113
+ .startOf("day")
114
+ .format("YYYY-MM-DDTHH:mm:ssZ");
115
+ const endTime = dayjs(range[1])
116
+ .endOf("day")
117
+ .format("YYYY-MM-DDTHH:mm:ssZ");
118
 
119
  const url = `/api/v1/panel/usage?startTime=${startTime}&endTime=${endTime}`;
120
+ const token = localStorage.getItem("access_token");
121
+ const response = await fetch(url, {
122
+ headers: {
123
+ Authorization: `Bearer ${token}`,
124
+ },
125
+ });
126
 
127
  if (!response.ok) throw new Error("Failed to fetch data");
128
 
129
  const data = await response.json();
130
  setUsageData(data);
131
 
 
132
  if (timeRangeType === "all") {
133
  const minTime = new Date(data.timeRange.minTime);
134
  const maxTime = new Date(data.timeRange.maxTime);
 
151
  params.pagination.pageSize?.toString() || "10"
152
  );
153
 
 
154
  if (params.sortField) {
155
  searchParams.append("sortField", params.sortField);
156
  searchParams.append("sortOrder", params.sortOrder || "ascend");
 
162
  searchParams.append("models", params.filters.model_name.join(","));
163
  }
164
 
 
165
  searchParams.append(
166
  "startDate",
167
+ dayjs(range[0]).startOf("day").format("YYYY-MM-DDTHH:mm:ssZ")
168
  );
169
  searchParams.append(
170
  "endDate",
171
+ dayjs(range[1]).endOf("day").format("YYYY-MM-DDTHH:mm:ssZ")
172
  );
173
 
174
+ const token = localStorage.getItem("access_token");
175
  const response = await fetch(
176
+ `/api/v1/panel/records?${searchParams.toString()}`,
177
+ {
178
+ headers: {
179
+ Authorization: `Bearer ${token}`,
180
+ },
181
+ }
182
  );
183
  const data = await response.json();
184
 
 
199
 
200
  useEffect(() => {
201
  const loadInitialData = async () => {
202
+ const token = localStorage.getItem("access_token");
203
+ const response = await fetch("/api/v1/panel/usage", {
204
+ headers: {
205
+ Authorization: `Bearer ${token}`,
206
+ },
207
+ });
208
  const data = await response.json();
209
 
210
  const minTime = dayjs(data.timeRange.minTime).startOf("day").toDate();
211
  const maxTime = dayjs(data.timeRange.maxTime).endOf("day").toDate();
212
  setAvailableTimeRange({ minTime, maxTime });
213
 
 
214
  const allTimeRange: [Date, Date] = [minTime, maxTime];
215
  setDateRange(allTimeRange);
216
  setTimeRangeType("all");
 
252
  };
253
 
254
  const renderDateRangeLabel = () => {
 
255
  if (dayjs(dateRange[0]).isSame(dateRange[1], "day")) {
256
  return dayjs(dateRange[0]).format("YYYY-MM-DD");
257
  }
 
258
  return `${dayjs(dateRange[0]).format("YYYY-MM-DD")} ~ ${dayjs(
259
  dateRange[1]
260
  ).format("YYYY-MM-DD")}`;
app/records/page.tsx CHANGED
@@ -177,7 +177,6 @@ export default function RecordsPage() {
177
  },
178
  });
179
 
180
- // 设置筛选选项
181
  setUsers(data.users as string[]);
182
  setModels(data.models as string[]);
183
  } catch (error) {
@@ -230,7 +229,6 @@ export default function RecordsPage() {
230
  }
231
  };
232
 
233
- // 修改表格样式
234
  const tableClassName = `
235
  [&_.ant-table]:!border-b-0
236
  [&_.ant-table-container]:!rounded-xl
 
177
  },
178
  });
179
 
 
180
  setUsers(data.users as string[]);
181
  setModels(data.models as string[]);
182
  } catch (error) {
 
229
  }
230
  };
231
 
 
232
  const tableClassName = `
233
  [&_.ant-table]:!border-b-0
234
  [&_.ant-table-container]:!rounded-xl
app/token/page.tsx CHANGED
@@ -34,7 +34,7 @@ export default function TokenPage() {
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
  },
@@ -76,7 +76,6 @@ export default function TokenPage() {
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" />
@@ -112,7 +111,6 @@ export default function TokenPage() {
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
@@ -120,7 +118,6 @@ export default function TokenPage() {
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
 
34
  setLoading(true);
35
  try {
36
  localStorage.setItem("access_token", token);
37
+ const res = await fetch("/api/v1/config", {
38
  headers: {
39
  Authorization: `Bearer ${token}`,
40
  },
 
76
  )}
77
  />
78
 
 
79
  <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" />
80
  <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" />
81
  <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" />
 
111
  className="backdrop-blur-[20px] bg-white/[0.08] p-10 rounded-[2.5rem] border border-white/20 shadow-2xl relative overflow-hidden
112
  hover:shadow-[0_8px_60px_rgba(120,119,198,0.15)] transition-shadow duration-300 group"
113
  >
 
114
  <div
115
  className="absolute inset-0 rounded-[2.5rem] p-[2px]
116
  bg-gradient-to-br from-white/30 via-transparent to-transparent
 
118
  [mask-composite:xor] opacity-30 group-hover:opacity-50 transition-opacity"
119
  ></div>
120
 
 
121
  <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" />
122
 
123
  <motion.form
app/users/page.tsx CHANGED
@@ -275,7 +275,6 @@ const BlockConfirmModal = ({
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" />
@@ -310,7 +309,7 @@ export default function UsersPage() {
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
  }
@@ -318,7 +317,12 @@ export default function UsersPage() {
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
 
@@ -339,7 +343,12 @@ export default function UsersPage() {
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);
@@ -361,29 +370,41 @@ export default function UsersPage() {
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
@@ -396,10 +417,12 @@ export default function UsersPage() {
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,
@@ -782,7 +805,6 @@ export default function UsersPage() {
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">
 
275
  );
276
  };
277
 
 
278
  const LoadingState = ({ t }: { t: TFunction }) => (
279
  <div className="flex flex-col items-center justify-center py-12 px-4">
280
  <div className="h-12 w-12 rounded-full border-4 border-primary/10 border-t-primary animate-spin mb-4" />
 
309
  const fetchUsers = async (page: number, isBlacklist: boolean = false) => {
310
  setLoading(true);
311
  try {
312
+ let url = `/api/v1/users?page=${page}&deleted=${isBlacklist}`;
313
  if (sortInfo.field && sortInfo.order) {
314
  url += `&sortField=${sortInfo.field}&sortOrder=${sortInfo.order}`;
315
  }
 
317
  url += `&search=${encodeURIComponent(searchText)}`;
318
  }
319
 
320
+ const token = localStorage.getItem("access_token");
321
+ const res = await fetch(url, {
322
+ headers: {
323
+ Authorization: `Bearer ${token}`,
324
+ },
325
+ });
326
  const data = await res.json();
327
  if (!res.ok) throw new Error(data.error);
328
 
 
343
 
344
  const fetchBlacklistTotal = async () => {
345
  try {
346
+ const token = localStorage.getItem("access_token");
347
+ const res = await fetch(`/api/v1/users?page=1&deleted=true&pageSize=1`, {
348
+ headers: {
349
+ Authorization: `Bearer ${token}`,
350
+ },
351
+ });
352
  const data = await res.json();
353
  if (!res.ok) throw new Error(data.error);
354
  setBlacklistTotal(data.total);
 
370
 
371
  const handleUpdateBalance = async (userId: string, newBalance: number) => {
372
  try {
373
+ console.log(`Updating balance for user ${userId} to ${newBalance}`);
374
+
375
+ const token = localStorage.getItem("access_token");
376
+ if (!token) {
377
+ throw new Error(t("auth.unauthorized"));
378
+ }
379
+
380
+ const res = await fetch(`/api/v1/users/${userId}/balance`, {
381
  method: "PUT",
382
+ headers: {
383
+ "Content-Type": "application/json",
384
+ Authorization: `Bearer ${token}`,
385
+ },
386
  body: JSON.stringify({ balance: newBalance }),
387
  });
388
 
389
  const data = await res.json();
390
+ console.log("Update balance response:", data);
391
+
392
+ if (!res.ok) {
393
+ throw new Error(data.error || t("users.message.updateBalance.error"));
394
+ }
395
 
 
396
  setUsers(
397
  users.map((user) =>
398
  user.id === userId ? { ...user, balance: newBalance } : user
399
  )
400
  );
401
 
 
402
  toast.success(t("users.message.updateBalance.success"));
403
  setEditingKey("");
404
 
 
405
  fetchUsers(currentPage, false);
406
  } catch (err) {
407
+ console.error("Failed to update balance:", err);
408
  toast.error(
409
  err instanceof Error
410
  ? err.message
 
417
  if (!userToDelete) return;
418
 
419
  try {
420
+ const token = localStorage.getItem("access_token");
421
+ const res = await fetch(`/api/v1/users/${userToDelete.id}`, {
422
  method: "PATCH",
423
  headers: {
424
  "Content-Type": "application/json",
425
+ Authorization: `Bearer ${token}`,
426
  },
427
  body: JSON.stringify({
428
  deleted: !userToDelete.deleted,
 
805
  );
806
  };
807
 
 
808
  const EmptyState = ({ searchText }: { searchText: string }) => (
809
  <div className="flex flex-col items-center justify-center py-12 px-4">
810
  <div className="h-12 w-12 rounded-full bg-muted/40 flex items-center justify-center mb-4">
components/AuthCheck.tsx CHANGED
@@ -9,9 +9,20 @@ export default function AuthCheck({ children }: { children: React.ReactNode }) {
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);
@@ -25,7 +36,7 @@ export default function AuthCheck({ children }: { children: React.ReactNode }) {
25
  }
26
 
27
  try {
28
- const res = await fetch("/api/config", {
29
  headers: {
30
  Authorization: `Bearer ${token}`,
31
  },
@@ -49,7 +60,6 @@ export default function AuthCheck({ children }: { children: React.ReactNode }) {
49
  checkAuth();
50
  }, [router, pathname]);
51
 
52
- // 显示加载状态或空白页面
53
  if (isLoading || !isAuthorized) {
54
  return null;
55
  }
 
9
  const router = useRouter();
10
  const pathname = usePathname();
11
 
12
+ useEffect(() => {
13
+ const initDb = async () => {
14
+ try {
15
+ await fetch("/api/init");
16
+ } catch (error) {
17
+ console.error("初始化数据库失败:", error);
18
+ }
19
+ };
20
+
21
+ initDb();
22
+ }, []);
23
+
24
  useEffect(() => {
25
  const checkAuth = async () => {
 
26
  if (pathname === "/token") {
27
  setIsLoading(false);
28
  setIsAuthorized(true);
 
36
  }
37
 
38
  try {
39
+ const res = await fetch("/api/v1/config", {
40
  headers: {
41
  Authorization: `Bearer ${token}`,
42
  },
 
60
  checkAuth();
61
  }, [router, pathname]);
62
 
 
63
  if (isLoading || !isAuthorized) {
64
  return null;
65
  }
components/Header.tsx CHANGED
@@ -40,15 +40,12 @@ export default function Header() {
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) {
@@ -89,15 +86,13 @@ export default function Header() {
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;
@@ -111,7 +106,6 @@ export default function Header() {
111
  })
112
  .catch(() => {
113
  setApiKey(t("common.error"));
114
- // 发生错误时也清除token并重定向
115
  localStorage.removeItem("access_token");
116
  router.push("/token");
117
  });
@@ -292,11 +286,10 @@ export default function Header() {
292
  ];
293
 
294
  const menuItems = [
295
- // 在小屏幕上将导航项添加到菜单中,但需要特殊处理
296
  ...(!isTokenPage
297
  ? navigationItems.map((item) => ({
298
  ...item,
299
- onClick: () => router.push(item.path), // 为导航项添加 onClick 处理
300
  }))
301
  : []),
302
  {
@@ -325,7 +318,6 @@ export default function Header() {
325
  },
326
  ];
327
 
328
- // 在navigationItems数组后添加
329
  const actionItems = [
330
  {
331
  icon: <Globe className="w-5 h-5" />,
@@ -372,9 +364,7 @@ export default function Header() {
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) => (
@@ -401,7 +391,6 @@ export default function Header() {
401
  </div>
402
  )}
403
 
404
- {/* 语言切换和菜单按钮 */}
405
  <div className="flex items-center gap-3">
406
  {actionItems.map((item, index) => (
407
  <button
@@ -433,7 +422,6 @@ export default function Header() {
433
  <AnimatePresence>
434
  {isMenuOpen && (
435
  <>
436
- {/* 背景遮罩 */}
437
  <motion.div
438
  initial={{ opacity: 0 }}
439
  animate={{ opacity: 1 }}
@@ -443,7 +431,6 @@ export default function Header() {
443
  onClick={() => setIsMenuOpen(false)}
444
  />
445
 
446
- {/* 菜单面板 - 响应式布局 */}
447
  <motion.div
448
  initial={{ x: "100%" }}
449
  animate={{ x: 0 }}
@@ -451,9 +438,7 @@ export default function Header() {
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")}
@@ -466,10 +451,8 @@ export default function Header() {
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
@@ -497,7 +480,6 @@ export default function Header() {
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
 
40
  const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
41
  const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
42
  const [accessToken, setAccessToken] = useState<string | null>(null);
 
43
 
 
44
  const handleLanguageChange = async (newLang: string) => {
45
  await i18n.changeLanguage(newLang);
46
  localStorage.setItem("language", newLang);
47
  };
48
 
 
49
  const isTokenPage = pathname === "/token";
50
 
51
  if (isTokenPage) {
 
86
  return;
87
  }
88
 
89
+ fetch("/api/v1/config", {
 
90
  headers: {
91
  Authorization: `Bearer ${token}`,
92
  },
93
  })
94
  .then((res) => {
95
  if (!res.ok) {
 
96
  localStorage.removeItem("access_token");
97
  router.push("/token");
98
  return;
 
106
  })
107
  .catch(() => {
108
  setApiKey(t("common.error"));
 
109
  localStorage.removeItem("access_token");
110
  router.push("/token");
111
  });
 
286
  ];
287
 
288
  const menuItems = [
 
289
  ...(!isTokenPage
290
  ? navigationItems.map((item) => ({
291
  ...item,
292
+ onClick: () => router.push(item.path),
293
  }))
294
  : []),
295
  {
 
318
  },
319
  ];
320
 
 
321
  const actionItems = [
322
  {
323
  icon: <Globe className="w-5 h-5" />,
 
364
  </Link>
365
  </motion.div>
366
 
 
367
  <div className="flex items-center gap-4">
 
368
  {!isTokenPage && (
369
  <div className="hidden md:flex items-center gap-3">
370
  {navigationItems.map((item) => (
 
391
  </div>
392
  )}
393
 
 
394
  <div className="flex items-center gap-3">
395
  {actionItems.map((item, index) => (
396
  <button
 
422
  <AnimatePresence>
423
  {isMenuOpen && (
424
  <>
 
425
  <motion.div
426
  initial={{ opacity: 0 }}
427
  animate={{ opacity: 1 }}
 
431
  onClick={() => setIsMenuOpen(false)}
432
  />
433
 
 
434
  <motion.div
435
  initial={{ x: "100%" }}
436
  animate={{ x: 0 }}
 
438
  transition={{ type: "spring", damping: 25, stiffness: 200 }}
439
  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"
440
  >
 
441
  <div className="relative h-full flex flex-col">
 
442
  <div className="flex items-center justify-between p-4 border-b border-gray-100/50">
443
  <h2 className="text-lg font-medium bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 bg-clip-text text-transparent">
444
  {t("header.menu.settings")}
 
451
  </button>
452
  </div>
453
 
 
454
  <div className="flex-1 overflow-y-auto p-2">
455
  <div className="space-y-1">
 
456
  <div className="md:hidden space-y-1">
457
  {navigationItems.map((item, index) => (
458
  <motion.button
 
480
  ))}
481
  </div>
482
 
 
483
  <div className="border-t border-gray-100/50 pt-2 md:border-t-0 md:pt-0">
484
  {settingsItems.map((item, index) => (
485
  <motion.button
components/editable-cell.tsx CHANGED
@@ -83,7 +83,6 @@ export function EditableCell({
83
 
84
  await onSubmit(numValue);
85
  } catch (err) {
86
- // 错误已在父组件中处理
87
  } finally {
88
  setIsSaving(false);
89
  }
 
83
 
84
  await onSubmit(numValue);
85
  } catch (err) {
 
86
  } finally {
87
  setIsSaving(false);
88
  }
components/panel/TimeRangeSelector.tsx CHANGED
@@ -1,7 +1,7 @@
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";
 
1
  "use client";
2
 
3
  import { useState, useEffect } from "react";
4
+ import dayjs from "@/lib/dayjs";
5
  import { Button } from "@/components/ui/button";
6
  import { useTranslation } from "react-i18next";
7
  import { motion, AnimatePresence } from "framer-motion";
components/panel/UsageRecordsTable.tsx CHANGED
@@ -4,7 +4,7 @@ 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;
@@ -200,7 +200,6 @@ export default function UsageRecordsTable({
200
  />
201
  </div>
202
 
203
- {/* 桌面设备表格 */}
204
  <div className="hidden sm:block">
205
  <Table
206
  columns={columns}
@@ -235,7 +234,6 @@ export default function UsageRecordsTable({
235
  />
236
  </div>
237
 
238
- {/* 移动设备卡片列表 */}
239
  <div className="sm:hidden space-y-4">
240
  {loading ? (
241
  <div className="flex justify-center py-8">
 
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 "@/lib/dayjs";
8
  import { useTranslation } from "react-i18next";
9
  interface UsageRecord {
10
  id: number;
 
200
  />
201
  </div>
202
 
 
203
  <div className="hidden sm:block">
204
  <Table
205
  columns={columns}
 
234
  />
235
  </div>
236
 
 
237
  <div className="sm:hidden space-y-4">
238
  {loading ? (
239
  <div className="flex justify-center py-8">
components/panel/UserRankingChart.tsx CHANGED
@@ -225,13 +225,12 @@ export default function UserRankingChart({
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:
@@ -242,7 +241,6 @@ export default function UserRankingChart({
242
  });
243
  isZoomed = true;
244
  } else {
245
- // 第二次点击,还原缩放
246
  instance.dispatchAction({
247
  type: "dataZoom",
248
  start: 0,
 
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
  instance.dispatchAction({
235
  type: "dataZoom",
236
  startValue:
 
241
  });
242
  isZoomed = true;
243
  } else {
 
244
  instance.dispatchAction({
245
  type: "dataZoom",
246
  start: 0,
components/ui/animated-grid-pattern.tsx CHANGED
@@ -43,7 +43,6 @@ export function AnimatedGridPattern({
43
  ];
44
  }
45
 
46
- // Adjust the generateSquares function to return objects with an id, x, and y
47
  function generateSquares(count: number) {
48
  return Array.from({ length: count }, (_, i) => ({
49
  id: i,
@@ -51,7 +50,6 @@ export function AnimatedGridPattern({
51
  }));
52
  }
53
 
54
- // Function to update a single square's position
55
  const updateSquarePosition = (id: number) => {
56
  setSquares((currentSquares) =>
57
  currentSquares.map((sq) =>
@@ -65,14 +63,12 @@ export function AnimatedGridPattern({
65
  );
66
  };
67
 
68
- // Update squares to animate in
69
  useEffect(() => {
70
  if (dimensions.width && dimensions.height) {
71
  setSquares(generateSquares(numSquares));
72
  }
73
  }, [dimensions, numSquares]);
74
 
75
- // Resize observer to update container dimensions
76
  useEffect(() => {
77
  const resizeObserver = new ResizeObserver((entries) => {
78
  for (let entry of entries) {
 
43
  ];
44
  }
45
 
 
46
  function generateSquares(count: number) {
47
  return Array.from({ length: count }, (_, i) => ({
48
  id: i,
 
50
  }));
51
  }
52
 
 
53
  const updateSquarePosition = (id: number) => {
54
  setSquares((currentSquares) =>
55
  currentSquares.map((sq) =>
 
63
  );
64
  };
65
 
 
66
  useEffect(() => {
67
  if (dimensions.width && dimensions.height) {
68
  setSquares(generateSquares(numSquares));
69
  }
70
  }, [dimensions, numSquares]);
71
 
 
72
  useEffect(() => {
73
  const resizeObserver = new ResizeObserver((entries) => {
74
  for (let entry of entries) {
components/ui/chart.tsx CHANGED
@@ -1,55 +1,49 @@
1
- "use client"
2
 
3
- import * as React from "react"
4
- import * as RechartsPrimitive from "recharts"
5
- import {
6
- NameType,
7
- Payload,
8
- ValueType,
9
- } from "recharts/types/component/DefaultTooltipContent"
10
 
11
- import { cn } from "@/lib/utils"
12
 
13
- // Format: { THEME_NAME: CSS_SELECTOR }
14
- const THEMES = { light: "", dark: ".dark" } as const
15
 
16
  export type ChartConfig = {
17
  [k in string]: {
18
- label?: React.ReactNode
19
- icon?: React.ComponentType
20
  } & (
21
  | { color?: string; theme?: never }
22
  | { color?: never; theme: Record<keyof typeof THEMES, string> }
23
- )
24
- }
25
 
26
  type ChartContextProps = {
27
- config: ChartConfig
28
- }
29
 
30
- const ChartContext = React.createContext<ChartContextProps | null>(null)
31
 
32
  function useChart() {
33
- const context = React.useContext(ChartContext)
34
 
35
  if (!context) {
36
- throw new Error("useChart must be used within a <ChartContainer />")
37
  }
38
 
39
- return context
40
  }
41
 
42
  const ChartContainer = React.forwardRef<
43
  HTMLDivElement,
44
  React.ComponentProps<"div"> & {
45
- config: ChartConfig
46
  children: React.ComponentProps<
47
  typeof RechartsPrimitive.ResponsiveContainer
48
- >["children"]
49
  }
50
  >(({ id, className, children, config, ...props }, ref) => {
51
- const uniqueId = React.useId()
52
- const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
53
 
54
  return (
55
  <ChartContext.Provider value={{ config }}>
@@ -68,17 +62,17 @@ const ChartContainer = React.forwardRef<
68
  </RechartsPrimitive.ResponsiveContainer>
69
  </div>
70
  </ChartContext.Provider>
71
- )
72
- })
73
- ChartContainer.displayName = "Chart"
74
 
75
  const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
76
  const colorConfig = Object.entries(config).filter(
77
  ([_, config]) => config.theme || config.color
78
- )
79
 
80
  if (!colorConfig.length) {
81
- return null
82
  }
83
 
84
  return (
@@ -92,8 +86,8 @@ ${colorConfig
92
  .map(([key, itemConfig]) => {
93
  const color =
94
  itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
95
- itemConfig.color
96
- return color ? ` --color-${key}: ${color};` : null
97
  })
98
  .join("\n")}
99
  }
@@ -102,20 +96,20 @@ ${colorConfig
102
  .join("\n"),
103
  }}
104
  />
105
- )
106
- }
107
 
108
- const ChartTooltip = RechartsPrimitive.Tooltip
109
 
110
  const ChartTooltipContent = React.forwardRef<
111
  HTMLDivElement,
112
  React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
113
  React.ComponentProps<"div"> & {
114
- hideLabel?: boolean
115
- hideIndicator?: boolean
116
- indicator?: "line" | "dot" | "dashed"
117
- nameKey?: string
118
- labelKey?: string
119
  }
120
  >(
121
  (
@@ -136,34 +130,34 @@ const ChartTooltipContent = React.forwardRef<
136
  },
137
  ref
138
  ) => {
139
- const { config } = useChart()
140
 
141
  const tooltipLabel = React.useMemo(() => {
142
  if (hideLabel || !payload?.length) {
143
- return null
144
  }
145
 
146
- const [item] = payload
147
- const key = `${labelKey || item.dataKey || item.name || "value"}`
148
- const itemConfig = getPayloadConfigFromPayload(config, item, key)
149
  const value =
150
  !labelKey && typeof label === "string"
151
  ? config[label as keyof typeof config]?.label || label
152
- : itemConfig?.label
153
 
154
  if (labelFormatter) {
155
  return (
156
  <div className={cn("font-medium", labelClassName)}>
157
  {labelFormatter(value, payload)}
158
  </div>
159
- )
160
  }
161
 
162
  if (!value) {
163
- return null
164
  }
165
 
166
- return <div className={cn("font-medium", labelClassName)}>{value}</div>
167
  }, [
168
  label,
169
  labelFormatter,
@@ -172,13 +166,13 @@ const ChartTooltipContent = React.forwardRef<
172
  labelClassName,
173
  config,
174
  labelKey,
175
- ])
176
 
177
  if (!active || !payload?.length) {
178
- return null
179
  }
180
 
181
- const nestLabel = payload.length === 1 && indicator !== "dot"
182
 
183
  return (
184
  <div
@@ -191,9 +185,9 @@ const ChartTooltipContent = React.forwardRef<
191
  {!nestLabel ? tooltipLabel : null}
192
  <div className="grid gap-1.5">
193
  {payload.map((item, index) => {
194
- const key = `${nameKey || item.name || item.dataKey || "value"}`
195
- const itemConfig = getPayloadConfigFromPayload(config, item, key)
196
- const indicatorColor = color || item.payload.fill || item.color
197
 
198
  return (
199
  <div
@@ -252,33 +246,33 @@ const ChartTooltipContent = React.forwardRef<
252
  </>
253
  )}
254
  </div>
255
- )
256
  })}
257
  </div>
258
  </div>
259
- )
260
  }
261
- )
262
- ChartTooltipContent.displayName = "ChartTooltip"
263
 
264
- const ChartLegend = RechartsPrimitive.Legend
265
 
266
  const ChartLegendContent = React.forwardRef<
267
  HTMLDivElement,
268
  React.ComponentProps<"div"> &
269
  Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
270
- hideIcon?: boolean
271
- nameKey?: string
272
  }
273
  >(
274
  (
275
  { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
276
  ref
277
  ) => {
278
- const { config } = useChart()
279
 
280
  if (!payload?.length) {
281
- return null
282
  }
283
 
284
  return (
@@ -291,8 +285,8 @@ const ChartLegendContent = React.forwardRef<
291
  )}
292
  >
293
  {payload.map((item) => {
294
- const key = `${nameKey || item.dataKey || "value"}`
295
- const itemConfig = getPayloadConfigFromPayload(config, item, key)
296
 
297
  return (
298
  <div
@@ -313,22 +307,21 @@ const ChartLegendContent = React.forwardRef<
313
  )}
314
  {itemConfig?.label}
315
  </div>
316
- )
317
  })}
318
  </div>
319
- )
320
  }
321
- )
322
- ChartLegendContent.displayName = "ChartLegend"
323
 
324
- // Helper to extract item config from a payload.
325
  function getPayloadConfigFromPayload(
326
  config: ChartConfig,
327
  payload: unknown,
328
  key: string
329
  ) {
330
  if (typeof payload !== "object" || payload === null) {
331
- return undefined
332
  }
333
 
334
  const payloadPayload =
@@ -336,15 +329,15 @@ function getPayloadConfigFromPayload(
336
  typeof payload.payload === "object" &&
337
  payload.payload !== null
338
  ? payload.payload
339
- : undefined
340
 
341
- let configLabelKey: string = key
342
 
343
  if (
344
  key in payload &&
345
  typeof payload[key as keyof typeof payload] === "string"
346
  ) {
347
- configLabelKey = payload[key as keyof typeof payload] as string
348
  } else if (
349
  payloadPayload &&
350
  key in payloadPayload &&
@@ -352,12 +345,12 @@ function getPayloadConfigFromPayload(
352
  ) {
353
  configLabelKey = payloadPayload[
354
  key as keyof typeof payloadPayload
355
- ] as string
356
  }
357
 
358
  return configLabelKey in config
359
  ? config[configLabelKey]
360
- : config[key as keyof typeof config]
361
  }
362
 
363
  export {
@@ -367,4 +360,4 @@ export {
367
  ChartLegend,
368
  ChartLegendContent,
369
  ChartStyle,
370
- }
 
1
+ "use client";
2
 
3
+ import * as React from "react";
4
+ import * as RechartsPrimitive from "recharts";
 
 
 
 
 
5
 
6
+ import { cn } from "@/lib/utils";
7
 
8
+ const THEMES = { light: "", dark: ".dark" } as const;
 
9
 
10
  export type ChartConfig = {
11
  [k in string]: {
12
+ label?: React.ReactNode;
13
+ icon?: React.ComponentType;
14
  } & (
15
  | { color?: string; theme?: never }
16
  | { color?: never; theme: Record<keyof typeof THEMES, string> }
17
+ );
18
+ };
19
 
20
  type ChartContextProps = {
21
+ config: ChartConfig;
22
+ };
23
 
24
+ const ChartContext = React.createContext<ChartContextProps | null>(null);
25
 
26
  function useChart() {
27
+ const context = React.useContext(ChartContext);
28
 
29
  if (!context) {
30
+ throw new Error("useChart must be used within a <ChartContainer />");
31
  }
32
 
33
+ return context;
34
  }
35
 
36
  const ChartContainer = React.forwardRef<
37
  HTMLDivElement,
38
  React.ComponentProps<"div"> & {
39
+ config: ChartConfig;
40
  children: React.ComponentProps<
41
  typeof RechartsPrimitive.ResponsiveContainer
42
+ >["children"];
43
  }
44
  >(({ id, className, children, config, ...props }, ref) => {
45
+ const uniqueId = React.useId();
46
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
47
 
48
  return (
49
  <ChartContext.Provider value={{ config }}>
 
62
  </RechartsPrimitive.ResponsiveContainer>
63
  </div>
64
  </ChartContext.Provider>
65
+ );
66
+ });
67
+ ChartContainer.displayName = "Chart";
68
 
69
  const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
70
  const colorConfig = Object.entries(config).filter(
71
  ([_, config]) => config.theme || config.color
72
+ );
73
 
74
  if (!colorConfig.length) {
75
+ return null;
76
  }
77
 
78
  return (
 
86
  .map(([key, itemConfig]) => {
87
  const color =
88
  itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
89
+ itemConfig.color;
90
+ return color ? ` --color-${key}: ${color};` : null;
91
  })
92
  .join("\n")}
93
  }
 
96
  .join("\n"),
97
  }}
98
  />
99
+ );
100
+ };
101
 
102
+ const ChartTooltip = RechartsPrimitive.Tooltip;
103
 
104
  const ChartTooltipContent = React.forwardRef<
105
  HTMLDivElement,
106
  React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
107
  React.ComponentProps<"div"> & {
108
+ hideLabel?: boolean;
109
+ hideIndicator?: boolean;
110
+ indicator?: "line" | "dot" | "dashed";
111
+ nameKey?: string;
112
+ labelKey?: string;
113
  }
114
  >(
115
  (
 
130
  },
131
  ref
132
  ) => {
133
+ const { config } = useChart();
134
 
135
  const tooltipLabel = React.useMemo(() => {
136
  if (hideLabel || !payload?.length) {
137
+ return null;
138
  }
139
 
140
+ const [item] = payload;
141
+ const key = `${labelKey || item.dataKey || item.name || "value"}`;
142
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
143
  const value =
144
  !labelKey && typeof label === "string"
145
  ? config[label as keyof typeof config]?.label || label
146
+ : itemConfig?.label;
147
 
148
  if (labelFormatter) {
149
  return (
150
  <div className={cn("font-medium", labelClassName)}>
151
  {labelFormatter(value, payload)}
152
  </div>
153
+ );
154
  }
155
 
156
  if (!value) {
157
+ return null;
158
  }
159
 
160
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>;
161
  }, [
162
  label,
163
  labelFormatter,
 
166
  labelClassName,
167
  config,
168
  labelKey,
169
+ ]);
170
 
171
  if (!active || !payload?.length) {
172
+ return null;
173
  }
174
 
175
+ const nestLabel = payload.length === 1 && indicator !== "dot";
176
 
177
  return (
178
  <div
 
185
  {!nestLabel ? tooltipLabel : null}
186
  <div className="grid gap-1.5">
187
  {payload.map((item, index) => {
188
+ const key = `${nameKey || item.name || item.dataKey || "value"}`;
189
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
190
+ const indicatorColor = color || item.payload.fill || item.color;
191
 
192
  return (
193
  <div
 
246
  </>
247
  )}
248
  </div>
249
+ );
250
  })}
251
  </div>
252
  </div>
253
+ );
254
  }
255
+ );
256
+ ChartTooltipContent.displayName = "ChartTooltip";
257
 
258
+ const ChartLegend = RechartsPrimitive.Legend;
259
 
260
  const ChartLegendContent = React.forwardRef<
261
  HTMLDivElement,
262
  React.ComponentProps<"div"> &
263
  Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
264
+ hideIcon?: boolean;
265
+ nameKey?: string;
266
  }
267
  >(
268
  (
269
  { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
270
  ref
271
  ) => {
272
+ const { config } = useChart();
273
 
274
  if (!payload?.length) {
275
+ return null;
276
  }
277
 
278
  return (
 
285
  )}
286
  >
287
  {payload.map((item) => {
288
+ const key = `${nameKey || item.dataKey || "value"}`;
289
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
290
 
291
  return (
292
  <div
 
307
  )}
308
  {itemConfig?.label}
309
  </div>
310
+ );
311
  })}
312
  </div>
313
+ );
314
  }
315
+ );
316
+ ChartLegendContent.displayName = "ChartLegend";
317
 
 
318
  function getPayloadConfigFromPayload(
319
  config: ChartConfig,
320
  payload: unknown,
321
  key: string
322
  ) {
323
  if (typeof payload !== "object" || payload === null) {
324
+ return undefined;
325
  }
326
 
327
  const payloadPayload =
 
329
  typeof payload.payload === "object" &&
330
  payload.payload !== null
331
  ? payload.payload
332
+ : undefined;
333
 
334
+ let configLabelKey: string = key;
335
 
336
  if (
337
  key in payload &&
338
  typeof payload[key as keyof typeof payload] === "string"
339
  ) {
340
+ configLabelKey = payload[key as keyof typeof payload] as string;
341
  } else if (
342
  payloadPayload &&
343
  key in payloadPayload &&
 
345
  ) {
346
  configLabelKey = payloadPayload[
347
  key as keyof typeof payloadPayload
348
+ ] as string;
349
  }
350
 
351
  return configLabelKey in config
352
  ? config[configLabelKey]
353
+ : config[key as keyof typeof config];
354
  }
355
 
356
  export {
 
360
  ChartLegend,
361
  ChartLegendContent,
362
  ChartStyle,
363
+ };
components/ui/sidebar.tsx CHANGED
@@ -70,8 +70,6 @@ const SidebarProvider = React.forwardRef<
70
  const isMobile = useIsMobile();
71
  const [openMobile, setOpenMobile] = React.useState(false);
72
 
73
- // This is the internal state of the sidebar.
74
- // We use openProp and setOpenProp for control from outside the component.
75
  const [_open, _setOpen] = React.useState(defaultOpen);
76
  const open = openProp ?? _open;
77
  const setOpen = React.useCallback(
@@ -83,20 +81,17 @@ const SidebarProvider = React.forwardRef<
83
  _setOpen(openState);
84
  }
85
 
86
- // This sets the cookie to keep the sidebar state.
87
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
88
  },
89
  [setOpenProp, open]
90
  );
91
 
92
- // Helper to toggle the sidebar.
93
  const toggleSidebar = React.useCallback(() => {
94
  return isMobile
95
  ? setOpenMobile((open) => !open)
96
  : setOpen((open) => !open);
97
  }, [isMobile, setOpen, setOpenMobile]);
98
 
99
- // Adds a keyboard shortcut to toggle the sidebar.
100
  React.useEffect(() => {
101
  const handleKeyDown = (event: KeyboardEvent) => {
102
  if (
@@ -112,8 +107,6 @@ const SidebarProvider = React.forwardRef<
112
  return () => window.removeEventListener("keydown", handleKeyDown);
113
  }, [toggleSidebar]);
114
 
115
- // We add a state so that we can do data-state="expanded" or "collapsed".
116
- // This makes it easier to style the sidebar with Tailwind classes.
117
  const state = open ? "expanded" : "collapsed";
118
 
119
  const contextValue = React.useMemo<SidebarContext>(
@@ -221,7 +214,6 @@ const Sidebar = React.forwardRef<
221
  data-variant={variant}
222
  data-side={side}
223
  >
224
- {/* This is what handles the sidebar gap on desktop */}
225
  <div
226
  className={cn(
227
  "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
@@ -238,7 +230,6 @@ const Sidebar = React.forwardRef<
238
  side === "left"
239
  ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
240
  : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
241
- // Adjust the padding for floating and inset variants.
242
  variant === "floating" || variant === "inset"
243
  ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
244
  : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
@@ -650,7 +641,6 @@ const SidebarMenuSkeleton = React.forwardRef<
650
  showIcon?: boolean;
651
  }
652
  >(({ className, showIcon = false, ...props }, ref) => {
653
- // Random width between 50 to 90%.
654
  const width = React.useMemo(() => {
655
  return `${Math.floor(Math.random() * 40) + 50}%`;
656
  }, []);
 
70
  const isMobile = useIsMobile();
71
  const [openMobile, setOpenMobile] = React.useState(false);
72
 
 
 
73
  const [_open, _setOpen] = React.useState(defaultOpen);
74
  const open = openProp ?? _open;
75
  const setOpen = React.useCallback(
 
81
  _setOpen(openState);
82
  }
83
 
 
84
  document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
85
  },
86
  [setOpenProp, open]
87
  );
88
 
 
89
  const toggleSidebar = React.useCallback(() => {
90
  return isMobile
91
  ? setOpenMobile((open) => !open)
92
  : setOpen((open) => !open);
93
  }, [isMobile, setOpen, setOpenMobile]);
94
 
 
95
  React.useEffect(() => {
96
  const handleKeyDown = (event: KeyboardEvent) => {
97
  if (
 
107
  return () => window.removeEventListener("keydown", handleKeyDown);
108
  }, [toggleSidebar]);
109
 
 
 
110
  const state = open ? "expanded" : "collapsed";
111
 
112
  const contextValue = React.useMemo<SidebarContext>(
 
214
  data-variant={variant}
215
  data-side={side}
216
  >
 
217
  <div
218
  className={cn(
219
  "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
 
230
  side === "left"
231
  ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
232
  : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
 
233
  variant === "floating" || variant === "inset"
234
  ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
235
  : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
 
641
  showIcon?: boolean;
642
  }
643
  >(({ className, showIcon = false, ...props }, ref) => {
 
644
  const width = React.useMemo(() => {
645
  return `${Math.floor(Math.random() * 40) + 50}%`;
646
  }, []);
hooks/use-toast.ts CHANGED
@@ -1,78 +1,74 @@
1
- "use client"
2
 
3
- // Inspired by react-hot-toast library
4
- import * as React from "react"
5
 
6
- import type {
7
- ToastActionElement,
8
- ToastProps,
9
- } from "@/components/ui/toast"
10
 
11
- const TOAST_LIMIT = 1
12
- const TOAST_REMOVE_DELAY = 1000000
13
 
14
  type ToasterToast = ToastProps & {
15
- id: string
16
- title?: React.ReactNode
17
- description?: React.ReactNode
18
- action?: ToastActionElement
19
- }
20
 
21
  const actionTypes = {
22
  ADD_TOAST: "ADD_TOAST",
23
  UPDATE_TOAST: "UPDATE_TOAST",
24
  DISMISS_TOAST: "DISMISS_TOAST",
25
  REMOVE_TOAST: "REMOVE_TOAST",
26
- } as const
27
 
28
- let count = 0
29
 
30
  function genId() {
31
- count = (count + 1) % Number.MAX_SAFE_INTEGER
32
- return count.toString()
33
  }
34
 
35
- type ActionType = typeof actionTypes
36
 
37
  type Action =
38
  | {
39
- type: ActionType["ADD_TOAST"]
40
- toast: ToasterToast
41
  }
42
  | {
43
- type: ActionType["UPDATE_TOAST"]
44
- toast: Partial<ToasterToast>
45
  }
46
  | {
47
- type: ActionType["DISMISS_TOAST"]
48
- toastId?: ToasterToast["id"]
49
  }
50
  | {
51
- type: ActionType["REMOVE_TOAST"]
52
- toastId?: ToasterToast["id"]
53
- }
54
 
55
  interface State {
56
- toasts: ToasterToast[]
57
  }
58
 
59
- const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60
 
61
  const addToRemoveQueue = (toastId: string) => {
62
  if (toastTimeouts.has(toastId)) {
63
- return
64
  }
65
 
66
  const timeout = setTimeout(() => {
67
- toastTimeouts.delete(toastId)
68
  dispatch({
69
  type: "REMOVE_TOAST",
70
  toastId: toastId,
71
- })
72
- }, TOAST_REMOVE_DELAY)
73
 
74
- toastTimeouts.set(toastId, timeout)
75
- }
76
 
77
  export const reducer = (state: State, action: Action): State => {
78
  switch (action.type) {
@@ -80,7 +76,7 @@ export const reducer = (state: State, action: Action): State => {
80
  return {
81
  ...state,
82
  toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83
- }
84
 
85
  case "UPDATE_TOAST":
86
  return {
@@ -88,19 +84,17 @@ export const reducer = (state: State, action: Action): State => {
88
  toasts: state.toasts.map((t) =>
89
  t.id === action.toast.id ? { ...t, ...action.toast } : t
90
  ),
91
- }
92
 
93
  case "DISMISS_TOAST": {
94
- const { toastId } = action
95
 
96
- // ! Side effects ! - This could be extracted into a dismissToast() action,
97
- // but I'll keep it here for simplicity
98
  if (toastId) {
99
- addToRemoveQueue(toastId)
100
  } else {
101
  state.toasts.forEach((toast) => {
102
- addToRemoveQueue(toast.id)
103
- })
104
  }
105
 
106
  return {
@@ -113,44 +107,44 @@ export const reducer = (state: State, action: Action): State => {
113
  }
114
  : t
115
  ),
116
- }
117
  }
118
  case "REMOVE_TOAST":
119
  if (action.toastId === undefined) {
120
  return {
121
  ...state,
122
  toasts: [],
123
- }
124
  }
125
  return {
126
  ...state,
127
  toasts: state.toasts.filter((t) => t.id !== action.toastId),
128
- }
129
  }
130
- }
131
 
132
- const listeners: Array<(state: State) => void> = []
133
 
134
- let memoryState: State = { toasts: [] }
135
 
136
  function dispatch(action: Action) {
137
- memoryState = reducer(memoryState, action)
138
  listeners.forEach((listener) => {
139
- listener(memoryState)
140
- })
141
  }
142
 
143
- type Toast = Omit<ToasterToast, "id">
144
 
145
  function toast({ ...props }: Toast) {
146
- const id = genId()
147
 
148
  const update = (props: ToasterToast) =>
149
  dispatch({
150
  type: "UPDATE_TOAST",
151
  toast: { ...props, id },
152
- })
153
- const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154
 
155
  dispatch({
156
  type: "ADD_TOAST",
@@ -159,36 +153,36 @@ function toast({ ...props }: Toast) {
159
  id,
160
  open: true,
161
  onOpenChange: (open) => {
162
- if (!open) dismiss()
163
  },
164
  },
165
- })
166
 
167
  return {
168
  id: id,
169
  dismiss,
170
  update,
171
- }
172
  }
173
 
174
  function useToast() {
175
- const [state, setState] = React.useState<State>(memoryState)
176
 
177
  React.useEffect(() => {
178
- listeners.push(setState)
179
  return () => {
180
- const index = listeners.indexOf(setState)
181
  if (index > -1) {
182
- listeners.splice(index, 1)
183
  }
184
- }
185
- }, [state])
186
 
187
  return {
188
  ...state,
189
  toast,
190
  dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191
- }
192
  }
193
 
194
- export { useToast, toast }
 
1
+ "use client";
2
 
3
+ import * as React from "react";
 
4
 
5
+ import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
 
 
 
6
 
7
+ const TOAST_LIMIT = 1;
8
+ const TOAST_REMOVE_DELAY = 1000000;
9
 
10
  type ToasterToast = ToastProps & {
11
+ id: string;
12
+ title?: React.ReactNode;
13
+ description?: React.ReactNode;
14
+ action?: ToastActionElement;
15
+ };
16
 
17
  const actionTypes = {
18
  ADD_TOAST: "ADD_TOAST",
19
  UPDATE_TOAST: "UPDATE_TOAST",
20
  DISMISS_TOAST: "DISMISS_TOAST",
21
  REMOVE_TOAST: "REMOVE_TOAST",
22
+ } as const;
23
 
24
+ let count = 0;
25
 
26
  function genId() {
27
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
28
+ return count.toString();
29
  }
30
 
31
+ type ActionType = typeof actionTypes;
32
 
33
  type Action =
34
  | {
35
+ type: ActionType["ADD_TOAST"];
36
+ toast: ToasterToast;
37
  }
38
  | {
39
+ type: ActionType["UPDATE_TOAST"];
40
+ toast: Partial<ToasterToast>;
41
  }
42
  | {
43
+ type: ActionType["DISMISS_TOAST"];
44
+ toastId?: ToasterToast["id"];
45
  }
46
  | {
47
+ type: ActionType["REMOVE_TOAST"];
48
+ toastId?: ToasterToast["id"];
49
+ };
50
 
51
  interface State {
52
+ toasts: ToasterToast[];
53
  }
54
 
55
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
56
 
57
  const addToRemoveQueue = (toastId: string) => {
58
  if (toastTimeouts.has(toastId)) {
59
+ return;
60
  }
61
 
62
  const timeout = setTimeout(() => {
63
+ toastTimeouts.delete(toastId);
64
  dispatch({
65
  type: "REMOVE_TOAST",
66
  toastId: toastId,
67
+ });
68
+ }, TOAST_REMOVE_DELAY);
69
 
70
+ toastTimeouts.set(toastId, timeout);
71
+ };
72
 
73
  export const reducer = (state: State, action: Action): State => {
74
  switch (action.type) {
 
76
  return {
77
  ...state,
78
  toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
79
+ };
80
 
81
  case "UPDATE_TOAST":
82
  return {
 
84
  toasts: state.toasts.map((t) =>
85
  t.id === action.toast.id ? { ...t, ...action.toast } : t
86
  ),
87
+ };
88
 
89
  case "DISMISS_TOAST": {
90
+ const { toastId } = action;
91
 
 
 
92
  if (toastId) {
93
+ addToRemoveQueue(toastId);
94
  } else {
95
  state.toasts.forEach((toast) => {
96
+ addToRemoveQueue(toast.id);
97
+ });
98
  }
99
 
100
  return {
 
107
  }
108
  : t
109
  ),
110
+ };
111
  }
112
  case "REMOVE_TOAST":
113
  if (action.toastId === undefined) {
114
  return {
115
  ...state,
116
  toasts: [],
117
+ };
118
  }
119
  return {
120
  ...state,
121
  toasts: state.toasts.filter((t) => t.id !== action.toastId),
122
+ };
123
  }
124
+ };
125
 
126
+ const listeners: Array<(state: State) => void> = [];
127
 
128
+ let memoryState: State = { toasts: [] };
129
 
130
  function dispatch(action: Action) {
131
+ memoryState = reducer(memoryState, action);
132
  listeners.forEach((listener) => {
133
+ listener(memoryState);
134
+ });
135
  }
136
 
137
+ type Toast = Omit<ToasterToast, "id">;
138
 
139
  function toast({ ...props }: Toast) {
140
+ const id = genId();
141
 
142
  const update = (props: ToasterToast) =>
143
  dispatch({
144
  type: "UPDATE_TOAST",
145
  toast: { ...props, id },
146
+ });
147
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
148
 
149
  dispatch({
150
  type: "ADD_TOAST",
 
153
  id,
154
  open: true,
155
  onOpenChange: (open) => {
156
+ if (!open) dismiss();
157
  },
158
  },
159
+ });
160
 
161
  return {
162
  id: id,
163
  dismiss,
164
  update,
165
+ };
166
  }
167
 
168
  function useToast() {
169
+ const [state, setState] = React.useState<State>(memoryState);
170
 
171
  React.useEffect(() => {
172
+ listeners.push(setState);
173
  return () => {
174
+ const index = listeners.indexOf(setState);
175
  if (index > -1) {
176
+ listeners.splice(index, 1);
177
  }
178
+ };
179
+ }, [state]);
180
 
181
  return {
182
  ...state,
183
  toast,
184
  dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
185
+ };
186
  }
187
 
188
+ export { useToast, toast };
lib/auth.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
4
+
5
+ export function verifyApiToken(req: Request) {
6
+ if (!ACCESS_TOKEN) {
7
+ console.error("ACCESS_TOKEN is not set");
8
+ return NextResponse.json(
9
+ { error: "Server configuration error" },
10
+ { status: 500 }
11
+ );
12
+ }
13
+
14
+ const authHeader = req.headers.get("authorization");
15
+ const token = authHeader?.replace("Bearer ", "");
16
+
17
+ if (!token || token !== ACCESS_TOKEN) {
18
+ console.log("Unauthorized access attempt");
19
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20
+ }
21
+
22
+ return null;
23
+ }
lib/dayjs.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dayjs from "dayjs";
2
+ import utc from "dayjs/plugin/utc";
3
+ import timezone from "dayjs/plugin/timezone";
4
+
5
+ dayjs.extend(utc);
6
+ dayjs.extend(timezone);
7
+
8
+ const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
9
+ dayjs.tz.setDefault(localTimezone);
10
+
11
+ const originalFormat = dayjs.prototype.format;
12
+ dayjs.prototype.format = function (template: string) {
13
+ if (template === "YYYY-MM-DDTHH:mm:ssZ") {
14
+ return this.toISOString();
15
+ }
16
+ return originalFormat.call(this, template);
17
+ };
18
+
19
+ export default dayjs;
lib/db.ts DELETED
@@ -1,352 +0,0 @@
1
- import { Pool, PoolClient } from "pg";
2
-
3
- // 构建数据库连接配置
4
- const dbConfig = process.env.POSTGRES_URL
5
- ? {
6
- // 远程数据库配置
7
- connectionString: process.env.POSTGRES_URL,
8
- ssl: {
9
- rejectUnauthorized: false, // 允许自签名证书
10
- },
11
- }
12
- : {
13
- // 本地 Docker 数据库配置
14
- host: process.env.POSTGRES_HOST || "localhost",
15
- user: process.env.POSTGRES_USER || "postgres",
16
- password: process.env.POSTGRES_PASSWORD,
17
- database: process.env.POSTGRES_DATABASE || "openwebui_monitor",
18
- ssl: false,
19
- };
20
-
21
- // 创建连接池
22
- export const pool = new Pool(dbConfig);
23
-
24
- // 测试连接
25
- pool.on("error", (err) => {
26
- console.error("Unexpected error on idle client", err);
27
- process.exit(-1);
28
- });
29
-
30
- // 数据库行的类型定义
31
- interface ModelPriceRow {
32
- id: string;
33
- name: string;
34
- input_price: string | number;
35
- output_price: string | number;
36
- per_msg_price: string | number;
37
- updated_at: Date;
38
- }
39
-
40
- export interface ModelPrice {
41
- id: string;
42
- name: string;
43
- input_price: number;
44
- output_price: number;
45
- per_msg_price: number;
46
- updated_at: Date;
47
- }
48
-
49
- export interface UserUsageRecord {
50
- id: number;
51
- userId: number;
52
- nickname: string;
53
- useTime: Date;
54
- modelName: string;
55
- inputTokens: number;
56
- outputTokens: number;
57
- cost: number;
58
- balanceAfter: number;
59
- }
60
-
61
- // 确保表存在
62
- export async function ensureTablesExist() {
63
- let client: PoolClient | null = null;
64
- try {
65
- client = await pool.connect();
66
-
67
- // 首先创建 users 表
68
- await client.query(`
69
- CREATE TABLE IF NOT EXISTS users (
70
- id TEXT PRIMARY KEY,
71
- email TEXT NOT NULL,
72
- name TEXT NOT NULL,
73
- role TEXT NOT NULL DEFAULT 'user',
74
- balance DECIMAL(16, 6) NOT NULL DEFAULT 0,
75
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
76
- );
77
- `);
78
-
79
- // 获取默认价格
80
- const defaultInputPrice = parseFloat(
81
- process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
82
- );
83
- const defaultOutputPrice = parseFloat(
84
- process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
85
- );
86
- const defaultPerMsgPrice = parseFloat(
87
- process.env.DEFAULT_MODEL_PER_MSG_PRICE || "-1"
88
- );
89
-
90
- // 然后创建 model_prices 表,使用具体的默认值而不是参数绑定
91
- await client.query(`
92
- CREATE TABLE IF NOT EXISTS model_prices (
93
- id TEXT PRIMARY KEY,
94
- name TEXT NOT NULL,
95
- base_model_id TEXT,
96
- input_price NUMERIC(10, 6) DEFAULT ${defaultInputPrice},
97
- output_price NUMERIC(10, 6) DEFAULT ${defaultOutputPrice},
98
- per_msg_price NUMERIC(10, 6) DEFAULT ${defaultPerMsgPrice},
99
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
100
- );
101
- `);
102
-
103
- // 检查并添加 per_msg_price 列(如果不存在)
104
- await client.query(`
105
- DO $$
106
- BEGIN
107
- BEGIN
108
- ALTER TABLE model_prices
109
- ADD COLUMN per_msg_price NUMERIC(10, 6) DEFAULT ${defaultPerMsgPrice};
110
- EXCEPTION
111
- WHEN duplicate_column THEN NULL;
112
- END;
113
- END $$;
114
- `);
115
-
116
- // 检查并添加 base_model_id 列(如果不存在)
117
- await client.query(`
118
- DO $$
119
- BEGIN
120
- BEGIN
121
- ALTER TABLE model_prices
122
- ADD COLUMN base_model_id TEXT;
123
- EXCEPTION
124
- WHEN duplicate_column THEN NULL;
125
- END;
126
- END $$;
127
- `);
128
-
129
- // 最后创建 user_usage_records 表
130
- await client.query(`
131
- CREATE TABLE IF NOT EXISTS user_usage_records (
132
- id SERIAL PRIMARY KEY,
133
- user_id TEXT NOT NULL,
134
- nickname VARCHAR(255) NOT NULL,
135
- use_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
136
- model_name VARCHAR(255) NOT NULL,
137
- input_tokens INTEGER NOT NULL,
138
- output_tokens INTEGER NOT NULL,
139
- cost DECIMAL(10, 4) NOT NULL,
140
- balance_after DECIMAL(10, 4) NOT NULL,
141
- FOREIGN KEY (user_id) REFERENCES users(id)
142
- );
143
- `);
144
- } catch (error) {
145
- console.error("Database connection/initialization error:", error);
146
- throw error;
147
- } finally {
148
- if (client) {
149
- client.release();
150
- }
151
- }
152
- }
153
-
154
- // 获取模型价格,如果不存在则创建默认值
155
- export async function getOrCreateModelPrices(
156
- models: Array<{ id: string; name: string; base_model_id?: string }>
157
- ): Promise<ModelPrice[]> {
158
- let client: PoolClient | null = null;
159
- try {
160
- client = await pool.connect();
161
-
162
- // 获取默认价格
163
- const defaultInputPrice = parseFloat(
164
- process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
165
- );
166
- const defaultOutputPrice = parseFloat(
167
- process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
168
- );
169
- const defaultPerMsgPrice = parseFloat(
170
- process.env.DEFAULT_MODEL_PER_MSG_PRICE || "-1"
171
- );
172
-
173
- // 1. 首先获取所有已存在的模型价格
174
- const modelIds = models.map((m) => m.id);
175
- const baseModelIds = models.map((m) => m.base_model_id).filter((id) => id);
176
-
177
- const existingModelsResult = await client.query<ModelPriceRow>(
178
- `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
179
- [modelIds]
180
- );
181
-
182
- // 2. 获取所有基础模型的价格
183
- const baseModelsResult = await client.query<ModelPriceRow>(
184
- `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
185
- [baseModelIds]
186
- );
187
-
188
- const existingModels = new Map(
189
- existingModelsResult.rows.map((row) => [row.id, row])
190
- );
191
- const baseModels = new Map(
192
- baseModelsResult.rows.map((row) => [row.id, row])
193
- );
194
-
195
- // 3. 更新所有模型的名称并插入缺失的模型
196
- const modelsToUpdate = models.filter((m) => existingModels.has(m.id));
197
- const missingModels = models.filter((m) => !existingModels.has(m.id));
198
-
199
- // 3.1 更新现有模型的名称
200
- if (modelsToUpdate.length > 0) {
201
- for (const model of modelsToUpdate) {
202
- await client.query(`UPDATE model_prices SET name = $2 WHERE id = $1`, [
203
- model.id,
204
- model.name,
205
- ]);
206
- }
207
- }
208
-
209
- // 3.2 插入缺失的模型
210
- if (missingModels.length > 0) {
211
- const values = missingModels.map((m) => {
212
- const baseModel = m.base_model_id
213
- ? baseModels.get(m.base_model_id)
214
- : null;
215
- return [
216
- m.id,
217
- m.name,
218
- baseModel?.input_price ?? defaultInputPrice,
219
- baseModel?.output_price ?? defaultOutputPrice,
220
- baseModel?.per_msg_price ?? defaultPerMsgPrice,
221
- ];
222
- });
223
-
224
- const placeholders = values
225
- .map(
226
- (_, i) =>
227
- `($${i * 5 + 1}, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${
228
- i * 5 + 5
229
- })`
230
- )
231
- .join(",");
232
-
233
- const result = await client.query<ModelPriceRow>(
234
- `INSERT INTO model_prices (id, name, input_price, output_price, per_msg_price)
235
- VALUES ${placeholders}
236
- RETURNING *`,
237
- values.flat()
238
- );
239
-
240
- result.rows.forEach((row) => existingModels.set(row.id, row));
241
- }
242
-
243
- // 4. 重新获取所有模型的最新数据
244
- const updatedModelsResult = await client.query<ModelPriceRow>(
245
- `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
246
- [modelIds]
247
- );
248
-
249
- const updatedModels = new Map(
250
- updatedModelsResult.rows.map((row) => [row.id, row])
251
- );
252
-
253
- return models.map((m) => {
254
- const row = updatedModels.get(m.id)!;
255
- return {
256
- id: row.id,
257
- name: row.name,
258
- input_price: Number(row.input_price),
259
- output_price: Number(row.output_price),
260
- per_msg_price: Number(row.per_msg_price),
261
- updated_at: row.updated_at,
262
- };
263
- });
264
- } catch (error) {
265
- console.error("Error in getOrCreateModelPrices:", error);
266
- throw error;
267
- } finally {
268
- if (client) {
269
- client.release();
270
- }
271
- }
272
- }
273
-
274
- // 更新模型价格
275
- export async function updateModelPrice(
276
- id: string,
277
- input_price: number,
278
- output_price: number,
279
- per_msg_price: number
280
- ): Promise<ModelPrice | null> {
281
- let client: PoolClient | null = null;
282
- try {
283
- client = await pool.connect();
284
-
285
- // 使用 CAST 确保数据类型正确
286
- const result = await client.query<ModelPriceRow>(
287
- `UPDATE model_prices
288
- SET
289
- input_price = CAST($2 AS NUMERIC(10,6)),
290
- output_price = CAST($3 AS NUMERIC(10,6)),
291
- per_msg_price = CAST($4 AS NUMERIC(10,6)),
292
- updated_at = CURRENT_TIMESTAMP
293
- WHERE id = $1
294
- RETURNING *`,
295
- [id, input_price, output_price, per_msg_price]
296
- );
297
-
298
- if (result.rows[0]) {
299
- return {
300
- id: result.rows[0].id,
301
- name: result.rows[0].name,
302
- input_price: Number(result.rows[0].input_price),
303
- output_price: Number(result.rows[0].output_price),
304
- per_msg_price: Number(result.rows[0].per_msg_price),
305
- updated_at: result.rows[0].updated_at,
306
- };
307
- }
308
- return null;
309
- } catch (error) {
310
- console.error(`Failed to update ${id} price:`, error);
311
- throw error;
312
- } finally {
313
- if (client) {
314
- client.release();
315
- }
316
- }
317
- }
318
-
319
- // 添加一个始化函数
320
- export async function initDatabase() {
321
- try {
322
- await ensureTablesExist();
323
- // console.log("Database initialized successfully");
324
- } catch (error) {
325
- console.error("Failed to initialize database:", error);
326
- throw error;
327
- }
328
- }
329
-
330
- // 更新用户余额
331
- export async function updateUserBalance(userId: string, balance: number) {
332
- let client: PoolClient | null = null;
333
- try {
334
- client = await pool.connect();
335
- const result = await client.query(
336
- `UPDATE users
337
- SET balance = $2
338
- WHERE id = $1
339
- RETURNING id, email, balance`,
340
- [userId, balance]
341
- );
342
-
343
- return result.rows[0];
344
- } catch (error) {
345
- console.error("Error in updateUserBalance:", error);
346
- throw error;
347
- } finally {
348
- if (client) {
349
- client.release();
350
- }
351
- }
352
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/db/client.ts CHANGED
@@ -6,7 +6,6 @@ import { Pool, PoolClient } from "pg";
6
 
7
  const isVercel = process.env.VERCEL === "1";
8
 
9
- // 为 Vercel 环境添加连接池
10
  let vercelPool: {
11
  client: ReturnType<typeof createClient>;
12
  isConnected: boolean;
@@ -21,7 +20,6 @@ async function getVercelClient() {
21
  };
22
  }
23
 
24
- // 如果还没连接,则建立连接
25
  if (!vercelPool.isConnected) {
26
  try {
27
  await vercelPool.client.connect();
@@ -48,7 +46,8 @@ function getClient() {
48
  port: parseInt(process.env.POSTGRES_PORT || "5432"),
49
  max: 20,
50
  idleTimeoutMillis: 30000,
51
- connectionTimeoutMillis: 2000,
 
52
  };
53
 
54
  if (process.env.POSTGRES_URL) {
@@ -59,7 +58,8 @@ function getClient() {
59
  },
60
  max: 20,
61
  idleTimeoutMillis: 30000,
62
- connectionTimeoutMillis: 2000,
 
63
  });
64
  } else {
65
  pgPool = new Pool(config);
@@ -74,13 +74,11 @@ function getClient() {
74
  }
75
  }
76
 
77
- // 定义一个通用的查询结果类型
78
  type CommonQueryResult<T = any> = {
79
  rows: T[];
80
  rowCount: number;
81
  };
82
 
83
- // 导出一个通用的查询函数
84
  export async function query<T = any>(
85
  text: string,
86
  params?: any[]
@@ -100,7 +98,6 @@ export async function query<T = any>(
100
  };
101
  } catch (error) {
102
  console.error("[DB Query Error]", error);
103
- // 如果连接出错,重置连接状态
104
  if (vercelPool) {
105
  vercelPool.isConnected = false;
106
  }
@@ -128,7 +125,6 @@ export async function query<T = any>(
128
  }
129
  }
130
 
131
- // 确保在应用关闭时清理连接
132
  if (typeof window === "undefined") {
133
  process.on("SIGTERM", async () => {
134
  console.log("SIGTERM received, closing database connections");
@@ -143,3 +139,347 @@ if (typeof window === "undefined") {
143
  }
144
 
145
  export { getClient };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  const isVercel = process.env.VERCEL === "1";
8
 
 
9
  let vercelPool: {
10
  client: ReturnType<typeof createClient>;
11
  isConnected: boolean;
 
20
  };
21
  }
22
 
 
23
  if (!vercelPool.isConnected) {
24
  try {
25
  await vercelPool.client.connect();
 
46
  port: parseInt(process.env.POSTGRES_PORT || "5432"),
47
  max: 20,
48
  idleTimeoutMillis: 30000,
49
+ connectionTimeoutMillis: 30000,
50
+ statement_timeout: 30000,
51
  };
52
 
53
  if (process.env.POSTGRES_URL) {
 
58
  },
59
  max: 20,
60
  idleTimeoutMillis: 30000,
61
+ connectionTimeoutMillis: 30000,
62
+ statement_timeout: 30000,
63
  });
64
  } else {
65
  pgPool = new Pool(config);
 
74
  }
75
  }
76
 
 
77
  type CommonQueryResult<T = any> = {
78
  rows: T[];
79
  rowCount: number;
80
  };
81
 
 
82
  export async function query<T = any>(
83
  text: string,
84
  params?: any[]
 
98
  };
99
  } catch (error) {
100
  console.error("[DB Query Error]", error);
 
101
  if (vercelPool) {
102
  vercelPool.isConnected = false;
103
  }
 
125
  }
126
  }
127
 
 
128
  if (typeof window === "undefined") {
129
  process.on("SIGTERM", async () => {
130
  console.log("SIGTERM received, closing database connections");
 
139
  }
140
 
141
  export { getClient };
142
+
143
+ export async function ensureTablesExist() {
144
+ try {
145
+ const usersTableExists = await query(`
146
+ SELECT EXISTS (
147
+ SELECT FROM information_schema.tables
148
+ WHERE table_name = 'users'
149
+ );
150
+ `);
151
+
152
+ if (!usersTableExists.rows[0].exists) {
153
+ await query(`
154
+ CREATE TABLE IF NOT EXISTS users (
155
+ id TEXT PRIMARY KEY,
156
+ email TEXT NOT NULL,
157
+ name TEXT NOT NULL,
158
+ role TEXT NOT NULL DEFAULT 'user',
159
+ balance DECIMAL(16, 6) NOT NULL DEFAULT 0,
160
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
161
+ deleted BOOLEAN DEFAULT FALSE
162
+ );
163
+ `);
164
+ } else {
165
+ try {
166
+ await query(`
167
+ DO $$
168
+ BEGIN
169
+ BEGIN
170
+ ALTER TABLE users
171
+ ADD COLUMN deleted BOOLEAN DEFAULT FALSE;
172
+ EXCEPTION
173
+ WHEN duplicate_column THEN NULL;
174
+ END;
175
+ END $$;
176
+ `);
177
+ } catch (error) {
178
+ console.error("Error adding deleted column to users table:", error);
179
+ }
180
+ }
181
+
182
+ const modelPricesTableExists = await query(`
183
+ SELECT EXISTS (
184
+ SELECT FROM information_schema.tables
185
+ WHERE table_name = 'model_prices'
186
+ );
187
+ `);
188
+
189
+ const defaultInputPrice = parseFloat(
190
+ process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
191
+ );
192
+ const defaultOutputPrice = parseFloat(
193
+ process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
194
+ );
195
+ const defaultPerMsgPrice = parseFloat(
196
+ process.env.DEFAULT_MODEL_PER_MSG_PRICE || "-1"
197
+ );
198
+
199
+ if (!modelPricesTableExists.rows[0].exists) {
200
+ await query(`
201
+ CREATE TABLE IF NOT EXISTS model_prices (
202
+ id TEXT PRIMARY KEY,
203
+ name TEXT NOT NULL,
204
+ base_model_id TEXT,
205
+ input_price NUMERIC(10, 6) DEFAULT ${defaultInputPrice},
206
+ output_price NUMERIC(10, 6) DEFAULT ${defaultOutputPrice},
207
+ per_msg_price NUMERIC(10, 6) DEFAULT ${defaultPerMsgPrice},
208
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
209
+ );
210
+ `);
211
+ } else {
212
+ try {
213
+ await query(`
214
+ DO $$
215
+ BEGIN
216
+ BEGIN
217
+ ALTER TABLE model_prices
218
+ ADD COLUMN per_msg_price NUMERIC(10, 6) DEFAULT ${defaultPerMsgPrice};
219
+ EXCEPTION
220
+ WHEN duplicate_column THEN NULL;
221
+ END;
222
+ END $$;
223
+ `);
224
+ } catch (error) {
225
+ console.error("Error adding per_msg_price column:", error);
226
+ }
227
+
228
+ try {
229
+ await query(`
230
+ DO $$
231
+ BEGIN
232
+ BEGIN
233
+ ALTER TABLE model_prices
234
+ ADD COLUMN base_model_id TEXT;
235
+ EXCEPTION
236
+ WHEN duplicate_column THEN NULL;
237
+ END;
238
+ END $$;
239
+ `);
240
+ } catch (error) {
241
+ console.error("Error adding base_model_id column:", error);
242
+ }
243
+ }
244
+
245
+ const userUsageRecordsTableExists = await query(`
246
+ SELECT EXISTS (
247
+ SELECT FROM information_schema.tables
248
+ WHERE table_name = 'user_usage_records'
249
+ );
250
+ `);
251
+
252
+ if (!userUsageRecordsTableExists.rows[0].exists) {
253
+ await query(`
254
+ CREATE TABLE IF NOT EXISTS user_usage_records (
255
+ id SERIAL PRIMARY KEY,
256
+ user_id TEXT NOT NULL,
257
+ nickname VARCHAR(255) NOT NULL,
258
+ use_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
259
+ model_name VARCHAR(255) NOT NULL,
260
+ input_tokens INTEGER NOT NULL,
261
+ output_tokens INTEGER NOT NULL,
262
+ cost DECIMAL(10, 4) NOT NULL,
263
+ balance_after DECIMAL(10, 4) NOT NULL,
264
+ FOREIGN KEY (user_id) REFERENCES users(id)
265
+ );
266
+ `);
267
+ }
268
+
269
+ console.log("Database tables initialized successfully");
270
+ } catch (error) {
271
+ console.error("Failed to initialize database tables:", error);
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ export async function initDatabase() {
277
+ try {
278
+ await ensureTablesExist();
279
+ console.log("Database initialized successfully");
280
+ } catch (error) {
281
+ console.error("Failed to initialize database:", error);
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ export interface ModelPrice {
287
+ id: string;
288
+ name: string;
289
+ input_price: number;
290
+ output_price: number;
291
+ per_msg_price: number;
292
+ updated_at: Date;
293
+ }
294
+
295
+ export interface UserUsageRecord {
296
+ id: number;
297
+ userId: number;
298
+ nickname: string;
299
+ useTime: Date;
300
+ modelName: string;
301
+ inputTokens: number;
302
+ outputTokens: number;
303
+ cost: number;
304
+ balanceAfter: number;
305
+ }
306
+
307
+ export async function getOrCreateModelPrices(
308
+ models: Array<{ id: string; name: string; base_model_id?: string }>
309
+ ): Promise<ModelPrice[]> {
310
+ try {
311
+ const defaultInputPrice = parseFloat(
312
+ process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
313
+ );
314
+ const defaultOutputPrice = parseFloat(
315
+ process.env.DEFAULT_MODEL_OUTPUT_PRICE || "60"
316
+ );
317
+ const defaultPerMsgPrice = parseFloat(
318
+ process.env.DEFAULT_MODEL_PER_MSG_PRICE || "-1"
319
+ );
320
+
321
+ const modelIds = models.map((m) => m.id);
322
+ const baseModelIds = models.map((m) => m.base_model_id).filter((id) => id);
323
+
324
+ const existingModelsResult = await query(
325
+ `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
326
+ [modelIds]
327
+ );
328
+
329
+ const baseModelsResult = await query(
330
+ `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
331
+ [baseModelIds]
332
+ );
333
+
334
+ const existingModels = new Map(
335
+ existingModelsResult.rows.map((row) => [row.id, row])
336
+ );
337
+ const baseModels = new Map(
338
+ baseModelsResult.rows.map((row) => [row.id, row])
339
+ );
340
+
341
+ const modelsToUpdate = models.filter((m) => existingModels.has(m.id));
342
+ const missingModels = models.filter((m) => !existingModels.has(m.id));
343
+
344
+ if (modelsToUpdate.length > 0) {
345
+ for (const model of modelsToUpdate) {
346
+ await query(`UPDATE model_prices SET name = $2 WHERE id = $1`, [
347
+ model.id,
348
+ model.name,
349
+ ]);
350
+ }
351
+ }
352
+
353
+ if (missingModels.length > 0) {
354
+ for (const model of missingModels) {
355
+ const baseModel = model.base_model_id
356
+ ? baseModels.get(model.base_model_id)
357
+ : null;
358
+
359
+ await query(
360
+ `INSERT INTO model_prices (id, name, input_price, output_price, per_msg_price)
361
+ VALUES ($1, $2, $3, $4, $5)
362
+ RETURNING *`,
363
+ [
364
+ model.id,
365
+ model.name,
366
+ baseModel?.input_price ?? defaultInputPrice,
367
+ baseModel?.output_price ?? defaultOutputPrice,
368
+ baseModel?.per_msg_price ?? defaultPerMsgPrice,
369
+ ]
370
+ );
371
+ }
372
+ }
373
+
374
+ const updatedModelsResult = await query(
375
+ `SELECT * FROM model_prices WHERE id = ANY($1::text[])`,
376
+ [modelIds]
377
+ );
378
+
379
+ return updatedModelsResult.rows.map((row) => ({
380
+ id: row.id,
381
+ name: row.name,
382
+ input_price: Number(row.input_price),
383
+ output_price: Number(row.output_price),
384
+ per_msg_price: Number(row.per_msg_price),
385
+ updated_at: row.updated_at,
386
+ }));
387
+ } catch (error) {
388
+ console.error("Error in getOrCreateModelPrices:", error);
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ export async function updateModelPrice(
394
+ id: string,
395
+ input_price: number,
396
+ output_price: number,
397
+ per_msg_price: number
398
+ ): Promise<ModelPrice | null> {
399
+ try {
400
+ const result = await query(
401
+ `UPDATE model_prices
402
+ SET
403
+ input_price = CAST($2 AS NUMERIC(10,6)),
404
+ output_price = CAST($3 AS NUMERIC(10,6)),
405
+ per_msg_price = CAST($4 AS NUMERIC(10,6)),
406
+ updated_at = CURRENT_TIMESTAMP
407
+ WHERE id = $1
408
+ RETURNING *`,
409
+ [id, input_price, output_price, per_msg_price]
410
+ );
411
+
412
+ if (result.rows[0]) {
413
+ return {
414
+ id: result.rows[0].id,
415
+ name: result.rows[0].model_name,
416
+ input_price: Number(result.rows[0].input_price),
417
+ output_price: Number(result.rows[0].output_price),
418
+ per_msg_price: Number(result.rows[0].per_msg_price),
419
+ updated_at: result.rows[0].updated_at,
420
+ };
421
+ }
422
+ return null;
423
+ } catch (error) {
424
+ console.error("Error updating model price:", error);
425
+ throw error;
426
+ }
427
+ }
428
+
429
+ export async function updateUserBalance(userId: string, balance: number) {
430
+ try {
431
+ const result = await query(
432
+ `UPDATE users
433
+ SET balance = $2
434
+ WHERE id = $1
435
+ RETURNING id, email, balance`,
436
+ [userId, balance]
437
+ );
438
+
439
+ return result.rows[0];
440
+ } catch (error) {
441
+ console.error("Error in updateUserBalance:", error);
442
+ throw error;
443
+ }
444
+ }
445
+
446
+ export const pool = {
447
+ connect: async () => {
448
+ if (isVercel) {
449
+ return {
450
+ query: async (text: string, params?: any[]) => {
451
+ const client = await getVercelClient();
452
+ const result = await client.query({
453
+ text,
454
+ values: params || [],
455
+ });
456
+ return result;
457
+ },
458
+ release: () => {},
459
+ };
460
+ } else {
461
+ return (pgPool || (getClient() as Pool)).connect();
462
+ }
463
+ },
464
+ query: async (text: string, params?: any[]) => {
465
+ if (isVercel) {
466
+ const client = await getVercelClient();
467
+ return client.query({
468
+ text,
469
+ values: params || [],
470
+ });
471
+ } else {
472
+ return (pgPool || (getClient() as Pool)).query(text, params);
473
+ }
474
+ },
475
+ end: async () => {
476
+ if (isVercel) {
477
+ if (vercelPool?.client) {
478
+ await vercelPool.client.end();
479
+ vercelPool.isConnected = false;
480
+ }
481
+ } else if (pgPool) {
482
+ await pgPool.end();
483
+ }
484
+ },
485
+ };
lib/db/index.ts CHANGED
@@ -1,8 +1,7 @@
1
  import { query } from "./client";
2
  import { ensureUserTableExists } from "./users";
3
- import { ModelPrice } from "../db";
4
 
5
- // 创建模型价格表
6
  async function ensureModelPricesTableExists() {
7
  const defaultInputPrice = parseFloat(
8
  process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
@@ -16,7 +15,7 @@ async function ensureModelPricesTableExists() {
16
 
17
  await query(
18
  `CREATE TABLE IF NOT EXISTS model_prices (
19
- model_id TEXT PRIMARY KEY,
20
  model_name TEXT NOT NULL,
21
  input_price DECIMAL(10, 6) DEFAULT CAST($1 AS DECIMAL(10, 6)),
22
  output_price DECIMAL(10, 6) DEFAULT CAST($2 AS DECIMAL(10, 6)),
@@ -26,7 +25,6 @@ async function ensureModelPricesTableExists() {
26
  [defaultInputPrice, defaultOutputPrice, defaultPerMsgPrice]
27
  );
28
 
29
- // 为现有记录添加 per_msg_price 字段(如果不存在)
30
  await query(
31
  `DO $$
32
  BEGIN
@@ -56,16 +54,16 @@ export async function getOrCreateModelPrice(
56
  );
57
 
58
  const result = await query(
59
- `INSERT INTO model_prices (model_id, model_name, per_msg_price, updated_at)
60
  VALUES ($1, $2, CAST($3 AS DECIMAL(10, 6)), CURRENT_TIMESTAMP)
61
- ON CONFLICT (model_id) DO UPDATE
62
  SET model_name = $2, updated_at = CURRENT_TIMESTAMP
63
  RETURNING *`,
64
  [id, name, defaultPerMsgPrice]
65
  );
66
 
67
  return {
68
- id: result.rows[0].model_id,
69
  name: result.rows[0].model_name,
70
  input_price: Number(result.rows[0].input_price),
71
  output_price: Number(result.rows[0].output_price),
@@ -83,37 +81,6 @@ export async function getOrCreateModelPrice(
83
  }
84
  }
85
 
86
- export async function updateModelPrice(
87
- modelId: string,
88
- input_price: number,
89
- output_price: number,
90
- per_msg_price: number
91
- ): Promise<ModelPrice | null> {
92
- const result = await query(
93
- `UPDATE model_prices
94
- SET
95
- input_price = CAST($2 AS DECIMAL(10,6)),
96
- output_price = CAST($3 AS DECIMAL(10,6)),
97
- per_msg_price = CAST($4 AS DECIMAL(10,6)),
98
- updated_at = CURRENT_TIMESTAMP
99
- WHERE model_id = $1
100
- RETURNING *;`,
101
- [modelId, input_price, output_price, per_msg_price]
102
- );
103
-
104
- if (result.rows.length === 0) {
105
- return null;
106
- }
107
-
108
- return {
109
- id: result.rows[0].model_id,
110
- name: result.rows[0].model_name,
111
- input_price: Number(result.rows[0].input_price),
112
- output_price: Number(result.rows[0].output_price),
113
- per_msg_price: Number(result.rows[0].per_msg_price),
114
- updated_at: result.rows[0].updated_at,
115
- };
116
- }
117
  export {
118
  getUsers,
119
  getOrCreateUser,
 
1
  import { query } from "./client";
2
  import { ensureUserTableExists } from "./users";
3
+ import { ModelPrice, updateModelPrice } from "./client";
4
 
 
5
  async function ensureModelPricesTableExists() {
6
  const defaultInputPrice = parseFloat(
7
  process.env.DEFAULT_MODEL_INPUT_PRICE || "60"
 
15
 
16
  await query(
17
  `CREATE TABLE IF NOT EXISTS model_prices (
18
+ id TEXT PRIMARY KEY,
19
  model_name TEXT NOT NULL,
20
  input_price DECIMAL(10, 6) DEFAULT CAST($1 AS DECIMAL(10, 6)),
21
  output_price DECIMAL(10, 6) DEFAULT CAST($2 AS DECIMAL(10, 6)),
 
25
  [defaultInputPrice, defaultOutputPrice, defaultPerMsgPrice]
26
  );
27
 
 
28
  await query(
29
  `DO $$
30
  BEGIN
 
54
  );
55
 
56
  const result = await query(
57
+ `INSERT INTO model_prices (id, model_name, per_msg_price, updated_at)
58
  VALUES ($1, $2, CAST($3 AS DECIMAL(10, 6)), CURRENT_TIMESTAMP)
59
+ ON CONFLICT (id) DO UPDATE
60
  SET model_name = $2, updated_at = CURRENT_TIMESTAMP
61
  RETURNING *`,
62
  [id, name, defaultPerMsgPrice]
63
  );
64
 
65
  return {
66
+ id: result.rows[0].id,
67
  name: result.rows[0].model_name,
68
  input_price: Number(result.rows[0].input_price),
69
  output_price: Number(result.rows[0].output_price),
 
81
  }
82
  }
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  export {
85
  getUsers,
86
  getOrCreateUser,
lib/utils/inlet-cost.ts CHANGED
@@ -5,13 +5,11 @@ interface ModelInletCost {
5
  function parseInletCostConfig(config: string | undefined): ModelInletCost {
6
  if (!config) return {};
7
 
8
- // 如果配置是一个数字,对所有模型使用相同的预扣费
9
  const numericValue = Number(config);
10
  if (!isNaN(numericValue)) {
11
  return { default: numericValue };
12
  }
13
 
14
- // 否则解析 model1:0.32,model2:0.01 格式
15
  try {
16
  const costs: ModelInletCost = {};
17
  config.split(",").forEach((pair) => {
 
5
  function parseInletCostConfig(config: string | undefined): ModelInletCost {
6
  if (!config) return {};
7
 
 
8
  const numericValue = Number(config);
9
  if (!isNaN(numericValue)) {
10
  return { default: numericValue };
11
  }
12
 
 
13
  try {
14
  const costs: ModelInletCost = {};
15
  config.split(",").forEach((pair) => {
lib/version.ts CHANGED
@@ -1,2 +1 @@
1
- // 从环境变量获取版本号
2
  export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.1";
 
 
1
  export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.1";
middleware.ts CHANGED
@@ -7,15 +7,24 @@ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
7
  export async function middleware(request: NextRequest) {
8
  const { pathname } = request.nextUrl;
9
 
10
- // 只验证 inlet/outlet/test API 请求
11
  if (
12
  pathname.startsWith("/api/v1/inlet") ||
13
  pathname.startsWith("/api/v1/outlet") ||
14
- pathname.startsWith("/api/v1/models/test")
 
 
 
15
  ) {
16
- // API 请求验证
17
- if (!API_KEY) {
18
- console.error("API Key is not set");
 
 
 
 
 
 
 
19
  return NextResponse.json(
20
  { error: "Server configuration error" },
21
  { status: 500 }
@@ -25,14 +34,13 @@ export async function middleware(request: NextRequest) {
25
  const authHeader = request.headers.get("authorization");
26
  const providedKey = authHeader?.replace("Bearer ", "");
27
 
28
- if (!providedKey || providedKey !== API_KEY) {
29
- console.log("Invalid API key");
30
- return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
31
  }
32
 
33
  return NextResponse.next();
34
  } else if (!pathname.startsWith("/api/")) {
35
- // 页面访问验证
36
  if (!ACCESS_TOKEN) {
37
  console.error("ACCESS_TOKEN is not set");
38
  return NextResponse.json(
@@ -41,12 +49,10 @@ export async function middleware(request: NextRequest) {
41
  );
42
  }
43
 
44
- // 如果是令牌验证页面,直接允许访问
45
  if (pathname === "/token") {
46
  return NextResponse.next();
47
  }
48
 
49
- // 添加 no-store 和 no-cache 头,防止 Cloudflare 缓存
50
  const response = NextResponse.next();
51
  response.headers.set(
52
  "Cache-Control",
@@ -57,14 +63,14 @@ export async function middleware(request: NextRequest) {
57
 
58
  return response;
59
  } else if (pathname.startsWith("/api/config/key")) {
60
- // 确保这个路径不被中间件拦截
 
61
  return NextResponse.next();
62
  }
63
 
64
  return NextResponse.next();
65
  }
66
 
67
- // 配置中间件匹配的路由
68
  export const config = {
69
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
70
  };
 
7
  export async function middleware(request: NextRequest) {
8
  const { pathname } = request.nextUrl;
9
 
 
10
  if (
11
  pathname.startsWith("/api/v1/inlet") ||
12
  pathname.startsWith("/api/v1/outlet") ||
13
+ pathname.startsWith("/api/v1/models") ||
14
+ pathname.startsWith("/api/v1/panel") ||
15
+ pathname.startsWith("/api/v1/config") ||
16
+ pathname.startsWith("/api/v1/users")
17
  ) {
18
+ const token =
19
+ pathname.startsWith("/api/v1/panel") ||
20
+ pathname.startsWith("/api/v1/config") ||
21
+ pathname.startsWith("/api/v1/users") ||
22
+ pathname.startsWith("/api/v1/models")
23
+ ? ACCESS_TOKEN
24
+ : API_KEY;
25
+
26
+ if (!token) {
27
+ console.error("API Key or Access Token is not set");
28
  return NextResponse.json(
29
  { error: "Server configuration error" },
30
  { status: 500 }
 
34
  const authHeader = request.headers.get("authorization");
35
  const providedKey = authHeader?.replace("Bearer ", "");
36
 
37
+ if (!providedKey || providedKey !== token) {
38
+ console.log("Invalid API key or token");
39
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
40
  }
41
 
42
  return NextResponse.next();
43
  } else if (!pathname.startsWith("/api/")) {
 
44
  if (!ACCESS_TOKEN) {
45
  console.error("ACCESS_TOKEN is not set");
46
  return NextResponse.json(
 
49
  );
50
  }
51
 
 
52
  if (pathname === "/token") {
53
  return NextResponse.next();
54
  }
55
 
 
56
  const response = NextResponse.next();
57
  response.headers.set(
58
  "Cache-Control",
 
63
 
64
  return response;
65
  } else if (pathname.startsWith("/api/config/key")) {
66
+ return NextResponse.next();
67
+ } else if (pathname.startsWith("/api/init")) {
68
  return NextResponse.next();
69
  }
70
 
71
  return NextResponse.next();
72
  }
73
 
 
74
  export const config = {
75
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
76
  };
package.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "$schema": "https://json.schemastore.org/package.json",
3
  "name": "openwebui-usage-monitor",
4
- "version": "0.3.5",
5
  "private": true,
6
  "scripts": {
7
  "dev": "next dev",
 
1
  {
2
  "$schema": "https://json.schemastore.org/package.json",
3
  "name": "openwebui-usage-monitor",
4
+ "version": "0.3.7",
5
  "private": true,
6
  "scripts": {
7
  "dev": "next dev",