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 {
|