diff --git a/.gitignore b/.gitignore index ebe8e97..d5d4e24 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ bailian.json.state.json .tmp-bailian/ # 本地联调用的快照替身:防止把过期样例数据打进 dist public/data/ + +# 本地工作台数据(记忆、临时脚本与部署日志,不进版本库) +.workbuddy/ diff --git a/src/App.vue b/src/App.vue index 39e58cb..b3a3f2b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,21 +13,27 @@ import type { } from './types' import UsageChart from './UsageChart.vue' import TotalTrendChart from './TotalTrendChart.vue' +import { GROUP_IDS } from './groups.config' import { cacheTokensOf, + filterModelsByAllowlist, getAccountAvailability, getAccountModels, getModels, getUsageTrend, getUserBreakdown, listAccounts, + listGroups, listUsers, + mergeAvailability, + projectAccountModels, } from './api' import { useModelPricing } from './useModelPricing' import { markSort, nextSort, sortModelsBy } from './modelSort' import type { ModelSortKey, ModelSortableItem } from './modelSort' import { fillTrend, + formatActiveAt, formatCount, formatCost, formatCostExact, @@ -193,6 +199,29 @@ function costShare(user: RankedUser): number { return ((user.actual_cost || 0) / totalCost.value) * 100 } +/** 扫描逐点趋势,取本周期内首个 / 末个有用量(请求或 Token)的时间点 key */ +function computeActiveRange(trend: ChartPoint[]): { first: string | null; last: string | null } { + let first: string | null = null + let last: string | null = null + for (const p of trend) { + if ((p.tokens || 0) > 0 || (p.requests || 0) > 0) { + if (!first) first = p.key + last = p.key + } + } + return { first, last } +} + +/** 首次 / 末次活跃时间的悬浮说明(上海时区) */ +function activeRangeTitle(user: RankedUser): string { + return `周期首次使用 ${user.firstActive || '—'} · 最后活跃 ${user.lastActive || '—'}(上海时区)` +} + +/** 输入输出列的悬停说明:输入 / 输出 / 缓存的精确值 */ +function ioTitle(user: RankedUser): string { + return `输入 ${formatTokensExact(user.input_tokens)} · 输出 ${formatTokensExact(user.output_tokens)} · 缓存 ${formatTokensExact(user.cache_tokens)}` +} + function applyPreset(next: Exclude) { period.value = next const preset = rangeFor(next) @@ -227,11 +256,26 @@ function statusLabel(status: AvailabilityStatus) { return status === 'available' ? '可用' : '不可用' } -function mergeAvailability(map: Map, models: string[], up: boolean) { - for (const name of models) { - if (up) map.set(name, true) - else if (!map.has(name)) map.set(name, false) +/** 模型名复制:点击后短暂显示对勾(与分组页一致) */ +const copiedModel = ref('') +async function copyModel(model: string) { + try { + await navigator.clipboard.writeText(model) + } catch { + // 非安全上下文(http 访问)下 clipboard API 不可用,退回临时 textarea + const ta = document.createElement('textarea') + ta.value = model + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) } + copiedModel.value = model + window.setTimeout(() => { + if (copiedModel.value === model) copiedModel.value = '' + }, 1500) } function usageKeyMap(list: ModelStat[]) { @@ -247,24 +291,44 @@ async function loadAvailability() { availabilityLoading.value = true availabilityError.value = '' try { - const [accountPage, availability] = await Promise.all([ + const [accountPage, availability, groupList] = await Promise.all([ listAccounts(), getAccountAvailability(), + listGroups(), ]) - const accounts = accountPage.items || [] - - const modelLists = await Promise.all( - accounts.map(async (account) => ({ - id: account.id, - models: await getAccountModels(account.id), - })), + // 只统计「已开放分组」(groups.config.ts 中配置的 GROUP_IDS)账号挂载的模型, + // 每个分组按 Sub2API 分组级模型白名单(model_allowlist)过滤,与网关「看到什么=能调什么」一致。 + const wantedGroups = new Set(GROUP_IDS as readonly number[]) + const accounts = (accountPage.items || []).filter((a) => + (a.group_ids || []).some((id) => wantedGroups.has(id)), ) - const modelsByAccount = new Map(modelLists.map((item) => [item.id, item.models])) + const allowByGroup = new Map() + for (const g of groupList) { + if (wantedGroups.has(g.id)) allowByGroup.set(g.id, g.model_allowlist ?? { enabled: false }) + } const isUp = (id: number) => availability[String(id)]?.is_available === true + // 逐个分组:每个账号的模型先按分组白名单过滤(与网关「能调什么」一致), + // 再按「该账号是否在线」标注状态。状态判定是账号粒度,不能用「分组任一账号 + // 在线」把可用放大到全组模型,否则只挂在故障/限流账号上的模型也会被标成可用。 const statusMap = new Map() - for (const account of accounts) { - mergeAvailability(statusMap, modelsByAccount.get(account.id) || [], isUp(account.id)) + for (const gid of wantedGroups) { + const allow = allowByGroup.get(gid) + const groupAccounts = accounts.filter((a) => (a.group_ids || []).includes(gid)) + if (!groupAccounts.length) continue + const perAccount = await Promise.all( + groupAccounts.map(async (a) => { + // 上游全量目录 → 账号 model_mapping 投影(上游名换成别名)→ 分组白名单 + const upstream = await getAccountModels(a.id) + return { + up: isUp(a.id), + models: filterModelsByAllowlist(projectAccountModels(a, upstream), allow), + } + }), + ) + for (const entry of perAccount) { + mergeAvailability(statusMap, entry.models, entry.up) + } } groupCatalog.value = [...statusMap.keys()] @@ -351,6 +415,8 @@ async function load() { trendGranularity: trendGranularity(start, end), trendLoaded: false, trendError: null, + firstActive: null, + lastActive: null, }) } @@ -362,8 +428,17 @@ 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))) + // 预取每个用户的模型明细与逐点趋势:趋势用于计算本周期首次/末次活跃时间 + 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)), + ]) if (seq !== loadSeq) return + for (const u of users.value) { + const { first, last } = computeActiveRange(u.trend) + u.firstActive = first + u.lastActive = last + } } catch (err) { if (seq !== loadSeq) return error.value = err instanceof Error ? err.message : String(err) @@ -550,35 +625,29 @@ useAutoRefresh(load, { isLoading: () => loading.value }) - + # - 用户 + 用户 成本 {{ sortMark('actual_cost') }} - 成本占比 + 成本占比 Token {{ sortMark('total_tokens') }} - Token 占比 + Token 占比 + 输入/输出 请求 {{ sortMark('requests') }} - - 输入 {{ sortMark('input_tokens') }} - - - 输出 {{ sortMark('output_tokens') }} - - - 缓存 {{ sortMark('cache_tokens') }} - + 开始使用 + 最后活跃 @@ -598,29 +667,31 @@ useAutoRefresh(load, { isLoading: () => loading.value }) {{ index + 1 }} - + {{ user.username }} {{ formatCost(user.actual_cost) }} - + {{ formatTokens(user.total_tokens) }} - + + + {{ formatTokens(user.input_tokens) }} / {{ formatTokens(user.output_tokens) }} + {{ formatCount(user.requests) }} - {{ formatTokens(user.input_tokens) }} - {{ formatTokens(user.output_tokens) }} - {{ formatTokens(user.cache_tokens) }} + {{ formatActiveAt(user.firstActive) }} + {{ formatActiveAt(user.lastActive) }} @@ -684,7 +755,7 @@ useAutoRefresh(load, { isLoading: () => loading.value })

可用模型

- 所有账号实际挂载的模型 · 可用 {{ availableCount }} / {{ catalogModels.length }} + 已开放分组(groups.config.ts)实际挂载的模型 · 可用 {{ availableCount }} / {{ catalogModels.length }} · {{ availabilityError }}

@@ -717,7 +788,17 @@ useAutoRefresh(load, { isLoading: () => loading.value }) - {{ item.model }} + + + {{ statusLabel(item.status) }} diff --git a/src/TotalTrendChart.vue b/src/TotalTrendChart.vue index 013d19a..81337e0 100644 --- a/src/TotalTrendChart.vue +++ b/src/TotalTrendChart.vue @@ -178,7 +178,7 @@ const option = computed(() => ({ type: 'value', splitLine: { lineStyle: { color: '#eef1f5' } }, axisLabel: { - color: '#4a7ba6', + color: '#1d4ed8', fontSize: 11, formatter: (v: number) => { const abs = Math.abs(v) @@ -213,9 +213,9 @@ const option = computed(() => ({ symbol: 'circle', symbolSize: 6, showSymbol: false, - lineStyle: { color: '#4a7ba6', width: 2 }, - itemStyle: { color: '#4a7ba6', borderColor: '#fff', borderWidth: 1 }, - areaStyle: { color: 'rgba(74, 123, 166, 0.10)' }, + lineStyle: { color: '#1d4ed8', width: 2 }, + itemStyle: { color: '#1d4ed8', borderColor: '#fff', borderWidth: 1 }, + areaStyle: { color: 'rgba(29, 78, 216, 0.10)' }, data: props.points.map((p) => p.tokens), }, { diff --git a/src/UsageChart.vue b/src/UsageChart.vue index 1200e14..7b23ec1 100644 --- a/src/UsageChart.vue +++ b/src/UsageChart.vue @@ -24,7 +24,16 @@ const hasData = computed(() => props.points.length > 0) const option = computed(() => ({ animation: false, - grid: { top: 12, right: 16, bottom: 22, left: 52 }, + grid: { top: 30, right: 52, bottom: 22, left: 52 }, + legend: { + data: ['Token', '成本'], + top: 2, + right: 6, + itemWidth: 10, + itemHeight: 10, + itemGap: 12, + textStyle: { color: '#5b6575', fontSize: 11 }, + }, tooltip: { trigger: 'axis', confine: true, @@ -51,32 +60,62 @@ const option = computed(() => ({ axisTick: { show: false }, axisLabel: { color: '#8b94a3', fontSize: 11, hideOverlap: true }, }, - yAxis: { - type: 'value', - splitLine: { lineStyle: { color: '#eef1f5' } }, - axisLabel: { - color: '#8b94a3', - fontSize: 11, - formatter: (v: number) => { - const abs = Math.abs(v) - if (abs >= 1e8) return `${Math.round(v / 1e8)}亿` - if (abs >= 1e4) return `${Math.round(v / 1e4)}万` - return String(v) + yAxis: [ + { + type: 'value', + splitLine: { lineStyle: { color: '#eef1f5' } }, + axisLabel: { + color: '#1d4ed8', + fontSize: 11, + formatter: (v: number) => { + const abs = Math.abs(v) + if (abs >= 1e8) return `${Math.round(v / 1e8)}亿` + if (abs >= 1e4) return `${Math.round(v / 1e4)}万` + return String(v) + }, }, }, - }, + { + type: 'value', + splitLine: { show: false }, + axisLabel: { + color: '#e56b1f', + fontSize: 11, + formatter: (v: number) => { + if (v === 0) return '0' + if (v < 0.01) return `$${v.toFixed(4)}` + if (v < 1) return `$${v.toFixed(2)}` + return `$${Math.round(v)}` + }, + }, + }, + ], series: [ { + name: 'Token', type: 'line', + yAxisIndex: 0, smooth: true, symbol: 'circle', symbolSize: 6, showSymbol: false, - lineStyle: { color: '#4a7ba6', width: 2 }, - itemStyle: { color: '#4a7ba6', borderColor: '#fff', borderWidth: 1 }, - areaStyle: { color: 'rgba(74, 123, 166, 0.10)' }, + lineStyle: { color: '#1d4ed8', width: 2 }, + itemStyle: { color: '#1d4ed8', borderColor: '#fff', borderWidth: 1 }, + areaStyle: { color: 'rgba(29, 78, 216, 0.10)' }, data: props.points.map((p) => ({ value: p.tokens, requests: p.requests, cost: p.cost })), }, + { + name: '成本', + type: 'line', + yAxisIndex: 1, + smooth: true, + symbol: 'circle', + symbolSize: 6, + showSymbol: false, + lineStyle: { color: '#e56b1f', width: 2 }, + itemStyle: { color: '#e56b1f', borderColor: '#fff', borderWidth: 1 }, + data: props.points.map((p) => p.cost), + }, ], })) diff --git a/src/api.ts b/src/api.ts index 7e001a4..bf7d612 100644 --- a/src/api.ts +++ b/src/api.ts @@ -140,6 +140,171 @@ export async function getAccountModels(id: number): Promise { return items.map((item) => item.id).filter(Boolean) } +/** + * 按分组级模型白名单过滤「可用模型」列表。 + * 忠实复刻 Sub2API backend/internal/service/group_model_allowlist.go 的 + * GroupModelAllowlist.FilterForListing —— 网关 /v1/models 列表接口用的就是同一套逻辑, + * 因此前端过滤结果与「网关看到什么 = 能调什么」完全一致: + * - 白名单未开启 → 原样返回 source(账户模型并集) + * - 开启但列表为空 → 返回空(管理端已禁止这种配置,这里兜底) + * - 精确条目:仅当 source 含该模型(精确 / 通配前缀 / Claude 归一化)时才列出 + * - 通配条目(gpt-*):展开为 source 中所有匹配项 + * source 应为某分组内所有账户 getAccountModels 结果的并集。 + */ +export function filterModelsByAllowlist( + source: string[], + allowlist?: { enabled: boolean; models?: string[] } | null, +): string[] { + if (!allowlist || !allowlist.enabled) return source + const entries = (allowlist.models || []).map((s) => s.trim()).filter(Boolean) + if (entries.length === 0) return [] + const patterns = source.map((s) => s.trim()).filter(Boolean) + if (patterns.length === 0) return [] + + const seen = new Set() + const out: string[] = [] + const add = (model: string) => { + const key = model.toLowerCase() + if (seen.has(key)) return + seen.add(key) + out.push(model) + } + + const sourceAllows = (model: string): boolean => { + const lower = model.toLowerCase() + for (const p of patterns) { + if (p.toLowerCase() === lower) return true + if (p.endsWith('*') && lower.startsWith(p.toLowerCase().slice(0, -1))) return true + } + // Claude 归一化(-thinking 后缀)后的精确匹配 + const normalized = normalizeClaudeModel(model.replace(/-thinking$/, '')) + if (normalized.toLowerCase() !== lower) { + for (const p of patterns) { + if (p.toLowerCase() === normalized.toLowerCase()) return true + } + } + return false + } + + for (const entry of entries) { + const e = entry.trim() + if (!e) continue + if (e.endsWith('*')) { + const prefix = e.toLowerCase().slice(0, -1) + for (const p of patterns) { + if (p.toLowerCase().startsWith(prefix)) add(p) + } + continue + } + if (sourceAllows(e)) add(e) + } + return out +} + +/** 去掉 Claude 模型名的 -thinking 后缀并做基础归一化(与 Sub2API claude.NormalizeModelID 行为对齐的精简版)。 */ +function normalizeClaudeModel(model: string): string { + return model.replace(/-thinking$/, '').trim() +} + +/** + * 账号是否开启 OpenAI 透传。透传只替换鉴权,模型语义完全交给上游, + * 因此即使残留非空 model_mapping 也必须放行(对应 Sub2API IsOpenAIPassthroughEnabled)。 + */ +function isPassthroughAccount(account: AdminAccount): boolean { + const extra = account?.extra || {} + return extra.openai_passthrough === true || extra.openai_oauth_passthrough === true +} + +/** 取出账号的 model_mapping(credentials.model_mapping,别名 → 上游模型)。没有则 null。 */ +function accountModelMapping(account: AdminAccount): Record | null { + const raw = account?.credentials?.model_mapping + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const out: Record = {} + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof value === 'string') out[key] = value + } + return Object.keys(out).length ? out : null +} + +/** 在 model_mapping 中解析模型:精确命中优先,其次末尾 `*` 通配(最长优先,同长取字典序小)。未命中返回 null。 */ +function resolveMappedModel(mapping: Record, model: string): string | null { + const exact = mapping[model] + if (exact !== undefined) return (exact || '').trim() + let best: { pattern: string; target: string } | null = null + for (const [pattern, target] of Object.entries(mapping)) { + if (!pattern.endsWith('*')) continue + if (!model.startsWith(pattern.slice(0, -1))) continue + if ( + !best || + pattern.length > best.pattern.length || + (pattern.length === best.pattern.length && pattern < best.pattern) + ) { + best = { pattern, target } + } + } + return best ? (best.target || '').trim() : null +} + +/** + * 按账号 model_mapping **投影**上游模型目录 —— 复刻网关 /v1/models 的 + * `projectAccountModelsBody`(backend/internal/service/openai_models_list.go:150)。 + * + * 背景:`/admin/accounts/{id}/models` 返回**上游渠道挂载的全量目录**( + * AccountTestService.FetchOpenAIAccountModels → FetchOpenAIModelsList),不做投影; + * 网关 /v1/models 会投影。二者不一致会带来两类错: + * 1. 「上游有、账号没配」的模型被列成可用(实际调用 404 model_not_found); + * 2. 别名式映射(deepseek-v4-flash => deepseek-v4-flash-0731)下,列出来的是上游名, + * 用户真正要配的别名反而看不到。 + * + * 投影规则(与网关逐条对齐): + * - 透传账号 / 未配置 model_mapping → 原样返回上游目录 + * - 候选 = 上游模型 ∪ model_mapping 的别名(去重、跳过含 `*` 的条目) + * - 逐条 `ResolveMappedModel(id)`:未命中映射 → 丢弃(这就是上游名不再出现的原因) + * - 命中后映射目标必须真的存在于上游目录,否则丢弃 + * - 输出 id(别名优先),而非映射后的上游名 + */ +export function projectAccountModels(account: AdminAccount, source: string[]): string[] { + const upstream = source.map((s) => (s || '').trim()).filter(Boolean) + if (upstream.length === 0) return [] + if (isPassthroughAccount(account)) return upstream + const mapping = accountModelMapping(account) + if (!mapping) return upstream + + const byId = new Set(upstream) + const seen = new Set() + const out: string[] = [] + const push = (raw: string) => { + const id = (raw || '').trim() + if (!id || id.includes('*') || seen.has(id)) return + const target = resolveMappedModel(mapping, id) + if (target == null || !byId.has(target)) return + seen.add(id) + out.push(id) + } + for (const model of upstream) push(model) + for (const alias of Object.keys(mapping)) push(alias) + return out +} + +/** + * 把「某个账号挂载的模型 + 该账号是否在线」合并进模型状态表。 + * + * 状态判定**必须是账号粒度**:模型 m 由账号 a 挂载,a 在线则 m 可用; + * 同一模型若还被其它账号挂载,只要其中任一在线即为可用。 + * 严禁按「分组内任一账号在线」把可用放大到分组的全部模型 —— 那会让 + * 只挂在故障/限流账号上的模型也被标成可用。 + */ +export function mergeAvailability( + map: Map, + models: string[], + up: boolean, +): void { + for (const name of models) { + if (up) map.set(name, true) + else if (!map.has(name)) map.set(name, false) + } +} + export async function getAccountAvailability() { const data = await request<{ account: Record }>('/admin/ops/account-availability') return data.account || {} diff --git a/src/format.ts b/src/format.ts index d9a9a75..9677caf 100644 --- a/src/format.ts +++ b/src/format.ts @@ -180,6 +180,23 @@ export function formatDateDay(value: string | null | undefined, now = new Date() return ymd.slice(0, 4) === todayISO(now).slice(0, 4) ? ymd.slice(5) : ymd } +/** + * 周期内首次/末次活跃时间短文案(来自趋势点的 key,已是上海时区本地时间)。 + * key 形如「YYYY-MM-DD HH:00」(小时粒度)或「YYYY-MM-DD」(天粒度)。 + * 同年省略年份:小时粒度显示「MM-DD HH:00」,天粒度显示「MM-DD」。 + */ +export function formatActiveAt(key: string | null | undefined): string { + if (!key) return '—' + const todayYear = todayISO().slice(0, 4) + if (key.includes(':')) { + const [datePart, timePart] = key.split(' ') + return datePart.slice(0, 4) === todayYear + ? `${datePart.slice(5)} ${timePart}` + : `${datePart} ${timePart}` + } + return key.slice(0, 4) === todayYear ? key.slice(5) : key +} + export function formatQuotaRecovery(value: string | null | undefined, now = new Date()): string { if (!value) return '' const date = new Date(value) @@ -220,6 +237,28 @@ export function formatQuotaRecovery(value: string | null | undefined, now = new return `${ymd.slice(5)} ${hm}` } +/** + * 滚动窗口额度恢复时间(完整版,上海时区)。 + * 始终带「年-月-日 时:分 (UTC+8)」,避免「周二 11:51」这类跨周歧义; + * 用于百炼等按周/按月重置、肉眼难判断具体日期的窗口。 + */ +export function formatResetAtFull(value: string | null | undefined): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value || '' + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} (UTC+8)` +} + function trimNum(n: number): string { const abs = Math.abs(n) const digits = abs >= 100 ? 1 : 2 diff --git a/src/groups/GroupsApp.vue b/src/groups/GroupsApp.vue index 0c1dd3a..61b4a1a 100644 --- a/src/groups/GroupsApp.vue +++ b/src/groups/GroupsApp.vue @@ -10,12 +10,15 @@ import type { Period, } from '../types' import { + filterModelsByAllowlist, getAccountAvailability, getAccountModels, getBailianSnapshot, getModels, listAccounts, listGroups, + mergeAvailability, + projectAccountModels, useBailianResetCard, } from '../api' import { @@ -24,6 +27,7 @@ import { formatCostExact, formatDateDay, formatQuotaRecovery, + formatResetAtFull, formatTokens, formatTokensExact, normalizeRange, @@ -208,11 +212,9 @@ async function confirmUseCard(card: BailianResetCard) { try { await useBailianResetCard(card.card_no) confirmingCardNo.value = null - const snap = await getBailianSnapshot() - if (snap) { - bailian.value = snap - updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) - } + // 用卡成功后服务器会立即重跑探针刷新快照,但静态文件写入有延迟, + // 这里轮询拉取快照直到额度真正回落,避免「点完重置显示空白 / 不刷新」。 + await pollBailianAfterReset() } catch (e) { cardActionError.value = e instanceof Error ? e.message : '用卡失败' } finally { @@ -220,6 +222,27 @@ async function confirmUseCard(card: BailianResetCard) { } } +/** 用卡后轮询快照:最多 ~16s(每 2s 一次),额度回落或重置卡减少即停 */ +const bailianRefreshing = ref(false) +async function pollBailianAfterReset() { + bailianRefreshing.value = true + try { + const beforeCards = bailian.value?.reset_cards?.length ?? 0 + for (let i = 0; i < 8; i++) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + const snap = await getBailianSnapshot() + if (!snap) continue + bailian.value = snap + updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) + const nowCards = snap.reset_cards?.length ?? 0 + const dropped = (snap.windows || []).some((w) => (w.used_percent ?? 0) < 100) + if (i > 0 && (nowCards < beforeCards || dropped)) break + } + } finally { + bailianRefreshing.value = false + } +} + function statusLabel(status: WindowAccount['status']): string { if (status === 'limited') return '限流中' if (status === 'online') return '在线' @@ -317,10 +340,31 @@ async function load() { ]) bailian.value = snapshot - // 1. 每个分组挂载的模型(分组内账号的模型并集)+ 可用状态 + // 1. 每个分组的可见模型 = 分组内账号模型并集,再按该分组 model_allowlist 过滤(与网关一致) const accounts = (accountPage.items || []).filter((a) => (a.group_ids || []).some((id) => wanted.has(id))) + // 每个账号的原始模型列表要留着:状态判定必须回到账号粒度 const accountModels = new Map() - await Promise.all(accounts.map(async (a) => accountModels.set(a.id, await getAccountModels(a.id)))) + const groupSource = new Map() + await Promise.all( + accounts.map(async (a) => { + // 上游全量目录 → 账号 model_mapping 投影(上游名换成别名) + const raw = projectAccountModels(a, await getAccountModels(a.id)) + accountModels.set(a.id, raw) + for (const gid of a.group_ids || []) { + if (!wanted.has(gid)) continue + if (!groupSource.has(gid)) groupSource.set(gid, []) + const arr = groupSource.get(gid)! + for (const m of raw) if (!arr.includes(m)) arr.push(m) + } + }), + ) + const groupVisibleModels = new Map() + const allowByGroup = new Map() + for (const g of groupList) { + if (!wanted.has(g.id)) continue + allowByGroup.set(g.id, g.model_allowlist ?? { enabled: false }) + groupVisibleModels.set(g.id, filterModelsByAllowlist(groupSource.get(g.id) || [], g.model_allowlist)) + } const isUp = (id: number) => availability[String(id)]?.is_available === true // groupId -> model -> 是否有可用账号挂载 @@ -331,17 +375,17 @@ async function load() { const orderedAccounts = [...accounts].sort((a, b) => a.id - b.id) for (const account of orderedAccounts) { const windowInfo = extractWindowAccount(account, availability) + const up = isUp(account.id) for (const gid of account.group_ids || []) { if (!wanted.has(gid)) continue if (!catalog.has(gid)) catalog.set(gid, new Map()) const statusMap = catalog.get(gid)! - for (const model of accountModels.get(account.id) || []) { - if (isUp(account.id)) statusMap.set(model, true) - else if (!statusMap.has(model)) statusMap.set(model, false) - } + // 只标注「该账号在白名单过滤后真正挂载」的模型,不用分组并集放大 + const visible = filterModelsByAllowlist(accountModels.get(account.id) || [], allowByGroup.get(gid)) + mergeAvailability(statusMap, visible, up) const counter = accountCount.get(gid) || { total: 0, online: 0 } counter.total += 1 - if (isUp(account.id)) counter.online += 1 + if (up) counter.online += 1 accountCount.set(gid, counter) if (windowInfo) { @@ -378,14 +422,15 @@ async function load() { const meta = metaById.get(gid) const modelStatus = catalog.get(gid) || new Map() const modelUsage = usage.get(gid) || new Map() - const models: GroupModel[] = [...modelStatus.keys()] - .map((model) => ({ - model, - available: modelStatus.get(model) === true, - requests: modelUsage.get(model)?.requests || 0, - totalTokens: modelUsage.get(model)?.tokens || 0, - cost: modelUsage.get(model)?.cost || 0, - })) + // 列表 = 分组白名单过滤后的账号模型并集(与网关一致); + // 状态 = 账号粒度判定(挂它的账号里至少一个在线才算可用) + const models: GroupModel[] = (groupVisibleModels.get(gid) || []).map((model) => ({ + model, + available: modelStatus.get(model) === true, + requests: modelUsage.get(model)?.requests || 0, + totalTokens: modelUsage.get(model)?.tokens || 0, + cost: modelUsage.get(model)?.cost || 0, + })) const counter = accountCount.get(gid) || { total: 0, online: 0 } return { id: gid, @@ -536,6 +581,7 @@ useAutoRefresh(load, { isLoading: () => loading.value })
滚动窗口限额
+ 额度刷新中…
确认重置当前窗口?不可撤销 @@ -574,7 +620,11 @@ useAutoRefresh(load, { isLoading: () => loading.value }) {{ w.label }} {{ Math.round(w.used_percent || 0) }}% {{ bailianCredits(w) }} - 额度恢复 {{ formatQuotaRecovery(w.reset_at) }} + 额度恢复 {{ formatResetAtFull(w.reset_at) }}
diff --git a/src/style.css b/src/style.css index da15ee3..6b07437 100644 --- a/src/style.css +++ b/src/style.css @@ -11,8 +11,8 @@ --faint: #8b94a3; --accent: #e56b1f; --accent-soft: #fff3ea; - /* Token 主题色:钢蓝,与成本橙互补,图表与进度条共用 */ - --token: #4a7ba6; + /* Token 主题色:深蓝,与成本橙互补,图表与进度条共用 */ + --token: #1d4ed8; --gold: #a97e0c; --silver: #64707f; --bronze: #b06428; @@ -356,7 +356,7 @@ table.rank > thead > tr > th { table.rank > thead > tr > th.right, table.rank > tbody > tr.user-row > td.right { - text-align: center; + text-align: right; white-space: nowrap; } @@ -396,7 +396,17 @@ table.rank tbody tr.user-row.open { } .right { - text-align: center; + text-align: right; +} + +/* 文本/标识列左对齐(覆盖主表默认居中):用户、时间等 */ +.left { + text-align: left; +} + +table.rank > thead > tr > th.left, +table.rank > tbody > tr.user-row > td.left { + text-align: left; } .rank-cell { @@ -464,11 +474,33 @@ table.rank tbody tr.user-row.open { .share-cell { display: flex; align-items: center; - justify-content: center; + justify-content: flex-end; gap: 8px; white-space: nowrap; } +/* 占比列左对齐(覆盖主表 .right 的右对齐):进度条在左、百分比紧随 */ +table.rank > thead > tr > th.share-col, +table.rank > tbody > tr.user-row > td.share-col { + text-align: left; +} + +.share-col .share-cell { + justify-content: flex-start; +} + +/* 请求列收窄(避免与其它 auto 列平分后过宽) */ +table.rank .col-requests { + width: 84px; +} + +/* 时间列左对齐(覆盖主表 .right 的右对齐) */ +table.rank > thead > tr > th.time-col, +table.rank > tbody > tr.user-row > td.time-col { + text-align: left; + white-space: nowrap; +} + .share-bar { width: 56px; flex: 0 0 56px; @@ -501,6 +533,12 @@ table.rank tbody tr.user-row.open { font-size: 12.5px; } +/* 输入输出合并列的分隔符 */ +.io-sep { + color: var(--faint); + margin: 0 4px; +} + /* ---------- expand panel ---------- */ .expand td { @@ -574,7 +612,16 @@ table.rank tbody tr.user-row.open { .model-name { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; - text-align: center; + text-align: left; + min-width: 0; + overflow-wrap: anywhere; +} + +/* 表头与单元格默认居中,模型名必须左对齐。 + 特异性要压过 `.model-table td`(0,1,1),单写 `.model-name`(0,1,0)会被覆盖。 */ +.model-table td.model-name, +.model-table th.model-name { + text-align: left; } .avail { @@ -746,6 +793,12 @@ table.rank tbody tr.user-row.open { margin-bottom: 0; } +.reset-refreshing { + font-size: 12px; + color: var(--accent); + font-weight: 600; +} + /* 重置卡入口默认隐藏,需控制台开关(见 GroupsApp.vue 注释)才显示 */ .reset-chip-row { display: none; diff --git a/src/types.ts b/src/types.ts index df132eb..e330f52 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,6 +81,10 @@ export interface RankedUser extends UserBreakdownItem { trendGranularity: TrendGranularity trendLoaded: boolean trendError: string | null + /** 本周期内首次有用量(请求/Token)的时间点 key,无则为 null */ + firstActive: string | null + /** 本周期内末次有用量(请求/Token)的时间点 key,无则为 null */ + lastActive: string | null } export interface AdminGroup { @@ -89,6 +93,8 @@ export interface AdminGroup { status: string rate_multiplier?: number model_pricing?: ModelPricing[] + /** 分组级模型白名单(与 Sub2API 网关准入同源):开启后列表与可调用的模型都受此约束。 */ + model_allowlist?: { enabled: boolean; models?: string[] } } /** 管理后台配置的模型计费单价(美元 / Token)。分组配置与内置计费目录共用此结构。 */ @@ -121,6 +127,11 @@ export interface AdminAccount { rate_limited_at?: string | null rate_limit_reset_at?: string | null extra?: Record | null + /** + * 账号凭证(已脱敏)。model_mapping 不在 Sub2API 敏感清单里,会原样回传, + * 是判定「这个账号能不能调这个模型」的唯一可靠依据。 + */ + credentials?: Record | null } export interface AccountModelItem {