diff --git a/src/features/draw/draw-status-meta.ts b/src/features/draw/draw-status-meta.ts index 4303a82..6e74b0c 100644 --- a/src/features/draw/draw-status-meta.ts +++ b/src/features/draw/draw-status-meta.ts @@ -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": diff --git a/src/features/hall/hall-draw-panel.tsx b/src/features/hall/hall-draw-panel.tsx index 53e2f0b..81b9cdd 100644 --- a/src/features/hall/hall-draw-panel.tsx +++ b/src/features/hall/hall-draw-panel.tsx @@ -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 ( <> --:--:-- - {t("draw.currentTime")} + {t(labelKey)} ); } @@ -38,6 +47,7 @@ function CurrentTime({ payload }: { payload: DrawCurrentPayload }) { <> {time} {date} + {t(labelKey)} ); } @@ -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 })
- +
+ {display.schedule_now ? ( +
+ {t("draw.scheduleNow", { + now: formatLotteryScheduleClock( + display.schedule_now, + display.schedule_timezone ?? LOTTERY_SCHEDULE_TIMEZONE, + ), + tz: getBrowserTimeZoneLabel(), + })} +
+ ) : null} {blockedUi ? (
diff --git a/src/features/hall/use-hall-draw-live.ts b/src/features/hall/use-hall-draw-live.ts index aea9eaa..c1844c7 100644 --- a/src/features/hall/use-hall-draw-live.ts +++ b/src/features/hall/use-hall-draw-live.ts @@ -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"; diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 9f3c81d..f2306f8 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -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…", diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 778a621..24ec272 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -73,6 +73,10 @@ "draw": { "currentIssue": "हालको इश्यू", "currentTime": "हालको समय", + "scheduledStart": "सुरु समय", + "scheduledClose": "बन्द समय", + "startsIn": "सुरु हुन बाँकी", + "scheduleNow": "हालको समय {{now}} ({{tz}})", "closesIn": "बन्द हुन बाँकी", "drawsIn": "ड्र हुन बाँकी", "drawProcessing": "ड्रअ प्रक्रियामा", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 233f58c..b831f74 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -73,6 +73,10 @@ "draw": { "currentIssue": "当前期号", "currentTime": "当前时间", + "scheduledStart": "开始时间", + "scheduledClose": "封盘时间", + "startsIn": "距开始", + "scheduleNow": "当前时间 {{now}}({{tz}})", "closesIn": "距封盘", "drawsIn": "距开奖", "drawProcessing": "开奖处理中", diff --git a/src/lib/format-gmt.ts b/src/lib/format-gmt.ts index e6ba7b1..1a62d79 100644 --- a/src/lib/format-gmt.ts +++ b/src/lib/format-gmt.ts @@ -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}`; } diff --git a/src/lib/lottery-schedule-timezone.ts b/src/lib/lottery-schedule-timezone.ts new file mode 100644 index 0000000..84110a2 --- /dev/null +++ b/src/lib/lottery-schedule-timezone.ts @@ -0,0 +1,5 @@ +/** + * PRD:服务端计划时刻按 UTC(GMT)存储与下发。 + * 管理端展示/录入仍按此时区;玩家端界面将 API 时刻换算为浏览器本地时区显示。 + */ +export const LOTTERY_SCHEDULE_TIMEZONE = "UTC"; diff --git a/src/lib/player-datetime.ts b/src/lib/player-datetime.ts index 344ed9c..ec101c9 100644 --- a/src/lib/player-datetime.ts +++ b/src/lib/player-datetime.ts @@ -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)); + } } diff --git a/src/types/api/draw-current.ts b/src/types/api/draw-current.ts index a039754..e54c74c 100644 --- a/src/types/api/draw-current.ts +++ b/src/types/api/draw-current.ts @@ -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;