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;