feat: enhance draw status and scheduling display

- Updated DrawStatusHud to include a new countdown kind "start" for better status representation.
- Refactored HallDrawPanel to improve time display logic, differentiating between scheduled start and close times.
- Added new translations for scheduled start and close times in multiple languages.
- Enhanced time formatting functions to support new scheduling features and improve user experience.
This commit is contained in:
2026-05-25 18:01:26 +08:00
parent 3b83c6627c
commit 3c2664e02c
10 changed files with 205 additions and 31 deletions

View File

@@ -3,7 +3,7 @@ export type DrawStatusHud = {
/** Tailwind 颜色类:状态圆点 */
dotClass: string;
/** 文案条(如「距封盘」) */
countdownKind: "close" | "draw" | "cooldown" | "none";
countdownKind: "start" | "close" | "draw" | "cooldown" | "none";
};
/**
@@ -43,7 +43,7 @@ export function isHallAwaitingDrawProcessing(
export function drawStatusHud(status: string): DrawStatusHud {
switch (status) {
case "pending":
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "none" };
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "start" };
case "open":
return { labelKey: "draw.status.open", dotClass: "bg-emerald-500", countdownKind: "close" };
case "closing":

View File

@@ -13,19 +13,28 @@ import {
} from "@/features/draw/draw-status-meta";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { formatSecondsClock } from "@/lib/format-gmt";
import { formatLotteryInstant } from "@/lib/player-datetime";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import {
formatLotteryInstant,
formatLotteryScheduleClock,
getBrowserTimeZoneLabel,
} from "@/lib/player-datetime";
import { cn } from "@/lib/utils";
import type { DrawCurrentPayload } from "@/types/api/draw-current";
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
const { t } = useTranslation("player");
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
const useStart = payload.status === "pending" || payload.status === "open";
const source = useStart
? payload.start_time ?? payload.close_time ?? payload.draw_time
: payload.close_time ?? payload.draw_time ?? payload.start_time;
const labelKey = useStart ? "draw.scheduledStart" : "draw.scheduledClose";
const formatted = source ? formatLotteryInstant(source) : null;
if (!formatted) {
return (
<>
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
<span className="mt-1 text-[11px] text-slate-500">{t("draw.currentTime")}</span>
<span className="mt-1 text-[11px] text-slate-500">{t(labelKey)}</span>
</>
);
}
@@ -38,6 +47,7 @@ function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
<>
<span className="text-lg font-black tabular-nums text-[#0b3f96]">{time}</span>
<span className="mt-1 text-[11px] text-slate-500">{date}</span>
<span className="mt-0.5 text-[10px] text-slate-400">{t(labelKey)}</span>
</>
);
}
@@ -70,6 +80,15 @@ function CloseTime({
} else if (hud.countdownKind === "none") {
label = t(hud.labelKey, { defaultValue: hud.labelKey });
showClock = false;
} else if (hud.countdownKind === "start") {
seconds =
payload.seconds_to_start != null
? Math.max(0, payload.seconds_to_start)
: Math.max(
0,
Math.ceil(((payload.start_time ? Date.parse(payload.start_time) : 0) - nowMs) / 1000),
);
label = t("draw.startsIn");
} else if (hud.countdownKind === "close") {
seconds =
payload.seconds_to_close != null
@@ -172,7 +191,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
</div>
</div>
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
<CurrentTime payload={display} />
<ScheduleAnchorTime payload={display} />
</div>
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
<CloseTime
@@ -190,6 +209,17 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
/>
</div>
</div>
{display.schedule_now ? (
<div className="border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-center text-[10px] text-slate-500">
{t("draw.scheduleNow", {
now: formatLotteryScheduleClock(
display.schedule_now,
display.schedule_timezone ?? LOTTERY_SCHEDULE_TIMEZONE,
),
tz: getBrowserTimeZoneLabel(),
})}
</div>
) : null}
{blockedUi ? (
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
<TimerReset className="size-4 shrink-0" aria-hidden />

View File

@@ -26,23 +26,36 @@ export type HallWsEnvelope = {
emitted_at_ms?: number;
};
function secondsUntilIso(iso: string | null | undefined, effectiveNowMs: number): number {
if (iso == null || iso === "") {
return 0;
}
const targetMs = Date.parse(iso);
if (Number.isNaN(targetMs)) {
return 0;
}
return Math.max(0, Math.ceil((targetMs - effectiveNowMs) / 1000));
}
/**
* 服务器时间为准」:以载荷里的 `seconds_*` 为基准、`emitted_at_ms` 为锚点在本地推演
* 服务端 `server_now_ms` 为锚、本地时钟仅负责推进,倒计时与 ISO 时刻一致
*/
function applySnapshotDrift(
payload: DrawCurrentPayload,
emittedAtMs: number,
nowMs: number,
clientNowMs: number,
serverNowMs: number,
): DrawCurrentPayload {
const elapsed = Math.max(0, Math.floor((nowMs - emittedAtMs) / 1000));
const effectiveNowMs = serverNowMs + (clientNowMs - emittedAtMs);
return {
...payload,
seconds_to_close: Math.max(0, payload.seconds_to_close - elapsed),
seconds_to_draw: Math.max(0, payload.seconds_to_draw - elapsed),
seconds_to_close: secondsUntilIso(payload.close_time, effectiveNowMs),
seconds_to_start: secondsUntilIso(payload.start_time, effectiveNowMs),
seconds_to_draw: secondsUntilIso(payload.draw_time, effectiveNowMs),
seconds_remaining_in_cooldown:
payload.seconds_remaining_in_cooldown == null
payload.cooling_end_time == null
? null
: Math.max(0, payload.seconds_remaining_in_cooldown - elapsed),
: secondsUntilIso(payload.cooling_end_time, effectiveNowMs),
};
}
@@ -73,14 +86,18 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
);
const mergeFromWs = useCallback((evt: HallWsEnvelope) => {
const anchor = evt.emitted_at_ms ?? Date.now();
setServerNowMs(anchor);
setRaw(evt.data);
setEmittedAtMs(evt.emitted_at_ms ?? Date.now());
setEmittedAtMs(anchor);
}, []);
const mergeCountdownFromWs = useCallback((evt: HallWsEnvelope) => {
if (evt.data === null) return;
const anchor = evt.emitted_at_ms ?? Date.now();
setServerNowMs(anchor);
setRaw(evt.data);
setEmittedAtMs(evt.emitted_at_ms ?? Date.now());
setEmittedAtMs(anchor);
}, []);
const updateFromResponse = useCallback((resp: DrawCurrentResponse) => {
@@ -250,7 +267,9 @@ export function useHallDrawLive(): HallDrawLiveSnapshot {
}, [isWebSocketConnected, mode, load, setDrawPollingIntervalId]);
const display: DrawCurrentPayload | null | undefined =
raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs);
raw === undefined || raw === null
? raw
: applySnapshotDrift(raw, emittedAtMs, nowMs, serverNowMs);
const isBettable = display != null && display.status === "open";