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
```
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.

View File

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

View File

@@ -142,7 +142,7 @@
<div class="control-group">
<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 style="display: flex; gap: 10px; flex-wrap: wrap;">
@@ -166,7 +166,7 @@
</div>
<script>
const lotteryOrigin = 'http://localhost:3000';
const lotteryOrigin = 'http://localhost:3800';
let currentToken = null;
let tokenExpiryTime = null;

View File

@@ -92,8 +92,8 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode {
const allowedOrigins = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3800",
"http://127.0.0.1:3800",
].filter(Boolean);
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",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"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 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]">
<tr>
<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">
{t("hall.preview.rebate")}
</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">
{t("hall.preview.actual")}
</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">
-{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")}
</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]">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</td>
@@ -223,7 +229,7 @@ export function HallBetPreviewDialog({
</div>
{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">
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
<p className="mt-1 font-black tabular-nums text-[#0b3f96]">
@@ -240,12 +246,18 @@ export function HallBetPreviewDialog({
)}
</p>
</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="mt-1 font-black tabular-nums text-[#e5002c]">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</p>
</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>
) : null}

View File

@@ -31,9 +31,10 @@ export function HallBetResultDialog({
}: HallBetResultDialogProps) {
const { t } = useTranslation("player");
const totalItems = data?.items.length ?? 0;
const totalSuccess = totalItems;
const totalFailure = 0;
const successItems = data?.items.filter((item) => (item.status ?? "success") === "success") ?? [];
const failedItems = data?.items.filter((item) => item.status === "failed") ?? [];
const totalSuccess = data?.summary.success_count ?? successItems.length;
const totalFailure = data?.summary.failure_count ?? failedItems.length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -131,7 +132,7 @@ export function HallBetResultDialog({
</tr>
</thead>
<tbody>
{data.items.map((item, index) => (
{successItems.map((item, index) => (
<tr
key={`${item.ticket_no}-${index}`}
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">
{t("hall.result.noFailures", { defaultValue: "本次提交没有失败注项。" })}
</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>
</>
)}

View File

@@ -44,12 +44,20 @@ type DraftRow = {
type DraftEntry = {
rowId: string;
rowNo: number;
amountKey: string;
play: PlayEffectivePlayRow;
digitSlot?: number;
number: string;
amountMinor: number;
line: TicketLineInput;
};
type PlayColumn = {
key: string;
play: PlayEffectivePlayRow;
digitSlot?: number;
};
type ClosedPlayCleanupData = {
cleanup_hint?: 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;
}
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"> {
if (row.play_code.startsWith("pos_2")) return "D2";
if (row.play_code.startsWith("pos_3")) return "D3";
@@ -159,16 +203,12 @@ function normalizeNumberForPlay(number: string, playCode: string): string {
return number;
}
function pickDigitSlot(category: HallCategory): number {
if (category === "D2") return 3;
return 3;
}
function lineForPlay(
category: Exclude<HallCategory, "JACKPOT">,
play: PlayEffectivePlayRow,
displayNumber: string,
amountMinor: number,
digitSlot?: number,
): TicketLineInput | null {
const number = normalizeNumberForPlay(displayNumber, play.play_code);
const spec = ticketNumberSpec(play.play_code);
@@ -186,7 +226,8 @@ function lineForPlay(
line.dimension = category;
}
if (playNeedsDigitSlot(play.play_code)) {
line.digit_slot = pickDigitSlot(category);
if (digitSlot === undefined) return null;
line.digit_slot = digitSlot;
}
return line;
@@ -256,6 +297,7 @@ function matchesRiskAlert(
playCode: string,
rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">,
digitSlot?: number,
): boolean {
const normalizedRow = rowNumber.toUpperCase();
@@ -297,7 +339,8 @@ function matchesRiskAlert(
: ["0", "2", "4", "6", "8"].includes(last);
}
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"
? ["5", "6", "7", "8", "9"].includes(last)
: ["0", "1", "2", "3", "4"].includes(last);
@@ -311,6 +354,7 @@ function cellRiskState(
rowNumber: string,
category: Exclude<HallCategory, "JACKPOT">,
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
digitSlot?: number,
): CellRiskState {
const alerts = alertRows ?? [];
if (alerts.length === 0) return "open";
@@ -318,7 +362,7 @@ function cellRiskState(
if (!normalizedRow) return "open";
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";
}
}
@@ -427,6 +471,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
);
}, [activeCategory, catalogState, openPlays]);
const playColumns = useMemo(() => {
if (activeCategory === "JACKPOT") return [];
return playColumnsForCategory(categoryPlays, activeCategory);
}, [activeCategory, categoryPlays]);
const activeRow = useMemo(
() => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
[activeRowId, rows],
@@ -532,15 +581,17 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (activeCategory === "JACKPOT") return [];
const entries: DraftEntry[] = [];
rows.forEach((row, rowIndex) => {
categoryPlays.forEach((play) => {
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
playColumns.forEach((column) => {
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "");
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;
entries.push({
rowId: row.id,
rowNo: rowIndex + 1,
play,
amountKey: column.key,
play: column.play,
digitSlot: column.digitSlot,
number: row.number,
amountMinor: amount,
line,
@@ -548,7 +599,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
});
});
return entries;
}, [activeCategory, categoryPlays, rows]);
}, [activeCategory, playColumns, rows]);
const draftEntries = collectEntries();
const draftSummary = useMemo(() => {
@@ -587,7 +638,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return;
const entry = entries[clientLineNo - 1];
if (!entry) return;
cleanupPairs.add(`${entry.rowId}::${playCode}`);
cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`);
});
if (cleanupPairs.size === 0) return false;
@@ -596,9 +647,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
current.map((row) => {
const nextAmounts = { ...row.amounts };
let changed = false;
Object.keys(nextAmounts).forEach((playCode) => {
if (!cleanupPairs.has(`${row.id}::${playCode}`)) return;
nextAmounts[playCode] = "";
Object.keys(nextAmounts).forEach((amountKey) => {
if (!cleanupPairs.has(`${row.id}::${amountKey}`)) return;
nextAmounts[amountKey] = "";
changed = true;
});
return changed ? { ...row, amounts: nextAmounts } : row;
@@ -699,6 +750,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
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) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
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 text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span>
</th>
{categoryPlays.map((play) => (
<th key={play.play_code} className="min-w-16 px-1 py-2 text-center font-bold">
<span className="block truncate">{pickDisplayName(play)}</span>
{playColumns.map((column) => (
<th key={column.key} className="min-w-16 px-1 py-2 text-center font-bold">
<span className="block truncate">
{pickDisplayName(column.play)}
{column.digitSlot !== undefined
? `-${digitSlotLabel(activeCategory, column.digitSlot)}`
: ""}
</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">
{t("hall.table.amountPlaceholder", { defaultValue: "金额" })}
</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]"
/>
</td>
{categoryPlays.map((play) => {
const amountText = row.amounts[play.play_code] ?? "";
{playColumns.map((column) => {
const { play } = column;
const amountText = row.amounts[column.key] ?? "";
const status = cellRiskState(
play,
row.number,
activeCategory as Exclude<HallCategory, "JACKPOT">,
alertRows,
column.digitSlot,
);
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
const hasAmount = amountText.trim().length > 0;
return (
<td
key={`${rowKey}-${play.play_code}`}
key={`${rowKey}-${column.key}`}
className={cn(
"px-1 py-2 align-top",
status === "warning" && "bg-amber-50/70",
@@ -1007,7 +1074,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}
onFocus={() => 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(
"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]",

View File

@@ -1,7 +1,7 @@
"use client";
import { Hourglass, Landmark, TimerReset } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -49,20 +49,18 @@ function CloseTime({
}) {
const { t } = useTranslation("player");
const sealedCountdown = isHallSealedCountdownUi(payload.status);
const [nowMs, setNowMs] = useState(serverNowMs);
useEffect(() => {
setNowMs(serverNowMs);
}, [serverNowMs]);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
const intervalId = window.setInterval(() => {
setNowMs((current) => current + 1000);
setElapsedSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
const nowMs = serverNowMs + elapsedSeconds * 1000;
let seconds = 0;
let label = t("draw.closesIn");
@@ -150,7 +148,12 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
<CurrentTime payload={display} />
</div>
<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
className={cn(
"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 { t } = useTranslation("player");
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 [page, setPage] = useState(1);
@@ -53,7 +57,7 @@ export function TicketOrdersListScreen() {
const [error, setError] = useState<string | null>(null);
const [queryDrawNo, setQueryDrawNo] = useState(drawNoFilter);
const [queryNumber, setQueryNumber] = useState("");
const [queryStatuses, setQueryStatuses] = useState<string[]>([]);
const [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [rangeOpen, setRangeOpen] = useState(false);
@@ -179,7 +183,7 @@ export function TicketOrdersListScreen() {
setQueryNumber("");
setFromDate("");
setToDate("");
setQueryStatuses([]);
setQueryStatuses(statusFilter);
setRangeOpen(false);
setStatusOpen(false);
}}

View File

@@ -271,7 +271,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
{t("results.hitHint")}
</p>
<Link
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}&status=settled_win`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"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">
<TwentyThreeResultsGrid numbers={featured.results} />
<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]"
>
{t("results.viewMyWinning")}

View File

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

View File

@@ -9,8 +9,8 @@ const ALLOWED_PARENT_ORIGINS: string[] = [
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
// 开发环境
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://localhost:3801",
"http://127.0.0.1:3801",
// 生产环境应从环境变量读取
].filter((o): o is string => Boolean(o));

View File

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

View File

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