feat: 增强 iframe 通信机制与通知处理功能
实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。 更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。 重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。 增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。 更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
This commit is contained in:
88
src/lib/currency-display-settings.ts
Normal file
88
src/lib/currency-display-settings.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
src/lib/pending-reconcile-notification.ts
Normal file
13
src/lib/pending-reconcile-notification.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user