From b819894e7548a6c9a0b7cea8e2bb212407a37be6 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 1 Jun 2026 13:38:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20iframe=20=E9=80=9A?= =?UTF-8?q?=E4=BF=A1=E6=9C=BA=E5=88=B6=E4=B8=8E=E9=80=9A=E7=9F=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。 更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。 重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。 增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。 更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。 --- src/components/iframe-bridge.tsx | 10 ++- .../layout/player-notification-bell.tsx | 10 +-- src/features/hall/use-hall-draw-live.ts | 8 -- src/features/player/hydrate-player-auth.tsx | 10 +++ src/features/player/notifications-screen.tsx | 45 +++++++--- src/hooks/use-pending-wallet-reconcile.ts | 39 +++++++- src/hooks/use-token-refresh.ts | 5 +- src/i18n/locales/en/common.json | 2 +- src/i18n/locales/en/player.json | 17 +++- src/i18n/locales/ne/common.json | 2 +- src/i18n/locales/ne/player.json | 17 +++- src/i18n/locales/zh/common.json | 2 +- src/i18n/locales/zh/player.json | 17 +++- src/lib/currency-display-settings.ts | 88 +++++++++++++++++++ src/lib/iframe-origins.ts | 74 ++++++++++++++-- src/lib/money.ts | 75 +++++++++++++--- src/lib/pending-reconcile-notification.ts | 13 +++ 17 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 src/lib/currency-display-settings.ts create mode 100644 src/lib/pending-reconcile-notification.ts diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index e8acb34..de580ea 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -7,6 +7,7 @@ import { setPlayerBearerToken } from "@/lib/lottery-auth"; import { isIframeOriginAllowed, loadIframeAllowedOrigins, + resolvePostMessageTargetOrigin, } from "@/lib/iframe-origins"; /** @@ -34,7 +35,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { timestamp: Date.now(), source: "lottery-iframe", }, - "*", // 生产环境应指定具体域名 + resolvePostMessageTargetOrigin(), ); }, [], @@ -93,7 +94,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { const handleMessage = async (event: MessageEvent): Promise => { if (!isIframeOriginAllowed(event.origin)) { - await loadIframeAllowedOrigins(); + await loadIframeAllowedOrigins(true); if (!isIframeOriginAllowed(event.origin)) { console.warn("[IframeBridge] Rejected message from:", event.origin); return; @@ -159,6 +160,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { notifyReady(); }); + const originsRefresh = setInterval(() => { + void loadIframeAllowedOrigins(true); + }, 60_000); + // 定期发送心跳 const heartbeat = setInterval(() => { sendToParent("HEARTBEAT", { @@ -168,6 +173,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { return () => { window.removeEventListener("message", handleMessage); + clearInterval(originsRefresh); clearInterval(heartbeat); }; }, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]); diff --git a/src/components/layout/player-notification-bell.tsx b/src/components/layout/player-notification-bell.tsx index 52e7472..e3a62c5 100644 --- a/src/components/layout/player-notification-bell.tsx +++ b/src/components/layout/player-notification-bell.tsx @@ -2,7 +2,7 @@ import { Bell } from "lucide-react"; import Link from "next/link"; -import { useEffect, type ReactElement } from "react"; +import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; @@ -13,14 +13,10 @@ type PlayerNotificationBellProps = { className?: string; }; -/** 顶栏铃铛:待对账划转等提醒(Popover 右上角展开) */ +/** 顶栏铃铛:待对账划转提醒 */ export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement { const { t } = useTranslation("common"); - const { hasUnread, refresh } = usePendingWalletReconcile(); - - useEffect(() => { - void refresh(); - }, [refresh]); + const { hasUnread } = usePendingWalletReconcile(); return ( window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); }, [load]); - // 初始加载 - useEffect(() => { - const timer = window.setTimeout(() => { - void load(); - }, 0); - return () => window.clearTimeout(timer); - }, [load]); - // 爆池等场景:刷新大厅快照(含奖池余额) useEffect(() => { const onHallRefresh = () => { diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index cf494c4..dce17dc 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { getPublicCurrencies } from "@/api/currency"; import { getPlayerMe } from "@/api/player"; +import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings"; import { usePlayerSessionStore } from "@/stores/player-session-store"; /** @@ -19,6 +20,11 @@ export function HydratePlayerAuth(): null { useEffect(() => { usePlayerSessionStore.getState().reconcileSelectedCurrency(); + void loadCurrencyDisplayFormat(); + const refreshFormat = setInterval(() => { + void loadCurrencyDisplayFormat(true); + }, 60_000); + const token = restoreBearerToken(); void (async () => { try { @@ -40,6 +46,10 @@ export function HydratePlayerAuth(): null { /* 401 由 lottery-http 拦截跳转 */ } })(); + + return () => { + clearInterval(refreshFormat); + }; }, [restoreBearerToken, setCurrencies, setProfile]); return null; diff --git a/src/features/player/notifications-screen.tsx b/src/features/player/notifications-screen.tsx index 7b5fe35..e9a3ce0 100644 --- a/src/features/player/notifications-screen.tsx +++ b/src/features/player/notifications-screen.tsx @@ -6,10 +6,13 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { PlayerPanel } from "@/components/layout/player-panel"; -import { logTypeLabel } from "@/features/wallet/wallet-logs-block"; import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile"; import { formatLocalDateTime } from "@/lib/format-local-datetime"; import { formatMinorAsCurrency } from "@/lib/money"; +import { + pendingReconcileDescriptionKey, + pendingReconcileTitleKey, +} from "@/lib/pending-reconcile-notification"; import { cn } from "@/lib/utils"; export function NotificationsScreen() { @@ -21,6 +24,10 @@ export function NotificationsScreen() { return (
+

+ {t("notifications.subtitle")} +

+

{t("notifications.unreadCount", { count: unreadCount })} @@ -67,26 +74,36 @@ export function NotificationsScreen() { >

-

- {logTypeLabel(item.type, t)} +

+ {t(pendingReconcileTitleKey(item.type))}

{formatLocalDateTime(item.created_at)}

- - {cardRead ? t("notifications.read") : t("notifications.unread")} - +
+ + {t("notifications.pendingBadge")} + + + {cardRead ? t("notifications.read") : t("notifications.unread")} + +
+

+ {t(pendingReconcileDescriptionKey(item.type))} +

+

+ + {t("notifications.amountLabel")}{" "} + {formatMinorAsCurrency(item.amount, item.currency_code)}

@@ -105,7 +122,7 @@ export function NotificationsScreen() { className="inline-flex h-8 items-center rounded-full bg-[#07459f] px-3 text-xs font-semibold text-white hover:bg-[#063b88]" onClick={() => markAsRead(item.transfer_no)} > - {t("notifications.open")} + {t("notifications.viewLogs")}
diff --git a/src/hooks/use-pending-wallet-reconcile.ts b/src/hooks/use-pending-wallet-reconcile.ts index 09bb726..2837b4a 100644 --- a/src/hooks/use-pending-wallet-reconcile.ts +++ b/src/hooks/use-pending-wallet-reconcile.ts @@ -8,6 +8,37 @@ import type { WalletPendingTransfer } from "@/types/api/wallet-logs"; export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed"; const WALLET_NOTIFICATION_READ_KEY = "lottery:wallet-notification-read-transfer-nos"; +let pendingReconcileInFlight: Promise | null = null; +let pendingReconcileCache: WalletPendingTransfer[] | null = null; +let pendingReconcileFetchedAtMs = 0; +const PENDING_RECONCILE_CACHE_TTL_MS = 5_000; + +async function fetchPendingWalletReconcile(): Promise { + const now = Date.now(); + if ( + pendingReconcileCache !== null && + now - pendingReconcileFetchedAtMs < PENDING_RECONCILE_CACHE_TTL_MS + ) { + return pendingReconcileCache; + } + + if (pendingReconcileInFlight) { + return pendingReconcileInFlight; + } + + pendingReconcileInFlight = getWalletLogs({ page: 1, size: 1 }) + .then((data) => { + pendingReconcileCache = data.pending_reconcile ?? []; + pendingReconcileFetchedAtMs = Date.now(); + return pendingReconcileCache; + }) + .catch(() => []) + .finally(() => { + pendingReconcileInFlight = null; + }); + + return pendingReconcileInFlight; +} type WalletLogsRefreshDetail = { pending?: WalletPendingTransfer[]; @@ -54,14 +85,14 @@ export function usePendingWalletReconcile(): { const refresh = useCallback(async (): Promise => { if (!bearerToken?.trim()) { setPending([]); + pendingReconcileCache = null; + pendingReconcileFetchedAtMs = 0; return; } setLoading(true); try { - const data = await getWalletLogs({ page: 1, size: 1 }); - setPending(data.pending_reconcile ?? []); - } catch { - setPending([]); + const nextPending = await fetchPendingWalletReconcile(); + setPending(nextPending); } finally { setLoading(false); } diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index af2f814..bf84373 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -5,6 +5,7 @@ import { useErrorStore } from "@/stores/error-store"; import { isIframeOriginAllowed, loadIframeAllowedOrigins, + resolvePostMessageTargetOrigin, } from "@/lib/iframe-origins"; /** Token 过期前警告阈值(毫秒) */ @@ -91,7 +92,7 @@ export function useTokenRefresh(): { type: "LOTTERY_TOKEN_REFRESH_REQUEST", timestamp: Date.now(), }, - "*", // 或指定主站域名 + resolvePostMessageTargetOrigin(), ); }, []); @@ -124,7 +125,7 @@ export function useTokenRefresh(): { const handleMessage = async (event: MessageEvent): Promise => { if (!isIframeOriginAllowed(event.origin)) { - await loadIframeAllowedOrigins(); + await loadIframeAllowedOrigins(true); if (!isIframeOriginAllowed(event.origin)) { console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin); return; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ef36e98..a49af6a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -21,7 +21,7 @@ "option": "{{code}} · {{name}}" }, "navigation": { - "notifications": "Notifications" + "notifications": "Pending reconciliation" }, "errors": { "general": "General" diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 82d45dc..f354b93 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -27,11 +27,22 @@ "wallet": "Wallet" }, "notifications": { - "title": "Notifications", - "empty": "No notifications", + "title": "Pending reconciliation", + "subtitle": "These transfers are not finalized yet. They do not mean funds have been credited or debited. Contact support if this stays unresolved.", + "empty": "No pending reconciliation", + "pendingBadge": "Pending", + "amountLabel": "Amount:", + "pendingTitle": { + "transfer_in": "Transfer in — pending", + "transfer_out": "Transfer out — pending" + }, + "pendingDescription": { + "transfer_in": "Main-site credit is not confirmed. Your lottery wallet balance may not have increased yet. Contact support if main site already debited you.", + "transfer_out": "Main-site debit is not confirmed. Your lottery wallet balance may not have decreased yet. Check wallet logs or contact support." + }, "unread": "Unread", "read": "Read", - "open": "Open", + "viewLogs": "View logs", "markRead": "Mark as read", "markAllRead": "Mark all read", "unreadCount_one": "{{count}} unread", diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 5d48391..3b61545 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -21,7 +21,7 @@ "option": "{{code}} · {{name}}" }, "navigation": { - "notifications": "सूचनाहरू" + "notifications": "मिलान बाँकी" }, "errors": { "general": "सामान्य" diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index bd7afc2..3afa990 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -27,11 +27,22 @@ "wallet": "वालेट" }, "notifications": { - "title": "सूचनाहरू", - "empty": "कुनै सूचना छैन", + "title": "मिलान बाँकी सूचना", + "subtitle": "तलका स्थानान्तरण अझै अन्तिम रूपमा पुष्टि भएका छैनन्। सफल जम्मा वा कटौती भएको मान्नु हुँदैन। लामो समयसम्म अपडेट नभएमा सहायतामा सम्पर्क गर्नुहोस्।", + "empty": "मिलान बाँकी रेकर्ड छैन", + "pendingBadge": "मिलान बाँकी", + "amountLabel": "रकम:", + "pendingTitle": { + "transfer_in": "भित्र स्थानान्तरण — मिलान बाँकी", + "transfer_out": "बाहिर स्थानान्तरण — मिलान बाँकी" + }, + "pendingDescription": { + "transfer_in": "मुख्य साइटबाट रकम आएको पुष्टि भएको छैन। लटरी वालेट ब्यालेन्स बढ्न सक्छ। मुख्य साइटबाट कटौती भइसकेको भए सहायतामा सम्पर्क गर्नुहोस्।", + "transfer_out": "मुख्य साइटमा कटौती पुष्टि भएको छैन। लटरी वालेट ब्यालेन्स घट्न सक्छ। वालेट लग वा सहायतामा जाँच गर्नुहोस्।" + }, "unread": "नपढिएको", "read": "पढिएको", - "open": "खोल्नुहोस्", + "viewLogs": "लग हेर्नुहोस्", "markRead": "पढिएको चिन्ह लगाउनुहोस्", "markAllRead": "सबै पढिएको", "unreadCount_one": "{{count}} नपढिएको", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 299cc38..e1778a4 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -21,7 +21,7 @@ "option": "{{code}} · {{name}}" }, "navigation": { - "notifications": "通知" + "notifications": "待对账提醒" }, "errors": { "general": "通用" diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index ee603f1..9da3606 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -27,11 +27,22 @@ "wallet": "钱包" }, "notifications": { - "title": "通知", - "empty": "暂无通知", + "title": "待对账提醒", + "subtitle": "以下划转尚未最终确认,不代表已成功到账或扣款。若长时间未更新,请联系客服。", + "empty": "暂无待对账记录", + "pendingBadge": "待对账", + "amountLabel": "涉及金额:", + "pendingTitle": { + "transfer_in": "转入待对账", + "transfer_out": "转出待对账" + }, + "pendingDescription": { + "transfer_in": "主站到账结果未确认,彩票钱包余额可能尚未增加。若主站已扣款,请联系客服处理。", + "transfer_out": "主站扣款结果未确认,彩票钱包余额可能尚未减少。请在流水中核对或联系客服。" + }, "unread": "未读", "read": "已读", - "open": "打开", + "viewLogs": "查看流水", "markRead": "标记已读", "markAllRead": "全部已读", "unreadCount_one": "未读 {{count}} 条", diff --git a/src/lib/currency-display-settings.ts b/src/lib/currency-display-settings.ts new file mode 100644 index 0000000..1b7048b --- /dev/null +++ b/src/lib/currency-display-settings.ts @@ -0,0 +1,88 @@ +"use client"; + +import { getPublicSettings, type SettingItem } from "@/api/settings"; + +export type CurrencyDisplayFormat = { + displayDecimals: number; + decimalSeparator: string; + thousandsSeparator: string; +}; + +const DEFAULT_FORMAT: CurrencyDisplayFormat = { + displayDecimals: 2, + decimalSeparator: ".", + thousandsSeparator: ",", +}; + +const SETTINGS_TTL_MS = 60_000; + +let cachedFormat: CurrencyDisplayFormat | null = null; +let fetchedAt = 0; +let pendingLoad: Promise | null = null; + +function parseFormatFromItems(items: SettingItem[]): CurrencyDisplayFormat { + const byKey = new Map(items.map((item) => [item.key, item.value])); + + const rawDecimals = byKey.get("currency.display_decimals"); + const displayDecimals = + typeof rawDecimals === "number" && Number.isFinite(rawDecimals) + ? Math.max(0, Math.min(12, Math.trunc(rawDecimals))) + : DEFAULT_FORMAT.displayDecimals; + + const decimalSeparator = + typeof byKey.get("currency.decimal_separator") === "string" + ? (byKey.get("currency.decimal_separator") as string) + : DEFAULT_FORMAT.decimalSeparator; + + const thousandsSeparator = + typeof byKey.get("currency.thousands_separator") === "string" + ? (byKey.get("currency.thousands_separator") as string) + : DEFAULT_FORMAT.thousandsSeparator; + + return { + displayDecimals, + decimalSeparator, + thousandsSeparator, + }; +} + +function isCacheFresh(): boolean { + return cachedFormat !== null && Date.now() - fetchedAt < SETTINGS_TTL_MS; +} + +export function getCurrencyDisplayFormat(): CurrencyDisplayFormat { + return cachedFormat ?? DEFAULT_FORMAT; +} + +export function invalidateCurrencyDisplayFormat(): void { + cachedFormat = null; + fetchedAt = 0; + pendingLoad = null; +} + +export async function loadCurrencyDisplayFormat( + force = false, +): Promise { + if (!force && isCacheFresh()) { + return getCurrencyDisplayFormat(); + } + + pendingLoad ??= getPublicSettings("currency") + .then((response) => { + cachedFormat = parseFormatFromItems(response.items); + fetchedAt = Date.now(); + return cachedFormat; + }) + .catch((error: unknown) => { + console.warn( + "[CurrencyDisplay] Failed to load public currency settings:", + error, + ); + return getCurrencyDisplayFormat(); + }) + .finally(() => { + pendingLoad = null; + }); + + return pendingLoad; +} diff --git a/src/lib/iframe-origins.ts b/src/lib/iframe-origins.ts index c757bf3..07276c1 100644 --- a/src/lib/iframe-origins.ts +++ b/src/lib/iframe-origins.ts @@ -6,7 +6,11 @@ type RuntimeOriginsResponse = { iframe_allowed_origins: string[]; }; -let cachedOrigins: string[] | null = null; +/** 后台白名单缓存 TTL,与 LotterySettings 默认 60s 同量级 */ +const RUNTIME_ORIGINS_TTL_MS = 60_000; + +let runtimeOrigins: string[] | null = null; +let runtimeFetchedAt = 0; let pendingOrigins: Promise | null = null; function normalizeOrigin(value: string | undefined): string | null { @@ -21,14 +25,22 @@ function normalizeOrigin(value: string | undefined): string | null { } function staticAllowedOrigins(): string[] { - return [ + const fromEnv = [ process.env.NEXT_PUBLIC_MAIN_SITE_URL, process.env.NEXT_PUBLIC_PARENT_ORIGIN, - "http://localhost:3800", - "http://127.0.0.1:3800", ] .map(normalizeOrigin) .filter((origin): origin is string => origin !== null); + + if (process.env.NODE_ENV === "development") { + return uniqueOrigins([ + ...fromEnv, + "http://localhost:3800", + "http://127.0.0.1:3800", + ]); + } + + return uniqueOrigins(fromEnv); } function uniqueOrigins(origins: string[]): string[] { @@ -38,12 +50,27 @@ function uniqueOrigins(origins: string[]): string[] { export function getKnownIframeAllowedOrigins(): string[] { return uniqueOrigins([ ...staticAllowedOrigins(), - ...(cachedOrigins ?? []), + ...(runtimeOrigins ?? []), ]); } -export async function loadIframeAllowedOrigins(): Promise { - if (cachedOrigins !== null) { +function isRuntimeCacheFresh(): boolean { + return ( + runtimeOrigins !== null && + Date.now() - runtimeFetchedAt < RUNTIME_ORIGINS_TTL_MS + ); +} + +export function invalidateIframeAllowedOrigins(): void { + runtimeOrigins = null; + runtimeFetchedAt = 0; + pendingOrigins = null; +} + +export async function loadIframeAllowedOrigins( + force = false, +): Promise { + if (!force && isRuntimeCacheFresh()) { return getKnownIframeAllowedOrigins(); } @@ -51,9 +78,10 @@ export async function loadIframeAllowedOrigins(): Promise { .get("/integration/runtime-origins") .then((response) => { const data = unwrapData(response.data); - cachedOrigins = data.iframe_allowed_origins + runtimeOrigins = data.iframe_allowed_origins .map(normalizeOrigin) .filter((origin): origin is string => origin !== null); + runtimeFetchedAt = Date.now(); return getKnownIframeAllowedOrigins(); }) @@ -61,17 +89,45 @@ export async function loadIframeAllowedOrigins(): Promise { pendingOrigins = null; console.warn("[IframeOrigins] Failed to load runtime origins:", error); return getKnownIframeAllowedOrigins(); + }) + .finally(() => { + pendingOrigins = null; }); return pendingOrigins; } +/** + * 未配置任何来源时默认拒绝(不再放行全部 origin)。 + * 开发环境仍可使用 env / localhost 静态来源。 + */ export function isIframeOriginAllowed(origin: string): boolean { const normalized = normalizeOrigin(origin); if (normalized === null) return false; const allowedOrigins = getKnownIframeAllowedOrigins(); - if (allowedOrigins.length === 0) return true; + if (allowedOrigins.length === 0) return false; return allowedOrigins.includes(normalized); } + +/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin */ +export function resolvePostMessageTargetOrigin(): string { + const allowedOrigins = getKnownIframeAllowedOrigins(); + if (allowedOrigins.length === 0) { + return "*"; + } + + if (typeof document !== "undefined" && document.referrer) { + try { + const referrerOrigin = new URL(document.referrer).origin; + if (allowedOrigins.includes(referrerOrigin)) { + return referrerOrigin; + } + } catch { + // ignore invalid referrer + } + } + + return allowedOrigins[0]; +} diff --git a/src/lib/money.ts b/src/lib/money.ts index a36591e..b6e2a4f 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -1,7 +1,9 @@ /** - * 与后端约定:金额存最小货币单位(如 NPR 2 位小数 → 分);展示时除以 10^decimals。 + * 与后端约定:金额存最小货币单位(如 NPR 2 位小数 → 分);展示时除以 10^displayDecimals。 + * 展示分隔符与 {@link CurrencyFormatter} / 后台「货币格式」设置一致。 */ +import { getCurrencyDisplayFormat } from "@/lib/currency-display-settings"; import { usePlayerSessionStore } from "@/stores/player-session-store"; const DEFAULT_DECIMAL_PLACES = 2; @@ -20,27 +22,62 @@ export function getCurrencyDecimalPlaces(currencyCode: string): number { return DEFAULT_DECIMAL_PLACES; } +function formatIntegerWithThousands(value: number, thousandsSep: string): string { + const digits = String(Math.trunc(value)); + if (!thousandsSep) return digits; + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSep); +} + +function formatMinorWithDisplaySettings( + minorUnits: number, + format = getCurrencyDisplayFormat(), +): string { + const { displayDecimals, decimalSeparator, thousandsSeparator } = format; + const divisor = Math.max(1, 10 ** displayDecimals); + const negative = minorUnits < 0; + const abs = Math.abs(minorUnits); + + const integerPart = Math.floor(abs / divisor); + const fractionRaw = abs % divisor; + const fractionPadded = String(fractionRaw).padStart(displayDecimals, "0"); + const integerPartFormatted = formatIntegerWithThousands( + integerPart, + thousandsSeparator, + ); + + if (displayDecimals === 0) { + return `${negative ? "-" : ""}${integerPartFormatted}`; + } + + return `${negative ? "-" : ""}${integerPartFormatted}${decimalSeparator}${fractionPadded}`; +} + export function formatMinorAsCurrency( minor: number | string, currencyCode: string, decimalPlaces?: number, ): string { - const resolvedDecimalPlaces = - typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0 - ? decimalPlaces - : getCurrencyDecimalPlaces(currencyCode); const n = typeof minor === "string" ? Number(minor) : minor; if (!Number.isFinite(n)) return `${currencyCode} —`; - const divisor = 10 ** resolvedDecimalPlaces; - const major = n / divisor; - return `${currencyCode} ${major.toLocaleString(undefined, { - minimumFractionDigits: resolvedDecimalPlaces, - maximumFractionDigits: resolvedDecimalPlaces, - })}`; + + const format = getCurrencyDisplayFormat(); + const resolvedDisplayDecimals = + typeof decimalPlaces === "number" && + Number.isFinite(decimalPlaces) && + decimalPlaces >= 0 + ? Math.min(12, Math.trunc(decimalPlaces)) + : format.displayDecimals; + + const amount = formatMinorWithDisplaySettings(n, { + ...format, + displayDecimals: resolvedDisplayDecimals, + }); + + return `${currencyCode} ${amount}`; } /** - * 用户输入如 `1000` 或 `1000.5` → 最小货币单位整数。 + * 用户输入如 `1000` 或 `1,000.50` → 最小货币单位整数(按币种实际 decimal_places)。 */ export function parseDecimalInputToMinor( raw: string, @@ -53,9 +90,19 @@ export function parseDecimalInputToMinor( Number.isFinite(decimalPlacesOrCurrencyCode) && decimalPlacesOrCurrencyCode >= 0 ? decimalPlacesOrCurrencyCode - : decimalPlacesOrCurrencyCode; + : decimalPlacesOrCurrencyCode; const resolvedDecimalPlaces = decimalPlaces ?? DEFAULT_DECIMAL_PLACES; - const cleaned = raw.replace(/,/g, "").trim(); + + const format = getCurrencyDisplayFormat(); + let cleaned = raw.trim(); + if (format.thousandsSeparator) { + cleaned = cleaned.split(format.thousandsSeparator).join(""); + } + if (format.decimalSeparator && format.decimalSeparator !== ".") { + cleaned = cleaned.replaceAll(format.decimalSeparator, "."); + } + cleaned = cleaned.replace(/,/g, ""); + if (cleaned === "") return null; const n = Number(cleaned); if (!Number.isFinite(n) || n < 0) return null; diff --git a/src/lib/pending-reconcile-notification.ts b/src/lib/pending-reconcile-notification.ts new file mode 100644 index 0000000..53069d4 --- /dev/null +++ b/src/lib/pending-reconcile-notification.ts @@ -0,0 +1,13 @@ +/** 待对账划转通知文案(与流水类型「转入/转出」区分,避免误解为已成功) */ + +export function pendingReconcileTitleKey(type: string): string { + return type === "transfer_out" + ? "notifications.pendingTitle.transfer_out" + : "notifications.pendingTitle.transfer_in"; +} + +export function pendingReconcileDescriptionKey(type: string): string { + return type === "transfer_out" + ? "notifications.pendingDescription.transfer_out" + : "notifications.pendingDescription.transfer_in"; +}