修正可用模型列表口径,并支持复制模型名
可用模型列表此前直接用 /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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@ bailian.json.state.json
|
|||||||
.tmp-bailian/
|
.tmp-bailian/
|
||||||
# 本地联调用的快照替身:防止把过期样例数据打进 dist
|
# 本地联调用的快照替身:防止把过期样例数据打进 dist
|
||||||
public/data/
|
public/data/
|
||||||
|
|
||||||
|
# 本地工作台数据(记忆、临时脚本与部署日志,不进版本库)
|
||||||
|
.workbuddy/
|
||||||
|
|||||||
155
src/App.vue
155
src/App.vue
@@ -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) }}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
165
src/api.ts
165
src/api.ts
@@ -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_mapping(credentials.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 || {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))}%` }" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user