feat: 优化下注结果展示与大厅表单交互,适配新端口配置

This commit is contained in:
2026-05-18 11:28:41 +08:00
parent 5f5ce6c29d
commit 418b446c09
16 changed files with 170 additions and 56 deletions

View File

@@ -14,7 +14,7 @@ pnpm dev
bun dev bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3800](http://localhost:3800) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --port 3800",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"

View File

@@ -142,7 +142,7 @@
<div class="control-group"> <div class="control-group">
<label>彩票系统地址:</label> <label>彩票系统地址:</label>
<input type="text" id="lotteryUrl" value="http://localhost:3000/hall?token="> <input type="text" id="lotteryUrl" value="http://localhost:3800/hall?token=">
</div> </div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> <div style="display: flex; gap: 10px; flex-wrap: wrap;">
@@ -166,7 +166,7 @@
</div> </div>
<script> <script>
const lotteryOrigin = 'http://localhost:3000'; const lotteryOrigin = 'http://localhost:3800';
let currentToken = null; let currentToken = null;
let tokenExpiryTime = null; let tokenExpiryTime = null;

View File

@@ -92,8 +92,8 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
const allowedOrigins = [ const allowedOrigins = [
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:3000", "http://localhost:3800",
"http://127.0.0.1:3000", "http://127.0.0.1:3800",
].filter(Boolean); ].filter(Boolean);
if ( if (

View File

@@ -87,7 +87,7 @@ function Calendar({
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
table: "w-full border-collapse", month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",

View File

@@ -171,7 +171,7 @@ export function HallBetPreviewDialog({
</div> </div>
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]"> <div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
<table className="min-w-[520px] w-full border-collapse text-xs"> <table className="min-w-[640px] w-full border-collapse text-xs">
<thead className="bg-[#f4f7fd] text-[#304f86]"> <thead className="bg-[#f4f7fd] text-[#304f86]">
<tr> <tr>
<th className="w-10 border-r border-[#dfe8f6] px-2 py-3 text-center font-black">No.</th> <th className="w-10 border-r border-[#dfe8f6] px-2 py-3 text-center font-black">No.</th>
@@ -187,6 +187,9 @@ export function HallBetPreviewDialog({
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black"> <th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
{t("hall.preview.rebate")} {t("hall.preview.rebate")}
</th> </th>
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
{t("hall.preview.estimatedMax")}
</th>
<th className="px-2 py-3 text-center font-black"> <th className="px-2 py-3 text-center font-black">
{t("hall.preview.actual")} {t("hall.preview.actual")}
</th> </th>
@@ -213,6 +216,9 @@ export function HallBetPreviewDialog({
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums text-emerald-600"> <td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums text-emerald-600">
-{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")} -{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")}
</td> </td>
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-black tabular-nums text-[#e5002c]">
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
</td>
<td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]"> <td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</td> </td>
@@ -223,7 +229,7 @@ export function HallBetPreviewDialog({
</div> </div>
{summary ? ( {summary ? (
<div className="grid grid-cols-3 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs"> <div className="grid grid-cols-2 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs sm:grid-cols-4">
<div className="border-r border-[#dfe8f6] px-2 py-3"> <div className="border-r border-[#dfe8f6] px-2 py-3">
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p> <p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
<p className="mt-1 font-black tabular-nums text-[#0b3f96]"> <p className="mt-1 font-black tabular-nums text-[#0b3f96]">
@@ -240,12 +246,18 @@ export function HallBetPreviewDialog({
)} )}
</p> </p>
</div> </div>
<div className="px-2 py-3"> <div className="border-r border-[#dfe8f6] px-2 py-3">
<p className="font-bold text-[#304f86]">{t("hall.preview.actualDeduct")}</p> <p className="font-bold text-[#304f86]">{t("hall.preview.actualDeduct")}</p>
<p className="mt-1 font-black tabular-nums text-[#e5002c]"> <p className="mt-1 font-black tabular-nums text-[#e5002c]">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</p> </p>
</div> </div>
<div className="px-2 py-3">
<p className="font-bold text-[#304f86]">{t("hall.preview.estimatedPayout")}</p>
<p className="mt-1 font-black tabular-nums text-[#e5002c]">
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
</p>
</div>
</div> </div>
) : null} ) : null}

View File

@@ -31,9 +31,10 @@ export function HallBetResultDialog({
}: HallBetResultDialogProps) { }: HallBetResultDialogProps) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const totalItems = data?.items.length ?? 0; const successItems = data?.items.filter((item) => (item.status ?? "success") === "success") ?? [];
const totalSuccess = totalItems; const failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
const totalFailure = 0; const totalSuccess = data?.summary.success_count ?? successItems.length;
const totalFailure = data?.summary.failure_count ?? failedItems.length;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -131,7 +132,7 @@ export function HallBetResultDialog({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((item, index) => ( {successItems.map((item, index) => (
<tr <tr
key={`${item.ticket_no}-${index}`} key={`${item.ticket_no}-${index}`}
className="border-t border-[#e8eef7] bg-white" className="border-t border-[#e8eef7] bg-white"
@@ -162,7 +163,27 @@ export function HallBetResultDialog({
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700"> <div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3 text-sm font-semibold text-emerald-700">
{t("hall.result.noFailures", { defaultValue: "本次提交没有失败注项。" })} {t("hall.result.noFailures", { defaultValue: "本次提交没有失败注项。" })}
</div> </div>
) : null} ) : (
<div className="space-y-2 rounded-lg border border-rose-100 bg-rose-50 px-3 py-3">
<p className="text-sm font-black text-[#e5002c]">
{t("hall.result.failedItems", { defaultValue: "失败注项明细" })}
</p>
{failedItems.map((item, index) => (
<div
key={`${item.ticket_no}-${index}`}
className="flex items-center justify-between gap-3 rounded-md bg-white px-3 py-2 text-xs"
>
<span className="min-w-0 truncate font-semibold text-slate-700">
<span className="font-mono font-black text-slate-950">{item.number}</span>{" "}
{playLabel(item.play_code, t)}
</span>
<span className="shrink-0 font-bold text-[#e5002c]">
{item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed", { defaultValue: "失败" })}
</span>
</div>
))}
</div>
)}
</div> </div>
</> </>
)} )}

View File

@@ -44,12 +44,20 @@ type DraftRow = {
type DraftEntry = { type DraftEntry = {
rowId: string; rowId: string;
rowNo: number; rowNo: number;
amountKey: string;
play: PlayEffectivePlayRow; play: PlayEffectivePlayRow;
digitSlot?: number;
number: string; number: string;
amountMinor: number; amountMinor: number;
line: TicketLineInput; line: TicketLineInput;
}; };
type PlayColumn = {
key: string;
play: PlayEffectivePlayRow;
digitSlot?: number;
};
type ClosedPlayCleanupData = { type ClosedPlayCleanupData = {
cleanup_hint?: string; cleanup_hint?: string;
cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>; cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
@@ -113,6 +121,42 @@ function pickDisplayName(row: PlayEffectivePlayRow): string {
return row.display_name_en ?? row.display_name_zh ?? row.play_code; return row.display_name_en ?? row.display_name_zh ?? row.play_code;
} }
function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] {
if (category === "D2") return [2, 3];
if (category === "D3") return [1, 2, 3];
return [0, 1, 2, 3];
}
function digitSlotLabel(category: Exclude<HallCategory, "JACKPOT">, slot: number): string {
const labels: Record<Exclude<HallCategory, "JACKPOT">, Record<number, string>> = {
D2: { 2: "十", 3: "个" },
D3: { 1: "百", 2: "十", 3: "个" },
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
};
return labels[category][slot] ?? String(slot + 1);
}
function amountKeyForPlay(playCode: string, digitSlot?: number): string {
return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
}
function playColumnsForCategory(
plays: PlayEffectivePlayRow[],
category: Exclude<HallCategory, "JACKPOT">,
): PlayColumn[] {
return plays.flatMap((play) => {
if (!playNeedsDigitSlot(play.play_code)) {
return [{ key: amountKeyForPlay(play.play_code), play }];
}
return digitSlotOptions(category).map((digitSlot) => ({
key: amountKeyForPlay(play.play_code, digitSlot),
play,
digitSlot,
}));
});
}
function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> { function inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> {
if (row.play_code.startsWith("pos_2")) return "D2"; if (row.play_code.startsWith("pos_2")) return "D2";
if (row.play_code.startsWith("pos_3")) return "D3"; if (row.play_code.startsWith("pos_3")) return "D3";
@@ -159,16 +203,12 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
return number; return number;
} }
function pickDigitSlot(category: HallCategory): number {
if (category === "D2") return 3;
return 3;
}
function lineForPlay( function lineForPlay(
category: Exclude<HallCategory, "JACKPOT">, category: Exclude<HallCategory, "JACKPOT">,
play: PlayEffectivePlayRow, play: PlayEffectivePlayRow,
displayNumber: string, displayNumber: string,
amountMinor: number, amountMinor: number,
digitSlot?: number,
): TicketLineInput | null { ): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code); const number = normalizeNumberForPlay(displayNumber, play.play_code);
const spec = ticketNumberSpec(play.play_code); const spec = ticketNumberSpec(play.play_code);
@@ -186,7 +226,8 @@ function lineForPlay(
line.dimension = category; line.dimension = category;
} }
if (playNeedsDigitSlot(play.play_code)) { if (playNeedsDigitSlot(play.play_code)) {
line.digit_slot = pickDigitSlot(category); if (digitSlot === undefined) return null;
line.digit_slot = digitSlot;
} }
return line; return line;
@@ -256,6 +297,7 @@ function matchesRiskAlert(
playCode: string, playCode: string,
rowNumber: string, rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">, category: Exclude<HallCategory, "JACKPOT">,
digitSlot?: number,
): boolean { ): boolean {
const normalizedRow = rowNumber.toUpperCase(); const normalizedRow = rowNumber.toUpperCase();
@@ -297,7 +339,8 @@ function matchesRiskAlert(
: ["0", "2", "4", "6", "8"].includes(last); : ["0", "2", "4", "6", "8"].includes(last);
} }
if (playCode === "digit_big" || playCode === "digit_small") { if (playCode === "digit_big" || playCode === "digit_small") {
const last = alertNumber[pickDigitSlot(category)] ?? ""; const slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
const last = alertNumber[slot] ?? "";
return playCode === "digit_big" return playCode === "digit_big"
? ["5", "6", "7", "8", "9"].includes(last) ? ["5", "6", "7", "8", "9"].includes(last)
: ["0", "1", "2", "3", "4"].includes(last); : ["0", "1", "2", "3", "4"].includes(last);
@@ -311,6 +354,7 @@ function cellRiskState(
rowNumber: string, rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">, category: Exclude<HallCategory, "JACKPOT">,
alertRows: DrawCurrentRiskPoolAlert[] | undefined, alertRows: DrawCurrentRiskPoolAlert[] | undefined,
digitSlot?: number,
): CellRiskState { ): CellRiskState {
const alerts = alertRows ?? []; const alerts = alertRows ?? [];
if (alerts.length === 0) return "open"; if (alerts.length === 0) return "open";
@@ -318,7 +362,7 @@ function cellRiskState(
if (!normalizedRow) return "open"; if (!normalizedRow) return "open";
for (const alert of alerts) { for (const alert of alerts) {
if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category)) { if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
return alert.is_sold_out ? "sold_out" : "warning"; return alert.is_sold_out ? "sold_out" : "warning";
} }
} }
@@ -427,6 +471,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
); );
}, [activeCategory, catalogState, openPlays]); }, [activeCategory, catalogState, openPlays]);
const playColumns = useMemo(() => {
if (activeCategory === "JACKPOT") return [];
return playColumnsForCategory(categoryPlays, activeCategory);
}, [activeCategory, categoryPlays]);
const activeRow = useMemo( const activeRow = useMemo(
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null, () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
[activeRowId, rows], [activeRowId, rows],
@@ -532,15 +581,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (activeCategory === "JACKPOT") return []; if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = []; const entries: DraftEntry[] = [];
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
categoryPlays.forEach((play) => { playColumns.forEach((column) => {
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? ""); const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "");
if (amount === null || amount <= 0) return; if (amount === null || amount <= 0) return;
const line = lineForPlay(activeCategory, play, row.number, amount); const line = lineForPlay(activeCategory, column.play, row.number, amount, column.digitSlot);
if (!line) return; if (!line) return;
entries.push({ entries.push({
rowId: row.id, rowId: row.id,
rowNo: rowIndex + 1, rowNo: rowIndex + 1,
play, amountKey: column.key,
play: column.play,
digitSlot: column.digitSlot,
number: row.number, number: row.number,
amountMinor: amount, amountMinor: amount,
line, line,
@@ -548,7 +599,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}); });
}); });
return entries; return entries;
}, [activeCategory, categoryPlays, rows]); }, [activeCategory, playColumns, rows]);
const draftEntries = collectEntries(); const draftEntries = collectEntries();
const draftSummary = useMemo(() => { const draftSummary = useMemo(() => {
@@ -587,7 +638,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return; if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return;
const entry = entries[clientLineNo - 1]; const entry = entries[clientLineNo - 1];
if (!entry) return; if (!entry) return;
cleanupPairs.add(`${entry.rowId}::${playCode}`); cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`);
}); });
if (cleanupPairs.size === 0) return false; if (cleanupPairs.size === 0) return false;
@@ -596,9 +647,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
current.map((row) => { current.map((row) => {
const nextAmounts = { ...row.amounts }; const nextAmounts = { ...row.amounts };
let changed = false; let changed = false;
Object.keys(nextAmounts).forEach((playCode) => { Object.keys(nextAmounts).forEach((amountKey) => {
if (!cleanupPairs.has(`${row.id}::${playCode}`)) return; if (!cleanupPairs.has(`${row.id}::${amountKey}`)) return;
nextAmounts[playCode] = ""; nextAmounts[amountKey] = "";
changed = true; changed = true;
}); });
return changed ? { ...row, amounts: nextAmounts } : row; return changed ? { ...row, amounts: nextAmounts } : row;
@@ -699,6 +750,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode), amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
}), }),
); );
if ((data.summary.failure_count ?? 0) > 0) {
toast.warning(
t("hall.placePartialFailed", {
success: data.summary.success_count ?? 0,
failed: data.summary.failure_count ?? 0,
defaultValue: "{{success}} 个成功,{{failed}} 个失败",
}),
);
}
} catch (e) { } catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0; const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed"); const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
@@ -945,9 +1005,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<span className="block">{t("hall.table.number", { defaultValue: "Number" })}</span> <span className="block">{t("hall.table.number", { defaultValue: "Number" })}</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span> <span className="block text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span>
</th> </th>
{categoryPlays.map((play) => ( {playColumns.map((column) => (
<th key={play.play_code} className="min-w-16 px-1 py-2 text-center font-bold"> <th key={column.key} className="min-w-16 px-1 py-2 text-center font-bold">
<span className="block truncate">{pickDisplayName(play)}</span> <span className="block truncate">
{pickDisplayName(column.play)}
{column.digitSlot !== undefined
? `-${digitSlotLabel(activeCategory, column.digitSlot)}`
: ""}
</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]"> <span className="block text-[9px] font-medium text-[#9aa8bd]">
{t("hall.table.amountPlaceholder", { defaultValue: "金额" })} {t("hall.table.amountPlaceholder", { defaultValue: "金额" })}
</span> </span>
@@ -976,20 +1041,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
className="h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono text-sm font-black tracking-[0.1em] text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]" className="h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono text-sm font-black tracking-[0.1em] text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]"
/> />
</td> </td>
{categoryPlays.map((play) => { {playColumns.map((column) => {
const amountText = row.amounts[play.play_code] ?? ""; const { play } = column;
const amountText = row.amounts[column.key] ?? "";
const status = cellRiskState( const status = cellRiskState(
play, play,
row.number, row.number,
activeCategory as Exclude<HallCategory, "JACKPOT">, activeCategory as Exclude<HallCategory, "JACKPOT">,
alertRows, alertRows,
column.digitSlot,
); );
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
const hasAmount = amountText.trim().length > 0; const hasAmount = amountText.trim().length > 0;
return ( return (
<td <td
key={`${rowKey}-${play.play_code}`} key={`${rowKey}-${column.key}`}
className={cn( className={cn(
"px-1 py-2 align-top", "px-1 py-2 align-top",
status === "warning" && "bg-amber-50/70", status === "warning" && "bg-amber-50/70",
@@ -1007,7 +1074,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
} }
onFocus={() => setActiveRowId(row.id)} onFocus={() => setActiveRowId(row.id)}
onClick={() => setActiveRowId(row.id)} onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)} onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn( className={cn(
"h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]", "h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]", hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Hourglass, Landmark, TimerReset } from "lucide-react"; import { Hourglass, Landmark, TimerReset } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -49,20 +49,18 @@ function CloseTime({
}) { }) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const sealedCountdown = isHallSealedCountdownUi(payload.status); const sealedCountdown = isHallSealedCountdownUi(payload.status);
const [nowMs, setNowMs] = useState(serverNowMs); const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
setNowMs(serverNowMs);
}, [serverNowMs]);
useEffect(() => { useEffect(() => {
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setNowMs((current) => current + 1000); setElapsedSeconds((current) => current + 1);
}, 1000); }, 1000);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, []); }, []);
const nowMs = serverNowMs + elapsedSeconds * 1000;
let seconds = 0; let seconds = 0;
let label = t("draw.closesIn"); let label = t("draw.closesIn");
@@ -150,7 +148,12 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
<CurrentTime payload={display} /> <CurrentTime payload={display} />
</div> </div>
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center"> <div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
<CloseTime serverNowMs={serverNowMs} hud={hud} payload={display} /> <CloseTime
key={`${display.draw_no}-${display.status}-${serverNowMs}`}
serverNowMs={serverNowMs}
hud={hud}
payload={display}
/>
<Hourglass <Hourglass
className={cn( className={cn(
"absolute right-2 top-1/2 size-5 -translate-y-1/2", "absolute right-2 top-1/2 size-5 -translate-y-1/2",

View File

@@ -43,6 +43,10 @@ export function TicketOrdersListScreen() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
const statusFilter = useMemo(
() => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean),
[searchParams],
);
const [items, setItems] = useState<TicketItemListRow[]>([]); const [items, setItems] = useState<TicketItemListRow[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -53,7 +57,7 @@ export function TicketOrdersListScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [queryDrawNo, setQueryDrawNo] = useState(drawNoFilter); const [queryDrawNo, setQueryDrawNo] = useState(drawNoFilter);
const [queryNumber, setQueryNumber] = useState(""); const [queryNumber, setQueryNumber] = useState("");
const [queryStatuses, setQueryStatuses] = useState<string[]>([]); const [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
const [fromDate, setFromDate] = useState(""); const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState(""); const [toDate, setToDate] = useState("");
const [rangeOpen, setRangeOpen] = useState(false); const [rangeOpen, setRangeOpen] = useState(false);
@@ -179,7 +183,7 @@ export function TicketOrdersListScreen() {
setQueryNumber(""); setQueryNumber("");
setFromDate(""); setFromDate("");
setToDate(""); setToDate("");
setQueryStatuses([]); setQueryStatuses(statusFilter);
setRangeOpen(false); setRangeOpen(false);
setStatusOpen(false); setStatusOpen(false);
}} }}

View File

@@ -271,7 +271,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
{t("results.hitHint")} {t("results.hitHint")}
</p> </p>
<Link <Link
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`} href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}&status=settled_win`}
className={cn( className={cn(
buttonVariants({ variant: "default", size: "sm" }), buttonVariants({ variant: "default", size: "sm" }),
"mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028] sm:w-auto", "mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028] sm:w-auto",

View File

@@ -268,7 +268,7 @@ export function DrawResultsListScreen() {
<div className="pt-4"> <div className="pt-4">
<TwentyThreeResultsGrid numbers={featured.results} /> <TwentyThreeResultsGrid numbers={featured.results} />
<Link <Link
href={`/orders?draw_no=${encodeURIComponent(featured.draw_no)}`} href={`/orders?draw_no=${encodeURIComponent(featured.draw_no)}&status=settled_win`}
className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#e5002c] px-4 text-sm font-bold text-white transition-colors hover:bg-[#d10028]" className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#e5002c] px-4 text-sm font-bold text-white transition-colors hover:bg-[#d10028]"
> >
{t("results.viewMyWinning")} {t("results.viewMyWinning")}

View File

@@ -121,8 +121,8 @@ export function useTokenRefresh(): {
const allowedOrigins = [ const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL, process.env.NEXT_PUBLIC_MAIN_SITE_URL,
// 开发环境允许本地 // 开发环境允许本地
"http://localhost:3000", "http://localhost:3800",
"http://127.0.0.1:3000", "http://127.0.0.1:3800",
].filter(Boolean); ].filter(Boolean);
if ( if (

View File

@@ -9,8 +9,8 @@ const ALLOWED_PARENT_ORIGINS: string[] = [
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:3001", "http://localhost:3801",
"http://127.0.0.1:3001", "http://127.0.0.1:3801",
// 生产环境应从环境变量读取 // 生产环境应从环境变量读取
].filter((o): o is string => Boolean(o)); ].filter((o): o is string => Boolean(o));

View File

@@ -65,5 +65,6 @@ export type TicketDrawMyMatchPayload = {
hit_numbers_4d: string[]; hit_numbers_4d: string[];
total_win_minor: number; total_win_minor: number;
total_jackpot_win_minor: number; total_jackpot_win_minor: number;
winning_ticket_count: number;
has_bets: boolean; has_bets: boolean;
}; };

View File

@@ -66,12 +66,18 @@ export type TicketPlaceItem = {
actual_deduct_amount: number; actual_deduct_amount: number;
estimated_max_payout: number; estimated_max_payout: number;
combination_count: number; combination_count: number;
status?: "success" | "failed" | string;
fail_reason_code?: string | null;
fail_reason_text?: string | null;
}; };
export type TicketPlaceData = { export type TicketPlaceData = {
order_no: string; order_no: string;
draw: { draw_id: string; status: string }; draw: { draw_id: string; status: string };
summary: TicketPreviewData["summary"]; summary: TicketPreviewData["summary"] & {
success_count?: number;
failure_count?: number;
};
balance_after: number; balance_after: number;
items: TicketPlaceItem[]; items: TicketPlaceItem[];
}; };