Przeglądaj źródła

fix(currency): respect system currencyDisplay setting in UI (#717)

Fixes #678 - Currency display unit configuration was not applied.

Root cause:
- `users-page-client.tsx` hardcoded `currencyCode="USD"`
- `UserLimitBadge` and `LimitStatusIndicator` had hardcoded `unit="$"` default
- `big-screen/page.tsx` used hardcoded "$" in multiple places

Changes:
- Add `getCurrencySymbol()` helper function to currency.ts
- Fetch system settings in `users-page-client.tsx` and pass to table
- Pass `currencySymbol` from `user-key-table-row.tsx` to limit badges
- Remove hardcoded "$" defaults from badge components
- Update big-screen page to fetch settings and use dynamic symbol
- Add unit tests for `getCurrencySymbol`

Co-authored-by: Claude Opus 4.5 <[email protected]>
Ding 1 tydzień temu
rodzic
commit
277c02f9e3

+ 1 - 1
src/app/[locale]/dashboard/_components/user/limit-status-indicator.tsx

@@ -47,7 +47,7 @@ export function LimitStatusIndicator({
   label,
   variant = "default",
   showPercentage = false,
-  unit = "$",
+  unit = "",
 }: LimitStatusIndicatorProps) {
   const isSet = typeof value === "number" && Number.isFinite(value);
   const hasUsage = typeof usage === "number" && Number.isFinite(usage);

+ 9 - 0
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
 import { useRouter } from "@/i18n/routing";
 import { cn } from "@/lib/utils";
 import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
+import { getCurrencySymbol } from "@/lib/utils/currency";
 import { formatDate } from "@/lib/utils/date-format";
 import type { UserDisplay } from "@/types/user";
 import { EditKeyDialog } from "./edit-key-dialog";
@@ -203,6 +204,9 @@ export function UserKeyTableRow({
   const limitTotal = normalizeLimitValue(user.limitTotalUsd);
   const limitSessions = normalizeLimitValue(user.limitConcurrentSessions);
 
+  // Convert currencyCode to symbol for display
+  const currencySymbol = getCurrencySymbol(currencyCode);
+
   const handleDeleteKey = (keyId: number) => {
     startTransition(async () => {
       const res = await removeKey(keyId);
@@ -404,6 +408,7 @@ export function UserKeyTableRow({
             limitType="5h"
             limit={limit5h}
             label={translations.columns.limit5h}
+            unit={currencySymbol}
           />
         </div>
 
@@ -414,6 +419,7 @@ export function UserKeyTableRow({
             limitType="daily"
             limit={limitDaily}
             label={translations.columns.limitDaily}
+            unit={currencySymbol}
           />
         </div>
 
@@ -424,6 +430,7 @@ export function UserKeyTableRow({
             limitType="weekly"
             limit={limitWeekly}
             label={translations.columns.limitWeekly}
+            unit={currencySymbol}
           />
         </div>
 
@@ -434,6 +441,7 @@ export function UserKeyTableRow({
             limitType="monthly"
             limit={limitMonthly}
             label={translations.columns.limitMonthly}
+            unit={currencySymbol}
           />
         </div>
 
@@ -444,6 +452,7 @@ export function UserKeyTableRow({
             limitType="total"
             limit={limitTotal}
             label={translations.columns.limitTotal}
+            unit={currencySymbol}
           />
         </div>
 

+ 1 - 1
src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx

@@ -70,7 +70,7 @@ export function UserLimitBadge({
   limitType,
   limit,
   label,
-  unit = "$",
+  unit = "",
 }: UserLimitBadgeProps) {
   const [usageData, setUsageData] = useState<LimitUsageData | null>(null);
   const [isLoading, setIsLoading] = useState(false);

+ 13 - 1
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -23,6 +23,7 @@ import {
 import { Skeleton } from "@/components/ui/skeleton";
 import { TagInput } from "@/components/ui/tag-input";
 import { useDebounce } from "@/lib/hooks/use-debounce";
+import type { CurrencyCode } from "@/lib/utils/currency";
 import type { User, UserDisplay } from "@/types/user";
 import { AddKeyDialog } from "../_components/user/add-key-dialog";
 import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
@@ -191,6 +192,17 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     enabled: isAdmin,
   });
 
+  // Fetch system settings for currency display
+  const { data: systemSettings } = useQuery({
+    queryKey: ["system-settings"],
+    queryFn: async () => {
+      const response = await fetch("/api/system-settings");
+      if (!response.ok) throw new Error("Failed to fetch settings");
+      return response.json() as Promise<{ currencyDisplay: CurrencyCode }>;
+    },
+    staleTime: 30_000,
+  });
+
   const allUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]);
   const visibleUsers = useMemo(() => {
     if (isAdmin) return allUsers;
@@ -691,7 +703,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             onLoadMore={fetchNextPage}
             scrollResetKey={scrollResetKey}
             currentUser={currentUser}
-            currencyCode="USD"
+            currencyCode={systemSettings?.currencyDisplay ?? "USD"}
             onCreateUser={isAdmin ? handleCreateUser : handleCreateKey}
             onAddKey={handleAddKey}
             highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined}

+ 30 - 5
src/app/[locale]/internal/dashboard/big-screen/page.tsx

@@ -39,6 +39,7 @@ import useSWR from "swr";
 import { getDashboardRealtimeData } from "@/actions/dashboard-realtime";
 import { type Locale, localeLabels, locales } from "@/i18n/config";
 import { usePathname, useRouter } from "@/i18n/routing";
+import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils/currency";
 
 /**
  * ============================================================================
@@ -411,6 +412,7 @@ const UserRankings = ({
   users,
   theme,
   t,
+  currencySymbol,
 }: {
   users: Array<{
     userId: number;
@@ -420,6 +422,7 @@ const UserRankings = ({
   }>;
   theme: (typeof THEMES)[keyof typeof THEMES];
   t: (key: string) => string;
+  currencySymbol: string;
 }) => {
   return (
     <div className="h-full flex flex-col relative">
@@ -467,7 +470,8 @@ const UserRankings = ({
               <div className="flex justify-between items-center">
                 <span className={`text-xs font-bold truncate ${theme.text}`}>{user.userName}</span>
                 <span className="text-[10px] text-gray-500 font-mono">
-                  ${Number(user.totalCost).toFixed(2)}
+                  {currencySymbol}
+                  {Number(user.totalCost).toFixed(2)}
                 </span>
               </div>
               <div className="flex justify-between items-center mt-1">
@@ -493,6 +497,7 @@ const ProviderRanking = ({
   providers,
   theme,
   t,
+  currencySymbol,
 }: {
   providers: Array<{
     providerId: number;
@@ -502,6 +507,7 @@ const ProviderRanking = ({
   }>;
   theme: (typeof THEMES)[keyof typeof THEMES];
   t: (key: string) => string;
+  currencySymbol: string;
 }) => {
   return (
     <div className="h-full flex flex-col">
@@ -523,7 +529,8 @@ const ProviderRanking = ({
             </div>
             <div className="text-right">
               <div className={`text-xs font-mono ${theme.accent}`}>
-                ${Number(p.totalCost).toFixed(2)}
+                {currencySymbol}
+                {Number(p.totalCost).toFixed(2)}
               </div>
               <div className="text-[9px] text-gray-500">
                 {p.totalTokens.toLocaleString()} Tokens
@@ -741,6 +748,19 @@ export default function BigScreenPage() {
     }
   );
 
+  // Fetch system settings for currency display
+  const { data: systemSettings } = useSWR(
+    "system-settings",
+    async () => {
+      const response = await fetch("/api/system-settings");
+      if (!response.ok) throw new Error("Failed to fetch settings");
+      return response.json() as Promise<{ currencyDisplay: CurrencyCode }>;
+    },
+    { revalidateOnFocus: false }
+  );
+
+  const currencySymbol = CURRENCY_CONFIG[systemSettings?.currencyDisplay ?? "USD"]?.symbol ?? "$";
+
   // 处理数据
   const metrics = data?.metrics || {
     concurrentSessions: 0,
@@ -837,7 +857,7 @@ export default function BigScreenPage() {
           />
           <MetricCard
             title={t("metrics.cost")}
-            value={<CountUp value={metrics.todayCost} prefix="$" decimals={2} />}
+            value={<CountUp value={metrics.todayCost} prefix={currencySymbol} decimals={2} />}
             subValue="Budget"
             type="neutral"
             icon={DollarSign}
@@ -866,10 +886,15 @@ export default function BigScreenPage() {
           {/* LEFT COL */}
           <div className="col-span-3 flex flex-col gap-4 h-full">
             <div className={`flex-[3] ${theme.card} rounded-lg p-4 overflow-hidden`}>
-              <UserRankings users={users} theme={theme} t={t} />
+              <UserRankings users={users} theme={theme} t={t} currencySymbol={currencySymbol} />
             </div>
             <div className={`flex-[2] ${theme.card} rounded-lg p-4 overflow-hidden`}>
-              <ProviderRanking providers={providerRankings} theme={theme} t={t} />
+              <ProviderRanking
+                providers={providerRankings}
+                theme={theme}
+                t={t}
+                currencySymbol={currencySymbol}
+              />
             </div>
           </div>
 

+ 12 - 0
src/lib/utils/currency.ts

@@ -132,4 +132,16 @@ export function formatCurrency(
   return `${config.symbol}${formatted}`;
 }
 
+/**
+ * Get currency symbol from currency code
+ * @param currencyCode - Currency code (default "USD")
+ * @returns Currency symbol (e.g., "$", "¥", "€")
+ */
+export function getCurrencySymbol(currencyCode?: CurrencyCode | string): string {
+  if (!currencyCode || !(currencyCode in CURRENCY_CONFIG)) {
+    return CURRENCY_CONFIG.USD.symbol;
+  }
+  return CURRENCY_CONFIG[currencyCode as CurrencyCode].symbol;
+}
+
 export { Decimal };

+ 36 - 0
tests/unit/lib/utils/currency.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+import { getCurrencySymbol, CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils/currency";
+
+describe("getCurrencySymbol", () => {
+  test("returns correct symbol for valid currency codes", () => {
+    expect(getCurrencySymbol("USD")).toBe("$");
+    expect(getCurrencySymbol("CNY")).toBe("\u00a5");
+    expect(getCurrencySymbol("EUR")).toBe("\u20ac");
+    expect(getCurrencySymbol("JPY")).toBe("\u00a5");
+    expect(getCurrencySymbol("GBP")).toBe("\u00a3");
+    expect(getCurrencySymbol("HKD")).toBe("HK$");
+    expect(getCurrencySymbol("TWD")).toBe("NT$");
+    expect(getCurrencySymbol("KRW")).toBe("\u20a9");
+    expect(getCurrencySymbol("SGD")).toBe("S$");
+  });
+
+  test("returns USD symbol for undefined", () => {
+    expect(getCurrencySymbol()).toBe("$");
+    expect(getCurrencySymbol(undefined)).toBe("$");
+  });
+
+  test("returns USD symbol for invalid currency code", () => {
+    expect(getCurrencySymbol("INVALID")).toBe("$");
+    expect(getCurrencySymbol("")).toBe("$");
+    expect(getCurrencySymbol("usd")).toBe("$"); // case-sensitive
+  });
+
+  test("all CURRENCY_CONFIG entries have valid symbols", () => {
+    const codes: CurrencyCode[] = ["USD", "CNY", "EUR", "JPY", "GBP", "HKD", "TWD", "KRW", "SGD"];
+    for (const code of codes) {
+      const symbol = getCurrencySymbol(code);
+      expect(symbol).toBe(CURRENCY_CONFIG[code].symbol);
+      expect(symbol.length).toBeGreaterThan(0);
+    }
+  });
+});