feat: 增强 iframe 通信机制与通知处理功能

实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。
更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。
重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。
增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。
更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
This commit is contained in:
2026-06-01 13:38:30 +08:00
parent aeaba5eea3
commit b819894e75
17 changed files with 362 additions and 72 deletions

View File

@@ -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<void> => {
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]);

View File

@@ -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 (
<Link

View File

@@ -166,14 +166,6 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
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 = () => {

View File

@@ -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;

View File

@@ -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 (
<PlayerPanel title={t("notifications.title")} backHref="/hall">
<div className="space-y-3">
<p className="rounded-xl border border-amber-200 bg-amber-50/90 px-3 py-2.5 text-xs leading-relaxed text-amber-900">
{t("notifications.subtitle")}
</p>
<div className="flex items-center justify-between rounded-xl border border-[#dce7f7] bg-[#f8fbff] px-3 py-2.5">
<p className="text-sm font-semibold text-[#0b3f96]">
{t("notifications.unreadCount", { count: unreadCount })}
@@ -67,26 +74,36 @@ export function NotificationsScreen() {
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-bold text-[#0b3f96]">
{logTypeLabel(item.type, t)}
<p className="text-sm font-bold text-amber-900">
{t(pendingReconcileTitleKey(item.type))}
</p>
<p className="mt-0.5 text-xs text-slate-500">
{formatLocalDateTime(item.created_at)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-bold text-amber-800">
{t("notifications.pendingBadge")}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold",
cardRead
? "bg-slate-100 text-slate-500"
: "bg-amber-100 text-amber-700",
"text-[10px] font-semibold",
cardRead ? "text-slate-400" : "text-amber-700",
)}
>
{cardRead ? t("notifications.read") : t("notifications.unread")}
</span>
</div>
</div>
<p className="mt-2 text-xs leading-relaxed text-amber-950/85">
{t(pendingReconcileDescriptionKey(item.type))}
</p>
<p className="mt-2 text-sm text-slate-700">
<span className="text-xs font-medium text-slate-500">
{t("notifications.amountLabel")}{" "}
</span>
{formatMinorAsCurrency(item.amount, item.currency_code)}
</p>
@@ -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")}
</Link>
</div>
</li>

View File

@@ -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<WalletPendingTransfer[]> | null = null;
let pendingReconcileCache: WalletPendingTransfer[] | null = null;
let pendingReconcileFetchedAtMs = 0;
const PENDING_RECONCILE_CACHE_TTL_MS = 5_000;
async function fetchPendingWalletReconcile(): Promise<WalletPendingTransfer[]> {
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<void> => {
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);
}

View File

@@ -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<void> => {
if (!isIframeOriginAllowed(event.origin)) {
await loadIframeAllowedOrigins();
await loadIframeAllowedOrigins(true);
if (!isIframeOriginAllowed(event.origin)) {
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
return;

View File

@@ -21,7 +21,7 @@
"option": "{{code}} · {{name}}"
},
"navigation": {
"notifications": "Notifications"
"notifications": "Pending reconciliation"
},
"errors": {
"general": "General"

View File

@@ -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",

View File

@@ -21,7 +21,7 @@
"option": "{{code}} · {{name}}"
},
"navigation": {
"notifications": "सूचनाहरू"
"notifications": "मिलान बाँकी"
},
"errors": {
"general": "सामान्य"

View File

@@ -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}} नपढिएको",

View File

@@ -21,7 +21,7 @@
"option": "{{code}} · {{name}}"
},
"navigation": {
"notifications": "通知"
"notifications": "待对账提醒"
},
"errors": {
"general": "通用"

View File

@@ -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}} 条",

View File

@@ -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<CurrencyDisplayFormat> | 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<CurrencyDisplayFormat> {
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;
}

View File

@@ -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<string[]> | 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<string[]> {
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<string[]> {
if (!force && isRuntimeCacheFresh()) {
return getKnownIframeAllowedOrigins();
}
@@ -51,9 +78,10 @@ export async function loadIframeAllowedOrigins(): Promise<string[]> {
.get("/integration/runtime-origins")
.then((response) => {
const data = unwrapData<RuntimeOriginsResponse>(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<string[]> {
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];
}

View File

@@ -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,
@@ -55,7 +92,17 @@ export function parseDecimalInputToMinor(
? 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;

View File

@@ -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";
}