- 合并钱包、订单、开奖、通知等独立 screen 到页面层,减少重复壳组件 - 优化订单列表/详情、钱包划转与待对账展示、开奖核对与结果详情交互 - 改进入口 gate、移动端视口与下拉刷新在窄屏下的表现 - dev 绑定 0.0.0.0 并默认放行 192.168/10 网段,修复局域网 HMR WebSocket
This commit is contained in:
@@ -116,6 +116,14 @@ export function EntryGate() {
|
||||
|
||||
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
||||
usePlayerSessionStore();
|
||||
/** 仅主站 iframe 内复用已有 token;直连站点只认 URL `?token=`(SSO) */
|
||||
const isReturningSession =
|
||||
typeof window !== "undefined" &&
|
||||
isInIframe() &&
|
||||
!sessionExpired &&
|
||||
!tokenFromUrl &&
|
||||
(bearerToken ?? "").trim() !== "";
|
||||
const [resumeSilent, setResumeSilent] = useState(isReturningSession);
|
||||
const waitingForEmbeddedToken =
|
||||
!sessionExpired &&
|
||||
typeof window !== "undefined" &&
|
||||
@@ -128,13 +136,11 @@ export function EntryGate() {
|
||||
|
||||
if (!isInIframe()) {
|
||||
if (sessionExpired) return false;
|
||||
|
||||
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
|
||||
if (!hasToken) return false;
|
||||
return tokenFromUrl !== "";
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [bearerToken, sessionExpired, tokenFromUrl]);
|
||||
}, [sessionExpired, tokenFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gateReady) return;
|
||||
@@ -143,13 +149,10 @@ export function EntryGate() {
|
||||
|
||||
if (sessionExpired) {
|
||||
router.replace("/login?session=expired");
|
||||
} else {
|
||||
const hasToken = tokenFromUrl !== "" || (bearerToken ?? "").trim() !== "";
|
||||
if (!hasToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
} else if (!tokenFromUrl) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [gateReady, router, sessionExpired, tokenFromUrl, bearerToken]);
|
||||
}, [gateReady, router, sessionExpired, tokenFromUrl]);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>(sessionExpired ? "failed" : "loading");
|
||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>(() =>
|
||||
@@ -164,7 +167,15 @@ export function EntryGate() {
|
||||
);
|
||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||
|
||||
const effectiveToken = tokenFromUrl || bearerToken;
|
||||
const entryToken = useMemo(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return tokenFromUrl || bearerToken;
|
||||
}
|
||||
if (!isInIframe()) {
|
||||
return tokenFromUrl;
|
||||
}
|
||||
return tokenFromUrl || bearerToken;
|
||||
}, [bearerToken, tokenFromUrl]);
|
||||
/** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */
|
||||
const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle");
|
||||
|
||||
@@ -183,7 +194,7 @@ export function EntryGate() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveToken) {
|
||||
if (!entryToken) {
|
||||
// 主站 iframe:token 由 MAIN_INIT_TOKEN 稍后到达,勿先闪「授权失败」
|
||||
if (typeof window !== "undefined" && isInIframe() && !tokenFromUrl) {
|
||||
return;
|
||||
@@ -199,6 +210,7 @@ export function EntryGate() {
|
||||
}
|
||||
|
||||
entryLifecycleRef.current = "running";
|
||||
setResumeSilent(false);
|
||||
setPhase("loading");
|
||||
setFailureDetails([]);
|
||||
|
||||
@@ -298,7 +310,7 @@ export function EntryGate() {
|
||||
},
|
||||
]);
|
||||
}, [
|
||||
effectiveToken,
|
||||
entryToken,
|
||||
tokenFromUrl,
|
||||
setBearerToken,
|
||||
setProfile,
|
||||
@@ -323,26 +335,75 @@ export function EntryGate() {
|
||||
stripSearchParamFromBrowserUrl("session");
|
||||
}, [sessionExpired, clearBearerToken]);
|
||||
|
||||
const trimmedEffectiveToken = (effectiveToken ?? "").trim();
|
||||
const trimmedEntryToken = (entryToken ?? "").trim();
|
||||
const doEntryRef = useRef(doEntry);
|
||||
useEffect(() => {
|
||||
doEntryRef.current = doEntry;
|
||||
}, [doEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionExpired || waitingForEmbeddedToken) return;
|
||||
if (sessionExpired || waitingForEmbeddedToken || isReturningSession) return;
|
||||
if (entryLifecycleRef.current !== "idle") return;
|
||||
if (!trimmedEffectiveToken) return;
|
||||
if (!trimmedEntryToken) return;
|
||||
|
||||
const tmr = window.setTimeout(() => {
|
||||
void doEntryRef.current();
|
||||
}, 300);
|
||||
return () => window.clearTimeout(tmr);
|
||||
}, [sessionExpired, trimmedEffectiveToken, waitingForEmbeddedToken]);
|
||||
}, [isReturningSession, sessionExpired, trimmedEntryToken, waitingForEmbeddedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReturningSession) return;
|
||||
if (entryLifecycleRef.current === "running" || entryLifecycleRef.current === "done") {
|
||||
return;
|
||||
}
|
||||
|
||||
entryLifecycleRef.current = "running";
|
||||
let cancelled = false;
|
||||
|
||||
const resumeToHall = async () => {
|
||||
try {
|
||||
if (!usePlayerSessionStore.getState().profile) {
|
||||
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const currencies = await getPublicCurrencies();
|
||||
setCurrencies(currencies.items);
|
||||
} catch {
|
||||
/* 不阻断回大厅 */
|
||||
}
|
||||
setProfile(me);
|
||||
await Promise.all([getPlayerPing(), sleep(200)]);
|
||||
if (cancelled) return;
|
||||
}
|
||||
entryLifecycleRef.current = "done";
|
||||
router.replace("/hall");
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof LotteryApiBizError) {
|
||||
entryLifecycleRef.current = "done";
|
||||
clearBearerToken();
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
entryLifecycleRef.current = "idle";
|
||||
setResumeSilent(false);
|
||||
void doEntryRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
void resumeToHall();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (entryLifecycleRef.current === "running") {
|
||||
entryLifecycleRef.current = "idle";
|
||||
}
|
||||
};
|
||||
}, [clearBearerToken, isReturningSession, router, setCurrencies, setProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionExpired) return;
|
||||
if (tokenFromUrl || effectiveToken) return;
|
||||
if (tokenFromUrl || bearerToken) return;
|
||||
if (typeof window === "undefined" || !isInIframe()) return;
|
||||
|
||||
const tmr = window.setTimeout(() => {
|
||||
@@ -354,14 +415,22 @@ export function EntryGate() {
|
||||
}, IFRAME_TOKEN_WAIT_MS);
|
||||
|
||||
return () => window.clearTimeout(tmr);
|
||||
}, [sessionExpired, effectiveToken, tokenFromUrl]);
|
||||
}, [bearerToken, sessionExpired, tokenFromUrl]);
|
||||
|
||||
if (!gateReady) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-white">
|
||||
<Loader2 className="size-8 animate-spin text-red-600" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resumeSilent && phase === "loading") {
|
||||
return <EntryBusyScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
|
||||
<div className={cn("relative h-[45vh] min-h-[200px]", phase === "success" ? "bg-white" : "bg-red-600")}>
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<Image
|
||||
@@ -472,7 +541,8 @@ export function EntryGate() {
|
||||
{t("failure.detailsTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]">
|
||||
<table className="w-full min-w-[20rem] text-sm">
|
||||
<thead className="bg-red-100/50 text-xs">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-700">
|
||||
@@ -502,6 +572,7 @@ export function EntryGate() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -586,6 +657,17 @@ export function EntryGate() {
|
||||
);
|
||||
}
|
||||
|
||||
function EntryBusyScreen() {
|
||||
const { t } = useTranslation("entry");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 bg-white px-6">
|
||||
<Loader2 className="size-10 animate-spin text-red-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-slate-600">{t("loading.title")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BellRing, CheckCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import {
|
||||
pendingReconcileDescriptionKey,
|
||||
pendingReconcileTitleKey,
|
||||
} from "@/lib/pending-reconcile-notification";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NotificationsScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const { pending, unreadPending, unreadCount, loading, markAsRead, markAllAsRead } =
|
||||
usePendingWalletReconcile();
|
||||
const unreadSet = new Set(unreadPending.map((item) => item.transfer_no));
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("notifications.title")} backHref="/hall">
|
||||
<div className="space-y-3">
|
||||
{creditMode ? (
|
||||
<div className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
|
||||
{t("notifications.creditEmptyHint", {
|
||||
defaultValue: "信用盘无主站划转,暂无待对账通知。",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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 })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-xs font-bold text-[#0b56b7] hover:bg-[#ebf2ff]"
|
||||
onClick={markAllAsRead}
|
||||
disabled={pending.length === 0 || unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="mr-1 size-3.5" aria-hidden />
|
||||
{t("notifications.markAllRead")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-[#dce7f7] bg-white px-4 py-8 text-center text-sm text-slate-500">
|
||||
{t("actions.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && pending.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-white px-4 py-10 text-center">
|
||||
<BellRing className="mx-auto size-5 text-slate-400" aria-hidden />
|
||||
<p className="mt-2 text-sm text-slate-500">{t("notifications.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{pending.map((item) => {
|
||||
const cardRead = !unreadSet.has(item.transfer_no);
|
||||
return (
|
||||
<li
|
||||
key={item.transfer_no}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3 transition-colors",
|
||||
cardRead
|
||||
? "border-[#e4eaf4] bg-white"
|
||||
: "border-amber-200 bg-amber-50/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-amber-900">
|
||||
{t(pendingReconcileTitleKey(item.type))}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{formatPlayerInstant(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(
|
||||
"text-[11px] 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>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-[#dce7f7] px-3 text-xs font-semibold text-[#0b56b7]"
|
||||
onClick={() => markAsRead(item.transfer_no)}
|
||||
>
|
||||
{t("notifications.markRead")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/wallet/logs"
|
||||
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.viewLogs")}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain bg-white">
|
||||
<div className="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-[#f8fafc]">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image src="/entry/image1.png" alt="" fill sizes="100vw" className="object-cover object-center" priority />
|
||||
|
||||
Reference in New Issue
Block a user