feat(web): 请求时间改为 ISO8601+08:00 偏移,响应仍为 Unix 秒
This commit is contained in:
@@ -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() {
|
||||
</a-form-item>
|
||||
<template v-if="form.kind === 'subscription'">
|
||||
<a-form-item label="期间起" required>
|
||||
<a-date-picker v-model:value="form.period_start" style="width: 100%" />
|
||||
<a-date-picker
|
||||
v-model:value="form.period_start"
|
||||
show-time
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="期间止(空 = 进行中)">
|
||||
<a-date-picker v-model:value="form.period_end" style="width: 100%" />
|
||||
<a-date-picker
|
||||
v-model:value="form.period_end"
|
||||
show-time
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="订阅 ID" required>
|
||||
<a-input-number
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
import { recentDaysRange, type RangeValue } from "@/utils/time.ts";
|
||||
import {
|
||||
recentDaysRange,
|
||||
wallAsBeijingIso,
|
||||
type RangeValue,
|
||||
} from "@/utils/time.ts";
|
||||
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -62,8 +66,8 @@ function detectKind(value: RangeValue): RangeKind {
|
||||
function onChange(dates: Dayjs[] | null) {
|
||||
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||
const value = {
|
||||
from: dates[0].toISOString(),
|
||||
to: dates[1].toISOString(),
|
||||
from: wallAsBeijingIso(dates[0]),
|
||||
to: wallAsBeijingIso(dates[1]),
|
||||
};
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value, detectKind(value));
|
||||
@@ -74,8 +78,9 @@ function onChange(dates: Dayjs[] | null) {
|
||||
<a-range-picker
|
||||
:value="pickerValue"
|
||||
:presets="presets"
|
||||
format="YYYY-MM-DD"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
show-time
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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」(北京时间日) */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<number>();
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
|
||||
Reference in New Issue
Block a user