mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
refactor(ui): refine settings and account interactions
Split settings into focused cards while retaining the original tabs and page layout. Simplify save feedback and help controls, preserve settings drafts, and align account dialog and model restriction widths. Keep select option scrolling tied to keyboard navigation and focus so hovering does not shift the dropdown. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
|
||||
import { useModalDrag } from './useModalDrag'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ModalSize = 'sm' | 'md' | 'md-wide' | 'lg' | 'xl'
|
||||
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -58,10 +58,11 @@ const focusableSelector = [
|
||||
].join(',')
|
||||
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
'sm': 'max-w-md',
|
||||
'md': 'max-w-xl',
|
||||
'md-wide': 'max-w-2xl',
|
||||
'lg': 'max-w-3xl',
|
||||
'xl': 'max-w-5xl',
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
|
||||
@@ -210,6 +210,17 @@ function moveActive(delta: number) {
|
||||
const current = indexes.indexOf(activeIndex.value)
|
||||
const next = current === -1 ? (delta > 0 ? 0 : indexes.length - 1) : current + delta
|
||||
activeIndex.value = indexes[(next + indexes.length) % indexes.length]
|
||||
// 只有键盘导航主动定位;鼠标悬停不能把半露出的选项滚入视口。
|
||||
scrollActiveIntoView()
|
||||
}
|
||||
|
||||
function handleOptionFocus(index: number) {
|
||||
const option = props.options[index]
|
||||
if (!option || option.disabled)
|
||||
return
|
||||
|
||||
activeIndex.value = index
|
||||
scrollActiveIntoView()
|
||||
}
|
||||
|
||||
function chooseOption(option: SelectOption, index: number) {
|
||||
@@ -282,8 +293,6 @@ function optionClasses(option: SelectOption, index: number) {
|
||||
]
|
||||
}
|
||||
|
||||
watch(activeIndex, scrollActiveIntoView, { flush: 'post' })
|
||||
|
||||
watch(
|
||||
() => [props.options, props.size, model.value],
|
||||
async () => {
|
||||
@@ -382,7 +391,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
:disabled="option.disabled"
|
||||
:class="optionClasses(option, index)"
|
||||
@mouseenter="activeIndex = option.disabled ? activeIndex : index"
|
||||
@focus="activeIndex = option.disabled ? activeIndex : index"
|
||||
@focus="handleOptionFocus(index)"
|
||||
@mousedown.prevent
|
||||
@click="chooseOption(option, index)"
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ function resolveModal(
|
||||
title: '账号设置',
|
||||
description: '设置将应用于本次导入的账号',
|
||||
tone: 'neutral' as const,
|
||||
size: 'md' as const,
|
||||
size: 'md-wide' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="编辑账号"
|
||||
size="lg"
|
||||
size="md-wide"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<div v-if="account" class="grid gap-5">
|
||||
|
||||
@@ -125,7 +125,7 @@ watch(search, () => {
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
<BaseSegmented v-model="mode" class="w-full" label="模型限制模式" :options="modes" :disabled="disabled" />
|
||||
<BaseSegmented v-model="mode" class="w-80 max-w-full" label="模型限制模式" :options="modes" :disabled="disabled" />
|
||||
</BaseFormItem>
|
||||
<template v-if="restricted">
|
||||
<BaseInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, Gauge, Snowflake, Timer } from '@lucide/vue'
|
||||
import { Activity, CircleAlert, Gauge, Snowflake, Timer } from '@lucide/vue'
|
||||
import { useId } from 'vue'
|
||||
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
@@ -15,18 +17,19 @@ const durationSeconds = defineModel<string>('durationSeconds', { required: true
|
||||
const probeEnabled = defineModel<boolean>('probeEnabled', { required: true })
|
||||
const probeModel = defineModel<string>('probeModel', { required: true })
|
||||
const adaptiveConcurrency = defineModel<boolean>('adaptiveConcurrency', { required: true })
|
||||
const adaptiveConcurrencyHintId = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
title="账号自动冻结"
|
||||
description="容量类错误高频出现时暂停账号调度,在账号管理中显示为限流中"
|
||||
title="过载策略"
|
||||
description="上游繁忙或服务不可用时,使用过载策略保护"
|
||||
>
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
class="col-span-full justify-self-start"
|
||||
label="启用自动冻结"
|
||||
label="启用策略"
|
||||
show-label
|
||||
/>
|
||||
|
||||
@@ -119,16 +122,29 @@ const adaptiveConcurrency = defineModel<boolean>('adaptiveConcurrency', { requir
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
<div class="col-span-full flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div class="col-span-full flex items-center gap-1">
|
||||
<BaseCheckbox
|
||||
v-model="adaptiveConcurrency"
|
||||
:disabled="!enabled"
|
||||
label="自适应并发下调"
|
||||
show-label
|
||||
/>
|
||||
<p class="m-0 text-cp-sm leading-5 text-cp-text-secondary">
|
||||
修改账号并发上限,恢复后不自动调高
|
||||
</p>
|
||||
<BasePopover class="-my-1" trigger="hover-click" placement="top-start" :hover-delay="240">
|
||||
<template #trigger="{ open }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
|
||||
aria-label="自适应并发下调说明"
|
||||
:aria-expanded="open"
|
||||
:aria-describedby="open ? adaptiveConcurrencyHintId : undefined"
|
||||
>
|
||||
<CircleAlert class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
<p :id="adaptiveConcurrencyHintId" role="tooltip" class="m-0 max-w-72 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
|
||||
修改账号并发上限,恢复后不自动调高
|
||||
</p>
|
||||
</BasePopover>
|
||||
</div>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { GitBranch, Plus, Trash2 } from '@lucide/vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
@@ -38,28 +38,17 @@ const emit = defineEmits<{
|
||||
<span v-if="error" class="text-xs font-emphasis text-cp-error-text">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-cp-sm font-emphasis text-cp-text-secondary">
|
||||
<GitBranch class="size-4 text-cp-primary-text" />
|
||||
全局模型映射
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-cp bg-cp-fill-quaternary px-4 py-4 text-cp font-emphasis text-cp-text-quaternary"
|
||||
>
|
||||
正在加载模型映射...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mappings.length === 0"
|
||||
class="rounded-cp bg-cp-fill-quaternary px-4 py-4 text-cp font-emphasis text-cp-text-quaternary"
|
||||
>
|
||||
暂无模型映射
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div v-else-if="mappings.length > 0" class="grid max-w-6xl gap-3">
|
||||
<div
|
||||
v-for="(row, index) in mappings"
|
||||
:key="index"
|
||||
class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-3 rounded-cp-card bg-cp-fill-quaternary p-3"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-cp-card bg-cp-fill-quaternary p-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<BaseInput
|
||||
:model-value="row.requestedModel"
|
||||
@@ -67,14 +56,16 @@ const emit = defineEmits<{
|
||||
aria-label="请求模型"
|
||||
@update:model-value="emit('updateMapping', index, 'requestedModel', $event)"
|
||||
/>
|
||||
<span class="text-cp-text-quaternary">→</span>
|
||||
<span class="hidden text-cp-text-quaternary sm:block" aria-hidden="true">→</span>
|
||||
<BaseInput
|
||||
class="col-start-1 row-start-2 sm:col-start-auto sm:row-start-auto"
|
||||
:model-value="row.upstreamModel"
|
||||
placeholder="上游模型"
|
||||
aria-label="上游模型名称"
|
||||
@update:model-value="emit('updateMapping', index, 'upstreamModel', $event)"
|
||||
/>
|
||||
<BaseIconButton
|
||||
class="col-start-2 row-span-2 row-start-1 sm:col-start-auto sm:row-span-1 sm:row-start-auto"
|
||||
variant="ghost"
|
||||
label="删除映射"
|
||||
@click="emit('removeMapping', index)"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: true })
|
||||
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
|
||||
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="请求排队">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem label="每个密钥最大排队数" description="各密钥独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerKey" aria-label="每个密钥最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="单账号最大排队数" description="各账号独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerAccount" aria-label="单账号最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="最长排队秒数" description="密钥和账号共用,从首次入队起计时,1~120 秒">
|
||||
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="最长排队秒数" type="number" min="1" max="120" step="1" />
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="请求体解压上限">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2 sm:items-end">
|
||||
<BaseFormItem label="最大解压大小" description="调高可接收更大的图片和历史上下文,也会增加内存占用">
|
||||
<BaseInput v-model="model" aria-label="请求体解压上限" type="number" min="1" step="1">
|
||||
<template #suffix>
|
||||
MiB
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
</template>
|
||||
@@ -13,11 +13,8 @@ const model = defineModel<RotationStrategy | ''>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
title="调度策略"
|
||||
description="决定每次请求如何调度账号池"
|
||||
>
|
||||
<div class="grid max-w-6xl gap-3 lg:grid-cols-4">
|
||||
<BaseCard title="调度策略">
|
||||
<div class="grid max-w-6xl gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
@@ -40,7 +37,7 @@ const model = defineModel<RotationStrategy | ''>({ required: true })
|
||||
:class="model === option.value ? 'bg-cp-primary opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-cp-lg leading-[1.15] font-heavy">{{ option.label }}</span>
|
||||
<span class="text-cp leading-snug font-emphasis">{{ option.label }}</span>
|
||||
</span>
|
||||
<span class="mt-2 block text-cp leading-normal font-emphasis text-cp-text-secondary">
|
||||
{{ option.description }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Gauge, Timer, Zap } from '@lucide/vue'
|
||||
import { Gauge, Timer } from '@lucide/vue'
|
||||
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
@@ -7,20 +7,11 @@ import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
const maxConcurrentPerAccount = defineModel<string>('maxConcurrentPerAccount', { required: true })
|
||||
const refreshMarginSeconds = defineModel<string>('refreshMarginSeconds', { required: true })
|
||||
const refreshConcurrency = defineModel<string>('refreshConcurrency', { required: true })
|
||||
const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: true })
|
||||
const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: true })
|
||||
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
|
||||
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
|
||||
const responsesMaxDecompressedBodyMiB = defineModel<string>('responsesMaxDecompressedBodyMiB', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
title="运行参数"
|
||||
description="请求节奏、账号并发和 Token 刷新"
|
||||
>
|
||||
<BaseCard title="并发与请求间隔">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem
|
||||
label="单账号默认最大并发"
|
||||
@@ -38,37 +29,7 @@ const responsesMaxDecompressedBodyMiB = defineModel<string>('responsesMaxDecompr
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem
|
||||
label="提前刷新秒数"
|
||||
description="Token 过期前多少秒触发刷新"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="refreshMarginSeconds"
|
||||
aria-label="提前刷新秒数"
|
||||
type="number"
|
||||
>
|
||||
<template #prefix>
|
||||
<Timer class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem
|
||||
label="刷新并发数"
|
||||
description="同时刷新 Token 的最大请求数,减小可避免限流"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="refreshConcurrency"
|
||||
aria-label="刷新并发数"
|
||||
type="number"
|
||||
>
|
||||
<template #prefix>
|
||||
<Zap class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem
|
||||
label="请求间隔 ms"
|
||||
label="请求间隔"
|
||||
description="控制同一账号两次调度之间的最小等待时间"
|
||||
>
|
||||
<BaseInput
|
||||
@@ -79,37 +40,11 @@ const responsesMaxDecompressedBodyMiB = defineModel<string>('responsesMaxDecompr
|
||||
<template #prefix>
|
||||
<Timer class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem
|
||||
label="Responses 解压上限"
|
||||
description="默认 64 MiB,保存后对新请求生效;调高可接收更大的图片和历史上下文,也会增加内存占用"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="responsesMaxDecompressedBodyMiB"
|
||||
aria-label="Responses 解压上限"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
>
|
||||
<template #suffix>
|
||||
MiB
|
||||
ms
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<div class="col-span-full grid gap-4 pt-4 sm:grid-cols-2">
|
||||
<BaseFormItem label="每个密钥最大排队数" description="所有密钥使用此上限,各自独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerKey" aria-label="每个密钥最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="单账号最大排队数" description="所有账号使用此上限,各自独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerAccount" aria-label="单账号最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="最长排队秒数" description="密钥和账号共用此时限,从首次入队开始计时,范围 1~120 秒">
|
||||
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="最长排队秒数" type="number" min="1" max="120" step="1" />
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Timer, Zap } from '@lucide/vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
const refreshMarginSeconds = defineModel<string>('refreshMarginSeconds', { required: true })
|
||||
const refreshConcurrency = defineModel<string>('refreshConcurrency', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="凭据自动刷新">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem label="提前刷新秒数" description="Token 过期前多少秒触发刷新">
|
||||
<BaseInput v-model="refreshMarginSeconds" aria-label="提前刷新秒数" type="number">
|
||||
<template #prefix>
|
||||
<Timer class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="刷新并发数" description="同时刷新 Token 的最大请求数,减小可避免限流">
|
||||
<BaseInput v-model="refreshConcurrency" aria-label="刷新并发数" type="number">
|
||||
<template #prefix>
|
||||
<Zap class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
</template>
|
||||
@@ -69,16 +69,23 @@ const storageReady = computed(
|
||||
() => loaded.value && storageConfigured.value && verified.value,
|
||||
)
|
||||
|
||||
// 首次进入备份页签时加载配置,后续切换只刷新记录,不覆盖草稿。
|
||||
watch(
|
||||
() => props.active,
|
||||
async (isActive) => {
|
||||
if (isActive) {
|
||||
await loadSettings()
|
||||
await loadRecords()
|
||||
startPolling()
|
||||
}
|
||||
else {
|
||||
async (isActive, _previous, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
stopPolling()
|
||||
})
|
||||
if (isActive) {
|
||||
if (!loaded.value && !settingsLoading.value)
|
||||
await loadSettings()
|
||||
if (cancelled)
|
||||
return
|
||||
await loadRecords()
|
||||
if (!cancelled)
|
||||
startPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
+9
-12
@@ -4,7 +4,6 @@ import { CircleHelp, MonitorUp, TerminalSquare } from '@lucide/vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
defineOptions({ name: 'ClientVersionPolicyCard' })
|
||||
@@ -31,27 +30,25 @@ const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required:
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
description="低于最低版本的 Codex 客户端将无法发起请求,留空表示不限制"
|
||||
>
|
||||
<BaseCard>
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span>客户端版本限制</span>
|
||||
<BaseIconButton
|
||||
label="查看安装与升级说明"
|
||||
size="sm"
|
||||
class="-my-1"
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
|
||||
aria-label="查看安装与升级说明"
|
||||
@click="emit('help')"
|
||||
>
|
||||
<CircleHelp class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
<CircleHelp class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem
|
||||
label="Codex Desktop 最低版本"
|
||||
description="只检查 Desktop 应用版本,例如 26.825.51511"
|
||||
description="只检查桌面端应用版本,例如 26.825.51511"
|
||||
:error="desktopError"
|
||||
>
|
||||
<BaseInput
|
||||
@@ -70,7 +67,7 @@ const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required:
|
||||
|
||||
<BaseFormItem
|
||||
label="Codex CLI 最低版本"
|
||||
description="只检查独立 CLI 版本,例如 0.152.0"
|
||||
description="只检查独立终端版本,例如 0.152.0"
|
||||
:error="cliError"
|
||||
>
|
||||
<BaseInput
|
||||
|
||||
@@ -48,6 +48,24 @@ export function useSettingsForm() {
|
||||
accountAutoFreezeAdaptiveConcurrency: true,
|
||||
})
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
form: { ...form, requestLocation: { ...form.requestLocation } },
|
||||
mappings: mappings.value.map(row => ({ ...row })),
|
||||
}
|
||||
}
|
||||
|
||||
const saved = shallowRef<ReturnType<typeof snapshot>>()
|
||||
const loaded = computed(() => saved.value !== undefined)
|
||||
const hasChanges = computed(() => loaded.value && JSON.stringify(snapshot()) !== JSON.stringify(saved.value))
|
||||
|
||||
function resetSettings() {
|
||||
if (!saved.value || saving.value)
|
||||
return
|
||||
Object.assign(form, saved.value.form, { requestLocation: { ...saved.value.form.requestLocation } })
|
||||
mappings.value = saved.value.mappings.map(row => ({ ...row }))
|
||||
}
|
||||
|
||||
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'requestIntervalMs' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
|
||||
return computed({
|
||||
get: () => (form[key] === null ? '' : String(form[key])),
|
||||
@@ -112,6 +130,7 @@ export function useSettingsForm() {
|
||||
requestedModel,
|
||||
upstreamModel: String(upstreamModel),
|
||||
}))
|
||||
saved.value = snapshot()
|
||||
}
|
||||
|
||||
async function loadSettings(silent = false) {
|
||||
@@ -246,6 +265,8 @@ export function useSettingsForm() {
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
hasChanges,
|
||||
resetSettings,
|
||||
error,
|
||||
form,
|
||||
mappings,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Save } from '@lucide/vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { Save, Undo2 } from '@lucide/vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
|
||||
@@ -14,33 +15,28 @@ import SettingsBackupSection from './components/backup/SettingsBackupSection.vue
|
||||
import ClientVersionSettings from './components/client-version/index.vue'
|
||||
import ModelAliasesCard from './components/ModelAliasesCard.vue'
|
||||
import RequestLocationCard from './components/RequestLocationCard.vue'
|
||||
import RequestQueueCard from './components/RequestQueueCard.vue'
|
||||
import ResponseBodyLimitCard from './components/ResponseBodyLimitCard.vue'
|
||||
import RotationStrategyCard from './components/RotationStrategyCard.vue'
|
||||
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
|
||||
import TokenRefreshCard from './components/TokenRefreshCard.vue'
|
||||
import { useAdminApiKey } from './composables/useAdminApiKey'
|
||||
import { useSettingsForm } from './composables/useSettingsForm'
|
||||
import { rotationOptions } from './constants'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
type SettingsSection = 'runtime' | 'backup'
|
||||
|
||||
const section = computed<SettingsSection>(() =>
|
||||
route.name === 'settings-backup' ? 'backup' : 'runtime',
|
||||
)
|
||||
const section = computed(() => route.name === 'settings-backup' ? 'backup' : 'runtime')
|
||||
|
||||
function switchSection(value: string): void {
|
||||
const paths: Record<SettingsSection, string> = {
|
||||
runtime: '/settings',
|
||||
backup: '/settings/backup',
|
||||
}
|
||||
const nextSection: SettingsSection = value === 'backup' ? 'backup' : 'runtime'
|
||||
void router.push(paths[nextSection])
|
||||
void router.push(value === 'backup' ? '/settings/backup' : '/settings')
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
hasChanges,
|
||||
resetSettings,
|
||||
error,
|
||||
form,
|
||||
mappings,
|
||||
@@ -78,17 +74,10 @@ const {
|
||||
loadStatus: loadAdminApiKeyStatus,
|
||||
} = useAdminApiKey()
|
||||
|
||||
// 只在切到对应分区时才加载各自接口,避免打开备份页也请求运行设置数据。
|
||||
watch(
|
||||
section,
|
||||
(value) => {
|
||||
if (value === 'runtime') {
|
||||
void loadSettings()
|
||||
void loadAdminApiKeyStatus()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onMounted(() => {
|
||||
void loadSettings()
|
||||
void loadAdminApiKeyStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,21 +95,37 @@ watch(
|
||||
]"
|
||||
@update:model-value="switchSection"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="section === 'runtime'"
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading"
|
||||
@click="saveSettings"
|
||||
>
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</BaseButton>
|
||||
<div v-if="section === 'runtime'" class="flex flex-wrap items-center justify-end gap-2">
|
||||
<BaseIconButton v-if="hasChanges" label="撤销更改" :disabled="saving || loading" @click="resetSettings">
|
||||
<Undo2 class="size-4" />
|
||||
</BaseIconButton>
|
||||
<span class="relative inline-flex">
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="loading || !hasChanges || !!error" @click="saveSettings">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</BaseButton>
|
||||
<span v-if="hasChanges" class="pointer-events-none absolute top-1 right-1 size-1.5 rounded-full bg-cp-warning ring-2 ring-cp-bg-layout" aria-hidden="true" />
|
||||
<span class="sr-only" role="status">{{ hasChanges ? '有未保存更改' : '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="section === 'runtime'" class="mt-5 grid w-full gap-5">
|
||||
<!-- 切换页签只隐藏内容,保留运行设置和备份配置的未保存草稿。 -->
|
||||
<div v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
|
||||
<div v-if="error" role="alert" class="flex flex-wrap items-center justify-between gap-3 rounded-cp-card bg-cp-error-container p-5 text-cp-error-on-container">
|
||||
<p class="m-0 text-cp">
|
||||
设置加载失败:{{ error }}
|
||||
</p>
|
||||
<BaseButton :loading="loading" @click="loadSettings()">
|
||||
重新加载
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div v-else-if="loading" role="status" class="rounded-cp-card bg-cp-bg-container p-6 text-cp text-cp-text-secondary shadow-cp-card">
|
||||
正在加载设置…
|
||||
</div>
|
||||
|
||||
<AdminApiKeyCard
|
||||
:status="adminApiKeyStatus"
|
||||
:loading="adminKeyLoading"
|
||||
@@ -132,64 +137,63 @@ watch(
|
||||
@copy="copyAdminApiKey"
|
||||
/>
|
||||
|
||||
<RuntimeSettingsCard
|
||||
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
|
||||
v-model:refresh-margin-seconds="refreshMarginSecondsValue"
|
||||
v-model:refresh-concurrency="refreshConcurrencyValue"
|
||||
v-model:request-interval-ms="requestIntervalMsValue"
|
||||
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
|
||||
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
|
||||
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
|
||||
v-model:responses-max-decompressed-body-mi-b="responsesMaxDecompressedBodyMiBValue"
|
||||
/>
|
||||
<fieldset :disabled="saving || loading || !!error" class="m-0 grid min-w-0 gap-5 border-0 p-0" aria-label="运行设置">
|
||||
<RuntimeSettingsCard
|
||||
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
|
||||
v-model:request-interval-ms="requestIntervalMsValue"
|
||||
/>
|
||||
<TokenRefreshCard v-model:refresh-margin-seconds="refreshMarginSecondsValue" v-model:refresh-concurrency="refreshConcurrencyValue" />
|
||||
<ResponseBodyLimitCard v-model="responsesMaxDecompressedBodyMiBValue" />
|
||||
<RequestQueueCard
|
||||
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
|
||||
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
|
||||
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
|
||||
/>
|
||||
|
||||
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="loading || saving" />
|
||||
<AccountAutoFreezeCard
|
||||
v-model:enabled="form.accountAutoFreezeEnabled"
|
||||
v-model:threshold="accountAutoFreezeThresholdValue"
|
||||
v-model:window-seconds="accountAutoFreezeWindowSecondsValue"
|
||||
v-model:duration-seconds="accountAutoFreezeDurationSecondsValue"
|
||||
v-model:probe-enabled="form.accountAutoFreezeProbeEnabled"
|
||||
v-model:probe-model="form.accountAutoFreezeProbeModel"
|
||||
v-model:adaptive-concurrency="form.accountAutoFreezeAdaptiveConcurrency"
|
||||
/>
|
||||
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="saving || loading || !!error" />
|
||||
<AccountAutoFreezeCard
|
||||
v-model:enabled="form.accountAutoFreezeEnabled"
|
||||
v-model:threshold="accountAutoFreezeThresholdValue"
|
||||
v-model:window-seconds="accountAutoFreezeWindowSecondsValue"
|
||||
v-model:duration-seconds="accountAutoFreezeDurationSecondsValue"
|
||||
v-model:probe-enabled="form.accountAutoFreezeProbeEnabled"
|
||||
v-model:probe-model="form.accountAutoFreezeProbeModel"
|
||||
v-model:adaptive-concurrency="form.accountAutoFreezeAdaptiveConcurrency"
|
||||
/>
|
||||
|
||||
<ClientVersionSettings
|
||||
v-model:min-codex-desktop-version="form.minCodexDesktopVersion"
|
||||
v-model:min-codex-cli-version="form.minCodexCliVersion"
|
||||
:loading="loading"
|
||||
:desktop-error="minCodexDesktopVersionError"
|
||||
:cli-error="minCodexCliVersionError"
|
||||
/>
|
||||
<ClientVersionSettings
|
||||
v-model:min-codex-desktop-version="form.minCodexDesktopVersion"
|
||||
v-model:min-codex-cli-version="form.minCodexCliVersion"
|
||||
:loading="loading"
|
||||
:desktop-error="minCodexDesktopVersionError"
|
||||
:cli-error="minCodexCliVersionError"
|
||||
/>
|
||||
|
||||
<ModelAliasesCard
|
||||
:mappings="mappings"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@add-mapping="addMapping"
|
||||
@update-mapping="updateMapping"
|
||||
@remove-mapping="removeMapping"
|
||||
/>
|
||||
|
||||
<RotationStrategyCard v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="showDeleteAdminKeyModal"
|
||||
title="删除管理员 API Key"
|
||||
description="删除后外部系统将无法继续使用该 Key 调用管理接口"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="adminKeyDeleting"
|
||||
@confirm="handleDeleteAdminApiKey"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定要删除当前管理员 API Key 吗?此操作会立即生效
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
<ModelAliasesCard
|
||||
:mappings="mappings"
|
||||
:loading="loading"
|
||||
@add-mapping="addMapping"
|
||||
@update-mapping="updateMapping"
|
||||
@remove-mapping="removeMapping"
|
||||
/>
|
||||
<RotationStrategyCard v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5">
|
||||
<SettingsBackupSection :active="section === 'backup'" />
|
||||
</div>
|
||||
<SettingsBackupSection v-show="section === 'backup'" class="mt-5" :active="section === 'backup'" />
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="showDeleteAdminKeyModal"
|
||||
title="删除管理员 API Key"
|
||||
description="删除后外部系统将无法继续使用该 Key 调用管理接口"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="adminKeyDeleting"
|
||||
@confirm="handleDeleteAdminApiKey"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定要删除当前管理员 API Key 吗?此操作会立即生效
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user