参考成本小时级时间升级为秒级精准,并调整排行榜列宽与排序

- 开始使用/最后活跃改从逐请求日志取秒级精准时间,失败回退图表近似值
- 用户列从 140px 收窄至 80px,时间列定宽 112px,请求列放回 auto 平分
- 开始使用、最后活跃支持点击排序(精准值优先,无记录排最后)
- 输入/输出列改为居中,缓解与请求列间距过大
This commit is contained in:
2026-09-15 15:52:43 +08:00
parent 853dc39f3a
commit 6606b9178c
5 changed files with 183 additions and 18 deletions

View File

@@ -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 })
<col class="col-share hide-sm" />
<col class="col-num hide-sm" />
<col class="col-requests" />
<col class="col-num hide-sm" />
<col class="col-num hide-sm" />
<col class="col-time hide-sm" />
<col class="col-time hide-sm" />
</colgroup>
<thead>
<tr>
@@ -642,12 +694,16 @@ useAutoRefresh(load, { isLoading: () => loading.value })
Token {{ sortMark('total_tokens') }}
</th>
<th class="right hide-sm share-col">Token 占比</th>
<th class="right hide-sm" title="输入 / 输出 Token悬停查看缓存">输入/输出</th>
<th class="hide-sm" title="输入 / 输出 Token悬停查看缓存">输入/输出</th>
<th class="sortable right" :class="{ active: sortBy === 'requests' }" @click="setSort('requests')">
请求 {{ sortMark('requests') }}
</th>
<th class="time-col hide-sm" title="本周期内首次有请求 / Token 的时间">开始使用</th>
<th class="time-col hide-sm" title="本周期内末次有请求 / Token 的时间">最后活跃</th>
<th class="time-col hide-sm sortable" :class="{ active: sortBy === 'first_active' }" @click="setSort('first_active')" title="本周期内首次有请求 / Token 的时间">
开始使用 {{ sortMark('first_active') }}
</th>
<th class="time-col hide-sm sortable" :class="{ active: sortBy === 'last_active' }" @click="setSort('last_active')" title="本周期内末次有请求 / Token 的时间">
最后活跃 {{ sortMark('last_active') }}
</th>
</tr>
</thead>
<tbody>
@@ -686,12 +742,12 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<span class="num">{{ formatPercent(user.share) }}</span>
</div>
</td>
<td class="right num hide-sm" :title="ioTitle(user)">
<td class="num hide-sm" :title="ioTitle(user)">
{{ formatTokens(user.input_tokens) }}<span class="io-sep"> / </span>{{ formatTokens(user.output_tokens) }}
</td>
<td class="right num">{{ formatCount(user.requests) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ formatActiveAt(user.firstActive) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ formatActiveAt(user.lastActive) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ activeText(user.firstActiveExact, user.firstActive) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ activeText(user.lastActiveExact, user.lastActive) }}</td>
</tr>
<tr v-if="expanded === user.user_id" class="expand">
<td colspan="10">

View File

@@ -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<Paginated<UsageLogRecord>>('/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<Paginated<UsageLogRecord>>('/admin/usage', { ...base, page: total })
return { first: withinRange(tail.items?.[0]?.created_at), last }
}
export function getModels(
start: string,
end: string,

View File

@@ -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_atISO 字符串)。
* 统一渲染为上海时区同年省略年份「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)

View File

@@ -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 的右对齐) */

View File

@@ -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_atISO秒级精准接口不可用时为 null */
firstActiveExact: string | null
/** 本周期内末条请求日志的 created_atISO秒级精准接口不可用时为 null */
lastActiveExact: string | null
}
/** Sub2API /admin/usage 请求日志单条记录中本前端用到的字段 */
export interface UsageLogRecord {
user_id: number
created_at: string
}
export interface AdminGroup {