const DEFAULT_TIMEZONE = 'Asia/Shanghai'; function pad2(value) { return String(value).padStart(2, '0'); } export function normalizeTimezone(timezone) { return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE; } export function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: normalizeTimezone(timezone), year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]), ); return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour === '24' ? 0 : parts.hour), minute: Number(parts.minute), second: Number(parts.second), }; } export function localDateKey(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`; } export function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); return `${parts.month}月${parts.day}日`; } export function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); return zonedTimeToEpochMs( { year: parts.year, month: parts.month, day: parts.day, hour: 0, minute: 0, second: 0, }, timezone, ); } export function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second); const shifted = new Date(utc); return zonedTimeToEpochMs( { year: shifted.getUTCFullYear(), month: shifted.getUTCMonth() + 1, day: shifted.getUTCDate(), hour: shifted.getUTCHours(), minute: shifted.getUTCMinutes(), second: shifted.getUTCSeconds(), }, timezone, ); } export function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) { const tz = normalizeTimezone(timezone); let guess = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour ?? 0), Number(parts.minute ?? 0), Number(parts.second ?? 0), 0, ); for (let i = 0; i < 3; i += 1) { const actual = getLocalParts(guess, tz); const actualAsUtc = Date.UTC( actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, actual.second, 0, ); const desiredAsUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour ?? 0), Number(parts.minute ?? 0), Number(parts.second ?? 0), 0, ); const delta = actualAsUtc - desiredAsUtc; if (delta === 0) break; guess -= delta; } return guess; } export function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) { const tz = normalizeTimezone(timezone); const todayStart = startOfLocalDay(now, tz); const todayParts = getLocalParts(todayStart, tz); let runAt = zonedTimeToEpochMs( { year: todayParts.year, month: todayParts.month, day: todayParts.day, hour: Number(hour), minute: Number(minute), second: 0, }, tz, ); if (runAt <= now) { const tomorrowStart = addLocalDays(todayStart, 1, tz); const tomorrowParts = getLocalParts(tomorrowStart, tz); runAt = zonedTimeToEpochMs( { year: tomorrowParts.year, month: tomorrowParts.month, day: tomorrowParts.day, hour: Number(hour), minute: Number(minute), second: 0, }, tz, ); } return runAt; } export function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); return `${pad2(parts.hour)}:${pad2(parts.minute)}`; } /** * Parse a wall-clock datetime in the given timezone, e.g. "2026-07-01 06:00". */ export function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) { const raw = String(value ?? '').trim(); if (!raw) return null; const match = raw.match( /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/, ); if (!match) { throw new Error(`无法解析本地时间「${raw}」,请使用 YYYY-MM-DD HH:mm`); } return zonedTimeToEpochMs( { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]), hour: Number(match[4] ?? 0), minute: Number(match[5] ?? 0), second: Number(match[6] ?? 0), }, timezone, ); } export function resolveScheduleTimestamp({ epochMs = null, localString = null, timezone = DEFAULT_TIMEZONE, fieldName = '时间', now = Date.now(), } = {}) { const local = String(localString ?? '').trim(); if (local) return parseLocalDateTimeString(local, timezone); if (epochMs == null || epochMs === '') return null; return assertReasonableScheduleEpoch(epochMs, { now, fieldName }); } export function assertReasonableScheduleEpoch( epochMs, { now = Date.now(), fieldName = '时间', maxPastDays = 2, maxFutureDays = 400 } = {}, ) { const safe = Number(epochMs); if (!Number.isFinite(safe) || safe <= 0) { throw new Error(`${fieldName}无效,请改用 YYYY-MM-DD HH:mm 的 local 字段`); } const min = now - maxPastDays * 86400000; const max = now + maxFutureDays * 86400000; if (safe < min || safe > max) { const label = new Intl.DateTimeFormat('zh-CN', { timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE), dateStyle: 'short', timeStyle: 'short', }).format(new Date(safe)); throw new Error( `${fieldName}(${label})超出合理范围。请改用 startLocal/remindLocal(YYYY-MM-DD HH:mm,用户时区),禁止自行估算 Unix 毫秒。`, ); } return safe; }