Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex d26bc36db5 chore(release): v3.31.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:42 +08:00
ZyyandCodex 96d5ab674a feat(ui): refine forms, navigation and theme loading
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:34 +08:00
ZyyandCodex 84c0db2613 fix(ui): simplify token label and center reasoning effort
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:58:06 +08:00
ZyyandCodex d819315d8b fix(ci): allow independent UI dependency versions
Keep frozen installs, source compatibility builds, and lockfile checks
without requiring every consumer to pin the UI submodule revision.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:48:28 +08:00
35 changed files with 262 additions and 129 deletions
-10
View File
@@ -129,16 +129,6 @@ jobs:
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
if [ "$locked_revision" != "$ui_revision" ]; then
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
exit 1
fi
done
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
+2 -2
View File
@@ -114,7 +114,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
```
`build:source` 用各项目的 `tsconfig.source.json` 检查源码类型,管理端构建到 `frontend/node_modules/.vite/source-dist`,示例构建到其前端项目的 `.vite/source-dist`,不改写正式依赖、锁文件或 `dist`。
源码联调 CI 显式检出子模块、核对 UI 提交锁定并验证这两个命令
源码联调 CI 显式检出子模块,分别按锁文件安装依赖,验证这两个命令并确认联调未改写正式依赖和锁文件
### 本地 SDK 验证
@@ -145,7 +145,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
1. 在要修改的子模块内先创建分支,例如 `git -C modules/ui switch -c feat/select`。初次初始化的子模块通常处于 detached HEAD
2. 在子模块运行自身检查,并在宿主根目录运行所需联调检查
3. 分别提交、推送并合并组件库或插件 PR。宿主只记录子模块提交号,不能替代子仓库的提交与推送
4. UI 更新后,将管理端和插件页面的 UI 依赖同步到同一固定标签或提交,重新生成各自锁文件,再更新宿主的子模块指针;CI 会核对三处是否一致
4. 管理端与插件页面按需升级 UI 依赖,各自固定标签或提交并维护锁文件,无需与 `modules/ui` 同步版本
5. 提交宿主改动及 `modules/ui`、`modules/plugins` 指针。指针必须指向远程可取得的提交,不能只存在于开发机
更新子模块指针不会自动升级消费方依赖。正式依赖不能写成开发机路径或浮动分支;SDK 仍由插件仓库固定到包含所需合同的宿主提交
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.6.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.7.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.59.0",
+15 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.6.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/c480b40b947761968412543d74d29a6e6e100f3f(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.7.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -443,9 +443,9 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/c480b40b947761968412543d74d29a6e6e100f3f':
resolution: {gitHosted: true, integrity: sha512-mmawMrkJokL/x/H/gWYOYSL+XXC4EFQ8cRyYVF3Pqj+EjU3RlhEP4zWHxWWkTjE9+8sWShXXHG1akdC37D/Y7A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/c480b40b947761968412543d74d29a6e6e100f3f}
version: 0.6.1
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225':
resolution: {gitHosted: true, integrity: sha512-JJesyJ1Y/QcdXhBtmKX6k0GyUgOSxiV8sJrUymvLyKjC8NhHLmtpXWt/evZcjYBDyJEIRll85bmSmtMF9/Wpqw==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225}
version: 0.7.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -972,6 +972,10 @@ packages:
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
'@rc-component/async-validator@6.0.0':
resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==}
engines: {node: '>=14.x'}
'@rolldown/binding-android-arm-eabi@1.2.14':
resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3422,11 +3426,12 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/c480b40b947761968412543d74d29a6e6e100f3f(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/0692ca034be88de6faf88bc76f1a6e9e7ad65225(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
'@lucide/vue': 1.59.0(vue@3.5.44(typescript@6.0.3))
'@rc-component/async-validator': 6.0.0
'@vueuse/core': 15.0.0(vue@3.5.44(typescript@6.0.3))
'@vueuse/integrations': 15.0.0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(sortablejs@1.15.7)(vue@3.5.44(typescript@6.0.3))
dayjs: 1.11.23
@@ -3858,6 +3863,10 @@ snapshots:
'@pkgr/core@0.3.6': {}
'@rc-component/async-validator@6.0.0':
dependencies:
'@babel/runtime': 7.29.10
'@rolldown/binding-android-arm-eabi@1.2.14':
optional: true
@@ -1,3 +1,4 @@
import { escape } from 'es-toolkit'
import { isRecord } from '@/utils/data'
interface ChartTooltipTheme {
@@ -45,6 +46,11 @@ export function chartTooltipStyle(
}
}
export function chartTooltipContent(label: string, lines: string[]) {
const title = label ? `<span style="font-size:13px;line-height:20px">${escape(label)}</span>` : ''
return [title, ...lines].filter(Boolean).join('<br/>')
}
export function tooltipRows(params: unknown): Record<string, unknown>[] {
const values = Array.isArray(params) ? params : [params]
return values.filter(isRecord)
@@ -7,7 +7,7 @@ import { formatProviderLabel } from '@/utils/providers'
export const usageDataColumns = computed(() => ([
{ key: 'model', label: t('usage.fields.model'), kind: 'custom', size: 'lg' },
{ key: 'reasoningEffort', label: t('usage.fields.reasoningEffort'), kind: 'status', size: 'md', align: 'left' },
{ key: 'reasoningEffort', label: t('usage.fields.reasoningEffort'), kind: 'status', size: 'md' },
{ key: 'route', label: t('usage.fields.endpoint'), kind: 'mono' },
{ key: 'upstreamTransport', label: t('usage.fields.upstream'), kind: 'status', size: 'md' },
{ key: 'clientTransport', label: t('usage.fields.access'), kind: 'status', size: 'md' },
@@ -30,6 +30,7 @@
},
"activeAccounts": {
"todayTokens": "Tokens",
"requestCount": "Requests",
"title": "Active account usage",
"sortByRecent": "Sorted by last used",
"empty": "No recent account requests",
@@ -640,6 +640,12 @@
"description": "Manage scheduling, upstreams, pricing, security, history retention, and backups",
"sectionsLabel": "Settings sections",
"undoAllBasicSettingsChanges": "Undo all basic settings changes",
"unsavedChanges": {
"title": "Unsaved changes",
"description": "Your unsaved settings changes will be lost if you leave",
"leave": "Discard and leave",
"stay": "Keep editing"
},
"saveChanges": "Save",
"settingsLoadingFailed": "Settings loading failed",
"reload": "Reload",
@@ -29,7 +29,8 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"todayTokens": "Token",
"requestCount": "次数",
"title": "活跃账号用量",
"sortByRecent": "最近使用排序",
"empty": "暂无账号请求记录",
@@ -640,6 +640,12 @@
"description": "配置网关调度、上游与定价,管理访问安全、历史保留与备份",
"sectionsLabel": "设置分区",
"undoAllBasicSettingsChanges": "撤销全部基础设置更改",
"unsavedChanges": {
"title": "有未保存的更改",
"description": "离开后,未保存的设置更改将丢失",
"leave": "放弃并离开",
"stay": "继续编辑"
},
"saveChanges": "保存基础设置",
"settingsLoadingFailed": "设置加载失败",
"reload": "重新加载",
@@ -29,7 +29,8 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"todayTokens": "Token",
"requestCount": "次數",
"title": "活躍賬號用量",
"sortByRecent": "最近使用排序",
"empty": "暫無賬號請求記錄",
@@ -640,6 +640,12 @@
"description": "配置網關調度、上游與定價,管理訪問安全、歷史保留與備份",
"sectionsLabel": "設置分區",
"undoAllBasicSettingsChanges": "撤銷全部基礎設置更改",
"unsavedChanges": {
"title": "有未儲存的更改",
"description": "離開後,未儲存的設定更改將遺失",
"leave": "放棄並離開",
"stay": "繼續編輯"
},
"saveChanges": "保存基礎設置",
"settingsLoadingFailed": "設置加載失敗",
"reload": "重新加載",
+29 -9
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ZScrollbar } from '@codex-proxy/ui'
import { useModalFocus, ZScrollbar } from '@codex-proxy/ui'
import { useMediaQuery } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRoute } from 'vue-router'
@@ -22,16 +23,30 @@ const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof ZScrollbar> | null>(null)
const mobileSidebarOpen = shallowRef(false)
const mobileSidebarPanel = useTemplateRef<HTMLElement>('mobileSidebarPanel')
const desktop = useMediaQuery('(min-width: 961px)')
const { rememberFocus, focusInitial, handleKeydown, restoreFocus } = useModalFocus(mobileSidebarPanel, {
isOpen: () => mobileSidebarOpen.value,
onEscape: closeMobileSidebar,
})
const systemUpdateOpen = shallowRef(false)
function openMobileSidebar() {
async function openMobileSidebar() {
rememberFocus()
mobileSidebarOpen.value = true
await nextTick()
focusInitial()
}
function closeMobileSidebar() {
mobileSidebarOpen.value = false
}
watch(desktop, (isDesktop) => {
if (isDesktop)
closeMobileSidebar()
})
onMounted(() => {
void systemUpdateStore.loadVersion().catch(() => undefined)
})
@@ -52,14 +67,14 @@ watch(
</script>
<template>
<div class="relative flex h-dvh overflow-hidden bg-cp-bg-layout">
<div class="relative flex h-dvh overflow-hidden bg-cp-bg-layout" :inert="mobileSidebarOpen">
<AppSidebar
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
@open-system-update="systemUpdateOpen = true"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<FloatingSidebarToggle v-show="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<ZScrollbar ref="pageScrollbarRef">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
@@ -71,21 +86,26 @@ watch(
</main>
<Teleport to="body">
<Transition name="mobile-sidebar">
<div v-if="mobileSidebarOpen" class="fixed inset-0 z-50 min-[961px]:hidden">
<Transition name="mobile-sidebar" @after-leave="restoreFocus">
<div v-if="mobileSidebarOpen" :inert="!mobileSidebarOpen" role="presentation" class="fixed inset-0 z-50 min-[961px]:hidden" @keydown="handleKeydown">
<button
type="button"
tabindex="-1"
class="absolute inset-0 border-0 bg-black/32 backdrop-blur-[1px] cursor-default"
:aria-label="t('navigation.sidebar.close')"
@click="closeMobileSidebar"
/>
<div class="mobile-sidebar-panel absolute inset-y-0 left-0 flex">
<div
ref="mobileSidebarPanel"
class="mobile-sidebar-panel absolute inset-y-0 left-0 flex"
role="dialog" aria-modal="true" :aria-label="t('navigation.mainLabel')" tabindex="-1"
>
<AppSidebar
mobile
@close="closeMobileSidebar"
@navigate="closeMobileSidebar"
@open-system-update="systemUpdateOpen = true"
@open-system-update="closeMobileSidebar(); systemUpdateOpen = true"
/>
</div>
</div>
@@ -55,6 +55,7 @@ const groupOptions = computed(() => [
>
<ZInput
v-model="search"
:aria-label="t('accounts.filters.searchLabel')"
:placeholder="t('accounts.filters.searchLabel')"
class="col-span-2 min-w-0 sm:col-span-3 xl:w-80 xl:flex-none"
>
+1 -1
View File
@@ -42,7 +42,7 @@ export const accountColumns = computed(() => (defineTableColumns<Account>([
align: 'center',
formatter: value => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: t('common.fields.status'), kind: 'status', align: 'left', sortable: true },
{ key: 'status', label: t('common.fields.status'), kind: 'status', align: 'center', sortable: true },
{ key: 'planType', label: t('accounts.profile.subscription.label'), kind: 'status', size: 'sm', sortable: true },
{ key: 'usage', label: t('usage.usage'), kind: 'custom', size: '2xl', sortable: true },
{ key: 'groups', label: t('accounts.fields.accountGrouping'), kind: 'status', size: 'sm' },
@@ -252,7 +252,7 @@ const statusBars = computed(() => {
>
<span class="grid min-w-0 gap-1">
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
{{ account.metric === 'requests' ? t('dashboard.metrics.requestCount') : t('dashboard.activeAccounts.todayTokens') }}
{{ account.metric === 'requests' ? t('dashboard.activeAccounts.requestCount') : t('dashboard.activeAccounts.todayTokens') }}
</span>
<strong
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-text"
@@ -5,7 +5,7 @@ import type { DashboardTrendMetric } from '@/api'
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap'
import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
import { chartTooltipContent, chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
import { t } from '@/i18n'
@@ -189,8 +189,7 @@ export function useRequestTrendChart(options: {
const title = point?.label ?? ''
if (activeKind.value === 'usage') {
return [
title,
return chartTooltipContent(title, [
tooltipItem(t('dashboard.metrics.inputTokens'), point?.inputTokens, trendColor('inputTokens')),
tooltipItem(t('dashboard.metrics.outputTokens'), point?.outputTokens, trendColor('outputTokens')),
tooltipItem(
@@ -204,9 +203,7 @@ export function useRequestTrendChart(options: {
trendColor('cachedInputTokens'),
),
tooltipItem(t('dashboard.metrics.requests'), point?.requests, palette.value.textSecondary),
]
.filter(Boolean)
.join('<br/>')
])
}
const lines = rows.map((row) => {
@@ -216,7 +213,7 @@ export function useRequestTrendChart(options: {
const marker = tooltipValue(row, 'marker')
return `${marker}${name}: ${tooltipDisplayValue(point, metric, value)}`
})
return [title, ...lines].filter(Boolean).join('<br/>')
return chartTooltipContent(title, lines)
}
function tooltipDisplayValue(point: TrendPoint | undefined, metric: string, value: string) {
@@ -5,10 +5,11 @@ import type { AccountGroupRef } from '@/api'
import { ZButton, ZDialog, ZForm, ZFormItem, ZInput } from '@codex-proxy/ui'
import { DollarSign, KeyRound } from '@lucide/vue'
import { computed } from 'vue'
import { computed, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import ProviderRequestProfilesEditor from '@/components/client-profile/ProviderRequestProfilesEditor.vue'
import { useApiKeyFormRules } from '../composables/useApiKeyFormRules'
const props = defineProps<{
groups: AccountGroupRef[]
@@ -27,6 +28,16 @@ const { t } = useI18n({ useScope: 'global' })
const open = defineModel<boolean>({ default: false })
const form = defineModel<ApiKeyFormValue>('form', { required: true })
const title = computed(() => props.editing ? t('keys.actions.editKey') : t('keys.filters.createApiKey'))
const formRef = useTemplateRef<InstanceType<typeof ZForm>>('formElement')
const rules = useApiKeyFormRules()
watch(open, () => formRef.value?.clearValidate())
async function requestSave() {
if (props.saving || !await formRef.value?.validate().catch(() => false))
return
emit('save')
}
function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFormValue[Key]) {
form.value = { ...form.value, [key]: value }
@@ -47,8 +58,8 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
<KeyRound class="text-cp-text" :size="20" aria-hidden="true" />
</template>
<ZForm class="grid gap-6">
<ZFormItem :label="t('common.fields.name')" required>
<ZForm ref="formElement" :model="form" :rules="rules" scroll-to-error focus-on-error novalidate class="grid gap-6" @submit="requestSave">
<ZFormItem prop="name" :label="t('common.fields.name')">
<ZInput
:model-value="form.name" :aria-label="t('common.fields.name')"
:placeholder="t('keys.editor.namePlaceholder')"
@@ -69,6 +80,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
<ZFormItem
v-if="!editing"
prop="customKey"
:label="t('keys.editor.customKeyOptional')"
>
<ZInput
@@ -102,7 +114,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
</ZFormItem>
<div class="grid gap-6 sm:grid-cols-2">
<ZFormItem :label="t('keys.editor.dailyLimit')">
<ZFormItem prop="dailyLimitUsd" :label="t('keys.editor.dailyLimit')">
<ZInput
:model-value="form.dailyLimitUsd" type="number"
min="0"
@@ -117,7 +129,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
</template>
</ZInput>
</ZFormItem>
<ZFormItem :label="t('keys.editor.weeklyLimit')">
<ZFormItem prop="weeklyLimitUsd" :label="t('keys.editor.weeklyLimit')">
<ZInput
:model-value="form.weeklyLimitUsd" type="number"
min="0"
@@ -135,7 +147,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
</div>
<div class="grid gap-6 sm:grid-cols-2">
<ZFormItem :label="t('keys.editor.maximumConcurrency')">
<ZFormItem prop="maxConcurrency" :label="t('keys.editor.maximumConcurrency')">
<ZInput
:model-value="form.maxConcurrency" type="number"
:aria-label="t('keys.editor.maximumConcurrency')"
@@ -146,7 +158,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
@update:model-value="updateForm('maxConcurrency', $event)"
/>
</ZFormItem>
<ZFormItem :label="t('keys.editor.requestsPerMinuteRpm')">
<ZFormItem prop="requestsPerMinute" :label="t('keys.editor.requestsPerMinuteRpm')">
<ZInput
:model-value="form.requestsPerMinute" type="number"
:aria-label="t('keys.editor.requestsPerMinute')"
@@ -167,8 +179,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
<ZButton
type="primary"
:loading="saving"
:disabled="!form.name.trim()"
@click="emit('save')"
@click="requestSave"
>
{{ editing ? t('common.actions.save') : t('common.actions.create') }}
</ZButton>
@@ -53,7 +53,7 @@ export function useApiKeyEditor(options: {
}
function requestSave() {
if (!validateForm() || savingKey.value)
if (savingKey.value)
return
if (form.value.groupIds.length === 0) {
showAllAccountsConfirm.value = true
@@ -68,7 +68,7 @@ export function useApiKeyEditor(options: {
}
async function save() {
if (!validateForm() || savingKey.value)
if (savingKey.value)
return
await savingKeyAction.run(
@@ -117,34 +117,6 @@ export function useApiKeyEditor(options: {
)
}
function validateForm() {
for (const [label, value] of [[t('keys.editor.dailyLimit'), form.value.dailyLimitUsd], [t('keys.editor.weeklyLimit'), form.value.weeklyLimitUsd]]) {
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
ZNotification.warning({ message: t('keys.editor.validation.amount', { label }) })
return false
}
}
if (!form.value.name.trim()) {
ZNotification.warning({ message: t('keys.editor.enterApiKeyName') })
return false
}
if (!editingKey.value && form.value.customKey && !/^[\x21-\x7E]+$/.test(form.value.customKey)) {
ZNotification.warning({ message: t('keys.editor.invalidKey') })
return false
}
for (const [label, value] of [
[t('keys.editor.maximumConcurrency'), form.value.maxConcurrency],
[t('keys.editor.requestsPerMinute'), form.value.requestsPerMinute],
] as const) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < 0) {
ZNotification.warning({ message: t('keys.editor.validation.integer', { label }) })
return false
}
}
return true
}
function clearCustomKey() {
form.value.customKey = ''
}
@@ -0,0 +1,46 @@
import type { FormRule, FormRules } from '@codex-proxy/ui'
import type { ApiKeyFormValue } from './useApiKeyEditor'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
export function useApiKeyFormRules() {
const { t } = useI18n({ useScope: 'global' })
function amountRule(label: string): FormRule {
return {
trigger: ['blur', 'change'],
transform: value => String(value ?? '').trim(),
pattern: /^\d{1,10}(?:\.\d{1,10})?$/,
message: t('keys.editor.validation.amount', { label }),
}
}
function integerRule(label: string): FormRule {
return {
trigger: ['blur', 'change'],
type: 'integer',
transform: Number,
min: 0,
max: Number.MAX_SAFE_INTEGER,
message: t('keys.editor.validation.integer', { label }),
}
}
return computed<FormRules<ApiKeyFormValue>>(() => ({
name: {
required: true,
whitespace: true,
message: t('keys.editor.enterApiKeyName'),
trigger: ['blur', 'change'],
},
customKey: {
trigger: ['blur', 'change'],
pattern: /^[\x21-\x7E]+$/,
message: t('keys.editor.invalidKey'),
},
dailyLimitUsd: amountRule(t('keys.editor.dailyLimit')),
weeklyLimitUsd: amountRule(t('keys.editor.weeklyLimit')),
maxConcurrency: integerRule(t('keys.editor.maximumConcurrency')),
requestsPerMinute: integerRule(t('keys.editor.requestsPerMinute')),
}))
}
@@ -16,6 +16,7 @@ const probeEnabled = defineModel<boolean>('probeEnabled', { required: true })
const probeModel = defineModel<string>('probeModel', { required: true })
const adaptiveConcurrency = defineModel<boolean>('adaptiveConcurrency', { required: true })
const adaptiveConcurrencyHintId = useId()
const probeModelId = useId()
</script>
<template>
@@ -98,6 +99,7 @@ const adaptiveConcurrencyHintId = useId()
<ZFormItem
:label="t('settings.autoDisable.recoveryProbeModel')"
:description="t('settings.autoDisable.probeModelHelp')"
:for="probeModelId"
>
<template #extra>
<ZCheckbox
@@ -107,6 +109,7 @@ const adaptiveConcurrencyHintId = useId()
/>
</template>
<ZInput
:id="probeModelId"
v-model="probeModel"
:disabled="!enabled || !probeEnabled"
:aria-label="t('settings.autoDisable.recoveryProbeModel')"
@@ -19,6 +19,7 @@ const {
testing,
savingSchedule,
loaded,
hasChanges,
verified,
storage,
schedule,
@@ -28,6 +29,8 @@ const {
saveSchedule,
} = useBackupSettings()
defineExpose({ hasChanges })
const {
records,
page,
@@ -1,4 +1,5 @@
import type { BackupSettingsView, UpdateBackupStoragePayload } from '@/api'
import { isEqual } from 'es-toolkit'
import { computed, ref, shallowRef } from 'vue'
import {
@@ -39,6 +40,9 @@ export function useBackupSettings() {
retentionDays: '7',
retentionCount: '7',
})
const savedStorage = shallowRef({ ...storage.value })
const savedSchedule = shallowRef({ ...schedule.value })
const hasChanges = computed(() => loaded.value && (!isEqual(storage.value, savedStorage.value) || !isEqual(schedule.value, savedSchedule.value)))
async function load(): Promise<void> {
if (loaded.value || busy.value)
@@ -68,6 +72,7 @@ export function useBackupSettings() {
prefix: data.prefix ?? 'backups/',
forcePathStyle: data.forcePathStyle,
}
savedStorage.value = { ...storage.value }
}
function applySchedule(data: BackupSettingsView): void {
@@ -77,6 +82,7 @@ export function useBackupSettings() {
retentionDays: String(data.retentionDays),
retentionCount: String(data.retentionCount),
}
savedSchedule.value = { ...schedule.value }
}
function storagePayload(): UpdateBackupStoragePayload {
@@ -101,8 +107,10 @@ export function useBackupSettings() {
const schedulePaused = storageChanged && schedule.value.scheduleEnabled && !data.scheduleEnabled
applyStorage(data)
// 仅存储版本变化时同步服务端暂停状态,普通保存不覆盖计划草稿。
if (storageChanged)
if (storageChanged) {
schedule.value.scheduleEnabled = data.scheduleEnabled
savedSchedule.value = { ...savedSchedule.value, scheduleEnabled: data.scheduleEnabled }
}
ZNotification.success({ message: schedulePaused ? t('settings.backup.storageSaved') : t('settings.backup.storageConfigurationSaved') })
return true
}
@@ -167,6 +175,7 @@ export function useBackupSettings() {
testing,
savingSchedule,
loaded,
hasChanges,
verified,
storage,
schedule,
@@ -0,0 +1,32 @@
import type { Ref } from 'vue'
import { useConfirmDialog, useEventListener } from '@vueuse/core'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
export function useSettingsLeaveGuard(hasChanges: Readonly<Ref<boolean>>) {
const showLeaveConfirm = shallowRef(false)
const { reveal, confirm: confirmLeave, cancel: cancelLeave } = useConfirmDialog(showLeaveConfirm)
let pending: Promise<boolean> | undefined
onBeforeRouteLeave((to) => {
if (to.matched.some(record => record.path === '/settings') || !hasChanges.value)
return true
pending ??= reveal().then(result => !result.isCanceled).finally(() => {
pending = undefined
})
return pending
})
// 关闭按钮、遮罩与 Esc 都按继续编辑处理,不能留下未完成的路由导航
watch(showLeaveConfirm, (open) => {
if (!open)
cancelLeave()
})
useEventListener(() => hasChanges.value ? window : undefined, 'beforeunload', (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
})
onScopeDispose(cancelLeave)
return { showLeaveConfirm, confirmLeave }
}
+15 -3
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ZButton, ZEmpty, ZIconButton, ZPageHeader, ZSegmented, ZSelect } from '@codex-proxy/ui'
import { ZButton, ZConfirmDialog, ZEmpty, ZIconButton, ZPageHeader, ZSegmented, ZSelect } from '@codex-proxy/ui'
import { Save, Undo2 } from '@lucide/vue'
import { computed, nextTick, reactive, shallowRef, watch } from 'vue'
import { computed, nextTick, reactive, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -22,6 +22,7 @@ import RotationStrategyCard from './components/RotationStrategyCard.vue'
import SettingsAccessSection from './components/SettingsAccessSection.vue'
import TokenRefreshCard from './components/TokenRefreshCard.vue'
import { useSettingsForm } from './composables/useSettingsForm'
import { useSettingsLeaveGuard } from './composables/useSettingsLeaveGuard'
import { rotationOptions } from './constants'
import PricingSection from './pricing/index.vue'
@@ -89,6 +90,9 @@ const {
saveSettings,
loadSettings,
} = useSettingsForm()
const backupSection = useTemplateRef<InstanceType<typeof SettingsBackupSection>>('backupSection')
const hasUnsavedChanges = computed(() => hasChanges.value || !!backupSection.value?.hasChanges)
const { showLeaveConfirm, confirmLeave } = useSettingsLeaveGuard(hasUnsavedChanges)
watch(() => !loading.value && route.hash === '#reset-credits', async (ready) => {
if (ready) {
@@ -231,7 +235,15 @@ watch(section, (value) => {
</fieldset>
</div>
<SettingsBackupSection v-if="visited.has('backup')" v-show="section === 'backup'" class="mt-5" :active="section === 'backup'" />
<SettingsBackupSection v-if="visited.has('backup')" v-show="section === 'backup'" ref="backupSection" class="mt-5" :active="section === 'backup'" />
<PricingSection v-if="visited.has('pricing')" v-show="section === 'pricing'" class="mt-5 min-h-0 flex-1" />
<ZConfirmDialog
v-model="showLeaveConfirm"
:title="t('settings.page.unsavedChanges.title')"
:description="t('settings.page.unsavedChanges.description')"
:confirm-text="t('settings.page.unsavedChanges.leave')"
:cancel-text="t('settings.page.unsavedChanges.stay')"
@confirm="confirmLeave()"
/>
</div>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ZButton, ZInput, ZPageHeader, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ZButton, ZInput, ZPageHeader, ZScrollbar, ZSegmented, ZSkeleton } from '@codex-proxy/ui'
import { Check, Eye, Pencil, RotateCcw, Save, Search, Undo2 } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import { computed, defineAsyncComponent, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -10,9 +10,15 @@ import { notification as ZNotification } from '@/utils/notification'
import { useThemeEditor } from '../composables/useThemeEditor'
import ThemeComponentTokenPanel from './ThemeComponentTokenPanel.vue'
import ThemeEditorPreview from './ThemeEditorPreview.vue'
import ThemeGlobalTokenPanel from './ThemeGlobalTokenPanel.vue'
const ThemeEditorPreview = defineAsyncComponent({
loader: () => import('./ThemeEditorPreview.vue'),
loadingComponent: ZSkeleton,
delay: 200,
timeout: 30000,
})
const { t } = useI18n({ useScope: 'global' })
const mobilePreviewOpen = shallowRef(false)
@@ -4,10 +4,10 @@ import type { ResolvedTheme, ThemeColorPreset, ThemeColorPresetId, ThemeMode, Th
import type { ThemeEditorDraft, ThemeEditorGlobalCategory } from '../composables/useThemeEditor'
import { ZSegmented } from '@codex-proxy/ui'
import { resolveTheme, THEME_COLOR_PRESETS } from '@codex-proxy/ui/theme'
import { resolveThemeSeedTokens, THEME_COLOR_PRESETS } from '@codex-proxy/ui/theme'
import { Check, ChevronDown, Laptop, Moon, Sun } from '@lucide/vue'
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { uiLocale } from '@/i18n/ui'
@@ -41,6 +41,7 @@ const emit = defineEmits<{
const { t } = useI18n({ useScope: 'global' })
const category = defineModel<ThemeEditorGlobalCategory>('category', { required: true })
const derivedOpen = shallowRef(false)
const localizedPresets = computed(() => THEME_COLOR_PRESETS.map(preset => ({ ...preset, ...uiLocale.value.theme[preset.id] })))
const colorPresets = THEME_COLOR_PRESETS.map(preset => preset.seed)
@@ -58,9 +59,9 @@ const presetSwatchesById = new Map(
THEME_COLOR_PRESETS.map(preset => [
preset.id,
[
resolveTheme('light', preset.id, preset.seed).seedTokens.colorBgBase,
resolveThemeSeedTokens('light', preset.seed, undefined, preset.appearance?.light).colorBgBase,
preset.seed,
resolveTheme('dark', preset.id, preset.seed).seedTokens.colorBgBase,
resolveThemeSeedTokens('dark', preset.seed, undefined, preset.appearance?.dark).colorBgBase,
],
]),
)
@@ -226,14 +227,19 @@ function presetSwatches(preset: ThemeColorPreset) {
/>
</section>
<details v-if="derivedTokens.length > 0" class="group rounded-cp-lg bg-cp-bg-container shadow-cp-tertiary">
<details
v-if="derivedTokens.length > 0"
:open="derivedOpen"
class="group rounded-cp-lg bg-cp-bg-container shadow-cp-tertiary"
@toggle="derivedOpen = ($event.target as HTMLDetailsElement).open"
>
<summary
class="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-3 text-cp-sm font-bold text-cp-text"
>
{{ t('appearance.globalTokens.derivedVariablesMapAliasToken') }}
<ChevronDown class="size-3.5 transition-transform duration-150 group-open:rotate-180" />
</summary>
<div class="grid gap-1 px-2 pb-2">
<div v-if="derivedOpen" class="grid gap-1 px-2 pb-2">
<div
v-for="[name, value] in derivedTokens"
:key="name"
@@ -11,7 +11,7 @@ import {
requestActivityByBucket,
zeroInactiveValues,
} from '@/components/charts/timeSeriesGap'
import { tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { chartTooltipContent, tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
import { decimalDisplayNumber, formatLocalizedCompactNumber as formatCompactNumber, formatPercent, formatUsd } from '@/utils/format'
@@ -21,7 +21,6 @@ import {
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
@@ -189,7 +188,7 @@ function formatTooltip(params: unknown) {
const theme = palette.value
if (activeView.value === 'savings') {
return usageTooltipContent(theme, point.label, [
return chartTooltipContent(point.label, [
usageTooltipItem(
t('usage.cost.cacheSavings'),
formatUsd(costAmount(point.cacheSavings, pointIndex)),
@@ -199,7 +198,7 @@ function formatTooltip(params: unknown) {
}
if (activeView.value === 'cache') {
return usageTooltipContent(theme, point.label, [
return chartTooltipContent(point.label, [
usageTooltipItem(t('usage.cost.cacheTokenProportion'), formatPercent(point.cachedTokenRate), theme.normal),
usageTooltipItem(t('usage.cost.hitRequestRate'), formatPercent(point.cacheHitRequestRate), theme.success),
])
@@ -219,7 +218,7 @@ function formatTooltip(params: unknown) {
theme.textMuted,
))
}
return usageTooltipContent(theme, point.label, lines)
return chartTooltipContent(point.label, lines)
}
function costAmount(value: string | null, pointIndex: number) {
@@ -9,7 +9,7 @@ import { use } from 'echarts/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseChart from '@/components/charts/BaseChart.vue'
import { tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { chartTooltipContent, tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { healthEligibleRequestCount } from '@/components/usage/shared/health'
import { useChartPalette } from '@/composables/useChartPalette'
@@ -20,7 +20,6 @@ import {
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
@@ -149,7 +148,7 @@ function formatTooltip(params: unknown) {
const successRate = serviceSuccessRate(point)
const theme = palette.value
return usageTooltipContent(theme, point.label, [
return chartTooltipContent(point.label, [
usageTooltipItem(t('usage.health.requestVolume'), formatCompactNumber(requestCount(point)), theme.textSecondary),
usageTooltipItem(t('common.status.success'), formatCompactNumber(point.successRequests), theme.success),
usageTooltipItem(t('usage.health.point.serviceFailed'), formatCompactNumber(point.failedRequests), theme.danger),
@@ -8,7 +8,7 @@ import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseChart from '@/components/charts/BaseChart.vue'
import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap'
import { tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { chartTooltipContent, tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber, formatDuration, formatPercent } from '@/utils/format'
@@ -18,7 +18,6 @@ import {
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipContent,
usageTooltipItem,
usageValueAxis,
} from '../utils/chart'
@@ -206,7 +205,7 @@ function formatTooltip(params: unknown) {
return ''
const theme = palette.value
return usageTooltipContent(theme, selected.point.label, [
return chartTooltipContent(selected.point.label, [
usageTooltipItem(
seriesLabels.value[0],
formatSeriesValue(selected.first, 'first'),
+1 -10
View File
@@ -93,20 +93,11 @@ export function usageTooltip(
) {
return {
trigger: 'axis' as const,
...chartTooltipStyle(theme, { axisPointer: true }),
...chartTooltipStyle(theme, { axisPointer: true, fontWeight: 600 }),
formatter,
}
}
export function usageTooltipContent(
theme: UsageChartPalette,
label: string,
lines: string[],
) {
const title = escapeTooltip(label)
return `<div style="margin:0 0 7px;padding:0 0 7px;color:${theme.textPrimary};font-family:var(--font-mono);font-size:11px;font-weight:750;line-height:1.2">${title}</div><div style="line-height:1.55">${lines.join('<br/>')}</div>`
}
export function usageTooltipItem(label: string, value: string, color: string) {
return `<span style="display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:999px;background:${escapeTooltip(color)}"></span>${escapeTooltip(label)}: ${escapeTooltip(value)}`
}
+14 -13
View File
@@ -1,19 +1,20 @@
# v3.31.1
# v3.31.2
## 插件与请求记录
## 表单与设置
- 移除 `provider_parameters` 和 `installation_id` 专用构造入口,身份生成、持久化及关联字段改写统一由插件通过现有 `request` / `attempt` / `outbound` 链路完成;原生默认身份处理保留
- 移除上游返回模型的采集、存储写入、接口字段和界面展示,保留请求模型、映射后模型以及原始上游响应透传
- 本次不新增数据库迁移,旧请求记录沿用现有保留策略清理
- API Key 名称、额度和并发等字段接入表单校验,错误持续显示在对应字段下方,提交失败时自动滚动并聚焦首个错误字段
- 错误提示显示时保持表单高度稳定,修复恢复探测模型标签误触发旁边复选框的问题
- 设置及备份配置存在未保存修改时,离开页面前提供确认,减少误操作造成的草稿丢失
## 界面优化
## 交互与显示
- 更新 UI 组件库至 v0.6.1,增强排序箭头的方向区分,保持原有尺寸和表头文字颜色
- 分页按钮随页码长度扩宽,按容器实际宽度调整可见页码;单行空间不足时隐藏每页条数选择框,保留翻页按钮与当前页码
- 折叠侧栏的悬停提示延迟至约 800 毫秒显示,语言切换的加载状态移至右侧勾选位置,保留国旗
- 统一使用统计、请求记录及英文 Analytics、Request log 文案,精简模型筛选输入并收窄模型列
- 修复请求健康卡片加载时显示临时统计粒度的问题,统一账号额度时间窗口标签
- 补齐移动侧栏的初始焦点、Tab 循环、Esc 关闭及焦点恢复,账号搜索补充可访问名称
- 收紧没有正文的确认弹窗留白,未保存设置的离开按钮简化为“放弃并离开”
- 账号状态与请求记录推理强度居中显示,收窄表格默认单元格左右内边距,精简活跃账号用量文案
- 统一首页与用量图表提示中的时间字体和下方间距,日期选择浮层与输入框保留更清楚的间隔
- 修复窗口重新激活时意外打开悬停浮层,以及紧凑行高下单行省略文本的纵向裁切
## 构建与部署
## 主题配置
- 更新 Node.js 构建镜像以及 PostgreSQL、Redis 镜像固定版本,调整 CI 运行环境并修复 Ubuntu 26.04 下的备份测试探针
- 减少进入主题配置时的同步计算,折叠的变量列表在展开后渲染,完整预览异步加载
- 管理端升级至 UI 组件库 v0.7.0
+1 -1
View File
@@ -1 +1 @@
version: "3.31.1"
version: "3.31.2"