feat: 增强 iframe 通信机制与通知处理功能
实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。 更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。 重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。 增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。 更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Pending reconciliation"
|
||||
},
|
||||
"errors": {
|
||||
"general": "General"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "सूचनाहरू"
|
||||
"notifications": "मिलान बाँकी"
|
||||
},
|
||||
"errors": {
|
||||
"general": "सामान्य"
|
||||
|
||||
@@ -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}} नपढिएको",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"option": "{{code}} · {{name}}"
|
||||
},
|
||||
"navigation": {
|
||||
"notifications": "通知"
|
||||
"notifications": "待对账提醒"
|
||||
},
|
||||
"errors": {
|
||||
"general": "通用"
|
||||
|
||||
@@ -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}} 条",
|
||||
|
||||
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,
|
||||
@@ -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;
|
||||
|
||||
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