Spaces:
Sleeping
Sleeping
25:05:05 10:41:39 v0.3.7
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- .github/workflows/docker-publish.yml +80 -0
- VERSION +1 -0
- app/api/config/route.ts +0 -23
- app/api/init/route.ts +22 -0
- app/api/{config → v1/config}/key/route.ts +7 -2
- app/api/v1/config/route.ts +14 -0
- app/api/v1/inlet/route.ts +0 -3
- app/api/v1/models/price/route.ts +7 -4
- app/api/v1/models/route.ts +65 -35
- app/api/v1/models/sync-all-prices/route.ts +7 -5
- app/api/v1/models/sync-price/route.ts +7 -6
- app/api/v1/models/test/route.ts +6 -0
- app/api/v1/outlet/route.ts +2 -17
- app/api/v1/panel/database/export/route.ts +10 -21
- app/api/v1/panel/database/import/route.ts +16 -26
- app/api/v1/panel/records/export/route.ts +9 -13
- app/api/v1/panel/records/route.ts +20 -15
- app/api/v1/panel/usage/route.ts +16 -5
- app/api/{users → v1/users}/[id]/balance/route.ts +11 -1
- app/api/{users → v1/users}/[id]/route.ts +11 -0
- app/api/{users → v1/users}/route.ts +6 -4
- app/apple-icon.png +0 -0
- app/globals.css +0 -3
- app/icon.png +0 -0
- app/models/page.tsx +22 -17
- app/page.tsx +0 -15
- app/panel/page.tsx +29 -16
- app/records/page.tsx +0 -2
- app/token/page.tsx +1 -4
- app/users/page.tsx +34 -12
- components/AuthCheck.tsx +13 -3
- components/Header.tsx +2 -20
- components/editable-cell.tsx +0 -1
- components/panel/TimeRangeSelector.tsx +1 -1
- components/panel/UsageRecordsTable.tsx +1 -3
- components/panel/UserRankingChart.tsx +1 -3
- components/ui/animated-grid-pattern.tsx +0 -4
- components/ui/chart.tsx +71 -78
- components/ui/sidebar.tsx +0 -10
- hooks/use-toast.ts +62 -68
- lib/auth.ts +23 -0
- lib/dayjs.ts +19 -0
- lib/db.ts +0 -352
- lib/db/client.ts +348 -8
- lib/db/index.ts +5 -38
- lib/utils/inlet-cost.ts +0 -2
- lib/version.ts +0 -1
- middleware.ts +19 -13
- 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
}
|
| 101 |
-
}
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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,
|
| 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("
|
| 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 {
|
| 2 |
import { NextResponse } from "next/server";
|
| 3 |
-
import {
|
| 4 |
|
| 5 |
-
export async function GET() {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
try {
|
| 9 |
-
|
| 10 |
-
|
| 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 {
|
| 2 |
import { NextResponse } from "next/server";
|
| 3 |
-
import {
|
| 4 |
|
| 5 |
export async function POST(req: Request) {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 25 |
-
await
|
| 26 |
-
await
|
|
|
|
| 27 |
|
| 28 |
-
// 导入用户数据
|
| 29 |
if (data.data.users?.length) {
|
| 30 |
for (const user of data.data.users) {
|
| 31 |
-
await
|
| 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
|
| 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
|
| 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
|
| 73 |
|
| 74 |
return NextResponse.json({
|
| 75 |
success: true,
|
| 76 |
message: "Data import successful",
|
| 77 |
});
|
| 78 |
} catch (error) {
|
| 79 |
-
await
|
| 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 {
|
| 2 |
import { NextResponse } from "next/server";
|
| 3 |
-
import {
|
| 4 |
|
| 5 |
-
export async function GET() {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
| 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 {
|
| 2 |
import { NextResponse } from "next/server";
|
| 3 |
-
import {
|
| 4 |
|
| 5 |
export async function GET(req: Request) {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 {
|
|
|
|
| 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 |
-
|
| 18 |
`
|
| 19 |
SELECT
|
| 20 |
model_name,
|
|
@@ -27,7 +35,7 @@ export async function GET(request: Request) {
|
|
| 27 |
`,
|
| 28 |
params
|
| 29 |
),
|
| 30 |
-
|
| 31 |
`
|
| 32 |
SELECT
|
| 33 |
nickname,
|
|
@@ -40,13 +48,13 @@ export async function GET(request: Request) {
|
|
| 40 |
`,
|
| 41 |
params
|
| 42 |
),
|
| 43 |
-
|
| 44 |
SELECT
|
| 45 |
MIN(use_time) as min_time,
|
| 46 |
MAX(use_time) as max_time
|
| 47 |
FROM user_usage_records
|
| 48 |
`),
|
| 49 |
-
|
| 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
|
| 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
|
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
|
app/models/page.tsx
CHANGED
|
@@ -263,7 +263,12 @@ export default function ModelsPage() {
|
|
| 263 |
useEffect(() => {
|
| 264 |
const fetchModels = async () => {
|
| 265 |
try {
|
| 266 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: {
|
|
|
|
|
|
|
|
|
|
| 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 ${
|
| 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])
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
const url = `/api/v1/panel/usage?startTime=${startTime}&endTime=${endTime}`;
|
| 116 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 162 |
);
|
| 163 |
searchParams.append(
|
| 164 |
"endDate",
|
| 165 |
-
dayjs(range[1]).endOf("day").format("YYYY-MM-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
method: "PUT",
|
| 366 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
| 367 |
body: JSON.stringify({ balance: newBalance }),
|
| 368 |
});
|
| 369 |
|
| 370 |
const data = await res.json();
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 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),
|
| 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 |
-
|
| 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 |
-
|
| 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:
|
|
|
|
| 52 |
};
|
| 53 |
|
| 54 |
if (process.env.POSTGRES_URL) {
|
|
@@ -59,7 +58,8 @@ function getClient() {
|
|
| 59 |
},
|
| 60 |
max: 20,
|
| 61 |
idleTimeoutMillis: 30000,
|
| 62 |
-
connectionTimeoutMillis:
|
|
|
|
| 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 "
|
| 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 |
-
|
| 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 (
|
| 60 |
VALUES ($1, $2, CAST($3 AS DECIMAL(10, 6)), CURRENT_TIMESTAMP)
|
| 61 |
-
ON CONFLICT (
|
| 62 |
SET model_name = $2, updated_at = CURRENT_TIMESTAMP
|
| 63 |
RETURNING *`,
|
| 64 |
[id, name, defaultPerMsgPrice]
|
| 65 |
);
|
| 66 |
|
| 67 |
return {
|
| 68 |
-
id: result.rows[0].
|
| 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
|
|
|
|
|
|
|
|
|
|
| 15 |
) {
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 !==
|
| 29 |
-
console.log("Invalid API key");
|
| 30 |
-
return NextResponse.json({ error: "
|
| 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 |
"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",
|