feat: 精简玩家端页面结构并支持局域网开发热更新
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- 合并钱包、订单、开奖、通知等独立 screen 到页面层,减少重复壳组件
- 优化订单列表/详情、钱包划转与待对账展示、开奖核对与结果详情交互
- 改进入口 gate、移动端视口与下拉刷新在窄屏下的表现
- dev 绑定 0.0.0.0 并默认放行 192.168/10 网段,修复局域网 HMR WebSocket
This commit is contained in:
2026-06-26 14:10:47 +08:00
parent dd1155978a
commit e5622c58cb
51 changed files with 1184 additions and 1335 deletions

View File

@@ -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) {
// 主站 iframetoken 由 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");

View File

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

View File

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