修正可用模型列表口径,并支持复制模型名

可用模型列表此前直接用 /admin/accounts/{id}/models 的返回,那是上游渠道挂载的
全量目录,未经账号 model_mapping 投影,导致「上游有但账号没配」的模型(qwen3.6-flash、
qwen3.7-max)被列成可用,而实际调用返回 404 model_not_found;别名式映射
(deepseek-v4-flash => deepseek-v4-flash-0731)还会把上游名列出来、别名反而不可用。

- 新增 projectAccountModels:复刻网关 /v1/models 的 projectAccountModelsBody,
  候选为「上游模型 ∪ model_mapping 别名」,逐条解析映射(精确优先、末尾 * 通配最长优先),
  未命中即丢弃,命中后校验映射目标确实存在于上游目录,最终输出别名而非上游名
- 过滤链统一为:上游全量 → model_mapping 投影 → 分组 model_allowlist → 账号粒度状态
- 状态判定回到账号粒度(挂它的账号里至少一个在线才算可用),不再按分组任一账号在线放大
- filterModelsByAllowlist 复刻 Sub2API GroupModelAllowlist.FilterForListing
- AdminAccount 补 credentials(model_mapping 不在脱敏清单内,可安全读取),
  移除三个 Sub2API 并不存在的 models/model_whitelist/allowed_models 字段

可用模型弹窗:模型名支持点击复制(带 1.5s 对勾反馈,非安全上下文回退 execCommand),
并左对齐——.model-table td 的居中特异性高于 .model-name,需用 td.model-name 覆盖。
This commit is contained in:
2026-09-15 14:53:51 +08:00
parent 7c6a5cff9e
commit 853dc39f3a
9 changed files with 525 additions and 84 deletions

3
.gitignore vendored
View File

@@ -16,3 +16,6 @@ bailian.json.state.json
.tmp-bailian/ .tmp-bailian/
# 本地联调用的快照替身:防止把过期样例数据打进 dist # 本地联调用的快照替身:防止把过期样例数据打进 dist
public/data/ public/data/
# 本地工作台数据(记忆、临时脚本与部署日志,不进版本库)
.workbuddy/

View File

@@ -13,21 +13,27 @@ import type {
} from './types' } from './types'
import UsageChart from './UsageChart.vue' import UsageChart from './UsageChart.vue'
import TotalTrendChart from './TotalTrendChart.vue' import TotalTrendChart from './TotalTrendChart.vue'
import { GROUP_IDS } from './groups.config'
import { import {
cacheTokensOf, cacheTokensOf,
filterModelsByAllowlist,
getAccountAvailability, getAccountAvailability,
getAccountModels, getAccountModels,
getModels, getModels,
getUsageTrend, getUsageTrend,
getUserBreakdown, getUserBreakdown,
listAccounts, listAccounts,
listGroups,
listUsers, listUsers,
mergeAvailability,
projectAccountModels,
} from './api' } from './api'
import { useModelPricing } from './useModelPricing' import { useModelPricing } from './useModelPricing'
import { markSort, nextSort, sortModelsBy } from './modelSort' import { markSort, nextSort, sortModelsBy } from './modelSort'
import type { ModelSortKey, ModelSortableItem } from './modelSort' import type { ModelSortKey, ModelSortableItem } from './modelSort'
import { import {
fillTrend, fillTrend,
formatActiveAt,
formatCount, formatCount,
formatCost, formatCost,
formatCostExact, formatCostExact,
@@ -193,6 +199,29 @@ function costShare(user: RankedUser): number {
return ((user.actual_cost || 0) / totalCost.value) * 100 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, 'custom'>) { function applyPreset(next: Exclude<Period, 'custom'>) {
period.value = next period.value = next
const preset = rangeFor(next) const preset = rangeFor(next)
@@ -227,11 +256,26 @@ function statusLabel(status: AvailabilityStatus) {
return status === 'available' ? '可用' : '不可用' return status === 'available' ? '可用' : '不可用'
} }
function mergeAvailability(map: Map<string, boolean>, models: string[], up: boolean) { /** 模型名复制:点击后短暂显示对勾(与分组页一致) */
for (const name of models) { const copiedModel = ref('')
if (up) map.set(name, true) async function copyModel(model: string) {
else if (!map.has(name)) map.set(name, false) 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[]) { function usageKeyMap(list: ModelStat[]) {
@@ -247,24 +291,44 @@ async function loadAvailability() {
availabilityLoading.value = true availabilityLoading.value = true
availabilityError.value = '' availabilityError.value = ''
try { try {
const [accountPage, availability] = await Promise.all([ const [accountPage, availability, groupList] = await Promise.all([
listAccounts(), listAccounts(),
getAccountAvailability(), getAccountAvailability(),
listGroups(),
]) ])
const accounts = accountPage.items || [] // 只统计「已开放分组」groups.config.ts 中配置的 GROUP_IDS账号挂载的模型
// 每个分组按 Sub2API 分组级模型白名单model_allowlist过滤与网关「看到什么=能调什么」一致。
const modelLists = await Promise.all( const wantedGroups = new Set<number>(GROUP_IDS as readonly number[])
accounts.map(async (account) => ({ const accounts = (accountPage.items || []).filter((a) =>
id: account.id, (a.group_ids || []).some((id) => wantedGroups.has(id)),
models: await getAccountModels(account.id),
})),
) )
const modelsByAccount = new Map(modelLists.map((item) => [item.id, item.models])) const allowByGroup = new Map<number, { enabled: boolean; models?: string[] }>()
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 isUp = (id: number) => availability[String(id)]?.is_available === true
// 逐个分组:每个账号的模型先按分组白名单过滤(与网关「能调什么」一致),
// 再按「该账号是否在线」标注状态。状态判定是账号粒度,不能用「分组任一账号
// 在线」把可用放大到全组模型,否则只挂在故障/限流账号上的模型也会被标成可用。
const statusMap = new Map<string, boolean>() const statusMap = new Map<string, boolean>()
for (const account of accounts) { for (const gid of wantedGroups) {
mergeAvailability(statusMap, modelsByAccount.get(account.id) || [], isUp(account.id)) 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()] groupCatalog.value = [...statusMap.keys()]
@@ -351,6 +415,8 @@ async function load() {
trendGranularity: trendGranularity(start, end), trendGranularity: trendGranularity(start, end),
trendLoaded: false, trendLoaded: false,
trendError: null, trendError: null,
firstActive: null,
lastActive: null,
}) })
} }
@@ -362,8 +428,17 @@ async function load() {
usageByModel.value = usageKeyMap(modelStats.models || []) usageByModel.value = usageKeyMap(modelStats.models || [])
updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) 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 if (seq !== loadSeq) return
for (const u of users.value) {
const { first, last } = computeActiveRange(u.trend)
u.firstActive = first
u.lastActive = last
}
} catch (err) { } catch (err) {
if (seq !== loadSeq) return if (seq !== loadSeq) return
error.value = err instanceof Error ? err.message : String(err) error.value = err instanceof Error ? err.message : String(err)
@@ -550,35 +625,29 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<col class="col-share hide-sm" /> <col class="col-share hide-sm" />
<col class="col-num" /> <col class="col-num" />
<col class="col-share hide-sm" /> <col class="col-share hide-sm" />
<col class="col-num" />
<col class="col-num 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-num hide-sm" /> <col class="col-num hide-sm" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th class="col-rank">#</th> <th class="col-rank">#</th>
<th>用户</th> <th class="left">用户</th>
<th class="sortable right cost-col" :class="{ active: sortBy === 'actual_cost' }" @click="setSort('actual_cost')"> <th class="sortable right cost-col" :class="{ active: sortBy === 'actual_cost' }" @click="setSort('actual_cost')">
成本 {{ sortMark('actual_cost') }} 成本 {{ sortMark('actual_cost') }}
</th> </th>
<th class="right hide-sm">成本占比</th> <th class="right hide-sm share-col">成本占比</th>
<th class="sortable right" :class="{ active: sortBy === 'total_tokens' }" @click="setSort('total_tokens')"> <th class="sortable right" :class="{ active: sortBy === 'total_tokens' }" @click="setSort('total_tokens')">
Token {{ sortMark('total_tokens') }} Token {{ sortMark('total_tokens') }}
</th> </th>
<th class="right hide-sm">Token 占比</th> <th class="right hide-sm share-col">Token 占比</th>
<th class="right hide-sm" title="输入 / 输出 Token悬停查看缓存">输入/输出</th>
<th class="sortable right" :class="{ active: sortBy === 'requests' }" @click="setSort('requests')"> <th class="sortable right" :class="{ active: sortBy === 'requests' }" @click="setSort('requests')">
请求 {{ sortMark('requests') }} 请求 {{ sortMark('requests') }}
</th> </th>
<th class="sortable right hide-sm" :class="{ active: sortBy === 'input_tokens' }" @click="setSort('input_tokens')"> <th class="time-col hide-sm" title="本周期内首次有请求 / Token 的时间">开始使用</th>
输入 {{ sortMark('input_tokens') }} <th class="time-col hide-sm" title="本周期内末次有请求 / Token 的时间">最后活跃</th>
</th>
<th class="sortable right hide-sm" :class="{ active: sortBy === 'output_tokens' }" @click="setSort('output_tokens')">
输出 {{ sortMark('output_tokens') }}
</th>
<th class="sortable right hide-sm" :class="{ active: sortBy === 'cache_tokens' }" @click="setSort('cache_tokens')">
缓存 {{ sortMark('cache_tokens') }}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -598,29 +667,31 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<span class="rank-badge" :class="rankClass(index)">{{ index + 1 }}</span> <span class="rank-badge" :class="rankClass(index)">{{ index + 1 }}</span>
</span> </span>
</td> </td>
<td> <td class="left">
<span class="user-name">{{ user.username }}</span> <span class="user-name">{{ user.username }}</span>
</td> </td>
<td class="right num cost-col" :title="formatCostExact(user.actual_cost)"> <td class="right num cost-col" :title="formatCostExact(user.actual_cost)">
{{ formatCost(user.actual_cost) }} {{ formatCost(user.actual_cost) }}
</td> </td>
<td class="right hide-sm"> <td class="right hide-sm share-col">
<div class="share-cell"> <div class="share-cell">
<span class="share-bar"><i class="cost" :style="{ width: `${Math.min(costShare(user), 100)}%` }" /></span> <span class="share-bar"><i class="cost" :style="{ width: `${Math.min(costShare(user), 100)}%` }" /></span>
<span class="num">{{ formatPercent(costShare(user)) }}</span> <span class="num">{{ formatPercent(costShare(user)) }}</span>
</div> </div>
</td> </td>
<td class="right num" :title="formatTokensExact(user.total_tokens)">{{ formatTokens(user.total_tokens) }}</td> <td class="right num" :title="formatTokensExact(user.total_tokens)">{{ formatTokens(user.total_tokens) }}</td>
<td class="right hide-sm"> <td class="right hide-sm share-col">
<div class="share-cell"> <div class="share-cell">
<span class="share-bar"><i :style="{ width: `${Math.min(user.share, 100)}%` }" /></span> <span class="share-bar"><i :style="{ width: `${Math.min(user.share, 100)}%` }" /></span>
<span class="num">{{ formatPercent(user.share) }}</span> <span class="num">{{ formatPercent(user.share) }}</span>
</div> </div>
</td> </td>
<td class="right 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="right num">{{ formatCount(user.requests) }}</td>
<td class="right num hide-sm" :title="formatTokensExact(user.input_tokens)">{{ formatTokens(user.input_tokens) }}</td> <td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ formatActiveAt(user.firstActive) }}</td>
<td class="right num hide-sm" :title="formatTokensExact(user.output_tokens)">{{ formatTokens(user.output_tokens) }}</td> <td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ formatActiveAt(user.lastActive) }}</td>
<td class="right num hide-sm" :title="formatTokensExact(user.cache_tokens)">{{ formatTokens(user.cache_tokens) }}</td>
</tr> </tr>
<tr v-if="expanded === user.user_id" class="expand"> <tr v-if="expanded === user.user_id" class="expand">
<td colspan="10"> <td colspan="10">
@@ -684,7 +755,7 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<div> <div>
<h2 id="model-dialog-title">可用模型</h2> <h2 id="model-dialog-title">可用模型</h2>
<p> <p>
所有账号实际挂载的模型 · 可用 {{ availableCount }} / {{ catalogModels.length }} 已开放分组groups.config.ts实际挂载的模型 · 可用 {{ availableCount }} / {{ catalogModels.length }}
<span v-if="availabilityError" class="error"> · {{ availabilityError }}</span> <span v-if="availabilityError" class="error"> · {{ availabilityError }}</span>
</p> </p>
</div> </div>
@@ -717,7 +788,17 @@ useAutoRefresh(load, { isLoading: () => loading.value })
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in visibleCatalog" :key="item.model"> <tr v-for="item in visibleCatalog" :key="item.model">
<td class="model-name">{{ item.model }}</td> <td class="model-name">
<button
type="button"
class="copy-model"
:title="`复制 ${item.model}`"
@click="copyModel(item.model)"
>
<span class="model-name">{{ item.model }}</span>
<span class="copy-icon">{{ copiedModel === item.model ? '✓' : '⧉' }}</span>
</button>
</td>
<td> <td>
<span class="avail" :class="{ on: item.status === 'available', off: item.status !== 'available' }"> <span class="avail" :class="{ on: item.status === 'available', off: item.status !== 'available' }">
{{ statusLabel(item.status) }} {{ statusLabel(item.status) }}

View File

@@ -178,7 +178,7 @@ const option = computed<echarts.EChartsCoreOption>(() => ({
type: 'value', type: 'value',
splitLine: { lineStyle: { color: '#eef1f5' } }, splitLine: { lineStyle: { color: '#eef1f5' } },
axisLabel: { axisLabel: {
color: '#4a7ba6', color: '#1d4ed8',
fontSize: 11, fontSize: 11,
formatter: (v: number) => { formatter: (v: number) => {
const abs = Math.abs(v) const abs = Math.abs(v)
@@ -213,9 +213,9 @@ const option = computed<echarts.EChartsCoreOption>(() => ({
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
showSymbol: false, showSymbol: false,
lineStyle: { color: '#4a7ba6', width: 2 }, lineStyle: { color: '#1d4ed8', width: 2 },
itemStyle: { color: '#4a7ba6', borderColor: '#fff', borderWidth: 1 }, itemStyle: { color: '#1d4ed8', borderColor: '#fff', borderWidth: 1 },
areaStyle: { color: 'rgba(74, 123, 166, 0.10)' }, areaStyle: { color: 'rgba(29, 78, 216, 0.10)' },
data: props.points.map((p) => p.tokens), data: props.points.map((p) => p.tokens),
}, },
{ {

View File

@@ -24,7 +24,16 @@ const hasData = computed(() => props.points.length > 0)
const option = computed<echarts.EChartsCoreOption>(() => ({ const option = computed<echarts.EChartsCoreOption>(() => ({
animation: false, 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: { tooltip: {
trigger: 'axis', trigger: 'axis',
confine: true, confine: true,
@@ -51,32 +60,62 @@ const option = computed<echarts.EChartsCoreOption>(() => ({
axisTick: { show: false }, axisTick: { show: false },
axisLabel: { color: '#8b94a3', fontSize: 11, hideOverlap: true }, axisLabel: { color: '#8b94a3', fontSize: 11, hideOverlap: true },
}, },
yAxis: { yAxis: [
type: 'value', {
splitLine: { lineStyle: { color: '#eef1f5' } }, type: 'value',
axisLabel: { splitLine: { lineStyle: { color: '#eef1f5' } },
color: '#8b94a3', axisLabel: {
fontSize: 11, color: '#1d4ed8',
formatter: (v: number) => { fontSize: 11,
const abs = Math.abs(v) formatter: (v: number) => {
if (abs >= 1e8) return `${Math.round(v / 1e8)}亿` const abs = Math.abs(v)
if (abs >= 1e4) return `${Math.round(v / 1e4)}` if (abs >= 1e8) return `${Math.round(v / 1e8)}亿`
return String(v) 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: [ series: [
{ {
name: 'Token',
type: 'line', type: 'line',
yAxisIndex: 0,
smooth: true, smooth: true,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
showSymbol: false, showSymbol: false,
lineStyle: { color: '#4a7ba6', width: 2 }, lineStyle: { color: '#1d4ed8', width: 2 },
itemStyle: { color: '#4a7ba6', borderColor: '#fff', borderWidth: 1 }, itemStyle: { color: '#1d4ed8', borderColor: '#fff', borderWidth: 1 },
areaStyle: { color: 'rgba(74, 123, 166, 0.10)' }, areaStyle: { color: 'rgba(29, 78, 216, 0.10)' },
data: props.points.map((p) => ({ value: p.tokens, requests: p.requests, cost: p.cost })), 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),
},
], ],
})) }))

View File

@@ -140,6 +140,171 @@ export async function getAccountModels(id: number): Promise<string[]> {
return items.map((item) => item.id).filter(Boolean) 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<string>()
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_mappingcredentials.model_mapping别名 → 上游模型)。没有则 null。 */
function accountModelMapping(account: AdminAccount): Record<string, string> | null {
const raw = account?.credentials?.model_mapping
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value === 'string') out[key] = value
}
return Object.keys(out).length ? out : null
}
/** 在 model_mapping 中解析模型:精确命中优先,其次末尾 `*` 通配(最长优先,同长取字典序小)。未命中返回 null。 */
function resolveMappedModel(mapping: Record<string, string>, 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<string>()
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<string, boolean>,
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() { export async function getAccountAvailability() {
const data = await request<{ account: Record<string, AccountAvailability> }>('/admin/ops/account-availability') const data = await request<{ account: Record<string, AccountAvailability> }>('/admin/ops/account-availability')
return data.account || {} return data.account || {}

View File

@@ -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 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 { export function formatQuotaRecovery(value: string | null | undefined, now = new Date()): string {
if (!value) return '' if (!value) return ''
const date = new Date(value) const date = new Date(value)
@@ -220,6 +237,28 @@ export function formatQuotaRecovery(value: string | null | undefined, now = new
return `${ymd.slice(5)} ${hm}` 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 { function trimNum(n: number): string {
const abs = Math.abs(n) const abs = Math.abs(n)
const digits = abs >= 100 ? 1 : 2 const digits = abs >= 100 ? 1 : 2

View File

@@ -10,12 +10,15 @@ import type {
Period, Period,
} from '../types' } from '../types'
import { import {
filterModelsByAllowlist,
getAccountAvailability, getAccountAvailability,
getAccountModels, getAccountModels,
getBailianSnapshot, getBailianSnapshot,
getModels, getModels,
listAccounts, listAccounts,
listGroups, listGroups,
mergeAvailability,
projectAccountModels,
useBailianResetCard, useBailianResetCard,
} from '../api' } from '../api'
import { import {
@@ -24,6 +27,7 @@ import {
formatCostExact, formatCostExact,
formatDateDay, formatDateDay,
formatQuotaRecovery, formatQuotaRecovery,
formatResetAtFull,
formatTokens, formatTokens,
formatTokensExact, formatTokensExact,
normalizeRange, normalizeRange,
@@ -208,11 +212,9 @@ async function confirmUseCard(card: BailianResetCard) {
try { try {
await useBailianResetCard(card.card_no) await useBailianResetCard(card.card_no)
confirmingCardNo.value = null confirmingCardNo.value = null
const snap = await getBailianSnapshot() // 用卡成功后服务器会立即重跑探针刷新快照,但静态文件写入有延迟,
if (snap) { // 这里轮询拉取快照直到额度真正回落,避免「点完重置显示空白 / 不刷新」。
bailian.value = snap await pollBailianAfterReset()
updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
}
} catch (e) { } catch (e) {
cardActionError.value = e instanceof Error ? e.message : '用卡失败' cardActionError.value = e instanceof Error ? e.message : '用卡失败'
} finally { } 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 { function statusLabel(status: WindowAccount['status']): string {
if (status === 'limited') return '限流中' if (status === 'limited') return '限流中'
if (status === 'online') return '在线' if (status === 'online') return '在线'
@@ -317,10 +340,31 @@ async function load() {
]) ])
bailian.value = snapshot bailian.value = snapshot
// 1. 每个分组挂载的模型(分组内账号模型并集+ 可用状态 // 1. 每个分组的可见模型 = 分组内账号模型并集,再按该分组 model_allowlist 过滤(与网关一致)
const accounts = (accountPage.items || []).filter((a) => (a.group_ids || []).some((id) => wanted.has(id))) const accounts = (accountPage.items || []).filter((a) => (a.group_ids || []).some((id) => wanted.has(id)))
// 每个账号的原始模型列表要留着:状态判定必须回到账号粒度
const accountModels = new Map<number, string[]>() const accountModels = new Map<number, string[]>()
await Promise.all(accounts.map(async (a) => accountModels.set(a.id, await getAccountModels(a.id)))) const groupSource = new Map<number, string[]>()
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<number, string[]>()
const allowByGroup = new Map<number, { enabled: boolean; models?: string[] }>()
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 const isUp = (id: number) => availability[String(id)]?.is_available === true
// groupId -> model -> 是否有可用账号挂载 // groupId -> model -> 是否有可用账号挂载
@@ -331,17 +375,17 @@ async function load() {
const orderedAccounts = [...accounts].sort((a, b) => a.id - b.id) const orderedAccounts = [...accounts].sort((a, b) => a.id - b.id)
for (const account of orderedAccounts) { for (const account of orderedAccounts) {
const windowInfo = extractWindowAccount(account, availability) const windowInfo = extractWindowAccount(account, availability)
const up = isUp(account.id)
for (const gid of account.group_ids || []) { for (const gid of account.group_ids || []) {
if (!wanted.has(gid)) continue if (!wanted.has(gid)) continue
if (!catalog.has(gid)) catalog.set(gid, new Map()) if (!catalog.has(gid)) catalog.set(gid, new Map())
const statusMap = catalog.get(gid)! const statusMap = catalog.get(gid)!
for (const model of accountModels.get(account.id) || []) { // 只标注「该账号在白名单过滤后真正挂载」的模型,不用分组并集放大
if (isUp(account.id)) statusMap.set(model, true) const visible = filterModelsByAllowlist(accountModels.get(account.id) || [], allowByGroup.get(gid))
else if (!statusMap.has(model)) statusMap.set(model, false) mergeAvailability(statusMap, visible, up)
}
const counter = accountCount.get(gid) || { total: 0, online: 0 } const counter = accountCount.get(gid) || { total: 0, online: 0 }
counter.total += 1 counter.total += 1
if (isUp(account.id)) counter.online += 1 if (up) counter.online += 1
accountCount.set(gid, counter) accountCount.set(gid, counter)
if (windowInfo) { if (windowInfo) {
@@ -378,14 +422,15 @@ async function load() {
const meta = metaById.get(gid) const meta = metaById.get(gid)
const modelStatus = catalog.get(gid) || new Map<string, boolean>() const modelStatus = catalog.get(gid) || new Map<string, boolean>()
const modelUsage = usage.get(gid) || new Map<string, { requests: number; tokens: number; cost: number }>() const modelUsage = usage.get(gid) || new Map<string, { requests: number; tokens: number; cost: number }>()
const models: GroupModel[] = [...modelStatus.keys()] // 列表 = 分组白名单过滤后的账号模型并集(与网关一致);
.map((model) => ({ // 状态 = 账号粒度判定(挂它的账号里至少一个在线才算可用)
model, const models: GroupModel[] = (groupVisibleModels.get(gid) || []).map((model) => ({
available: modelStatus.get(model) === true, model,
requests: modelUsage.get(model)?.requests || 0, available: modelStatus.get(model) === true,
totalTokens: modelUsage.get(model)?.tokens || 0, requests: modelUsage.get(model)?.requests || 0,
cost: modelUsage.get(model)?.cost || 0, totalTokens: modelUsage.get(model)?.tokens || 0,
})) cost: modelUsage.get(model)?.cost || 0,
}))
const counter = accountCount.get(gid) || { total: 0, online: 0 } const counter = accountCount.get(gid) || { total: 0, online: 0 }
return { return {
id: gid, id: gid,
@@ -536,6 +581,7 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<div v-if="bailianWindows.length" class="window-panel"> <div v-if="bailianWindows.length" class="window-panel">
<div class="window-panel-head"> <div class="window-panel-head">
<div class="window-panel-title">滚动窗口限额</div> <div class="window-panel-title">滚动窗口限额</div>
<span v-if="bailianRefreshing" class="reset-refreshing">额度刷新中</span>
<div v-if="earliestResetCard" class="reset-chip-row"> <div v-if="earliestResetCard" class="reset-chip-row">
<span v-if="confirmingCardNo === earliestResetCard.card_no" class="reset-confirm"> <span v-if="confirmingCardNo === earliestResetCard.card_no" class="reset-confirm">
<span class="reset-confirm-text">确认重置当前窗口不可撤销</span> <span class="reset-confirm-text">确认重置当前窗口不可撤销</span>
@@ -574,7 +620,11 @@ useAutoRefresh(load, { isLoading: () => loading.value })
<span class="window-label">{{ w.label }}</span> <span class="window-label">{{ w.label }}</span>
<span class="window-pct num" :class="meterTone(w.used_percent || 0)">{{ Math.round(w.used_percent || 0) }}%</span> <span class="window-pct num" :class="meterTone(w.used_percent || 0)">{{ Math.round(w.used_percent || 0) }}%</span>
<span v-if="bailianCredits(w)" class="window-recover" style="margin-left: 8px">{{ bailianCredits(w) }}</span> <span v-if="bailianCredits(w)" class="window-recover" style="margin-left: 8px">{{ bailianCredits(w) }}</span>
<span v-if="w.reset_at" class="window-recover">额度恢复 {{ formatQuotaRecovery(w.reset_at) }}</span> <span
v-if="w.reset_at"
class="window-recover"
:title="`百炼滚动窗口将于 ${formatResetAtFull(w.reset_at)} 重置刷新(上海时区)`"
>额度恢复 {{ formatResetAtFull(w.reset_at) }}</span>
</div> </div>
<div class="window-bar"> <div class="window-bar">
<i :class="meterTone(w.used_percent || 0)" :style="{ width: `${Math.max(0, Math.min(100, w.used_percent || 0))}%` }" /> <i :class="meterTone(w.used_percent || 0)" :style="{ width: `${Math.max(0, Math.min(100, w.used_percent || 0))}%` }" />

View File

@@ -11,8 +11,8 @@
--faint: #8b94a3; --faint: #8b94a3;
--accent: #e56b1f; --accent: #e56b1f;
--accent-soft: #fff3ea; --accent-soft: #fff3ea;
/* Token 主题色:蓝,与成本橙互补,图表与进度条共用 */ /* Token 主题色:蓝,与成本橙互补,图表与进度条共用 */
--token: #4a7ba6; --token: #1d4ed8;
--gold: #a97e0c; --gold: #a97e0c;
--silver: #64707f; --silver: #64707f;
--bronze: #b06428; --bronze: #b06428;
@@ -356,7 +356,7 @@ table.rank > thead > tr > th {
table.rank > thead > tr > th.right, table.rank > thead > tr > th.right,
table.rank > tbody > tr.user-row > td.right { table.rank > tbody > tr.user-row > td.right {
text-align: center; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@@ -396,7 +396,17 @@ table.rank tbody tr.user-row.open {
} }
.right { .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 { .rank-cell {
@@ -464,11 +474,33 @@ table.rank tbody tr.user-row.open {
.share-cell { .share-cell {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
gap: 8px; gap: 8px;
white-space: nowrap; 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 { .share-bar {
width: 56px; width: 56px;
flex: 0 0 56px; flex: 0 0 56px;
@@ -501,6 +533,12 @@ table.rank tbody tr.user-row.open {
font-size: 12.5px; font-size: 12.5px;
} }
/* 输入输出合并列的分隔符 */
.io-sep {
color: var(--faint);
margin: 0 4px;
}
/* ---------- expand panel ---------- */ /* ---------- expand panel ---------- */
.expand td { .expand td {
@@ -574,7 +612,16 @@ table.rank tbody tr.user-row.open {
.model-name { .model-name {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px; 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 { .avail {
@@ -746,6 +793,12 @@ table.rank tbody tr.user-row.open {
margin-bottom: 0; margin-bottom: 0;
} }
.reset-refreshing {
font-size: 12px;
color: var(--accent);
font-weight: 600;
}
/* 重置卡入口默认隐藏,需控制台开关(见 GroupsApp.vue 注释)才显示 */ /* 重置卡入口默认隐藏,需控制台开关(见 GroupsApp.vue 注释)才显示 */
.reset-chip-row { .reset-chip-row {
display: none; display: none;

View File

@@ -81,6 +81,10 @@ export interface RankedUser extends UserBreakdownItem {
trendGranularity: TrendGranularity trendGranularity: TrendGranularity
trendLoaded: boolean trendLoaded: boolean
trendError: string | null trendError: string | null
/** 本周期内首次有用量(请求/Token的时间点 key无则为 null */
firstActive: string | null
/** 本周期内末次有用量(请求/Token的时间点 key无则为 null */
lastActive: string | null
} }
export interface AdminGroup { export interface AdminGroup {
@@ -89,6 +93,8 @@ export interface AdminGroup {
status: string status: string
rate_multiplier?: number rate_multiplier?: number
model_pricing?: ModelPricing[] model_pricing?: ModelPricing[]
/** 分组级模型白名单(与 Sub2API 网关准入同源):开启后列表与可调用的模型都受此约束。 */
model_allowlist?: { enabled: boolean; models?: string[] }
} }
/** 管理后台配置的模型计费单价(美元 / Token。分组配置与内置计费目录共用此结构。 */ /** 管理后台配置的模型计费单价(美元 / Token。分组配置与内置计费目录共用此结构。 */
@@ -121,6 +127,11 @@ export interface AdminAccount {
rate_limited_at?: string | null rate_limited_at?: string | null
rate_limit_reset_at?: string | null rate_limit_reset_at?: string | null
extra?: Record<string, unknown> | null extra?: Record<string, unknown> | null
/**
* 账号凭证已脱敏。model_mapping 不在 Sub2API 敏感清单里,会原样回传,
* 是判定「这个账号能不能调这个模型」的唯一可靠依据。
*/
credentials?: Record<string, unknown> | null
} }
export interface AccountModelItem { export interface AccountModelItem {