mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -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 认证'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user