feat: 增强 iframe 通信机制与通知处理功能
实现 resolvePostMessageTargetOrigin,优化 iframe 消息通信中的目标来源(origin)解析与校验。 更新 IframeBridge:支持定期刷新允许的来源列表,并优化消息事件管理机制。 重构 usePendingWalletReconcile:优化待对账通知的获取与缓存逻辑,提升性能与用户体验。 增强 NotificationsScreen:新增待对账通知内容,并优化界面展示效果。 更新英文、尼泊尔语与中文语言包,新增待对账通知相关翻译文案。
This commit is contained in:
@@ -7,6 +7,7 @@ import { setPlayerBearerToken } from "@/lib/lottery-auth";
|
|||||||
import {
|
import {
|
||||||
isIframeOriginAllowed,
|
isIframeOriginAllowed,
|
||||||
loadIframeAllowedOrigins,
|
loadIframeAllowedOrigins,
|
||||||
|
resolvePostMessageTargetOrigin,
|
||||||
} from "@/lib/iframe-origins";
|
} from "@/lib/iframe-origins";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +35,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
source: "lottery-iframe",
|
source: "lottery-iframe",
|
||||||
},
|
},
|
||||||
"*", // 生产环境应指定具体域名
|
resolvePostMessageTargetOrigin(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -93,7 +94,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
|
|
||||||
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
await loadIframeAllowedOrigins();
|
await loadIframeAllowedOrigins(true);
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
console.warn("[IframeBridge] Rejected message from:", event.origin);
|
console.warn("[IframeBridge] Rejected message from:", event.origin);
|
||||||
return;
|
return;
|
||||||
@@ -159,6 +160,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
notifyReady();
|
notifyReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const originsRefresh = setInterval(() => {
|
||||||
|
void loadIframeAllowedOrigins(true);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
// 定期发送心跳
|
// 定期发送心跳
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
sendToParent("HEARTBEAT", {
|
sendToParent("HEARTBEAT", {
|
||||||
@@ -168,6 +173,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("message", handleMessage);
|
window.removeEventListener("message", handleMessage);
|
||||||
|
clearInterval(originsRefresh);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
};
|
};
|
||||||
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
|
}, [notifyReady, notifyTokenRefreshed, sendToParent, setBearerToken]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, type ReactElement } from "react";
|
import { type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||||
@@ -13,14 +13,10 @@ type PlayerNotificationBellProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 顶栏铃铛:待对账划转等提醒(Popover 右上角展开) */
|
/** 顶栏铃铛:待对账划转提醒 */
|
||||||
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
|
export function PlayerNotificationBell({ className }: PlayerNotificationBellProps): ReactElement {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { hasUnread, refresh } = usePendingWalletReconcile();
|
const { hasUnread } = usePendingWalletReconcile();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -166,14 +166,6 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
|
|||||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void load();
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
// 爆池等场景:刷新大厅快照(含奖池余额)
|
// 爆池等场景:刷新大厅快照(含奖池余额)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHallRefresh = () => {
|
const onHallRefresh = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|||||||
|
|
||||||
import { getPublicCurrencies } from "@/api/currency";
|
import { getPublicCurrencies } from "@/api/currency";
|
||||||
import { getPlayerMe } from "@/api/player";
|
import { getPlayerMe } from "@/api/player";
|
||||||
|
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,11 @@ export function HydratePlayerAuth(): null {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
usePlayerSessionStore.getState().reconcileSelectedCurrency();
|
||||||
|
void loadCurrencyDisplayFormat();
|
||||||
|
const refreshFormat = setInterval(() => {
|
||||||
|
void loadCurrencyDisplayFormat(true);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
const token = restoreBearerToken();
|
const token = restoreBearerToken();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +46,10 @@ export function HydratePlayerAuth(): null {
|
|||||||
/* 401 由 lottery-http 拦截跳转 */
|
/* 401 由 lottery-http 拦截跳转 */
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(refreshFormat);
|
||||||
|
};
|
||||||
}, [restoreBearerToken, setCurrencies, setProfile]);
|
}, [restoreBearerToken, setCurrencies, setProfile]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { logTypeLabel } from "@/features/wallet/wallet-logs-block";
|
|
||||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||||
import { formatLocalDateTime } from "@/lib/format-local-datetime";
|
import { formatLocalDateTime } from "@/lib/format-local-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import {
|
||||||
|
pendingReconcileDescriptionKey,
|
||||||
|
pendingReconcileTitleKey,
|
||||||
|
} from "@/lib/pending-reconcile-notification";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function NotificationsScreen() {
|
export function NotificationsScreen() {
|
||||||
@@ -21,6 +24,10 @@ export function NotificationsScreen() {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
||||||
<div className="space-y-3">
|
<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">
|
<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]">
|
<p className="text-sm font-semibold text-[#0b3f96]">
|
||||||
{t("notifications.unreadCount", { count: unreadCount })}
|
{t("notifications.unreadCount", { count: unreadCount })}
|
||||||
@@ -67,26 +74,36 @@ export function NotificationsScreen() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-bold text-[#0b3f96]">
|
<p className="text-sm font-bold text-amber-900">
|
||||||
{logTypeLabel(item.type, t)}
|
{t(pendingReconcileTitleKey(item.type))}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
{formatLocalDateTime(item.created_at)}
|
{formatLocalDateTime(item.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold",
|
"text-[10px] font-semibold",
|
||||||
cardRead
|
cardRead ? "text-slate-400" : "text-amber-700",
|
||||||
? "bg-slate-100 text-slate-500"
|
|
||||||
: "bg-amber-100 text-amber-700",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cardRead ? t("notifications.read") : t("notifications.unread")}
|
{cardRead ? t("notifications.read") : t("notifications.unread")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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)}
|
{formatMinorAsCurrency(item.amount, item.currency_code)}
|
||||||
</p>
|
</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]"
|
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)}
|
onClick={() => markAsRead(item.transfer_no)}
|
||||||
>
|
>
|
||||||
{t("notifications.open")}
|
{t("notifications.viewLogs")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,6 +8,37 @@ import type { WalletPendingTransfer } from "@/types/api/wallet-logs";
|
|||||||
|
|
||||||
export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed";
|
export const WALLET_LOGS_REFRESH_EVENT = "lottery:wallet-logs-refreshed";
|
||||||
const WALLET_NOTIFICATION_READ_KEY = "lottery:wallet-notification-read-transfer-nos";
|
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 = {
|
type WalletLogsRefreshDetail = {
|
||||||
pending?: WalletPendingTransfer[];
|
pending?: WalletPendingTransfer[];
|
||||||
@@ -54,14 +85,14 @@ export function usePendingWalletReconcile(): {
|
|||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
if (!bearerToken?.trim()) {
|
if (!bearerToken?.trim()) {
|
||||||
setPending([]);
|
setPending([]);
|
||||||
|
pendingReconcileCache = null;
|
||||||
|
pendingReconcileFetchedAtMs = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getWalletLogs({ page: 1, size: 1 });
|
const nextPending = await fetchPendingWalletReconcile();
|
||||||
setPending(data.pending_reconcile ?? []);
|
setPending(nextPending);
|
||||||
} catch {
|
|
||||||
setPending([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useErrorStore } from "@/stores/error-store";
|
|||||||
import {
|
import {
|
||||||
isIframeOriginAllowed,
|
isIframeOriginAllowed,
|
||||||
loadIframeAllowedOrigins,
|
loadIframeAllowedOrigins,
|
||||||
|
resolvePostMessageTargetOrigin,
|
||||||
} from "@/lib/iframe-origins";
|
} from "@/lib/iframe-origins";
|
||||||
|
|
||||||
/** Token 过期前警告阈值(毫秒) */
|
/** Token 过期前警告阈值(毫秒) */
|
||||||
@@ -91,7 +92,7 @@ export function useTokenRefresh(): {
|
|||||||
type: "LOTTERY_TOKEN_REFRESH_REQUEST",
|
type: "LOTTERY_TOKEN_REFRESH_REQUEST",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
"*", // 或指定主站域名
|
resolvePostMessageTargetOrigin(),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ export function useTokenRefresh(): {
|
|||||||
|
|
||||||
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
await loadIframeAllowedOrigins();
|
await loadIframeAllowedOrigins(true);
|
||||||
if (!isIframeOriginAllowed(event.origin)) {
|
if (!isIframeOriginAllowed(event.origin)) {
|
||||||
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
|
console.warn("[TokenRefresh] Ignored message from unknown origin:", event.origin);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"option": "{{code}} · {{name}}"
|
"option": "{{code}} · {{name}}"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "Notifications"
|
"notifications": "Pending reconciliation"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "General"
|
"general": "General"
|
||||||
|
|||||||
@@ -27,11 +27,22 @@
|
|||||||
"wallet": "Wallet"
|
"wallet": "Wallet"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Pending reconciliation",
|
||||||
"empty": "No notifications",
|
"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",
|
"unread": "Unread",
|
||||||
"read": "Read",
|
"read": "Read",
|
||||||
"open": "Open",
|
"viewLogs": "View logs",
|
||||||
"markRead": "Mark as read",
|
"markRead": "Mark as read",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"unreadCount_one": "{{count}} unread",
|
"unreadCount_one": "{{count}} unread",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"option": "{{code}} · {{name}}"
|
"option": "{{code}} · {{name}}"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "सूचनाहरू"
|
"notifications": "मिलान बाँकी"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "सामान्य"
|
"general": "सामान्य"
|
||||||
|
|||||||
@@ -27,11 +27,22 @@
|
|||||||
"wallet": "वालेट"
|
"wallet": "वालेट"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "सूचनाहरू",
|
"title": "मिलान बाँकी सूचना",
|
||||||
"empty": "कुनै सूचना छैन",
|
"subtitle": "तलका स्थानान्तरण अझै अन्तिम रूपमा पुष्टि भएका छैनन्। सफल जम्मा वा कटौती भएको मान्नु हुँदैन। लामो समयसम्म अपडेट नभएमा सहायतामा सम्पर्क गर्नुहोस्।",
|
||||||
|
"empty": "मिलान बाँकी रेकर्ड छैन",
|
||||||
|
"pendingBadge": "मिलान बाँकी",
|
||||||
|
"amountLabel": "रकम:",
|
||||||
|
"pendingTitle": {
|
||||||
|
"transfer_in": "भित्र स्थानान्तरण — मिलान बाँकी",
|
||||||
|
"transfer_out": "बाहिर स्थानान्तरण — मिलान बाँकी"
|
||||||
|
},
|
||||||
|
"pendingDescription": {
|
||||||
|
"transfer_in": "मुख्य साइटबाट रकम आएको पुष्टि भएको छैन। लटरी वालेट ब्यालेन्स बढ्न सक्छ। मुख्य साइटबाट कटौती भइसकेको भए सहायतामा सम्पर्क गर्नुहोस्।",
|
||||||
|
"transfer_out": "मुख्य साइटमा कटौती पुष्टि भएको छैन। लटरी वालेट ब्यालेन्स घट्न सक्छ। वालेट लग वा सहायतामा जाँच गर्नुहोस्।"
|
||||||
|
},
|
||||||
"unread": "नपढिएको",
|
"unread": "नपढिएको",
|
||||||
"read": "पढिएको",
|
"read": "पढिएको",
|
||||||
"open": "खोल्नुहोस्",
|
"viewLogs": "लग हेर्नुहोस्",
|
||||||
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
|
"markRead": "पढिएको चिन्ह लगाउनुहोस्",
|
||||||
"markAllRead": "सबै पढिएको",
|
"markAllRead": "सबै पढिएको",
|
||||||
"unreadCount_one": "{{count}} नपढिएको",
|
"unreadCount_one": "{{count}} नपढिएको",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"option": "{{code}} · {{name}}"
|
"option": "{{code}} · {{name}}"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"notifications": "通知"
|
"notifications": "待对账提醒"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "通用"
|
"general": "通用"
|
||||||
|
|||||||
@@ -27,11 +27,22 @@
|
|||||||
"wallet": "钱包"
|
"wallet": "钱包"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "通知",
|
"title": "待对账提醒",
|
||||||
"empty": "暂无通知",
|
"subtitle": "以下划转尚未最终确认,不代表已成功到账或扣款。若长时间未更新,请联系客服。",
|
||||||
|
"empty": "暂无待对账记录",
|
||||||
|
"pendingBadge": "待对账",
|
||||||
|
"amountLabel": "涉及金额:",
|
||||||
|
"pendingTitle": {
|
||||||
|
"transfer_in": "转入待对账",
|
||||||
|
"transfer_out": "转出待对账"
|
||||||
|
},
|
||||||
|
"pendingDescription": {
|
||||||
|
"transfer_in": "主站到账结果未确认,彩票钱包余额可能尚未增加。若主站已扣款,请联系客服处理。",
|
||||||
|
"transfer_out": "主站扣款结果未确认,彩票钱包余额可能尚未减少。请在流水中核对或联系客服。"
|
||||||
|
},
|
||||||
"unread": "未读",
|
"unread": "未读",
|
||||||
"read": "已读",
|
"read": "已读",
|
||||||
"open": "打开",
|
"viewLogs": "查看流水",
|
||||||
"markRead": "标记已读",
|
"markRead": "标记已读",
|
||||||
"markAllRead": "全部已读",
|
"markAllRead": "全部已读",
|
||||||
"unreadCount_one": "未读 {{count}} 条",
|
"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[];
|
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;
|
let pendingOrigins: Promise<string[]> | null = null;
|
||||||
|
|
||||||
function normalizeOrigin(value: string | undefined): string | null {
|
function normalizeOrigin(value: string | undefined): string | null {
|
||||||
@@ -21,14 +25,22 @@ function normalizeOrigin(value: string | undefined): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function staticAllowedOrigins(): string[] {
|
function staticAllowedOrigins(): string[] {
|
||||||
return [
|
const fromEnv = [
|
||||||
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
|
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
|
||||||
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
|
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
|
||||||
"http://localhost:3800",
|
|
||||||
"http://127.0.0.1:3800",
|
|
||||||
]
|
]
|
||||||
.map(normalizeOrigin)
|
.map(normalizeOrigin)
|
||||||
.filter((origin): origin is string => origin !== null);
|
.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[] {
|
function uniqueOrigins(origins: string[]): string[] {
|
||||||
@@ -38,12 +50,27 @@ function uniqueOrigins(origins: string[]): string[] {
|
|||||||
export function getKnownIframeAllowedOrigins(): string[] {
|
export function getKnownIframeAllowedOrigins(): string[] {
|
||||||
return uniqueOrigins([
|
return uniqueOrigins([
|
||||||
...staticAllowedOrigins(),
|
...staticAllowedOrigins(),
|
||||||
...(cachedOrigins ?? []),
|
...(runtimeOrigins ?? []),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadIframeAllowedOrigins(): Promise<string[]> {
|
function isRuntimeCacheFresh(): boolean {
|
||||||
if (cachedOrigins !== null) {
|
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();
|
return getKnownIframeAllowedOrigins();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +78,10 @@ export async function loadIframeAllowedOrigins(): Promise<string[]> {
|
|||||||
.get("/integration/runtime-origins")
|
.get("/integration/runtime-origins")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const data = unwrapData<RuntimeOriginsResponse>(response.data);
|
const data = unwrapData<RuntimeOriginsResponse>(response.data);
|
||||||
cachedOrigins = data.iframe_allowed_origins
|
runtimeOrigins = data.iframe_allowed_origins
|
||||||
.map(normalizeOrigin)
|
.map(normalizeOrigin)
|
||||||
.filter((origin): origin is string => origin !== null);
|
.filter((origin): origin is string => origin !== null);
|
||||||
|
runtimeFetchedAt = Date.now();
|
||||||
|
|
||||||
return getKnownIframeAllowedOrigins();
|
return getKnownIframeAllowedOrigins();
|
||||||
})
|
})
|
||||||
@@ -61,17 +89,45 @@ export async function loadIframeAllowedOrigins(): Promise<string[]> {
|
|||||||
pendingOrigins = null;
|
pendingOrigins = null;
|
||||||
console.warn("[IframeOrigins] Failed to load runtime origins:", error);
|
console.warn("[IframeOrigins] Failed to load runtime origins:", error);
|
||||||
return getKnownIframeAllowedOrigins();
|
return getKnownIframeAllowedOrigins();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingOrigins = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return pendingOrigins;
|
return pendingOrigins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未配置任何来源时默认拒绝(不再放行全部 origin)。
|
||||||
|
* 开发环境仍可使用 env / localhost 静态来源。
|
||||||
|
*/
|
||||||
export function isIframeOriginAllowed(origin: string): boolean {
|
export function isIframeOriginAllowed(origin: string): boolean {
|
||||||
const normalized = normalizeOrigin(origin);
|
const normalized = normalizeOrigin(origin);
|
||||||
if (normalized === null) return false;
|
if (normalized === null) return false;
|
||||||
|
|
||||||
const allowedOrigins = getKnownIframeAllowedOrigins();
|
const allowedOrigins = getKnownIframeAllowedOrigins();
|
||||||
if (allowedOrigins.length === 0) return true;
|
if (allowedOrigins.length === 0) return false;
|
||||||
|
|
||||||
return allowedOrigins.includes(normalized);
|
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";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
const DEFAULT_DECIMAL_PLACES = 2;
|
const DEFAULT_DECIMAL_PLACES = 2;
|
||||||
@@ -20,27 +22,62 @@ export function getCurrencyDecimalPlaces(currencyCode: string): number {
|
|||||||
return DEFAULT_DECIMAL_PLACES;
|
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(
|
export function formatMinorAsCurrency(
|
||||||
minor: number | string,
|
minor: number | string,
|
||||||
currencyCode: string,
|
currencyCode: string,
|
||||||
decimalPlaces?: number,
|
decimalPlaces?: number,
|
||||||
): string {
|
): string {
|
||||||
const resolvedDecimalPlaces =
|
|
||||||
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
|
|
||||||
? decimalPlaces
|
|
||||||
: getCurrencyDecimalPlaces(currencyCode);
|
|
||||||
const n = typeof minor === "string" ? Number(minor) : minor;
|
const n = typeof minor === "string" ? Number(minor) : minor;
|
||||||
if (!Number.isFinite(n)) return `${currencyCode} —`;
|
if (!Number.isFinite(n)) return `${currencyCode} —`;
|
||||||
const divisor = 10 ** resolvedDecimalPlaces;
|
|
||||||
const major = n / divisor;
|
const format = getCurrencyDisplayFormat();
|
||||||
return `${currencyCode} ${major.toLocaleString(undefined, {
|
const resolvedDisplayDecimals =
|
||||||
minimumFractionDigits: resolvedDecimalPlaces,
|
typeof decimalPlaces === "number" &&
|
||||||
maximumFractionDigits: resolvedDecimalPlaces,
|
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(
|
export function parseDecimalInputToMinor(
|
||||||
raw: string,
|
raw: string,
|
||||||
@@ -55,7 +92,17 @@ export function parseDecimalInputToMinor(
|
|||||||
? decimalPlacesOrCurrencyCode
|
? decimalPlacesOrCurrencyCode
|
||||||
: decimalPlacesOrCurrencyCode;
|
: decimalPlacesOrCurrencyCode;
|
||||||
const resolvedDecimalPlaces = decimalPlaces ?? DEFAULT_DECIMAL_PLACES;
|
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;
|
if (cleaned === "") return null;
|
||||||
const n = Number(cleaned);
|
const n = Number(cleaned);
|
||||||
if (!Number.isFinite(n) || n < 0) return null;
|
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