diff --git a/apps/web/src/components/common/time-range-picker.vue b/apps/web/src/components/common/time-range-picker.vue index 8207d76..ceeccbe 100644 --- a/apps/web/src/components/common/time-range-picker.vue +++ b/apps/web/src/components/common/time-range-picker.vue @@ -2,9 +2,8 @@ import type { Dayjs } from "dayjs"; import dayjs from "dayjs"; import { computed } from "vue"; -import { recentDaysRange } from "@/utils/time.ts"; - -type RangeValue = { from: string; to: string }; +import { recentDaysRange, type RangeValue } from "@/utils/time.ts"; +import type { RangeKind } from "@/composables/use-live-time-range.ts"; const props = defineProps<{ modelValue: RangeValue; @@ -14,6 +13,8 @@ const props = defineProps<{ const emit = defineEmits<{ (e: "update:modelValue", value: RangeValue): void; + /** 相对区间可自动跟日历推进;fixed 为绝对窗口 */ + (e: "change", value: RangeValue, kind: RangeKind): void; }>(); const pickerValue = computed<[Dayjs, Dayjs] | null>(() => { @@ -43,12 +44,29 @@ function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] { return [dayjs(range.from), dayjs(range.to)]; } +function detectKind(value: RangeValue): RangeKind { + const fromD = dayjs(value.from).format("YYYY-MM-DD"); + const toD = dayjs(value.to).format("YYYY-MM-DD"); + for (const days of [7, 30] as const) { + const r = recentDaysRange(days); + if ( + dayjs(r.from).format("YYYY-MM-DD") === fromD && + dayjs(r.to).format("YYYY-MM-DD") === toD + ) { + return days === 7 ? "recent-7" : "recent-30"; + } + } + return "fixed"; +} + function onChange(dates: Dayjs[] | null) { if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return; - emit("update:modelValue", { + const value = { from: dates[0].toISOString(), to: dates[1].toISOString(), - }); + }; + emit("update:modelValue", value); + emit("change", value, detectKind(value)); } diff --git a/apps/web/src/composables/use-live-time-range.ts b/apps/web/src/composables/use-live-time-range.ts new file mode 100644 index 0000000..99a62b3 --- /dev/null +++ b/apps/web/src/composables/use-live-time-range.ts @@ -0,0 +1,35 @@ +import { computed, onUnmounted, ref } from "vue"; +import { recentDaysRange } from "@/utils/time.ts"; + +export type RangeValue = { from: string; to: string }; +export type RangeKind = "recent-7" | "recent-30" | "fixed"; + +/** + * 可跟随「今天」推进的时间范围: + * - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧) + * - fixed:自定义/订阅期间等绝对区间,窗口不挪 + */ +export function useLiveTimeRange(initial: Exclude = "recent-30") { + const kind = ref(initial); + const fixed = ref(recentDaysRange(30)); + const nowTick = ref(Date.now()); + + const timer = setInterval(() => { + nowTick.value = Date.now(); + }, 60_000); + onUnmounted(() => clearInterval(timer)); + + const range = computed(() => { + void nowTick.value; + if (kind.value === "recent-7") return recentDaysRange(7); + if (kind.value === "recent-30") return recentDaysRange(30); + return fixed.value; + }); + + function setRange(value: RangeValue, nextKind: RangeKind) { + kind.value = nextKind; + if (nextKind === "fixed") fixed.value = value; + } + + return { range, setRange, kind }; +} diff --git a/apps/web/src/queries/admin.ts b/apps/web/src/queries/admin.ts index 05c0e33..fca70a7 100644 --- a/apps/web/src/queries/admin.ts +++ b/apps/web/src/queries/admin.ts @@ -27,6 +27,8 @@ export function useAdminOverview(range: () => TimeRange) { return useQuery({ queryKey: () => qk.admin.overview(range()), queryFn: () => fetchOverview(range()), + // 页面可长时间挂起:定期拉取引擎新写入的数据 + refetchInterval: 60_000, }); } diff --git a/apps/web/src/utils/time.ts b/apps/web/src/utils/time.ts index 4c16515..bacbff1 100644 --- a/apps/web/src/utils/time.ts +++ b/apps/web/src/utils/time.ts @@ -39,9 +39,14 @@ export function beijingDayEndToIso(date: string): string { return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString(); } +export type RangeValue = { from: string; to: string }; + /** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */ -export function recentDaysRange(days: number): { from: string; to: string } { - const to = dayjs().tz(DISPLAY_TZ).add(1, "day").startOf("day"); +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() }; } diff --git a/apps/web/src/views/admin/overview-view.vue b/apps/web/src/views/admin/overview-view.vue index 5b2b261..bbf7d87 100644 --- a/apps/web/src/views/admin/overview-view.vue +++ b/apps/web/src/views/admin/overview-view.vue @@ -11,12 +11,17 @@ import TimeRangePicker from "@/components/common/time-range-picker.vue"; import QuotaText from "@/components/common/quota-text.vue"; import SourceBar from "@/components/common/source-bar.vue"; import HealthBand from "@/components/admin/health-band.vue"; +import { + useLiveTimeRange, + type RangeKind, + type RangeValue, +} from "@/composables/use-live-time-range.ts"; import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts"; -import { recentDaysRange, formatMinute } from "@/utils/time.ts"; +import { formatMinute } from "@/utils/time.ts"; import { formatInt } from "@/utils/format.ts"; const router = useRouter(); -const range = ref(recentDaysRange(30)); +const { range, setRange } = useLiveTimeRange("recent-30"); const overviewQuery = useAdminOverview(() => range.value); const engineQuery = useEngine(); @@ -60,6 +65,10 @@ function memberPercent(quota: number): number { return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0; } +function onRangeChange(value: RangeValue, kind: RangeKind) { + setRange(value, kind); +} + function onMemberClick(memberId: number | null) { void router.push({ path: "/admin/keys", @@ -155,7 +164,11 @@ const keyColumns = [ >