Browse Source

chore: 更新 Docker 配置和文档以优化数据持久化

**主要改动**:
1. 更新 `.gitignore`,排除特定日志目录。
2. 修改 `docker-compose.yaml`,调整 PostgreSQL 数据目录以避免权限冲突,并更新环境变量配置。
3. 删除旧的 `docker-compose.yml` 文件,整合配置。
4. 更新 `README.md` 和 `data/README.md`,详细说明 PostgreSQL 数据目录结构及权限问题解决方案。

这些更改旨在提升 Docker 容器的可用性和数据管理的便利性。
ding113 3 months ago
parent
commit
a43cdfc32a

+ 1 - 0
.gitignore

@@ -47,6 +47,7 @@ next-env.d.ts
 .claude/
 .serena/
 logs/
+!src/app/dashboard/logs/
 .idea/
 .specify
 

+ 2 - 0
README.md

@@ -79,6 +79,8 @@ services:
       POSTGRES_USER: ${DB_USER:-postgres}
       POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
       POSTGRES_DB: ${DB_NAME:-claude_code_hub}
+      # 指定 PostgreSQL 数据目录为挂载点的子目录,避免权限问题
+      PGDATA: /var/lib/postgresql/data/pgdata
     volumes:
       # 持久化数据库数据到本地 ./data/postgres 目录
       # 重建容器不会丢失数据,可直接备份此目录

+ 25 - 2
data/README.md

@@ -2,7 +2,7 @@
 
 此目录用于存储 Docker Compose 容器的持久化数据:
 
-- `postgres/` - PostgreSQL 数据库数据
+- `postgres/pgdata/` - PostgreSQL 数据库数据(实际数据存储位置)
 - `redis/` - Redis 持久化数据
 
 ## 注意事项
@@ -12,7 +12,30 @@
 3. 备份此目录即可备份所有数据库数据
 4. 删除此目录将清空所有数据库数据
 
-## 权限问题
+## PostgreSQL 数据目录说明
+
+为了避免权限问题,PostgreSQL 配置了 `PGDATA=/var/lib/postgresql/data/pgdata`:
+- 容器挂载点: `/var/lib/postgresql/data` → `./data/postgres`
+- 实际数据目录: `/var/lib/postgresql/data/pgdata` → `./data/postgres/pgdata`
+
+这样 PostgreSQL 可以在挂载点内创建所需的子目录结构。
+
+## 常见问题
+
+### 如果遇到 "no such file or directory" 错误
+
+**原因**: PostgreSQL 容器需要在挂载点内创建 pgdata 子目录
+
+**解决方案**:
+1. 确保 docker-compose 中包含 `PGDATA: /var/lib/postgresql/data/pgdata`
+2. 清空 data/postgres 目录并重启:
+   ```bash
+   docker compose down
+   sudo rm -rf data/postgres/*
+   docker compose up -d
+   ```
+
+### 权限问题
 
 如果遇到 PostgreSQL 权限问题,执行:
 ```bash

+ 4 - 4
docker-compose.yaml

@@ -7,15 +7,16 @@ services:
       - "35432:5432"
     env_file:
       - ./.env
-      - ./.env.local
     environment:
       POSTGRES_USER: ${DB_USER:-postgres}
       POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
       POSTGRES_DB: ${DB_NAME:-claude_code_hub}
+      # 使用自定义数据目录
+      PGDATA: /data/pgdata
     volumes:
       # 持久化数据库数据到本地 ./data/postgres 目录
-      # 重建容器不会丢失数据,可直接备份此目录
-      - ./data/postgres:/var/lib/postgresql/data
+      # 挂载到 /data 而不是 /var/lib/postgresql/data 避免权限冲突
+      - ./data/postgres:/data
     healthcheck:
       test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-claude_code_hub}"]
       interval: 5s
@@ -49,7 +50,6 @@ services:
         condition: service_started
     env_file:
       - ./.env
-      - ./.env.local
     environment:
       NODE_ENV: production
       PORT: ${APP_PORT:-23000}

+ 0 - 64
docker-compose.yml

@@ -1,64 +0,0 @@
-version: '3.8'
-
-services:
-  postgres:
-    image: postgres:16-alpine
-    container_name: claude-hub-db
-    environment:
-      POSTGRES_USER: claude
-      POSTGRES_PASSWORD: dev123456
-      POSTGRES_DB: claude_hub
-    ports:
-      - "5432:5432"
-    volumes:
-      # 持久化数据库数据到本地 ./data/postgres 目录
-      # 重建容器不会丢失数据,可直接备份此目录
-      - ./data/postgres:/var/lib/postgresql/data
-    healthcheck:
-      test: ["CMD-SHELL", "pg_isready -U claude"]
-      interval: 5s
-      timeout: 5s
-      retries: 5
-
-  redis:
-    image: redis:7-alpine
-    container_name: claude-hub-redis
-    ports:
-      - "6379:6379"
-    volumes:
-      # 持久化 Redis 数据到本地 ./data/redis 目录
-      # 使用 AOF 持久化模式,确保数据不丢失
-      - ./data/redis:/data
-    command: redis-server --appendonly yes
-    healthcheck:
-      test: ["CMD", "redis-cli", "ping"]
-      interval: 5s
-      timeout: 3s
-      retries: 5
-
-  app:
-    build:
-      context: .
-      dockerfile: deploy/Dockerfile
-      args:
-        APP_VERSION: ${APP_VERSION:-dev}
-    container_name: claude-hub-app
-    environment:
-      NODE_ENV: production
-      DSN: postgres://claude:dev123456@postgres:5432/claude_hub
-      ADMIN_TOKEN: ${ADMIN_TOKEN:-local-dev-token-change-in-production}
-      AUTO_MIGRATE: "true"
-      DEBUG_MODE: "true"
-      PORT: 3000
-    ports:
-      - "13500:3000"
-    depends_on:
-      postgres:
-        condition: service_healthy
-      redis:
-        condition: service_healthy
-    restart: unless-stopped
-
-# volumes 配置已移除,改用本地目录映射
-# 数据存储在 ./data/postgres 和 ./data/redis 目录
-# 请确保 ./data 目录已添加到 .gitignore 中

+ 276 - 0
src/app/dashboard/logs/_components/usage-logs-filters.tsx

@@ -0,0 +1,276 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { getModelList, getStatusCodeList } from "@/actions/usage-logs";
+import { getKeys } from "@/actions/keys";
+import type { UserDisplay } from "@/types/user";
+import type { ProviderDisplay } from "@/types/provider";
+import type { Key } from "@/types/key";
+
+interface UsageLogsFiltersProps {
+  isAdmin: boolean;
+  users: UserDisplay[];
+  providers: ProviderDisplay[];
+  filters: {
+    userId?: number;
+    keyId?: number;
+    providerId?: number;
+    startDate?: Date;
+    endDate?: Date;
+    statusCode?: number;
+    model?: string;
+  };
+  onChange: (filters: UsageLogsFiltersProps["filters"]) => void;
+  onReset: () => void;
+}
+
+export function UsageLogsFilters({
+  isAdmin,
+  users,
+  providers,
+  filters,
+  onChange,
+  onReset,
+}: UsageLogsFiltersProps) {
+  const [models, setModels] = useState<string[]>([]);
+  const [statusCodes, setStatusCodes] = useState<number[]>([]);
+  const [keys, setKeys] = useState<Key[]>([]);
+  const [localFilters, setLocalFilters] = useState(filters);
+
+  // 加载筛选器选项
+  useEffect(() => {
+    const loadOptions = async () => {
+      const [modelsResult, codesResult] = await Promise.all([
+        getModelList(),
+        getStatusCodeList(),
+      ]);
+
+      if (modelsResult.ok && modelsResult.data) {
+        setModels(modelsResult.data);
+      }
+
+      if (codesResult.ok && codesResult.data) {
+        setStatusCodes(codesResult.data);
+      }
+
+      // 如果选择了用户,加载该用户的 keys
+      if (localFilters.userId) {
+        const keysResult = await getKeys(localFilters.userId);
+        if (keysResult.ok && keysResult.data) {
+          setKeys(keysResult.data);
+        }
+      }
+    };
+
+    loadOptions();
+  }, [localFilters.userId]);
+
+  // 处理用户选择变更
+  const handleUserChange = async (userId: string) => {
+    const newUserId = userId ? parseInt(userId) : undefined;
+    const newFilters = { ...localFilters, userId: newUserId, keyId: undefined };
+    setLocalFilters(newFilters);
+
+    // 加载该用户的 keys
+    if (newUserId) {
+      const keysResult = await getKeys(newUserId);
+      if (keysResult.ok && keysResult.data) {
+        setKeys(keysResult.data);
+      }
+    } else {
+      setKeys([]);
+    }
+  };
+
+  const handleApply = () => {
+    onChange(localFilters);
+  };
+
+  const handleReset = () => {
+    setLocalFilters({});
+    setKeys([]);
+    onReset();
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+        {/* 时间范围 */}
+        <div className="space-y-2">
+          <Label>开始时间</Label>
+          <Input
+            type="datetime-local"
+            value={localFilters.startDate?.toISOString().slice(0, 16) || ""}
+            onChange={(e) =>
+              setLocalFilters({
+                ...localFilters,
+                startDate: e.target.value ? new Date(e.target.value) : undefined,
+              })
+            }
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label>结束时间</Label>
+          <Input
+            type="datetime-local"
+            value={localFilters.endDate?.toISOString().slice(0, 16) || ""}
+            onChange={(e) =>
+              setLocalFilters({
+                ...localFilters,
+                endDate: e.target.value ? new Date(e.target.value) : undefined,
+              })
+            }
+          />
+        </div>
+
+        {/* 用户选择(仅 Admin) */}
+        {isAdmin && (
+          <div className="space-y-2">
+            <Label>用户</Label>
+            <Select
+              value={localFilters.userId?.toString() || ""}
+              onValueChange={handleUserChange}
+            >
+              <SelectTrigger>
+                <SelectValue placeholder="全部用户" />
+              </SelectTrigger>
+              <SelectContent>
+                {users.map((user) => (
+                  <SelectItem key={user.id} value={user.id.toString()}>
+                    {user.name}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+        )}
+
+        {/* Key 选择 */}
+        <div className="space-y-2">
+          <Label>API 密钥</Label>
+          <Select
+            value={localFilters.keyId?.toString() || ""}
+            onValueChange={(value: string) =>
+              setLocalFilters({
+                ...localFilters,
+                keyId: value ? parseInt(value) : undefined,
+              })
+            }
+            disabled={isAdmin && !localFilters.userId}
+          >
+            <SelectTrigger>
+              <SelectValue placeholder={isAdmin && !localFilters.userId ? "请先选择用户" : "全部密钥"} />
+            </SelectTrigger>
+            <SelectContent>
+              {keys.map((key) => (
+                <SelectItem key={key.id} value={key.id.toString()}>
+                  {key.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+
+        {/* 供应商选择 */}
+        {isAdmin && (
+          <div className="space-y-2">
+            <Label>供应商</Label>
+            <Select
+              value={localFilters.providerId?.toString() || ""}
+              onValueChange={(value: string) =>
+                setLocalFilters({
+                  ...localFilters,
+                  providerId: value ? parseInt(value) : undefined,
+                })
+              }
+            >
+              <SelectTrigger>
+                <SelectValue placeholder="全部供应商" />
+              </SelectTrigger>
+              <SelectContent>
+                {providers.map((provider) => (
+                  <SelectItem key={provider.id} value={provider.id.toString()}>
+                    {provider.name}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+        )}
+
+        {/* 模型选择 */}
+        <div className="space-y-2">
+          <Label>模型</Label>
+          <Select
+            value={localFilters.model || ""}
+            onValueChange={(value: string) =>
+              setLocalFilters({ ...localFilters, model: value || undefined })
+            }
+          >
+            <SelectTrigger>
+              <SelectValue placeholder="全部模型" />
+            </SelectTrigger>
+            <SelectContent>
+              {models.map((model) => (
+                <SelectItem key={model} value={model}>
+                  {model}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+
+        {/* 状态码选择 */}
+        <div className="space-y-2">
+          <Label>状态码</Label>
+          <Select
+            value={localFilters.statusCode?.toString() || ""}
+            onValueChange={(value: string) =>
+              setLocalFilters({
+                ...localFilters,
+                statusCode: value ? parseInt(value) : undefined,
+              })
+            }
+          >
+            <SelectTrigger>
+              <SelectValue placeholder="全部状态码" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="200">200 (成功)</SelectItem>
+              <SelectItem value="400">400 (错误请求)</SelectItem>
+              <SelectItem value="401">401 (未授权)</SelectItem>
+              <SelectItem value="429">429 (限流)</SelectItem>
+              <SelectItem value="500">500 (服务器错误)</SelectItem>
+              {statusCodes
+                .filter((code) => ![200, 400, 401, 429, 500].includes(code))
+                .map((code) => (
+                  <SelectItem key={code} value={code.toString()}>
+                    {code}
+                  </SelectItem>
+                ))}
+            </SelectContent>
+          </Select>
+        </div>
+      </div>
+
+      {/* 操作按钮 */}
+      <div className="flex gap-2">
+        <Button onClick={handleApply}>应用筛选</Button>
+        <Button variant="outline" onClick={handleReset}>
+          重置
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 132 - 0
src/app/dashboard/logs/_components/usage-logs-table.tsx

@@ -0,0 +1,132 @@
+"use client";
+
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import type { UsageLogRow } from "@/repository/usage-logs";
+import { formatDistanceToNow } from "date-fns";
+import { zhCN } from "date-fns/locale";
+
+interface UsageLogsTableProps {
+  logs: UsageLogRow[];
+  total: number;
+  page: number;
+  pageSize: number;
+  onPageChange: (page: number) => void;
+  isPending: boolean;
+}
+
+export function UsageLogsTable({
+  logs,
+  total,
+  page,
+  pageSize,
+  onPageChange,
+  isPending,
+}: UsageLogsTableProps) {
+  const totalPages = Math.ceil(total / pageSize);
+
+  const getStatusBadge = (statusCode: number | null) => {
+    if (!statusCode) return <Badge variant="secondary">-</Badge>;
+
+    if (statusCode >= 200 && statusCode < 300) {
+      return <Badge variant="default">{statusCode}</Badge>;
+    } else if (statusCode >= 400 && statusCode < 500) {
+      return <Badge variant="destructive">{statusCode}</Badge>;
+    } else if (statusCode >= 500) {
+      return <Badge variant="destructive">{statusCode}</Badge>;
+    }
+
+    return <Badge variant="secondary">{statusCode}</Badge>;
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="rounded-md border">
+        <Table>
+          <TableHeader>
+            <TableRow>
+              <TableHead>时间</TableHead>
+              <TableHead>用户</TableHead>
+              <TableHead>密钥</TableHead>
+              <TableHead>供应商</TableHead>
+              <TableHead>模型</TableHead>
+              <TableHead className="text-right">Token</TableHead>
+              <TableHead className="text-right">成本</TableHead>
+              <TableHead className="text-right">耗时</TableHead>
+              <TableHead>状态</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {logs.length === 0 ? (
+              <TableRow>
+                <TableCell colSpan={9} className="text-center text-muted-foreground">
+                  暂无数据
+                </TableCell>
+              </TableRow>
+            ) : (
+              logs.map((log) => (
+                <TableRow key={log.id}>
+                  <TableCell className="font-mono text-xs">
+                    {log.createdAt ? formatDistanceToNow(log.createdAt, {
+                      addSuffix: true,
+                      locale: zhCN,
+                    }) : '-'}
+                  </TableCell>
+                  <TableCell>{log.userName}</TableCell>
+                  <TableCell className="font-mono text-xs">{log.keyName}</TableCell>
+                  <TableCell>{log.providerName}</TableCell>
+                  <TableCell className="font-mono text-xs">{log.model || "-"}</TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {log.totalTokens.toLocaleString()}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {log.costUsd ? `$${parseFloat(log.costUsd).toFixed(6)}` : "-"}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {log.durationMs ? `${log.durationMs}ms` : "-"}
+                  </TableCell>
+                  <TableCell>{getStatusBadge(log.statusCode)}</TableCell>
+                </TableRow>
+              ))
+            )}
+          </TableBody>
+        </Table>
+      </div>
+
+      {/* 分页 */}
+      {totalPages > 1 && (
+        <div className="flex items-center justify-between">
+          <div className="text-sm text-muted-foreground">
+            共 {total} 条记录,第 {page} / {totalPages} 页
+          </div>
+          <div className="flex gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => onPageChange(page - 1)}
+              disabled={page === 1 || isPending}
+            >
+              上一页
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => onPageChange(page + 1)}
+              disabled={page === totalPages || isPending}
+            >
+              下一页
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 169 - 0
src/app/dashboard/logs/_components/usage-logs-view.tsx

@@ -0,0 +1,169 @@
+"use client";
+
+import { useState, useTransition, useEffect } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { getUsageLogs } from "@/actions/usage-logs";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { UsageLogsFilters } from "./usage-logs-filters";
+import { UsageLogsTable } from "./usage-logs-table";
+import type { UsageLogsResult } from "@/repository/usage-logs";
+import type { UserDisplay } from "@/types/user";
+import type { ProviderDisplay } from "@/types/provider";
+
+interface UsageLogsViewProps {
+  isAdmin: boolean;
+  users: UserDisplay[];
+  providers: ProviderDisplay[];
+  searchParams: { [key: string]: string | string[] | undefined };
+}
+
+export function UsageLogsView({
+  isAdmin,
+  users,
+  providers,
+  searchParams,
+}: UsageLogsViewProps) {
+  const router = useRouter();
+  const params = useSearchParams();
+  const [isPending, startTransition] = useTransition();
+  const [data, setData] = useState<UsageLogsResult | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // 从 URL 参数解析筛选条件
+  const filters: {
+    userId?: number;
+    keyId?: number;
+    providerId?: number;
+    startDate?: Date;
+    endDate?: Date;
+    statusCode?: number;
+    model?: string;
+    page: number;
+  } = {
+    userId: searchParams.userId ? parseInt(searchParams.userId as string) : undefined,
+    keyId: searchParams.keyId ? parseInt(searchParams.keyId as string) : undefined,
+    providerId: searchParams.providerId ? parseInt(searchParams.providerId as string) : undefined,
+    startDate: searchParams.startDate ? new Date(searchParams.startDate as string) : undefined,
+    endDate: searchParams.endDate ? new Date(searchParams.endDate as string) : undefined,
+    statusCode: searchParams.statusCode ? parseInt(searchParams.statusCode as string) : undefined,
+    model: searchParams.model as string | undefined,
+    page: searchParams.page ? parseInt(searchParams.page as string) : 1,
+  };
+
+  // 加载数据
+  const loadData = async () => {
+    startTransition(async () => {
+      const result = await getUsageLogs(filters);
+      if (result.ok && result.data) {
+        setData(result.data);
+        setError(null);
+      } else {
+        setError(!result.ok && 'error' in result ? result.error : "加载失败");
+        setData(null);
+      }
+    });
+  };
+
+  // 初始加载
+  useEffect(() => {
+    loadData();
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // 处理筛选条件变更
+  const handleFilterChange = (newFilters: Omit<typeof filters, 'page'>) => {
+    const query = new URLSearchParams();
+
+    if (newFilters.userId) query.set("userId", newFilters.userId.toString());
+    if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString());
+    if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString());
+    if (newFilters.startDate) query.set("startDate", newFilters.startDate.toISOString());
+    if (newFilters.endDate) query.set("endDate", newFilters.endDate.toISOString());
+    if (newFilters.statusCode) query.set("statusCode", newFilters.statusCode.toString());
+    if (newFilters.model) query.set("model", newFilters.model);
+
+    router.push(`/dashboard/logs?${query.toString()}`);
+  };
+
+  // 处理分页
+  const handlePageChange = (page: number) => {
+    const query = new URLSearchParams(params.toString());
+    query.set("page", page.toString());
+    router.push(`/dashboard/logs?${query.toString()}`);
+  };
+
+  return (
+    <div className="space-y-6">
+      {/* 统计卡片 */}
+      {data && (
+        <div className="grid gap-4 md:grid-cols-3">
+          <Card>
+            <CardHeader className="pb-3">
+              <CardDescription>总请求数</CardDescription>
+              <CardTitle className="text-3xl font-mono">
+                {data.summary.totalRequests.toLocaleString()}
+              </CardTitle>
+            </CardHeader>
+          </Card>
+
+          <Card>
+            <CardHeader className="pb-3">
+              <CardDescription>总消耗金额</CardDescription>
+              <CardTitle className="text-3xl font-mono">
+                ${data.summary.totalCost.toFixed(4)}
+              </CardTitle>
+            </CardHeader>
+          </Card>
+
+          <Card>
+            <CardHeader className="pb-3">
+              <CardDescription>总 Token 数</CardDescription>
+              <CardTitle className="text-3xl font-mono">
+                {data.summary.totalTokens.toLocaleString()}
+              </CardTitle>
+            </CardHeader>
+          </Card>
+        </div>
+      )}
+
+      {/* 筛选器 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>筛选条件</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <UsageLogsFilters
+            isAdmin={isAdmin}
+            users={users}
+            providers={providers}
+            filters={filters}
+            onChange={handleFilterChange}
+            onReset={() => router.push("/dashboard/logs")}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 数据表格 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>使用记录</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {error ? (
+            <div className="text-center py-8 text-destructive">{error}</div>
+          ) : !data ? (
+            <div className="text-center py-8 text-muted-foreground">加载中...</div>
+          ) : (
+            <UsageLogsTable
+              logs={data.logs}
+              total={data.total}
+              page={filters.page || 1}
+              pageSize={50}
+              onPageChange={handlePageChange}
+              isPending={isPending}
+            />
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 45 - 0
src/app/dashboard/logs/page.tsx

@@ -0,0 +1,45 @@
+import { Suspense } from "react";
+import { redirect } from "next/navigation";
+import { getSession } from "@/lib/auth";
+import { Section } from "@/components/section";
+import { UsageLogsView } from "./_components/usage-logs-view";
+import { getUsers } from "@/actions/users";
+import { getProviders } from "@/actions/providers";
+
+export const dynamic = "force-dynamic";
+
+export default async function UsageLogsPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+}) {
+  const session = await getSession();
+  if (!session) {
+    redirect("/login");
+  }
+
+  const isAdmin = session.user.role === "admin";
+
+  // 只有 admin 才需要获取用户和供应商列表
+  const [users, providers, resolvedSearchParams] = isAdmin
+    ? await Promise.all([getUsers(), getProviders(), searchParams])
+    : [[], [], await searchParams];
+
+  return (
+    <div className="space-y-6">
+      <Section
+        title="使用记录"
+        description="查看 API 调用日志和使用统计"
+      >
+        <Suspense fallback={<div className="text-center py-8 text-muted-foreground">加载中...</div>}>
+          <UsageLogsView
+            isAdmin={isAdmin}
+            users={users}
+            providers={providers}
+            searchParams={resolvedSearchParams}
+          />
+        </Suspense>
+      </Section>
+    </div>
+  );
+}