mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge remote-tracking branch 'origin/aether-rust-pioneer' into payment-billing-plans
# Conflicts: # crates/aether-data/src/lifecycle/bootstrap/postgres.rs # crates/aether-data/src/lifecycle/migrate/tests.rs
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||
|
||||
// LDAP 配置导出结构
|
||||
export interface LDAPConfigExport {
|
||||
@@ -678,6 +679,18 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 创建独立余额 Key 的 CLI 安装会话
|
||||
async createApiKeyInstallSession(
|
||||
keyId: string,
|
||||
data: { target_cli: InstallTargetCli; target_system: InstallSessionTargetSystem }
|
||||
): Promise<ApiKeyInstallSession> {
|
||||
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||
`/api/admin/api-keys/${keyId}/install-sessions`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 系统配置相关
|
||||
// 获取所有系统配置
|
||||
async getAllSystemConfigs(): Promise<Array<{ key: string; value: unknown; description?: string }>> {
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface PoolKeyDetail {
|
||||
quota_updated_at?: number | null
|
||||
health_score?: number
|
||||
circuit_breaker_open?: boolean
|
||||
pool_score?: PoolKeyScoreDetail | null
|
||||
api_formats?: string[]
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
internal_priority?: number
|
||||
@@ -192,6 +193,69 @@ export interface PoolKeysPageResponse {
|
||||
keys: PoolKeyDetail[]
|
||||
}
|
||||
|
||||
export interface PoolKeyScoreDetail {
|
||||
id: string
|
||||
capability: string
|
||||
scope_kind: string
|
||||
scope_id: string | null
|
||||
score: number
|
||||
hard_state: PoolScoreHardState
|
||||
score_version: number
|
||||
score_reason: Record<string, unknown> | null
|
||||
last_ranked_at: number | null
|
||||
last_scheduled_at: number | null
|
||||
last_success_at: number | null
|
||||
last_failure_at: number | null
|
||||
failure_count: number
|
||||
last_probe_attempt_at: number | null
|
||||
last_probe_success_at: number | null
|
||||
last_probe_failure_at: number | null
|
||||
probe_failure_count: number
|
||||
probe_status: PoolScoreProbeStatus
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type PoolScoreHardState =
|
||||
| 'available'
|
||||
| 'unknown'
|
||||
| 'cooldown'
|
||||
| 'quota_exhausted'
|
||||
| 'auth_invalid'
|
||||
| 'banned'
|
||||
| 'inactive'
|
||||
|
||||
export type PoolScoreProbeStatus = 'never' | 'ok' | 'failed' | 'stale' | 'in_progress'
|
||||
|
||||
export interface PoolScoreKeySummary {
|
||||
id: string
|
||||
name: string
|
||||
auth_type: string
|
||||
is_active: boolean
|
||||
internal_priority: number
|
||||
last_used_at: number | null
|
||||
}
|
||||
|
||||
export interface PoolMemberScoreItem extends PoolKeyScoreDetail {
|
||||
pool_kind: string
|
||||
pool_id: string
|
||||
member_kind: string
|
||||
member_id: string
|
||||
key?: PoolScoreKeySummary | null
|
||||
}
|
||||
|
||||
export interface PoolScoresResponse {
|
||||
provider_id: string
|
||||
page: number
|
||||
page_size: number
|
||||
filters: {
|
||||
api_format?: string | null
|
||||
model_id?: string | null
|
||||
hard_state?: string | null
|
||||
probe_status?: string | null
|
||||
}
|
||||
items: PoolMemberScoreItem[]
|
||||
}
|
||||
|
||||
export interface PoolKeysQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
@@ -203,6 +267,15 @@ export interface PoolKeysQuery {
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PoolScoresQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
api_format?: string
|
||||
model_id?: string
|
||||
hard_state?: string
|
||||
probe_status?: string
|
||||
}
|
||||
|
||||
export interface PoolKeySelectionRequest {
|
||||
search?: string
|
||||
quick_selectors?: string[]
|
||||
@@ -293,6 +366,29 @@ export async function listPoolKeys(
|
||||
)
|
||||
}
|
||||
|
||||
export async function listPoolScores(
|
||||
providerId: string,
|
||||
params: PoolScoresQuery = {},
|
||||
options: PoolReadOptions = {},
|
||||
): Promise<PoolScoresResponse> {
|
||||
const normalizedParams = { ...params }
|
||||
const cacheKey = buildCacheKey(
|
||||
`pool:scores:${providerId}`,
|
||||
normalizedParams as Record<string, unknown>,
|
||||
)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await client.get<PoolScoresResponse>(
|
||||
`/api/admin/pool/${providerId}/scores`,
|
||||
{ params: normalizedParams },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
options.cacheTtlMs ?? 0,
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolvePoolKeySelection(
|
||||
providerId: string,
|
||||
body: PoolKeySelectionRequest,
|
||||
|
||||
@@ -521,6 +521,24 @@ export interface SchedulingPresetItem {
|
||||
mode?: string | null
|
||||
}
|
||||
|
||||
export interface PoolScoreWeights {
|
||||
manual_priority?: number | null
|
||||
health?: number | null
|
||||
probe_freshness?: number | null
|
||||
quota_remaining?: number | null
|
||||
latency?: number | null
|
||||
cost_lru?: number | null
|
||||
}
|
||||
|
||||
export interface PoolScoreRules {
|
||||
weights?: PoolScoreWeights | null
|
||||
probe_freshness_ttl_seconds?: number | null
|
||||
unschedulable_score_cap?: number | null
|
||||
probe_failure_penalty?: number | null
|
||||
request_failure_penalty?: number | null
|
||||
probe_failure_cooldown_threshold?: number | null
|
||||
}
|
||||
|
||||
export interface PoolAdvancedConfig {
|
||||
global_priority?: number | null
|
||||
sticky_session_ttl_seconds?: number | null
|
||||
@@ -548,6 +566,10 @@ export interface PoolAdvancedConfig {
|
||||
health_policy_enabled?: boolean
|
||||
unschedulable_rules?: Array<Record<string, unknown>> | null
|
||||
batch_concurrency?: number | null
|
||||
probe_concurrency?: number | null
|
||||
score_top_n?: number | null
|
||||
score_fallback_scan_limit?: number | null
|
||||
score_rules?: PoolScoreRules | null
|
||||
probing_enabled?: boolean
|
||||
probing_interval_minutes?: number | null
|
||||
auto_remove_banned_keys?: boolean
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface ApiKey {
|
||||
|
||||
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
||||
export type InstallTargetSystem = 'macos' | 'linux' | 'windows' | 'auto'
|
||||
export type InstallSessionTargetSystem = Exclude<InstallTargetSystem, 'auto'>
|
||||
|
||||
export interface ApiKeyInstallSession {
|
||||
install_code: string
|
||||
@@ -287,7 +288,7 @@ export const meApi = {
|
||||
|
||||
async createApiKeyInstallSession(
|
||||
keyId: string,
|
||||
data: { target_cli: InstallTargetCli; target_system: InstallTargetSystem }
|
||||
data: { target_cli: InstallTargetCli; target_system: InstallSessionTargetSystem }
|
||||
): Promise<ApiKeyInstallSession> {
|
||||
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||
`/api/users/me/api-keys/${keyId}/install-sessions`,
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-muted/30 p-4">
|
||||
<div class="grid gap-3 rounded-xl bg-muted/30 p-4 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
并发数
|
||||
@@ -247,10 +247,204 @@
|
||||
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测并发
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probe_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
placeholder="4"
|
||||
@update:model-value="(v) => form.probe_concurrency = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
评分 Top-N
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.score_top_n ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4096"
|
||||
placeholder="128"
|
||||
@update:model-value="(v) => form.score_top_n = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
回退扫描
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.score_fallback_scan_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000"
|
||||
placeholder="1024"
|
||||
@update:model-value="(v) => form.score_fallback_scan_limit = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
分数规则
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
候选排序
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
调整主动探测、健康、额度、延迟和使用成本进入号池候选排序时的权重。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>优先级权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_manual_priority ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.30"
|
||||
@update:model-value="(v) => form.score_weight_manual_priority = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>健康权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_health ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.20"
|
||||
@update:model-value="(v) => form.score_weight_health = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>探测新鲜度权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_probe_freshness ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.15"
|
||||
@update:model-value="(v) => form.score_weight_probe_freshness = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>额度剩余权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_quota_remaining ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.15"
|
||||
@update:model-value="(v) => form.score_weight_quota_remaining = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>延迟权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_latency ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.10"
|
||||
@update:model-value="(v) => form.score_weight_latency = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>成本/LRU 权重</Label>
|
||||
<Input
|
||||
:model-value="form.score_weight_cost_lru ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.10"
|
||||
@update:model-value="(v) => form.score_weight_cost_lru = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测新鲜度 TTL
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probe_freshness_ttl_seconds ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="604800"
|
||||
placeholder="1800"
|
||||
@update:model-value="(v) => form.probe_freshness_ttl_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>探测失败惩罚</Label>
|
||||
<Input
|
||||
:model-value="form.probe_failure_penalty ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.05"
|
||||
@update:model-value="(v) => form.probe_failure_penalty = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>请求失败惩罚</Label>
|
||||
<Input
|
||||
:model-value="form.request_failure_penalty ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.001"
|
||||
placeholder="0.005"
|
||||
@update:model-value="(v) => form.request_failure_penalty = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>探测失败冷却阈值</Label>
|
||||
<Input
|
||||
:model-value="form.probe_failure_cooldown_threshold ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="3"
|
||||
@update:model-value="(v) => form.probe_failure_cooldown_threshold = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>不可调度分数上限</Label>
|
||||
<Input
|
||||
:model-value="form.unschedulable_score_cap ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
placeholder="0.05"
|
||||
@update:model-value="(v) => form.unschedulable_score_cap = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="isClaudeCode"
|
||||
class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5"
|
||||
@@ -464,6 +658,20 @@ const form = ref({
|
||||
cost_limit_per_key_tokens: null as number | null | undefined,
|
||||
cost_soft_threshold_percent: null as number | null | undefined,
|
||||
batch_concurrency: null as number | null | undefined,
|
||||
probe_concurrency: null as number | null | undefined,
|
||||
score_top_n: null as number | null | undefined,
|
||||
score_fallback_scan_limit: null as number | null | undefined,
|
||||
score_weight_manual_priority: null as number | null | undefined,
|
||||
score_weight_health: null as number | null | undefined,
|
||||
score_weight_probe_freshness: null as number | null | undefined,
|
||||
score_weight_quota_remaining: null as number | null | undefined,
|
||||
score_weight_latency: null as number | null | undefined,
|
||||
score_weight_cost_lru: null as number | null | undefined,
|
||||
probe_freshness_ttl_seconds: null as number | null | undefined,
|
||||
unschedulable_score_cap: null as number | null | undefined,
|
||||
probe_failure_penalty: null as number | null | undefined,
|
||||
request_failure_penalty: null as number | null | undefined,
|
||||
probe_failure_cooldown_threshold: null as number | null | undefined,
|
||||
probing_enabled: false,
|
||||
probing_interval_minutes: null as number | null | undefined,
|
||||
auto_remove_banned_keys: false,
|
||||
@@ -529,6 +737,8 @@ watch(() => props.modelValue, (open) => {
|
||||
if (!open) return
|
||||
|
||||
const cfg = props.currentConfig
|
||||
const scoreRules = cfg?.score_rules
|
||||
const scoreWeights = scoreRules?.weights
|
||||
form.value = {
|
||||
global_priority: cfg?.global_priority ?? null,
|
||||
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
|
||||
@@ -539,6 +749,20 @@ watch(() => props.modelValue, (open) => {
|
||||
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
|
||||
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
|
||||
batch_concurrency: cfg?.batch_concurrency ?? null,
|
||||
probe_concurrency: cfg?.probe_concurrency ?? null,
|
||||
score_top_n: cfg?.score_top_n ?? null,
|
||||
score_fallback_scan_limit: cfg?.score_fallback_scan_limit ?? null,
|
||||
score_weight_manual_priority: scoreWeights?.manual_priority ?? null,
|
||||
score_weight_health: scoreWeights?.health ?? null,
|
||||
score_weight_probe_freshness: scoreWeights?.probe_freshness ?? null,
|
||||
score_weight_quota_remaining: scoreWeights?.quota_remaining ?? null,
|
||||
score_weight_latency: scoreWeights?.latency ?? null,
|
||||
score_weight_cost_lru: scoreWeights?.cost_lru ?? null,
|
||||
probe_freshness_ttl_seconds: scoreRules?.probe_freshness_ttl_seconds ?? null,
|
||||
unschedulable_score_cap: scoreRules?.unschedulable_score_cap ?? null,
|
||||
probe_failure_penalty: scoreRules?.probe_failure_penalty ?? null,
|
||||
request_failure_penalty: scoreRules?.request_failure_penalty ?? null,
|
||||
probe_failure_cooldown_threshold: scoreRules?.probe_failure_cooldown_threshold ?? null,
|
||||
probing_enabled: cfg?.probing_enabled ?? false,
|
||||
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
|
||||
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
|
||||
@@ -560,6 +784,23 @@ watch(() => props.modelValue, (open) => {
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const scoreRules = {
|
||||
...(props.currentConfig?.score_rules ?? {}),
|
||||
weights: {
|
||||
...(props.currentConfig?.score_rules?.weights ?? {}),
|
||||
manual_priority: form.value.score_weight_manual_priority ?? undefined,
|
||||
health: form.value.score_weight_health ?? undefined,
|
||||
probe_freshness: form.value.score_weight_probe_freshness ?? undefined,
|
||||
quota_remaining: form.value.score_weight_quota_remaining ?? undefined,
|
||||
latency: form.value.score_weight_latency ?? undefined,
|
||||
cost_lru: form.value.score_weight_cost_lru ?? undefined,
|
||||
},
|
||||
probe_freshness_ttl_seconds: form.value.probe_freshness_ttl_seconds ?? undefined,
|
||||
unschedulable_score_cap: form.value.unschedulable_score_cap ?? undefined,
|
||||
probe_failure_penalty: form.value.probe_failure_penalty ?? undefined,
|
||||
request_failure_penalty: form.value.request_failure_penalty ?? undefined,
|
||||
probe_failure_cooldown_threshold: form.value.probe_failure_cooldown_threshold ?? undefined,
|
||||
}
|
||||
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
|
||||
const poolAdvanced: Record<string, unknown> = {
|
||||
...(props.currentConfig ?? {}),
|
||||
@@ -572,6 +813,10 @@ async function handleSave() {
|
||||
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
|
||||
health_policy_enabled: form.value.health_policy_enabled,
|
||||
batch_concurrency: form.value.batch_concurrency ?? undefined,
|
||||
probe_concurrency: form.value.probe_concurrency ?? undefined,
|
||||
score_top_n: form.value.score_top_n ?? undefined,
|
||||
score_fallback_scan_limit: form.value.score_fallback_scan_limit ?? undefined,
|
||||
score_rules: scoreRules,
|
||||
probing_enabled: form.value.probing_enabled,
|
||||
probing_interval_minutes: form.value.probing_enabled
|
||||
? (form.value.probing_interval_minutes ?? undefined)
|
||||
|
||||
@@ -662,19 +662,6 @@ const formatNumber = (num: number): string => {
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算最终状态:优先检查进行中状态,再使用外部状态码
|
||||
const computedFinalStatus = computed(() => {
|
||||
const hasPending = trace.value?.candidates?.some(
|
||||
c => c.status === 'pending' || c.status === 'streaming'
|
||||
)
|
||||
return resolveTimelineFinalStatus({
|
||||
hasPendingCandidates: hasPending,
|
||||
statusCode: props.overrideStatusCode,
|
||||
requestStatus: props.requestStatus ?? usageData.value?.status,
|
||||
traceFinalStatus: trace.value?.final_status,
|
||||
})
|
||||
})
|
||||
|
||||
// 获取最终状态标签
|
||||
const getFinalStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -806,11 +793,25 @@ const STATUS_PRIORITY: Record<string, number> = {
|
||||
}
|
||||
|
||||
const isParticipatedCandidate = (candidate: CandidateRecord): boolean => {
|
||||
if (candidate.status === 'available' || candidate.status === 'unused') return false
|
||||
if (candidate.status === 'pending' && !candidate.started_at) return false
|
||||
return true
|
||||
return TIMELINE_STATUS.includes(candidate.status)
|
||||
}
|
||||
|
||||
const isLiveCandidate = (candidate: CandidateRecord): boolean => {
|
||||
if (candidate.status === 'streaming') return true
|
||||
return candidate.status === 'pending' && Boolean(candidate.started_at)
|
||||
}
|
||||
|
||||
// 计算最终状态:优先检查真正已启动的进行中状态,再使用外部状态码
|
||||
const computedFinalStatus = computed(() => {
|
||||
const hasPending = trace.value?.candidates?.some(isLiveCandidate)
|
||||
return resolveTimelineFinalStatus({
|
||||
hasPendingCandidates: hasPending,
|
||||
statusCode: props.overrideStatusCode,
|
||||
requestStatus: props.requestStatus ?? usageData.value?.status,
|
||||
traceFinalStatus: trace.value?.final_status,
|
||||
})
|
||||
})
|
||||
|
||||
const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): number => {
|
||||
if (a.candidate_index !== b.candidate_index) {
|
||||
return a.candidate_index - b.candidate_index
|
||||
@@ -1695,10 +1696,7 @@ const propsRequestIsActive = computed(() => {
|
||||
})
|
||||
|
||||
const traceHasActiveCandidate = computed(() => {
|
||||
return rawTimeline.value.some((candidate) => {
|
||||
const status = getDisplayStatus(candidate)
|
||||
return status === 'pending' || status === 'streaming'
|
||||
})
|
||||
return rawTimeline.value.some(isLiveCandidate)
|
||||
})
|
||||
|
||||
const traceFinalIsTerminal = computed(() => {
|
||||
@@ -1751,15 +1749,12 @@ watch(groupedTimeline, (newGroups) => {
|
||||
}
|
||||
|
||||
// 查找正在进行的组
|
||||
const activeIdx = newGroups.findIndex(g => g.primaryStatus === 'pending' || g.primaryStatus === 'streaming')
|
||||
const activeIdx = newGroups.findIndex(g => g.allAttempts.some(isLiveCandidate))
|
||||
if (activeIdx >= 0) {
|
||||
selectedGroupIndex.value = activeIdx
|
||||
// 选中正在进行的尝试,而非最后一个
|
||||
const group = newGroups[activeIdx]
|
||||
const attemptIdx = group.allAttempts.findIndex(a => {
|
||||
const status = getDisplayStatus(a)
|
||||
return status === 'pending' || status === 'streaming'
|
||||
})
|
||||
const attemptIdx = group.allAttempts.findIndex(isLiveCandidate)
|
||||
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : group.allAttempts.length - 1
|
||||
return
|
||||
}
|
||||
@@ -1853,8 +1848,8 @@ const formatDuration = (startStr: string, endStr: string): string => {
|
||||
// 获取状态标签
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
available: '未执行',
|
||||
unused: '未执行',
|
||||
available: '可用未尝试',
|
||||
unused: '未使用',
|
||||
pending: '进行中',
|
||||
streaming: '传输中',
|
||||
stream_interrupted: '流中断',
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('HorizontalRequestTimeline', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders visible candidates by scheduling index and hides unstarted lazy candidates', async () => {
|
||||
it('orders visible candidates by scheduling index and includes unattempted candidates', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-success',
|
||||
@@ -244,7 +244,17 @@ describe('HorizontalRequestTimeline', () => {
|
||||
|
||||
const labels = [...root.querySelectorAll<HTMLElement>('.node-label')]
|
||||
.map(label => label.textContent?.trim())
|
||||
expect(labels).toEqual(['Provider Skipped', 'Provider Failed', 'Provider Success'])
|
||||
expect(labels).toEqual([
|
||||
'Provider Available',
|
||||
'Provider Skipped',
|
||||
'Provider Pending',
|
||||
'Provider Failed',
|
||||
'Provider Success',
|
||||
])
|
||||
|
||||
const nodeDots = [...root.querySelectorAll<HTMLElement>('.node-dot')]
|
||||
expect(nodeDots[0].classList.contains('status-available')).toBe(true)
|
||||
expect(nodeDots[2].classList.contains('status-pending')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses candidate terminal status for node colors instead of overriding with HTTP code', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ function buildCandidate(
|
||||
}
|
||||
|
||||
describe('poolTrace', () => {
|
||||
it('keeps only pool nodes that actually participated in scheduling audit fallback', () => {
|
||||
it('keeps pool audit nodes even when they were not attempted', () => {
|
||||
const attempts = buildPoolAttemptCandidatesFromAudit([], [
|
||||
{
|
||||
candidate_index: 0,
|
||||
@@ -68,11 +68,13 @@ describe('poolTrace', () => {
|
||||
},
|
||||
], 'req-1')
|
||||
|
||||
expect(attempts).toHaveLength(2)
|
||||
expect(attempts).toHaveLength(3)
|
||||
expect(attempts[0].key_id).toBe('key-success')
|
||||
expect(attempts[0].status).toBe('success')
|
||||
expect(attempts[1].key_id).toBe('key-skipped')
|
||||
expect(attempts[1].status).toBe('skipped')
|
||||
expect(attempts[2].key_id).toBe('key-available')
|
||||
expect(attempts[2].status).toBe('available')
|
||||
})
|
||||
|
||||
it('preserves real trace attempts even when audit status is non-standard', () => {
|
||||
|
||||
@@ -12,11 +12,6 @@ export const TIMELINE_STATUS: CandidateRecord['status'][] = [
|
||||
'stream_interrupted',
|
||||
]
|
||||
|
||||
const POOL_HIDDEN_STATUS = new Set<CandidateRecord['status']>([
|
||||
'available',
|
||||
'unused',
|
||||
])
|
||||
|
||||
const PROVIDER_TYPE_LIKE_NAMES = new Set<string>([
|
||||
'codex',
|
||||
'kiro',
|
||||
@@ -40,9 +35,7 @@ export const makeAttemptKey = (candidateIndex: number, retryIndex: number): stri
|
||||
}
|
||||
|
||||
export const isPoolParticipatedCandidate = (candidate: CandidateRecord): boolean => {
|
||||
if (POOL_HIDDEN_STATUS.has(candidate.status)) return false
|
||||
if (candidate.status === 'pending' && !candidate.started_at) return false
|
||||
return true
|
||||
return TIMELINE_STATUS.includes(candidate.status)
|
||||
}
|
||||
|
||||
export const isAttemptedCandidate = (
|
||||
|
||||
@@ -317,6 +317,15 @@
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="一键安装并配置 CLI"
|
||||
@click="openInstallDialog(apiKey)"
|
||||
>
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -533,6 +542,15 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@click="openInstallDialog(apiKey)"
|
||||
>
|
||||
<Terminal class="mr-1.5 h-3.5 w-3.5" />
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -652,6 +670,123 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 一键安装并配置 CLI 对话框 -->
|
||||
<Dialog
|
||||
v-model="showInstallDialog"
|
||||
size="lg"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Terminal class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
一键安装并配置 CLI
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
当前密钥:{{ selectedInstallApiKey?.name || selectedInstallApiKey?.key_display || '未选择' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
选择要配置的 CLI 和目标系统,Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-semibold">目标 CLI</Label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
v-for="option in installCliOptions"
|
||||
:key="option.value"
|
||||
:variant="installCli === option.value ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="selectInstallCli(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-semibold">目标系统</Label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
v-for="option in installSystemOptions"
|
||||
:key="option.value"
|
||||
:variant="installSystem === option.value ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="selectInstallSystem(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
:disabled="installLoading || !installCommand"
|
||||
:title="installCopied ? '已复制' : '一键复制安装命令'"
|
||||
@click="copyInstallCommand"
|
||||
>
|
||||
<CheckCircle
|
||||
v-if="installCopied"
|
||||
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ installCopied ? '已复制' : '一键复制' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="installLoading || !selectedInstallApiKey"
|
||||
@click="refreshInstallCommand"
|
||||
>
|
||||
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ installCommandHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="showInstallDialog = false"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||
:disabled="!installCommand || installLoading"
|
||||
@click="copyInstallCommand"
|
||||
>
|
||||
{{ installCopied ? '已复制' : '复制命令' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<WalletOpsDrawer
|
||||
:open="showWalletActionDrawer"
|
||||
:wallet="walletActionTarget?.wallet || null"
|
||||
@@ -667,11 +802,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from '@/api/me'
|
||||
import type { AdminWallet } from '@/api/admin-wallets'
|
||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
@@ -710,7 +846,8 @@ import {
|
||||
Copy,
|
||||
CheckCircle,
|
||||
SquarePen,
|
||||
Search
|
||||
Search,
|
||||
Terminal
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
||||
@@ -729,8 +866,16 @@ const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const limit = ref(100)
|
||||
const showNewKeyDialog = ref(false)
|
||||
const showInstallDialog = ref(false)
|
||||
const newKeyValue = ref('')
|
||||
const keyInput = ref<HTMLInputElement>()
|
||||
const selectedInstallApiKey = ref<AdminApiKey | null>(null)
|
||||
const installCli = ref<InstallTargetCli>('claude_code')
|
||||
const installSystem = ref<InstallSessionTargetSystem>('linux')
|
||||
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||
const installLoading = ref(false)
|
||||
const installCopied = ref(false)
|
||||
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 统一的表单对话框状态
|
||||
const showKeyFormDialog = ref(false)
|
||||
@@ -750,6 +895,18 @@ const statusFilters = [
|
||||
{ value: 'inactive' as const, label: '禁用' }
|
||||
]
|
||||
|
||||
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
||||
{ value: 'claude_code', label: 'Claude Code' },
|
||||
{ value: 'codex_cli', label: 'Codex CLI' },
|
||||
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||
]
|
||||
|
||||
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
|
||||
{ value: 'macos', label: 'macOS' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'windows', label: 'Windows' }
|
||||
]
|
||||
|
||||
const balanceFilters = [
|
||||
{ value: 'all' as const, label: '全部类型' },
|
||||
{ value: 'limited' as const, label: '限额' },
|
||||
@@ -760,6 +917,20 @@ const hasActiveFilters = computed(() => {
|
||||
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
|
||||
})
|
||||
|
||||
const installCommand = computed(() => {
|
||||
if (!installSession.value) return ''
|
||||
return installSystem.value === 'windows'
|
||||
? installSession.value.powershell_command
|
||||
: installSession.value.unix_command
|
||||
})
|
||||
|
||||
const installCommandHint = computed(() => {
|
||||
if (installSystem.value === 'windows') {
|
||||
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||
}
|
||||
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
filterStatus.value = 'all'
|
||||
@@ -808,9 +979,32 @@ const showWalletActionDrawer = ref(false)
|
||||
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
installSystem.value = detectCurrentSystem()
|
||||
await refreshApiKeys()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetInstallCopiedState()
|
||||
})
|
||||
|
||||
watch(showInstallDialog, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetInstallCopiedState()
|
||||
}
|
||||
})
|
||||
|
||||
function clearInstallCopiedResetTimer() {
|
||||
if (installCopiedResetTimer) {
|
||||
clearTimeout(installCopiedResetTimer)
|
||||
installCopiedResetTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetInstallCopiedState() {
|
||||
clearInstallCopiedResetTimer()
|
||||
installCopied.value = false
|
||||
}
|
||||
|
||||
function buildAdminWalletFromApiKey(apiKey: AdminApiKey): AdminWallet | null {
|
||||
if (!apiKey.wallet?.id) {
|
||||
return null
|
||||
@@ -867,6 +1061,64 @@ function handlePageChange(page: number) {
|
||||
refreshApiKeys()
|
||||
}
|
||||
|
||||
function detectCurrentSystem(): InstallSessionTargetSystem {
|
||||
const platform = window.navigator.platform.toLowerCase()
|
||||
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||
if (platform.includes('mac')) return 'macos'
|
||||
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
|
||||
return 'linux'
|
||||
}
|
||||
|
||||
async function openInstallDialog(apiKey: AdminApiKey) {
|
||||
selectedInstallApiKey.value = apiKey
|
||||
installSession.value = null
|
||||
resetInstallCopiedState()
|
||||
showInstallDialog.value = true
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function selectInstallCli(value: InstallTargetCli) {
|
||||
installCli.value = value
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function selectInstallSystem(value: InstallSessionTargetSystem) {
|
||||
installSystem.value = value
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function refreshInstallCommand() {
|
||||
if (!selectedInstallApiKey.value) return
|
||||
installLoading.value = true
|
||||
installSession.value = null
|
||||
resetInstallCopiedState()
|
||||
try {
|
||||
installSession.value = await adminApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||
target_cli: installCli.value,
|
||||
target_system: installSystem.value,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
log.error('生成 CLI 安装命令失败:', err)
|
||||
error(parseApiError(err, '生成 CLI 安装命令失败'))
|
||||
} finally {
|
||||
installLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInstallCommand() {
|
||||
if (!installCommand.value) return
|
||||
const copied = await copyToClipboard(installCommand.value, false)
|
||||
if (!copied) return
|
||||
|
||||
installCopied.value = true
|
||||
success('安装命令已复制到剪贴板')
|
||||
clearInstallCopiedResetTimer()
|
||||
installCopiedResetTimer = setTimeout(() => {
|
||||
installCopied.value = false
|
||||
installCopiedResetTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function toggleApiKey(apiKey: AdminApiKey) {
|
||||
try {
|
||||
const response = await adminApi.toggleApiKey(apiKey.id)
|
||||
@@ -1063,7 +1315,12 @@ async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await copyToClipboard(response.key)
|
||||
const copied = await copyToClipboard(response.key, false)
|
||||
if (copied) {
|
||||
success('完整密钥已复制到剪贴板')
|
||||
} else {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error('复制失败,请重试')
|
||||
|
||||
@@ -405,6 +405,12 @@
|
||||
>
|
||||
最后使用
|
||||
</SortableTableHead>
|
||||
<TableHead
|
||||
class="font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.score }"
|
||||
>
|
||||
分数
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
class="font-semibold text-center whitespace-nowrap"
|
||||
column-key="status"
|
||||
@@ -665,6 +671,67 @@
|
||||
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center align-middle">
|
||||
<div class="inline-flex items-center justify-center gap-1">
|
||||
<span class="font-mono text-xs tabular-nums text-foreground/90">
|
||||
{{ formatPoolScore(key.pool_score?.score) }}
|
||||
</span>
|
||||
<Popover
|
||||
v-if="key.pool_score"
|
||||
:open="scoreDesktopPopoverOpenKeyId === key.key_id"
|
||||
@update:open="(open: boolean) => handleScoreDesktopPopoverToggle(key.key_id, open)"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
|
||||
title="查看评分计算结果"
|
||||
aria-label="查看评分计算结果"
|
||||
@click.stop
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
v-if="scoreDesktopPopoverOpenKeyId === key.key_id"
|
||||
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
|
||||
side="bottom"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
>
|
||||
<div class="text-left">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
|
||||
<span class="font-mono text-xs tabular-nums text-foreground/90">
|
||||
{{ formatPoolScore(key.pool_score?.score) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2 px-3 py-2.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
|
||||
>
|
||||
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 rounded-md px-2 text-[10px] font-normal"
|
||||
>
|
||||
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
|
||||
</Badge>
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<Badge
|
||||
:variant="keyUiStateMap[key.key_id]?.schedulingBadgeVariant || 'default'"
|
||||
@@ -928,6 +995,68 @@
|
||||
<span class="text-muted-foreground">最后使用</span>
|
||||
<span class="font-medium text-foreground/90">{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted-foreground">分数</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-mono font-medium text-foreground/90 tabular-nums">
|
||||
{{ formatPoolScore(key.pool_score?.score) }}
|
||||
</span>
|
||||
<Popover
|
||||
v-if="key.pool_score"
|
||||
:open="scoreMobilePopoverOpenKeyId === key.key_id"
|
||||
@update:open="(open: boolean) => handleScoreMobilePopoverToggle(key.key_id, open)"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
|
||||
title="查看评分计算结果"
|
||||
aria-label="查看评分计算结果"
|
||||
@click.stop
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
v-if="scoreMobilePopoverOpenKeyId === key.key_id"
|
||||
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
|
||||
side="bottom"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
>
|
||||
<div class="text-left">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
|
||||
<span class="font-mono text-xs tabular-nums text-foreground/90">
|
||||
{{ formatPoolScore(key.pool_score?.score) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2 px-3 py-2.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
|
||||
>
|
||||
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 rounded-md px-2 text-[10px] font-normal"
|
||||
>
|
||||
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
|
||||
</Badge>
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1273,6 +1402,7 @@ import {
|
||||
Users,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
CircleHelp,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import {
|
||||
@@ -1391,6 +1521,8 @@ import {
|
||||
getQuotaDisplayText,
|
||||
} from '@/utils/providerKeyQuota'
|
||||
|
||||
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
|
||||
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
@@ -1432,6 +1564,24 @@ const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status
|
||||
{ value: 'cooldown', label: '冷却中' },
|
||||
{ value: 'inactive', label: '禁用' },
|
||||
]
|
||||
const poolScoreHardStateOptions = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'available', label: '可用' },
|
||||
{ value: 'unknown', label: '未知' },
|
||||
{ value: 'cooldown', label: '冷却' },
|
||||
{ value: 'quota_exhausted', label: '额度耗尽' },
|
||||
{ value: 'auth_invalid', label: '授权无效' },
|
||||
{ value: 'banned', label: '封禁' },
|
||||
{ value: 'inactive', label: '禁用' },
|
||||
]
|
||||
const poolScoreProbeStatusOptions = [
|
||||
{ value: 'all', label: '全部探测' },
|
||||
{ value: 'never', label: '未探测' },
|
||||
{ value: 'ok', label: '正常' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'stale', label: '过期' },
|
||||
{ value: 'in_progress', label: '探测中' },
|
||||
]
|
||||
|
||||
async function loadOverview(options: { cacheTtlMs?: number } = {}) {
|
||||
const requestId = ++overviewRequestId
|
||||
@@ -1662,23 +1812,25 @@ const showAccountQuotaColumn = computed(() => {
|
||||
const desktopColumnWidths = computed(() => {
|
||||
if (showAccountQuotaColumn.value) {
|
||||
return {
|
||||
name: '22%',
|
||||
quota: '21%',
|
||||
stats: '15%',
|
||||
name: '21%',
|
||||
quota: '18%',
|
||||
stats: '13%',
|
||||
imported: '10%',
|
||||
lastUsed: '9%',
|
||||
lastUsed: '8%',
|
||||
score: '9%',
|
||||
status: '7%',
|
||||
actions: '16%',
|
||||
actions: '14%',
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: '34%',
|
||||
name: '31%',
|
||||
quota: '0%',
|
||||
stats: '16%',
|
||||
imported: '12%',
|
||||
lastUsed: '12%',
|
||||
status: '9%',
|
||||
actions: '17%',
|
||||
stats: '15%',
|
||||
imported: '11%',
|
||||
lastUsed: '11%',
|
||||
score: '9%',
|
||||
status: '8%',
|
||||
actions: '15%',
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1703,6 +1855,8 @@ async function selectProvider(
|
||||
closeProviderProxyPopovers()
|
||||
proxyDesktopPopoverOpenKeyId.value = null
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
scoreDesktopPopoverOpenKeyId.value = null
|
||||
scoreMobilePopoverOpenKeyId.value = null
|
||||
suppressFiltersWatch = true
|
||||
if (!options.preservePagination) {
|
||||
currentPage.value = 1
|
||||
@@ -1766,6 +1920,8 @@ const resettingCycleKeyId = ref<string | null>(null)
|
||||
const savingProxyKeyId = ref<string | null>(null)
|
||||
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
|
||||
const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
|
||||
const scoreDesktopPopoverOpenKeyId = ref<string | null>(null)
|
||||
const scoreMobilePopoverOpenKeyId = ref<string | null>(null)
|
||||
const deletingKeyId = ref<string | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
const editingPriorityKeyId = ref<string | null>(null)
|
||||
@@ -2449,6 +2605,20 @@ function getKeyProxyNodeName(key: PoolKeyDetail): string | null {
|
||||
return node ? node.name : `${key.proxy.node_id.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
function handleScoreDesktopPopoverToggle(keyId: string, open: boolean) {
|
||||
scoreDesktopPopoverOpenKeyId.value = open ? keyId : null
|
||||
if (open) {
|
||||
scoreMobilePopoverOpenKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleScoreMobilePopoverToggle(keyId: string, open: boolean) {
|
||||
scoreMobilePopoverOpenKeyId.value = open ? keyId : null
|
||||
if (open) {
|
||||
scoreDesktopPopoverOpenKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleProxyDesktopPopoverToggle(keyId: string, open: boolean) {
|
||||
proxyDesktopPopoverOpenKeyId.value = open ? keyId : null
|
||||
if (open) {
|
||||
@@ -3584,6 +3754,37 @@ function formatStatUsd(value: number | string | null | undefined): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPoolScore(value: number | null | undefined): string {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '-'
|
||||
return n.toFixed(3)
|
||||
}
|
||||
|
||||
function formatPoolScoreReason(value: PoolKeyScore['score_reason'] | null | undefined): string {
|
||||
if (!value) return '暂无计算结果'
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function getPoolScoreHardStateLabel(value: PoolKeyScore['hard_state'] | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return poolScoreHardStateOptions.find(item => item.value === value)?.label || value
|
||||
}
|
||||
|
||||
function getPoolScoreProbeStatusLabel(value: PoolKeyScore['probe_status'] | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return poolScoreProbeStatusOptions.find(item => item.value === value)?.label || value
|
||||
}
|
||||
|
||||
function formatUnixSeconds(seconds: number | null | undefined): string {
|
||||
const raw = Number(seconds ?? 0)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return '-'
|
||||
return formatRelativeTime(new Date(raw * 1000).toISOString())
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoStr: string): string {
|
||||
const date = new Date(isoStr)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
@@ -139,11 +139,12 @@ vi.mock('lucide-vue-next', async () => {
|
||||
Users: Icon,
|
||||
Settings2: Icon,
|
||||
SlidersHorizontal: Icon,
|
||||
CircleHelp: Icon,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const { computed, defineComponent, h, inject, provide } = await import('vue')
|
||||
const passthrough = (name: string, tag = 'div') => defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
@@ -204,6 +205,53 @@ vi.mock('@/components/ui', async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const popoverContextKey = Symbol('PopoverStubContext')
|
||||
|
||||
const Popover = defineComponent({
|
||||
name: 'PopoverStub',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
open: Boolean,
|
||||
},
|
||||
emits: ['update:open'],
|
||||
setup(props, { slots, emit }) {
|
||||
const context = {
|
||||
open: computed(() => props.open),
|
||||
toggle: () => emit('update:open', !props.open),
|
||||
}
|
||||
provide(popoverContextKey, context)
|
||||
return () => slots.default?.()
|
||||
},
|
||||
})
|
||||
|
||||
const PopoverTrigger = defineComponent({
|
||||
name: 'PopoverTriggerStub',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
const context = inject<{ open: { value: boolean }, toggle: () => void } | null>(popoverContextKey, null)
|
||||
return () => {
|
||||
return h('span', {
|
||||
...attrs,
|
||||
onClickCapture: () => {
|
||||
context?.toggle()
|
||||
},
|
||||
}, slots.default?.())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const PopoverContent = defineComponent({
|
||||
name: 'PopoverContentStub',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
const context = inject<{ open: { value: boolean } } | null>(popoverContextKey, null)
|
||||
return () => {
|
||||
if (!context?.open.value) return null
|
||||
return h('div', { ...attrs, 'data-state': 'open' }, slots.default?.())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Card: passthrough('CardStub'),
|
||||
Badge: passthrough('BadgeStub', 'span'),
|
||||
@@ -224,9 +272,9 @@ vi.mock('@/components/ui', async () => {
|
||||
TableCell: passthrough('TableCellStub', 'td'),
|
||||
Switch,
|
||||
Pagination,
|
||||
Popover: passthrough('PopoverStub'),
|
||||
PopoverTrigger: passthrough('PopoverTriggerStub'),
|
||||
PopoverContent: passthrough('PopoverContentStub'),
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -523,6 +571,87 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
expect(root.textContent).not.toContain('总计')
|
||||
})
|
||||
|
||||
it('renders unified pool score in the key list with a calculation entry point', async () => {
|
||||
const scoredKey = createPoolKey('codex', {
|
||||
pool_score: {
|
||||
id: 'pms-account-score',
|
||||
capability: 'account',
|
||||
scope_kind: 'account',
|
||||
scope_id: null,
|
||||
score: 0.875,
|
||||
hard_state: 'available',
|
||||
score_version: 1,
|
||||
score_reason: { weights: { manual_priority: 0.3 } },
|
||||
last_ranked_at: 1_700_000_000,
|
||||
last_scheduled_at: 1_700_000_010,
|
||||
last_success_at: 1_700_000_020,
|
||||
last_failure_at: null,
|
||||
failure_count: 0,
|
||||
last_probe_attempt_at: 1_700_000_030,
|
||||
last_probe_success_at: 1_700_000_040,
|
||||
last_probe_failure_at: null,
|
||||
probe_failure_count: 0,
|
||||
probe_status: 'ok',
|
||||
updated_at: 1_700_000_050,
|
||||
},
|
||||
})
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(scoredKey))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
||||
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(root.textContent).toContain('0.875')
|
||||
expect(root.querySelectorAll('button[title="查看评分计算结果"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('opens only one score popover across desktop and mobile layouts', async () => {
|
||||
const scoredKey = createPoolKey('codex', {
|
||||
pool_score: {
|
||||
id: 'pms-account-score',
|
||||
capability: 'account',
|
||||
scope_kind: 'account',
|
||||
scope_id: null,
|
||||
score: 0.662,
|
||||
hard_state: 'available',
|
||||
score_version: 1,
|
||||
score_reason: {
|
||||
rules: {
|
||||
probe_failure_penalty: 0.05,
|
||||
},
|
||||
},
|
||||
last_ranked_at: 1_700_000_000,
|
||||
last_scheduled_at: null,
|
||||
last_success_at: null,
|
||||
last_failure_at: null,
|
||||
failure_count: 0,
|
||||
last_probe_attempt_at: null,
|
||||
last_probe_success_at: null,
|
||||
last_probe_failure_at: null,
|
||||
probe_failure_count: 0,
|
||||
probe_status: 'ok',
|
||||
updated_at: 1_700_000_050,
|
||||
},
|
||||
})
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(scoredKey))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
||||
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
const helpButtons = root.querySelectorAll<HTMLButtonElement>('button[title="查看评分计算结果"]')
|
||||
expect(helpButtons.length).toBe(2)
|
||||
|
||||
helpButtons[0]?.click()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelectorAll('pre').length).toBe(1)
|
||||
expect(root.textContent).toContain('评分计算结果')
|
||||
expect(root.textContent).toContain('0.662')
|
||||
})
|
||||
|
||||
it('refreshes quota only for keys on the current page', async () => {
|
||||
const pageKeys = [
|
||||
createPoolKey('codex', { key_id: 'codex-page-key-1', quota_updated_at: null }),
|
||||
|
||||
@@ -589,14 +589,34 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="installLoading || !selectedInstallApiKey"
|
||||
@click="refreshInstallCommand"
|
||||
>
|
||||
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
:disabled="installLoading || !installCommand"
|
||||
:title="installCopied ? '已复制' : '一键复制安装命令'"
|
||||
@click="copyInstallCommand"
|
||||
>
|
||||
<CheckCircle
|
||||
v-if="installCopied"
|
||||
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ installCopied ? '已复制' : '一键复制' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="installLoading || !selectedInstallApiKey"
|
||||
@click="refreshInstallCommand"
|
||||
>
|
||||
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||
@@ -618,9 +638,9 @@
|
||||
<Button
|
||||
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||
:disabled="!installCommand || installLoading"
|
||||
@click="copyTextToClipboard(installCommand)"
|
||||
@click="copyInstallCommand"
|
||||
>
|
||||
复制命令
|
||||
{{ installCopied ? '已复制' : '复制命令' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -640,8 +660,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -674,7 +694,7 @@ const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
||||
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||
]
|
||||
|
||||
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
|
||||
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
|
||||
{ value: 'macos', label: 'macOS' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'windows', label: 'Windows' }
|
||||
@@ -708,9 +728,11 @@ const editingApiKey = ref<ApiKey | null>(null)
|
||||
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
||||
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
||||
const installCli = ref<InstallTargetCli>('claude_code')
|
||||
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
|
||||
const installSystem = ref<InstallSessionTargetSystem>('linux')
|
||||
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||
const installLoading = ref(false)
|
||||
const installCopied = ref(false)
|
||||
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const installCommand = computed(() => {
|
||||
if (!installSession.value) return ''
|
||||
@@ -731,6 +753,16 @@ onMounted(() => {
|
||||
loadApiKeys()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetInstallCopiedState()
|
||||
})
|
||||
|
||||
watch(showInstallDialog, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetInstallCopiedState()
|
||||
}
|
||||
})
|
||||
|
||||
watch(showKeyDialog, (isOpen) => {
|
||||
if (!isOpen && pendingFirstInstallApiKey.value) {
|
||||
closeCreatedKeyDialog()
|
||||
@@ -756,6 +788,18 @@ async function loadApiKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearInstallCopiedResetTimer() {
|
||||
if (installCopiedResetTimer) {
|
||||
clearTimeout(installCopiedResetTimer)
|
||||
installCopiedResetTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetInstallCopiedState() {
|
||||
clearInstallCopiedResetTimer()
|
||||
installCopied.value = false
|
||||
}
|
||||
|
||||
function openEditApiKeyDialog(apiKey: ApiKey) {
|
||||
editingApiKey.value = apiKey
|
||||
newKeyName.value = apiKey.name || ''
|
||||
@@ -772,7 +816,7 @@ function openCreateApiKeyDialog() {
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
||||
function detectCurrentSystem(): InstallSessionTargetSystem {
|
||||
const platform = window.navigator.platform.toLowerCase()
|
||||
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||
if (platform.includes('mac')) return 'macos'
|
||||
@@ -783,6 +827,7 @@ function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
||||
async function openInstallDialog(apiKey: ApiKey) {
|
||||
selectedInstallApiKey.value = apiKey
|
||||
installSession.value = null
|
||||
resetInstallCopiedState()
|
||||
showInstallDialog.value = true
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
@@ -792,7 +837,7 @@ async function selectInstallCli(value: InstallTargetCli) {
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
|
||||
async function selectInstallSystem(value: InstallSessionTargetSystem) {
|
||||
installSystem.value = value
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
@@ -801,6 +846,7 @@ async function refreshInstallCommand() {
|
||||
if (!selectedInstallApiKey.value) return
|
||||
installLoading.value = true
|
||||
installSession.value = null
|
||||
resetInstallCopiedState()
|
||||
try {
|
||||
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||
target_cli: installCli.value,
|
||||
@@ -814,6 +860,20 @@ async function refreshInstallCommand() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInstallCommand() {
|
||||
if (!installCommand.value) return
|
||||
const copied = await copyTextToClipboard(installCommand.value, false)
|
||||
if (!copied) return
|
||||
|
||||
installCopied.value = true
|
||||
success('安装命令已复制到剪贴板')
|
||||
clearInstallCopiedResetTimer()
|
||||
installCopiedResetTimer = setTimeout(() => {
|
||||
installCopied.value = false
|
||||
installCopiedResetTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function closeCreatedKeyDialog() {
|
||||
showKeyDialog.value = false
|
||||
const pending = pendingFirstInstallApiKey.value
|
||||
@@ -911,19 +971,22 @@ async function copyApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await meApi.getFullApiKey(apiKey.id)
|
||||
await copyTextToClipboard(response.key, false) // 不显示内部提示
|
||||
success('完整密钥已复制到剪贴板')
|
||||
const copied = await copyTextToClipboard(response.key, false) // 不显示内部提示
|
||||
if (copied) {
|
||||
success('完整密钥已复制到剪贴板')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('复制密钥失败:', error)
|
||||
showError('复制失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
||||
async function copyTextToClipboard(text: string, showToast: boolean = true): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
@@ -938,8 +1001,12 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful && showToast) {
|
||||
success('已复制到剪贴板')
|
||||
} else if (!successful) {
|
||||
}
|
||||
if (successful) {
|
||||
return true
|
||||
} else {
|
||||
showError('复制失败,请手动复制')
|
||||
return false
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
@@ -948,6 +1015,7 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
||||
} catch (error) {
|
||||
log.error('复制失败:', error)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user