diff --git a/apps/web/src/components/admin/payment-form-modal.vue b/apps/web/src/components/admin/payment-form-modal.vue index 44b4855..560d3fc 100644 --- a/apps/web/src/components/admin/payment-form-modal.vue +++ b/apps/web/src/components/admin/payment-form-modal.vue @@ -8,6 +8,7 @@ import dayjs from "dayjs"; import type { PaymentItem } from "@new-api-key-usage/core"; import type { ApiError } from "@/api/http.ts"; import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts"; +import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts"; const props = defineProps<{ open: boolean; @@ -67,9 +68,12 @@ watch( if (ed) { form.kind = ed.kind; form.paid_amount = ed.paid_amount; - form.paid_at = ed.paid_at ? dayjs.unix(ed.paid_at) : null; - form.period_start = ed.period_start ? dayjs.unix(ed.period_start) : null; - form.period_end = ed.period_end ? dayjs.unix(ed.period_end) : null; + // 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写 + form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null; + form.period_start = + ed.period_start != null ? unixToBeijingWall(ed.period_start) : null; + form.period_end = + ed.period_end != null ? unixToBeijingWall(ed.period_end) : null; form.subscription_id = ed.subscription_id; form.remark = ed.remark; form.quotaMode = "quota"; @@ -91,7 +95,7 @@ watch( ); function toIso(d: Dayjs | null): string | null { - return d ? d.toISOString() : null; + return d ? wallAsBeijingIso(d) : null; } async function onSubmit() { @@ -157,6 +161,7 @@ async function onSubmit() { :confirm-loading="saving" ok-text="保存" cancel-text="取消" + :styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }" @update:open="emit('update:open', $event)" @ok="onSubmit" > @@ -185,10 +190,18 @@ async function onSubmit() { diff --git a/apps/web/src/utils/time.ts b/apps/web/src/utils/time.ts index bacbff1..a84e0d7 100644 --- a/apps/web/src/utils/time.ts +++ b/apps/web/src/utils/time.ts @@ -10,6 +10,38 @@ dayjs.extend(timezone); /** 北京时区展示(固定 +08:00,不随主机时区变) */ const DISPLAY_TZ = "Asia/Shanghai"; +/** 请求时间序列化:ISO 8601 带偏移(契约 §4;墙钟按北京时间) */ +const REQUEST_OFFSET = "+08:00"; + +/** + * 绝对时刻(Date / Unix / 带时区的 ISO)→ 北京墙钟 + `+08:00`。 + * 先转 Asia/Shanghai,与主机时区无关。 + */ +export function toRequestIso(value: Date | string | number | dayjs.Dayjs): string { + return shanghaiIso(dayjs(value as never)); +} + +/** + * 表单/日历墙钟(用户当作北京时间)→ `+08:00`。 + * 不做时区换算,只给墙钟贴偏移。 + */ +export function wallAsBeijingIso(value: Date | dayjs.Dayjs): string { + const wall = dayjs(value as never).format("YYYY-MM-DDTHH:mm:ss.SSS"); + return `${wall}${REQUEST_OFFSET}`; +} + +/** Unix 秒 → 可放进日历控件的「北京墙钟」dayjs */ +export function unixToBeijingWall(unixSec: number): dayjs.Dayjs { + return dayjs( + dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss.SSS"), + ); +} + +/** 已在 Asia/Shanghai 上的时刻 → 带偏移 ISO */ +function shanghaiIso(d: dayjs.Dayjs): string { + const wall = d.tz(DISPLAY_TZ).format("YYYY-MM-DDTHH:mm:ss.SSS"); + return `${wall}${REQUEST_OFFSET}`; +} /** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */ export function formatDateTime(unixSec: number | null | undefined): string { @@ -29,26 +61,26 @@ export function formatTimeOfDay(unixSec: number | null | undefined): string { return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss"); } -/** 北京时间日初 → 请求用 ISO 8601 UTC(契约 §4:from/to 收 ISO) */ +/** 北京时间日初 → 请求用 ISO(带 +08:00 偏移) */ export function beijingDayStartToIso(date: string): string { - return dayjs.tz(date, DISPLAY_TZ).startOf("day").toISOString(); + return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).startOf("day")); } -/** 北京时间日界(次日零点,左闭右开的 to)→ ISO UTC */ +/** 北京时间日界(次日零点,左闭右开的 to)→ ISO 带偏移 */ export function beijingDayEndToIso(date: string): string { - return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString(); + return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day")); } export type RangeValue = { from: string; to: string }; -/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */ +/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */ export function recentDaysRange( days: number, nowMs: number = Date.now(), ): { from: string; to: string } { const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day"); const from = to.subtract(days, "day"); - return { from: from.toISOString(), to: to.toISOString() }; + return { from: shanghaiIso(from), to: shanghaiIso(to) }; } /** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */ diff --git a/apps/web/src/views/admin/overview-view.vue b/apps/web/src/views/admin/overview-view.vue index bbf7d87..54cb5e1 100644 --- a/apps/web/src/views/admin/overview-view.vue +++ b/apps/web/src/views/admin/overview-view.vue @@ -17,7 +17,7 @@ import { type RangeValue, } from "@/composables/use-live-time-range.ts"; import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts"; -import { formatMinute } from "@/utils/time.ts"; +import { formatMinute, toRequestIso } from "@/utils/time.ts"; import { formatInt } from "@/utils/format.ts"; const router = useRouter(); @@ -40,10 +40,10 @@ const extraPresets = computed(() => { .filter((p) => p.kind === "subscription" && p.period_start != null) .map((p) => ({ label: `订阅 #${p.subscription_id ?? p.id} 期间`, - from: new Date((p.period_start ?? 0) * 1000).toISOString(), - to: new Date( - (p.period_end ?? Math.floor(Date.now() / 1000)) * 1000, - ).toISOString(), + from: toRequestIso(new Date((p.period_start ?? 0) * 1000)), + to: toRequestIso( + new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000), + ), })) .slice(0, 4); }); diff --git a/apps/web/src/views/admin/payments-view.vue b/apps/web/src/views/admin/payments-view.vue index e6e371a..469c911 100644 --- a/apps/web/src/views/admin/payments-view.vue +++ b/apps/web/src/views/admin/payments-view.vue @@ -11,7 +11,7 @@ import PageHeader from "@/components/common/page-header.vue"; import PaymentFormModal from "@/components/admin/payment-form-modal.vue"; import QuotaText from "@/components/common/quota-text.vue"; import { useDeletePayment, usePayments } from "@/queries/admin.ts"; -import { formatDate, formatDateTime } from "@/utils/time.ts"; +import { formatDateTime, toRequestIso } from "@/utils/time.ts"; import { formatPaidAmount } from "@/utils/format.ts"; const paymentsQuery = usePayments(); @@ -40,8 +40,8 @@ const columns = [ function timeText(p: PaymentItem): string { if (p.kind === "topup") return formatDateTime(p.paid_at); - const start = formatDate(p.period_start); - const end = p.period_end == null ? "进行中" : formatDate(p.period_end); + const start = formatDateTime(p.period_start); + const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end); return `${start} ~ ${end}`; } @@ -76,8 +76,8 @@ async function reconcile(pays: PaymentItem[]) { pays.map((p) => p.subscription_id).filter((v): v is number => v != null), ); const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000); - const from = new Date((earliest - 30 * 86400) * 1000).toISOString(); - const to = new Date().toISOString(); + const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000)); + const to = toRequestIso(new Date()); const observed = new Set(); let page = 1; for (;;) { diff --git a/docs/design/API契约-公共约定.md b/docs/design/API契约-公共约定.md index 8bd0159..8fb9260 100644 --- a/docs/design/API契约-公共约定.md +++ b/docs/design/API契约-公共约定.md @@ -57,7 +57,7 @@ ## 4. 时间口径 - **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换 -- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`** +- **请求时间参数**(`from` / `to`、支付 `paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`** - 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec` - 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数 @@ -82,7 +82,7 @@ ## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审) -1. 时间传输格式 ISO 8601 UTC 字符串(§4); +1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4); 2. 时间范围左闭右开 `[from, to)`(§4); 3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5); 4. DTO 与表字段 snake_case 同构、无映射层(§7);