-
-
{t("orders.stake")}
-
- {formatMinorAsCurrency(group.total_bet_amount, cur)}
+
+
+
+ {group.draw_no ?? "—"}
+
-
-
{t("orders.deduction")}
-
- {formatMinorAsCurrency(group.actual_deduct_amount, cur)}
-
+
+
+
+
{t("orders.stake")}
+
+ {formatMinorAsCurrency(group.total_bet_amount, cur)}
+
+
+
+
{t("orders.deduction")}
+
+ {formatMinorAsCurrency(group.actual_deduct_amount, cur)}
+
+
+ {group.status === "partial_failed" ? (
+
+ {t("orders.partialFailedHint")}
+
+ ) : null}
+ {totalWin > 0 && group.status === "settled_win" ? (
+
+ {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
+
+ ) : null}
- {group.status === "partial_failed" ? (
-
- {t("orders.partialFailedHint")}
+
+
+
+ {t("orders.betItems")}
- ) : null}
- {totalWin > 0 && group.status === "settled_win" ? (
-
- {t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
-
- ) : null}
-
+ {group.items.map((row, index) => {
+ const lineSt = ticketStatusDisplay(
+ row.status,
+ row.win_amount,
+ row.jackpot_win_amount,
+ t,
+ creditMode,
+ );
+ const lineCur = row.currency_code ?? cur;
+ return (
+
+
+ {index + 1}
+
+
+
+ {playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
+
+
+ {t("orders.deduction")}{" "}
+
+ {formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
);
})}
- {isMobile ?
: null}
- {isMobile && page < lastPage ? (
-
- ) : !isMobile && lastPage > 1 ? (
-
-
- {visiblePages.map((p) => (
-
- ))}
-
-
+
+ {page < lastPage ? (
+
) : lastPage > 1 ? (
{t("orders.noMore")}
@@ -551,11 +573,4 @@ export function TicketOrdersListScreen() {
);
}
-function buildPageWindow(current: number, last: number): number[] {
- if (last <= 5) {
- return Array.from({ length: last }, (_, index) => index + 1);
- }
- const start = Math.max(1, Math.min(current - 2, last - 4));
- return Array.from({ length: 5 }, (_, index) => start + index);
-}
diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx
index 58cae35..37db977 100644
--- a/src/features/player/entry-gate.tsx
+++ b/src/features/player/entry-gate.tsx
@@ -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(sessionExpired ? "failed" : "loading");
const [failureDetails, setFailureDetails] = useState(() =>
@@ -164,7 +167,15 @@ export function EntryGate() {
);
const [steps, setSteps] = useState(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 (
+
+
+
+ );
+ }
+
+ if (resumeSilent && phase === "loading") {
+ return ;
}
return (
-
+
-
+
+
|
@@ -502,6 +572,7 @@ export function EntryGate() {
))}
|
+
) : null}
@@ -586,6 +657,17 @@ export function EntryGate() {
);
}
+function EntryBusyScreen() {
+ const { t } = useTranslation("entry");
+
+ return (
+
+
+
{t("loading.title")}
+
+ );
+}
+
function EntryStatusBadge({ status }: { status: EntryStepStatus }) {
const { t } = useTranslation("common");
diff --git a/src/features/player/notifications-screen.tsx b/src/features/player/notifications-screen.tsx
deleted file mode 100644
index 934ef6e..0000000
--- a/src/features/player/notifications-screen.tsx
+++ /dev/null
@@ -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 (
-
-
- {creditMode ? (
-
- {t("notifications.creditEmptyHint", {
- defaultValue: "信用盘无主站划转,暂无待对账通知。",
- })}
-
- ) : (
- <>
-
-
- {t("notifications.unreadCount", { count: unreadCount })}
-
-
-
-
- {loading && pending.length === 0 ? (
-
- {t("actions.loading")}
-
- ) : null}
-
- {!loading && pending.length === 0 ? (
-
-
-
{t("notifications.empty")}
-
- ) : null}
-
- {pending.length > 0 ? (
-
- {pending.map((item) => {
- const cardRead = !unreadSet.has(item.transfer_no);
- return (
- -
-
-
-
- {t(pendingReconcileTitleKey(item.type))}
-
-
- {formatPlayerInstant(item.created_at)}
-
-
-
-
- {t("notifications.pendingBadge")}
-
-
- {cardRead ? t("notifications.read") : t("notifications.unread")}
-
-
-
-
-
- {t(pendingReconcileDescriptionKey(item.type))}
-
-
-
-
- {t("notifications.amountLabel")}{" "}
-
- {formatMinorAsCurrency(item.amount, item.currency_code)}
-
-
-
-
- markAsRead(item.transfer_no)}
- >
- {t("notifications.viewLogs")}
-
-
-
- );
- })}
-
- ) : null}
- >
- )}
-
-
- );
-}
diff --git a/src/features/player/player-login-screen.tsx b/src/features/player/player-login-screen.tsx
index c937247..e9e0a44 100644
--- a/src/features/player/player-login-screen.tsx
+++ b/src/features/player/player-login-screen.tsx
@@ -150,7 +150,7 @@ export function PlayerLoginScreen(): React.ReactElement {
}
return (
-
+
diff --git a/src/features/results/check-winning-redirect.tsx b/src/features/results/check-winning-redirect.tsx
new file mode 100644
index 0000000..7b48ee4
--- /dev/null
+++ b/src/features/results/check-winning-redirect.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+import { getDrawResults } from "@/api/draw";
+
+/** 旧 `/results/check` 路由:跳转到最新期详情并展开查奖面板 */
+export function CheckWinningRedirect() {
+ const router = useRouter();
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const res = await getDrawResults({ page: 1, size: 1 });
+ const drawNo = res.items[0]?.draw_no;
+ if (drawNo) {
+ router.replace(`/results/${encodeURIComponent(drawNo)}?check=1`);
+ return;
+ }
+ } catch {
+ /* 回落到列表 */
+ }
+ router.replace("/results");
+ })();
+ }, [router]);
+
+ return null;
+}
\ No newline at end of file
diff --git a/src/features/results/check-winning-screen.tsx b/src/features/results/check-winning-screen.tsx
index 77aed47..788eb30 100644
--- a/src/features/results/check-winning-screen.tsx
+++ b/src/features/results/check-winning-screen.tsx
@@ -1,310 +1,31 @@
"use client";
-import Link from "next/link";
-import { BriefcaseBusiness, CheckCircle2, Clock3, RefreshCw, XIcon } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getDrawResults } from "@/api/draw";
-import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
import { PlayerPanel } from "@/components/layout/player-panel";
-import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
-import { formatMinorAsCurrency } from "@/lib/money";
-import { formatPlayerInstant } from "@/lib/player-datetime";
-import { playLabel } from "@/lib/play-labels";
-import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
-import type { DrawResultListItem } from "@/types/api/draw-results";
-import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
-
-type WinningCheckResult = {
- draw: DrawResultListItem;
- match: TicketDrawMyMatchPayload;
- tickets: TicketItemListRow[];
-};
+import { Skeleton } from "@/components/ui/skeleton";
+import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
+/** 保留组件供内嵌使用;独立路由已 redirect 至期号详情 */
export function CheckWinningScreen() {
const { t } = useTranslation("player");
- useCurrencyCatalog();
- const [ticketNo, setTicketNo] = useState("");
- const [latestDraw, setLatestDraw] = useState
(null);
- const [recent, setRecent] = useState([]);
- const [result, setResult] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
+ const [drawNo, setDrawNo] = useState(null);
useEffect(() => {
- queueMicrotask(() => {
- void (async () => {
- try {
- const res = await getDrawResults({ page: 1, size: 1 });
- setLatestDraw(res.items[0] ?? null);
- } catch {
- setLatestDraw(null);
- }
- })();
- });
+ void getDrawResults({ page: 1, size: 1 })
+ .then((res) => setDrawNo(res.items[0]?.draw_no ?? null))
+ .catch(() => setDrawNo(null));
}, []);
- const normalizedTicketNo = ticketNo.trim();
-
- const runCheck = useCallback(async () => {
- if (!latestDraw || normalizedTicketNo === "") {
- return;
- }
-
- setLoading(true);
- setError(null);
- try {
- const [match, tickets] = await Promise.all([
- getTicketDrawMyMatch(latestDraw.draw_no),
- getTicketItems({
- draw_no: latestDraw.draw_no,
- number: normalizedTicketNo,
- per_page: 10,
- page: 1,
- }),
- ]);
- const next = {
- draw: latestDraw,
- match,
- tickets: tickets.items,
- };
- setResult(next);
- setRecent((current) => [normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5));
- } catch {
- setError(t("results.check.loadFailed"));
- } finally {
- setLoading(false);
- }
- }, [latestDraw, normalizedTicketNo, t]);
-
return (
-
-
-
-
-
-
-
- {t("results.check.enterTicket")}
-
-
- {t("results.check.description")}
-
-
-
-
-
- {latestDraw ? (
-
- {t("results.check.latestDraw", { drawNo: latestDraw.draw_no })}
-
- ) : null}
- {error ?
{error}
: null}
-
-
-
-
-
-
-
- {t("results.check.recent")}
-
- {recent.length > 0 ? (
-
- ) : null}
-
-
- {recent.length === 0 ? (
-
- {t("results.check.noRecent")}
-
- ) : (
- recent.map((row) => (
-
- ))
- )}
-
-
-
-
- {
- if (!open) setResult(null);
- }}
- onCheckAnother={() => {
- setResult(null);
- setTicketNo("");
- }}
- />
+ {drawNo ? (
+
+ ) : (
+
+ )}
);
-}
-
-function WinningResultDialog({
- open,
- data,
- query,
- onOpenChange,
- onCheckAnother,
-}: {
- open: boolean;
- data: WinningCheckResult | null;
- query: string;
- onOpenChange: (open: boolean) => void;
- onCheckAnother: () => void;
-}) {
- const { t } = useTranslation("player");
- const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
- const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
- const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
- const { activeCurrency } = useActivePlayerCurrency();
- const currency = firstTicket?.currency_code ?? activeCurrency;
-
- if (!data) return null;
-
- return (
-
- );
-}
+}
\ No newline at end of file
diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx
index fd2d5a5..4ee40f2 100644
--- a/src/features/results/draw-result-detail-screen.tsx
+++ b/src/features/results/draw-result-detail-screen.tsx
@@ -1,11 +1,12 @@
"use client";
-import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { getDrawResultByNo } from "@/api/draw";
import { getTicketDrawMyMatch } from "@/api/ticket-items";
+import Link from "next/link";
import { Button, buttonVariants } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
@@ -16,6 +17,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
+import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-panel";
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
@@ -37,6 +39,8 @@ type DrawResultDetailScreenProps = {
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
const { t } = useTranslation("player");
+ const searchParams = useSearchParams();
+ const checkOpen = searchParams.get("check") === "1";
const { activeCurrency } = useActivePlayerCurrency();
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
useCurrencyCatalog();
@@ -268,20 +272,14 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
) : null}
-
-
- {t("results.hitHint")}
-
-
- {t("results.viewMyWinning")}
-
-
+
diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx
index f491811..540c67f 100644
--- a/src/features/results/draw-results-list-screen.tsx
+++ b/src/features/results/draw-results-list-screen.tsx
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/select";
import { PlayerPanel } from "@/components/layout/player-panel";
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
-import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
+
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
@@ -259,14 +259,17 @@ export function DrawResultsListScreen() {
{t("results.openDetail", { defaultValue: "查看详情" })}
-
-
-
- {t("results.viewMyWinning")}
-
+
+ {RESULTS_TOP_PRIZE_KEYS.map((tier) => (
+
+
+ {t(resultsPrizeLabelKey(tier))}
+
+
+ {featured.results[tier]}
+
+
+ ))}
) : null}
diff --git a/src/features/results/draw-winning-check-panel.tsx b/src/features/results/draw-winning-check-panel.tsx
new file mode 100644
index 0000000..81c1f5a
--- /dev/null
+++ b/src/features/results/draw-winning-check-panel.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import Link from "next/link";
+import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon } from "lucide-react";
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
+import { formatMinorAsCurrency } from "@/lib/money";
+import { formatPlayerInstant } from "@/lib/player-datetime";
+import { playLabel } from "@/lib/play-labels";
+import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
+import { cn } from "@/lib/utils";
+import type { DrawResultListItem } from "@/types/api/draw-results";
+import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
+
+type WinningCheckResult = {
+ draw: DrawResultListItem;
+ match: TicketDrawMyMatchPayload;
+ tickets: TicketItemListRow[];
+};
+
+export type DrawWinningCheckPanelProps = {
+ drawNo: string;
+ businessDate?: string | null;
+ drawTimeIso?: string | null;
+ drawTime?: string | null;
+ defaultOpen?: boolean;
+ collapsible?: boolean;
+};
+
+export function DrawWinningCheckPanel({
+ drawNo,
+ businessDate,
+ drawTimeIso,
+ drawTime,
+ defaultOpen = false,
+ collapsible = true,
+}: DrawWinningCheckPanelProps) {
+ const { t } = useTranslation("player");
+ useCurrencyCatalog();
+ const [open, setOpen] = useState(defaultOpen);
+ const [ticketNo, setTicketNo] = useState("");
+ const [recent, setRecent] = useState
([]);
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const normalizedTicketNo = ticketNo.trim();
+
+ const runCheck = useCallback(async () => {
+ if (normalizedTicketNo === "") {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const [match, tickets] = await Promise.all([
+ getTicketDrawMyMatch(drawNo),
+ getTicketItems({
+ draw_no: drawNo,
+ number: normalizedTicketNo,
+ per_page: 10,
+ page: 1,
+ }),
+ ]);
+ const next = {
+ draw: {
+ draw_id: "",
+ draw_no: drawNo,
+ business_date: businessDate ?? "",
+ draw_time: drawTime ?? null,
+ draw_time_iso: drawTimeIso ?? null,
+ result_version: 0,
+ result_source: null,
+ results: { "1st": "", "2nd": "", "3rd": "", starter: [], consolation: [] },
+ result_items: [],
+ } satisfies DrawResultListItem,
+ match,
+ tickets: tickets.items,
+ };
+ setResult(next);
+ setRecent((current) =>
+ [normalizedTicketNo, ...current.filter((x) => x !== normalizedTicketNo)].slice(0, 5),
+ );
+ } catch {
+ setError(t("results.check.loadFailed"));
+ } finally {
+ setLoading(false);
+ }
+ }, [businessDate, drawNo, drawTime, drawTimeIso, normalizedTicketNo, t]);
+
+ const panelBody = (
+
+
+
+
+ {t("results.check.forDraw", { drawNo })}
+
+ {error ?
{error}
: null}
+
+
+
+
+
+
{t("results.check.recent")}
+ {recent.length > 0 ? (
+
+ ) : null}
+
+
+ {recent.length === 0 ? (
+
{t("results.check.noRecent")}
+ ) : (
+ recent.map((row) => (
+
+ ))
+ )}
+
+
+
+ );
+
+ return (
+ <>
+
+ {collapsible ? (
+ <>
+
+ {open ? {panelBody}
: null}
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {t("results.check.enterTicket")}
+
+
+ {t("results.check.forDraw", { drawNo })}
+
+
+ {panelBody}
+ >
+ )}
+
+
+ {
+ if (!nextOpen) setResult(null);
+ }}
+ onCheckAnother={() => {
+ setResult(null);
+ setTicketNo("");
+ }}
+ />
+ >
+ );
+}
+
+function WinningResultDialog({
+ open,
+ data,
+ query,
+ onOpenChange,
+ onCheckAnother,
+}: {
+ open: boolean;
+ data: WinningCheckResult | null;
+ query: string;
+ onOpenChange: (open: boolean) => void;
+ onCheckAnother: () => void;
+}) {
+ const { t } = useTranslation("player");
+ const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
+ const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
+ const firstTicket = data?.tickets[0] ?? null;
+ const { activeCurrency } = useActivePlayerCurrency();
+ const currency = firstTicket?.currency_code ?? activeCurrency;
+
+ if (!data) return null;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx
deleted file mode 100644
index 873dc76..0000000
--- a/src/features/wallet/transfer-in-screen.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useCallback, useEffect } from "react";
-import { useSWRConfig } from "swr";
-
-import { getWalletBalance } from "@/api/wallet";
-import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
-import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
-import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
-import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
-import { useApiQuery } from "@/hooks/use-api-query";
-import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
-
-const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
-
-/** 独立路由 `/wallet/transfer-in` */
-export function TransferInScreen() {
- const router = useRouter();
- const { activeCurrency: currency } = useActivePlayerCurrency();
- const { mutate } = useSWRConfig();
-
- const { data: balance, isLoading: loading } = useApiQuery(
- BALANCE_KEY(currency),
- () => getWalletBalance({ currency }),
- );
-
- useEffect(() => {
- const onRefresh = () => void mutate(BALANCE_KEY(currency));
- window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
- return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
- }, [mutate, currency]);
-
- const onSuccess = useCallback(async () => {
- await mutate(BALANCE_KEY(currency));
- router.push("/wallet");
- }, [mutate, currency, router]);
-
- if (loading && !balance) {
- return ;
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/features/wallet/transfer-out-screen.tsx b/src/features/wallet/transfer-out-screen.tsx
deleted file mode 100644
index 771b35d..0000000
--- a/src/features/wallet/transfer-out-screen.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useCallback, useEffect } from "react";
-import { useSWRConfig } from "swr";
-
-import { getWalletBalance } from "@/api/wallet";
-import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
-import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
-import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
-import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
-import { useApiQuery } from "@/hooks/use-api-query";
-import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
-
-const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
-
-/** 独立路由 `/wallet/transfer-out` */
-export function TransferOutScreen() {
- const router = useRouter();
- const { activeCurrency: currency } = useActivePlayerCurrency();
- const { mutate } = useSWRConfig();
-
- const { data: balance, isLoading: loading } = useApiQuery(
- BALANCE_KEY(currency),
- () => getWalletBalance({ currency }),
- );
-
- useEffect(() => {
- const onRefresh = () => void mutate(BALANCE_KEY(currency));
- window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
- return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
- }, [mutate, currency]);
-
- const onSuccess = useCallback(async () => {
- await mutate(BALANCE_KEY(currency));
- router.push("/wallet");
- }, [mutate, currency, router]);
-
- if (loading && !balance) {
- return ;
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/features/wallet/wallet-logs-block.tsx b/src/features/wallet/wallet-logs-block.tsx
index 4396778..9e8b209 100644
--- a/src/features/wallet/wallet-logs-block.tsx
+++ b/src/features/wallet/wallet-logs-block.tsx
@@ -241,24 +241,14 @@ export function LogRow({
- {creditMode && item.affects_available_credit === false ? (
-
- {t("wallet.creditPaymentRecordOnly", {
- defaultValue: "账期收付记账,不计入可用信用",
- })}
-
- ) : (
- <>
-
- {creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
-
-
- {item.balance_after != null
- ? formatMinorAsCurrency(item.balance_after, ccy)
- : "—"}
-
- >
- )}
+
+ {creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
+
+
+ {item.balance_after != null
+ ? formatMinorAsCurrency(item.balance_after, ccy)
+ : "—"}
+
);
diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx
deleted file mode 100644
index ede135c..0000000
--- a/src/features/wallet/wallet-logs-screen.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-
-import { getWalletBalance, getWalletLogs } from "@/api/wallet";
-import { Button } from "@/components/ui/button";
-import { PlayerPanel } from "@/components/layout/player-panel";
-import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
-import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
-import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
-import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
-import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
-import { formatWalletClientError } from "@/lib/wallet-api-error";
-import { usePlayerSessionStore } from "@/stores/player-session-store";
-import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
-
-const WALLET_LOGS_PAGE_SIZE = 10;
-
-export function WalletLogsScreen() {
- const { activeCurrency: currency } = useActivePlayerCurrency();
- const { t } = useTranslation("player");
- const [logs, setLogs] = useState(null);
- const [filter, setFilter] = useState("");
- const [loading, setLoading] = useState(true);
- const [logsLoading, setLogsLoading] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
- const [error, setError] = useState(null);
- const profile = usePlayerSessionStore((s) => s.profile);
- const [creditMode, setCreditMode] = useState(() => isCreditFundingPlayer(profile));
- const loadMoreRef = useRef(null);
-
- const fetchPassRef = useRef(true);
-
- const load = useCallback(async (targetPage = 1, append = false) => {
- setError(null);
- if (append) {
- setLoadingMore(true);
- } else if (fetchPassRef.current) {
- setLoading(true);
- fetchPassRef.current = false;
- } else {
- setLogsLoading(true);
- }
- try {
- const balance = await getWalletBalance({ currency });
- setCreditMode(isCreditFundingPlayer(balance));
- const nextLogs = await getWalletLogs({
- page: targetPage,
- size: WALLET_LOGS_PAGE_SIZE,
- type: filter || undefined,
- currency,
- });
- setLogs((current) =>
- append && current
- ? { ...nextLogs, items: [...current.items, ...nextLogs.items] }
- : nextLogs,
- );
- dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
- } catch (e) {
- setError(formatWalletClientError(e, t));
- if (!append) {
- setLogs(null);
- }
- } finally {
- setLoading(false);
- setLogsLoading(false);
- setLoadingMore(false);
- }
- }, [currency, filter, t]);
-
- useEffect(() => {
- queueMicrotask(() => {
- void load(1, false);
- });
- }, [currency, load]);
-
- useEffect(() => {
- const onCurrencyChange = () => void load(1, false);
- window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
- return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
- }, [load]);
-
- const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
-
- const loadMore = useCallback(() => {
- if (!logs || !hasMore || loadingMore) return;
- void load(logs.page + 1, true);
- }, [hasMore, load, loadingMore, logs]);
-
- useEffect(() => {
- const target = loadMoreRef.current;
- if (!target || loading || logsLoading || loadingMore || !hasMore) return;
-
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry?.isIntersecting) {
- loadMore();
- }
- },
- { rootMargin: "160px" },
- );
-
- observer.observe(target);
- return () => observer.disconnect();
- }, [hasMore, loadMore, loading, loadingMore, logsLoading]);
-
- return (
-
-
- {error ? (
-
-
{error}
-
-
- ) : null}
-
-
-
-
- );
-}
diff --git a/src/features/wallet/wallet-pending-reconcile-banner.tsx b/src/features/wallet/wallet-pending-reconcile-banner.tsx
deleted file mode 100644
index 790a8cd..0000000
--- a/src/features/wallet/wallet-pending-reconcile-banner.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { AlertTriangle } from "lucide-react";
-import { useTranslation } from "react-i18next";
-
-import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
-
-/** 钱包盘:顶栏铃铛隐藏后,在钱包页展示待对账提醒入口 */
-export function WalletPendingReconcileBanner() {
- const { t } = useTranslation("player");
- const { pending, unreadCount, hasPending } = usePendingWalletReconcile();
-
- if (!hasPending) {
- return null;
- }
-
- return (
-
-
-
-
-
{t("wallet.pendingTitle")}
-
- {t("wallet.pendingDescription")}
-
- {unreadCount > 0 ? (
-
- {t("notifications.unreadCount", { count: unreadCount })}
-
- ) : null}
-
- {t("wallet.viewPendingReconcile", {
- defaultValue: "查看待对账详情({{count}})",
- count: pending.length,
- })}
-
-
-
-
- );
-}
diff --git a/src/features/wallet/wallet-pending-reconcile-section.tsx b/src/features/wallet/wallet-pending-reconcile-section.tsx
new file mode 100644
index 0000000..ea91578
--- /dev/null
+++ b/src/features/wallet/wallet-pending-reconcile-section.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { BellRing, CheckCheck } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+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";
+
+type WalletPendingReconcileSectionProps = {
+ onViewLogs?: () => void;
+};
+
+/** 钱包页内嵌:待对账提醒(原独立 /notifications 页) */
+export function WalletPendingReconcileSection({
+ onViewLogs,
+}: WalletPendingReconcileSectionProps) {
+ 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));
+
+ if (creditMode) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {t("notifications.title")}
+ {unreadCount > 0 ? (
+
+ ({t("notifications.unreadCount", { count: unreadCount })})
+
+ ) : null}
+
+
+
+
+
+ {loading && pending.length === 0 ? (
+
+ {t("actions.loading")}
+
+ ) : null}
+
+ {!loading && pending.length === 0 ? (
+
+
{t("notifications.empty")}
+
+ ) : null}
+
+ {pending.length > 0 ? (
+
+ {pending.map((item) => {
+ const cardRead = !unreadSet.has(item.transfer_no);
+ return (
+ -
+
+
+
+ {t(pendingReconcileTitleKey(item.type))}
+
+
+ {formatPlayerInstant(item.created_at)}
+
+
+
+
+ {t("notifications.pendingBadge")}
+
+
+ {cardRead ? t("notifications.read") : t("notifications.unread")}
+
+
+
+
+
+ {t(pendingReconcileDescriptionKey(item.type))}
+
+
+
+
+ {t("notifications.amountLabel")}{" "}
+
+ {formatMinorAsCurrency(item.amount, item.currency_code)}
+
+
+
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx
index 6c15640..2f36ab4 100644
--- a/src/features/wallet/wallet-screen.tsx
+++ b/src/features/wallet/wallet-screen.tsx
@@ -2,6 +2,7 @@
import { Wallet } from "lucide-react";
import Image from "next/image";
+import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -13,7 +14,7 @@ import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
-import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner";
+import { WalletPendingReconcileSection } from "@/features/wallet/wallet-pending-reconcile-section";
import { PlayerMoneyDisplay } from "@/components/player-money-display";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
@@ -28,9 +29,18 @@ import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-l
const WALLET_LOGS_PAGE_SIZE = 10;
+function scrollToWalletSection(id: string): void {
+ if (typeof document === "undefined") return;
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
export function WalletScreen() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player");
+ const [transferInOpen, setTransferInOpen] = useState(false);
+ const [transferOutOpen, setTransferOutOpen] = useState(false);
const [balance, setBalance] = useState(null);
const [logs, setLogs] = useState(null);
const [filter, setFilter] = useState("");
@@ -39,8 +49,47 @@ export function WalletScreen() {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
const loadMoreRef = useRef(null);
+
const filterInitializedRef = useRef(false);
const prevFilterRef = useRef("");
+ const actionDeepLinkHandledRef = useRef(false);
+ const sectionDeepLinkHandledRef = useRef(false);
+
+ const profile = usePlayerSessionStore((s) => s.profile);
+ const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
+
+ useEffect(() => {
+ if (actionDeepLinkHandledRef.current || loading) return;
+ const action = searchParams.get("action");
+ if (action !== "transfer-in" && action !== "transfer-out") return;
+
+ actionDeepLinkHandledRef.current = true;
+ if (!isCreditPlayer) {
+ if (action === "transfer-in") {
+ setTransferInOpen(true);
+ } else {
+ setTransferOutOpen(true);
+ }
+ }
+ router.replace("/wallet", { scroll: false });
+ }, [isCreditPlayer, loading, router, searchParams]);
+
+ useEffect(() => {
+ if (sectionDeepLinkHandledRef.current || loading) return;
+ const section = searchParams.get("section");
+ if (section !== "logs" && section !== "pending") return;
+
+ sectionDeepLinkHandledRef.current = true;
+ const targetId =
+ section === "logs" || (section === "pending" && isCreditPlayer)
+ ? "wallet-logs"
+ : "wallet-pending";
+ const timer = window.setTimeout(() => {
+ scrollToWalletSection(targetId);
+ router.replace("/wallet", { scroll: false });
+ }, 120);
+ return () => window.clearTimeout(timer);
+ }, [isCreditPlayer, loading, router, searchParams]);
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({
@@ -164,8 +213,6 @@ export function WalletScreen() {
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
- const profile = usePlayerSessionStore((s) => s.profile);
- const isCreditPlayer = isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile);
const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0);
@@ -261,17 +308,8 @@ export function WalletScreen() {
- {isCreditPlayer ? (
-
- {t("wallet.creditNoTransferHint", {
- defaultValue:
- "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
- })}
-
- ) : (
- <>
-
-
+ {!isCreditPlayer ? (
+
- >
- )}
+ ) : null}
+
scrollToWalletSection("wallet-logs")}
+ />
+
+
+
);
diff --git a/src/features/wallet/wallet-transfer-credit-guard.tsx b/src/features/wallet/wallet-transfer-credit-guard.tsx
index 541ec25..ae03353 100644
--- a/src/features/wallet/wallet-transfer-credit-guard.tsx
+++ b/src/features/wallet/wallet-transfer-credit-guard.tsx
@@ -2,9 +2,6 @@
import { useRouter } from "next/navigation";
import { useEffect, type ReactNode } from "react";
-import { useTranslation } from "react-i18next";
-
-import { PlayerPanel } from "@/components/layout/player-panel";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
@@ -21,7 +18,6 @@ export function WalletTransferCreditGuard({
children,
}: WalletTransferCreditGuardProps) {
const router = useRouter();
- const { t } = useTranslation("player");
useEffect(() => {
if (loading || !balance) {
@@ -37,20 +33,7 @@ export function WalletTransferCreditGuard({
}
if (isCreditFundingPlayer(balance)) {
- return (
-
-
- {t("wallet.creditNoTransferHint", {
- defaultValue:
- "由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
- })}
-
-
- );
+ return null;
}
return <>{children}>;
diff --git a/src/features/wallet/wallet-transfer-dialogs.tsx b/src/features/wallet/wallet-transfer-dialogs.tsx
index 9ffc34b..1dff973 100644
--- a/src/features/wallet/wallet-transfer-dialogs.tsx
+++ b/src/features/wallet/wallet-transfer-dialogs.tsx
@@ -1,7 +1,7 @@
"use client";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -23,6 +23,8 @@ type BaseProps = {
onSuccess: () => Promise;
/** 避免同页多实例 input id 冲突 */
idPrefix?: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
};
export function TransferInDialog({
@@ -34,6 +36,8 @@ export function TransferInDialog({
triggerClassName,
triggerVariant = "wallet",
triggerLabel,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
}: BaseProps & {
lotteryMinor: number;
mainMinor?: number | null;
@@ -41,7 +45,19 @@ export function TransferInDialog({
triggerVariant?: "wallet" | "hall";
triggerLabel?: string;
}) {
- const [open, setOpen] = useState(false);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
+ const setOpen = useCallback(
+ (next: boolean) => {
+ if (isControlled) {
+ controlledOnOpenChange?.(next);
+ } else {
+ setUncontrolledOpen(next);
+ }
+ },
+ [controlledOnOpenChange, isControlled],
+ );
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
@@ -94,13 +110,27 @@ export function TransferOutDialog({
triggerClassName,
triggerVariant = "wallet",
triggerLabel,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
}: BaseProps & {
availableMinor: number;
triggerClassName?: string;
triggerVariant?: "wallet" | "hall";
triggerLabel?: string;
}) {
- const [open, setOpen] = useState(false);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
+ const setOpen = useCallback(
+ (next: boolean) => {
+ if (isControlled) {
+ controlledOnOpenChange?.(next);
+ } else {
+ setUncontrolledOpen(next);
+ }
+ },
+ [controlledOnOpenChange, isControlled],
+ );
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts
index 3c6da11..2da3b71 100644
--- a/src/hooks/use-mobile.ts
+++ b/src/hooks/use-mobile.ts
@@ -1,17 +1,9 @@
"use client";
-import { useEffect, useState } from "react";
-
-export function useIsMobile(breakpoint = 768): boolean {
- const [isMobile, setIsMobile] = useState(false);
-
- useEffect(() => {
- const query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
- const update = () => setIsMobile(query.matches);
- update();
- query.addEventListener("change", update);
- return () => query.removeEventListener("change", update);
- }, [breakpoint]);
-
- return isMobile;
+/**
+ * 玩家端 H5 内容列最大 480px,交互一律按紧凑布局处理。
+ * 宽屏浏览器中虽 viewport 更宽,画布仍按手机宽度展示,因此恒为 `true`。
+ */
+export function useIsMobile(): boolean {
+ return true;
}
diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json
index 134c0a4..a633d14 100644
--- a/src/i18n/locales/en/player.json
+++ b/src/i18n/locales/en/player.json
@@ -506,6 +506,8 @@
"lineFailedTitle": "This line did not succeed",
"status": "Status",
"statusFilter": "Status filter",
+ "showFilters": "Filters",
+ "hideFilters": "Hide",
"noMore": "No more tickets",
"submitBet": "Submit Bet",
"stake": "Stake",
@@ -520,6 +522,7 @@
"groupDetail": "Order detail",
"groupNotFound": "Could not load this order. Please open it again from My Bets.",
"betItems": "Bet lines",
+ "sameOrderItems": "Other lines in this order",
"itemCount": "{{count}} bet line(s)",
"viewBetLine": "View bet line detail",
"drawNo": "Issue",
@@ -607,6 +610,7 @@
"ticketNumber": "Ticket No. / Number",
"placeholder": "Enter ticket number or number",
"latestDraw": "Latest issue {{drawNo}}",
+ "forDraw": "Check your bets and winnings for issue {{drawNo}}.",
"loading": "Checking...",
"submit": "Check now",
"recent": "Recent checks",
diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json
index 08eebab..8612917 100644
--- a/src/i18n/locales/ne/player.json
+++ b/src/i18n/locales/ne/player.json
@@ -505,6 +505,8 @@
"lineFailedTitle": "यो लाइन सफल भएन",
"status": "स्थिति",
"statusFilter": "स्थिति फिल्टर",
+ "showFilters": "फिल्टर",
+ "hideFilters": "लुकाउनुहोस्",
"noMore": "थप टिकट छैन",
"submitBet": "बेट पेश गर्नुहोस्",
"stake": "बेट",
@@ -519,6 +521,7 @@
"groupDetail": "अर्डर विवरण",
"groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।",
"betItems": "बेट लाइन विवरण",
+ "sameOrderItems": "यही अर्डरका अन्य बेट लाइनहरू",
"itemCount": "जम्मा {{count}} बेट लाइन",
"viewBetLine": "बेट लाइन विवरण हेर्नुहोस्",
"drawNo": "इश्यू",
@@ -606,6 +609,7 @@
"ticketNumber": "टिकट नं. / नम्बर",
"placeholder": "टिकट नम्बर वा नम्बर लेख्नुहोस्",
"latestDraw": "पछिल्लो इश्यू {{drawNo}}",
+ "forDraw": "इश्यू {{drawNo}} का लागि तपाईंको बेट र जित जाँच गर्नुहोस्।",
"loading": "जाँच हुँदैछ...",
"submit": "अहिले जाँच गर्नुहोस्",
"recent": "हालका जाँच",
diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json
index 11cad7e..9d19139 100644
--- a/src/i18n/locales/zh/player.json
+++ b/src/i18n/locales/zh/player.json
@@ -505,6 +505,8 @@
"lineFailedTitle": "本注项未成功",
"status": "状态",
"statusFilter": "状态筛选",
+ "showFilters": "筛选",
+ "hideFilters": "收起",
"noMore": "没有更多注单",
"submitBet": "提交下注",
"stake": "下注",
@@ -519,6 +521,7 @@
"groupDetail": "订单详情",
"groupNotFound": "无法加载该订单,请从注单列表重新进入。",
"betItems": "注项明细",
+ "sameOrderItems": "同订单其他注项",
"itemCount": "共 {{count}} 条注项",
"viewBetLine": "查看注项详情",
"drawNo": "期号",
@@ -606,6 +609,7 @@
"ticketNumber": "票号 / 号码",
"placeholder": "请输入票号或号码",
"latestDraw": "最新期号 {{drawNo}}",
+ "forDraw": "按本期 {{drawNo}} 查询你的注单和中奖情况。",
"loading": "查询中...",
"submit": "立即查询",
"recent": "最近查询",
diff --git a/src/lib/next-dev-origins.ts b/src/lib/next-dev-origins.ts
index 599ae92..b315308 100644
--- a/src/lib/next-dev-origins.ts
+++ b/src/lib/next-dev-origins.ts
@@ -1,11 +1,15 @@
+/** 常见局域网段,供手机/其他设备通过 IP 访问 dev 时 HMR WebSocket 放行 */
+const DEFAULT_LAN_DEV_ORIGINS = ["192.168.*.*", "10.*.*.*"];
+
/** 解析 `ALLOWED_DEV_ORIGINS`(逗号分隔),供 next.config `allowedDevOrigins` 使用 */
export function parseAllowedDevOrigins(envValue: string | undefined): string[] {
- if (envValue === undefined || envValue.trim() === "") {
- return [];
- }
+ const fromEnv =
+ envValue === undefined || envValue.trim() === ""
+ ? []
+ : envValue
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter((origin) => origin !== "");
- return envValue
- .split(",")
- .map((origin) => origin.trim())
- .filter((origin) => origin !== "");
+ return [...DEFAULT_LAN_DEV_ORIGINS, ...fromEnv];
}
diff --git a/src/lib/player-viewport.ts b/src/lib/player-viewport.ts
index ea81cf5..44b029a 100644
--- a/src/lib/player-viewport.ts
+++ b/src/lib/player-viewport.ts
@@ -7,3 +7,6 @@ export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
export const playerViewportFixedBarClass =
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const;
+
+/** 刘海屏 / 全屏 WebView 顶部安全区 */
+export const playerSafeAreaTopClass = "pt-[env(safe-area-inset-top,0px)]" as const;