mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
Merge origin/main into main
Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes. Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed. Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
@@ -1071,6 +1071,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRouteQuery } from '@/composables/useRouteQuery'
|
||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
getPoolOverview,
|
||||
@@ -1395,6 +1396,8 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
|
||||
|
||||
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
|
||||
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
|
||||
// 保存前发出的详情读取不得覆盖这次保存返回的新配置。
|
||||
providerDataRequestId += 1
|
||||
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
||||
if (selectedProviderData.value) {
|
||||
Object.assign(selectedProviderData.value, updatedProvider)
|
||||
@@ -1403,7 +1406,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
|
||||
}
|
||||
showSchedulingDialog.value = false
|
||||
showAdvancedDialog.value = false
|
||||
await loadOverview({ silent: true })
|
||||
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
|
||||
}
|
||||
|
||||
// --- Provider Selection ---
|
||||
@@ -1694,12 +1697,14 @@ watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
|
||||
|
||||
const showAccountQuotaColumn = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'claude_code'
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
|| selectedProviderType.value === 'xai'
|
||||
})
|
||||
|
||||
const desktopColumnWidths = computed(() => {
|
||||
@@ -2143,12 +2148,14 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
|
||||
|
||||
const quotaRefreshSupported = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'claude_code'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
|| selectedProviderType.value === 'xai'
|
||||
})
|
||||
|
||||
function canResetCycleStats(_key: PoolKeyDetail): boolean {
|
||||
@@ -2267,7 +2274,19 @@ function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string |
|
||||
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
|
||||
}
|
||||
|
||||
function getClaudeCodeResetCredits(key: PoolKeyDetail) {
|
||||
if (getQuotaSnapshotProviderType(key) !== 'claude_code') return null
|
||||
return key.status_snapshot?.quota?.reset_credits
|
||||
?? key.upstream_metadata?.claude_code?.reset_credits
|
||||
?? null
|
||||
}
|
||||
|
||||
function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
|
||||
const claudeCredits = getClaudeCodeResetCredits(key)
|
||||
if (claudeCredits) {
|
||||
const claudeCount = getCodexResetCreditAvailableCount(claudeCredits)
|
||||
return claudeCount === null ? null : formatCodexResetCreditCount(claudeCount)
|
||||
}
|
||||
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
|
||||
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
|
||||
? null
|
||||
@@ -2275,7 +2294,11 @@ function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
|
||||
}
|
||||
|
||||
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
|
||||
return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3)
|
||||
return getVisibleCodexResetCreditItems(
|
||||
getClaudeCodeResetCredits(key) ?? getCodexResetCredits(key),
|
||||
undefined,
|
||||
3,
|
||||
)
|
||||
.map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
|
||||
}
|
||||
|
||||
@@ -3480,8 +3503,10 @@ function normalizeQuotaLabel(label: string): string {
|
||||
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
|
||||
if (normalized.includes('5H')) return '5H'
|
||||
if (normalized.includes('周')) return '周'
|
||||
if (normalized.includes('月')) return '月'
|
||||
if (normalized.includes('最低剩余')) return '最低'
|
||||
if (normalized === '剩余' || normalized.includes('剩余')) return '剩余'
|
||||
if (normalized === '额度') return '额度'
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -3490,6 +3515,9 @@ function getQuotaProgressLabel(label: string): string {
|
||||
if (label === '5H') return '5H'
|
||||
if (label === '周') return '周'
|
||||
if (label === '月') return '月'
|
||||
if (label === '周额度') return '周'
|
||||
if (label === '月额度') return '月'
|
||||
if (label === '额度') return '额度'
|
||||
if (label === 'Spark5H') return 'Spark5H'
|
||||
if (label === 'Spark周') return 'Spark周'
|
||||
if (label === '最低') return '最低'
|
||||
@@ -3498,7 +3526,7 @@ function getQuotaProgressLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressCountdown(item: QuotaProgressItem) {
|
||||
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
|
||||
const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
|
||||
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
|
||||
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
|
||||
return getCodexResetCountdown(
|
||||
@@ -3567,6 +3595,7 @@ function getQuotaLabelOrder(label: string): number {
|
||||
if (label === 'Prompt') return 12
|
||||
if (label === 'Flex') return 13
|
||||
if (label === '剩余') return 14
|
||||
if (label === '额度') return 14
|
||||
if (label === '最低') return 15
|
||||
if (label === '生图') return 16
|
||||
if (label === '速率') return 17
|
||||
@@ -3758,7 +3787,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
.filter((item): item is QuotaProgressItem => item != null)
|
||||
}
|
||||
|
||||
if (providerType === 'kiro') {
|
||||
if (providerType === 'kiro' || providerType === 'xai') {
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
const window = getQuotaSnapshotWindow(quota, 'usage')
|
||||
@@ -3772,12 +3801,13 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
: undefined
|
||||
|
||||
return [{
|
||||
label: '剩余',
|
||||
label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'),
|
||||
remainingPercent,
|
||||
detail,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
allowDynamicReset: true,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -3891,6 +3921,33 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
.filter((item): item is QuotaProgressItem => item != null)
|
||||
}
|
||||
|
||||
if (providerType === 'claude_code') {
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
const windowPresentations: Record<string, { labelKey: MessageKey, sortOrder: number }> = {
|
||||
'5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 },
|
||||
weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 },
|
||||
weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 },
|
||||
weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 },
|
||||
}
|
||||
return (quota.windows ?? [])
|
||||
.map((window): QuotaProgressItem | null => {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return null
|
||||
const presentation = windowPresentations[String(window.code || '')]
|
||||
return {
|
||||
label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'),
|
||||
sortOrder: presentation?.sortOrder ?? 9,
|
||||
remainingPercent,
|
||||
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
allowDynamicReset: true,
|
||||
}
|
||||
})
|
||||
.filter((item): item is QuotaProgressItem => item != null)
|
||||
}
|
||||
|
||||
if (providerType === 'gemini_cli') {
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
|
||||
@@ -1,203 +1,221 @@
|
||||
<template>
|
||||
<main class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
|
||||
<OverviewToolbar
|
||||
:title="t('用户分析', 'User analysis')"
|
||||
:range="range"
|
||||
:preset="relativePreset"
|
||||
:show-range="false"
|
||||
:refresh-active="autoRefresh"
|
||||
:refresh-title="refreshTitle"
|
||||
presets-only
|
||||
@update:range="setRange"
|
||||
@refresh="toggleAutoRefresh"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting"
|
||||
@click="exportCsv('users', requestQuery)"
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="mb-4">
|
||||
<TabsTrigger value="accounts">
|
||||
{{ t('用户账目', 'User accounts') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="usage">
|
||||
{{ t('使用统计', 'Usage statistics') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="accounts"
|
||||
class="space-y-5"
|
||||
>
|
||||
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
|
||||
</Button>
|
||||
</OverviewToolbar>
|
||||
<OverviewStatus
|
||||
:error="error || exportError"
|
||||
@retry="refreshAll"
|
||||
/>
|
||||
<UserFinanceSummary
|
||||
:summary="data?.data.summary"
|
||||
:finance="data?.data.finance_summary"
|
||||
:user-count="data?.data.summary?.user_count"
|
||||
:active-user-count="data?.data.summary?.active_user_count"
|
||||
/>
|
||||
<UserReports :revision="revision" />
|
||||
<TableCard
|
||||
:title="t('用户账目', 'User accounts')"
|
||||
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
|
||||
class="relative min-w-0"
|
||||
data-user-accounts
|
||||
>
|
||||
<template #actions>
|
||||
<form
|
||||
class="flex w-full items-center gap-2 md:w-auto"
|
||||
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
|
||||
<OverviewToolbar
|
||||
:title="t('用户分析', 'User analysis')"
|
||||
:range="range"
|
||||
:preset="relativePreset"
|
||||
:show-range="false"
|
||||
:refresh-active="autoRefresh"
|
||||
:refresh-title="refreshTitle"
|
||||
presets-only
|
||||
@update:range="setRange"
|
||||
@refresh="toggleAutoRefresh"
|
||||
>
|
||||
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="t('搜索用户', 'Search users')"
|
||||
:aria-label="t('搜索用户', 'Search users')"
|
||||
class="h-8 w-full text-xs border-border/60 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 text-xs"
|
||||
size="sm"
|
||||
:disabled="exporting"
|
||||
@click="exportCsv('users', requestQuery)"
|
||||
>
|
||||
{{ t('查询', 'Search') }}
|
||||
<Download class="mr-2 h-4 w-4" />{{ t('导出用户报表', 'Export user report') }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
<p
|
||||
v-if="query.search"
|
||||
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
|
||||
>
|
||||
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
|
||||
</p>
|
||||
<div
|
||||
class="min-w-0"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<Table class="min-w-[1040px]">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-12 font-semibold">
|
||||
{{ t('用户', 'User') }}
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="h-12 font-semibold text-right"
|
||||
:column-key="column.key"
|
||||
:active-key="requestQuery.sort"
|
||||
:direction="requestQuery.order"
|
||||
default-direction="desc"
|
||||
align="right"
|
||||
@sort="sort"
|
||||
>
|
||||
{{ column.label }}
|
||||
</SortableTableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('充值', 'Recharges') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('余额', 'Balance') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('最近使用', 'Last used') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('记录', 'Records') }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="user in data?.data.items || []"
|
||||
:key="user.user_id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
|
||||
>
|
||||
<TableCell class="max-w-64 py-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="break-words hover:text-primary hover:underline"
|
||||
@click="accountUser = user"
|
||||
>
|
||||
{{ user.username }}
|
||||
</button>
|
||||
<span
|
||||
v-if="!user.is_active"
|
||||
class="ml-2 text-xs text-muted-foreground"
|
||||
>{{ t('停用', 'Disabled') }}</span>
|
||||
<p class="mt-0.5 break-words text-xs text-muted-foreground">
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
|
||||
:title="user.billable_amount?.value ?? ''"
|
||||
>
|
||||
{{ money(user.billable_amount) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-right text-xs tabular-nums">
|
||||
{{ count(user.request_count) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-right text-xs tabular-nums">
|
||||
{{ count(user.total_tokens) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
|
||||
{{ money(user.finance?.recharge_amount) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
|
||||
{{ money(user.finance?.wallet_balance) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
|
||||
{{ timestamp(user.last_used_at, range.timezone) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
|
||||
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
|
||||
@click="accountUser = user"
|
||||
>
|
||||
<ReceiptText class="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!data?.data.items.length">
|
||||
<TableCell
|
||||
colspan="8"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="data"
|
||||
:total="data.data.total"
|
||||
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
|
||||
:page-size="requestQuery.limit"
|
||||
:page-size-options="[25, 50, 100]"
|
||||
@update:current="changePage"
|
||||
@update:page-size="changePageSize"
|
||||
</OverviewToolbar>
|
||||
<OverviewStatus
|
||||
:error="error || exportError"
|
||||
@retry="refreshAll"
|
||||
/>
|
||||
</template>
|
||||
</TableCard>
|
||||
<UserAccountDrawer
|
||||
v-if="accountUser"
|
||||
:key="accountUser.user_id"
|
||||
:user="accountUser"
|
||||
:timezone="range.timezone"
|
||||
@close="accountUser = null"
|
||||
/>
|
||||
<UserFinanceSummary
|
||||
:summary="data?.data.summary"
|
||||
:finance="data?.data.finance_summary"
|
||||
:user-count="data?.data.summary?.user_count"
|
||||
:active-user-count="data?.data.summary?.active_user_count"
|
||||
/>
|
||||
<UserReports :revision="revision" />
|
||||
<TableCard
|
||||
:title="t('用户账目', 'User accounts')"
|
||||
:description="t('消费与到账按所选时间统计,余额为当前值', 'Consumption and credits follow the selected period; balances are current')"
|
||||
class="relative min-w-0"
|
||||
data-user-accounts
|
||||
>
|
||||
<template #actions>
|
||||
<form
|
||||
class="flex w-full items-center gap-2 md:w-auto"
|
||||
@submit.prevent="patch({ search: search.trim() || undefined, offset: undefined })"
|
||||
>
|
||||
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="t('搜索用户', 'Search users')"
|
||||
:aria-label="t('搜索用户', 'Search users')"
|
||||
class="h-8 w-full text-xs border-border/60 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 text-xs"
|
||||
>
|
||||
{{ t('查询', 'Search') }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
<p
|
||||
v-if="query.search"
|
||||
class="border-b border-border/60 px-4 py-3 text-xs text-muted-foreground sm:px-6"
|
||||
>
|
||||
{{ t('上方汇总与账目仅包含匹配的用户', 'The summary and accounts include matching users only') }}
|
||||
</p>
|
||||
<div
|
||||
class="min-w-0"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<Table class="min-w-[1040px]">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-12 font-semibold">
|
||||
{{ t('用户', 'User') }}
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="h-12 font-semibold text-right"
|
||||
:column-key="column.key"
|
||||
:active-key="requestQuery.sort"
|
||||
:direction="requestQuery.order"
|
||||
default-direction="desc"
|
||||
align="right"
|
||||
@sort="sort"
|
||||
>
|
||||
{{ column.label }}
|
||||
</SortableTableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('充值', 'Recharges') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('余额', 'Balance') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('最近使用', 'Last used') }}
|
||||
</TableHead>
|
||||
<TableHead class="h-12 font-semibold text-right">
|
||||
{{ t('记录', 'Records') }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="user in data?.data.items || []"
|
||||
:key="user.user_id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]"
|
||||
>
|
||||
<TableCell class="max-w-64 py-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="break-words hover:text-primary hover:underline"
|
||||
@click="accountUser = user"
|
||||
>
|
||||
{{ user.username }}
|
||||
</button>
|
||||
<span
|
||||
v-if="!user.is_active"
|
||||
class="ml-2 text-xs text-muted-foreground"
|
||||
>{{ t('停用', 'Disabled') }}</span>
|
||||
<p class="mt-0.5 break-words text-xs text-muted-foreground">
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="whitespace-nowrap py-4 text-right text-xs font-medium tabular-nums"
|
||||
:title="user.billable_amount?.value ?? ''"
|
||||
>
|
||||
{{ money(user.billable_amount) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-right text-xs tabular-nums">
|
||||
{{ count(user.request_count) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-right text-xs tabular-nums">
|
||||
{{ count(user.total_tokens) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
|
||||
{{ money(user.finance?.recharge_amount) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs tabular-nums">
|
||||
{{ money(user.finance?.wallet_balance) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs text-muted-foreground">
|
||||
{{ timestamp(user.last_used_at, range.timezone) }}
|
||||
</TableCell>
|
||||
<TableCell class="whitespace-nowrap py-4 text-right text-xs">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
|
||||
:aria-label="t('查看', 'View') + ' ' + user.username + ' ' + t('账目', 'account')"
|
||||
@click="accountUser = user"
|
||||
>
|
||||
<ReceiptText class="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!data?.data.items.length">
|
||||
<TableCell
|
||||
colspan="8"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载中', 'Loading') : error ? t('用户账目暂不可用', 'User accounts unavailable') : t('没有符合条件的用户', 'No matching users') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="data"
|
||||
:total="data.data.total"
|
||||
:current="Math.floor(requestQuery.offset / requestQuery.limit) + 1"
|
||||
:page-size="requestQuery.limit"
|
||||
:page-size-options="[25, 50, 100]"
|
||||
@update:current="changePage"
|
||||
@update:page-size="changePageSize"
|
||||
/>
|
||||
</template>
|
||||
</TableCard>
|
||||
<UserAccountDrawer
|
||||
v-if="accountUser"
|
||||
:key="accountUser.user_id"
|
||||
:user="accountUser"
|
||||
:timezone="range.timezone"
|
||||
@close="accountUser = null"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="usage">
|
||||
<UserUsageStats v-if="activeTab === 'usage'" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { Download, Search, ReceiptText } from 'lucide-vue-next'
|
||||
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import { Button, Input, Pagination, SortableTableHead, Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
|
||||
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
|
||||
@@ -210,6 +228,8 @@ import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
|
||||
import { useOverviewExport } from '@/features/overview/useOverviewExport'
|
||||
import { useOverviewI18n } from '@/features/overview/i18n'
|
||||
import { count, money, timestamp } from '@/features/overview/format'
|
||||
const UserUsageStats = defineAsyncComponent(() => import('@/features/overview/users/UserUsageStats.vue'))
|
||||
const activeTab = ref('accounts')
|
||||
const { t } = useOverviewI18n()
|
||||
const { query, range, relativePreset, refreshRange, setRange, patch } = useOverviewQuery('today', { rolling: true })
|
||||
const { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh } = useUserAnalysisRefresh(refreshRange)
|
||||
|
||||
@@ -340,12 +340,24 @@ vi.mock('@/components/ui/refresh-button.vue', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/features/pool/components/PoolSchedulingDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'PoolSchedulingDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
props: {
|
||||
providerId: { type: String, required: true },
|
||||
currentConfig: { type: Object, default: null },
|
||||
},
|
||||
emits: ['saved'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', {
|
||||
'data-testid': 'save-pool-scheduling',
|
||||
'data-reserve': String(props.currentConfig?.reserve_minimum_quota ?? false),
|
||||
onClick: () => emit('saved', {
|
||||
id: props.providerId,
|
||||
pool_advanced: { ...props.currentConfig, reserve_minimum_quota: false },
|
||||
}),
|
||||
}, '保存调度测试')
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1278,4 +1290,80 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
|
||||
expect(disabledRoot.querySelector('[data-testid="pool-demand-metrics-button"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reloads account scheduling state immediately after disabling minimum quota reserve', async () => {
|
||||
const key = createPoolKey('codex', {
|
||||
scheduling_status: 'blocked',
|
||||
scheduling_reason: 'account_quota_exhausted',
|
||||
scheduling_label: '额度耗尽',
|
||||
})
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(key))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
|
||||
pool_advanced: { reserve_minimum_quota: true },
|
||||
}))
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
expect(root.querySelector('tbody')?.textContent).toContain('额度耗尽')
|
||||
endpointMocks.listPoolKeys.mockClear()
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage({
|
||||
...key,
|
||||
scheduling_status: 'available',
|
||||
scheduling_reason: 'available',
|
||||
scheduling_label: '可用',
|
||||
}))
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.listPoolKeys).toHaveBeenCalledWith(
|
||||
'codex-provider', expect.anything(), { cacheTtlMs: 0 },
|
||||
)
|
||||
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
|
||||
expect(root.querySelector('tbody')?.textContent).toContain('可用')
|
||||
})
|
||||
|
||||
it('shows 17% remaining without marking a schedulable account exhausted when reserve is enabled', async () => {
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
|
||||
pool_advanced: { reserve_minimum_quota: true },
|
||||
}))
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey('codex', {
|
||||
scheduling_status: 'available',
|
||||
scheduling_reason: 'available',
|
||||
scheduling_label: '可用',
|
||||
status_snapshot: {
|
||||
account: { code: 'ok', blocked: false },
|
||||
quota: {
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
provider_type: 'codex',
|
||||
windows: [{ code: '5h', used_ratio: 0.83, remaining_ratio: 0.17 }],
|
||||
},
|
||||
oauth: { code: 'none' },
|
||||
},
|
||||
})))
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('17.0%')
|
||||
expect(root.querySelector('tbody')?.textContent).toContain('可用')
|
||||
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
|
||||
})
|
||||
|
||||
it('does not replace saved scheduling settings with an older provider detail response', async () => {
|
||||
let resolveOldProvider!: (provider: ReturnType<typeof createProvider>) => void
|
||||
endpointMocks.getProvider.mockReturnValue(new Promise((resolve) => { resolveOldProvider = resolve }))
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey()))
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
|
||||
await settle()
|
||||
resolveOldProvider(createProvider('codex', { pool_advanced: { reserve_minimum_quota: true } }))
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="save-pool-scheduling"]')?.getAttribute('data-reserve')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -527,6 +527,8 @@ const filteredRecords = computed(() => {
|
||||
records = records.filter(record => hasUsageFallback(record))
|
||||
} else if (filterStatus.value === 'has_retry') {
|
||||
records = records.filter(record => record.has_retry === true)
|
||||
} else if (filterStatus.value === 'has_skipped_candidate') {
|
||||
records = records.filter(record => record.has_skipped_candidate === true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +737,10 @@ async function pollActiveRequests() {
|
||||
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
|
||||
? update.actual_service_tier
|
||||
: null
|
||||
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
|
||||
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
|
||||
? update.response_model
|
||||
: null
|
||||
// 管理员接口返回额外字段
|
||||
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
|
||||
if ('provider' in update && typeof update.provider === 'string') {
|
||||
@@ -1307,6 +1313,7 @@ function handleDetailRequestState(update: {
|
||||
reasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
actualServiceTier?: string | null
|
||||
responseModel?: string | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
updatedAt?: string | null
|
||||
@@ -1449,6 +1456,11 @@ function handleDetailRequestState(update: {
|
||||
? update.actualServiceTier
|
||||
: null
|
||||
}
|
||||
if ('responseModel' in update) {
|
||||
record.response_model = typeof update.responseModel === 'string'
|
||||
? update.responseModel
|
||||
: null
|
||||
}
|
||||
if ('imageProgress' in update) {
|
||||
const nextProgress = update.imageProgress ?? null
|
||||
if (!sameImageProgress(record.image_progress, nextProgress)) {
|
||||
|
||||
Reference in New Issue
Block a user