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 { PaymentItem } from "@new-api-key-usage/core";
|
||||||
import type { ApiError } from "@/api/http.ts";
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts";
|
import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts";
|
||||||
|
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -67,9 +68,12 @@ watch(
|
|||||||
if (ed) {
|
if (ed) {
|
||||||
form.kind = ed.kind;
|
form.kind = ed.kind;
|
||||||
form.paid_amount = ed.paid_amount;
|
form.paid_amount = ed.paid_amount;
|
||||||
form.paid_at = ed.paid_at ? dayjs.unix(ed.paid_at) : null;
|
// 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写
|
||||||
form.period_start = ed.period_start ? dayjs.unix(ed.period_start) : null;
|
form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null;
|
||||||
form.period_end = ed.period_end ? dayjs.unix(ed.period_end) : 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.subscription_id = ed.subscription_id;
|
||||||
form.remark = ed.remark;
|
form.remark = ed.remark;
|
||||||
form.quotaMode = "quota";
|
form.quotaMode = "quota";
|
||||||
@@ -91,7 +95,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toIso(d: Dayjs | null): string | null {
|
function toIso(d: Dayjs | null): string | null {
|
||||||
return d ? d.toISOString() : null;
|
return d ? wallAsBeijingIso(d) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
@@ -157,6 +161,7 @@ async function onSubmit() {
|
|||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
ok-text="保存"
|
ok-text="保存"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
|
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }"
|
||||||
@update:open="emit('update:open', $event)"
|
@update:open="emit('update:open', $event)"
|
||||||
@ok="onSubmit"
|
@ok="onSubmit"
|
||||||
>
|
>
|
||||||
@@ -185,10 +190,18 @@ async function onSubmit() {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-if="form.kind === 'subscription'">
|
<template v-if="form.kind === 'subscription'">
|
||||||
<a-form-item label="期间起" required>
|
<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>
|
||||||
<a-form-item label="期间止(空 = 进行中)">
|
<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>
|
||||||
<a-form-item label="订阅 ID" required>
|
<a-form-item label="订阅 ID" required>
|
||||||
<a-input-number
|
<a-input-number
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { computed } from "vue";
|
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";
|
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -62,8 +66,8 @@ function detectKind(value: RangeValue): RangeKind {
|
|||||||
function onChange(dates: Dayjs[] | null) {
|
function onChange(dates: Dayjs[] | null) {
|
||||||
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||||
const value = {
|
const value = {
|
||||||
from: dates[0].toISOString(),
|
from: wallAsBeijingIso(dates[0]),
|
||||||
to: dates[1].toISOString(),
|
to: wallAsBeijingIso(dates[1]),
|
||||||
};
|
};
|
||||||
emit("update:modelValue", value);
|
emit("update:modelValue", value);
|
||||||
emit("change", value, detectKind(value));
|
emit("change", value, detectKind(value));
|
||||||
@@ -74,8 +78,9 @@ function onChange(dates: Dayjs[] | null) {
|
|||||||
<a-range-picker
|
<a-range-picker
|
||||||
:value="pickerValue"
|
:value="pickerValue"
|
||||||
:presets="presets"
|
:presets="presets"
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
:placeholder="['开始日期', '结束日期']"
|
:placeholder="['开始时间', '结束时间']"
|
||||||
|
show-time
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ dayjs.extend(timezone);
|
|||||||
|
|
||||||
/** 北京时区展示(固定 +08:00,不随主机时区变) */
|
/** 北京时区展示(固定 +08:00,不随主机时区变) */
|
||||||
const DISPLAY_TZ = "Asia/Shanghai";
|
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」 */
|
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
|
||||||
export function formatDateTime(unixSec: number | null | undefined): string {
|
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");
|
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 {
|
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 {
|
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 };
|
export type RangeValue = { from: string; to: string };
|
||||||
|
|
||||||
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */
|
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */
|
||||||
export function recentDaysRange(
|
export function recentDaysRange(
|
||||||
days: number,
|
days: number,
|
||||||
nowMs: number = Date.now(),
|
nowMs: number = Date.now(),
|
||||||
): { from: string; to: string } {
|
): { from: string; to: string } {
|
||||||
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
||||||
const from = to.subtract(days, "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」(北京时间日) */
|
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type RangeValue,
|
type RangeValue,
|
||||||
} from "@/composables/use-live-time-range.ts";
|
} from "@/composables/use-live-time-range.ts";
|
||||||
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.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";
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -40,10 +40,10 @@ const extraPresets = computed(() => {
|
|||||||
.filter((p) => p.kind === "subscription" && p.period_start != null)
|
.filter((p) => p.kind === "subscription" && p.period_start != null)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
|
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
|
||||||
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
|
||||||
to: new Date(
|
to: toRequestIso(
|
||||||
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
|
||||||
).toISOString(),
|
),
|
||||||
}))
|
}))
|
||||||
.slice(0, 4);
|
.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 PaymentFormModal from "@/components/admin/payment-form-modal.vue";
|
||||||
import QuotaText from "@/components/common/quota-text.vue";
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
import { useDeletePayment, usePayments } from "@/queries/admin.ts";
|
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";
|
import { formatPaidAmount } from "@/utils/format.ts";
|
||||||
|
|
||||||
const paymentsQuery = usePayments();
|
const paymentsQuery = usePayments();
|
||||||
@@ -40,8 +40,8 @@ const columns = [
|
|||||||
|
|
||||||
function timeText(p: PaymentItem): string {
|
function timeText(p: PaymentItem): string {
|
||||||
if (p.kind === "topup") return formatDateTime(p.paid_at);
|
if (p.kind === "topup") return formatDateTime(p.paid_at);
|
||||||
const start = formatDate(p.period_start);
|
const start = formatDateTime(p.period_start);
|
||||||
const end = p.period_end == null ? "进行中" : formatDate(p.period_end);
|
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
|
||||||
return `${start} ~ ${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),
|
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 earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
|
||||||
const from = new Date((earliest - 30 * 86400) * 1000).toISOString();
|
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
|
||||||
const to = new Date().toISOString();
|
const to = toRequestIso(new Date());
|
||||||
const observed = new Set<number>();
|
const observed = new Set<number>();
|
||||||
let page = 1;
|
let page = 1;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
## 4. 时间口径
|
## 4. 时间口径
|
||||||
|
|
||||||
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
|
- **响应时刻字段: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`
|
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
|
||||||
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
|
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
|
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
|
||||||
|
|
||||||
1. 时间传输格式 ISO 8601 UTC 字符串(§4);
|
1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4);
|
||||||
2. 时间范围左闭右开 `[from, to)`(§4);
|
2. 时间范围左闭右开 `[from, to)`(§4);
|
||||||
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
|
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
|
||||||
4. DTO 与表字段 snake_case 同构、无映射层(§7);
|
4. DTO 与表字段 snake_case 同构、无映射层(§7);
|
||||||
|
|||||||
Reference in New Issue
Block a user