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:
@@ -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":
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
"draw": {
|
||||
"currentIssue": "Current issue",
|
||||
"currentTime": "Current Time",
|
||||
"scheduledStart": "Start time",
|
||||
"scheduledClose": "Close time",
|
||||
"startsIn": "Starts in",
|
||||
"scheduleNow": "Current time {{now}} ({{tz}})",
|
||||
"closesIn": "Closes In",
|
||||
"drawsIn": "Draws In",
|
||||
"drawProcessing": "Drawing…",
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
"draw": {
|
||||
"currentIssue": "हालको इश्यू",
|
||||
"currentTime": "हालको समय",
|
||||
"scheduledStart": "सुरु समय",
|
||||
"scheduledClose": "बन्द समय",
|
||||
"startsIn": "सुरु हुन बाँकी",
|
||||
"scheduleNow": "हालको समय {{now}} ({{tz}})",
|
||||
"closesIn": "बन्द हुन बाँकी",
|
||||
"drawsIn": "ड्र हुन बाँकी",
|
||||
"drawProcessing": "ड्रअ प्रक्रियामा",
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
"draw": {
|
||||
"currentIssue": "当前期号",
|
||||
"currentTime": "当前时间",
|
||||
"scheduledStart": "开始时间",
|
||||
"scheduledClose": "封盘时间",
|
||||
"startsIn": "距开始",
|
||||
"scheduleNow": "当前时间 {{now}}({{tz}})",
|
||||
"closesIn": "距封盘",
|
||||
"drawsIn": "距开奖",
|
||||
"drawProcessing": "开奖处理中",
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
export function formatSecondsClock(total: number): string {
|
||||
const s = Math.max(0, Math.floor(total));
|
||||
const mm = String(Math.floor(s / 60)).padStart(2, "0");
|
||||
const ss = String(s % 60).padStart(2, "0");
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) {
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
const mm = String(m).padStart(2, "0");
|
||||
const ss = String(sec).padStart(2, "0");
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
5
src/lib/lottery-schedule-timezone.ts
Normal file
5
src/lib/lottery-schedule-timezone.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* PRD:服务端计划时刻按 UTC(GMT)存储与下发。
|
||||
* 管理端展示/录入仍按此时区;玩家端界面将 API 时刻换算为浏览器本地时区显示。
|
||||
*/
|
||||
export const LOTTERY_SCHEDULE_TIMEZONE = "UTC";
|
||||
@@ -1,10 +1,74 @@
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
function formatParts(date: Date, timeZone?: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const year = map.get("year") ?? "0000";
|
||||
const month = map.get("month") ?? "00";
|
||||
const day = map.get("day") ?? "00";
|
||||
const hour = map.get("hour") ?? "00";
|
||||
const minute = map.get("minute") ?? "00";
|
||||
const second = map.get("second") ?? "00";
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
const NAIVE_SCHEDULE_CLOCK_RE =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/;
|
||||
|
||||
function parseScheduleClockToMs(
|
||||
clock: string,
|
||||
scheduleTimezone: string,
|
||||
): number | null {
|
||||
const trimmed = clock.trim();
|
||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(trimmed) || trimmed.includes("T")) {
|
||||
const ms = Date.parse(trimmed);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
const match = NAIVE_SCHEDULE_CLOCK_RE.exec(trimmed);
|
||||
if (!match) {
|
||||
const ms = Date.parse(trimmed);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
const [, y, mo, d, h, mi, s] = match;
|
||||
if (scheduleTimezone !== "UTC") {
|
||||
const ms = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${s}`);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
return Date.UTC(
|
||||
Number(y),
|
||||
Number(mo) - 1,
|
||||
Number(d),
|
||||
Number(h),
|
||||
Number(mi),
|
||||
Number(s),
|
||||
);
|
||||
}
|
||||
|
||||
/** 浏览器本地时区短标签(如 CST、GMT+8),用于界面说明。 */
|
||||
export function getBrowserTimeZoneLabel(date = new Date()): string {
|
||||
try {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const parts = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone,
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
return parts.find((part) => part.type === "timeZoneName")?.value ?? timeZone;
|
||||
} catch {
|
||||
return "Local";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口 ISO 时间串格式化为 **浏览器本地时区** 下的 `YYYY-MM-DD HH:mm:ss`,
|
||||
* 与后台 `lotteryadmin/src/lib/admin-datetime.ts` {@link formatAdminInstant} 行为一致。
|
||||
* 将接口 ISO 时间串格式化为 **浏览器本地时区** 下的 `YYYY-MM-DD HH:mm:ss`。
|
||||
*/
|
||||
export function formatLotteryInstant(iso: string | null | undefined): string {
|
||||
if (iso == null || iso === "") {
|
||||
@@ -14,12 +78,44 @@ export function formatLotteryInstant(iso: string | null | undefined): string {
|
||||
if (Number.isNaN(ms)) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(ms);
|
||||
const y = date.getFullYear();
|
||||
const m = pad2(date.getMonth() + 1);
|
||||
const d = pad2(date.getDate());
|
||||
const h = pad2(date.getHours());
|
||||
const min = pad2(date.getMinutes());
|
||||
const s = pad2(date.getSeconds());
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||
return formatParts(new Date(ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将服务端计划时刻(无时区的 `YYYY-MM-DD HH:mm:ss`,按 schedule_timezone 解释,默认 UTC)
|
||||
* 格式化为浏览器本地时区的 `YYYY-MM-DD HH:mm:ss`。
|
||||
*/
|
||||
export function formatLotteryScheduleClock(
|
||||
clock: string | null | undefined,
|
||||
scheduleTimezone = "UTC",
|
||||
): string {
|
||||
if (clock == null || clock === "") {
|
||||
return "—";
|
||||
}
|
||||
const ms = parseScheduleClockToMs(clock, scheduleTimezone);
|
||||
if (ms == null) {
|
||||
return "—";
|
||||
}
|
||||
return formatParts(new Date(ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定 IANA 时区格式化(管理端等场景);玩家端展示请用 {@link formatLotteryInstant}。
|
||||
*/
|
||||
export function formatLotteryInstantInTimeZone(
|
||||
iso: string | null | undefined,
|
||||
timeZone: string,
|
||||
): string {
|
||||
if (iso == null || iso === "") {
|
||||
return "—";
|
||||
}
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) {
|
||||
return "—";
|
||||
}
|
||||
try {
|
||||
return formatParts(new Date(ms), timeZone);
|
||||
} catch {
|
||||
return formatParts(new Date(ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export type DrawCurrentRiskPoolAlert = {
|
||||
};
|
||||
|
||||
export type DrawCurrentPayload = {
|
||||
/** 期号计划时区,PRD 固定为 UTC(GMT) */
|
||||
schedule_timezone?: string;
|
||||
/** 服务端当前时刻(UTC 下的 YYYY-MM-DD HH:mm:ss) */
|
||||
schedule_now?: string;
|
||||
draw_no: string;
|
||||
business_date: string;
|
||||
sequence_no: number;
|
||||
@@ -28,6 +32,8 @@ export type DrawCurrentPayload = {
|
||||
close_time: string | null;
|
||||
draw_time: string | null;
|
||||
seconds_to_close: number;
|
||||
/** 距开始下注(pending 且 start_time 在未来) */
|
||||
seconds_to_start?: number;
|
||||
seconds_to_draw: number;
|
||||
cooling_end_time: string | null;
|
||||
seconds_remaining_in_cooldown: number | null;
|
||||
|
||||
Reference in New Issue
Block a user