feat(player): 优化桌面投注布局与账号功能

This commit is contained in:
wchino
2026-07-21 21:11:07 +08:00
parent 406ebb7671
commit 8307cc3f88
25 changed files with 1514 additions and 195 deletions

View File

@@ -27,6 +27,11 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import {
HallDesktopReviewPanel,
HallDesktopWorkflowBar,
type HallDesktopReviewModel,
} from "@/features/hall/hall-desktop-workspace";
import {
draftLineIssueReason,
playNeedsDigitSlot,
@@ -111,6 +116,63 @@ type PlayColumn = {
digitSlot?: number;
};
type HallSummaryItem = {
key: string;
label: string;
totalMinor: number;
};
function HallPlaySummaryGrid({
items,
className,
}: {
items: HallSummaryItem[];
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
className,
)}
>
{items.map((item) => {
const hasValue = item.totalMinor > 0;
return (
<div
key={`summary-${item.key}`}
className={cn(
"min-w-0 border-b border-r transition-colors",
hasValue
? "border-[#cbdcf7] bg-[#eef5ff]"
: "border-slate-200 bg-slate-50",
)}
>
<p
className={cn(
"flex min-h-7 items-center justify-center px-1 text-[10px] font-bold leading-tight transition-colors lg:min-h-8 lg:text-[11px]",
hasValue
? "bg-[#2d63e2] text-white"
: "bg-slate-200 text-slate-500",
)}
>
{item.label}
</p>
<p
className={cn(
"px-1 py-1 text-[11px] font-black transition-colors lg:py-1.5 lg:text-xs",
hasValue ? "text-[#0b3f96]" : "text-slate-400",
)}
>
{formatMinorAmount(item.totalMinor)}
</p>
</div>
);
})}
</div>
);
}
function playCategory(playCode: string): PlayHallCategory {
if (playCode.startsWith("pos_3")) return "D3";
if (playCode.startsWith("pos_2")) return "D2";
@@ -195,13 +257,16 @@ const D4_PLAY_ORDER = [
"digit_big",
"digit_small",
] as const;
/** 按大厅 Tab 对应的玩法顺序;汇总条默认以当前 Tab 玩法开头 */
/** 按大厅 Tab 对应的投注列顺序。 */
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
D2: D2_PLAY_ORDER,
D3: D3_PLAY_ORDER,
D4: D4_PLAY_ORDER,
};
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
(category) => PLAY_ORDER_BY_CATEGORY[category],
);
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
const DEFAULT_DRAFT_ROW_COUNT = 20;
@@ -550,9 +615,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
});
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
const placeTraceIdRef = useRef<string | null>(null);
/** 玩法汇总条:切 Tab 后滚回左侧,保证当前维度玩法从开头可见 */
const playSummaryScrollRef = useRef<HTMLDivElement | null>(null);
const newPlaceTraceId = (): string =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
@@ -648,7 +710,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return [];
// 4D / 3D / 2D 汇总条与列顺序以当前 Tab 玩法为先,避免切到 4D 仍从 2A 起显示
// 投注表格列以当前 Tab 玩法为先;顶部汇总另用稳定的全玩法顺序。
const order = playOrderForActiveCategory(activeCategory);
const orderSet = new Set(order);
return sortByPlayOrder(
@@ -659,11 +721,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
);
}, [activeCategory, catalogState]);
useEffect(() => {
const el = playSummaryScrollRef.current;
if (el) el.scrollLeft = 0;
}, [activeCategory]);
const activeCategoryPlays = useMemo(
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
[activeCategory, openPlays],
@@ -686,6 +743,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
});
}, [openPlays]);
const summaryPlayColumns = useMemo<PlayColumn[]>(() => {
return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => {
const category = playCategory(play.play_code);
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,
}));
});
}, [openPlays]);
const playColumns = useMemo(() => {
return playColumnsForCategory(activeCategoryPlays, activeCategory);
}, [activeCategory, activeCategoryPlays]);
@@ -693,31 +764,31 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]";
const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]";
const desktopSelectionTypeColClass = "w-[4.75rem] min-w-[4.75rem]";
const desktopIndexColClass = "w-9 min-w-9";
const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]";
const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]";
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
const showSelectionTypeColumn = activeCategory !== "D2";
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]";
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9";
const amountColClass = !isMobile
? "w-[3.9rem] min-w-[3.9rem]"
? "w-14 min-w-14"
: "w-[4rem] min-w-[4rem]";
const providerColClass = !isMobile
? "w-[2.85rem] min-w-[2.85rem]"
? "w-11 min-w-11"
: "w-[2.7rem] min-w-[2.7rem]";
const rowTotalColClass = !isMobile
? "w-[5.5rem] min-w-[5.5rem]"
? "w-20 min-w-20"
: "w-[5rem] min-w-[5rem]";
const tableWidthPx = useMemo(() => {
const indexCol = !isMobile ? 34 : 28;
const numberCol = !isMobile ? 88 : 68;
const selectionTypeCol = !isMobile ? 76 : 64;
const providerCol = !isMobile ? 46 : 43;
const rowTotalCol = !isMobile ? 88 : 80;
const amountCol = !isMobile ? 62 : 64;
const indexCol = !isMobile ? 36 : 28;
const numberCol = !isMobile ? 84 : 68;
const selectionTypeCol = !isMobile ? 72 : 64;
const providerCol = !isMobile ? 44 : 43;
const rowTotalCol = !isMobile ? 80 : 80;
const amountCol = !isMobile ? 56 : 64;
return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
}, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
@@ -841,11 +912,76 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
setActiveRowId(rowId);
}, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);
const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => {
setSyncAmountColumns((current) => ({
...current,
[column.key]: checked,
}));
setRows((current) => {
if (!checked) {
return current.map((row) => ({
...row,
amounts: { ...row.amounts, [column.key]: "" },
}));
}
const sourceAmount = current.find((row) => {
const amount = row.amounts[column.key];
return Boolean(
amount &&
draftLineIssueReason(
column.play.play_code,
row.number,
column.digitSlot,
) === null,
);
})?.amounts[column.key];
if (!sourceAmount) return current;
return current.map((row) => {
const validNumber =
draftLineIssueReason(
column.play.play_code,
row.number,
column.digitSlot,
) === null;
const status = cellRiskState(
column.play,
row.number,
playCategory(column.play.play_code),
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
column.digitSlot,
);
if (
!validNumber ||
status === "sold_out" ||
(column.play.config !== null && !column.play.config.is_enabled)
) {
return row;
}
return {
...row,
amounts: { ...row.amounts, [column.key]: sourceAmount },
};
});
});
}, [alertRows, liveSoldOutNumbers, liveWarningNumbers]);
const clearAllRows = () => {
if (tableDisabled) return;
const next = [newDraftRow()];
setRows(next);
setActiveRowId(next[0].id);
setRows((current) =>
current.map((row) => ({
...row,
number: "",
amounts: {},
})),
);
setActiveRowId((current) => current ?? rows[0]?.id ?? null);
};
const clearActiveRowAmounts = useCallback(() => {
@@ -1512,11 +1648,72 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const submitActualMinor =
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
const selectedProviderCount = new Set(rows.flatMap((row) => row.providerCodes)).size;
const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId));
const selectedProviderCount = new Set(
rows
.filter((row) => activeDraftRowIds.has(row.id))
.flatMap((row) => row.providerCodes),
).size;
const warningRowCount = rows.filter(
(row) =>
row.number.trim().length > 0 &&
playColumns.some(
(column) =>
cellRiskState(
column.play,
row.number,
playCategory(column.play.play_code),
alertRows,
liveSoldOutNumbers,
liveWarningNumbers,
column.digitSlot,
) === "warning",
),
).length;
const canSubmit =
!tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
const favoriteChips = favorites.slice(0, 10);
const historyChips = historyNumbers.slice(0, 20);
const summaryItems = [
{
key: "total",
label: t("hall.table.total", { defaultValue: "共" }),
totalMinor: submitActualMinor,
},
...summaryPlayColumns.map((column) => ({
key: column.key,
label: playColumnHeaderLabel(
column.play,
playCategory(column.play.play_code),
column.digitSlot,
t,
),
totalMinor: rows.reduce((sum, row) => {
const amount = row.amounts[column.key];
if (
!amount ||
row.number.trim().length === 0 ||
draftLineIssueReason(
column.play.play_code,
row.number,
column.digitSlot,
) !== null
) {
return sum;
}
return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0);
}, 0),
})),
];
const desktopReviewModel: HallDesktopReviewModel = {
lineCount: draftEntries.length,
providerCount: selectedProviderCount,
actualAmount: formatMinorAmount(submitActualMinor),
availableCredit: formatMinorAmount(availableMinor),
remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)),
warningCount: warningRowCount,
creditSufficient: availableMinor >= submitActualMinor,
};
return (
<>
<section className="space-y-3" aria-label={t("hall.aria")}>
@@ -1543,10 +1740,101 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
) : null}
<HallPlaySummaryGrid items={summaryItems} className="hidden lg:grid" />
<HallDesktopWorkflowBar
stepGame={t("hall.desktop.stepGame", { defaultValue: "选择玩法" })}
stepFill={t("hall.desktop.stepFill", { defaultValue: "填写号码和金额" })}
stepReview={t("hall.desktop.stepReview", { defaultValue: "核对并提交" })}
gameControls={(
<div className="inline-flex max-w-full items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
{categoryTabs.map((tab) => {
const hasPlays = openPlays.some(
(play) => playCategory(play.play_code) === tab.value,
);
const active = activeCategory === tab.value;
return (
<button
key={`desktop-${tab.value}`}
type="button"
disabled={!hasPlays}
onClick={() => setActiveCategory(tab.value)}
className={cn(
"inline-flex min-w-14 items-center justify-center rounded-lg px-2 py-2 text-sm font-bold transition-colors xl:min-w-[4.5rem] xl:px-3.5",
active
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.24)]"
: "text-[#5b7fbf] hover:bg-white hover:text-[#2d63e2]",
!hasPlays && "cursor-not-allowed opacity-40",
)}
aria-pressed={active}
>
{tab.label}
</button>
);
})}
</div>
)}
fillControls={(
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-1.5">
{favoriteChips.length > 0 ? (
<>
<span className="mr-0.5 text-xs font-bold text-[#d81435]">
{t("hall.quickFill.favorites")}
</span>
{favoriteChips.slice(0, 5).map((number) => (
<button
key={`desktop-fav-${number}`}
type="button"
className="inline-flex h-8 items-center rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 text-xs font-bold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
onClick={() => fillCurrentRow(number)}
>
{number}
</button>
))}
</>
) : null}
<span className="ml-1 mr-0.5 text-xs font-bold text-slate-400">
{t("hall.quickFill.history")}
</span>
{historyChips.length > 0 ? (
historyChips.slice(0, 6).map((number) => (
<button
key={`desktop-his-${number}`}
type="button"
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
onClick={() => fillCurrentRow(number)}
>
{number}
</button>
))
) : (
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
{t("hall.quickFill.emptyHistory")}
</span>
)}
</div>
)}
reviewControls={(
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
className="h-9 flex-1 justify-center rounded-lg border-[#dfe6f0] bg-white text-sm font-bold text-slate-600"
onClick={clearAllRows}
>
<Trash2 className="size-4" aria-hidden />
{t("hall.quickFill.clearAll")}
</Button>
</div>
)}
/>
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
<div
className={cn(
!isMobile &&
"flex items-center gap-2 rounded-xl border border-[#e3ebf7] bg-white px-2 py-2 shadow-[0_6px_18px_rgba(15,23,42,0.04)]",
isMobile ? "space-y-3" : "hidden",
)}
>
<div
@@ -1835,7 +2123,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
"overflow-x-auto",
isMobile
? "-mx-1 flex gap-1.5 px-1 pb-1"
: "order-first inline-flex w-fit max-w-full shrink-0 items-center gap-1 rounded-lg bg-[#f3f6fb] p-1",
: "hidden",
)}
>
{categoryTabs.map((tab) => {
@@ -1885,7 +2173,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
) : null}
<div className={cn("min-w-0", !isMobile && "flex items-start gap-3")}>
<div
className={cn(
"min-w-0",
!isMobile &&
"flex flex-col items-stretch gap-3 min-[1500px]:flex-row min-[1500px]:items-start",
)}
>
<div
className={cn(
"min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
@@ -1894,71 +2188,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
showWideTableHint && "player-table-scroll-wrap",
)}
>
<div
ref={playSummaryScrollRef}
className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm"
>
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
<thead>
<tr className="bg-[#2d63e2] text-white">
<th className="border-r border-[#4a7aeb] px-2 py-1.5 font-bold">
{t("hall.table.total", { defaultValue: "共" })}
</th>
{allPlayColumns.map((column) => (
<th key={column.key} className="border-r border-[#4a7aeb] px-1 py-1.5 font-bold last:border-0">
{playColumnHeaderLabel(
column.play,
playCategory(column.play.play_code),
column.digitSlot,
t,
)}
</th>
))}
</tr>
</thead>
<tbody className="bg-white text-[#17408d]">
<tr>
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
{formatMinorAmount(submitActualMinor)}
</td>
{allPlayColumns.map((column) => {
const colTotalMinor = rows.reduce((sum, row) => {
const amtStr = row.amounts[column.key];
if (!amtStr) return sum;
if (
row.number.trim().length === 0 ||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null
) {
return sum;
}
const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode);
return sum + (minorAmt ?? 0);
}, 0);
return (
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
{formatMinorAmount(colTotalMinor)}
</td>
);
})}
</tr>
</tbody>
</table>
</div>
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
<table
className="mx-auto w-max border-collapse text-[11px]"
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
style={{ width: tableWidthPx }}
>
<thead>
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", indexColClass)}>
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", isMobile ? "py-1.5" : "py-2.5", indexColClass)}>
{t("hall.table.no", { defaultValue: "No." })}
</th>
<th
className={cn(
"sticky z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2.5",
stickyNumberLeftClass,
numberColClass,
)}
@@ -1971,7 +2214,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
{playColumns.map((column) => (
<th
key={column.key}
className={cn(amountColClass, "px-0.5 py-1.5 text-center font-bold")}
className={cn(amountColClass, "px-0.5 text-center font-bold", isMobile ? "py-1.5" : "py-2.5")}
>
<span className="block whitespace-nowrap text-[10px] leading-tight">
{playColumnHeaderLabel(
@@ -1984,17 +2227,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<Checkbox
checked={syncAmountColumns[column.key] === true}
disabled={tableDisabled}
onCheckedChange={(checked) => setSyncAmountColumns((current) => ({
...current,
[column.key]: checked === true,
}))}
onCheckedChange={(checked) =>
toggleSyncAmountColumn(column, checked === true)
}
aria-label={t("hall.table.syncColumnAmount", {
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
})}
title={t("hall.table.syncColumnAmount", {
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
})}
className="mx-auto mt-1 size-3.5"
className={cn("mx-auto mt-1", isMobile ? "size-3.5" : "size-4")}
/>
</th>
))}
@@ -2087,49 +2329,35 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
{!isMobile ? (
<aside className="sticky top-3 z-20 w-52 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-3.5 py-3.5 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
<dl className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
<dd className="font-mono text-sm font-black tabular-nums">{draftEntries.length}</dd>
</div>
<div className="flex items-center justify-between gap-2">
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
<dd className="font-mono text-sm font-black tabular-nums">{selectedProviderCount}</dd>
</div>
<div className="border-t border-[#dce6f5] pt-3">
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
<dd className="mt-1 text-base font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
</dd>
</div>
</dl>
<Button
type="button"
disabled={!canSubmit || previewLoading}
onClick={() => void handlePreview()}
className={cn(
"mt-4 h-10 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
!isBettable
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
)}
>
{!isBettable ? (
<Lock className="size-4 shrink-0" aria-hidden />
) : (
<Ticket className="size-4 shrink-0" aria-hidden />
)}
{previewLoading
<HallDesktopReviewPanel
model={desktopReviewModel}
disabled={!canSubmit}
loading={previewLoading}
isBettable={isBettable}
onSubmit={() => void handlePreview()}
labels={{
title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }),
lineCount: t("hall.table.lineCount"),
providerCount: t("hall.table.providerCount"),
actualTotal: t("hall.table.actualTotal"),
availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }),
remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }),
noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }),
warningCount: t("hall.desktop.warningCount", {
defaultValue: "{{count}} 行接近售罄",
count: warningRowCount,
}),
creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }),
creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }),
submit: previewLoading
? t("hall.table.previewing")
: !isBettable
? t("hall.closed.title")
: availableMinor < submitActualMinor
? t("hall.table.insufficientBalance")
: t("hall.table.submitBet")}
</Button>
</aside>
: t("hall.table.submitBet"),
}}
/>
) : null}
</div>
@@ -2377,7 +2605,8 @@ const DraftRowItem = memo(function DraftRowItem({
>
<td
className={cn(
"sticky left-0 z-20 align-top px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
"sticky left-0 z-20 align-top px-0.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2",
indexColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white",
)}
@@ -2386,7 +2615,8 @@ const DraftRowItem = memo(function DraftRowItem({
</td>
<td
className={cn(
"sticky z-20 align-top px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
"sticky z-20 align-top px-1 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
isMobile ? "py-1.5" : "py-2",
stickyNumberLeftClass,
numberColClass,
rowActive ? "bg-[#f5f9ff]" : "bg-white",
@@ -2404,8 +2634,8 @@ const DraftRowItem = memo(function DraftRowItem({
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateRowNumber(row.id, event.target.value)}
className={cn(
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "text-[13px]" : "text-sm",
"w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
"tracking-[0.12em]",
)}
/>
@@ -2431,7 +2661,8 @@ const DraftRowItem = memo(function DraftRowItem({
<td
key={`${row.id}-${column.key}`}
className={cn(
"px-0.5 py-1.5 align-top",
"px-0.5 align-top",
isMobile ? "py-1.5" : "py-2",
status === "warning" && "bg-amber-50/70",
status === "sold_out" && "bg-slate-100 text-slate-400",
)}
@@ -2450,15 +2681,15 @@ const DraftRowItem = memo(function DraftRowItem({
onClick={() => setActiveRowId(row.id)}
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
className={cn(
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "text-[13px]" : "text-sm",
"w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
)}
/>
) : (
<div className="h-8 w-full rounded-md border border-slate-200 bg-slate-100/70" />
<div className={cn("w-full rounded-md border border-slate-200 bg-slate-100/70", isMobile ? "h-8" : "h-9")} />
)}
{isInputValidForPlay && status === "sold_out" ? (
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
@@ -2473,12 +2704,12 @@ const DraftRowItem = memo(function DraftRowItem({
);
})}
{showSelectionTypeColumn ? (
<td className={cn(selectionTypeColClass, "align-top px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}>
<td className={cn(selectionTypeColClass, "align-top px-1 text-center", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#f5f9ff]")}>
<select
value={row.selectionType}
disabled={tableDisabled}
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
className={cn("w-full rounded-md border border-[#e1e8f3] bg-white px-1 font-semibold text-[#304f86]", isMobile ? "h-8 text-[10px]" : "h-9 text-xs")}
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
>
{selectionTypeOptions.map((type) => (
@@ -2522,18 +2753,18 @@ const DraftRowItem = memo(function DraftRowItem({
{betProviders.map((provider, providerIndex) => (
<td
key={provider.code}
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1.5 text-center")}
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 text-center", isMobile ? "py-1.5" : "py-2")}
>
<Checkbox
checked={row.providerCodes.includes(provider.code)}
disabled={tableDisabled}
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
aria-label={`${provider.name} ${index + 1}`}
className="mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
className={cn("mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800", !isMobile && "size-4")}
/>
</td>
))}
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 py-1.5 text-center font-mono font-black tabular-nums text-[#0b3f96]", rowActive && "bg-[#edf5ff]")}>
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 text-center font-mono font-black tabular-nums text-[#0b3f96]", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#edf5ff]")}>
{formatMinorAmount(rowTotalMinor)}
</td>
</tr>

View File

@@ -0,0 +1,184 @@
import type { ReactNode } from "react";
import { AlertTriangle, CheckCircle2, Lock, Ticket } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type HallDesktopWorkflowBarProps = {
gameControls: ReactNode;
fillControls: ReactNode;
reviewControls: ReactNode;
stepGame: string;
stepFill: string;
stepReview: string;
};
export function HallDesktopWorkflowBar({
gameControls,
fillControls,
reviewControls,
stepGame,
stepFill,
stepReview,
}: HallDesktopWorkflowBarProps) {
const steps = [
{ label: stepGame, content: gameControls },
{ label: stepFill, content: fillControls },
{ label: stepReview, content: reviewControls },
];
return (
<div className="hidden overflow-hidden rounded-xl border border-[#dfe8f5] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.04)] lg:grid lg:grid-cols-[minmax(10rem,0.8fr)_minmax(16rem,1.5fr)_minmax(10rem,0.65fr)] xl:grid-cols-[minmax(15rem,0.8fr)_minmax(24rem,1.5fr)_minmax(13rem,0.65fr)]">
{steps.map((step, index) => (
<section
key={step.label}
className={cn(
"min-w-0 px-4 py-3",
index > 0 && "border-l border-[#e7edf6]",
)}
>
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#1658c4]">
{index + 1}
</span>
<h2 className="text-sm font-black text-[#23395f]">{step.label}</h2>
</div>
{step.content}
</section>
))}
</div>
);
}
export type HallDesktopReviewModel = {
lineCount: number;
providerCount: number;
actualAmount: string;
availableCredit: string;
remainingCredit: string;
warningCount: number;
creditSufficient: boolean;
};
type HallDesktopReviewPanelProps = {
model: HallDesktopReviewModel;
labels: {
title: string;
lineCount: string;
providerCount: string;
actualTotal: string;
availableCredit: string;
remainingCredit: string;
noWarnings: string;
warningCount: string;
creditEnough: string;
creditInsufficient: string;
submit: string;
};
disabled: boolean;
loading: boolean;
isBettable: boolean;
onSubmit: () => void;
};
export function HallDesktopReviewPanel({
model,
labels,
disabled,
loading,
isBettable,
onSubmit,
}: HallDesktopReviewPanelProps) {
return (
<aside className="w-full shrink-0 self-start overflow-hidden rounded-xl border border-[#dfe8f5] bg-white text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)] min-[1500px]:sticky min-[1500px]:top-3 min-[1500px]:w-80">
<div className="border-b border-[#e7edf6] bg-[#f7faff] px-4 py-3.5">
<p className="text-[15px] font-black text-[#20385f]">{labels.title}</p>
</div>
<div className="grid gap-3 p-4 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-center min-[1500px]:block min-[1500px]:space-y-4">
<dl className="grid grid-cols-2 gap-x-5 gap-y-3 lg:contents min-[1500px]:grid">
<div>
<dt className="text-xs font-semibold text-slate-500">{labels.lineCount}</dt>
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
{model.lineCount}
</dd>
</div>
<div>
<dt className="text-xs font-semibold text-slate-500">{labels.providerCount}</dt>
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
{model.providerCount}
</dd>
</div>
<div>
<dt className="text-xs font-semibold text-slate-500">{labels.availableCredit}</dt>
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
{model.availableCredit}
</dd>
</div>
<div>
<dt className="text-xs font-semibold text-slate-500">{labels.remainingCredit}</dt>
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
{model.remainingCredit}
</dd>
</div>
</dl>
<div className="rounded-lg border border-[#d8e5f7] bg-[#f7fbff] px-3 py-2.5 lg:min-w-36 min-[1500px]:px-4 min-[1500px]:py-3.5">
<p className="text-xs font-semibold text-slate-500">{labels.actualTotal}</p>
<p className="mt-1 text-lg font-black tabular-nums text-[#0b3f96] min-[1500px]:text-2xl">
{model.actualAmount}
</p>
</div>
<div className="space-y-2 lg:min-w-48 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
<div
className={cn(
"flex items-center gap-2 text-xs font-bold",
model.creditSufficient ? "text-emerald-700" : "text-red-600",
)}
>
{model.creditSufficient ? (
<CheckCircle2 className="size-4 shrink-0" aria-hidden />
) : (
<AlertTriangle className="size-4 shrink-0" aria-hidden />
)}
<span>{model.creditSufficient ? labels.creditEnough : labels.creditInsufficient}</span>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
{model.warningCount > 0 ? (
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden />
) : (
<CheckCircle2 className="size-4 shrink-0 text-slate-400" aria-hidden />
)}
<span>
{model.warningCount > 0
? labels.warningCount
: labels.noWarnings}
</span>
</div>
</div>
<div className="lg:min-w-52 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
<Button
type="button"
disabled={disabled || loading}
onClick={onSubmit}
className={cn(
"h-11 w-full gap-2 rounded-lg border-0 text-sm font-black text-white",
!isBettable
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.24)] hover:bg-[#d10028]",
)}
>
{!isBettable ? (
<Lock className="size-4 shrink-0" aria-hidden />
) : (
<Ticket className="size-4 shrink-0" aria-hidden />
)}
{labels.submit}
</Button>
</div>
</div>
</aside>
);
}

View File

@@ -54,7 +54,7 @@ function ScheduleAnchorTime({
<span className="font-mono text-base font-black tabular-nums tracking-tight text-[#0b3f96]">
{time}
</span>
{date ? <span className="text-sm text-slate-400">{date}</span> : null}
{date ? <span className="hidden text-sm text-slate-400 2xl:inline">{date}</span> : null}
</div>
);
}
@@ -268,11 +268,11 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
</div>
{/* Desktop: 单行工具条 — 左期号状态,右时间/倒计时,与右侧钱包卡同高 */}
<div className="hidden h-full items-center justify-between gap-6 px-4 py-2.5 lg:flex">
<div className="hidden h-full items-center justify-between gap-3 px-4 py-2.5 lg:flex xl:gap-6">
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">{t("draw.issueNo")}</span>
<span className="inline-flex items-center rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
<span className="whitespace-nowrap text-sm text-slate-500">{t("draw.issueNo")}</span>
<span className="inline-flex items-center whitespace-nowrap rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
{display.draw_no}
</span>
</div>
@@ -281,7 +281,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
{t(hud.labelKey, { defaultValue: hud.labelKey })}
</span>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-5 gap-y-1">
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1 xl:gap-x-5">
<ScheduleAnchorTime payload={display} layout="desktop" />
<CloseTime
key={`desk-${display.draw_no}-${display.status}-${display.seconds_to_draw ?? ""}-${display.seconds_remaining_in_cooldown ?? ""}`}

View File

@@ -52,7 +52,7 @@ export function HallScreen() {
<span>{blockedNotice}</span>
</div>
) : null}
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-stretch lg:gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-stretch lg:gap-3 lg:space-y-0 xl:grid-cols-[minmax(0,1fr)_20rem] xl:gap-4">
<HallDrawPanel drawLive={drawLive} />
<aside className="min-w-0">
<HallWalletStrip />

View File

@@ -84,7 +84,12 @@ export function HallWalletStrip() {
<div
className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]",
isMobile ? "px-3 py-2.5" : "px-5 py-3 lg:h-[5.5rem] lg:px-4 lg:pr-14",
isMobile
? "px-3 py-2.5"
: cn(
"px-5 py-3 lg:h-[5.5rem]",
isCreditPlayer ? "lg:px-3 xl:px-4" : "lg:px-4 lg:pr-14",
),
)}
>
<Image
@@ -95,7 +100,7 @@ export function HallWalletStrip() {
className="pointer-events-none object-cover object-center"
aria-hidden
/>
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-4")}>
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-3 xl:gap-4")}>
<div
className={cn(
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
@@ -112,8 +117,14 @@ export function HallWalletStrip() {
!isMobile && "flex items-center gap-3 overflow-hidden",
)}
>
<div className={cn("min-w-0", !isMobile && "flex shrink-0 items-baseline gap-2")}>
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
<div
className={cn(
"min-w-0",
!isMobile &&
"flex shrink-0 flex-col items-start gap-1 overflow-hidden xl:flex-row xl:items-baseline xl:gap-2",
)}
>
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
{label}
</p>
{loading ? (
@@ -129,7 +140,7 @@ export function HallWalletStrip() {
currency={currency}
className={cn(
"text-white",
isMobile ? "mt-0.5" : "text-xl",
isMobile ? "mt-0.5" : "text-lg xl:text-xl",
)}
/>
)}
@@ -138,7 +149,7 @@ export function HallWalletStrip() {
<p
className={cn(
"text-white/80",
isMobile ? "mt-1 text-[11px]" : "truncate text-xs",
isMobile ? "mt-1 text-[11px]" : "hidden min-w-0 truncate text-xs xl:block",
)}
>
{t("wallet.creditSummary", {

View File

@@ -480,7 +480,7 @@ export function TicketOrdersListScreen() {
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white">
{/* PC 顶栏:统计 + 筛选 + 操作 */}
<div className="hidden lg:block lg:border-b lg:border-[#eef3fa] lg:bg-white lg:px-5 lg:py-3">
<div className="hidden lg:block lg:border-b lg:border-[#e5edf8] lg:bg-[#f8fbff] lg:px-5 lg:py-3.5">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 shrink-0">
<p className="text-[11px] font-bold text-[#7890b8]">
@@ -533,7 +533,7 @@ export function TicketOrdersListScreen() {
</Button>
</div>
) : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
<div className="player-pc-empty flex flex-col items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
<Link
href="/hall"
@@ -683,25 +683,25 @@ export function TicketOrdersListScreen() {
<Table className="table-fixed">
<TableHeader>
<TableRow className="border-[#eef3fa] hover:bg-transparent">
<TableHead className="h-11 w-[22%] bg-white px-5 text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[22%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
{t("orders.drawNo")}
</TableHead>
<TableHead className="h-11 w-[24%] bg-white px-3 text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[24%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
{t("orders.betItems")}
</TableHead>
<TableHead className="h-11 w-[12%] bg-white px-3 text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
{t("hall.providers.title")}
</TableHead>
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
{t("orders.stake")}
</TableHead>
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
{t("orders.deduction")}
</TableHead>
<TableHead className="h-11 w-[14%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[14%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
{t("orders.status")}
</TableHead>
<TableHead className="h-11 w-10 bg-white px-3" aria-hidden />
<TableHead className="h-12 w-10 bg-[#f8fbff] px-3" aria-hidden />
</TableRow>
</TableHeader>
<TableBody>
@@ -900,4 +900,3 @@ export function TicketOrdersListScreen() {
</PlayerPanel>
);
}

View File

@@ -0,0 +1,279 @@
"use client";
import { ChevronDown, KeyRound, Loader2, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { putPlayerPassword } from "@/api/player-auth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { validatePlayerLoginPassword } from "@/lib/player-input-validation";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors";
type AccountView = "profile" | "password";
function ProfileRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-start gap-3 py-2.5 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-right font-medium text-foreground">{value}</dd>
</div>
);
}
function accountInitial(value: string): string {
const trimmed = value.trim();
return trimmed === "" ? "P" : trimmed.slice(0, 1).toUpperCase();
}
export function PlayerAccountDialog() {
const { t } = useTranslation("player");
const router = useRouter();
const profile = usePlayerSessionStore((state) => state.profile);
const clearBearerToken = usePlayerSessionStore((state) => state.clearBearerToken);
const [open, setOpen] = useState(false);
const [view, setView] = useState<AccountView>("profile");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const displayName =
profile?.nickname?.trim() ||
profile?.username?.trim() ||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : t("account.loading"));
const isNative = profile?.auth_source === "lottery_native";
function resetPasswordForm() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) {
setView("profile");
resetPasswordForm();
}
}
function handleLogout() {
handleOpenChange(false);
clearBearerToken();
router.replace("/login");
}
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (currentPassword === "" || newPassword === "" || confirmPassword === "") {
toast.error(t("account.password.required"));
return;
}
if (validatePlayerLoginPassword(newPassword) === "too_short") {
toast.error(t("account.password.tooShort"));
return;
}
if (newPassword !== confirmPassword) {
toast.error(t("account.password.mismatch"));
return;
}
if (newPassword === currentPassword) {
toast.error(t("account.password.mustDiffer"));
return;
}
setSaving(true);
try {
await putPlayerPassword({
current_password: currentPassword,
password: newPassword,
password_confirmation: confirmPassword,
});
handleOpenChange(false);
clearBearerToken();
router.replace("/login?password=changed");
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("account.password.failed"),
);
} finally {
setSaving(false);
}
}
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={profile === null}
aria-label={t("account.open")}
onClick={() => setOpen(true)}
className="max-w-[8.5rem] justify-start rounded-full bg-muted/40 px-1.5 sm:max-w-[11rem]"
>
<Avatar size="sm">
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-left">{displayName}</span>
<ChevronDown data-icon="inline-end" className="hidden sm:block" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
{view === "profile" ? (
<>
<DialogHeader>
<DialogTitle>{t("account.title")}</DialogTitle>
<DialogDescription>{t("account.description")}</DialogDescription>
</DialogHeader>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<CardTitle className="truncate">{displayName}</CardTitle>
<CardDescription className="truncate">
{profile?.username || profile?.site_player_id || "—"}
</CardDescription>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="secondary">
{isNative ? t("account.auth.native") : t("account.auth.sso")}
</Badge>
<Badge variant="outline">
{profile?.funding_mode === "credit"
? t("account.funding.credit")
: t("account.funding.wallet")}
</Badge>
</div>
</CardHeader>
<CardContent>
<dl>
<ProfileRow label={t("account.fields.username")} value={profile?.username || "—"} />
<Separator />
<ProfileRow label={t("account.fields.site")} value={profile?.site_code || "—"} />
<Separator />
<ProfileRow
label={t("account.fields.playerId")}
value={profile?.site_player_id || "—"}
/>
<Separator />
<ProfileRow
label={t("account.fields.currency")}
value={profile?.default_currency || "—"}
/>
</dl>
</CardContent>
</Card>
{!isNative ? (
<p className="text-sm leading-relaxed text-muted-foreground">
{t("account.ssoManaged")}
</p>
) : null}
{isNative ? (
<DialogFooter>
<Button type="button" variant="destructive" onClick={handleLogout}>
<LogOut data-icon="inline-start" />
{t("account.logout")}
</Button>
<Button type="button" variant="outline" onClick={() => setView("password")}>
<KeyRound data-icon="inline-start" />
{t("account.password.action")}
</Button>
</DialogFooter>
) : null}
</>
) : (
<form onSubmit={handlePasswordSubmit}>
<div className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{t("account.password.title")}</DialogTitle>
<DialogDescription>{t("account.password.description")}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="player-current-password">
{t("account.password.current")}
</FieldLabel>
<Input
id="player-current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="player-new-password">
{t("account.password.new")}
</FieldLabel>
<Input
id="player-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
<FieldDescription>{t("account.password.hint")}</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="player-confirm-password">
{t("account.password.confirm")}
</FieldLabel>
<Input
id="player-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : null}
{saving ? t("account.password.saving") : t("account.password.submit")}
</Button>
<Button type="button" variant="outline" disabled={saving} onClick={() => setView("profile")}>
{t("actions.cancel")}
</Button>
</DialogFooter>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -44,6 +44,7 @@ export function PlayerLoginScreen(): React.ReactElement {
const setProfile = usePlayerSessionStore((s) => s.setProfile);
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
const sessionExpiredHandled = useRef(false);
const passwordChangedHandled = useRef(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -94,6 +95,15 @@ export function PlayerLoginScreen(): React.ReactElement {
stripSearchParamFromBrowserUrl("session");
}, [clearBearerToken, searchParams, tErrors]);
useEffect(() => {
if (passwordChangedHandled.current) return;
if (searchParams.get("password") !== "changed") return;
passwordChangedHandled.current = true;
toast.success(t("login.passwordChanged"));
stripSearchParamFromBrowserUrl("password");
}, [searchParams, t]);
async function handleSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
if (!username.trim() || !password) {
@@ -184,4 +194,4 @@ export function PlayerLoginScreen(): React.ReactElement {
</div>
</>
);
}
}

View File

@@ -258,7 +258,7 @@ export function DrawResultsListScreen() {
</Button>
</div>
) : listRows.length === 0 ? (
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
<div className="player-pc-empty flex items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
{t("results.empty")}
</div>
) : (
@@ -358,18 +358,18 @@ export function DrawResultsListScreen() {
<Table className="table-fixed">
<TableHeader>
<TableRow className="border-[#eef3fa] hover:bg-transparent">
<TableHead className="h-11 w-[30%] bg-[#f8fbff] px-5 text-xs font-bold text-[#59739f]">
<TableHead className="player-pc-table-head h-12 w-[30%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
{t("results.detailTitle")}
</TableHead>
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
<TableHead
key={tier}
className="h-11 bg-[#f8fbff] px-3 text-center text-xs font-bold text-[#59739f]"
className="player-pc-table-head h-12 bg-[#f8fbff] px-3 text-center font-bold text-[#59739f]"
>
{t(resultsPrizeLabelKey(tier))}
</TableHead>
))}
<TableHead className="h-11 w-12 bg-[#f8fbff] px-3" aria-hidden />
<TableHead className="h-12 w-12 bg-[#f8fbff] px-3" aria-hidden />
</TableRow>
</TableHeader>
<TableBody>
@@ -391,7 +391,7 @@ export function DrawResultsListScreen() {
}
}}
>
<TableCell className="px-5 py-3 align-middle">
<TableCell className="px-5 py-3.5 align-middle">
<div className="block min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate font-mono text-sm font-bold text-[#0b3f96]">
@@ -417,13 +417,13 @@ export function DrawResultsListScreen() {
</div>
</TableCell>
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
<TableCell key={tier} className="px-3 py-3 text-center align-middle">
<TableCell key={tier} className="px-3 py-3.5 text-center align-middle">
<span className="font-mono text-base font-bold tabular-nums text-[#e5002c]">
{row.results[tier]}
</span>
</TableCell>
))}
<TableCell className="px-3 py-3 text-right align-middle">
<TableCell className="px-3 py-3.5 text-right align-middle">
<span className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors group-hover:bg-[#eaf2ff] group-hover:text-[#0b56b7]">
<ChevronRight className="size-4" aria-hidden />
</span>

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
import { getPublicSettings } from "@/api";
import { PlayerPanel } from "@/components/layout/player-panel";
@@ -12,6 +12,7 @@ import { Card, CardContent } from "@/components/ui/card";
export function PlayRulesScreen() {
const { t, i18n } = useTranslation("player");
const [htmlContent, setHtmlContent] = useState<string | null>(null);
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -21,11 +22,14 @@ export function PlayRulesScreen() {
const html = resolvePlayRulesHtml(res.items, i18n.language);
if (html) {
setHtmlContent(html);
setContentState("html");
} else {
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.empty", { defaultValue: "暂无玩法规则说明" })}</div>`);
setHtmlContent(null);
setContentState("empty");
}
} catch {
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.loadFailed", { defaultValue: "规则加载失败" })}</div>`);
setHtmlContent(null);
setContentState("error");
} finally {
setLoading(false);
}
@@ -39,16 +43,31 @@ export function PlayRulesScreen() {
backHref="/hall"
className="bg-[#f7f9fd]"
>
<div className="space-y-3">
<Card className="border-[#dbe7fb] bg-white shadow-sm overflow-hidden min-h-[300px]">
<CardContent className="p-4">
<div className="mx-auto w-full max-w-5xl space-y-3">
<Card className="min-h-[300px] overflow-hidden border-[#dbe7fb] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] lg:min-h-[24rem]">
<CardContent className="p-4 lg:p-8">
{loading ? (
<div className="flex h-[200px] items-center justify-center">
<div className="flex min-h-[18rem] items-center justify-center">
<Loader2 className="size-6 animate-spin text-slate-400" />
</div>
) : contentState !== "html" ? (
<div className="flex min-h-[18rem] flex-col items-center justify-center px-4 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#2d63e2]">
{contentState === "error" ? (
<TriangleAlert className="size-5" aria-hidden />
) : (
<BookOpenText className="size-5" aria-hidden />
)}
</span>
<p className="mt-3 text-sm font-bold text-slate-700">
{contentState === "error"
? t("rules.loadFailed", { defaultValue: "规则加载失败" })
: t("rules.empty", { defaultValue: "暂无玩法规则说明" })}
</p>
</div>
) : (
<div
className="prose prose-sm max-w-none text-slate-700"
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
/>
)}

View File

@@ -189,7 +189,7 @@ export function WalletLogsBlock({
</p>
<div className={logsLoading ? "opacity-60" : undefined}>
{logs.items.length === 0 ? (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
<div className="player-pc-empty flex items-center justify-center rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
{t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
})}
@@ -198,24 +198,24 @@ export function WalletLogsBlock({
<>
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3">
<p className="text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head font-bold text-[#59739f]">
{t("wallet.logType", { defaultValue: "类型" })}
</p>
<p className="text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head font-bold text-[#59739f]">
{t("wallet.logTime", { defaultValue: "时间" })}
</p>
<p className="text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head font-bold text-[#59739f]">
{t("wallet.logRef", { defaultValue: "关联单号" })}
</p>
<p className="text-right text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
{t("wallet.logAmount", { defaultValue: "金额" })}
</p>
<p className="text-right text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
{creditMode
? t("wallet.creditAvailableAfter")
: t("wallet.balanceAfter")}
</p>
<p className="text-right text-xs font-bold text-[#59739f]">
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
{t("orders.status")}
</p>
</div>