mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge pull request #804 from AAEE86/fix-frontend-eslint
Fix frontend ESLint issues
This commit is contained in:
@@ -392,8 +392,7 @@ class ApiClient {
|
|||||||
|
|
||||||
this.isRefreshing = true
|
this.isRefreshing = true
|
||||||
const requestAuthStateVersion = this.authStateVersion
|
const requestAuthStateVersion = this.authStateVersion
|
||||||
let restorePromise!: Promise<string>
|
const restorePromise = (async () => {
|
||||||
restorePromise = (async () => {
|
|
||||||
const accessToken = await this.coordinatedRefresh()
|
const accessToken = await this.coordinatedRefresh()
|
||||||
if (requestAuthStateVersion !== this.authStateVersion) {
|
if (requestAuthStateVersion !== this.authStateVersion) {
|
||||||
throw new Error('Auth state changed during session restore')
|
throw new Error('Auth state changed during session restore')
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<div class="space-y-3.5">
|
<div class="space-y-3.5">
|
||||||
<nav class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5" aria-label="批量导入步骤">
|
<nav
|
||||||
|
class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5"
|
||||||
|
aria-label="批量导入步骤"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="step in steps"
|
v-for="step in steps"
|
||||||
:key="step.id"
|
:key="step.id"
|
||||||
@@ -37,8 +40,12 @@
|
|||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span>
|
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="text-balance text-sm font-semibold text-foreground">粘贴名称与 Key</h3>
|
<h3 class="text-balance text-sm font-semibold text-foreground">
|
||||||
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">每行一条,仅接受四个短横线分隔</p>
|
粘贴名称与 Key
|
||||||
|
</h3>
|
||||||
|
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
|
||||||
|
每行一条,仅接受四个短横线分隔
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -50,34 +57,40 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<Label for="provider-key-batch-input" class="sr-only">Key 列表</Label>
|
<Label
|
||||||
<Textarea
|
for="provider-key-batch-input"
|
||||||
id="provider-key-batch-input"
|
class="sr-only"
|
||||||
v-model="inputText"
|
>Key 列表</Label>
|
||||||
class="h-[280px] min-h-[220px] max-h-[520px] !resize-y !rounded-none !border-0 !bg-transparent !px-4 !py-4 font-mono text-[13px] leading-6 !shadow-none !ring-0 focus-visible:!ring-0"
|
<Textarea
|
||||||
spellcheck="false"
|
id="provider-key-batch-input"
|
||||||
placeholder="主账号----sk-xxxx 备用账号----sk-yyyy"
|
v-model="inputText"
|
||||||
/>
|
class="h-[280px] min-h-[220px] max-h-[520px] !resize-y !rounded-none !border-0 !bg-transparent !px-4 !py-4 font-mono text-[13px] leading-6 !shadow-none !ring-0 focus-visible:!ring-0"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="主账号----sk-xxxx 备用账号----sk-yyyy"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="parsed.errors.length > 0"
|
||||||
|
class="mx-4 mb-3 space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive ring-1 ring-destructive/20"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="parsed.errors.length > 0"
|
v-for="(item, index) in parsed.errors.slice(0, 6)"
|
||||||
class="mx-4 mb-3 space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive ring-1 ring-destructive/20"
|
:key="`${item.lineNumber}-${index}`"
|
||||||
>
|
>
|
||||||
<div
|
{{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }}
|
||||||
v-for="(item, index) in parsed.errors.slice(0, 6)"
|
|
||||||
:key="`${item.lineNumber}-${index}`"
|
|
||||||
>
|
|
||||||
{{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }}
|
|
||||||
</div>
|
|
||||||
<div v-if="parsed.errors.length > 6" class="font-medium">
|
|
||||||
另有 {{ parsed.errors.length - 6 }} 个问题
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 border-t border-border/50 bg-muted/10 px-4 py-2.5 text-[11px] text-muted-foreground">
|
<div
|
||||||
<span class="rounded-md bg-muted px-2 py-1 font-mono text-foreground/80">名称----Key</span>
|
v-if="parsed.errors.length > 6"
|
||||||
<span>名称和 Key 都不能为空</span>
|
class="font-medium"
|
||||||
<span class="ml-auto hidden tabular-nums sm:inline">已识别 {{ parsed.items.length }} 条</span>
|
>
|
||||||
|
另有 {{ parsed.errors.length - 6 }} 个问题
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-t border-border/50 bg-muted/10 px-4 py-2.5 text-[11px] text-muted-foreground">
|
||||||
|
<span class="rounded-md bg-muted px-2 py-1 font-mono text-foreground/80">名称----Key</span>
|
||||||
|
<span>名称和 Key 都不能为空</span>
|
||||||
|
<span class="ml-auto hidden tabular-nums sm:inline">已识别 {{ parsed.items.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -98,7 +111,10 @@
|
|||||||
>{{ item }}</span>
|
>{{ item }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Badge :variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'" class="ml-auto shrink-0 tabular-nums">
|
<Badge
|
||||||
|
:variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'"
|
||||||
|
class="ml-auto shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
{{ selectedApiFormats.length }} 种格式
|
{{ selectedApiFormats.length }} 种格式
|
||||||
</Badge>
|
</Badge>
|
||||||
</header>
|
</header>
|
||||||
@@ -123,12 +139,18 @@
|
|||||||
<header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3">
|
<header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3">
|
||||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span>
|
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-balance text-sm font-semibold">逐项确认</h3>
|
<h3 class="text-balance text-sm font-semibold">
|
||||||
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">展开任意 Key 可修改内容或设置单独配置</p>
|
逐项确认
|
||||||
|
</h3>
|
||||||
|
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
|
||||||
|
展开任意 Key 可修改内容或设置单独配置
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 text-right text-[11px] text-muted-foreground">
|
<div class="shrink-0 text-right text-[11px] text-muted-foreground">
|
||||||
<div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div>
|
<div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div>
|
||||||
<div v-if="customizedItemCount > 0"><span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置</div>
|
<div v-if="customizedItemCount > 0">
|
||||||
|
<span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -156,9 +178,13 @@
|
|||||||
v-if="reviewErrorsByIndex.has(entry.index)"
|
v-if="reviewErrorsByIndex.has(entry.index)"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
class="shrink-0 text-[10px]"
|
class="shrink-0 text-[10px]"
|
||||||
>需修正</Badge>
|
>
|
||||||
|
需修正
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{{ maskSecret(entry.item.apiKey) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ maskSecret(entry.item.apiKey) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden shrink-0 items-center gap-1.5 sm:flex">
|
<div class="hidden shrink-0 items-center gap-1.5 sm:flex">
|
||||||
<span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span>
|
<span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span>
|
||||||
@@ -186,18 +212,30 @@
|
|||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">名称</Label>
|
<Label class="text-xs">名称</Label>
|
||||||
<Input v-model="entry.item.name" class="h-10" placeholder="必填" />
|
<Input
|
||||||
|
v-model="entry.item.name"
|
||||||
|
class="h-10"
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">Key</Label>
|
<Label class="text-xs">Key</Label>
|
||||||
<Input v-model="entry.item.apiKey" class="h-10 font-mono text-xs" placeholder="必填" />
|
<Input
|
||||||
|
v-model="entry.item.apiKey"
|
||||||
|
class="h-10 font-mono text-xs"
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium">单独配置此 Key</div>
|
<div class="text-xs font-medium">
|
||||||
<div class="text-[11px] text-muted-foreground">开启后覆盖第二步中的统一配置</div>
|
单独配置此 Key
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
开启后覆盖第二步中的统一配置
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="entry.item.customized"
|
:model-value="entry.item.customized"
|
||||||
@@ -220,7 +258,12 @@
|
|||||||
v-if="reviewErrorsByIndex.has(entry.index)"
|
v-if="reviewErrorsByIndex.has(entry.index)"
|
||||||
class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive"
|
class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive"
|
||||||
>
|
>
|
||||||
<div v-for="message in reviewErrorsByIndex.get(entry.index)" :key="message">{{ message }}</div>
|
<div
|
||||||
|
v-for="message in reviewErrorsByIndex.get(entry.index)"
|
||||||
|
:key="message"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -230,12 +273,24 @@
|
|||||||
v-if="reviewPageCount > 1"
|
v-if="reviewPageCount > 1"
|
||||||
class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4"
|
class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === 1" @click="changeReviewPage(reviewPage - 1)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-9"
|
||||||
|
:disabled="reviewPage === 1"
|
||||||
|
@click="changeReviewPage(reviewPage - 1)"
|
||||||
|
>
|
||||||
<ChevronLeft class="mr-1 h-4 w-4" />
|
<ChevronLeft class="mr-1 h-4 w-4" />
|
||||||
上一页
|
上一页
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span>
|
<span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span>
|
||||||
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === reviewPageCount" @click="changeReviewPage(reviewPage + 1)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-9"
|
||||||
|
:disabled="reviewPage === reviewPageCount"
|
||||||
|
@click="changeReviewPage(reviewPage + 1)"
|
||||||
|
>
|
||||||
下一页
|
下一页
|
||||||
<ChevronRight class="ml-1 h-4 w-4" />
|
<ChevronRight class="ml-1 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -245,14 +300,35 @@
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<Button class="w-full sm:w-auto" variant="outline" :disabled="importing" @click="handleBack">
|
<Button
|
||||||
<ArrowLeft v-if="currentStep > 1" class="mr-2 h-4 w-4" />
|
class="w-full sm:w-auto"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="importing"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<ArrowLeft
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
{{ currentStep === 1 ? '取消' : '上一步' }}
|
{{ currentStep === 1 ? '取消' : '上一步' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="w-full sm:w-auto" :disabled="primaryActionDisabled" @click="handlePrimaryAction">
|
<Button
|
||||||
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin" />
|
class="w-full sm:w-auto"
|
||||||
<ListPlus v-else-if="currentStep === 3" class="mr-2 h-4 w-4" />
|
:disabled="primaryActionDisabled"
|
||||||
<ArrowRight v-else class="mr-2 h-4 w-4" />
|
@click="handlePrimaryAction"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="importing"
|
||||||
|
class="mr-2 h-4 w-4 animate-spin"
|
||||||
|
/>
|
||||||
|
<ListPlus
|
||||||
|
v-else-if="currentStep === 3"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<ArrowRight
|
||||||
|
v-else
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
{{ primaryActionLabel }}
|
{{ primaryActionLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,8 +28,12 @@
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="api_key">API Key</SelectItem>
|
<SelectItem value="api_key">
|
||||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
API Key
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="bearer">
|
||||||
|
Bearer Token
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,8 +116,12 @@
|
|||||||
|
|
||||||
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium">导入后立即启用</div>
|
<div class="text-xs font-medium">
|
||||||
<div class="text-[11px] text-muted-foreground">关闭后仍会创建,但不会进入调度</div>
|
导入后立即启用
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
关闭后仍会创建,但不会进入调度
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="settings.is_active"
|
:model-value="settings.is_active"
|
||||||
|
|||||||
@@ -100,6 +100,51 @@ describe('auth store logout', () => {
|
|||||||
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['synchronous', 'asynchronous'] as const)(
|
||||||
|
'allows a forced retry after a %s restore failure',
|
||||||
|
async (failureMode) => {
|
||||||
|
const failure = new Error('temporary refresh failure')
|
||||||
|
if (failureMode === 'synchronous') {
|
||||||
|
restoreSessionMock.mockImplementationOnce(() => {
|
||||||
|
throw failure
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
restoreSessionMock.mockRejectedValueOnce(failure)
|
||||||
|
}
|
||||||
|
restoreSessionMock.mockResolvedValueOnce('retry-access-token')
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
await expect(store.restoreSession()).resolves.toBe(false)
|
||||||
|
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
||||||
|
|
||||||
|
await expect(store.restoreSession(true)).resolves.toBe(true)
|
||||||
|
expect(store.token).toBe('retry-access-token')
|
||||||
|
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('deduplicates in-flight restores and allows a refresh after completion', async () => {
|
||||||
|
let resolveRestore!: (token: string) => void
|
||||||
|
const pendingRestore = new Promise<string>((resolve) => {
|
||||||
|
resolveRestore = resolve
|
||||||
|
})
|
||||||
|
restoreSessionMock.mockReturnValueOnce(pendingRestore)
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const firstRestore = store.restoreSession()
|
||||||
|
const secondRestore = store.restoreSession()
|
||||||
|
expect(restoreSessionMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveRestore('restored-access-token')
|
||||||
|
await expect(Promise.all([firstRestore, secondRestore])).resolves.toEqual([true, true])
|
||||||
|
expect(store.token).toBe('restored-access-token')
|
||||||
|
|
||||||
|
restoreSessionMock.mockResolvedValueOnce('refreshed-access-token')
|
||||||
|
await expect(store.restoreSession(true)).resolves.toBe(true)
|
||||||
|
expect(store.token).toBe('refreshed-access-token')
|
||||||
|
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves an existing access token when a forced restore fails', async () => {
|
it('preserves an existing access token when a forced restore fails', async () => {
|
||||||
getTokenMock.mockReturnValue('still-valid-access-token')
|
getTokenMock.mockReturnValue('still-valid-access-token')
|
||||||
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
|
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
sessionRestoreAttempted = true
|
sessionRestoreAttempted = true
|
||||||
const requestAuthStateVersion = authStateVersion
|
const requestAuthStateVersion = authStateVersion
|
||||||
const requestToken = token.value
|
const requestToken = token.value
|
||||||
let request!: Promise<boolean>
|
const request = (async () => {
|
||||||
request = (async () => {
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await apiClient.restoreSession(notifyOtherTabs)
|
const accessToken = await apiClient.restoreSession(notifyOtherTabs)
|
||||||
if (requestAuthStateVersion !== authStateVersion) {
|
if (requestAuthStateVersion !== authStateVersion) {
|
||||||
@@ -82,12 +81,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} finally {
|
|
||||||
if (sessionRestorePromise === request) {
|
|
||||||
sessionRestorePromise = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()
|
})().finally(() => {
|
||||||
|
if (sessionRestorePromise === request) {
|
||||||
|
sessionRestorePromise = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
sessionRestorePromise = request
|
sessionRestorePromise = request
|
||||||
return request
|
return request
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
safeInternalNavigationPath,
|
safeInternalNavigationPath,
|
||||||
} from '../navigationSecurity'
|
} from '../navigationSecurity'
|
||||||
|
|
||||||
|
const unicodeSegments = ['👩\u200d💻', 'راهنمای\u200cکاربر', '\ue000']
|
||||||
|
const controlCodePoints = [
|
||||||
|
...Array.from({ length: 32 }, (_value, index) => index),
|
||||||
|
...Array.from({ length: 33 }, (_value, index) => index + 0x7f),
|
||||||
|
]
|
||||||
|
|
||||||
describe('safeInternalNavigationPath', () => {
|
describe('safeInternalNavigationPath', () => {
|
||||||
it('keeps normalized same-origin paths, queries, and fragments', () => {
|
it('keeps normalized same-origin paths, queries, and fragments', () => {
|
||||||
expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe(
|
expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe(
|
||||||
@@ -13,6 +19,17 @@ describe('safeInternalNavigationPath', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(unicodeSegments)('keeps valid Unicode query values: %s', (segment) => {
|
||||||
|
expect(safeInternalNavigationPath(`/dashboard?search=${segment}`)).toBe(
|
||||||
|
`/dashboard?search=${encodeURIComponent(segment)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||||
|
expect(safeInternalNavigationPath(`/dashboard/before${String.fromCodePoint(codePoint)}after`))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'https://attacker.example/steal',
|
'https://attacker.example/steal',
|
||||||
'//attacker.example/steal',
|
'//attacker.example/steal',
|
||||||
@@ -32,6 +49,17 @@ describe('safeExternalHttpsUrl', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(unicodeSegments)('allows valid Unicode URL paths: %s', (segment) => {
|
||||||
|
expect(safeExternalHttpsUrl(`https://provider.example/docs/${segment}`)).toBe(
|
||||||
|
`https://provider.example/docs/${encodeURIComponent(segment)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||||
|
expect(safeExternalHttpsUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'javascript:alert(1)',
|
'javascript:alert(1)',
|
||||||
'data:text/html,attack',
|
'data:text/html,attack',
|
||||||
@@ -53,6 +81,17 @@ describe('safeExternalWebUrl', () => {
|
|||||||
expect(safeExternalWebUrl(value)).toBe(expected)
|
expect(safeExternalWebUrl(value)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(unicodeSegments)('allows valid Unicode provider websites: %s', (segment) => {
|
||||||
|
expect(safeExternalWebUrl(`https://provider.example/docs/${segment}`)).toBe(
|
||||||
|
`https://provider.example/docs/${encodeURIComponent(segment)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||||
|
expect(safeExternalWebUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'javascript:alert(1)',
|
'javascript:alert(1)',
|
||||||
'data:text/html,attack',
|
'data:text/html,attack',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
|
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
|
||||||
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\u0000-\u001f\u007f]/
|
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{Cc}]/u
|
||||||
|
|
||||||
function safeAbsoluteExternalUrl(
|
function safeAbsoluteExternalUrl(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -38,7 +38,7 @@ export function safeInternalNavigationPath(value: string | null | undefined): st
|
|||||||
!candidate.startsWith('/')
|
!candidate.startsWith('/')
|
||||||
|| candidate.startsWith('//')
|
|| candidate.startsWith('//')
|
||||||
|| candidate.includes('\\')
|
|| candidate.includes('\\')
|
||||||
|| /[\u0000-\u001f\u007f]/.test(candidate)
|
|| /\p{Cc}/u.test(candidate)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,82 +403,82 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-4">
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
class="order-1 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
class="order-1 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
||||||
data-testid="keep-priority-on-conversion"
|
data-testid="keep-priority-on-conversion"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
<span class="font-medium">格式转换保持优先级</span>
|
<span class="font-medium">格式转换保持优先级</span>
|
||||||
<HelpHint
|
<HelpHint
|
||||||
label="格式转换保持优先级"
|
label="格式转换保持优先级"
|
||||||
text="开启后,跨 API 格式转换的候选不会被降级到同格式候选之后;Provider 自身的同名开关仍单独生效。"
|
text="开启后,跨 API 格式转换的候选不会被降级到同格式候选之后;Provider 自身的同名开关仍单独生效。"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
:model-value="keepPriorityOnConversion"
|
|
||||||
:disabled="saving"
|
|
||||||
aria-label="格式转换保持优先级"
|
|
||||||
@update:model-value="updateKeepPriorityOnConversion"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Switch
|
||||||
class="order-4 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
:model-value="keepPriorityOnConversion"
|
||||||
data-testid="sticky-key-attempts"
|
:disabled="saving"
|
||||||
>
|
aria-label="格式转换保持优先级"
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
@update:model-value="updateKeepPriorityOnConversion"
|
||||||
<span class="font-medium">错误重试次数</span>
|
/>
|
||||||
<HelpHint
|
</div>
|
||||||
label="错误重试次数"
|
<div
|
||||||
text="首个候选(缓存亲和命中的 Key)的总尝试次数。2 表示失败后同 Key 重试 1 次再转移;0 或 1 表示不重试。"
|
class="order-4 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
||||||
/>
|
data-testid="sticky-key-attempts"
|
||||||
</div>
|
>
|
||||||
<Input
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
:model-value="stickyKeyAttempts"
|
<span class="font-medium">错误重试次数</span>
|
||||||
type="number"
|
<HelpHint
|
||||||
min="0"
|
label="错误重试次数"
|
||||||
max="99"
|
text="首个候选(缓存亲和命中的 Key)的总尝试次数。2 表示失败后同 Key 重试 1 次再转移;0 或 1 表示不重试。"
|
||||||
class="w-20 shrink-0"
|
|
||||||
:disabled="saving"
|
|
||||||
aria-label="错误重试次数"
|
|
||||||
@update:model-value="updateStickyKeyAttempts"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Input
|
||||||
class="order-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
:model-value="stickyKeyAttempts"
|
||||||
data-testid="cf-heartbeat"
|
type="number"
|
||||||
>
|
min="0"
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
max="99"
|
||||||
<span class="font-medium">CF保持心跳</span>
|
class="w-20 shrink-0"
|
||||||
<HelpHint
|
:disabled="saving"
|
||||||
label="CF保持心跳"
|
aria-label="错误重试次数"
|
||||||
text="同步生图和标准文本非流式失败时保持外层 HTTP 状态为 200,并在响应体中返回错误。"
|
@update:model-value="updateStickyKeyAttempts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<div
|
||||||
:model-value="cfHeartbeat"
|
class="order-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
||||||
:disabled="saving"
|
data-testid="cf-heartbeat"
|
||||||
aria-label="CF保持心跳"
|
>
|
||||||
@update:model-value="updateExecutionPolicy('enable_cf_heartbeat', $event)"
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span class="font-medium">CF保持心跳</span>
|
||||||
|
<HelpHint
|
||||||
|
label="CF保持心跳"
|
||||||
|
text="同步生图和标准文本非流式失败时保持外层 HTTP 状态为 200,并在响应体中返回错误。"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Switch
|
||||||
class="order-2 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
:model-value="cfHeartbeat"
|
||||||
data-testid="cyber-continue-failover"
|
:disabled="saving"
|
||||||
>
|
aria-label="CF保持心跳"
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
@update:model-value="updateExecutionPolicy('enable_cf_heartbeat', $event)"
|
||||||
<span class="font-medium">Cyber继续转移</span>
|
/>
|
||||||
<HelpHint
|
</div>
|
||||||
label="Cyber继续转移"
|
<div
|
||||||
text="响应开始前遇到 Cyber Policy 错误时继续故障转移。"
|
class="order-2 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
||||||
/>
|
data-testid="cyber-continue-failover"
|
||||||
</div>
|
>
|
||||||
<Switch
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
:model-value="cyberContinueFailover"
|
<span class="font-medium">Cyber继续转移</span>
|
||||||
:disabled="saving"
|
<HelpHint
|
||||||
aria-label="Cyber继续转移"
|
label="Cyber继续转移"
|
||||||
@update:model-value="updateExecutionPolicy('cyber_continue_failover', $event)"
|
text="响应开始前遇到 Cyber Policy 错误时继续故障转移。"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="cyberContinueFailover"
|
||||||
|
:disabled="saving"
|
||||||
|
aria-label="Cyber继续转移"
|
||||||
|
@update:model-value="updateExecutionPolicy('cyber_continue_failover', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -574,192 +574,192 @@
|
|||||||
请先在下方选择一个模型,再配置该模型的优先级模式和调度策略。
|
请先在下方选择一个模型,再配置该模型的优先级模式和调度策略。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="sortingScope === 'unified'"
|
v-if="sortingScope === 'unified'"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<RoutingPriorityPolicyEditor
|
<RoutingPriorityPolicyEditor
|
||||||
:config="draft.config_json"
|
:config="draft.config_json"
|
||||||
:model="DEFAULT_ROUTING_POLICY_MODEL"
|
:model="DEFAULT_ROUTING_POLICY_MODEL"
|
||||||
:show-priority-mode="false"
|
:show-priority-mode="false"
|
||||||
:show-scheduling-mode="false"
|
:show-scheduling-mode="false"
|
||||||
subtitle="统一作用于当前策略的所有模型"
|
subtitle="统一作用于当前策略的所有模型"
|
||||||
@update:config="updateDraftConfig"
|
@update:config="updateDraftConfig"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else>
|
<section v-else>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
按模型配置
|
按模型配置
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
选择模型后,在下方配置该模型的提供商排序。
|
选择模型后,在下方配置该模型的提供商排序。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
|
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
v-model="globalModelSearch"
|
v-model="globalModelSearch"
|
||||||
placeholder="搜索模型"
|
placeholder="搜索模型"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
|
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
|
||||||
<button
|
<button
|
||||||
v-for="filter in modelFilters"
|
v-for="filter in modelFilters"
|
||||||
:key="filter.value"
|
:key="filter.value"
|
||||||
type="button"
|
type="button"
|
||||||
class="h-9 rounded-md px-3 font-medium transition-colors"
|
class="h-9 rounded-md px-3 font-medium transition-colors"
|
||||||
:class="modelFilter === filter.value
|
:class="modelFilter === filter.value
|
||||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||||
@click="modelFilter = filter.value"
|
@click="modelFilter = filter.value"
|
||||||
>
|
>
|
||||||
{{ filter.label }}
|
{{ filter.label }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loadingGlobalModels"
|
|
||||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
正在加载模型
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="globalModelsError"
|
|
||||||
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
|
||||||
>
|
|
||||||
{{ globalModelsError }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="modelRows.length === 0"
|
|
||||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="row in modelRows"
|
v-if="loadingGlobalModels"
|
||||||
:key="row.name"
|
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||||
class="rounded-lg border transition-colors"
|
>
|
||||||
:class="selectedPerModelName === row.name
|
正在加载模型
|
||||||
? 'border-primary/50 bg-primary/5'
|
</div>
|
||||||
: 'border-border/60'"
|
<div
|
||||||
|
v-else-if="globalModelsError"
|
||||||
|
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ globalModelsError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="modelRows.length === 0"
|
||||||
|
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
|
||||||
>
|
>
|
||||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
|
|
||||||
@click="selectGlobalModel(row.name)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="row.configured"
|
|
||||||
class="h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Plus
|
|
||||||
v-else
|
|
||||||
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 flex-1">
|
|
||||||
<span class="block truncate font-medium">{{ row.displayName }}</span>
|
|
||||||
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
|
||||||
:disabled="copySourceCandidates.length === 0"
|
|
||||||
title="加载其他模型配置"
|
|
||||||
>
|
|
||||||
<Copy class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
class="max-h-[320px] overflow-y-auto"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="source in copySourceCandidates"
|
|
||||||
:key="source.model"
|
|
||||||
@select="copyModelConfig(source.model)"
|
|
||||||
>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="block truncate text-sm font-medium">{{ source.label }}</span>
|
|
||||||
<span class="block truncate text-xs text-muted-foreground">{{ source.model }}</span>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
|
||||||
:disabled="!canSaveCurrentModel"
|
|
||||||
title="保存到草稿"
|
|
||||||
@click="saveCurrentModel"
|
|
||||||
>
|
|
||||||
<Save class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="hasModelPolicy(activePerModelPolicy.model)"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0"
|
|
||||||
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
|
|
||||||
:disabled="!canRemoveCurrentModel"
|
|
||||||
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
|
|
||||||
@click="removePerModelPolicy(activePerModelPolicy.model)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="shrink-0"
|
|
||||||
@click="selectGlobalModel(row.name)"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedPerModelName === row.name && activePerModelPolicy"
|
v-for="row in modelRows"
|
||||||
class="border-t border-border/60 p-4"
|
:key="row.name"
|
||||||
|
class="rounded-lg border transition-colors"
|
||||||
|
:class="selectedPerModelName === row.name
|
||||||
|
? 'border-primary/50 bg-primary/5'
|
||||||
|
: 'border-border/60'"
|
||||||
>
|
>
|
||||||
<RoutingPriorityPolicyEditor
|
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||||
:config="activeConfigForReading"
|
<button
|
||||||
:model="activePerModelPolicy.model"
|
type="button"
|
||||||
:model-id="globalModelIdFor(activePerModelPolicy.model)"
|
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
|
||||||
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
|
@click="selectGlobalModel(row.name)"
|
||||||
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
|
>
|
||||||
:show-priority-mode="false"
|
<span
|
||||||
:show-scheduling-mode="false"
|
v-if="row.configured"
|
||||||
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
|
class="h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
@update:config="updateEditingConfig"
|
aria-hidden="true"
|
||||||
@update:priority-mode="mode => updateModelPriorityMode(activePerModelPolicy.model, mode)"
|
/>
|
||||||
@update:scheduling-mode="mode => updateModelSchedulingMode(activePerModelPolicy.model, mode)"
|
<Plus
|
||||||
/>
|
v-else
|
||||||
|
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate font-medium">{{ row.displayName }}</span>
|
||||||
|
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||||
|
:disabled="copySourceCandidates.length === 0"
|
||||||
|
title="加载其他模型配置"
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
class="max-h-[320px] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="source in copySourceCandidates"
|
||||||
|
:key="source.model"
|
||||||
|
@select="copyModelConfig(source.model)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-sm font-medium">{{ source.label }}</span>
|
||||||
|
<span class="block truncate text-xs text-muted-foreground">{{ source.model }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||||
|
:disabled="!canSaveCurrentModel"
|
||||||
|
title="保存到草稿"
|
||||||
|
@click="saveCurrentModel"
|
||||||
|
>
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="hasModelPolicy(activePerModelPolicy.model)"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0"
|
||||||
|
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
|
||||||
|
:disabled="!canRemoveCurrentModel"
|
||||||
|
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
|
||||||
|
@click="removePerModelPolicy(activePerModelPolicy.model)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0"
|
||||||
|
@click="selectGlobalModel(row.name)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||||
|
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedPerModelName === row.name && activePerModelPolicy"
|
||||||
|
class="border-t border-border/60 p-4"
|
||||||
|
>
|
||||||
|
<RoutingPriorityPolicyEditor
|
||||||
|
:config="activeConfigForReading"
|
||||||
|
:model="activePerModelPolicy.model"
|
||||||
|
:model-id="globalModelIdFor(activePerModelPolicy.model)"
|
||||||
|
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
|
||||||
|
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
|
||||||
|
:show-priority-mode="false"
|
||||||
|
:show-scheduling-mode="false"
|
||||||
|
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
|
||||||
|
@update:config="updateEditingConfig"
|
||||||
|
@update:priority-mode="mode => updateModelPriorityMode(activePerModelPolicy.model, mode)"
|
||||||
|
@update:scheduling-mode="mode => updateModelSchedulingMode(activePerModelPolicy.model, mode)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user