参考成本小时级时间升级为秒级精准,并调整排行榜列宽与排序
- 开始使用/最后活跃改从逐请求日志取秒级精准时间,失败回退图表近似值 - 用户列从 140px 收窄至 80px,时间列定宽 112px,请求列放回 auto 平分 - 开始使用、最后活跃支持点击排序(精准值优先,无记录排最后) - 输入/输出列改为居中,缓解与请求列间距过大
This commit is contained in:
80
src/App.vue
80
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 })
|
||||
<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">
|
||||
|
||||
48
src/api.ts
48
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<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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 的右对齐) */
|
||||
|
||||
16
src/types.ts
16
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 {
|
||||
|
||||
Reference in New Issue
Block a user