feat: improve routing policy diagnostics

This commit is contained in:
fawney19
2026-05-28 12:11:43 +08:00
parent 14ad6e9b75
commit 93d3de1644
23 changed files with 2015 additions and 1087 deletions
+194 -194
View File
@@ -131,208 +131,208 @@
</div>
</template>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如:My OIDC Provider"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
</div>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 图标 URL -->
<div>
<Label class="block text-sm font-medium">图标 URL</Label>
<Input
v-model="form.icon_url"
class="mt-1"
placeholder="https://example.com/icon.svg"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
登录页显示的 Provider 图标,留空使用默认图标
</p>
</div>
<!-- 高级选项(折叠) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.scopes_input"
v-model="form.new_display_name"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
placeholder="例如:My OIDC Provider"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔;留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
</p>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
</div>
</details>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 图标 URL -->
<div>
<Label class="block text-sm font-medium">图标 URL</Label>
<Input
v-model="form.icon_url"
class="mt-1"
placeholder="https://example.com/icon.svg"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
登录页显示的 Provider 图标,留空使用默认图标
</p>
</div>
<!-- 高级选项(折叠) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔;留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
</p>
</div>
</div>
</div>
</details>
</div>
<div
v-if="lastTestResult"
+6 -6
View File
@@ -1155,12 +1155,12 @@
>
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
<span
v-if="getQuotaProgressResetDisplayText(item)"
data-testid="pool-quota-reset-text"
class="text-muted-foreground/80 tabular-nums truncate"
:title="getQuotaProgressResetDisplayText(item)"
>{{ getQuotaProgressResetDisplayText(item) }}</span>
</div>
<div class="flex items-center gap-1.5">
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
+395 -313
View File
@@ -3,25 +3,7 @@
<PageHeader
title="调度策略"
description="管理调度分组、模型范围、默认调度模式和规则配置"
>
<template #actions>
<Button
variant="outline"
:disabled="loading"
@click="refreshPage"
>
<RefreshCw
class="mr-2 h-4 w-4"
:class="{ 'animate-spin': loading || loadingGlobalModels }"
/>
刷新
</Button>
<Button @click="startCreate">
<Plus class="mr-2 h-4 w-4" />
新建策略
</Button>
</template>
</PageHeader>
/>
<div class="mt-6 grid gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
<Card class="overflow-hidden">
@@ -35,7 +17,17 @@
共 {{ groups.length }} 个
</p>
</div>
<SlidersHorizontal class="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
:disabled="loading"
aria-label="添加策略"
title="添加策略"
@click="startCreate"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
@@ -47,49 +39,111 @@
正在加载调度策略
</div>
<div
v-else-if="groups.length === 0"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center"
>
<p class="text-sm font-medium">
暂无调度策略
</p>
<p class="mt-1 text-xs text-muted-foreground">
可以先创建一个默认分组
</p>
</div>
<button
v-for="group in groups"
v-else
:key="group.id"
type="button"
class="mb-2 w-full rounded-lg border px-4 py-3 text-left transition-colors"
:class="group.id === selectedGroupId
? 'border-primary/60 bg-primary/10'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/50'"
@click="selectGroup(group)"
class="space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ group.name }}
</p>
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{{ group.description || '未填写描述' }}
</p>
<div
v-if="groups.length === 0 && !isCreating"
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center"
>
<p class="text-sm font-medium">
暂无调度策略
</p>
<p class="mt-1 text-xs text-muted-foreground">
可以先创建一个默认分组
</p>
</div>
<div
v-if="isCreating && draft"
class="rounded-lg border border-primary/60 bg-primary/10 px-4 py-2.5 text-left"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ draft.name || '新调度策略' }}
</p>
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{{ draft.description || '未填写描述' }}
</p>
</div>
<div class="shrink-0">
<Badge :variant="draft.enabled ? 'default' : 'secondary'">
{{ draft.enabled ? '启用' : '停用' }}
</Badge>
</div>
</div>
<div class="mt-1.5 flex items-end justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span v-if="draft.is_system_default">系统默认</span>
<span>{{ draft.config_json.allowed_models.length || '全部' }} 模型范围</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="draft.enabled
? 'text-primary hover:text-primary'
: 'text-muted-foreground/70 hover:text-foreground'"
:aria-label="draft.enabled ? '停用策略' : '启用策略'"
:title="draft.enabled ? '已启用,点击停用' : '已停用,点击启用'"
@click="setDraftEnabled(!draft.enabled)"
>
<Power class="h-4 w-4" />
</Button>
</div>
<Badge
:variant="group.enabled ? 'default' : 'secondary'"
class="shrink-0"
>
{{ group.enabled ? '启用' : '停用' }}
</Badge>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>v{{ group.version }}</span>
<span v-if="group.is_system_default">系统默认</span>
<span>{{ group.config_json.allowed_models.length || '全部' }} 模型范围</span>
<div
v-for="group in groups"
:key="group.id"
role="button"
tabindex="0"
class="w-full rounded-lg border px-4 py-2.5 text-left transition-colors"
:class="group.id === selectedGroupId
? 'border-primary/60 bg-primary/10'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/50'"
@click="selectGroup(group)"
@keydown.enter.prevent="selectGroup(group)"
@keydown.space.prevent="selectGroup(group)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ group.name }}
</p>
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{{ group.description || '未填写描述' }}
</p>
</div>
<div class="shrink-0">
<Badge :variant="displayGroupEnabled(group) ? 'default' : 'secondary'">
{{ displayGroupEnabled(group) ? '启用' : '停用' }}
</Badge>
</div>
</div>
<div class="mt-1.5 flex items-end justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span v-if="group.is_system_default">系统默认</span>
<span>{{ group.config_json.allowed_models.length || '全部' }} 模型范围</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="displayGroupEnabled(group)
? 'text-primary hover:text-primary'
: 'text-muted-foreground/70 hover:text-foreground'"
:aria-label="displayGroupEnabled(group) ? '停用策略' : '启用策略'"
:title="displayGroupEnabled(group) ? '已启用,点击停用' : '已停用,点击启用'"
@keydown.stop
@click.stop="updateGroupEnabled(group, !displayGroupEnabled(group))"
>
<Power class="h-4 w-4" />
</Button>
</div>
</div>
</button>
</div>
</div>
</Card>
@@ -104,12 +158,6 @@
<h2 class="text-base font-semibold">
{{ isCreating ? '新建调度策略' : draft.name || '未命名策略' }}
</h2>
<Badge
v-if="!isCreating"
variant="outline"
>
v{{ draft.version }}
</Badge>
<Badge
v-if="draft.is_system_default"
variant="secondary"
@@ -123,42 +171,50 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
:class="draft.is_system_default ? 'border-primary/50 bg-primary/10 text-primary' : ''"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="draft.is_system_default
? 'text-primary hover:text-primary'
: 'text-muted-foreground/70 hover:text-foreground'"
:aria-label="draft.is_system_default ? '系统默认' : '设为系统默认'"
:title="draft.is_system_default ? '系统默认' : '设为系统默认'"
@click="draft.is_system_default = !draft.is_system_default"
>
<Star class="mr-2 h-4 w-4" />
{{ draft.is_system_default ? '系统默认' : '设为系统默认' }}
<Star class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
:disabled="!canSaveDraft"
aria-label="保存"
title="保存"
@click="saveDraft"
>
<Save
class="mr-2 h-4 w-4"
class="h-4 w-4"
:class="{ 'animate-pulse': saving }"
/>
保存
</Button>
<Button
v-if="!isCreating"
variant="destructive"
size="sm"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
:disabled="deleting"
aria-label="删除"
title="删除"
@click="deleteDraft"
>
<Trash2 class="mr-2 h-4 w-4" />
删除
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="space-y-6 p-5">
<div class="grid gap-3 lg:grid-cols-[240px_minmax(0,1fr)_160px]">
<div class="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_320px]">
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">名称</span>
<Input
@@ -173,45 +229,35 @@
placeholder="例如:默认策略 / 高推理策略 / 号池优先策略"
/>
</label>
<div class="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm">
<span>启用策略</span>
<Switch v-model="draft.enabled" />
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">
维度
</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'unified'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('unified')"
>
统一调度
</button>
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'per_model'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('per_model')"
>
区分模型
</button>
</div>
</div>
</div>
<section class="space-y-3 rounded-lg border border-border/60 p-4">
<div>
<h3 class="text-sm font-medium">
排序作用范围
</h3>
<p class="mt-1 text-xs text-muted-foreground">
统一排序对策略内全部模型生效;按模型排序需先挑选模型,再逐个配置排序与调度方式。
</p>
</div>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 sm:max-w-[320px]">
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'unified'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('unified')"
>
统一排序
</button>
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'per_model'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('per_model')"
>
按模型排序
</button>
</div>
</section>
<section
v-if="sortingScope === 'unified'"
class="space-y-4"
@@ -225,7 +271,7 @@
统一作用于策略范围内的全部模型。
</p>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
@@ -284,28 +330,20 @@
/>
</section>
<section
v-else
class="space-y-4"
>
<div class="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<div class="flex flex-col gap-3 rounded-lg border border-border/60 p-3">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium">全局模型</span>
<Badge variant="outline">
{{ filteredGlobalModels.length }}
</Badge>
</div>
<section v-else>
<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">
<Input
v-model="globalModelSearch"
placeholder="搜索模型"
class="w-full"
/>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
<button
v-for="filter in modelFilterOptions"
v-for="filter in modelFilters"
:key="filter.value"
type="button"
class="h-7 rounded-md px-2 text-xs font-medium transition-colors"
class="h-9 rounded-md px-3 font-medium transition-colors"
:class="modelFilter === filter.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@@ -314,82 +352,71 @@
{{ filter.label }}
</button>
</div>
<div
v-if="loadingGlobalModels"
class="rounded-md border border-dashed border-border/70 px-3 py-4 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="globalModels.length === 0"
class="rounded-md border border-dashed border-border/70 px-3 py-4 text-center text-xs text-muted-foreground"
>
暂无可选择的全局模型
</div>
<div
v-else-if="filteredGlobalModels.length === 0"
class="rounded-md border border-dashed border-border/70 px-3 py-4 text-center text-xs text-muted-foreground"
>
未匹配到模型
</div>
<div
v-else
class="max-h-[640px] overflow-y-auto"
>
<button
v-for="model in filteredGlobalModels"
:key="model.id"
type="button"
class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="activePerModelPolicy?.model === model.name
? 'bg-primary/10 text-foreground'
: 'hover:bg-muted/60'"
@click="selectGlobalModel(model.name)"
>
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="hasModelPolicy(model.name)
? 'bg-primary'
: 'bg-muted-foreground/20'"
:title="hasModelPolicy(model.name) ? '已配置' : '未配置'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{{ model.display_name || model.name }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ model.name }}</span>
</span>
</button>
</div>
</div>
<div class="min-w-0 rounded-lg border border-border/60 p-4">
<template v-if="activePerModelPolicy">
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0 space-y-1 text-sm">
<span class="text-muted-foreground">当前模型</span>
<div class="truncate text-sm font-medium">
{{ globalModelLabel(activePerModelPolicy.model) }}
</div>
<div class="truncate text-xs text-muted-foreground">
{{ activePerModelPolicy.model }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<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
v-for="row in modelRows"
:key="row.name"
class="rounded-lg border transition-colors"
:class="selectedPerModelName === row.name
? 'border-primary/50 bg-primary/5'
: 'border-border/60'"
>
<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="outline"
size="sm"
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="mr-2 h-4 w-4" />
加载
<Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -410,108 +437,110 @@
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
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="mr-2 h-4 w-4"
/>
保存到草稿
<Save class="h-4 w-4" />
</Button>
<Button
v-if="hasModelPolicy(activePerModelPolicy.model)"
type="button"
:variant="canRemoveCurrentModel ? 'destructive' : 'outline'"
size="sm"
:class="canRemoveCurrentModel ? 'shadow-sm' : 'text-muted-foreground'"
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="mr-2 h-4 w-4" />
移除
<Trash2 class="h-4 w-4" />
</Button>
</div>
</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 class="mb-4 space-y-3 rounded-lg border border-border/60 p-4">
<div>
<div
v-if="selectedPerModelName === row.name && activePerModelPolicy"
class="border-t border-border/60 p-4"
>
<div class="mb-4 space-y-3 rounded-lg border border-border/60 p-4">
<h3 class="text-sm font-medium">
优先级模式与调度策略
</h3>
<p class="mt-1 text-xs text-muted-foreground">
仅作用于当前选中的模型。
</p>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'provider'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'global_key'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'provider'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'global_key'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelSchedulingMode(activePerModelPolicy.model) === mode.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelSchedulingMode(activePerModelPolicy.model, mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelSchedulingMode(activePerModelPolicy.model) === mode.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelSchedulingMode(activePerModelPolicy.model, mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
<RoutingPriorityPolicyEditor
:config="activeConfigForReading"
:model="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>
<RoutingPriorityPolicyEditor
:config="activeConfigForReading"
:model="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)"
/>
</template>
<div
v-else
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
>
在左侧添加模型后即可配置
</div>
</div>
</div>
@@ -556,10 +585,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Copy, Key, Layers, Plus, RefreshCw, Save, SlidersHorizontal, Star, Trash2 } from 'lucide-vue-next'
import { ChevronDown, Copy, Key, Layers, Plus, Power, Save, Star, Trash2 } from 'lucide-vue-next'
import { PageContainer, PageHeader } from '@/components/layout'
import { Badge, Button, Card, Input, Switch } from '@/components/ui'
import { Badge, Button, Card, Input } from '@/components/ui'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { AlertDialog } from '@/components/common'
import {
@@ -605,7 +634,12 @@ interface RoutingGroupDraft {
}
type SortingScope = 'unified' | 'per_model'
type ModelFilter = 'all' | 'configured' | 'unconfigured'
type ModelFilter = 'configured' | 'unconfigured'
const modelFilters: Array<{ value: ModelFilter; label: string }> = [
{ value: 'unconfigured', label: '未配置' },
{ value: 'configured', label: '已配置' },
]
const { success, error: showError } = useToast()
@@ -615,12 +649,6 @@ const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> =
{ value: 'fixed_order', label: '固定顺序' },
]
const modelFilterOptions: Array<{ value: ModelFilter; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'configured', label: '已配置' },
{ value: 'unconfigured', label: '未配置' },
]
const groups = ref<RoutingGroupRecord[]>([])
const selectedGroupId = ref<string | null>(null)
const draft = ref<RoutingGroupDraft | null>(null)
@@ -629,7 +657,7 @@ const sortingScope = ref<SortingScope>('unified')
const selectedPerModelName = ref<string | null>(null)
const editingConfig = ref<RoutingGroupConfig | null>(null)
const globalModelSearch = ref('')
const modelFilter = ref<ModelFilter>('all')
const modelFilter = ref<ModelFilter>('unconfigured')
const globalModels = ref<GlobalModelResponse[]>([])
const loadingGlobalModels = ref(false)
const globalModelsError = ref<string | null>(null)
@@ -668,22 +696,53 @@ const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
})
const filteredGlobalModels = computed(() => {
interface ModelRow {
name: string
displayName: string
configured: boolean
}
const modelRows = computed<ModelRow[]>(() => {
const query = globalModelSearch.value.trim().toLowerCase()
const filter = modelFilter.value
const models = [...globalModels.value].sort((left, right) =>
left.name.localeCompare(right.name)
)
return models.filter(model => {
if (query
&& !model.name.toLowerCase().includes(query)
&& !model.display_name?.toLowerCase().includes(query)) {
return false
}
if (filter === 'configured' && !hasModelPolicy(model.name)) return false
if (filter === 'unconfigured' && hasModelPolicy(model.name)) return false
return true
})
const seen = new Set<string>()
const rows: ModelRow[] = []
for (const policy of perModelPolicies.value) {
const name = policy.model
const found = globalModels.value.find(item => item.name === name)
rows.push({
name,
displayName: found?.display_name || name,
configured: true,
})
seen.add(name)
}
for (const model of globalModels.value) {
if (seen.has(model.name)) continue
rows.push({
name: model.name,
displayName: model.display_name || model.name,
configured: false,
})
}
return rows
.filter(row => {
if (modelFilter.value === 'configured' && !row.configured) return false
if (modelFilter.value === 'unconfigured' && row.configured) return false
if (!query) return true
return (
row.name.toLowerCase().includes(query)
|| row.displayName.toLowerCase().includes(query)
)
})
.sort((left, right) => {
if (left.configured !== right.configured) {
return left.configured ? -1 : 1
}
return left.name.localeCompare(right.name)
})
})
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
@@ -730,13 +789,32 @@ function selectGroup(group: RoutingGroupRecord): void {
resetEditingConfig()
}
function displayGroupEnabled(group: RoutingGroupRecord): boolean {
if (!isCreating.value && group.id === selectedGroupId.value && draft.value) {
return draft.value.enabled
}
return group.enabled
}
function setDraftEnabled(value: boolean): void {
if (!draft.value) return
draft.value.enabled = value
}
function updateGroupEnabled(group: RoutingGroupRecord, value: boolean): void {
if (isCreating.value || group.id !== selectedGroupId.value || !draft.value) {
selectGroup(group)
}
setDraftEnabled(value)
}
function startCreate(): void {
isCreating.value = true
selectedGroupId.value = null
draft.value = {
name: '新调度策略',
description: '',
enabled: true,
enabled: false,
is_system_default: groups.value.length === 0,
config_json: createEmptyRoutingGroupConfig(),
version: 1,
@@ -803,7 +881,6 @@ const canRemoveCurrentModel = computed(() => {
&& currentModelPersisted.value
&& !saving.value
&& !editingDirty.value
&& !draftDirty.value
})
function syncEditorStateFromConfig(config: RoutingGroupConfig): void {
@@ -863,7 +940,7 @@ function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
function removePerModelPolicy(model: string): void {
if (!draft.value) return
if (perModelEditingActive.value && (editingDirty.value || draftDirty.value)) {
if (perModelEditingActive.value && editingDirty.value) {
showError('请先保存当前改动后再移除模型')
return
}
@@ -873,18 +950,27 @@ function removePerModelPolicy(model: string): void {
if (selectedPerModelName.value === model) {
selectedPerModelName.value = null
}
modelFilter.value = 'unconfigured'
updateDraftConfig(next)
resetEditingConfig()
}
function selectGlobalModel(model: string): void {
if (!model) return
if (model === selectedPerModelName.value) return
if (model === selectedPerModelName.value) {
resetEditingConfig()
selectedPerModelName.value = null
return
}
const shouldAddModel = !hasModelPolicy(model)
if (perModelEditingActive.value && editingDirty.value) {
switchModelTarget.value = model
switchModelDialogOpen.value = true
return
}
if (shouldAddModel) {
resetEditingConfig()
}
selectedPerModelName.value = model
}
@@ -1013,11 +1099,6 @@ function replaceGroup(group: RoutingGroupRecord): void {
selectGroup(normalized)
}
function refreshPage(): void {
void fetchGroups()
void loadGlobalModels()
}
async function fetchGroups(): Promise<void> {
loading.value = true
try {
@@ -1106,6 +1187,7 @@ function saveCurrentModel(): void {
next = { ...next, allowed_models: [...next.allowed_models, model] }
}
updateDraftConfig(next)
modelFilter.value = 'configured'
resetEditingConfig()
success('当前模型配置已保存到草稿,点击外层保存后生效')
}
@@ -33,375 +33,375 @@
</template>
<div class="space-y-5">
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<CloudUpload class="w-4.5 h-4.5" />
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<CloudUpload class="w-4.5 h-4.5" />
</div>
<div class="min-w-0">
<h4 class="text-sm font-medium">
自动备份
</h4>
<p class="text-xs text-muted-foreground mt-0.5">
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
</p>
</div>
</div>
<div class="min-w-0">
<h4 class="text-sm font-medium">
自动备份
</h4>
<p class="text-xs text-muted-foreground mt-0.5">
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
</p>
</div>
</div>
<Switch
:model-value="backup.config.value.enabled"
@update:model-value="backup.config.value.enabled = $event"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label
for="backup-scope"
class="block text-sm font-medium"
>
备份范围
</Label>
<Select
:model-value="backup.config.value.scope"
@update:model-value="setScope"
>
<SelectTrigger
id="backup-scope"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="config">
配置数据
</SelectItem>
<SelectItem value="users">
用户数据
</SelectItem>
<SelectItem value="data">
完整备份
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label
for="backup-bucket"
class="block text-sm font-medium"
>
Bucket
</Label>
<Input
id="backup-bucket"
:model-value="backup.config.value.bucket"
class="mt-1"
placeholder="aether-backups"
@update:model-value="backup.config.value.bucket = String($event)"
<Switch
:model-value="backup.config.value.enabled"
@update:model-value="backup.config.value.enabled = $event"
/>
</div>
<div>
<Label
for="backup-endpoint"
class="block text-sm font-medium"
>
Endpoint
</Label>
<Input
id="backup-endpoint"
:model-value="backup.config.value.endpoint"
class="mt-1"
placeholder="https://s3.example.com"
@update:model-value="backup.config.value.endpoint = String($event)"
/>
</div>
<div>
<Label
for="backup-access-key"
class="block text-sm font-medium"
>
Access Key ID
</Label>
<Input
id="backup-access-key"
:model-value="backup.config.value.accessKeyId"
class="mt-1"
autocomplete="off"
@update:model-value="backup.config.value.accessKeyId = String($event)"
/>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<Label
for="backup-secret-key"
class="block text-sm font-medium"
>
Secret Access Key
</Label>
<button
v-if="backup.config.value.secretAccessKeyIsSet"
type="button"
class="text-xs text-muted-foreground hover:text-foreground"
:disabled="backup.saving.value"
@click="backup.clearS3SecretAccessKey"
>
清除
</button>
</div>
<div class="relative mt-1">
<Input
id="backup-secret-key"
:model-value="backup.config.value.secretAccessKey"
masked
disable-autofill
autocomplete="new-password"
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
@update:model-value="backup.config.value.secretAccessKey = String($event)"
/>
<KeyRound
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
/>
</div>
</div>
<div>
<Label
for="backup-retention"
class="block text-sm font-medium"
>
最多保留备份数
</Label>
<Input
id="backup-retention"
:model-value="backup.config.value.retentionCount"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('retentionCount', $event, 1)"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
<div>
<Label
for="backup-interval"
class="block text-sm font-medium"
>
周期间隔
</Label>
<Input
id="backup-interval"
:model-value="backup.config.value.scheduleInterval"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('scheduleInterval', $event, 1)"
/>
</div>
<div>
<Label
for="backup-unit"
class="block text-sm font-medium"
>
周期单位
</Label>
<Select
:model-value="backup.config.value.scheduleUnit"
@update:model-value="setScheduleUnit"
>
<SelectTrigger
id="backup-unit"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">
小时
</SelectItem>
<SelectItem value="days">
天
</SelectItem>
<SelectItem value="weeks">
周
</SelectItem>
<SelectItem value="months">
月
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Button
variant="ghost"
size="sm"
class="px-0"
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
>
<ChevronDown
v-if="backup.advancedOpen.value"
class="w-3.5 h-3.5 mr-1.5"
/>
<ChevronRight
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
高级选项
</Button>
<div
v-if="backup.advancedOpen.value"
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label
for="backup-region"
for="backup-scope"
class="block text-sm font-medium"
>
Region
</Label>
<Input
id="backup-region"
:model-value="backup.config.value.region"
class="mt-1"
placeholder="auto"
@update:model-value="backup.config.value.region = String($event)"
/>
</div>
<div>
<Label
for="backup-prefix"
class="block text-sm font-medium"
>
Prefix
</Label>
<Input
id="backup-prefix"
:model-value="backup.config.value.prefix"
class="mt-1"
placeholder="aether/backups/"
@update:model-value="backup.config.value.prefix = String($event)"
/>
</div>
<div>
<Label
for="backup-hour"
class="block text-sm font-medium"
>
执行小时
</Label>
<Input
id="backup-hour"
:model-value="backup.config.value.scheduleHour"
type="number"
min="0"
max="23"
class="mt-1"
@update:model-value="setNumber('scheduleHour', $event, 0)"
/>
</div>
<div>
<Label
for="backup-minute"
class="block text-sm font-medium"
>
执行分钟
</Label>
<Input
id="backup-minute"
:model-value="backup.config.value.scheduleMinute"
type="number"
min="0"
max="59"
class="mt-1"
@update:model-value="setNumber('scheduleMinute', $event, 0)"
/>
</div>
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
<Label
for="backup-weekday"
class="block text-sm font-medium"
>
星期
备份范围
</Label>
<Select
:model-value="String(backup.config.value.scheduleWeekday)"
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
:model-value="backup.config.value.scope"
@update:model-value="setScope"
>
<SelectTrigger
id="backup-weekday"
id="backup-scope"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in weekdays"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
<SelectItem value="config">
配置数据
</SelectItem>
<SelectItem value="users">
用户数据
</SelectItem>
<SelectItem value="data">
完整备份
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="backup.config.value.scheduleUnit === 'months'">
<div>
<Label
for="backup-month-day"
for="backup-bucket"
class="block text-sm font-medium"
>
每月日期
Bucket
</Label>
<Input
id="backup-month-day"
:model-value="backup.config.value.scheduleMonthDay"
id="backup-bucket"
:model-value="backup.config.value.bucket"
class="mt-1"
placeholder="aether-backups"
@update:model-value="backup.config.value.bucket = String($event)"
/>
</div>
<div>
<Label
for="backup-endpoint"
class="block text-sm font-medium"
>
Endpoint
</Label>
<Input
id="backup-endpoint"
:model-value="backup.config.value.endpoint"
class="mt-1"
placeholder="https://s3.example.com"
@update:model-value="backup.config.value.endpoint = String($event)"
/>
</div>
<div>
<Label
for="backup-access-key"
class="block text-sm font-medium"
>
Access Key ID
</Label>
<Input
id="backup-access-key"
:model-value="backup.config.value.accessKeyId"
class="mt-1"
autocomplete="off"
@update:model-value="backup.config.value.accessKeyId = String($event)"
/>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<Label
for="backup-secret-key"
class="block text-sm font-medium"
>
Secret Access Key
</Label>
<button
v-if="backup.config.value.secretAccessKeyIsSet"
type="button"
class="text-xs text-muted-foreground hover:text-foreground"
:disabled="backup.saving.value"
@click="backup.clearS3SecretAccessKey"
>
清除
</button>
</div>
<div class="relative mt-1">
<Input
id="backup-secret-key"
:model-value="backup.config.value.secretAccessKey"
masked
disable-autofill
autocomplete="new-password"
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
@update:model-value="backup.config.value.secretAccessKey = String($event)"
/>
<KeyRound
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
/>
</div>
</div>
<div>
<Label
for="backup-retention"
class="block text-sm font-medium"
>
最多保留备份数
</Label>
<Input
id="backup-retention"
:model-value="backup.config.value.retentionCount"
type="number"
min="1"
max="31"
class="mt-1"
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
@update:model-value="setNumber('retentionCount', $event, 1)"
/>
</div>
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<span class="text-sm font-medium">Path Style</span>
<Switch
:model-value="backup.config.value.pathStyle"
@update:model-value="backup.config.value.pathStyle = $event"
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
<div>
<Label
for="backup-interval"
class="block text-sm font-medium"
>
周期间隔
</Label>
<Input
id="backup-interval"
:model-value="backup.config.value.scheduleInterval"
type="number"
min="1"
class="mt-1"
@update:model-value="setNumber('scheduleInterval', $event, 1)"
/>
</div>
<div>
<Label
for="backup-compression"
for="backup-unit"
class="block text-sm font-medium"
>
压缩格式
周期单位
</Label>
<Select
:model-value="backup.config.value.compression"
@update:model-value="backup.config.value.compression = $event"
:model-value="backup.config.value.scheduleUnit"
@update:model-value="setScheduleUnit"
>
<SelectTrigger
id="backup-compression"
id="backup-unit"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zstd">
zstd
<SelectItem value="hours">
小时
</SelectItem>
<SelectItem value="days">
天
</SelectItem>
<SelectItem value="weeks">
周
</SelectItem>
<SelectItem value="months">
月
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<Button
variant="ghost"
size="sm"
class="px-0"
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
>
<ChevronDown
v-if="backup.advancedOpen.value"
class="w-3.5 h-3.5 mr-1.5"
/>
<ChevronRight
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
高级选项
</Button>
<div
v-if="backup.advancedOpen.value"
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label
for="backup-region"
class="block text-sm font-medium"
>
Region
</Label>
<Input
id="backup-region"
:model-value="backup.config.value.region"
class="mt-1"
placeholder="auto"
@update:model-value="backup.config.value.region = String($event)"
/>
</div>
<div>
<Label
for="backup-prefix"
class="block text-sm font-medium"
>
Prefix
</Label>
<Input
id="backup-prefix"
:model-value="backup.config.value.prefix"
class="mt-1"
placeholder="aether/backups/"
@update:model-value="backup.config.value.prefix = String($event)"
/>
</div>
<div>
<Label
for="backup-hour"
class="block text-sm font-medium"
>
执行小时
</Label>
<Input
id="backup-hour"
:model-value="backup.config.value.scheduleHour"
type="number"
min="0"
max="23"
class="mt-1"
@update:model-value="setNumber('scheduleHour', $event, 0)"
/>
</div>
<div>
<Label
for="backup-minute"
class="block text-sm font-medium"
>
执行分钟
</Label>
<Input
id="backup-minute"
:model-value="backup.config.value.scheduleMinute"
type="number"
min="0"
max="59"
class="mt-1"
@update:model-value="setNumber('scheduleMinute', $event, 0)"
/>
</div>
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
<Label
for="backup-weekday"
class="block text-sm font-medium"
>
星期
</Label>
<Select
:model-value="String(backup.config.value.scheduleWeekday)"
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
>
<SelectTrigger
id="backup-weekday"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in weekdays"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="backup.config.value.scheduleUnit === 'months'">
<Label
for="backup-month-day"
class="block text-sm font-medium"
>
每月日期
</Label>
<Input
id="backup-month-day"
:model-value="backup.config.value.scheduleMonthDay"
type="number"
min="1"
max="31"
class="mt-1"
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
/>
</div>
<div class="flex items-center justify-between rounded-lg border border-border p-4">
<span class="text-sm font-medium">Path Style</span>
<Switch
:model-value="backup.config.value.pathStyle"
@update:model-value="backup.config.value.pathStyle = $event"
/>
</div>
<div>
<Label
for="backup-compression"
class="block text-sm font-medium"
>
压缩格式
</Label>
<Select
:model-value="backup.config.value.compression"
@update:model-value="backup.config.value.compression = $event"
>
<SelectTrigger
id="backup-compression"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zstd">
zstd
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</CardSection>
</PageContainer>
+3 -3
View File
@@ -111,9 +111,9 @@
>
{{ apiKey.name }}
</div>
<div class="text-xs text-muted-foreground mt-0.5">
创建于 {{ formatDate(apiKey.created_at) }}
</div>
<div class="text-xs text-muted-foreground mt-0.5">
创建于 {{ formatDate(apiKey.created_at) }}
</div>
<div class="text-xs text-muted-foreground mt-0.5 truncate">
IP 限制:{{ formatIpRules(apiKey.ip_rules) }}
</div>