2026-06-19 23:06:43 +08:00
|
|
|
|
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)}`;
|
|
|
|
|
|
}
|
2026-06-30 02:02:58 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|