Merge origin/main into fix/gemini-cli-v1internal

This commit is contained in:
Mas0nShi
2026-05-28 11:58:00 +08:00
410 changed files with 38026 additions and 6621 deletions
+1 -21
View File
@@ -33,6 +33,7 @@ import {
} from '@/composables/useTTLAnalysis'
import { log } from '@/utils/logger'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
// ==================== 缓存统计与亲和性列表 ====================
@@ -264,27 +265,6 @@ function affinityModelSubtitle(item: UserAffinity): string {
return item.model_name
}
function formatClientFamily(family?: string | null): string {
switch (family) {
case 'codex':
return 'Codex'
case 'opencode':
return 'OpenCode'
case 'claude_code':
return 'Claude Code'
case 'openai_js_sdk':
return 'OpenAI JS SDK'
case 'generic':
return '通用'
case undefined:
case null:
case '':
return '未知'
default:
return family
}
}
function providerKeyLabel(item: UserAffinity): string {
if (item.key_name) return item.key_name
if (item.key_prefix === '[OAuth Token]') return 'OAuth 认证'
+2 -1
View File
@@ -111,6 +111,7 @@ import { adminApi, type CostForecastResponse, type CostSavingsResponse, type Lea
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import { normalizeUsageProviderStats } from '@/features/usage/utils/providerStats'
import type { DateRangeParams } from '@/features/usage/types'
import type { ProviderStatsItem } from '@/features/usage/types'
@@ -196,7 +197,7 @@ async function loadProviderStats() {
limit: 8
})
if (requestId !== providerStatsRequestId) return
providerStats.value = stats
providerStats.value = normalizeUsageProviderStats(stats)
}
async function loadApiKeyLeaderboard() {
+5 -5
View File
@@ -3720,7 +3720,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3']
const staticResetLabels = ['日', '5H', '周', 'Spark5H', '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(
@@ -4145,10 +4145,10 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
const remainingValue = typeof window?.remaining_value === 'number' ? window.remaining_value : null
const limitValue = typeof window?.limit_value === 'number' ? window.limit_value : null
const usedValue = typeof window?.used_value === 'number' ? window.used_value : null
const detail = usedValue != null && limitValue != null
? `${formatQuotaValue(usedValue)}/${formatQuotaValue(limitValue)}`
: remainingValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - remainingValue, 0))}/${formatQuotaValue(limitValue)}`
const detail = remainingValue != null && limitValue != null
? `${formatQuotaValue(remainingValue)}/${formatQuotaValue(limitValue)}`
: usedValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - usedValue, 0))}/${formatQuotaValue(limitValue)}`
: remainingValue != null
? `剩余 ${formatQuotaValue(remainingValue)}`
: undefined
+164 -7
View File
@@ -13,6 +13,14 @@
代理节点
</h3>
<div class="flex items-center gap-2">
<Button
size="sm"
variant="outline"
class="h-7 text-xs"
@click="showPoolProxyDistributionDialog = true"
>
均分
</Button>
<Button
size="sm"
variant="outline"
@@ -101,6 +109,15 @@
</Select>
</div>
<div class="h-4 w-px bg-border" />
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="showPoolProxyDistributionDialog = true"
>
<Shuffle class="w-3.5 h-3.5 mr-1.5" />
号池均分
</Button>
<Button
variant="outline"
size="sm"
@@ -524,14 +541,14 @@
<Dialog
:model-value="showAddDialog"
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动添加已有 HTTP/SOCKS 代理'"
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动或批量添加已有 HTTP/SOCKS 代理'"
:icon="editingNode ? SquarePen : Plus"
size="lg"
@update:model-value="handleDialogClose"
>
<div
v-if="!editingNode"
class="mb-4 grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
>
<Button
type="button"
@@ -551,6 +568,15 @@
<Plus class="w-3.5 h-3.5 mr-1.5" />
手动添加
</Button>
<Button
type="button"
:variant="addMode === 'batch' ? 'default' : 'ghost'"
class="h-9"
@click="addMode = 'batch'"
>
<ListPlus class="w-3.5 h-3.5 mr-1.5" />
批量添加
</Button>
</div>
<div
@@ -632,6 +658,53 @@
</div>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="space-y-4"
>
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
支持一行一个,或使用英文逗号分隔。URL 中的用户名和密码会自动拆分到手动添加接口,节点名称自动使用主机和端口。
</div>
<div class="space-y-1.5">
<Label>代理地址 *</Label>
<Textarea
v-model="batchForm.content"
class="min-h-[180px] font-mono text-xs break-all !rounded-xl"
placeholder="socks5://username:[email protected]:1080&#10;http://username:[email protected]:8080"
/>
</div>
<div
v-if="batchParseResult.errors.length"
class="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
>
<div class="font-medium">
有 {{ batchParseResult.errors.length }} 条格式错误
</div>
<ul class="mt-1 space-y-1">
<li
v-for="message in batchParseResult.errors.slice(0, 3)"
:key="message"
>
{{ message }}
</li>
</ul>
<div
v-if="batchParseResult.errors.length > 3"
class="mt-1 text-destructive/80"
>
还有 {{ batchParseResult.errors.length - 3 }} 条错误未显示
</div>
</div>
<p
v-else-if="batchForm.content.trim()"
class="text-xs text-muted-foreground"
>
已识别 {{ batchParseResult.nodes.length }} 个代理节点。
</p>
</div>
<form
v-else
class="space-y-4"
@@ -704,6 +777,28 @@
{{ installCopied ? '已复制' : '复制命令' }}
</Button>
</div>
<div
v-else-if="!editingNode && addMode === 'batch'"
class="flex items-center justify-between gap-3 w-full"
>
<span class="text-xs text-muted-foreground">
{{ batchForm.content.trim() ? `待添加 ${batchParseResult.nodes.length} 个` : '等待输入代理地址' }}
</span>
<div class="flex items-center gap-2">
<Button
variant="outline"
@click="handleDialogClose(false)"
>
取消
</Button>
<Button
:disabled="addingNode || !batchForm.content.trim() || batchParseResult.errors.length > 0 || batchParseResult.nodes.length === 0"
@click="handleBatchAddManualNodes"
>
{{ addingNode ? '添加中...' : '批量添加' }}
</Button>
</div>
</div>
<div
v-else
class="flex items-center justify-between w-full"
@@ -883,6 +978,10 @@
</template>
</Dialog>
<PoolProxyDistributionDialog
v-model="showPoolProxyDistributionDialog"
/>
<!-- 连接事件对话框 -->
<Dialog
:open="showEventsDialog"
@@ -995,13 +1094,17 @@ import {
Pagination,
RefreshButton,
Dialog,
Textarea,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle, ListPlus, Shuffle } from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { formatCompactNumber } from '@/utils/format'
import { formatRegion } from '@/utils/region'
import { parseBatchProxyNodeInput } from './proxy-node-batch'
import HardwareTooltip from './components/HardwareTooltip.vue'
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
import PoolProxyDistributionDialog from '@/features/pool/components/PoolProxyDistributionDialog.vue'
const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm()
@@ -1020,9 +1123,10 @@ const pageSize = ref(20)
// 手动添加/编辑对话框
const showAddDialog = ref(false)
const showPoolProxyDistributionDialog = ref(false)
const addingNode = ref(false)
const editingNode = ref<ProxyNode | null>(null)
const addMode = ref<'script' | 'manual'>('script')
const addMode = ref<'script' | 'manual' | 'batch'>('script')
const addForm = ref({
name: '',
proxy_url: '',
@@ -1030,6 +1134,9 @@ const addForm = ref({
password: '',
region: '',
})
const batchForm = ref({
content: '',
})
const installForm = ref({
node_name: '',
})
@@ -1053,6 +1160,8 @@ const proxyInstallHint = computed(() => {
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
})
const batchParseResult = computed(() => parseBatchProxyNodeInput(batchForm.value.content))
// 远程配置对话框 (aether-tunnel 节点)
const showConfigDialog = ref(false)
const savingConfig = ref(false)
@@ -1195,6 +1304,7 @@ function openAddDialog() {
editingNode.value = null
addMode.value = 'script'
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
batchForm.value = { content: '' }
installForm.value = { node_name: '' }
resetProxyInstallState()
showAddDialog.value = true
@@ -1253,6 +1363,7 @@ function handleDialogClose(open: boolean) {
editingNode.value = null
addMode.value = 'script'
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
batchForm.value = { content: '' }
installForm.value = { node_name: '' }
resetProxyInstallState()
}
@@ -1302,6 +1413,54 @@ async function handleAddManualNode() {
}
}
async function handleBatchAddManualNodes() {
const { nodes, errors } = batchParseResult.value
if (!batchForm.value.content.trim() || addingNode.value) return
if (errors.length > 0) {
toastError(`批量输入存在 ${errors.length} 条格式错误,请先修正后再添加`)
return
}
if (nodes.length === 0) {
toastError('请先输入至少一条代理地址')
return
}
addingNode.value = true
const failures: string[] = []
let successCount = 0
try {
for (const node of nodes) {
try {
await proxyNodesApi.createManualNode(node)
successCount += 1
} catch (err: unknown) {
failures.push(`${node.name}: ${parseApiError(err, '添加失败')}`)
}
}
await store.fetchNodes()
if (successCount > 0 && failures.length === 0) {
success(`已添加 ${successCount} 个代理节点`)
handleDialogClose(false)
return
}
if (successCount > 0) {
success(`已添加 ${successCount} 个代理节点,${failures.length} 个失败`)
}
if (failures.length > 0) {
toastError(failures.slice(0, 3).join(';'))
}
} catch (err: unknown) {
toastError(parseApiError(err, '批量添加失败'))
} finally {
addingNode.value = false
}
}
function handleConfig(node: ProxyNode) {
configNode.value = node
const rc: ProxyNodeRemoteConfig = node.remote_config ?? {}
@@ -1567,9 +1726,7 @@ function statusTitle(node: ProxyNode) {
}
function formatNumber(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
return formatCompactNumber(n, { fractionDigits: 1 })
}
function formatTime(iso: string | null) {
+19 -6
View File
@@ -8,13 +8,25 @@
description="管理系统级别的配置和参数"
/>
<div class="mt-6 space-y-6">
<div
class="mt-6 space-y-6 transition-opacity"
:class="{ 'pointer-events-none opacity-60': systemConfigLoading }"
:inert="systemConfigLoading"
:aria-busy="systemConfigLoading"
>
<div
v-if="systemConfigLoading"
class="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
>
系统配置加载中...
</div>
<!-- 站点信息 -->
<SiteInfoSection
id="section-site-info"
:site-name="systemConfig.site_name"
:site-subtitle="systemConfig.site_subtitle"
:loading="siteInfoLoading"
:loading="systemConfigLoading || siteInfoLoading"
:has-changes="hasSiteInfoChanges"
@save="saveSiteInfo"
@update:site-name="systemConfig.site_name = $event"
@@ -40,7 +52,7 @@
:proxy-node-id="systemConfig.system_proxy_node_id"
:online-nodes="proxyNodesStore.onlineNodes"
:all-nodes="proxyNodesStore.nodes"
:loading="proxyConfigLoading"
:loading="systemConfigLoading || proxyConfigLoading"
:has-changes="hasProxyConfigChanges"
@save="saveProxyConfig"
@update:proxy-node-id="systemConfig.system_proxy_node_id = $event"
@@ -70,7 +82,7 @@
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
:loading="basicConfigLoading"
:loading="systemConfigLoading || basicConfigLoading"
:has-changes="hasBasicConfigChanges"
@save="saveBasicConfig"
@update:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd = $event"
@@ -103,7 +115,7 @@
:max-request-body-size-k-b="maxRequestBodySizeKB"
:max-response-body-size-k-b="maxResponseBodySizeKB"
:sensitive-headers-str="sensitiveHeadersStr"
:loading="logConfigLoading"
:loading="systemConfigLoading || logConfigLoading"
:has-changes="hasLogConfigChanges"
@save="saveLogConfig"
@update:request-record-level="systemConfig.request_record_level = $event"
@@ -127,7 +139,7 @@
:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days"
:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days"
:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size"
:loading="cleanupConfigLoading"
:loading="systemConfigLoading || cleanupConfigLoading"
:has-changes="hasCleanupConfigChanges"
@save="saveCleanupConfig"
@toggle-auto-cleanup="handleAutoCleanupToggle"
@@ -332,6 +344,7 @@ function setupScrollSpy() {
const {
systemConfig,
systemVersion,
systemConfigLoading,
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
+77 -6
View File
@@ -41,8 +41,8 @@
</div>
</div>
<!-- 筛选器 -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-40 flex-1">
<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
id="users-search-mobile"
@@ -110,6 +110,22 @@
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="sortOption"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in userSortOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@@ -201,6 +217,23 @@
</SelectContent>
</Select>
<div class="xl:hidden">
<Select v-model="sortOption">
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in userSortOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
@@ -317,9 +350,17 @@
<TableHead class="w-[170px] h-12 font-semibold">
统计/限速
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
<SortableTableHead
class="w-[110px] h-12 font-semibold"
column-key="created_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
title="按创建时间排序"
@sort="handleTableSort"
>
创建时间
</TableHead>
</SortableTableHead>
<SortableTableHead
class="w-[180px] h-12 font-semibold"
column-key="status"
@@ -1470,7 +1511,18 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
import {
usersApi,
type User,
type ApiKey,
type UserSession,
type UserBatchActionResponse,
type UserBatchSelectionFilters,
type UserGroup,
type AdminUserPlanEntitlement,
type AdminUserSortBy,
type AdminUserSortOrder,
} from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
@@ -1598,6 +1650,7 @@ const searchQuery = ref('')
const filterRole = ref<'all' | User['role']>('all')
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
const filterGroup = ref('all')
const sortOption = ref<'default' | 'created_at_desc' | 'created_at_asc'>('default')
const userGroups = ref<UserGroup[]>([])
const userRoleFilterOptions = [
{ value: 'all', label: '全部角色' },
@@ -1610,6 +1663,17 @@ const userStatusFilterOptions = [
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '禁用' },
]
const userSortOptions = [
{ value: 'default', label: '默认排序' },
{ value: 'created_at_desc', label: '创建时间 新到旧' },
{ value: 'created_at_asc', label: '创建时间 旧到新' },
]
const sortBy = computed<AdminUserSortBy | null>(() =>
sortOption.value === 'default' ? null : 'created_at'
)
const sortOrder = computed<AdminUserSortOrder>(() =>
sortOption.value === 'created_at_asc' ? 'asc' : 'desc'
)
const currentPage = ref(1)
const pageSize = ref(20)
@@ -1659,7 +1723,7 @@ const grantableBillingPlans = computed(() =>
)
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
watch([searchQuery, filterRole, filterStatus, filterGroup, sortOption], () => {
currentPage.value = 1
resetBatchSelection()
void refreshUsers()
@@ -1691,6 +1755,8 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
role: filterRole.value === 'all' ? undefined : filterRole.value,
is_active: filterStatus.value === 'all' ? undefined : filterStatus.value === 'active',
group_id: filterGroup.value === 'all' ? undefined : filterGroup.value,
sort_by: sortBy.value ?? undefined,
sort_order: sortBy.value ? sortOrder.value : undefined,
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
}),
@@ -1701,6 +1767,11 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
})
}
function handleTableSort(payload: { key: string, direction: AdminUserSortOrder }): void {
if (payload.key !== 'created_at') return
sortOption.value = payload.direction === 'asc' ? 'created_at_asc' : 'created_at_desc'
}
function handlePageChange(page: number): void {
currentPage.value = page
void refreshUsers({ preferCache: true })
@@ -643,6 +643,47 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.querySelectorAll('button[title="查看评分计算结果"]').length).toBeGreaterThan(0)
})
it('shows ChatGPT Web image quota reset countdown above the quota bar', async () => {
const chatgptWebKey = createPoolKey('chatgpt_web', {
api_formats: ['openai:image'],
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'chatgpt_web',
updated_at: 1_700_000_000,
windows: [
{
code: 'image_gen',
label: '生图',
scope: 'account',
remaining_ratio: 0.96,
remaining_value: 24,
limit_value: 25,
reset_seconds: 3600,
},
],
},
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('chatgpt_web')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(chatgptWebKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('chatgpt_web', {
api_formats: ['openai:image'],
}))
const root = mountPoolManagement()
await settle()
const resetTexts = Array.from(root.querySelectorAll('[data-testid="pool-quota-reset-text"]'))
.map((element) => element.textContent?.trim())
.filter(Boolean)
expect(resetTexts).toContain('1h')
expect(root.textContent).toContain('生图')
})
it('opens only one score popover across desktop and mobile layouts', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { parseBatchProxyNodeInput } from '../proxy-node-batch'
describe('parseBatchProxyNodeInput', () => {
it('parses newline and comma separated proxy URLs into manual node payloads', () => {
const result = parseBatchProxyNodeInput([
'socks5://alice:[email protected]:1080',
'http://bob:[email protected]:8080, https://carol:p%[email protected]:8443',
].join('\n'))
expect(result.errors).toEqual([])
expect(result.nodes).toEqual([
{
name: '1.2.3.4:1080',
proxy_url: 'socks5://1.2.3.4:1080',
username: 'alice',
password: 'secret',
},
{
name: '5.6.7.8:8080',
proxy_url: 'http://5.6.7.8:8080',
username: 'bob',
password: 'pwd',
},
{
name: 'example.com:8443',
proxy_url: 'https://example.com:8443',
username: 'carol',
password: 'p@ss',
},
])
})
it('reports unsupported and invalid entries without dropping valid entries', () => {
const result = parseBatchProxyNodeInput('socks5://user:[email protected]:1080, ftp://user:[email protected]:21, nope')
expect(result.nodes).toEqual([
{
name: '127.0.0.1:1080',
proxy_url: 'socks5://127.0.0.1:1080',
username: 'user',
password: 'pass',
},
])
expect(result.errors).toHaveLength(2)
expect(result.errors[0]).toContain('仅支持 http/https/socks5/socks5h 协议')
expect(result.errors[1]).toContain('必须是合法的代理 URL')
})
})
@@ -3,6 +3,7 @@ import type { ProxyNode } from '@/api/proxy-nodes'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Cpu } from 'lucide-vue-next'
import { computed } from 'vue'
import { formatCompactNumber } from '@/utils/format'
const props = defineProps<{ node: ProxyNode }>()
@@ -104,9 +105,7 @@ function formatMemory(mb: number | null) {
}
function formatNumber(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
return formatCompactNumber(n, { fractionDigits: 1 })
}
function normalizeHardwareInfo(info: unknown): Record<string, unknown> | null {
@@ -234,6 +234,7 @@
import { computed } from 'vue'
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
import { Badge, Button } from '@/components/ui'
import { formatCompactNumber } from '@/utils/format'
import type {
ProxyNode,
ProxyNodeEvent,
@@ -456,9 +457,7 @@ function formatTunnelNumber(key: string) {
function formatNumber(value: number) {
if (!Number.isFinite(value)) return '-'
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return String(Math.round(value))
return formatCompactNumber(Math.round(value), { fractionDigits: 1 })
}
function formatOptionalNumber(value: number | null) {
@@ -42,6 +42,9 @@
<p class="max-w-3xl text-sm text-muted-foreground">
管理员只配置功能是否启用和匹配规则。用户、用户 Key、独立余额 Key 可在各自配置中附加此功能。
</p>
<p class="max-w-3xl text-xs text-muted-foreground">
当前支持 OpenAI Chat Completions、OpenAI Responses、Claude Messages;同格式转发和已支持的跨格式转换都会在发送给供应商前替换占位符。
</p>
</div>
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3">
<div class="text-right">
@@ -0,0 +1,484 @@
<template>
<PageContainer>
<PageHeader
title="S3 备份"
description="按导出范围备份到 S3-compatible 存储"
/>
<CardSection
title="备份配置"
description="配置自动备份周期、对象存储连接和保留策略"
class="mt-6"
>
<template #actions>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="backupRunDisabled"
@click="backup.runS3BackupNow"
>
<Play class="w-3.5 h-3.5 mr-1.5" />
{{ backup.running.value ? '提交中...' : '立即备份' }}
</Button>
<Button
size="sm"
:disabled="backup.saving.value || !backup.hasChanges.value"
@click="backup.saveS3BackupConfig"
>
<Save class="w-3.5 h-3.5 mr-1.5" />
{{ backup.saving.value ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="space-y-5">
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<CloudUpload class="w-4.5 h-4.5" />
</div>
<div class="min-w-0">
<h4 class="text-sm font-medium">
自动备份
</h4>
<p class="text-xs text-muted-foreground mt-0.5">
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
</p>
</div>
</div>
<Switch
:model-value="backup.config.value.enabled"
@update:model-value="backup.config.value.enabled = $event"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label
for="backup-scope"
class="block text-sm font-medium"
>
备份范围
</Label>
<Select
:model-value="backup.config.value.scope"
@update:model-value="setScope"
>
<SelectTrigger
id="backup-scope"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="config">
配置数据
</SelectItem>
<SelectItem value="users">
用户数据
</SelectItem>
<SelectItem value="data">
完整备份
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="backup-bucket"
class="block text-sm font-medium"
>
Bucket
</Label>
<Input
id="backup-bucket"
:model-value="backup.config.value.bucket"
class="mt-1"
placeholder="aether-backups"
@update:model-value="backup.config.value.bucket = String($event)"
/>
</div>
<div>
<Label
for="backup-endpoint"
class="block text-sm font-medium"
>
Endpoint
</Label>
<Input
id="backup-endpoint"
:model-value="backup.config.value.endpoint"
class="mt-1"
placeholder="https://s3.example.com"
@update:model-value="backup.config.value.endpoint = String($event)"
/>
</div>
<div>
<Label
for="backup-access-key"
class="block text-sm font-medium"
>
Access Key ID
</Label>
<Input
id="backup-access-key"
:model-value="backup.config.value.accessKeyId"
class="mt-1"
autocomplete="off"
@update:model-value="backup.config.value.accessKeyId = String($event)"
/>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<Label
for="backup-secret-key"
class="block text-sm font-medium"
>
Secret Access Key
</Label>
<button
v-if="backup.config.value.secretAccessKeyIsSet"
type="button"
class="text-xs text-muted-foreground hover:text-foreground"
:disabled="backup.saving.value"
@click="backup.clearS3SecretAccessKey"
>
清除
</button>
</div>
<div class="relative mt-1">
<Input
id="backup-secret-key"
:model-value="backup.config.value.secretAccessKey"
masked
disable-autofill
autocomplete="new-password"
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
@update:model-value="backup.config.value.secretAccessKey = String($event)"
/>
<KeyRound
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
/>
</div>
</div>
<div>
<Label
for="backup-retention"
class="block text-sm font-medium"
>
最多保留备份数
</Label>
<Input
id="backup-retention"
:model-value="backup.config.value.retentionCount"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('retentionCount', $event, 1)"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
<div>
<Label
for="backup-interval"
class="block text-sm font-medium"
>
周期间隔
</Label>
<Input
id="backup-interval"
:model-value="backup.config.value.scheduleInterval"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('scheduleInterval', $event, 1)"
/>
</div>
<div>
<Label
for="backup-unit"
class="block text-sm font-medium"
>
周期单位
</Label>
<Select
:model-value="backup.config.value.scheduleUnit"
@update:model-value="setScheduleUnit"
>
<SelectTrigger
id="backup-unit"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">
小时
</SelectItem>
<SelectItem value="days">
天
</SelectItem>
<SelectItem value="weeks">
周
</SelectItem>
<SelectItem value="months">
月
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Button
variant="ghost"
size="sm"
class="px-0"
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
>
<ChevronDown
v-if="backup.advancedOpen.value"
class="w-3.5 h-3.5 mr-1.5"
/>
<ChevronRight
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
高级选项
</Button>
<div
v-if="backup.advancedOpen.value"
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label
for="backup-region"
class="block text-sm font-medium"
>
Region
</Label>
<Input
id="backup-region"
:model-value="backup.config.value.region"
class="mt-1"
placeholder="auto"
@update:model-value="backup.config.value.region = String($event)"
/>
</div>
<div>
<Label
for="backup-prefix"
class="block text-sm font-medium"
>
Prefix
</Label>
<Input
id="backup-prefix"
:model-value="backup.config.value.prefix"
class="mt-1"
placeholder="aether/backups/"
@update:model-value="backup.config.value.prefix = String($event)"
/>
</div>
<div>
<Label
for="backup-hour"
class="block text-sm font-medium"
>
执行小时
</Label>
<Input
id="backup-hour"
:model-value="backup.config.value.scheduleHour"
type="number"
min="0"
max="23"
class="mt-1"
@update:model-value="setNumber('scheduleHour', $event, 0)"
/>
</div>
<div>
<Label
for="backup-minute"
class="block text-sm font-medium"
>
执行分钟
</Label>
<Input
id="backup-minute"
:model-value="backup.config.value.scheduleMinute"
type="number"
min="0"
max="59"
class="mt-1"
@update:model-value="setNumber('scheduleMinute', $event, 0)"
/>
</div>
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
<Label
for="backup-weekday"
class="block text-sm font-medium"
>
星期
</Label>
<Select
:model-value="String(backup.config.value.scheduleWeekday)"
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
>
<SelectTrigger
id="backup-weekday"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in weekdays"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="backup.config.value.scheduleUnit === 'months'">
<Label
for="backup-month-day"
class="block text-sm font-medium"
>
每月日期
</Label>
<Input
id="backup-month-day"
:model-value="backup.config.value.scheduleMonthDay"
type="number"
min="1"
max="31"
class="mt-1"
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
/>
</div>
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<span class="text-sm font-medium">Path Style</span>
<Switch
:model-value="backup.config.value.pathStyle"
@update:model-value="backup.config.value.pathStyle = $event"
/>
</div>
<div>
<Label
for="backup-compression"
class="block text-sm font-medium"
>
压缩格式
</Label>
<Select
:model-value="backup.config.value.compression"
@update:model-value="backup.config.value.compression = $event"
>
<SelectTrigger
id="backup-compression"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zstd">
zstd
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</CardSection>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
ChevronDown,
ChevronRight,
CloudUpload,
KeyRound,
Play,
Save,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import Switch from '@/components/ui/switch.vue'
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
import {
useS3BackupConfig,
type S3BackupConfig,
type S3BackupScheduleUnit,
} from './composables/useS3BackupConfig'
import type { S3BackupScope } from '@/api/admin'
const backup = useS3BackupConfig()
const backupRunDisabled = computed(() =>
backup.running.value ||
backup.saving.value ||
backup.loading.value ||
backup.hasChanges.value
)
const weekdays = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 7, label: '周日' },
]
function setScope(value: string) {
if (value === 'config' || value === 'users' || value === 'data') {
backup.config.value.scope = value as S3BackupScope
}
}
function setScheduleUnit(value: string) {
if (value === 'hours' || value === 'days' || value === 'weeks' || value === 'months') {
backup.config.value.scheduleUnit = value as S3BackupScheduleUnit
}
}
function setNumber(field: keyof Pick<
S3BackupConfig,
| 'retentionCount'
| 'scheduleInterval'
| 'scheduleMinute'
| 'scheduleHour'
| 'scheduleWeekday'
| 'scheduleMonthDay'
>, value: string | number, fallback: number) {
const next = Number(value)
backup.config.value[field] = Number.isFinite(next) ? next : fallback
}
onMounted(() => {
backup.loadS3BackupConfig()
})
</script>
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getSystemConfigMock, updateSystemConfigMock, runS3BackupMock, errorMock, successMock } =
vi.hoisted(() => ({
getSystemConfigMock: vi.fn(),
updateSystemConfigMock: vi.fn(),
runS3BackupMock: vi.fn(),
errorMock: vi.fn(),
successMock: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminApi: {
getSystemConfig: getSystemConfigMock,
updateSystemConfig: updateSystemConfigMock,
runS3Backup: runS3BackupMock,
},
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
error: errorMock,
success: successMock,
}),
}))
import { useS3BackupConfig } from '../composables/useS3BackupConfig'
describe('useS3BackupConfig', () => {
beforeEach(() => {
getSystemConfigMock.mockReset()
updateSystemConfigMock.mockReset()
runS3BackupMock.mockReset()
errorMock.mockReset()
successMock.mockReset()
})
it('loads write-only secret as configured without exposing the value', async () => {
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: true }
}
if (key === 'backup_s3_scope') {
return { key, value: 'data' }
}
return { key, value: null }
})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
expect(backup.config.value.scope).toBe('data')
expect(backup.config.value.secretAccessKey).toBe('')
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
})
it('keeps an existing secret when saving with an empty secret field', async () => {
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: true }
}
return { key, value: null }
})
updateSystemConfigMock.mockResolvedValue({})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
backup.config.value.bucket = 'aether-backups'
await backup.saveS3BackupConfig()
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
expect(savedKeys).toContain('backup_s3_bucket')
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
})
it('reloads server state when saving fails before writing a new secret', async () => {
let loadRound = 0
getSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_secret_access_key') {
return { key, value: null, is_set: false }
}
if (key === 'backup_s3_bucket') {
return { key, value: loadRound === 0 ? 'old-bucket' : 'server-bucket' }
}
return { key, value: null }
})
updateSystemConfigMock.mockImplementation(async (key: string) => {
if (key === 'backup_s3_bucket') {
loadRound += 1
throw new Error('save failed')
}
return {}
})
const backup = useS3BackupConfig()
await backup.loadS3BackupConfig()
backup.config.value.bucket = 'new-bucket'
backup.config.value.secretAccessKey = 'new-secret'
await backup.saveS3BackupConfig()
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
expect(savedKeys).toContain('backup_s3_bucket')
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
expect(backup.config.value.bucket).toBe('server-bucket')
expect(backup.config.value.secretAccessKey).toBe('')
})
})
@@ -0,0 +1,283 @@
import { computed, ref } from 'vue'
import { adminApi, type S3BackupScope } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
export type S3BackupScheduleUnit = 'hours' | 'days' | 'weeks' | 'months'
export interface S3BackupConfig {
enabled: boolean
scope: S3BackupScope
endpoint: string
region: string
bucket: string
prefix: string
accessKeyId: string
secretAccessKey: string
secretAccessKeyIsSet: boolean
pathStyle: boolean
compression: string
scheduleUnit: S3BackupScheduleUnit
scheduleInterval: number
scheduleMinute: number
scheduleHour: number
scheduleWeekday: number
scheduleMonthDay: number
retentionCount: number
}
type ConfigField = Exclude<keyof S3BackupConfig, 'secretAccessKeyIsSet'>
const CONFIG_KEY_BY_FIELD: Record<ConfigField, string> = {
enabled: 'backup_s3_enabled',
scope: 'backup_s3_scope',
endpoint: 'backup_s3_endpoint',
region: 'backup_s3_region',
bucket: 'backup_s3_bucket',
prefix: 'backup_s3_prefix',
accessKeyId: 'backup_s3_access_key_id',
secretAccessKey: 'backup_s3_secret_access_key',
pathStyle: 'backup_s3_path_style',
compression: 'backup_s3_compression',
scheduleUnit: 'backup_s3_schedule_unit',
scheduleInterval: 'backup_s3_schedule_interval',
scheduleMinute: 'backup_s3_schedule_minute',
scheduleHour: 'backup_s3_schedule_hour',
scheduleWeekday: 'backup_s3_schedule_weekday',
scheduleMonthDay: 'backup_s3_schedule_month_day',
retentionCount: 'backup_s3_retention_count',
}
const FIELD_BY_CONFIG_KEY = Object.fromEntries(
Object.entries(CONFIG_KEY_BY_FIELD).map(([field, key]) => [key, field])
) as Record<string, ConfigField>
const CONFIG_KEYS = Object.values(CONFIG_KEY_BY_FIELD)
const VALID_SCOPES: S3BackupScope[] = ['config', 'users', 'data']
const VALID_UNITS: S3BackupScheduleUnit[] = ['hours', 'days', 'weeks', 'months']
function defaultS3BackupConfig(): S3BackupConfig {
return {
enabled: false,
scope: 'data',
endpoint: '',
region: 'auto',
bucket: '',
prefix: 'aether/backups/',
accessKeyId: '',
secretAccessKey: '',
secretAccessKeyIsSet: false,
pathStyle: true,
compression: 'zstd',
scheduleUnit: 'days',
scheduleInterval: 1,
scheduleMinute: 0,
scheduleHour: 3,
scheduleWeekday: 1,
scheduleMonthDay: 1,
retentionCount: 7,
}
}
function stringValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function numberValue(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return fallback
}
function booleanValue(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value
if (value === 'true') return true
if (value === 'false') return false
return fallback
}
function scopeValue(value: unknown, fallback: S3BackupScope): S3BackupScope {
return typeof value === 'string' && VALID_SCOPES.includes(value as S3BackupScope)
? value as S3BackupScope
: fallback
}
function scheduleUnitValue(
value: unknown,
fallback: S3BackupScheduleUnit,
): S3BackupScheduleUnit {
return typeof value === 'string' && VALID_UNITS.includes(value as S3BackupScheduleUnit)
? value as S3BackupScheduleUnit
: fallback
}
function cloneConfig(value: S3BackupConfig): S3BackupConfig {
return JSON.parse(JSON.stringify(value)) as S3BackupConfig
}
export function useS3BackupConfig() {
const { success, error } = useToast()
const config = ref<S3BackupConfig>(defaultS3BackupConfig())
const originalConfig = ref<S3BackupConfig | null>(null)
const loading = ref(false)
const saving = ref(false)
const running = ref(false)
const advancedOpen = ref(false)
const hasChanges = computed(() => {
if (!originalConfig.value) return false
return JSON.stringify({
...config.value,
secretAccessKey: config.value.secretAccessKey.trim(),
}) !== JSON.stringify({
...originalConfig.value,
secretAccessKey: '',
})
})
async function loadS3BackupConfig() {
loading.value = true
try {
const next = defaultS3BackupConfig()
await Promise.all(CONFIG_KEYS.map(async (key) => {
try {
const response = await adminApi.getSystemConfig(key)
const field = FIELD_BY_CONFIG_KEY[key]
if (field === 'secretAccessKey') {
next.secretAccessKey = ''
next.secretAccessKeyIsSet = !!response.is_set
return
}
if (response.value === null || response.value === undefined) return
if (field === 'enabled' || field === 'pathStyle') {
next[field] = booleanValue(response.value, next[field])
} else if (
field === 'scheduleInterval' ||
field === 'scheduleMinute' ||
field === 'scheduleHour' ||
field === 'scheduleWeekday' ||
field === 'scheduleMonthDay' ||
field === 'retentionCount'
) {
next[field] = numberValue(response.value, next[field])
} else if (field === 'scope') {
next.scope = scopeValue(response.value, next.scope)
} else if (field === 'scheduleUnit') {
next.scheduleUnit = scheduleUnitValue(response.value, next.scheduleUnit)
} else {
next[field] = stringValue(response.value, next[field] as string)
}
} catch {
// 单个配置缺失时使用默认值
}
}))
config.value = next
originalConfig.value = cloneConfig(next)
} catch (err) {
error('加载 S3 备份配置失败')
log.error('加载 S3 备份配置失败:', err)
} finally {
loading.value = false
}
}
async function saveS3BackupConfig() {
saving.value = true
try {
const entries: Array<{ key: string; value: unknown; description: string }> = [
{ key: 'backup_s3_enabled', value: config.value.enabled, description: 'S3 自动备份开关' },
{ key: 'backup_s3_scope', value: config.value.scope, description: 'S3 备份范围' },
{ key: 'backup_s3_endpoint', value: config.value.endpoint.trim() || null, description: 'S3 Endpoint' },
{ key: 'backup_s3_region', value: config.value.region.trim() || 'auto', description: 'S3 Region' },
{ key: 'backup_s3_bucket', value: config.value.bucket.trim() || null, description: 'S3 Bucket' },
{ key: 'backup_s3_prefix', value: config.value.prefix.trim() || 'aether/backups/', description: 'S3 备份前缀' },
{ key: 'backup_s3_access_key_id', value: config.value.accessKeyId.trim() || null, description: 'S3 Access Key ID' },
{ key: 'backup_s3_path_style', value: config.value.pathStyle, description: 'S3 Path Style' },
{ key: 'backup_s3_compression', value: config.value.compression || 'zstd', description: 'S3 备份压缩格式' },
{ key: 'backup_s3_schedule_unit', value: config.value.scheduleUnit, description: 'S3 备份周期单位' },
{ key: 'backup_s3_schedule_interval', value: Math.max(1, Math.floor(config.value.scheduleInterval || 1)), description: 'S3 备份周期间隔' },
{ key: 'backup_s3_schedule_minute', value: Math.min(59, Math.max(0, Math.floor(config.value.scheduleMinute || 0))), description: 'S3 备份分钟' },
{ key: 'backup_s3_schedule_hour', value: Math.min(23, Math.max(0, Math.floor(config.value.scheduleHour || 0))), description: 'S3 备份小时' },
{ key: 'backup_s3_schedule_weekday', value: Math.min(7, Math.max(1, Math.floor(config.value.scheduleWeekday || 1))), description: 'S3 备份星期' },
{ key: 'backup_s3_schedule_month_day', value: Math.min(31, Math.max(1, Math.floor(config.value.scheduleMonthDay || 1))), description: 'S3 备份月日' },
{ key: 'backup_s3_retention_count', value: Math.max(1, Math.floor(config.value.retentionCount || 1)), description: 'S3 备份保留份数' },
]
const secret = config.value.secretAccessKey.trim()
if (secret) {
entries.push({
key: 'backup_s3_secret_access_key',
value: secret,
description: 'S3 Secret Access Key',
})
}
for (const item of entries) {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
}
if (secret) {
config.value.secretAccessKey = ''
config.value.secretAccessKeyIsSet = true
}
originalConfig.value = cloneConfig(config.value)
success('S3 备份配置已保存')
} catch (err) {
error(parseApiError(err, '保存 S3 备份配置失败'))
log.error('保存 S3 备份配置失败:', err)
await loadS3BackupConfig()
} finally {
saving.value = false
}
}
async function clearS3SecretAccessKey() {
saving.value = true
try {
await adminApi.updateSystemConfig(
'backup_s3_secret_access_key',
'',
'S3 Secret Access Key',
)
config.value.secretAccessKey = ''
config.value.secretAccessKeyIsSet = false
if (originalConfig.value) {
originalConfig.value.secretAccessKey = ''
originalConfig.value.secretAccessKeyIsSet = false
}
success('S3 访问密钥已清除')
} catch (err) {
error(parseApiError(err, '清除 S3 访问密钥失败'))
log.error('清除 S3 访问密钥失败:', err)
} finally {
saving.value = false
}
}
async function runS3BackupNow() {
running.value = true
try {
const response = await adminApi.runS3Backup()
success(response.message || 'S3 备份任务已提交')
} catch (err) {
error(parseApiError(err, '提交 S3 备份任务失败'))
log.error('提交 S3 备份任务失败:', err)
} finally {
running.value = false
}
}
return {
config,
loading,
saving,
running,
advancedOpen,
hasChanges,
loadS3BackupConfig,
saveS3BackupConfig,
clearS3SecretAccessKey,
runS3BackupNow,
}
}
@@ -0,0 +1,91 @@
export interface BatchManualProxyNode {
name: string
proxy_url: string
username?: string
password?: string
}
export interface BatchProxyNodeParseResult {
nodes: BatchManualProxyNode[]
errors: string[]
}
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks5:', 'socks5h:'])
export function parseBatchProxyNodeInput(input: string): BatchProxyNodeParseResult {
const entries = input
.split(/[\n,]+/)
.map(entry => entry.trim())
.filter(Boolean)
const nodes: BatchManualProxyNode[] = []
const errors: string[] = []
entries.forEach((entry, index) => {
try {
nodes.push(parseBatchProxyNodeEntry(entry))
} catch (err) {
const message = err instanceof Error ? err.message : '格式不正确'
errors.push(`第 ${index + 1} 条 ${entry}: ${message}`)
}
})
return { nodes, errors }
}
function parseBatchProxyNodeEntry(entry: string): BatchManualProxyNode {
let url: URL
try {
url = new URL(entry)
} catch {
throw new Error('必须是合法的代理 URL')
}
if (!SUPPORTED_PROXY_PROTOCOLS.has(url.protocol)) {
throw new Error('仅支持 http/https/socks5/socks5h 协议')
}
const host = url.hostname.trim()
if (!host) {
throw new Error('缺少主机地址')
}
const username = decodeUrlCredential(url.username, '用户名')
const password = decodeUrlCredential(url.password, '密码')
if (!username && password) {
throw new Error('包含密码时必须同时包含用户名')
}
const port = url.port || defaultProxyPort(url.protocol)
const proxyHost = url.host || `${host}:${port}`
const proxyUrl = `${url.protocol}//${proxyHost}`
return {
name: `${formatNodeNameHost(host)}:${port}`,
proxy_url: proxyUrl,
username: username || undefined,
password: password || undefined,
}
}
function defaultProxyPort(protocol: string): string {
if (protocol === 'https:') return '443'
if (protocol === 'socks5:' || protocol === 'socks5h:') return '1080'
return '80'
}
function decodeUrlCredential(value: string, field: string): string {
if (!value) return ''
try {
return decodeURIComponent(value)
} catch {
throw new Error(`${field}包含无效的 URL 编码`)
}
}
function formatNodeNameHost(host: string): string {
if (host.includes(':') && !host.startsWith('[')) {
return `[${host}]`
}
return host
}
@@ -42,6 +42,9 @@
<li v-if="aggregateImportPreview.user_data.standalone_keys?.length">
独立余额 Keys: {{ aggregateImportPreview.user_data.standalone_keys.length }} 个
</li>
<li v-if="usageAggregatePreviewCounts.total > 0">
统计聚合: {{ usageAggregatePreviewCounts.total }} 行
</li>
</ul>
</div>
</div>
@@ -84,7 +87,7 @@
</div>
<p class="text-xs text-muted-foreground">
注意:完整备份会先导入配置数据,再导入用户数据;文件包含用户、用户组、API Keys 与钱包快照,用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY。
注意:完整备份会先导入配置数据,再导入用户数据;文件包含用户、用户组、API Keys、Key 用量、钱包快照与统计聚合。正常导出的 API Keys 会在导入时使用目标系统密钥重新加密;仅当备份中包含 key_encrypted 等未解密密文字段时,才需要目标系统使用兼容的 ENCRYPTION_KEY。
</p>
<div
@@ -149,6 +152,9 @@
用户创建 {{ aggregateImportResult.users.stats.users.created }},
API Keys 创建 {{ aggregateImportResult.users.stats.api_keys.created }},
跳过 {{ aggregateImportResult.users.stats.users.skipped }} 个用户
<template v-if="usageAggregateResultText">
,统计聚合 {{ usageAggregateResultText }}
</template>
</p>
</div>
</div>
@@ -217,4 +223,36 @@ const warningMessages = computed(() => {
const userErrors = props.aggregateImportResult.users.stats.errors.map((message) => `用户数据: ${message}`)
return [...configErrors, ...userErrors]
})
const usageAggregatePreviewCounts = computed(() => {
const aggregates = props.aggregateImportPreview?.user_data.usage_aggregates
const statsDaily = aggregates?.stats_daily?.length ?? 0
const statsUserDaily = aggregates?.stats_user_daily?.length ?? 0
const statsDailyApiKey = aggregates?.stats_daily_api_key?.length ?? 0
return {
statsDaily,
statsUserDaily,
statsDailyApiKey,
total: statsDaily + statsUserDaily + statsDailyApiKey,
}
})
const usageAggregateResultText = computed(() => {
const aggregates = props.aggregateImportResult?.users.stats.usage_aggregates
if (!aggregates) return ''
const counters = [
aggregates.stats_daily,
aggregates.stats_user_daily,
aggregates.stats_daily_api_key,
]
const created = counters.reduce((sum, item) => sum + item.created, 0)
const updated = counters.reduce((sum, item) => sum + item.updated, 0)
const skipped = counters.reduce((sum, item) => sum + item.skipped, 0)
const total = created + updated + skipped
if (total === 0) return ''
return `创建 ${created},更新 ${updated},跳过 ${skipped}`
})
</script>
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getSystemConfigMock } = vi.hoisted(() => ({
getSystemConfigMock: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminApi: {
getSystemConfig: getSystemConfigMock,
updateSystemConfig: vi.fn(),
getSystemVersion: vi.fn(),
},
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/composables/useSiteInfo', () => ({
useSiteInfo: () => ({
refreshSiteInfo: vi.fn(),
}),
}))
vi.mock('@/utils/logger', () => ({
log: {
error: vi.fn(),
},
}))
import { useSystemConfig } from '../composables/useSystemConfig'
interface DeferredConfigResponse {
resolve: (value: { key: string, value: unknown, is_set?: boolean }) => void
}
describe('useSystemConfig', () => {
beforeEach(() => {
getSystemConfigMock.mockReset()
})
it('loads config keys in parallel and keeps change detection disabled until the baseline is ready', async () => {
const pending = new Map<string, DeferredConfigResponse>()
getSystemConfigMock.mockImplementation((key: string) => new Promise((resolve) => {
pending.set(key, { resolve })
}))
const state = useSystemConfig()
const loadPromise = state.loadSystemConfig()
expect(getSystemConfigMock.mock.calls.map(([key]) => key)).toContain('request_record_level')
expect(getSystemConfigMock.mock.calls.map(([key]) => key)).toContain('proxy_node_metrics_cleanup_batch_size')
state.systemConfig.value.request_record_level = 'headers'
expect(state.systemConfigLoading.value).toBe(true)
expect(state.hasLogConfigChanges.value).toBe(false)
for (const [key, deferred] of pending) {
deferred.resolve({
key,
value: key === 'request_record_level' ? 'basic' : undefined,
is_set: false,
})
}
await loadPromise
expect(state.systemConfigLoading.value).toBe(false)
expect(state.systemConfig.value.request_record_level).toBe('basic')
expect(state.hasLogConfigChanges.value).toBe(false)
state.systemConfig.value.request_record_level = 'full'
expect(state.hasLogConfigChanges.value).toBe(true)
})
})
@@ -177,6 +177,7 @@ export function useSystemConfig() {
const systemConfig = ref<SystemConfig>(createDefaultConfig())
const originalConfig = ref<SystemConfig | null>(null)
const systemVersion = ref<string>('')
const systemConfigLoading = ref(true)
// 各模块 loading 状态
const siteInfoLoading = ref(false)
@@ -187,6 +188,7 @@ export function useSystemConfig() {
// 变动检测
const hasSiteInfoChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.site_name !== originalConfig.value.site_name ||
@@ -195,11 +197,13 @@ export function useSystemConfig() {
})
const hasProxyConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
})
const hasBasicConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.default_user_initial_gift_usd !== originalConfig.value.default_user_initial_gift_usd ||
@@ -231,6 +235,7 @@ export function useSystemConfig() {
})
const hasLogConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.request_record_level !== originalConfig.value.request_record_level ||
@@ -242,6 +247,7 @@ export function useSystemConfig() {
})
const hasCleanupConfigChanges = computed(() => {
if (systemConfigLoading.value) return false
if (!originalConfig.value) return false
return (
systemConfig.value.detail_log_retention_days !==
@@ -304,26 +310,47 @@ export function useSystemConfig() {
// 加载配置
async function loadSystemConfig() {
systemConfigLoading.value = true
try {
for (const key of CONFIG_KEYS) {
const results = await Promise.all(
CONFIG_KEYS.map(async (key) => {
try {
return {
key,
response: await adminApi.getSystemConfig(key),
}
} catch {
return null
}
})
)
const nextConfig = createDefaultConfig()
for (const result of results) {
if (!result) {
continue
}
const { key, response } = result
try {
const response = await adminApi.getSystemConfig(key)
if (key === 'turnstile_secret_key') {
systemConfig.value.turnstile_secret_key = ''
systemConfig.value.turnstile_secret_key_is_set = !!response.is_set
nextConfig.turnstile_secret_key = ''
nextConfig.turnstile_secret_key_is_set = !!response.is_set
continue
}
if (response.value !== null && response.value !== undefined) {
; (systemConfig.value as Record<string, unknown>)[key] = response.value
; (nextConfig as Record<string, unknown>)[key] = response.value
}
} catch {
// 单个配置项加载失败时忽略,使用默认值
}
}
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
systemConfig.value = nextConfig
originalConfig.value = JSON.parse(JSON.stringify(nextConfig))
} catch (err) {
error('加载系统配置失败')
log.error('加载系统配置失败:', err)
} finally {
systemConfigLoading.value = false
}
}
@@ -734,6 +761,7 @@ export function useSystemConfig() {
systemConfig,
originalConfig,
systemVersion,
systemConfigLoading,
// loading 状态
siteInfoLoading,
proxyConfigLoading,
+43 -3
View File
@@ -156,6 +156,7 @@ import {
hasUsageFallback,
isUsageRecordFailed,
isUsageUpstreamStream,
normalizeRequestStatus,
resolveDisplayRequestStatus,
} from '@/features/usage/utils/status'
import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types'
@@ -531,6 +532,16 @@ async function pollActiveRequests() {
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
record.target_model = update.target_model
}
if ('reasoning_effort' in update) {
record.reasoning_effort = typeof update.reasoning_effort === 'string'
? update.reasoning_effort
: null
}
if ('service_tier' in update) {
record.service_tier = typeof update.service_tier === 'string'
? update.service_tier
: null
}
// 管理员接口返回额外字段
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
if ('provider' in update && typeof update.provider === 'string') {
@@ -547,6 +558,15 @@ async function pollActiveRequests() {
? update.provider_key_name
: undefined
}
if ('client_family' in update) {
record.client_family = typeof update.client_family === 'string' ? update.client_family : null
}
if ('client_ip' in update) {
record.client_ip = typeof update.client_ip === 'string' ? update.client_ip : null
}
if ('user_agent' in update) {
record.user_agent = typeof update.user_agent === 'string' ? update.user_agent : null
}
}
}
@@ -965,6 +985,8 @@ function handleDetailRequestState(update: {
const record = currentRecords.value.find(record => record.id === update.id)
if (!record) return
const nextStatus = resolveDetailUpdateStatus(update)
const statusPriority: Record<RequestStatus, number> = {
pending: 0,
streaming: 1,
@@ -972,11 +994,11 @@ function handleDetailRequestState(update: {
failed: 2,
cancelled: 2,
}
if (update.status) {
if (nextStatus) {
const currentRank = record.status ? statusPriority[record.status] : 0
const nextRank = statusPriority[update.status]
const nextRank = statusPriority[nextStatus]
if (nextRank >= currentRank) {
record.status = update.status
record.status = nextStatus
}
}
if ('statusCode' in update) {
@@ -996,6 +1018,24 @@ function handleDetailRequestState(update: {
}
}
function resolveDetailUpdateStatus(update: {
status?: RequestStatus
statusCode?: number | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
}): RequestStatus | undefined {
const status = normalizeRequestStatus(update.status)
const hasFailureSignal =
(typeof update.statusCode === 'number' && update.statusCode >= 400) ||
(typeof update.errorMessage === 'string' && update.errorMessage.trim().length > 0) ||
update.imageProgress?.phase === 'failed'
if ((status == null || status === 'pending' || status === 'streaming') && hasFailureSignal) {
return 'failed'
}
return status
}
function prefetchRequestDetail(id: string) {
if (!isAdminPage.value) return
void dashboardApi.prefetchRequestDetail(id).catch(error => {
@@ -335,6 +335,7 @@ import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens } from '@/utils/format'
import type { PublicGlobalModel } from '@/api/public-models'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
@@ -373,12 +374,7 @@ function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): nu
function formatTierLimit(limit: number | null | undefined): string {
if (limit == null) return ''
if (limit >= 1000000) {
return `${(limit / 1000000).toFixed(1)}M`
} else if (limit >= 1000) {
return `${(limit / 1000).toFixed(0)}K`
}
return limit.toString()
return formatTokens(limit)
}
function get1hCachePrice(tier: PricingTier): string {