Просмотр исходного кода

chore: update .gitignore to exclude logs directory except for specific paths

ding113 3 месяцев назад
Родитель
Сommit
cd76cd3aad

+ 0 - 1
.gitignore

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

+ 332 - 0
src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx

@@ -0,0 +1,332 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Link } from "@/i18n/routing";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { AlertCircle, ArrowRight, CheckCircle, ExternalLink, Loader2, Monitor } from "lucide-react";
+import type { ProviderChainItem } from "@/types/message";
+import { hasSessionMessages } from "@/actions/active-sessions";
+import { formatProviderTimeline } from "@/lib/utils/provider-chain-formatter";
+
+interface ErrorDetailsDialogProps {
+  statusCode: number | null;
+  errorMessage: string | null;
+  providerChain: ProviderChainItem[] | null;
+  sessionId: string | null;
+  blockedBy?: string | null; // 拦截类型
+  blockedReason?: string | null; // 拦截原因(JSON 字符串)
+  originalModel?: string | null; // 原始模型(重定向前)
+  currentModel?: string | null; // 当前模型(重定向后)
+  userAgent?: string | null; // User-Agent
+  messagesCount?: number | null; // Messages 数量
+}
+
+const blockedByLabels: Record<string, string> = {
+  sensitive_word: '敏感词拦截',
+};
+
+export function ErrorDetailsDialog({
+  statusCode,
+  errorMessage,
+  providerChain,
+  sessionId,
+  blockedBy,
+  blockedReason,
+  originalModel,
+  currentModel,
+  userAgent,
+  messagesCount,
+}: ErrorDetailsDialogProps) {
+  const [open, setOpen] = useState(false);
+  const [hasMessages, setHasMessages] = useState(false);
+  const [checkingMessages, setCheckingMessages] = useState(false);
+
+  const isSuccess = statusCode && statusCode >= 200 && statusCode < 300;
+  const isError = statusCode && (statusCode >= 400 || statusCode < 200);
+  const isInProgress = !statusCode; // 没有状态码表示请求进行中
+  const isBlocked = !!blockedBy; // 是否被拦截
+
+  // 解析 blockedReason JSON
+  let parsedBlockedReason: { word?: string; matchType?: string; matchedText?: string } | null = null;
+  if (blockedReason) {
+    try {
+      parsedBlockedReason = JSON.parse(blockedReason);
+    } catch {
+      // 解析失败,忽略
+    }
+  }
+
+  // 检查 session 是否有 messages 数据
+  useEffect(() => {
+    if (open && sessionId) {
+      setCheckingMessages(true);
+      hasSessionMessages(sessionId)
+        .then((result) => {
+          if (result.ok) {
+            setHasMessages(result.data);
+          }
+        })
+        .catch((err) => {
+          console.error('Failed to check session messages:', err);
+        })
+        .finally(() => {
+          setCheckingMessages(false);
+        });
+    } else {
+      // 弹窗关闭时重置状态
+      setHasMessages(false);
+      setCheckingMessages(false);
+    }
+  }, [open, sessionId]);
+
+  const getStatusBadgeVariant = () => {
+    if (isInProgress) return "outline"; // 请求中使用 outline 样式
+    if (isSuccess) return "default";
+    if (isError) return "destructive";
+    return "secondary";
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button
+          variant="ghost"
+          className="h-auto p-0 font-normal hover:bg-transparent"
+        >
+          <Badge variant={getStatusBadgeVariant()} className="cursor-pointer">
+            {isInProgress ? "请求中" : statusCode}
+          </Badge>
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            {isInProgress ? (
+              <Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
+            ) : isSuccess ? (
+              <CheckCircle className="h-5 w-5 text-green-600" />
+            ) : (
+              <AlertCircle className="h-5 w-5 text-destructive" />
+            )}
+            请求详情 - 状态码 {isInProgress ? "请求中" : statusCode || "未知"}
+          </DialogTitle>
+          <DialogDescription>
+            {isInProgress
+              ? "请求正在进行中,尚未完成"
+              : isSuccess
+              ? "请求成功完成"
+              : "请求失败,以下是详细的错误信息和供应商决策链"}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-6 mt-4">
+          {/* 拦截信息 */}
+          {isBlocked && blockedBy && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm flex items-center gap-2">
+                <AlertCircle className="h-4 w-4 text-orange-600" />
+                拦截信息
+              </h4>
+              <div className="rounded-md border bg-orange-50 dark:bg-orange-950/20 p-4 space-y-2">
+                <div className="flex items-center gap-2">
+                  <span className="text-xs font-medium text-orange-900 dark:text-orange-100">
+                    拦截类型:
+                  </span>
+                  <Badge variant="outline" className="border-orange-600 text-orange-600">
+                    {blockedByLabels[blockedBy] || blockedBy}
+                  </Badge>
+                </div>
+                {parsedBlockedReason && (
+                  <div className="space-y-1 text-xs">
+                    {parsedBlockedReason.word && (
+                      <div className="flex items-baseline gap-2">
+                        <span className="font-medium text-orange-900 dark:text-orange-100">
+                          敏感词:
+                        </span>
+                        <code className="bg-orange-100 dark:bg-orange-900/50 px-2 py-0.5 rounded text-orange-900 dark:text-orange-100">
+                          {parsedBlockedReason.word}
+                        </code>
+                      </div>
+                    )}
+                    {parsedBlockedReason.matchType && (
+                      <div className="flex items-baseline gap-2">
+                        <span className="font-medium text-orange-900 dark:text-orange-100">
+                          匹配类型:
+                        </span>
+                        <span className="text-orange-800 dark:text-orange-200">
+                          {parsedBlockedReason.matchType === 'contains' && '包含匹配'}
+                          {parsedBlockedReason.matchType === 'exact' && '精确匹配'}
+                          {parsedBlockedReason.matchType === 'regex' && '正则表达式'}
+                        </span>
+                      </div>
+                    )}
+                    {parsedBlockedReason.matchedText && (
+                      <div className="flex flex-col gap-1">
+                        <span className="font-medium text-orange-900 dark:text-orange-100">
+                          匹配内容:
+                        </span>
+                        <pre className="bg-orange-100 dark:bg-orange-900/50 px-2 py-1 rounded text-orange-900 dark:text-orange-100 whitespace-pre-wrap break-words">
+                          {parsedBlockedReason.matchedText}
+                        </pre>
+                      </div>
+                    )}
+                  </div>
+                )}
+              </div>
+            </div>
+          )}
+
+          {/* Session 信息 */}
+          {sessionId && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm">会话 ID</h4>
+              <div className="flex items-center gap-3">
+                <div className="flex-1 rounded-md border bg-muted/50 p-3">
+                  <code className="text-xs font-mono break-all">
+                    {sessionId}
+                  </code>
+                </div>
+                {hasMessages && !checkingMessages && (
+                  <Link href={`/dashboard/sessions/${sessionId}/messages`}>
+                    <Button variant="outline" size="sm">
+                      <ExternalLink className="h-4 w-4 mr-2" />
+                      查看详情
+                    </Button>
+                  </Link>
+                )}
+              </div>
+            </div>
+          )}
+
+          {/* Messages 数量 */}
+          {messagesCount !== null && messagesCount !== undefined && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm">消息数量</h4>
+              <div className="rounded-md border bg-muted/50 p-3">
+                <div className="text-sm">
+                  <span className="font-medium">Messages:</span>{" "}
+                  <code className="text-base font-mono font-semibold">{messagesCount}</code> 条
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* User-Agent 信息 */}
+          {userAgent && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm flex items-center gap-2">
+                <Monitor className="h-4 w-4 text-blue-600" />
+                客户端信息
+              </h4>
+              <div className="rounded-md border bg-muted/50 p-3">
+                <code className="text-xs font-mono break-all">
+                  {userAgent}
+                </code>
+              </div>
+            </div>
+          )}
+
+          {/* 模型重定向信息 */}
+          {originalModel && currentModel && originalModel !== currentModel && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm flex items-center gap-2">
+                <ArrowRight className="h-4 w-4 text-blue-600" />
+                模型重定向
+              </h4>
+              <div className="rounded-md border bg-blue-50 dark:bg-blue-950/20 p-4 space-y-3">
+                <div className="grid grid-cols-2 gap-4 text-sm">
+                  <div>
+                    <span className="font-medium text-blue-900 dark:text-blue-100">
+                      请求模型:
+                    </span>
+                    <div className="mt-1">
+                      <code className="bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded text-blue-900 dark:text-blue-100">
+                        {originalModel}
+                      </code>
+                    </div>
+                  </div>
+                  <div>
+                    <span className="font-medium text-blue-900 dark:text-blue-100">
+                      实际调用:
+                    </span>
+                    <div className="mt-1">
+                      <code className="bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded text-blue-900 dark:text-blue-100">
+                        {currentModel}
+                      </code>
+                    </div>
+                  </div>
+                </div>
+                <div className="text-xs text-blue-800 dark:text-blue-200 border-t border-blue-200 dark:border-blue-800 pt-2">
+                  <span className="font-medium">计费说明:</span>{" "}
+                  系统优先使用请求模型({originalModel})的价格计费。
+                  如果价格表中不存在该模型,则使用实际调用模型({currentModel})的价格。
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* 最终错误信息 */}
+          {errorMessage && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm flex items-center gap-2">
+                <AlertCircle className="h-4 w-4" />
+                错误信息
+              </h4>
+              <div className="rounded-md border bg-destructive/10 p-4">
+                <pre className="text-xs text-destructive whitespace-pre-wrap break-words font-mono">
+                  {errorMessage}
+                </pre>
+              </div>
+            </div>
+          )}
+
+          {/* 供应商决策链时间线 */}
+          {providerChain && providerChain.length > 0 && (
+            <div className="space-y-2">
+              <h4 className="font-semibold text-sm">供应商决策链时间线</h4>
+
+              {(() => {
+                const { timeline, totalDuration } = formatProviderTimeline(providerChain);
+                return (
+                  <>
+                    <div className="rounded-md border bg-muted/50 p-4 max-h-[500px] overflow-y-auto overflow-x-hidden">
+                      <pre className="text-xs whitespace-pre-wrap break-words font-mono leading-relaxed">
+                        {timeline}
+                      </pre>
+                    </div>
+
+                    {totalDuration > 0 && (
+                      <div className="text-xs text-muted-foreground text-right">
+                        总耗时: {totalDuration}ms
+                      </div>
+                    )}
+                  </>
+                );
+              })()}
+            </div>
+          )}
+
+          {/* 无错误信息的情况 */}
+          {!errorMessage && (!providerChain || providerChain.length === 0) && (
+            <div className="text-center py-8 text-muted-foreground">
+              {isInProgress
+                ? "请求正在处理中,等待响应..."
+                : isSuccess
+                ? "请求成功,无错误信息"
+                : "暂无详细错误信息"}
+            </div>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 54 - 0
src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx

@@ -0,0 +1,54 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { ArrowRight } from "lucide-react";
+
+interface ModelDisplayWithRedirectProps {
+  originalModel: string | null;
+  currentModel: string | null;
+}
+
+export function ModelDisplayWithRedirect({
+  originalModel,
+  currentModel,
+}: ModelDisplayWithRedirectProps) {
+  // 判断是否发生重定向
+  const isRedirected =
+    originalModel && currentModel && originalModel !== currentModel;
+
+  if (!isRedirected) {
+    return <span>{currentModel || originalModel || "-"}</span>;
+  }
+
+  return (
+    <div className="flex items-center gap-2">
+      <span>{originalModel}</span>
+      <TooltipProvider>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Badge
+              variant="outline"
+              className="cursor-help text-xs border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300"
+            >
+              <ArrowRight className="h-3 w-3 mr-1" />
+              已重定向
+            </Badge>
+          </TooltipTrigger>
+          <TooltipContent>
+            <div className="text-xs space-y-1">
+              <div>
+                <span className="font-medium">目标模型:</span> {currentModel}
+              </div>
+            </div>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    </div>
+  );
+}

+ 77 - 0
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { InfoIcon } from "lucide-react";
+import type { ProviderChainItem } from "@/types/message";
+import { formatProviderDescription } from "@/lib/utils/provider-chain-formatter";
+
+interface ProviderChainPopoverProps {
+  chain: ProviderChainItem[];
+  finalProvider: string;
+}
+
+/**
+ * 判断是否为实际请求记录(排除中间状态)
+ */
+function isActualRequest(item: ProviderChainItem): boolean {
+  // 并发限制失败:算作一次尝试
+  if (item.reason === 'concurrent_limit_failed') return true;
+
+  // 失败记录
+  if (item.reason === 'retry_failed' || item.reason === 'system_error') return true;
+
+  // 成功记录:必须有 statusCode
+  if ((item.reason === 'request_success' || item.reason === 'retry_success') && item.statusCode) {
+    return true;
+  }
+
+  // 其他都是中间状态
+  return false;
+}
+
+export function ProviderChainPopover({ chain, finalProvider }: ProviderChainPopoverProps) {
+  // 计算实际请求次数(排除中间状态)
+  const requestCount = chain.filter(isActualRequest).length;
+
+  // 如果只有一次请求,不显示 popover
+  if (requestCount <= 1) {
+    return <span>{finalProvider}</span>;
+  }
+
+  return (
+    <Popover>
+      <PopoverTrigger asChild>
+        <Button variant="ghost" className="h-auto p-0 font-normal hover:bg-transparent">
+          <span className="flex items-center gap-1">
+            {finalProvider}
+            <Badge variant="secondary" className="ml-1">
+              {requestCount}次
+            </Badge>
+            <InfoIcon className="h-3 w-3 text-muted-foreground" />
+          </span>
+        </Button>
+      </PopoverTrigger>
+
+      <PopoverContent className="w-[500px]" align="start">
+        <div className="space-y-3">
+          <div className="flex items-center justify-between">
+            <h4 className="font-semibold text-sm">供应商决策链</h4>
+            <Badge variant="outline">{requestCount}次</Badge>
+          </div>
+
+          <div className="rounded-md border bg-muted/50 p-4 max-h-[300px] overflow-y-auto overflow-x-hidden">
+            <pre className="text-xs whitespace-pre-wrap break-words leading-relaxed">
+              {formatProviderDescription(chain)}
+            </pre>
+          </div>
+
+          <div className="text-xs text-muted-foreground text-center">
+            点击状态码查看完整时间线
+          </div>
+        </div>
+      </PopoverContent>
+    </Popover>
+  );
+}

+ 302 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -0,0 +1,302 @@
+"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";
+
+/**
+ * 将 Date 对象格式化为 datetime-local 输入所需的格式
+ * 保持本地时区,不转换为 UTC
+ */
+function formatDateTimeLocal(date: Date): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  const hours = String(date.getHours()).padStart(2, '0');
+  const minutes = String(date.getMinutes()).padStart(2, '0');
+  return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+/**
+ * 解析 datetime-local 输入的值为 Date 对象
+ * 保持本地时区语义
+ */
+function parseDateTimeLocal(value: string): Date {
+  // datetime-local 返回格式: "2025-10-23T10:30"
+  // 直接用 new Date() 会按照本地时区解析
+  return new Date(value);
+}
+
+interface UsageLogsFiltersProps {
+  isAdmin: boolean;
+  users: UserDisplay[];
+  providers: ProviderDisplay[];
+  initialKeys: Key[];
+  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,
+  initialKeys,
+  filters,
+  onChange,
+  onReset,
+}: UsageLogsFiltersProps) {
+  const [models, setModels] = useState<string[]>([]);
+  const [statusCodes, setStatusCodes] = useState<number[]>([]);
+  const [keys, setKeys] = useState<Key[]>(initialKeys);
+  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
+      // 非管理员:已经有 initialKeys,不需要额外加载
+      if (isAdmin && localFilters.userId) {
+        const keysResult = await getKeys(localFilters.userId);
+        if (keysResult.ok && keysResult.data) {
+          setKeys(keysResult.data);
+        }
+      }
+    };
+
+    loadOptions();
+  }, [isAdmin, 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 ? formatDateTimeLocal(localFilters.startDate) : ""}
+            onChange={(e) =>
+              setLocalFilters({
+                ...localFilters,
+                startDate: e.target.value ? parseDateTimeLocal(e.target.value) : undefined,
+              })
+            }
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label>结束时间</Label>
+          <Input
+            type="datetime-local"
+            value={localFilters.endDate ? formatDateTimeLocal(localFilters.endDate) : ""}
+            onChange={(e) =>
+              setLocalFilters({
+                ...localFilters,
+                endDate: e.target.value ? parseDateTimeLocal(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 && keys.length === 0}
+          >
+            <SelectTrigger>
+              <SelectValue placeholder={isAdmin && !localFilters.userId && keys.length === 0 ? "请先选择用户" : "全部密钥"} />
+            </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>
+  );
+}

+ 254 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -0,0 +1,254 @@
+"use client";
+
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import type { UsageLogRow } from "@/repository/usage-logs";
+import { RelativeTime } from "@/components/ui/relative-time";
+import { ProviderChainPopover } from "./provider-chain-popover";
+import { ErrorDetailsDialog } from "./error-details-dialog";
+import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter";
+import { ModelDisplayWithRedirect } from "./model-display-with-redirect";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import { formatCurrency } from "@/lib/utils/currency";
+import { formatTokenAmount } from "@/lib/utils";
+
+/**
+ * 格式化请求耗时
+ * - 1000ms 以上显示为秒(如 "1.23s")
+ * - 1000ms 以下显示为毫秒(如 "850ms")
+ */
+function formatDuration(durationMs: number | null): string {
+  if (!durationMs) return '-';
+
+  // 1000ms 以上转换为秒
+  if (durationMs >= 1000) {
+    return `${(durationMs / 1000).toFixed(2)}s`;
+  }
+
+  // 1000ms 以下显示毫秒
+  return `${durationMs}ms`;
+}
+
+interface UsageLogsTableProps {
+  logs: UsageLogRow[];
+  total: number;
+  page: number;
+  pageSize: number;
+  onPageChange: (page: number) => void;
+  isPending: boolean;
+  newLogIds?: Set<number>; // 新增记录 ID 集合(用于动画高亮)
+  currencyCode?: CurrencyCode;
+}
+
+export function UsageLogsTable({
+  logs,
+  total,
+  page,
+  pageSize,
+  onPageChange,
+  isPending,
+  newLogIds,
+  currencyCode = "USD",
+}: UsageLogsTableProps) {
+  const totalPages = Math.ceil(total / pageSize);
+
+  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">输入</TableHead>
+              <TableHead className="text-right">输出</TableHead>
+              <TableHead className="text-right">缓存写入</TableHead>
+              <TableHead className="text-right">缓存读取</TableHead>
+              <TableHead className="text-right">成本</TableHead>
+              <TableHead className="text-right">耗时</TableHead>
+              <TableHead>状态</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {logs.length === 0 ? (
+              <TableRow>
+                <TableCell colSpan={12} className="text-center text-muted-foreground">
+                  暂无数据
+                </TableCell>
+              </TableRow>
+            ) : (
+              logs.map((log) => (
+                <TableRow
+                  key={log.id}
+                  className={newLogIds?.has(log.id) ? 'animate-highlight-flash' : ''}
+                >
+                  <TableCell className="font-mono text-xs">
+                    <RelativeTime date={log.createdAt} fallback="-" />
+                  </TableCell>
+                  <TableCell>{log.userName}</TableCell>
+                  <TableCell className="font-mono text-xs">{log.keyName}</TableCell>
+                  <TableCell className="text-left">
+                    {log.blockedBy ? (
+                      // 被拦截的请求显示拦截标记
+                      <span className="inline-flex items-center gap-1 rounded-md bg-orange-100 dark:bg-orange-950 px-2 py-1 text-xs font-medium text-orange-700 dark:text-orange-300">
+                        <span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
+                        被拦截
+                      </span>
+                    ) : (
+                      <div className="flex items-start gap-2">
+                        <div className="flex flex-col items-start gap-0.5 min-w-0 flex-1">
+                          {log.providerChain && log.providerChain.length > 0 ? (
+                            <>
+                              <div className="w-full">
+                                <ProviderChainPopover
+                                  chain={log.providerChain}
+                                  finalProvider={
+                                    log.providerChain[log.providerChain.length - 1].name || log.providerName || "未知"
+                                  }
+                                />
+                              </div>
+                              {/* 摘要文字(第二行显示,左对齐) */}
+                              {formatProviderSummary(log.providerChain) && (
+                                <div className="w-full">
+                                  <TooltipProvider>
+                                    <Tooltip delayDuration={300}>
+                                      <TooltipTrigger asChild>
+                                        <span className="text-xs text-muted-foreground cursor-help truncate max-w-[200px] block text-left">
+                                          {formatProviderSummary(log.providerChain)}
+                                        </span>
+                                      </TooltipTrigger>
+                                      <TooltipContent side="bottom" align="start" className="max-w-[500px]">
+                                        <p className="text-xs whitespace-normal break-words font-mono">
+                                          {formatProviderSummary(log.providerChain)}
+                                        </p>
+                                      </TooltipContent>
+                                    </Tooltip>
+                                  </TooltipProvider>
+                                </div>
+                              )}
+                            </>
+                          ) : (
+                            <span>{log.providerName || "-"}</span>
+                          )}
+                        </div>
+                        {/* 显示供应商倍率 Badge(不为 1.0 时) */}
+                        {(() => {
+                          // 从决策链中找到最后一个成功的供应商,使用它的倍率
+                          const successfulProvider = log.providerChain && log.providerChain.length > 0
+                            ? [...log.providerChain]
+                                .reverse()
+                                .find(item =>
+                                  item.reason === 'request_success' ||
+                                  item.reason === 'retry_success'
+                                )
+                            : null;
+
+                          const actualCostMultiplier = successfulProvider?.costMultiplier ?? log.costMultiplier;
+
+                          return actualCostMultiplier && parseFloat(String(actualCostMultiplier)) !== 1.0 ? (
+                            <Badge
+                              variant="outline"
+                              className={
+                                parseFloat(String(actualCostMultiplier)) > 1.0
+                                  ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0"
+                                  : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0"
+                              }
+                            >
+                              ×{parseFloat(String(actualCostMultiplier)).toFixed(2)}
+                            </Badge>
+                          ) : null;
+                        })()}
+                      </div>
+                    )}
+                  </TableCell>
+                  <TableCell className="font-mono text-xs">
+                    <ModelDisplayWithRedirect
+                      originalModel={log.originalModel}
+                      currentModel={log.model}
+                    />
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {formatTokenAmount(log.inputTokens)}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {formatTokenAmount(log.outputTokens)}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {formatTokenAmount(log.cacheCreationInputTokens)}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {formatTokenAmount(log.cacheReadInputTokens)}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {log.costUsd ? formatCurrency(log.costUsd, currencyCode, 6) : "-"}
+                  </TableCell>
+                  <TableCell className="text-right font-mono text-xs">
+                    {formatDuration(log.durationMs)}
+                  </TableCell>
+                  <TableCell>
+                    <ErrorDetailsDialog
+                      statusCode={log.statusCode}
+                      errorMessage={log.errorMessage}
+                      providerChain={log.providerChain}
+                      sessionId={log.sessionId}
+                      blockedBy={log.blockedBy}
+                      blockedReason={log.blockedReason}
+                      originalModel={log.originalModel}
+                      currentModel={log.model}
+                      userAgent={log.userAgent}
+                      messagesCount={log.messagesCount}
+                    />
+                  </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>
+  );
+}

+ 335 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx

@@ -0,0 +1,335 @@
+"use client";
+
+import { useState, useTransition, useEffect, useRef } 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 { Button } from "@/components/ui/button";
+import { RefreshCw, Pause, Play } from "lucide-react";
+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";
+import type { Key } from "@/types/key";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import { formatCurrency } from "@/lib/utils/currency";
+import { formatTokenAmount } from "@/lib/utils";
+
+/**
+ * 将 Date 对象格式化为 datetime-local 格式的字符串
+ * 用于 URL 参数传递,保持本地时区
+ */
+function formatDateTimeLocal(date: Date): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  const hours = String(date.getHours()).padStart(2, '0');
+  const minutes = String(date.getMinutes()).padStart(2, '0');
+  return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+interface UsageLogsViewProps {
+  isAdmin: boolean;
+  users: UserDisplay[];
+  providers: ProviderDisplay[];
+  initialKeys: Key[];
+  searchParams: { [key: string]: string | string[] | undefined };
+  currencyCode?: CurrencyCode;
+}
+
+export function UsageLogsView({
+  isAdmin,
+  users,
+  providers,
+  initialKeys,
+  searchParams,
+  currencyCode = "USD",
+}: 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);
+  const [isAutoRefresh, setIsAutoRefresh] = useState(true);
+  const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+
+  // 追踪新增记录(用于动画高亮)
+  const [newLogIds, setNewLogIds] = useState<Set<number>>(new Set());
+  const previousLogsRef = useRef<Map<number, boolean>>(new Map());
+  const previousParamsRef = useRef<string>('');
+
+  // 从 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,
+  };
+
+  // 使用 ref 来存储最新的值,避免闭包陷阱
+  const isPendingRef = useRef(isPending);
+  const filtersRef = useRef(filters);
+
+  isPendingRef.current = isPending;
+
+  // 更新 filtersRef
+  filtersRef.current = filters;
+
+  // 加载数据
+  // shouldDetectNew: 是否检测新增记录(只在刷新时为 true,筛选/翻页时为 false)
+  const loadData = async (shouldDetectNew = false) => {
+    startTransition(async () => {
+      const result = await getUsageLogs(filtersRef.current);
+      if (result.ok && result.data) {
+        // 只在刷新时检测新增(非筛选/翻页)
+        if (shouldDetectNew && previousLogsRef.current.size > 0) {
+          const newIds = result.data.logs
+            .filter(log => !previousLogsRef.current.has(log.id))
+            .map(log => log.id)
+            .slice(0, 10); // 限制最多高亮 10 条
+
+          if (newIds.length > 0) {
+            setNewLogIds(new Set(newIds));
+            // 800ms 后清除高亮
+            setTimeout(() => setNewLogIds(new Set()), 800);
+          }
+        }
+
+        // 更新记录缓存
+        previousLogsRef.current = new Map(
+          result.data.logs.map(log => [log.id, true])
+        );
+
+        setData(result.data);
+        setError(null);
+      } else {
+        setError(!result.ok && 'error' in result ? result.error : "加载失败");
+        setData(null);
+      }
+    });
+  };
+
+  // 手动刷新(检测新增)
+  const handleManualRefresh = async () => {
+    setIsManualRefreshing(true);
+    await loadData(true); // 刷新时检测新增
+    setTimeout(() => setIsManualRefreshing(false), 500);
+  };
+
+  // 监听 URL 参数变化(筛选/翻页时重置缓存)
+  useEffect(() => {
+    const currentParams = params.toString();
+
+    if (previousParamsRef.current && previousParamsRef.current !== currentParams) {
+      // URL 变化 = 用户操作(筛选/翻页),重置缓存,不检测新增
+      previousLogsRef.current = new Map();
+      loadData(false);
+    } else if (!previousParamsRef.current) {
+      // 首次加载,不检测新增
+      loadData(false);
+    }
+
+    previousParamsRef.current = currentParams;
+  }, [params]);
+
+  // 自动轮询(3秒间隔,检测新增)
+  useEffect(() => {
+    if (!isAutoRefresh) return;
+
+    const intervalId = setInterval(() => {
+      // 如果正在加载,跳过本次轮询
+      if (isPendingRef.current) return;
+      loadData(true); // 自动刷新时检测新增
+    }, 3000); // 3 秒间隔
+
+    return () => clearInterval(intervalId);
+  }, [isAutoRefresh]);  
+
+  // 处理筛选条件变更
+  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());
+    // 使用本地时间格式传递,而不是 ISO(UTC)格式
+    if (newFilters.startDate) query.set("startDate", formatDateTimeLocal(newFilters.startDate));
+    if (newFilters.endDate) query.set("endDate", formatDateTimeLocal(newFilters.endDate));
+    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-4">
+          <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">
+                {formatCurrency(data.summary.totalCost, currencyCode)}
+              </CardTitle>
+            </CardHeader>
+          </Card>
+
+          <Card>
+            <CardHeader className="pb-3">
+              <CardDescription>总 Token 数</CardDescription>
+              <CardTitle className="text-3xl font-mono">
+                {formatTokenAmount(data.summary.totalTokens)}
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="text-xs text-muted-foreground space-y-1">
+              <div className="flex justify-between">
+                <span>输入:</span>
+                <span className="font-mono">{formatTokenAmount(data.summary.totalInputTokens)}</span>
+              </div>
+              <div className="flex justify-between">
+                <span>输出:</span>
+                <span className="font-mono">{formatTokenAmount(data.summary.totalOutputTokens)}</span>
+              </div>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader className="pb-3">
+              <CardDescription>缓存 Token</CardDescription>
+              <CardTitle className="text-3xl font-mono">
+                {formatTokenAmount(
+                  data.summary.totalCacheCreationTokens + data.summary.totalCacheReadTokens
+                )}
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="text-xs text-muted-foreground space-y-1">
+              <div className="flex justify-between">
+                <span>写入:</span>
+                <span className="font-mono">
+                  {formatTokenAmount(data.summary.totalCacheCreationTokens)}
+                </span>
+              </div>
+              <div className="flex justify-between">
+                <span>读取:</span>
+                <span className="font-mono">
+                  {formatTokenAmount(data.summary.totalCacheReadTokens)}
+                </span>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
+      {/* 筛选器 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>筛选条件</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <UsageLogsFilters
+            isAdmin={isAdmin}
+            users={users}
+            providers={providers}
+            initialKeys={initialKeys}
+            filters={filters}
+            onChange={handleFilterChange}
+            onReset={() => router.push("/dashboard/logs")}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 数据表格 */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <CardTitle>使用记录</CardTitle>
+            <div className="flex items-center gap-2">
+              {/* 手动刷新按钮 */}
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={handleManualRefresh}
+                disabled={isPending}
+                className="gap-2"
+              >
+                <RefreshCw
+                  className={`h-4 w-4 ${isManualRefreshing ? 'animate-spin' : ''}`}
+                />
+                刷新
+              </Button>
+
+              {/* 自动刷新开关 */}
+              <Button
+                variant={isAutoRefresh ? "default" : "outline"}
+                size="sm"
+                onClick={() => setIsAutoRefresh(!isAutoRefresh)}
+                className="gap-2"
+              >
+                {isAutoRefresh ? (
+                  <>
+                    <Pause className="h-4 w-4" />
+                    停止自动刷新
+                  </>
+                ) : (
+                  <>
+                    <Play className="h-4 w-4" />
+                    开启自动刷新
+                  </>
+                )}
+              </Button>
+            </div>
+          </div>
+        </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}
+              newLogIds={newLogIds}
+              currencyCode={currencyCode}
+            />
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 59 - 0
src/app/[locale]/dashboard/logs/page.tsx

@@ -0,0 +1,59 @@
+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 { ActiveSessionsPanel } from "@/components/customs/active-sessions-panel";
+import { getUsers } from "@/actions/users";
+import { getProviders } from "@/actions/providers";
+import { getKeys } from "@/actions/keys";
+import { getSystemSettings } from "@/repository/system-config";
+
+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";
+
+  // 管理员:获取用户和供应商列表
+  // 非管理员:获取当前用户的 Keys 列表
+  const [users, providers, initialKeys, resolvedSearchParams, systemSettings] = isAdmin
+    ? await Promise.all([getUsers(), getProviders(), Promise.resolve({ ok: true, data: [] }), searchParams, getSystemSettings()])
+    : await Promise.all([
+        Promise.resolve([]),
+        Promise.resolve([]),
+        getKeys(session.user.id),
+        searchParams,
+        getSystemSettings(),
+      ]);
+
+  return (
+    <div className="space-y-6">
+      <ActiveSessionsPanel currencyCode={systemSettings.currencyDisplay} />
+
+      <Section
+        title="使用记录"
+        description="查看 API 调用日志和使用统计"
+      >
+        <Suspense fallback={<div className="text-center py-8 text-muted-foreground">加载中...</div>}>
+          <UsageLogsView
+            isAdmin={isAdmin}
+            users={users}
+            providers={providers}
+            initialKeys={initialKeys.ok ? initialKeys.data : []}
+            searchParams={resolvedSearchParams}
+            currencyCode={systemSettings.currencyDisplay}
+          />
+        </Suspense>
+      </Section>
+    </div>
+  );
+}

+ 120 - 0
src/app/[locale]/settings/logs/_components/log-level-form.tsx

@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { toast } from "sonner";
+
+type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
+
+const LOG_LEVELS: { value: LogLevel; label: string; description: string }[] = [
+  { value: 'fatal', label: 'Fatal', description: '仅致命错误' },
+  { value: 'error', label: 'Error', description: '错误信息' },
+  { value: 'warn', label: 'Warn', description: '警告 + 错误' },
+  { value: 'info', label: 'Info', description: '关键业务事件 + 警告 + 错误(推荐生产)' },
+  { value: 'debug', label: 'Debug', description: '调试信息 + 所有级别(推荐开发)' },
+  { value: 'trace', label: 'Trace', description: '极详细追踪 + 所有级别' },
+];
+
+export function LogLevelForm() {
+  const [currentLevel, setCurrentLevel] = useState<LogLevel>('info');
+  const [selectedLevel, setSelectedLevel] = useState<LogLevel>('info');
+  const [isPending, startTransition] = useTransition();
+
+  // 获取当前日志级别
+  useEffect(() => {
+    fetch('/api/admin/log-level')
+      .then((res) => res.json())
+      .then((data) => {
+        setCurrentLevel(data.level);
+        setSelectedLevel(data.level);
+      })
+      .catch(() => {
+        toast.error('获取日志级别失败');
+      });
+  }, []);
+
+  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    startTransition(async () => {
+      try {
+        const response = await fetch('/api/admin/log-level', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ level: selectedLevel }),
+        });
+
+        const result = await response.json();
+
+        if (!response.ok) {
+          toast.error(result.error || '设置失败');
+          return;
+        }
+
+        setCurrentLevel(selectedLevel);
+        toast.success(`日志级别已设置为: ${selectedLevel.toUpperCase()}`);
+      } catch {
+        toast.error('设置日志级别失败');
+      }
+    });
+  };
+
+  return (
+    <form onSubmit={handleSubmit} className="space-y-6">
+      <div className="space-y-2">
+        <Label htmlFor="log-level">当前日志级别</Label>
+        <Select value={selectedLevel} onValueChange={(value) => setSelectedLevel(value as LogLevel)}>
+          <SelectTrigger id="log-level" disabled={isPending}>
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            {LOG_LEVELS.map((level) => (
+              <SelectItem key={level.value} value={level.value}>
+                <div className="flex flex-col">
+                  <span className="font-medium">{level.label}</span>
+                  <span className="text-xs text-muted-foreground">{level.description}</span>
+                </div>
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+        <p className="text-xs text-muted-foreground">
+          调整日志级别后立即生效,无需重启服务。
+        </p>
+      </div>
+
+      <div className="rounded-lg border border-dashed border-border px-4 py-3 space-y-2">
+        <h4 className="text-sm font-medium">日志级别说明</h4>
+        <ul className="text-xs text-muted-foreground space-y-1">
+          <li><strong>Fatal/Error</strong>: 仅显示错误,日志最少,适合高负载生产环境</li>
+          <li><strong>Warn</strong>: 包含警告(限流触发、熔断器打开等)+ 错误</li>
+          <li><strong>Info(推荐生产)</strong>: 显示关键业务事件(供应商选择、Session 复用、价格同步)+ 警告 + 错误</li>
+          <li><strong>Debug(推荐开发)</strong>: 包含详细调试信息,适合排查问题时使用</li>
+          <li><strong>Trace</strong>: 极详细的追踪信息,包含所有细节</li>
+        </ul>
+      </div>
+
+      {selectedLevel !== currentLevel && (
+        <div className="rounded-lg bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 px-4 py-3">
+          <p className="text-sm text-orange-800 dark:text-orange-200">
+            当前级别为 <strong>{currentLevel.toUpperCase()}</strong>,点击保存后将切换到 <strong>{selectedLevel.toUpperCase()}</strong>
+          </p>
+        </div>
+      )}
+
+      <div className="flex justify-end">
+        <Button type="submit" disabled={isPending || selectedLevel === currentLevel}>
+          {isPending ? "保存中..." : "保存设置"}
+        </Button>
+      </div>
+    </form>
+  );
+}

+ 26 - 0
src/app/[locale]/settings/logs/page.tsx

@@ -0,0 +1,26 @@
+import { getTranslations } from "next-intl/server";
+import { Section } from "@/components/section";
+import { SettingsPageHeader } from "../_components/settings-page-header";
+import { LogLevelForm } from "./_components/log-level-form";
+
+export const dynamic = "force-dynamic";
+
+export default async function SettingsLogsPage() {
+  const t = await getTranslations("settings");
+
+  return (
+    <>
+      <SettingsPageHeader
+        title={t("logs.title")}
+        description={t("logs.description")}
+      />
+
+      <Section
+        title={t("logs.section.title")}
+        description={t("logs.section.description")}
+      >
+        <LogLevelForm />
+      </Section>
+    </>
+  );
+}

+ 120 - 0
src/app/\[locale\]/settings/logs/_components/log-level-form.tsx

@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { toast } from "sonner";
+
+type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
+
+const LOG_LEVELS: { value: LogLevel; label: string; description: string }[] = [
+  { value: 'fatal', label: 'Fatal', description: '仅致命错误' },
+  { value: 'error', label: 'Error', description: '错误信息' },
+  { value: 'warn', label: 'Warn', description: '警告 + 错误' },
+  { value: 'info', label: 'Info', description: '关键业务事件 + 警告 + 错误(推荐生产)' },
+  { value: 'debug', label: 'Debug', description: '调试信息 + 所有级别(推荐开发)' },
+  { value: 'trace', label: 'Trace', description: '极详细追踪 + 所有级别' },
+];
+
+export function LogLevelForm() {
+  const [currentLevel, setCurrentLevel] = useState<LogLevel>('info');
+  const [selectedLevel, setSelectedLevel] = useState<LogLevel>('info');
+  const [isPending, startTransition] = useTransition();
+
+  // 获取当前日志级别
+  useEffect(() => {
+    fetch('/api/admin/log-level')
+      .then((res) => res.json())
+      .then((data) => {
+        setCurrentLevel(data.level);
+        setSelectedLevel(data.level);
+      })
+      .catch(() => {
+        toast.error('获取日志级别失败');
+      });
+  }, []);
+
+  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    startTransition(async () => {
+      try {
+        const response = await fetch('/api/admin/log-level', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ level: selectedLevel }),
+        });
+
+        const result = await response.json();
+
+        if (!response.ok) {
+          toast.error(result.error || '设置失败');
+          return;
+        }
+
+        setCurrentLevel(selectedLevel);
+        toast.success(`日志级别已设置为: ${selectedLevel.toUpperCase()}`);
+      } catch {
+        toast.error('设置日志级别失败');
+      }
+    });
+  };
+
+  return (
+    <form onSubmit={handleSubmit} className="space-y-6">
+      <div className="space-y-2">
+        <Label htmlFor="log-level">当前日志级别</Label>
+        <Select value={selectedLevel} onValueChange={(value) => setSelectedLevel(value as LogLevel)}>
+          <SelectTrigger id="log-level" disabled={isPending}>
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            {LOG_LEVELS.map((level) => (
+              <SelectItem key={level.value} value={level.value}>
+                <div className="flex flex-col">
+                  <span className="font-medium">{level.label}</span>
+                  <span className="text-xs text-muted-foreground">{level.description}</span>
+                </div>
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+        <p className="text-xs text-muted-foreground">
+          调整日志级别后立即生效,无需重启服务。
+        </p>
+      </div>
+
+      <div className="rounded-lg border border-dashed border-border px-4 py-3 space-y-2">
+        <h4 className="text-sm font-medium">日志级别说明</h4>
+        <ul className="text-xs text-muted-foreground space-y-1">
+          <li><strong>Fatal/Error</strong>: 仅显示错误,日志最少,适合高负载生产环境</li>
+          <li><strong>Warn</strong>: 包含警告(限流触发、熔断器打开等)+ 错误</li>
+          <li><strong>Info(推荐生产)</strong>: 显示关键业务事件(供应商选择、Session 复用、价格同步)+ 警告 + 错误</li>
+          <li><strong>Debug(推荐开发)</strong>: 包含详细调试信息,适合排查问题时使用</li>
+          <li><strong>Trace</strong>: 极详细的追踪信息,包含所有细节</li>
+        </ul>
+      </div>
+
+      {selectedLevel !== currentLevel && (
+        <div className="rounded-lg bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 px-4 py-3">
+          <p className="text-sm text-orange-800 dark:text-orange-200">
+            当前级别为 <strong>{currentLevel.toUpperCase()}</strong>,点击保存后将切换到 <strong>{selectedLevel.toUpperCase()}</strong>
+          </p>
+        </div>
+      )}
+
+      <div className="flex justify-end">
+        <Button type="submit" disabled={isPending || selectedLevel === currentLevel}>
+          {isPending ? "保存中..." : "保存设置"}
+        </Button>
+      </div>
+    </form>
+  );
+}