From 6606b9178cf01e12ceed734ca5a3e08f81b1861f Mon Sep 17 00:00:00 2001 From: Charles7c Date: Tue, 15 Sep 2026 15:52:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=88=90=E6=9C=AC=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=E7=BA=A7=E6=97=B6=E9=97=B4=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E7=A7=92=E7=BA=A7=E7=B2=BE=E5=87=86=EF=BC=8C=E5=B9=B6=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=8E=92=E8=A1=8C=E6=A6=9C=E5=88=97=E5=AE=BD=E4=B8=8E?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 开始使用/最后活跃改从逐请求日志取秒级精准时间,失败回退图表近似值 - 用户列从 140px 收窄至 80px,时间列定宽 112px,请求列放回 auto 平分 - 开始使用、最后活跃支持点击排序(精准值优先,无记录排最后) - 输入/输出列改为居中,缓解与请求列间距过大 --- src/App.vue | 80 +++++++++++++++++++++++++++++++++++++++++++-------- src/api.ts | 48 ++++++++++++++++++++++++++++++- src/format.ts | 45 +++++++++++++++++++++++++++++ src/style.css | 12 ++++++-- src/types.ts | 16 +++++++++-- 5 files changed, 183 insertions(+), 18 deletions(-) diff --git a/src/App.vue b/src/App.vue index b3a3f2b..c69e4ad 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,6 +20,7 @@ import { getAccountAvailability, getAccountModels, getModels, + getUsageActiveBounds, getUsageTrend, getUserBreakdown, listAccounts, @@ -34,6 +35,7 @@ import type { ModelSortKey, ModelSortableItem } from './modelSort' import { fillTrend, formatActiveAt, + formatActiveExact, formatCount, formatCost, formatCostExact, @@ -92,6 +94,31 @@ const range = computed(() => { const titleLabel = computed(() => (period.value === 'custom' ? '自定义' : periodLabel[period.value])) +/** 时间列排序取值:精准日志时间(ISO)优先,回退趋势点 key,统一转毫秒时间戳;解析失败返回 null(排最后) */ +function activeSortValue(user: RankedUser, which: 'first' | 'last'): number | null { + const iso = which === 'first' ? user.firstActiveExact : user.lastActiveExact + if (iso) { + const t = new Date(iso).getTime() + if (!Number.isNaN(t)) return t + return null + } + const key = which === 'first' ? user.firstActive || '' : user.lastActive || '' + if (!key) return null + // 趋势 key 形如「YYYY-MM-DD HH:00」(小时粒度)或「YYYY-MM-DD」(天粒度),按上海时区解析 + const parsed = new Date(key.includes(' ') ? `${key.replace(' ', 'T')}:00+08:00` : `${key}T00:00:00+08:00`).getTime() + return Number.isNaN(parsed) ? null : parsed +} + +/** 时间列排序:无效值(无活跃记录)恒排最后,与升降序无关 */ +function compareActive(a: RankedUser, b: RankedUser, which: 'first' | 'last', dir: number): number { + const ta = activeSortValue(a, which) + const tb = activeSortValue(b, which) + if (ta === null && tb === null) return 0 + if (ta === null) return 1 + if (tb === null) return -1 + return (ta - tb) * dir +} + const sortedUsers = computed(() => { const q = query.value.trim().toLowerCase() const list = users.value.filter((u) => { @@ -101,7 +128,10 @@ const sortedUsers = computed(() => { const key = sortBy.value const dir = sortDir.value === 'asc' ? 1 : -1 return [...list].sort((a, b) => { - const diff = (Number(a[key] || 0) - Number(b[key] || 0)) * dir + const diff = + key === 'first_active' || key === 'last_active' + ? compareActive(a, b, key === 'first_active' ? 'first' : 'last', dir) + : (Number(a[key] || 0) - Number(b[key] || 0)) * dir if (diff !== 0) return diff return a.username.localeCompare(b.username, 'zh-CN') }) @@ -212,9 +242,16 @@ function computeActiveRange(trend: ChartPoint[]): { first: string | null; last: return { first, last } } -/** 首次 / 末次活跃时间的悬浮说明(上海时区) */ +/** 单元格短文案:有日志精准值(到分)用精准值,否则回退趋势粒度近似值 */ +function activeText(exact: string | null, approx: string | null): string { + return formatActiveExact(exact) || formatActiveAt(approx) +} + +/** 首次 / 末次活跃时间的悬浮说明(上海时区):精准值到秒,未取到时回退趋势粒度近似值 */ function activeRangeTitle(user: RankedUser): string { - return `周期首次使用 ${user.firstActive || '—'} · 最后活跃 ${user.lastActive || '—'}(上海时区)` + const first = formatActiveExact(user.firstActiveExact, { seconds: true }) || formatActiveAt(user.firstActive) + const last = formatActiveExact(user.lastActiveExact, { seconds: true }) || formatActiveAt(user.lastActive) + return `周期首次使用 ${first || '—'} · 最后活跃 ${last || '—'}(上海时区)` } /** 输入输出列的悬停说明:输入 / 输出 / 缓存的精确值 */ @@ -417,6 +454,8 @@ async function load() { trendError: null, firstActive: null, lastActive: null, + firstActiveExact: null, + lastActiveExact: null, }) } @@ -428,10 +467,12 @@ async function load() { usageByModel.value = usageKeyMap(modelStats.models || []) updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) - // 预取每个用户的模型明细与逐点趋势:趋势用于计算本周期首次/末次活跃时间 + // 预取每个用户的模型明细、逐点趋势、日志首末活跃时刻: + // 趋势给出近似兜底值,日志探针给出分钟级精准值(精准优先展示) await Promise.all([ ...users.value.filter((u) => !u.modelsLoaded).map((u) => ensureModels(u, false)), ...users.value.filter((u) => !u.trendLoaded).map((u) => ensureTrend(u, false)), + ...users.value.map((u) => ensureActiveBounds(u, start, end, seq)), ]) if (seq !== loadSeq) return for (const u of users.value) { @@ -451,6 +492,17 @@ async function load() { } } +async function ensureActiveBounds(user: RankedUser, start: string, end: string, seq: number) { + try { + const { first, last } = await getUsageActiveBounds(user.user_id, start, end) + if (seq !== loadSeq) return + user.firstActiveExact = first + user.lastActiveExact = last + } catch { + // 日志接口不可用时静默回退趋势粒度近似值,不打扰主榜 + } +} + async function ensureModels(user: RankedUser, force = false) { if (user.modelsLoaded && !force) return if (loadingModels[user.user_id]) return @@ -627,8 +679,8 @@ useAutoRefresh(load, { isLoading: () => loading.value }) - - + + @@ -642,12 +694,16 @@ useAutoRefresh(load, { isLoading: () => loading.value }) Token {{ sortMark('total_tokens') }} Token 占比 - 输入/输出 + 输入/输出 请求 {{ sortMark('requests') }} - 开始使用 - 最后活跃 + + 开始使用 {{ sortMark('first_active') }} + + + 最后活跃 {{ sortMark('last_active') }} + @@ -686,12 +742,12 @@ useAutoRefresh(load, { isLoading: () => loading.value }) {{ formatPercent(user.share) }} - + {{ formatTokens(user.input_tokens) }} / {{ formatTokens(user.output_tokens) }} {{ formatCount(user.requests) }} - {{ formatActiveAt(user.firstActive) }} - {{ formatActiveAt(user.lastActive) }} + {{ activeText(user.firstActiveExact, user.firstActive) }} + {{ activeText(user.lastActiveExact, user.lastActive) }} diff --git a/src/api.ts b/src/api.ts index bf7d612..dc2eb9f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,9 +10,10 @@ import type { Paginated, TrendGranularity, TrendPoint, + UsageLogRecord, UserBreakdownItem, } from './types' -import { TZ } from './format' +import { shanghaiDateOf, TZ } from './format' const API_BASE = (import.meta.env.VITE_API_BASE || '/api/v1').replace(/\/$/, '') const ADMIN_KEY = import.meta.env.VITE_ADMIN_API_KEY || '' @@ -80,6 +81,51 @@ export function getUsageTrend( ) } +/** + * 单个用户在某日期区间内的精准首末活跃时刻(秒级,来自逐请求日志 /admin/usage)。 + * + * 实测契约(决定下面的取法,改动前先复核): + * - 只有带 user_id 过滤时 start_date/end_date 与 total 才可靠; + * 不带 user_id 时日期被忽略、total 随页码虚增,禁止全站聚合式调用。 + * - 结果固定按 created_at 倒序(sort/order 等参数实测全部无效): + * 第 1 页第 1 条 = 末次活跃;页码 = total 的末页那 1 条 = 首次活跃。 + * - 每条记录嵌套 user/api_key/group 全量对象(约 4KB/条), + * 所以 page_size 固定为 1,只探边界,不拉数据。 + * - 两次调用间新进来的请求会让 total 微增,末页可能偏移一两条, + * 对「周期内首次使用」影响可忽略;末页为空则放弃精准值。 + * - 后端日期截断的时区未经确证(工作时段数据无法区分上海日/UTC 日), + * 取回后按上海日历日校验,越界即视为不可信、返回 null 由调用方回退近似值。 + */ +const LOG_PROBE_PAGE_SIZE = 1 +/** 翻页取末页的 OFFSET 上限,超过则不查首次活跃(交给图表近似值兜底) */ +const LOG_LAST_PAGE_MAX = 20000 + +export async function getUsageActiveBounds( + userId: number, + start: string, + end: string, +): Promise<{ first: string | null; last: string | null }> { + const withinRange = (rec: string | null | undefined): string | null => { + const day = shanghaiDateOf(rec || '') + return day && day >= start && day <= end ? (rec as string) : null + } + const base = { + user_id: userId, + start_date: start, + end_date: end, + page_size: LOG_PROBE_PAGE_SIZE, + } + const head = await request>('/admin/usage', { ...base, page: 1 }) + const last = withinRange(head.items?.[0]?.created_at) + if (!last) return { first: null, last: null } + const total = Number(head.total || 0) + if (total <= 1 || total > LOG_LAST_PAGE_MAX) { + return { first: total <= 1 ? last : null, last } + } + const tail = await request>('/admin/usage', { ...base, page: total }) + return { first: withinRange(tail.items?.[0]?.created_at), last } +} + export function getModels( start: string, end: string, diff --git a/src/format.ts b/src/format.ts index 9677caf..670fb07 100644 --- a/src/format.ts +++ b/src/format.ts @@ -197,6 +197,51 @@ export function formatActiveAt(key: string | null | undefined): string { return key.slice(0, 4) === todayYear ? key.slice(5) : key } +/** 日志时间戳(ISO,含时区)→ 上海日历日 YYYY-MM-DD,解析失败返回空串 */ +export function shanghaiDateOf(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value || '' + return `${get('year')}-${get('month')}-${get('day')}` +} + +/** + * 精准活跃时间短文案(来自 /admin/usage 日志的 created_at,ISO 字符串)。 + * 统一渲染为上海时区,同年省略年份:「MM-DD HH:MM」; + * 传 seconds=true 显示到秒(用于悬浮提示)。 + */ +export function formatActiveExact( + iso: string | null | undefined, + options?: { seconds?: boolean; now?: Date }, +): string { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const now = options?.now || new Date() + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + ...(options?.seconds ? { second: '2-digit' } : {}), + hourCycle: 'h23', + }).formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value || '' + const ymd = `${get('year')}-${get('month')}-${get('day')}` + const hm = options?.seconds + ? `${get('hour')}:${get('minute')}:${get('second')}` + : `${get('hour')}:${get('minute')}` + return ymd.slice(0, 4) === todayISO(now).slice(0, 4) ? `${ymd.slice(5)} ${hm}` : `${ymd} ${hm}` +} + export function formatQuotaRecovery(value: string | null | undefined, now = new Date()): string { if (!value) return '' const date = new Date(value) diff --git a/src/style.css b/src/style.css index 6b07437..2fb24b3 100644 --- a/src/style.css +++ b/src/style.css @@ -322,7 +322,7 @@ table.rank .col-rank { } table.rank .col-user { - width: 140px; + width: 80px; } table.rank .col-num { @@ -489,9 +489,15 @@ table.rank > tbody > tr.user-row > td.share-col { justify-content: flex-start; } -/* 请求列收窄(避免与其它 auto 列平分后过宽) */ +/* 请求列与 Token / 输入输出同为数值列,交给 auto 平分剩余空间, + 避免固定宽度截断大数(近30天请求数可达百万级千分位) */ table.rank .col-requests { - width: 84px; + width: auto; +} + +/* 时间列定宽:精准时间形如「MM-DD HH:MM」(10 字符),不需要很大 */ +table.rank .col-time { + width: 112px; } /* 时间列左对齐(覆盖主表 .right 的右对齐) */ diff --git a/src/types.ts b/src/types.ts index e330f52..4c79637 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,8 @@ export type SortKey = | 'output_tokens' | 'cache_tokens' | 'actual_cost' + | 'first_active' + | 'last_active' export interface DateRange { start: string @@ -81,10 +83,20 @@ export interface RankedUser extends UserBreakdownItem { trendGranularity: TrendGranularity trendLoaded: boolean trendError: string | null - /** 本周期内首次有用量(请求/Token)的时间点 key,无则为 null */ + /** 本周期内首次有用量(请求/Token)的时间点 key(趋势图粒度,近似),无则为 null */ firstActive: string | null - /** 本周期内末次有用量(请求/Token)的时间点 key,无则为 null */ + /** 本周期内末次有用量(请求/Token)的时间点 key(趋势图粒度,近似),无则为 null */ lastActive: string | null + /** 本周期内首条请求日志的 created_at(ISO,秒级精准),接口不可用时为 null */ + firstActiveExact: string | null + /** 本周期内末条请求日志的 created_at(ISO,秒级精准),接口不可用时为 null */ + lastActiveExact: string | null +} + +/** Sub2API /admin/usage 请求日志单条记录中本前端用到的字段 */ +export interface UsageLogRecord { + user_id: number + created_at: string } export interface AdminGroup {