refactor(frontend): adopt UI v0.4.0 component APIs

Migrate the management UI to Z components and managed notifications, register the library loading directive, and align fixed dependencies and submodules with the published UI version.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-10 14:54:56 +08:00
co-authored by Codex
parent 389dc63633
commit fa0b87dd0a
204 changed files with 2327 additions and 2634 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请
### 3.4 前端模块职责
`frontend/src` 持有应用状态和业务交互,基础组件与主题算法由 `@codex-proxy/ui` 提供:
`frontend/src` 持有应用状态和业务交互,基础组件与主题算法由 `@codex-proxy/ui` 提供,组件目录与合同见 [UI 组件架构](../modules/ui/playground/guide/architecture.md):
| 入口 | 职责 |
| --- | --- |
+2
View File
@@ -63,6 +63,8 @@ pnpm --dir frontend dev
后端职责和依赖方向见 [Workspace 边界](architecture.md#3-workspace-边界),页面与共享逻辑见 [前端模块职责](architecture.md#34-前端模块职责)。
先按所属模块定位,再跟踪请求、状态与展示的调用关系
共享 UI 的命名与目录见[组件架构](../modules/ui/playground/guide/architecture.md)
## 源码联调
宿主、组件库和官方插件保留独立仓库,通过 Git 子模块在同一目录开发:
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.2",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.4.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.47.0",
+24 -15
View File
@@ -170,8 +170,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.3.2
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.4.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -381,9 +381,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/83c92024dec82b5a644d6adca96a140c541684b0':
resolution: {gitHosted: true, integrity: sha512-9s6sXP8QrUZATh80wQnIMi68lORp+HuixhoXM2WANsgULqF0Rq270VHllSCNISUCj6aP8t5ugNDPkIrkAFpu3A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0}
version: 0.3.2
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0':
resolution: {gitHosted: true, integrity: sha512-AhG9idgKHs9AWY1Fyo75wlVcBx3KXD3NEY1dF+sOg84PGAz/7mKq6Fb4xqkYqCT6UrduTjT3WbAw/2VhJJdblQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0}
version: 0.4.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -537,6 +537,11 @@ packages:
peerDependencies:
vue: '>=3.0.1'
'@lucide/vue@1.54.0':
resolution: {integrity: sha512-fq5S0Fk3a3c2VPs+QRiiHZylZV+1pxyTyOYxoH507b/OdiqciUiEm83N7d7xijS8lDlsGFf12ad9AkLGVHRevg==}
peerDependencies:
vue: '>=3.0.1'
'@ota-meshi/ast-token-store@0.3.0':
resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -1319,8 +1324,8 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dompurify@3.4.16:
resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
@@ -2003,8 +2008,8 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@18.0.14:
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
marked@18.1.0:
resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==}
engines: {node: '>= 20'}
hasBin: true
@@ -2777,17 +2782,17 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
'@lucide/vue': 1.47.0(vue@3.5.43(typescript@6.0.3))
'@lucide/vue': 1.54.0(vue@3.5.43(typescript@6.0.3))
'@vueuse/core': 15.0.0(vue@3.5.43(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.43(typescript@6.0.3))
dompurify: 3.4.15
dompurify: 3.4.16
es-toolkit: 1.52.0
gsap: 3.15.0
marked: 18.0.14
marked: 18.1.0
sortablejs: 1.15.7
vue: 3.5.43(typescript@6.0.3)
optionalDependencies:
@@ -2955,6 +2960,10 @@ snapshots:
dependencies:
vue: 3.5.43(typescript@6.0.3)
'@lucide/vue@1.54.0(vue@3.5.43(typescript@6.0.3))':
dependencies:
vue: 3.5.43(typescript@6.0.3)
'@ota-meshi/ast-token-store@0.3.0': {}
'@oxc-project/types@0.149.0': {}
@@ -3581,7 +3590,7 @@ snapshots:
diff-sequences@29.6.3: {}
dompurify@3.4.15:
dompurify@3.4.16:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -4270,7 +4279,7 @@ snapshots:
markdown-table@3.0.4: {}
marked@18.0.14: {}
marked@18.1.0: {}
math-intrinsics@1.1.0: {}
-3
View File
@@ -1,10 +1,7 @@
<script setup lang="ts">
import { BaseToast } from '@codex-proxy/ui'
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
<BaseToast />
</template>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { RequestContext, RequestNext } from './index'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ApiError, isSessionRequired } from './error'
// 共享刷新 Promise 的同一个失败只提示一次,弱引用不保留已结束请求
@@ -13,7 +13,7 @@ export async function feedbackPlugin(context: RequestContext, next: RequestNext)
if (error instanceof ApiError && error.kind !== 'cancelled' && !isSessionRequired(error)
&& !context.config.silent && context.isCurrent() && !reported.has(error)) {
reported.add(error)
toast.error(error.message)
ZNotification.error({ message: error.message })
}
throw error
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestConfig } from '../index'
import type { RequestContext, RequestNext } from './index'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ApiError, isSessionRequired } from './error'
// 会话失效是后端业务事实,登录凭据错误和 403 不清除已有会话
@@ -54,7 +54,7 @@ function expireSession(error: ApiError, config: RequestConfig, generation: numbe
const alreadyHandled = unauthorizedHandled
handleUnauthorizedOnce()
if (!config.silent && !alreadyHandled)
toast.error(error.message)
ZNotification.error({ message: error.message })
}
throw error
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountGroupRef } from '@/api'
import { BaseCheckbox } from '@codex-proxy/ui'
import { ZCheckbox } from '@codex-proxy/ui'
withDefaults(
defineProps<{
@@ -35,10 +35,9 @@ function updateGroup(groupId: string, selected: boolean) {
>
<span class="flex min-w-0 items-center gap-2.5">
<span class="size-3.5 shrink-0 rounded-sm" :style="{ backgroundColor: group.color }" />
<BaseCheckbox
<ZCheckbox
:model-value="selectedGroupIds.includes(group.id)"
:label="group.name"
show-label
:disabled="disabled || loading"
@update:model-value="updateGroup(group.id, $event)"
/>
+18 -18
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseIconButton, BaseModal, BaseScrollbar, BaseSegmented, BaseSwitch } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZScrollbar, ZSegmented, ZSwitch } from '@codex-proxy/ui'
import { Apple, Copy, Monitor, Upload } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
@@ -52,18 +52,18 @@ function importToCcs() {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="title"
description="保存或合并下方配置后重启 Codex"
size="lg"
width="48rem"
>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented v-model="activePlatform" label="配置平台" :options="platformOptions" />
<BaseSwitch
<ZSegmented v-model="activePlatform" aria-label="配置平台" :options="platformOptions" />
<ZSwitch
v-model="websocketEnabled"
label="切换 WebSocket 配置"
aria-label="切换 WebSocket 配置"
active-text="WS"
inactive-text="WS"
inline-prompt
@@ -78,34 +78,34 @@ function importToCcs() {
>
{{ configPath }}
</span>
<BaseIconButton
variant="secondary"
size="sm"
label="复制"
<ZIconButton
variant="solid"
size="small"
aria-label="复制"
@click="emit('copy', codexConfig)"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<BaseScrollbar max-height="calc(100dvh - 21rem)">
<ZScrollbar max-height="calc(100dvh - 21rem)">
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.65] font-emphasis text-cp-text"
v-text="codexConfig"
/>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="!keyValue" @click="importToCcs">
<ZButton :disabled="!keyValue" @click="importToCcs">
<Upload class="size-4" />
导入 CCSwitch
</BaseButton>
<BaseButton variant="primary" @click="open = false">
</ZButton>
<ZButton type="primary" @click="open = false">
关闭
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Github } from '@boxicons/vue'
import { BaseModal } from '@codex-proxy/ui'
import { ZDialog } from '@codex-proxy/ui'
import { ExternalLink } from '@lucide/vue'
import { computed } from 'vue'
@@ -49,7 +49,7 @@ const linkItems = [
</script>
<template>
<BaseModal v-model="open" title="关于" size="sm">
<ZDialog v-model="open" title="关于" width="28rem">
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
@@ -108,5 +108,5 @@ const linkItems = [
{{ versionLine }}
</p>
</div>
</BaseModal>
</ZDialog>
</template>
+1 -1
View File
@@ -31,7 +31,7 @@ const mistGradientId = `${id}-brand-mist`
</radialGradient>
</defs>
<rect class="fill-[var(--cp-brand-mark-bg)]" x="48" y="48" width="416" height="416" rx="88" />
<rect class="fill-(--cp-brand-mark-bg)" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
+3 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { LayoutGrid } from '@lucide/vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
@@ -14,9 +14,9 @@ const options = [
<template>
<div class="flex min-w-0 max-w-full items-center gap-1">
<div class="min-w-0 overflow-x-auto">
<BaseSegmented
<ZSegmented
v-model="provider"
label="按平台筛选"
aria-label="按平台筛选"
display="icon"
:style="{ width: `${options.length * 40 + 4}px` }"
:options="options"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { RequestLocation } from '@/api'
import { BaseFormItem, BaseInput } from '@codex-proxy/ui'
import { ZFormItem, ZInput } from '@codex-proxy/ui'
defineProps<{ disabled?: boolean }>()
const location = defineModel<RequestLocation>({ required: true })
@@ -12,17 +12,17 @@ function update(field: keyof RequestLocation, value: string) {
<template>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="国家代码" required>
<BaseInput :model-value="location.country" :disabled="disabled" aria-label="国家代码" placeholder="请输入两位国家代码" maxlength="2" @update:model-value="update('country', $event)" />
</BaseFormItem>
<BaseFormItem label="地区" required>
<BaseInput :model-value="location.region" :disabled="disabled" aria-label="地区" placeholder="请输入地区" maxlength="128" @update:model-value="update('region', $event)" />
</BaseFormItem>
<BaseFormItem label="城市" required>
<BaseInput :model-value="location.city" :disabled="disabled" aria-label="城市" placeholder="请输入城市" maxlength="128" @update:model-value="update('city', $event)" />
</BaseFormItem>
<BaseFormItem label="IANA 时区" required>
<BaseInput :model-value="location.timezone" :disabled="disabled" aria-label="IANA 时区" placeholder="请输入 IANA 时区" @update:model-value="update('timezone', $event)" />
</BaseFormItem>
<ZFormItem label="国家代码" required>
<ZInput :model-value="location.country" :disabled="disabled" aria-label="国家代码" placeholder="请输入两位国家代码" maxlength="2" @update:model-value="update('country', $event)" />
</ZFormItem>
<ZFormItem label="地区" required>
<ZInput :model-value="location.region" :disabled="disabled" aria-label="地区" placeholder="请输入地区" maxlength="128" @update:model-value="update('region', $event)" />
</ZFormItem>
<ZFormItem label="城市" required>
<ZInput :model-value="location.city" :disabled="disabled" aria-label="城市" placeholder="请输入城市" maxlength="128" @update:model-value="update('city', $event)" />
</ZFormItem>
<ZFormItem label="IANA 时区" required>
<ZInput :model-value="location.timezone" :disabled="disabled" aria-label="IANA 时区" placeholder="请输入 IANA 时区" @update:model-value="update('timezone', $event)" />
</ZFormItem>
</div>
</template>
@@ -4,7 +4,7 @@ import type {
ClientProfilePreview,
ClientProfileSelection,
} from '@/api/modules/settings/profiles'
import { BaseButton, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZSegmented } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getClientProfileOptions, previewClientProfile } from '@/api/modules/settings/profiles'
import { errorMessage } from '@/utils/operation'
@@ -95,9 +95,9 @@ onMounted(() => load())
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
<ZSegmented
v-model="profileSource"
label="客户端身份来源"
aria-label="客户端身份来源"
class="shrink-0"
:options="[
{ label: '全局配置', value: 'global' },
@@ -109,9 +109,9 @@ onMounted(() => load())
</div>
<div v-if="loadError" role="alert" class="flex flex-wrap items-center justify-between gap-3 text-cp text-cp-error">
<span>预设加载失败:{{ loadError }}</span>
<BaseButton size="sm" :disabled="disabled || loading" @click="load()">
<ZButton size="small" :disabled="disabled || loading" @click="load()">
重试
</BaseButton>
</ZButton>
</div>
<ClientProfilePresetFields
v-if="!inherited"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfilePreset, ClientProfilePreview, ClientProfileSelection, CustomClientProfileSelection, PresetClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseFormItem, BaseInput, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
const props = withDefaults(defineProps<{
@@ -115,11 +115,11 @@ const userAgent = computed({
<template>
<div class="grid gap-4">
<div class="grid gap-4" :class="preset?.client === 'cli' && !custom ? 'sm:grid-cols-3' : 'sm:grid-cols-2'">
<BaseFormItem label="客户端预设">
<BaseSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :placeholder="custom ? '使用自定义用户代理' : undefined" :disabled="disabled || custom || !presets.length" />
</BaseFormItem>
<BaseFormItem v-if="preset?.client === 'cli' && !custom" label="CLI 入口">
<BaseSelect
<ZFormItem label="客户端预设">
<ZSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :placeholder="custom ? '使用自定义用户代理' : undefined" :disabled="disabled || custom || !presets.length" />
</ZFormItem>
<ZFormItem v-if="preset?.client === 'cli' && !custom" label="CLI 入口">
<ZSelect
v-model="cliEntry"
class="w-full"
:options="[
@@ -129,9 +129,9 @@ const userAgent = computed({
]"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect
</ZFormItem>
<ZFormItem label="版本策略">
<ZSelect
v-model="versionMode"
class="w-full"
:options="[
@@ -140,14 +140,14 @@ const userAgent = computed({
]"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
<p v-if="!custom && currentPreset?.reason" class="m-0 text-cp-sm text-cp-text-secondary">
{{ currentPreset.reason }}
</p>
<div v-if="!custom" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="版本滞后" :error="versionLagError">
<BaseInput
<ZFormItem label="版本滞后" :error="versionLagError">
<ZInput
v-model="versionLag"
aria-label="版本滞后"
type="number"
@@ -158,10 +158,10 @@ const userAgent = computed({
:placeholder="`滞后 1~${maxVersionLag} 个版本,留空不滞后`"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
<div class="grid gap-4" :class="{ 'sm:grid-cols-2': preset?.client === 'desktop' }">
<BaseFormItem label="终端标识">
<BaseInput
<ZFormItem label="终端标识">
<ZInput
v-model="terminal"
:disabled="disabled"
aria-label="终端标识"
@@ -171,9 +171,9 @@ const userAgent = computed({
autocomplete="off"
class="font-mono"
/>
</BaseFormItem>
<BaseFormItem v-if="preset?.client === 'desktop'" label="系统版本">
<BaseInput
</ZFormItem>
<ZFormItem v-if="preset?.client === 'desktop'" label="系统版本">
<ZInput
v-model="osVersion"
:disabled="disabled"
aria-label="系统版本"
@@ -183,11 +183,11 @@ const userAgent = computed({
autocomplete="off"
class="font-mono"
/>
</BaseFormItem>
</ZFormItem>
</div>
</div>
<template v-if="custom">
<BaseTextarea
<ZTextarea
v-model="userAgent"
:rows="3"
:disabled="disabled || (!model?.mode && previewing)"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfilePreview } from '@/api/modules/settings/profiles'
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
defineProps<{
preview?: Pick<ClientProfilePreview, 'userAgent' | 'versionSource' | 'checkedAtDisplay' | 'error'> & { versionLag?: number | null }
@@ -25,10 +25,10 @@ defineProps<{
>
<template v-if="previewing">
<div class="flex h-lh items-center text-cp-sm" role="status" aria-label="正在解析客户端身份">
<BaseSkeleton shape="text" class="w-4/5" aria-hidden="true" />
<ZSkeleton shape="text" class="w-4/5" aria-hidden="true" />
</div>
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
<BaseSkeleton shape="text" class="w-52 max-w-full" />
<ZSkeleton shape="text" class="w-52 max-w-full" />
</div>
</template>
<template v-else-if="preview">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfileSelection, ProviderRequestProfile, ProviderRequestProfiles, XaiClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
import ClientProfileEditor from './ClientProfileEditor.vue'
@@ -48,10 +48,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
<div class="grid min-w-0 gap-4">
<div v-if="!allowInherit" class="flex flex-wrap items-start justify-between gap-3">
<slot name="heading" />
<BaseSegmented
<ZSegmented
v-model="provider"
class="ml-auto max-w-full shrink-0"
label="客户端身份 Provider"
aria-label="客户端身份 Provider"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -66,10 +66,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
:disabled="disabled"
>
<template #source-extra>
<BaseSegmented
<ZSegmented
v-model="provider"
class="max-w-full shrink-0"
label="上游身份平台"
aria-label="上游身份平台"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -85,10 +85,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
:disabled="disabled"
>
<template #source-extra>
<BaseSegmented
<ZSegmented
v-model="provider"
class="max-w-full shrink-0"
label="上游身份平台"
aria-label="上游身份平台"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { XaiClientProfilePreview, XaiClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseButton, BaseFormItem, BaseInput, BaseSegmented, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZFormItem, ZInput, ZSegmented, ZSelect } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getXaiClientProfileOptions, previewXaiClientProfile } from '@/api/modules/settings/profiles'
import { errorMessage } from '@/utils/operation'
@@ -98,9 +98,9 @@ onMounted(() => props.allowInherit && void load())
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
<ZSegmented
v-model="source"
label="xAI 客户端身份来源"
aria-label="xAI 客户端身份来源"
class="shrink-0"
:options="[{ label: '全局配置', value: 'global' }, { label: '独立配置', value: 'independent' }]"
:disabled="disabled || !globalConfiguration"
@@ -109,26 +109,26 @@ onMounted(() => props.allowInherit && void load())
</div>
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<span>全局配置加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
<ZButton size="small" @click="load">
重试
</BaseButton>
</ZButton>
</div>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端类型">
<BaseInput model-value="Grok CLI" readonly :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect v-model="versionMode" class="w-full" :disabled="disabled" :options="[{ label: '跟随最新版本', value: 'latest' }, { label: '自定义版本', value: 'fixed' }]" />
</BaseFormItem>
<ZFormItem label="客户端类型">
<ZInput model-value="Grok CLI" readonly :disabled="disabled" />
</ZFormItem>
<ZFormItem label="版本策略">
<ZSelect v-model="versionMode" class="w-full" :disabled="disabled" :options="[{ label: '跟随最新版本', value: 'latest' }, { label: '自定义版本', value: 'fixed' }]" />
</ZFormItem>
</div>
<div v-if="model.versionMode === 'fixed'" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="Grok CLI 版本" required>
<BaseInput :model-value="model.clientVersion ?? ''" :disabled="disabled" placeholder="例如:1.0.13" @update:model-value="updateField('clientVersion', $event)" />
</BaseFormItem>
<BaseFormItem v-for="field in fields" :key="field.key" :label="field.label" required>
<BaseInput :model-value="model[field.key]" :disabled="disabled" @update:model-value="updateField(field.key, $event)" />
</BaseFormItem>
<ZFormItem label="Grok CLI 版本" required>
<ZInput :model-value="model.clientVersion ?? ''" :disabled="disabled" placeholder="例如:1.0.13" @update:model-value="updateField('clientVersion', $event)" />
</ZFormItem>
<ZFormItem v-for="field in fields" :key="field.key" :label="field.label" required>
<ZInput :model-value="model[field.key]" :disabled="disabled" @update:model-value="updateField(field.key, $event)" />
</ZFormItem>
</div>
</template>
<ClientProfilePreviewPanel :preview="preview" :previewing="previewing" :needs-version-input="needsVersionInput" :error="previewError" />
+22 -22
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { InputField } from './schema'
import { BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { ZForm, ZFormItem, ZIconButton, ZInput, ZPopover, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { Braces, Info, List } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef } from 'vue'
import { parseJsonObject } from '@/utils/data'
@@ -94,18 +94,18 @@ defineExpose({ focusField })
<template>
<div v-if="!empty" ref="root" class="grid gap-3">
<BaseForm v-if="showJson">
<BaseFormItem label="输入内容" :error="error">
<ZForm v-if="showJson">
<ZFormItem label="输入内容" :error="error">
<template #extra>
<BaseIconButton v-if="fields !== null" label="使用表单" size="sm" variant="secondary" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<ZIconButton v-if="fields !== null" aria-label="使用表单" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
</BaseFormItem>
</BaseForm>
<BaseForm v-else>
<BaseFormItem
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
</ZFormItem>
</ZForm>
<ZForm v-else>
<ZFormItem
v-for="(field, index) in fields"
:key="field.key"
:label="field.label"
@@ -113,32 +113,32 @@ defineExpose({ focusField })
:error="fieldErrors?.[field.key]"
>
<template v-if="field.description" #label-extra>
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger>
<BaseIconButton :label="`${field.label}说明`" class="size-3.5!" :title="undefined">
<ZPopover trigger="hover-click" placement="top-start">
<template #reference>
<ZIconButton :aria-label="`${field.label}说明`" class="size-3.5!" :title="undefined">
<Info class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<p class="m-0 max-w-72 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
{{ field.description }}
</p>
</BasePopover>
</ZPopover>
</template>
<template v-if="index === 0" #extra>
<BaseIconButton label="编辑 JSON" size="sm" variant="secondary" :disabled="disabled" @click="jsonMode = true">
<ZIconButton aria-label="编辑 JSON" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseSelect
<ZSelect
v-if="field.values || field.type === 'boolean'"
:model-value="fieldValue(field)"
:options="fieldOptions(field)"
:disabled="disabled"
:data-schema-control="field.key"
class="w-full"
@update:model-value="update(field, $event)"
@update:model-value="update(field, $event ?? '')"
/>
<BaseInput
<ZInput
v-else
:model-value="fieldValue(field)"
:type="field.secret ? 'password' : 'text'"
@@ -148,7 +148,7 @@ defineExpose({ focusField })
autocomplete="off"
@update:model-value="update(field, $event)"
/>
</BaseFormItem>
</BaseForm>
</ZFormItem>
</ZForm>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DashboardHealthTimeline, DashboardHealthTimelinePoint } from '@/api'
import { BaseCard, BasePopover } from '@codex-proxy/ui'
import { ZCard, ZPopover } from '@codex-proxy/ui'
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
@@ -183,7 +183,7 @@ onBeforeUnmount(() => {
</script>
<template>
<BaseCard as="article" :title="timeline.title" :description="timeline.description" class="w-full">
<ZCard as="article" :title="timeline.title" :description="timeline.description" class="w-full">
<template #actions>
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div
@@ -211,18 +211,18 @@ onBeforeUnmount(() => {
<template #body>
<div>
<BasePopover
<ZPopover
v-model="popoverOpen"
trigger="hover"
placement="top"
:offset="12"
:anchor-element="activeAnchor"
:reference-element="activeAnchor"
:disabled="!activePoint"
animate-position
:arrow-surface-class="healthPopoverArrowSurfaceClasses"
class="min-w-0 w-full"
>
<template #trigger>
<template #reference>
<div
ref="timelineGrid" class="grid w-full grid-cols-[repeat(var(--timeline-columns),minmax(0,1fr))] items-end gap-x-0.5 gap-y-1 sm:grid-cols-[repeat(var(--timeline-columns-wide),minmax(0,1fr))]"
:style="{ '--timeline-columns': Math.max(1, Math.ceil(points.length / 2)), '--timeline-columns-wide': Math.max(1, points.length) }"
@@ -263,8 +263,8 @@ onBeforeUnmount(() => {
<div class="w-72 overflow-hidden rounded-cp-lg">
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
</div>
</BasePopover>
</ZPopover>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BasePopover } from '@codex-proxy/ui'
import { ZPopover } from '@codex-proxy/ui'
import { Info } from '@lucide/vue'
@@ -12,11 +12,11 @@ defineProps<{
</script>
<template>
<BasePopover
<ZPopover
trigger="hover"
placement="right"
>
<template #trigger>
<template #reference>
<button
type="button"
class="inline-flex size-4 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@@ -35,5 +35,5 @@ defineProps<{
</p>
<slot />
</div>
</BasePopover>
</ZPopover>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { BaseTableColumn } from '@codex-proxy/ui'
import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { BaseTable } from '@codex-proxy/ui'
import { ZTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
@@ -22,7 +22,7 @@ import {
// 使用记录表只负责该领域的单元格呈现;筛选与分页由页面组合。
withDefaults(
defineProps<{
columns: BaseTableColumn<UsageListRecord>[]
columns: TableColumn<UsageListRecord>[]
rows: UsageListRecord[]
loading?: boolean
emptyText?: string
@@ -35,9 +35,9 @@ withDefaults(
</script>
<template>
<BaseTable
<ZTable
:columns="columns"
:rows="rows"
:data="rows"
:loading="loading"
:empty-text="emptyText"
>
@@ -142,5 +142,5 @@ withDefaults(
<template v-if="$slots.actions" #actions="scope">
<slot name="actions" v-bind="scope" />
</template>
</BaseTable>
</ZTable>
</template>
@@ -18,7 +18,7 @@ export const usageRecordColumns = defineTableColumns<UsageListRecord>([
kind: 'status',
size: 'md',
fixedWidth: true,
format: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
+2 -2
View File
@@ -1,4 +1,4 @@
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { ApiError } from '@/api/request'
@@ -42,7 +42,7 @@ export function useAsyncAction(defaults: AsyncActionRunOptions = {}) {
const message = resolveErrorText(error, options.errorText)
if (message) {
toast.error(message)
ZNotification.error({ message })
}
if (options.rethrow) {
+5 -5
View File
@@ -1,4 +1,4 @@
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { useClipboard } from '@vueuse/core'
import { errorMessage } from '@/utils/operation'
@@ -11,7 +11,7 @@ interface CopyTextOptions {
errorFromException?: boolean
}
// 剪贴板复制 + toast 反馈;空值与错误文案语义由调用方按站点配置。
// 剪贴板复制 + notification 反馈;空值与错误文案语义由调用方按站点配置。
export function useCopyText() {
// 普通 HTTP 页面没有 Clipboard API,启用 VueUse 内置的兼容复制路径。
const { copy } = useClipboard({ legacy: true })
@@ -19,15 +19,15 @@ export function useCopyText() {
return async function copyText(value: string, options: CopyTextOptions) {
if (!value) {
if (options.emptyErrorText)
toast.error(options.emptyErrorText)
ZNotification.error({ message: options.emptyErrorText })
return
}
try {
await copy(value)
toast.success(options.successText)
ZNotification.success({ message: options.successText })
}
catch (error: unknown) {
toast.error(options.errorFromException ? errorMessage(error, '复制失败') : '复制失败')
ZNotification.error({ message: options.errorFromException ? errorMessage(error, '复制失败') : '复制失败' })
}
}
}
-156
View File
@@ -1,156 +0,0 @@
import type { Directive, DirectiveBinding } from 'vue'
type LoadingBindingValue
= | boolean
| {
loading?: boolean
text?: string
preserveContent?: boolean
}
interface LoadingState {
overlay: HTMLDivElement
previousPosition: string
managedPosition: boolean
preserveContent: boolean
}
type LoadingElement = HTMLElement & {
__cpLoading?: LoadingState
}
const OVERLAY_BASE_CLASS = [
'absolute',
'inset-0',
'z-50',
'grid',
'min-h-18',
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-cp-bg-container/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
'items-center',
'gap-2.5',
'rounded-xl',
'py-2.25',
'pr-3.5',
'pl-3',
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-cp-text-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-color-bg-container)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-color-primary)_0deg_105deg,var(--cp-color-primary-hover)_105deg_200deg,var(--cp-color-fill-tertiary)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
function normalizeBinding(value: LoadingBindingValue | undefined) {
if (typeof value === 'object' && value !== null) {
return {
loading: Boolean(value.loading),
text: value.text || '加载中',
preserveContent: Boolean(value.preserveContent),
}
}
return {
loading: Boolean(value),
text: '加载中',
preserveContent: false,
}
}
function overlayClass(preserveContent: boolean) {
return preserveContent ? OVERLAY_BASE_CLASS : `${OVERLAY_BASE_CLASS} ${OVERLAY_MASK_CLASS}`
}
function createOverlay(text: string, preserveContent: boolean) {
const overlay = document.createElement('div')
overlay.className = overlayClass(preserveContent)
overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite')
const panel = document.createElement('div')
panel.className = PANEL_CLASS
const mark = document.createElement('span')
mark.className = MARK_CLASS
const label = document.createElement('span')
label.className = LABEL_CLASS
label.dataset.loadingLabel = ''
label.textContent = text
panel.append(mark, label)
overlay.append(panel)
return overlay
}
function showLoading(element: LoadingElement, text: string, preserveContent: boolean) {
if (element.__cpLoading) {
const label = element.__cpLoading.overlay.querySelector<HTMLElement>('[data-loading-label]')
if (label)
label.textContent = text
if (element.__cpLoading.preserveContent !== preserveContent) {
element.__cpLoading.overlay.className = overlayClass(preserveContent)
element.__cpLoading.preserveContent = preserveContent
}
return
}
const previousPosition = element.style.position
const managedPosition = window.getComputedStyle(element).position === 'static'
if (managedPosition) {
element.style.position = 'relative'
}
const overlay = createOverlay(text, preserveContent)
element.append(overlay)
element.setAttribute('aria-busy', 'true')
element.classList.add('overflow-hidden')
element.__cpLoading = {
overlay,
previousPosition,
managedPosition,
preserveContent,
}
}
function hideLoading(element: LoadingElement) {
const state = element.__cpLoading
if (!state)
return
state.overlay.remove()
element.removeAttribute('aria-busy')
element.classList.remove('overflow-hidden')
if (state.managedPosition) {
element.style.position = state.previousPosition
}
element.__cpLoading = undefined
}
function syncLoading(element: LoadingElement, binding: DirectiveBinding<LoadingBindingValue>) {
const { loading, text, preserveContent } = normalizeBinding(binding.value)
if (loading) {
showLoading(element, text, preserveContent)
}
else {
hideLoading(element)
}
}
export const loading: Directive<LoadingElement, LoadingBindingValue> = {
mounted: syncLoading,
updated: syncLoading,
beforeUnmount: hideLoading,
}
+27 -29
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { PluginManagementView } from '@/api'
import { BaseIconButton, BaseMotionIcon, BaseScrollbar } from '@codex-proxy/ui'
import { ZIconButton, ZMotionIcon, ZScrollbar } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Blocks,
@@ -391,12 +391,12 @@ onBeforeUnmount(() => {
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
>
<BaseMotionIcon
<ZMotionIcon
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp"
>
<AppBrandMark class="block size-11 select-none" />
</BaseMotionIcon>
</ZMotionIcon>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<strong class="text-base leading-[1.1] font-heavy text-cp-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
</span>
</div>
<BaseScrollbar class="my-6 w-full flex-1">
<ZScrollbar class="my-6 w-full flex-1">
<div class="px-4">
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" aria-label="主导航">
<span
@@ -546,7 +546,7 @@ onBeforeUnmount(() => {
</template>
</nav>
</div>
</BaseScrollbar>
</ZScrollbar>
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
<div
@@ -562,54 +562,52 @@ onBeforeUnmount(() => {
</span>
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<BaseIconButton
<ZIconButton
v-if="isCollapsed && hasUpdate"
variant="success"
size="md"
:label="updateButtonLabel"
type="success" variant="plain"
size="default"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<ArrowUpCircle :size="19" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
:size="isCollapsed ? 'md' : 'sm'"
label="退出登录"
variant="destructive"
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
aria-label="退出登录"
type="danger"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
variant="ghost"
:size="isCollapsed ? 'md' : 'sm'"
:label="themeToggleLabel"
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
:aria-label="themeToggleLabel"
@click="toggleTheme($event)"
>
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
<Moon v-else :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton variant="ghost" :size="isCollapsed ? 'md' : 'sm'" label="关于" @click="emit('openAbout')">
<ZIconButton :size="isCollapsed ? 'default' : 'small'" aria-label="关于" @click="emit('openAbout')">
<Info :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton v-if="mobile" variant="ghost" size="sm" label="关闭侧边栏" @click="emit('close')">
<ZIconButton v-if="mobile" size="small" aria-label="关闭侧边栏" @click="emit('close')">
<PanelLeftClose :size="18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
<ZIconButton
v-else
variant="ghost"
:size="isCollapsed ? 'md' : 'sm'"
:size="isCollapsed ? 'default' : 'small'"
data-sidebar-toggle
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
:aria-label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="emit('toggle')"
>
<PanelLeftOpen v-if="isCollapsed" :size="19" />
<PanelLeftClose v-else :size="18" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail } from '@/api'
import { BaseButton, BaseConfirmModal, BaseMarkdown, BaseModal, BasePopover, BaseScrollbar, BaseSegmented, BaseSkeleton, toast } from '@codex-proxy/ui'
import { ZButton, ZConfirmDialog, ZDialog, ZMarkdown, ZNotification, ZPopover, ZScrollbar, ZSegmented, ZSkeleton } from '@codex-proxy/ui'
import {
ArrowUpCircle,
@@ -49,7 +49,7 @@ const {
} = storeToRefs(systemUpdateStore)
const { loadSystem, checkUpdates, changeChannel, updateNow, restartNow } = systemUpdateStore
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateLogScrollbar = useTemplateRef<InstanceType<typeof ZScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
@@ -125,10 +125,10 @@ async function handleCheckUpdates(force = true) {
try {
const data = await checkUpdates(force)
if (data?.warning) {
toast.error(data.warning)
ZNotification.error({ message: data.warning })
return
}
toast.success(data?.hasUpdate ? '发现可用更新' : '当前没有可用更新')
ZNotification.success({ message: data?.hasUpdate ? '发现可用更新' : '当前没有可用更新' })
}
catch {}
}
@@ -142,16 +142,16 @@ async function handleUpdateRequest() {
try {
const data = await checkUpdates(true)
if (data?.warning) {
toast.error(data.warning)
ZNotification.error({ message: data.warning })
return
}
if (!data?.hasUpdate || !canUpdate.value) {
toast.success('当前没有可用更新')
ZNotification.success({ message: '当前没有可用更新' })
return
}
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
if (!remoteTargetVersion) {
toast.error('远端目标版本为空')
ZNotification.error({ message: '远端目标版本为空' })
return
}
if (previousTargetVersion && previousTargetVersion !== remoteTargetVersion) {
@@ -172,7 +172,7 @@ async function runConfirmedUpdate(targetVersion: string, channel: SystemUpdateCh
try {
const result = await updateNow(targetVersion, channel)
if (result) {
toast.success('更新已开始')
ZNotification.success({ message: '更新已开始' })
}
}
catch {}
@@ -204,7 +204,7 @@ async function handleRestart() {
await restartNow(restartPlan.value)
}
catch (error: unknown) {
toast.error(errorMessage(error, '重启前检查失败'))
ZNotification.error({ message: errorMessage(error, '重启前检查失败') })
}
finally {
checkingRestart.value = false
@@ -220,7 +220,7 @@ async function handleConfirmRestart() {
}
catch (error: unknown) {
restartConfirmOpen.value = false
toast.error(errorMessage(error, '重启失败,请重新检查'))
ZNotification.error({ message: errorMessage(error, '重启失败,请重新检查') })
}
}
@@ -243,13 +243,13 @@ watch(
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="系统更新"
description="检查版本、查看发布说明并执行在线更新"
tone="success"
size="lg"
:dismissible="!restarting"
type="success"
width="48rem"
:show-close="!restarting" :close-on-click-modal="!restarting" :close-on-press-escape="!restarting"
>
<template #icon>
<ArrowUpCircle class="size-4.5 text-cp-success" />
@@ -305,7 +305,7 @@ watch(
class="mt-2 mb-0 h-[1em] truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
<BaseSkeleton
<ZSkeleton
v-if="loading || ((checking || changingChannel) && item.key === 'latest')"
class="h-full w-20 max-w-full"
aria-label="正在检查版本"
@@ -326,14 +326,14 @@ watch(
>
{{ updateError || updateInfo?.warning }}
</p>
<BasePopover v-if="lastFailedOperation" placement="bottom-start" class="justify-self-start">
<template #trigger>
<BaseButton variant="ghost" size="sm">
<ZPopover v-if="lastFailedOperation" placement="bottom-start" class="justify-self-start">
<template #reference>
<ZButton variant="text" size="small">
<template #icon>
<History class="size-3.5" />
</template>
上次操作失败
</BaseButton>
</ZButton>
</template>
<div class="grid w-80 max-w-[calc(100vw-2rem)] gap-2 p-3 text-cp-sm">
<div v-if="lastFailedOperation.targetVersion || lastFailedOperation.finishedAt" class="flex flex-wrap gap-x-3 gap-y-1 text-cp-xs text-cp-text-quaternary">
@@ -344,7 +344,7 @@ watch(
{{ lastFailedOperation.error || lastFailedOperation.message || '操作失败' }}
</p>
</div>
</BasePopover>
</ZPopover>
</section>
<section
@@ -356,26 +356,26 @@ watch(
发布说明
</p>
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
<BaseSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
<ZSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
<template v-else>{{ presentation.releaseVersion }}</template>
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px">
<ZScrollbar class="-mx-4" max-height="160px">
<div
class="relative px-4"
:class="{ 'min-h-40': releaseNotesLoading && !hasReleaseNotes }"
:aria-busy="releaseNotesLoading"
>
<div :class="{ invisible: releaseNotesLoading }" :aria-hidden="releaseNotesLoading || undefined">
<BaseMarkdown :source="updateInfo?.notes" />
<ZMarkdown :source="updateInfo?.notes" />
</div>
<div v-if="releaseNotesLoading" class="absolute inset-x-4 top-0 grid h-full content-start gap-3 overflow-hidden py-1" aria-hidden="true">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-4/5" />
<BaseSkeleton shape="text" class="w-3/5" />
<ZSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-4/5" />
<ZSkeleton shape="text" class="w-3/5" />
</div>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
<section
@@ -401,7 +401,7 @@ watch(
</span>
</header>
<BaseScrollbar
<ZScrollbar
ref="updateLogScrollbar"
height="260px"
>
@@ -423,27 +423,27 @@ watch(
</p>
</div>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</div>
<template #footer>
<div class="mr-auto flex min-w-0 basis-full items-center gap-2 sm:basis-auto">
<BaseSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
<BaseSegmented
<ZSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
<ZSegmented
v-else
:model-value="selectedChannel"
:options="channelOptions"
label="更新通道"
size="sm"
aria-label="更新通道"
size="small"
:disabled="!canChangeChannel || preparingUpdate || updateConfirmOpen"
@update:model-value="handleChannelChange"
/>
<BasePopover placement="top-start">
<template #trigger>
<BaseButton variant="ghost" size="sm" square aria-label="更新通道说明">
<ZPopover placement="top-start">
<template #reference>
<ZButton variant="text" size="small" square aria-label="更新通道说明">
<CircleHelp class="size-4" />
</BaseButton>
</ZButton>
</template>
<div class="grid w-72 gap-2 p-3 text-cp-sm text-cp-text-secondary">
<p v-for="channel in availableChannels" :key="channel" class="m-0">
@@ -454,10 +454,9 @@ watch(
选择仅用于本次检查,重新打开时按运行版本选择通道,不会自动安装或降级
</p>
</div>
</BasePopover>
</ZPopover>
</div>
<BaseButton
variant="secondary"
<ZButton
:loading="checking"
:disabled="loading || updating || restarting || changingChannel || preparingUpdate"
@click="handleCheckUpdates(true)"
@@ -469,10 +468,10 @@ watch(
<RefreshCw class="size-3.5" />
</template>
检查更新
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="needRestart"
variant="primary"
type="primary"
:loading="restarting || checkingRestart"
:disabled="loading || updating || restartConfirmOpen"
@click="handleRestart"
@@ -481,10 +480,10 @@ watch(
<Power class="size-4" />
</template>
{{ presentation.restartButtonLabel }}
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-else-if="hasUpdate || updating"
variant="primary"
type="primary"
:loading="preparingUpdate || updating"
:disabled="!canUpdate || preparingUpdate"
@click="handleUpdateRequest"
@@ -493,11 +492,11 @@ watch(
<Download class="size-4" />
</template>
{{ updating ? '更新中' : '下载并更新' }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="restartConfirmOpen"
title="插件兼容性提醒"
description="重启后保留启用配置,逐个尝试启动插件"
@@ -520,9 +519,9 @@ watch(
</li>
</ul>
</div>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="updateConfirmOpen"
title="发现新的更新版本"
description="所选通道的最新版本已变化"
@@ -552,5 +551,5 @@ watch(
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
</BaseConfirmModal>
</ZConfirmDialog>
</template>
+4 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseScrollbar } from '@codex-proxy/ui'
import { ZScrollbar } from '@codex-proxy/ui'
import { storeToRefs } from 'pinia'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
@@ -18,7 +18,7 @@ const { sidebarCollapsed } = storeToRefs(uiStore)
const { version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
const pageScrollbarRef = ref<InstanceType<typeof ZScrollbar> | null>(null)
const mobileSidebarOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const systemUpdateOpen = shallowRef(false)
@@ -60,13 +60,13 @@ watch(
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar ref="pageScrollbarRef">
<ZScrollbar ref="pageScrollbarRef">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
</div>
</BaseScrollbar>
</ZScrollbar>
</main>
<Teleport to="body">
+2 -2
View File
@@ -1,7 +1,7 @@
import { vLoading } from '@codex-proxy/ui'
import { createApp } from 'vue'
import App from './App.vue'
import { loading } from './directives/loading'
import { authPlugin } from './plugins/auth'
import { router } from './router'
import { pinia } from './stores'
@@ -14,7 +14,7 @@ import './styles/index.css'
const app = createApp(App)
app.directive('loading', loading)
app.directive('loading', vLoading)
app.use(pinia)
useThemeStore(pinia).initializeTheme()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import { BaseFormItem, BaseInput, BaseSegmented } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSegmented } from '@codex-proxy/ui'
defineProps<{ disabled?: boolean, editing?: boolean }>()
const form = defineModel<ApiKeyAccountForm>({ required: true })
@@ -12,20 +12,20 @@ const transportOptions = [
<template>
<div class="grid gap-4">
<BaseFormItem v-if="!editing" label="账号名称" required>
<BaseInput v-model="form.name" placeholder="输入上游名称" :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="Base URL" required>
<ZFormItem v-if="!editing" label="账号名称" required>
<ZInput v-model="form.name" placeholder="输入上游名称" :disabled="disabled" />
</ZFormItem>
<ZFormItem label="Base URL" required>
<template #extra>
<span class="text-cp-xs text-cp-text-quaternary" title="填写 API 前缀,接口路径会自动追加">API 前缀</span>
</template>
<BaseInput v-model="form.base_url" type="url" placeholder="https://api.example.com/v1" :disabled="disabled" autocomplete="off" />
</BaseFormItem>
<BaseFormItem label="API Key" :required="!editing">
<BaseInput v-model="form.apiKey" type="password" :placeholder="editing ? '留空保留当前密钥' : '输入上游密钥'" :disabled="disabled" autocomplete="new-password" />
</BaseFormItem>
<BaseFormItem label="传输方式">
<BaseSegmented v-model="form.transport" label="传输方式" :options="transportOptions" :disabled="disabled" title="WS 优先需要上游支持 Responses WebSocket" />
</BaseFormItem>
<ZInput v-model="form.base_url" type="url" placeholder="https://api.example.com/v1" :disabled="disabled" autocomplete="off" />
</ZFormItem>
<ZFormItem label="API Key" :required="!editing">
<ZInput v-model="form.apiKey" type="password" :placeholder="editing ? '留空保留当前密钥' : '输入上游密钥'" :disabled="disabled" autocomplete="new-password" />
</ZFormItem>
<ZFormItem label="传输方式">
<ZSegmented v-model="form.transport" aria-label="传输方式" :options="transportOptions" :disabled="disabled" title="WS 优先需要上游支持 Responses WebSocket" />
</ZFormItem>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseButton, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog } from '@codex-proxy/ui'
import AccountSettingsFields from './AccountSettingsFields.vue'
defineProps<{
@@ -28,12 +28,12 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="批量编辑账号"
:description="`编辑 ${selectedCount} 个账号`"
size="lg"
:dismissible="!saving"
width="48rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
>
<div class="grid gap-5">
<AccountSettingsFields
@@ -53,17 +53,17 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="selectedCount === 0 || groupsLoading || !hasChanges"
@click="emit('save')"
>
保存更改
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { useAccountConnectionTest } from '../composables/useAccountConnectionTest'
import { BaseButton, BaseIconButton, BaseModal, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZSelect } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
import AccountStatusBadge from './account-status-badge/index.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
@@ -42,12 +42,12 @@ function connectionLogClass(tone: string) {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="测试连接"
description="验证账号凭据、身份绑定与上游模型端点是否可用"
tone="info"
size="lg"
type="info"
width="48rem"
>
<div v-if="account" class="flex flex-col gap-4">
<section
@@ -73,10 +73,9 @@ function connectionLogClass(tone: string) {
<span class="text-cp-sm font-heavy text-cp-text-quaternary">
测试模型
</span>
<BaseIconButton
variant="ghost"
size="sm"
label="刷新上游模型"
<ZIconButton
size="small"
aria-label="刷新上游模型"
:loading="refreshingModels"
:disabled="status === 'running' || loadingModels"
@click="emit('refreshModels')"
@@ -85,9 +84,9 @@ function connectionLogClass(tone: string) {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<BaseSelect
<ZSelect
v-model="selectedModel"
aria-label="测试模型"
:options="modelOptions"
@@ -212,17 +211,17 @@ function connectionLogClass(tone: string) {
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="status === 'running'"
:disabled="!account || loadingModels || refreshingModels || !selectedModel"
@click="emit('test')"
>
{{ logs.length > 0 || error ? '重新测试' : '开始测试' }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -3,7 +3,7 @@ import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import type { Account, AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseButton, BaseFormItem, BaseModal, BaseSegmented, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZFormItem, ZSegmented, ZTextarea } from '@codex-proxy/ui'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { isOpenAiApiKeyAccount, isOpenAiOAuthAccount } from '../utils/upstreamApiKey'
@@ -42,11 +42,11 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="编辑账号"
size="md-wide"
:dismissible="!saving"
width="42rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
>
<div v-if="account" class="grid gap-5">
<div
@@ -66,15 +66,15 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
</div>
<BaseFormItem v-if="isOpenAiOAuthAccount(account)" label="上游传输方式">
<ZFormItem v-if="isOpenAiOAuthAccount(account)" label="上游传输方式">
<p v-if="configurationLoading" role="status" class="m-0 text-cp-sm text-cp-text-secondary">
正在读取上游设置…
</p>
<p v-else-if="!configurationReady" role="alert" class="m-0 text-cp-sm text-cp-error">
传输方式读取失败,其他设置仍可保存
</p>
<BaseSegmented v-else v-model="oauthTransport" label="上游传输方式" :options="transportOptions" :disabled="saving" />
</BaseFormItem>
<ZSegmented v-else v-model="oauthTransport" aria-label="上游传输方式" :options="transportOptions" :disabled="saving" />
</ZFormItem>
<section v-if="isOpenAiApiKeyAccount(account)" class="grid gap-4">
<h3 class="m-0 text-cp font-heavy text-cp-text">
@@ -105,29 +105,29 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:account-id="account.id"
/>
<BaseFormItem label="备注">
<BaseTextarea
<ZFormItem label="备注">
<ZTextarea
v-model="notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,留空可清除备注"
:disabled="saving"
/>
</BaseFormItem>
</ZFormItem>
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="!account || groupsLoading || (isOpenAiApiKeyAccount(account) && !configurationReady)"
@click="emit('save')"
>
保存更改
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountGroupRef } from '@/api'
import { BaseButton, BaseInput, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZInput, ZSelect } from '@codex-proxy/ui'
import { Download, ListTodo, Pencil, Search, Trash2, Upload } from '@lucide/vue'
import { computed } from 'vue'
@@ -49,7 +49,7 @@ const groupOptions = computed(() => [
<div
class="grid w-full min-w-0 grid-cols-2 items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7.75rem] xl:flex xl:w-auto xl:flex-none xl:flex-wrap xl:gap-3"
>
<BaseInput
<ZInput
v-model="search"
placeholder="搜索账号"
class="col-span-2 min-w-0 sm:col-span-3 xl:w-80 xl:flex-none"
@@ -57,16 +57,16 @@ const groupOptions = computed(() => [
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</ZInput>
<BaseSelect
<ZSelect
v-model="status"
:options="accountStatusFilterOptions"
aria-label="按账号状态筛选"
class="w-full min-w-0 xl:w-40 xl:shrink-0"
/>
<BaseSelect
<ZSelect
v-model="group"
:options="groupOptions"
:disabled="groupsLoading"
@@ -83,28 +83,28 @@ const groupOptions = computed(() => [
<div
class="grid w-full grid-cols-2 gap-2 xl:flex xl:w-auto xl:flex-wrap xl:shrink-0 xl:self-end xl:items-center xl:justify-end xl:ml-auto"
>
<BaseButton
<ZButton
v-if="selectedCount > 0"
variant="secondary"
type="default" variant="solid"
class="w-full whitespace-nowrap xl:w-auto"
@click="emit('editSelected')"
>
<Pencil class="size-4 text-cp-link" />
批量编辑账号
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="selectedCount > 0"
variant="destructive"
type="danger" variant="plain"
class="w-full whitespace-nowrap xl:w-auto"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
<Trash2 class="size-4" />
删除选中 ({{ selectedCount }})
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="selectedCount > 0"
variant="secondary"
type="default" variant="solid"
class="w-full whitespace-nowrap xl:w-auto"
:loading="exportingAccounts"
:disabled="Boolean(exportDisabledReason)"
@@ -116,25 +116,25 @@ const groupOptions = computed(() => [
<Download class="size-4" />
</template>
导出选中 ({{ selectedCount }})
</BaseButton>
</ZButton>
<div
class="col-span-2 flex min-w-0 items-center justify-end gap-2"
:class="selectedCount > 0 ? 'w-full xl:w-auto' : 'justify-self-end'"
>
<BaseButton v-if="hasImportTasks" variant="secondary" class="whitespace-nowrap" @click="emit('importTasks')">
<ZButton v-if="hasImportTasks" class="whitespace-nowrap" @click="emit('importTasks')">
<ListTodo class="size-4" />
导入任务 <span v-if="activeImportCount" class="font-mono text-cp-link">{{ activeImportCount }}</span>
</BaseButton>
</ZButton>
<slot name="actions" />
<BaseButton
variant="primary"
<ZButton
type="primary"
class="whitespace-nowrap"
:class="selectedCount > 0 ? 'flex-1 xl:flex-none' : undefined"
@click="emit('create')"
>
<Upload class="size-4" />
导入账号
</BaseButton>
</ZButton>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountModelAccess } from '@/api'
import { BaseCheckbox, BaseEmpty, BaseFormItem, BaseIconButton, BaseInput, BaseScrollbar, BaseSegmented } from '@codex-proxy/ui'
import { ZCheckbox, ZEmpty, ZFormItem, ZIconButton, ZInput, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { RefreshCw, Search } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { getAccountModels, refreshAccountModels } from '@/api'
@@ -110,29 +110,29 @@ watch(search, () => {
<template>
<div class="grid gap-3">
<BaseFormItem label="模型限制">
<ZFormItem label="模型限制">
<template v-if="restricted && accountId" #extra>
<BaseIconButton label="刷新模型" size="sm" :loading="loading" :disabled="disabled" @click="load(true)">
<ZIconButton aria-label="刷新模型" size="small" :loading="loading" :disabled="disabled" @click="load(true)">
<template #loading>
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseSegmented v-model="mode" class="w-80 max-w-full" label="模型限制模式" :options="modes" :disabled="disabled" />
</BaseFormItem>
<ZSegmented v-model="mode" class="w-80 max-w-full" aria-label="模型限制模式" :options="modes" :disabled="disabled" />
</ZFormItem>
<template v-if="restricted">
<BaseInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<ZInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</BaseInput>
</ZInput>
<p v-if="inputError" class="m-0 text-cp-xs text-cp-error-text" role="alert">
{{ inputError }}
</p>
<BaseScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<ZScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" aria-label="选择模型" :aria-busy="loading || undefined">
<BaseCheckbox
<ZCheckbox
v-for="item in models"
:key="item.id"
class="min-h-11 min-w-0 rounded-cp px-3 py-2.5 transition-colors duration-150 motion-reduce:transition-none"
@@ -140,21 +140,20 @@ watch(search, () => {
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:title="item.unavailable && accountId ? `${item.id}(当前目录未返回)` : item.id"
show-label
:disabled="disabled"
@update:model-value="select(item.id, $event)"
>
<template #label>
<span class="block truncate">{{ item.id }}</span>
</template>
</BaseCheckbox>
</ZCheckbox>
</div>
</BaseScrollbar>
</ZScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-cp-sm text-cp-text-tertiary" role="status">
加载模型中…
</p>
<BaseEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="sm" surface="none" />
<BaseEmpty v-if="error" title="模型列表加载失败" description="请刷新后重试" size="sm" surface="none" role="alert" />
<ZEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="small" surface="none" />
<ZEmpty v-if="error" title="模型列表加载失败" description="请刷新后重试" size="small" surface="none" role="alert" />
</template>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BasePopover, BaseScrollbar } from '@codex-proxy/ui'
import { ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, useId, useTemplateRef, watch } from 'vue'
@@ -29,7 +29,7 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
</script>
<template>
<BasePopover
<ZPopover
:model-value="open"
class="min-w-0 max-w-full"
trigger="hover-click"
@@ -38,7 +38,7 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
:hover-delay="240"
@update:model-value="updateOpen"
>
<template #trigger>
<template #reference>
<button
type="button"
class="inline-flex min-w-0 max-w-full touch-manipulation items-center rounded-cp-sm border-0 bg-transparent p-0 text-left text-cp-xs leading-4 text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
@@ -53,11 +53,11 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
</template>
<section :id="detailId" class="w-max max-w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-cp-lg" role="dialog" aria-label="账号备注">
<BaseScrollbar max-height="min(240px, calc(100dvh - 2rem))">
<ZScrollbar max-height="min(240px, calc(100dvh - 2rem))">
<div class="px-3 py-2.5 text-cp-sm leading-5 whitespace-pre-wrap text-cp-text select-text wrap-anywhere">
{{ notes }}
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { getAccounts } from '@/api'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { ZCard, ZMotionIcon } from '@codex-proxy/ui'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import { computed } from 'vue'
@@ -57,7 +57,7 @@ function overviewIconClass(tone: string) {
<template>
<div class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<BaseCard v-for="item in overviewItems" :key="item.label" as="article" padding="compact">
<ZCard v-for="item in overviewItems" :key="item.label" as="article" padding="compact">
<div class="flex items-stretch justify-between gap-3">
<div class="flex min-w-0 flex-col">
<p class="m-0 text-cp-sm leading-none font-heavy text-cp-text-secondary">
@@ -70,13 +70,13 @@ function overviewIconClass(tone: string) {
{{ item.caption }}
</p>
</div>
<BaseMotionIcon
<ZMotionIcon
class="inline-flex size-9 shrink-0 items-center justify-center self-start rounded-lg"
:class="overviewIconClass(item.tone)"
>
<component :is="item.icon" class="size-4.5" />
</BaseMotionIcon>
</ZMotionIcon>
</div>
</BaseCard>
</ZCard>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseFormItem, BaseSelect } from '@codex-proxy/ui'
import { ZFormItem, ZSelect } from '@codex-proxy/ui'
import { computed } from 'vue'
import { useProxyCatalog } from '@/composables/useProxyCatalog'
@@ -34,7 +34,7 @@ const selection = computed({
</script>
<template>
<BaseFormItem label="出站隧道" :error="error">
<BaseSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
</BaseFormItem>
<ZFormItem label="出站隧道" :error="error">
<ZSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
</ZFormItem>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseFormItem, BaseInput, BaseSwitch } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSwitch } from '@codex-proxy/ui'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import AccountModelAccessField from './AccountModelAccessField.vue'
import AccountProxyField from './AccountProxyField.vue'
@@ -31,16 +31,16 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
<AccountModelAccessField v-model="modelAccess" :account-id="accountId" :disabled="disabled" :allow-preserve="preserveModelAccess" />
<div v-if="showScheduling" class="flex min-h-6 items-center justify-between gap-3">
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
<ZSwitch
v-model="enabled"
label="切换账号调度"
aria-label="切换账号调度"
:disabled="disabled"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="并发限制">
<BaseInput
<ZFormItem label="并发限制">
<ZInput
v-model="concurrencyLimit"
aria-label="账号并发限制"
type="number"
@@ -49,9 +49,9 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
placeholder="留空使用默认值"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="权重">
<BaseInput
</ZFormItem>
<ZFormItem label="权重">
<ZInput
v-model="weight"
aria-label="账号调度权重"
type="number"
@@ -60,17 +60,17 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
placeholder="越高越优先,最大 100"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
<BaseFormItem label="所属分组">
<ZFormItem label="所属分组">
<AccountGroupCheckboxGrid
v-model="selectedGroupIds"
:groups="groups"
:loading="groupsLoading"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseIconButton, BaseMenuItem, BasePopover } from '@codex-proxy/ui'
import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui'
import { Download, KeyRound, MoreHorizontal, Pencil, Power, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
import { computed } from 'vue'
@@ -31,35 +31,33 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<template>
<div class="relative flex items-center justify-start gap-1">
<BaseIconButton
variant="ghost"
size="sm"
label="编辑账号"
<ZIconButton
size="small"
aria-label="编辑账号"
@click.stop="emit('edit', account)"
>
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
variant="ghost"
size="sm"
label="删除账号"
<ZIconButton
size="small"
aria-label="删除账号"
:disabled="deleting"
@click.stop="emit('delete', account)"
>
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</ZIconButton>
<BasePopover placement="bottom-end">
<template #trigger="{ open }">
<BaseIconButton variant="ghost" size="sm" label="更多操作" :pressed="open">
<ZPopover placement="bottom-end">
<template #reference="{ open }">
<ZIconButton size="small" aria-label="更多操作" :pressed="open">
<MoreHorizontal class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<template #default="{ close }">
<div class="w-40 p-1.5">
<BaseMenuItem
<ZMenuItem
:loading="testing"
:disabled="testing"
@click.stop="(close(), emit('test', account))"
@@ -68,8 +66,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Wifi class="size-3.5 text-cp-text-quaternary" />
</template>
测试连接
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
:loading="togglingScheduling"
:disabled="togglingScheduling"
@click.stop="(close(), emit('toggleScheduling', account))"
@@ -78,8 +76,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Power class="size-3.5 text-cp-text-quaternary" />
</template>
{{ account.enabled ? '停用调度' : '启用调度' }}
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
v-if="credentialEligible"
:loading="refreshing"
:disabled="refreshing"
@@ -92,14 +90,14 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<RefreshCw class="size-3.5 text-cp-text-quaternary" />
</template>
刷新令牌
</BaseMenuItem>
<BaseMenuItem v-if="credentialEligible" @click.stop="(close(), emit('reauthorize', account))">
</ZMenuItem>
<ZMenuItem v-if="credentialEligible" @click.stop="(close(), emit('reauthorize', account))">
<template #icon>
<KeyRound class="size-3.5 text-cp-text-quaternary" />
</template>
重新授权
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'"
:loading="downloadingCatalog"
:disabled="downloadingCatalog"
@@ -112,8 +110,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Download class="size-3.5 text-cp-text-quaternary" />
</template>
下载模型目录
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
:loading="recovering"
:disabled="recovering"
@click.stop="(close(), emit('recover', account.id))"
@@ -122,9 +120,9 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<RotateCcw class="size-3.5 text-cp-text-quaternary" />
</template>
恢复状态
</BaseMenuItem>
</ZMenuItem>
</div>
</template>
</BasePopover>
</ZPopover>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { defineTableColumns, ZTable } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
@@ -100,11 +100,11 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
<div class="h-56 min-w-0 xl:h-auto xl:min-h-0 xl:flex-1 xl:basis-0">
<BaseTable
<ZTable
:columns="modelUsageColumns"
:rows="account.usage.models"
:data="account.usage.models"
row-key="model"
density="compact"
size="small"
empty-text="暂无模型用量"
>
<template #successRateDisplay="{ row }">
@@ -112,7 +112,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
{{ row.successRateDisplay }}
</span>
</template>
</BaseTable>
</ZTable>
</div>
</div>
</section>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZFormItem, ZTextarea } from '@codex-proxy/ui'
import { Upload } from '@lucide/vue'
import { useFileDialog } from '@vueuse/core'
import { onScopeDispose, ref } from 'vue'
@@ -45,16 +45,16 @@ function updateText(value: string) {
</script>
<template>
<BaseFormItem :label="label" required :error="fileError || undefined">
<ZFormItem :label="label" required :error="fileError || undefined">
<template v-if="uploadable" #extra>
<BaseButton size="sm" :disabled="disabled" @click="openFile()">
<ZButton size="small" :disabled="disabled" @click="openFile()">
<template #icon>
<Upload class="size-3.5" aria-hidden="true" />
</template>
上传文件
</BaseButton>
</ZButton>
</template>
<BaseTextarea
<ZTextarea
:model-value="text"
:aria-label="label"
:rows="9"
@@ -62,5 +62,5 @@ function updateText(value: string) {
:disabled="disabled"
@update:model-value="updateText"
/>
</BaseFormItem>
</ZFormItem>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountAuthorizationView } from '../../composables/useAccountAuthorization'
import { BaseButton, BaseForm, BaseFormItem, BaseIconButton, BaseScrollbar, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZForm, ZFormItem, ZIconButton, ZScrollbar, ZTextarea } from '@codex-proxy/ui'
import { Copy, KeyRound } from '@lucide/vue'
import { computed } from 'vue'
import { useCopyText } from '@/composables/useCopyText'
@@ -49,40 +49,39 @@ const statusText = computed(() => {
</div>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="secondary"
<ZButton
:loading="loading"
:disabled="disabled || (!authorization.flow && !canStart)"
@click="emit('regenerate')"
>
{{ authUrl ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</ZButton>
</div>
<BaseForm v-if="authUrl">
<BaseFormItem label="授权链接">
<ZForm v-if="authUrl">
<ZFormItem label="授权链接">
<template #extra>
<BaseIconButton
variant="secondary"
size="sm"
<ZIconButton
variant="solid"
size="small"
title="复制链接"
label="复制链接"
aria-label="复制链接"
:disabled="disabled"
@click="copyWithToast(authUrl, { successText: '授权链接已复制' })"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseScrollbar max-height="92px">
<ZScrollbar max-height="92px">
<div class="rounded-cp bg-(--cp-input-bg) px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="authUrl"
/>
</div>
</BaseScrollbar>
</BaseFormItem>
</BaseForm>
</ZScrollbar>
</ZFormItem>
</ZForm>
<p v-if="statusText" class="m-0 text-cp-sm text-cp-text-secondary" role="status">
{{ statusText }}
@@ -91,16 +90,16 @@ const statusText = computed(() => {
{{ authorization.error }}
</p>
<BaseForm v-if="authorization.flow">
<BaseFormItem :label="callbackLabel" required>
<BaseTextarea
<ZForm v-if="authorization.flow">
<ZFormItem :label="callbackLabel" required>
<ZTextarea
v-model="callback"
:aria-label="callbackLabel"
:rows="4"
:placeholder="callbackPlaceholder"
:disabled="disabled"
/>
</BaseFormItem>
</BaseForm>
</ZFormItem>
</ZForm>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountCreateSource } from '../../utils/accountCreate'
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { LayoutGrid } from '@lucide/vue'
import { computed } from 'vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
@@ -26,12 +26,12 @@ const selected = computed({
</script>
<template>
<BaseSegmented
<ZSegmented
v-model="selected"
class="w-full"
label="选择账号平台"
aria-label="选择账号平台"
:options="options"
:disabled="disabled"
size="lg"
size="large"
/>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountCreateForm } from '../../utils/accountCreate'
import type { AccountGroupRef } from '@/api'
import { BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
import { ZFormItem, ZTextarea } from '@codex-proxy/ui'
import AccountSettingsFields from '../AccountSettingsFields.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
@@ -37,14 +37,14 @@ const form = defineModel<AccountCreateForm>({ required: true })
:disabled="disabled"
:proxy-error="proxyError"
/>
<BaseFormItem label="备注">
<BaseTextarea
<ZFormItem label="备注">
<ZTextarea
v-model="form.notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,可不填"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
</template>
@@ -4,7 +4,7 @@ import type { AccountCreateForm, AccountImportMode } from '../../utils/accountCr
import type { Account, AccountGroupRef } from '@/api'
import { Openai, Xai } from '@boxicons/vue'
import { BaseButton, BaseIconButton, BaseModal, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZSegmented } from '@codex-proxy/ui'
import { Copy, LayoutGrid, Settings2 } from '@lucide/vue'
import { computed } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
@@ -76,13 +76,13 @@ function continueToImport() {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="view.modal.title"
:description="view.modal.description"
:tone="view.modal.tone"
:size="view.modal.size"
:dismissible="!busy"
:type="view.modal.type"
:width="view.modal.width"
:show-close="!busy" :close-on-click-modal="!busy" :close-on-press-escape="!busy"
>
<template #icon>
<Settings2 v-if="view.configuring" class="text-cp-text" :size="20" aria-hidden="true" />
@@ -111,15 +111,15 @@ function continueToImport() {
:provider="account.provider"
:authentication-kind="account.authenticationKind"
/>
<BaseIconButton
variant="secondary"
size="sm"
label="复制账号"
<ZIconButton
variant="solid"
size="small"
aria-label="复制账号"
:disabled="busy || !accountCopyValue"
@click="copyWithToast(accountCopyValue, { successText: '账号已复制' })"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
@@ -135,10 +135,10 @@ function continueToImport() {
{{ scheduling.message }}
</p>
<template v-if="!view.configuring">
<BaseSegmented
<ZSegmented
v-if="!reauthorizing && !view.isBatch"
v-model="mode"
label="账号添加方式"
aria-label="账号添加方式"
:options="view.modeOptions"
:disabled="busy"
class="w-full"
@@ -171,18 +171,18 @@ function continueToImport() {
</div>
<template #footer>
<BaseButton v-if="view.configuring || reauthorizing" variant="secondary" :disabled="busy" @click="open = false">
<ZButton v-if="view.configuring || reauthorizing" :disabled="busy" @click="open = false">
取消
</BaseButton>
<BaseButton v-else class="mr-auto" variant="secondary" :disabled="busy" @click="form.step = 'settings'">
</ZButton>
<ZButton v-else class="mr-auto" :disabled="busy" @click="form.step = 'settings'">
上一步
</BaseButton>
<BaseButton v-if="view.configuring" variant="primary" :disabled="!canSelect || !scheduling.valid || Boolean(modelError) || groupsLoading || Boolean(proxyError) || busy" @click="continueToImport">
</ZButton>
<ZButton v-if="view.configuring" type="primary" :disabled="!canSelect || !scheduling.valid || Boolean(modelError) || groupsLoading || Boolean(proxyError) || busy" @click="continueToImport">
继续导入
</BaseButton>
<BaseButton v-else variant="primary" :loading="saving || oauthLoading" :disabled="!view.canSubmit" @click="emit('create')">
</ZButton>
<ZButton v-else type="primary" :loading="saving || oauthLoading" :disabled="!view.canSubmit" @click="emit('create')">
{{ view.submitLabel }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -49,8 +49,8 @@ export function resolveAccountCreatePresentation(input: {
modal: {
title: configuring ? '账号设置' : input.reauthorizing ? '重新授权账号' : '导入账号',
description: configuring ? '设置将应用于本次导入的账号' : input.reauthorizing ? '完成授权后更新账号凭据' : description,
tone: configuring ? 'neutral' as const : 'info' as const,
size: configuring ? 'md-wide' as const : 'md' as const,
type: configuring ? 'default' as const : 'info' as const,
width: configuring ? '42rem' : '36rem',
},
oauth: {
callbackLabel: id === 'xai' ? '回调地址或授权码' : '回调地址',
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountImportTaskDetail } from '@/api'
import { BaseButton, BaseEmpty, BaseScrollbar, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZEmpty, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ArrowUpRight, Check, CircleAlert, Square, X } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
@@ -40,9 +40,9 @@ watch(hasAttention, (value) => {
{{ task.createdAtDisplay }} 创建
</p>
</div>
<BaseButton
<ZButton
v-if="!task.finishedAt"
size="sm"
size="small"
:loading="stopping"
:disabled="task.stopRequested || task.counts.pending === 0"
@click="emit('stop')"
@@ -51,10 +51,10 @@ watch(hasAttention, (value) => {
<Square class="size-3 text-cp-error" />
</template>
{{ task.stopRequested || task.counts.pending === 0 ? '等待当前条目结束' : '停止未开始条目' }}
</BaseButton>
<BaseButton v-else size="sm" variant="soft" @click="emit('viewAccounts')">
</ZButton>
<ZButton v-else size="small" type="primary" variant="plain" @click="emit('viewAccounts')">
查看账号 <ArrowUpRight class="size-3.5" />
</BaseButton>
</ZButton>
</div>
<div>
@@ -86,17 +86,17 @@ watch(hasAttention, (value) => {
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
待核对条目可能已入库,请先查看账号,再决定是否重新导入
</p>
<BaseSegmented
<ZSegmented
v-model="filter"
class="ml-auto shrink-0"
label="筛选导入明细"
aria-label="筛选导入明细"
:options="filterOptions"
size="sm"
size="small"
/>
</div>
<div>
<BaseScrollbar :key="`${task.taskId}-${filter}`" max-height="min(18rem, 32dvh)">
<BaseEmpty v-if="!visibleItems.length" size="sm" title="暂无需要处理的条目" surface="none" />
<ZScrollbar :key="`${task.taskId}-${filter}`" max-height="min(18rem, 32dvh)">
<ZEmpty v-if="!visibleItems.length" size="small" title="暂无需要处理的条目" surface="none" />
<ol v-else class="m-0 list-none space-y-1 p-0" aria-label="导入条目结果">
<li
v-for="item in visibleItems"
@@ -120,7 +120,7 @@ watch(hasAttention, (value) => {
</span>
</li>
</ol>
</BaseScrollbar>
</ZScrollbar>
</div>
</div>
</section>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountImportTask, AccountImportTaskDetail } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal, BasePopover, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton, ZPopover, ZSelect } from '@codex-proxy/ui'
import { CircleAlert, ListTodo } from '@lucide/vue'
import { computed, shallowRef, useId, watch } from 'vue'
import { taskLabel } from './presenter'
@@ -39,41 +39,41 @@ function handleRetentionKeydown(event: KeyboardEvent) {
</script>
<template>
<BaseModal v-model="open" title="导入任务" description="关闭页面后继续导入,再次打开即可查看进度" size="lg">
<ZDialog v-model="open" title="导入任务" description="关闭页面后继续导入,再次打开即可查看进度" width="48rem">
<div v-if="error" role="alert" class="mb-4 flex items-center justify-between gap-3 rounded-cp bg-cp-warning-container p-3 text-xs text-cp-warning-on-container">
<span>进度暂未更新:{{ error.replace(/[。.]\s*$/u, '') }},恢复连接后将自动刷新</span>
<BaseButton size="sm" :loading="loading" @click="emit('refresh')">
<ZButton size="small" :loading="loading" @click="emit('refresh')">
重试
</BaseButton>
</ZButton>
</div>
<BaseEmpty v-if="!tasks.length" class="min-h-[min(20rem,50dvh)] content-center" :icon="ListTodo" :title="loading ? '正在读取导入任务' : '暂无导入任务'" description="新建一次账号导入,即可在这里查看逐条入库结果" />
<ZEmpty v-if="!tasks.length" class="min-h-[min(20rem,50dvh)] content-center" :icon="ListTodo" :title="loading ? '正在读取导入任务' : '暂无导入任务'" description="新建一次账号导入,即可在这里查看逐条入库结果" />
<div v-else class="min-h-[min(20rem,50dvh)] min-w-0">
<div v-if="tasks.length > 1" class="mb-5">
<BaseSelect
<ZSelect
:model-value="selectedId"
:options="taskOptions"
size="sm"
size="small"
aria-label="切换导入任务"
class="w-full min-w-0 sm:max-w-96"
@update:model-value="emit('select', $event)"
@update:model-value="emit('select', $event ?? selectedId)"
/>
</div>
<TaskDetail v-if="detail" :task="detail" :stopping="stopping" @stop="emit('stop')" @view-accounts="emit('viewAccounts')" />
<BaseEmpty v-else class="min-h-[min(16rem,40dvh)] content-center" :title="loading ? '正在读取条目结果' : '请选择一个任务'" surface="none" />
<ZEmpty v-else class="min-h-[min(16rem,40dvh)] content-center" :title="loading ? '正在读取条目结果' : '请选择一个任务'" surface="none" />
</div>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-between gap-3">
<BasePopover v-model="retentionOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
label="任务保留说明"
size="sm"
<ZPopover v-model="retentionOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #reference>
<ZIconButton
aria-label="任务保留说明"
size="small"
:aria-expanded="retentionOpen"
:aria-describedby="retentionOpen ? retentionId : undefined"
@keydown="handleRetentionKeydown"
>
<CircleAlert class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<div :id="retentionId" role="tooltip" class="grid w-72 max-w-[calc(100vw-2rem)] gap-2 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0">
@@ -83,11 +83,11 @@ function handleRetentionKeydown(event: KeyboardEvent) {
服务重启后任务记录消失,已入库账号保留
</p>
</div>
</BasePopover>
<BaseButton @click="open = false">
</ZPopover>
<ZButton @click="open = false">
关闭
</BaseButton>
</ZButton>
</div>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountProfileStatisticsResponse } from '@/api'
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
import { computed } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/format'
@@ -67,7 +67,7 @@ function formatDuration(value: number | null | undefined) {
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
<BaseSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<ZSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<template v-else>
{{ metric.value }}
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
</script>
<template>
@@ -8,20 +8,20 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<section class="grid min-w-0 gap-4 overflow-hidden">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
<ZSkeleton shape="text" class="h-4 w-20" />
<ZSkeleton class="h-7 w-32 rounded-cp opacity-70" />
</div>
<div class="grid min-w-190 gap-1" style="grid-template-columns: repeat(52, minmax(0, 1fr))">
<div v-for="week in 52" :key="week" class="grid gap-1">
<BaseSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
<ZSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
</div>
</div>
</section>
<section class="grid gap-10 sm:grid-cols-2">
<div v-for="column in 2" :key="column" class="grid gap-3">
<BaseSkeleton shape="text" class="h-4 w-28" />
<BaseSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
<ZSkeleton shape="text" class="h-4 w-28" />
<ZSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
</div>
</section>
</div>
@@ -51,7 +51,7 @@ const dates = computed(() => [
<dt class="text-cp-xs text-cp-text-secondary">
计费
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed break-words text-cp-text">
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed wrap-break-word text-cp-text">
{{ billing }}
</dd>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ProfileActivityLevel, ProfileActivityMode } from './activity'
import type { ProfileActivityCalendar } from '@/api'
import { BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { buildProfileActivityGrid, profileActivityCellLabel } from './activity'
@@ -40,14 +40,14 @@ const monthLabels = computed(() =>
Token 活动
</h3>
</div>
<BaseSegmented v-model="mode" label="Token 活动统计方式" size="sm" :options="modeOptions" />
<ZSegmented v-model="mode" aria-label="Token 活动统计方式" size="small" :options="modeOptions" />
</div>
<BaseEmpty
<ZEmpty
v-if="calendar === null"
title="暂无 Token 活动"
description="本次未获取到每日 Token 活动"
size="sm"
size="small"
surface="none"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseModal, BaseSkeleton } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZSkeleton } from '@codex-proxy/ui'
import { RefreshCw, TriangleAlert } from '@lucide/vue'
import { toRef } from 'vue'
@@ -24,7 +24,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
</script>
<template>
<BaseModal v-model="open" title="个人信息" size="xl">
<ZDialog v-model="open" title="个人信息" width="64rem">
<div class="flex min-h-0 min-w-0 flex-col gap-6 pb-2">
<div class="grid min-w-0 grid-cols-1 gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:items-center" :class="account.capabilities.profile ? 'lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)]' : undefined">
<AccountProfileHero :account="account" :profile="profile" />
@@ -42,10 +42,10 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
<AccountProfileSkeleton v-if="account.capabilities.profile" />
<div v-else role="status" aria-busy="true">
<span class="sr-only">正在加载订阅信息</span>
<BaseSkeleton class="h-24 w-full rounded-cp-lg" />
<ZSkeleton class="h-24 w-full rounded-cp-lg" />
</div>
</template>
<BaseEmpty
<ZEmpty
v-else-if="error && !profile"
title="个人信息加载失败"
:description="error"
@@ -53,7 +53,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
surface="none"
/>
<template v-else-if="profile">
<BaseEmpty
<ZEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="本次未获取到统计数据,可以点击刷新信息重试"
@@ -68,22 +68,22 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
本次信息刷新未完成,保留已获取的结果
</p>
</template>
<BaseEmpty
<ZEmpty
v-else-if="!loading && !account.capabilities.profile && !subscription"
title="暂无订阅信息"
surface="none"
/>
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton :aria-busy="loading" :aria-disabled="loading" @click="load">
</ZButton>
<ZButton :aria-busy="loading" :aria-disabled="loading" @click="load">
<template #icon>
<RefreshCw class="size-4" :class="loading ? 'animate-spin motion-reduce:animate-none' : undefined" />
</template>
刷新信息
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
</script>
<template>
@@ -9,27 +9,27 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-16" />
<BaseSkeleton class="h-6 w-28" />
<ZSkeleton shape="text" class="w-16" />
<ZSkeleton class="h-6 w-28" />
</div>
<BaseSkeleton class="mt-2 h-8 w-28" />
<BaseSkeleton class="mt-3 h-3 w-full rounded-full" />
<ZSkeleton class="mt-2 h-8 w-28" />
<ZSkeleton class="mt-3 h-3 w-full rounded-full" />
<div class="mt-2 flex justify-between">
<BaseSkeleton v-for="tick in 5" :key="tick" shape="text" class="h-2.5 w-5" />
<ZSkeleton v-for="tick in 5" :key="tick" shape="text" class="h-2.5 w-5" />
</div>
</div>
<div class="grid gap-2">
<div class="mb-1 flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
</div>
<div v-for="metric in 2" :key="metric" class="rounded-cp bg-cp-bg-container px-3 py-3">
<BaseSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
<div class="mt-3 grid grid-cols-[1fr_20px_1.2fr] items-center gap-2">
<BaseSkeleton shape="text" class="w-14" />
<BaseSkeleton class="size-3" />
<BaseSkeleton class="h-6 w-24 justify-self-end" />
<ZSkeleton shape="text" class="w-14" />
<ZSkeleton class="size-3" />
<ZSkeleton class="h-6 w-24 justify-self-end" />
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton, ZPopover, ZSegmented } from '@codex-proxy/ui'
import { ChartNoAxesCombined, CircleAlert, RefreshCw } from '@lucide/vue'
import { useIntervalFn, useNow } from '@vueuse/core'
import { computed, ref, toRef, useId, watch } from 'vue'
@@ -62,12 +62,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="额度预测"
description="按本周期估算,重置后重新累计"
size="md"
tone="info"
width="36rem"
type="info"
>
<template #icon>
<ChartNoAxesCombined class="size-5 text-cp-primary-text" :stroke-width="1.75" />
@@ -80,23 +80,23 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<ProviderIconGroup :provider="account.provider" size="xs" />
</template>
</AccountIdentityCell>
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
<ZSegmented v-model="period" aria-label="预测周期" :options="options" class="w-48" />
</div>
<div aria-live="polite" :aria-busy="loading || refreshing">
<ForecastSkeleton v-if="loading || refreshing" />
<div v-else-if="error && !report" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载" surface="none" class="min-h-80 content-center">
<ZEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载" surface="none" class="min-h-80 content-center">
<template #action>
<BaseButton variant="secondary" @click="load">
<ZButton @click="load">
重新加载
</BaseButton>
</ZButton>
</template>
</BaseEmpty>
</ZEmpty>
</div>
<template v-else-if="forecast">
<div v-if="unavailableReason" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
<ZEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
</div>
<ForecastCapacity v-else :forecast="forecast" />
</template>
@@ -108,18 +108,17 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
label="预测说明"
variant="ghost"
size="sm"
<ZPopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #reference>
<ZIconButton
aria-label="预测说明"
size="small"
:aria-expanded="explanationOpen"
:aria-describedby="explanationOpen ? explanationId : undefined"
@keydown="handleExplanationKeydown"
>
<CircleAlert class="size-3.5" aria-hidden="true" />
</BaseIconButton>
</ZIconButton>
</template>
<section :id="explanationId" role="tooltip" class="grid w-96 max-w-[calc(100vw-2rem)] gap-3 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<h4 class="m-0 font-heavy text-cp-text">
@@ -141,12 +140,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
预测以数据更新时间为准,等价费用不是实际账单或账户余额
</p>
</section>
</BasePopover>
</ZPopover>
</div>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton variant="primary" :loading="refreshing" :disabled="loading" @click="refresh">
</ZButton>
<ZButton type="primary" :loading="refreshing" :disabled="loading" @click="refresh">
<template #loading>
<RefreshCw class="size-3.5 motion-safe:animate-spin" />
</template>
@@ -154,7 +153,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<RefreshCw class="size-3.5" />
</template>
刷新额度
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Account, AccountResetCredit } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -121,12 +121,12 @@ function handleRequestConsume(creditId: string) {
</span>
</button>
<BaseModal
<ZDialog
v-model="panelOpen"
:title="modalTitle"
:tone="confirmation ? 'warning' : 'neutral'"
:size="confirmation ? 'sm' : 'md'"
:dismissible="!consuming"
:type="confirmation ? 'warning' : 'default'"
:width="confirmation ? '28rem' : '36rem'"
:show-close="!consuming" :close-on-click-modal="!consuming" :close-on-press-escape="!consuming"
>
<div v-if="confirmation" class="grid gap-3">
<section class="rounded-cp bg-cp-fill-quaternary px-4 py-3.5">
@@ -158,9 +158,9 @@ function handleRequestConsume(creditId: string) {
请继续确认上次重置结果
</p>
</div>
<BaseButton size="sm" variant="soft" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
<ZButton size="small" type="primary" variant="plain" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
继续确认
</BaseButton>
</ZButton>
</section>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
@@ -178,10 +178,9 @@ function handleRequestConsume(creditId: string) {
>
{{ countLabel }}
</span>
<BaseIconButton
variant="ghost"
size="sm"
label="刷新主动重置卡"
<ZIconButton
size="small"
aria-label="刷新主动重置卡"
:loading="loading"
:disabled="loading || consuming"
@click="loadCredits"
@@ -190,7 +189,7 @@ function handleRequestConsume(creditId: string) {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<div :aria-busy="loading || consuming">
@@ -222,22 +221,22 @@ function handleRequestConsume(creditId: string) {
{{ credit.expiry }}
</p>
</div>
<BaseButton
size="sm"
variant="primary"
<ZButton
size="small"
type="primary"
:disabled="!account.capabilities.consumeResetCredit || loading || consuming || ambiguous || availableCount <= 0"
:aria-label="`使用重置:${credit.title},${credit.expiry}`"
@click="handleRequestConsume(credit.id)"
>
使用重置
</BaseButton>
</ZButton>
</li>
</ul>
<BaseEmpty
<ZEmpty
v-else-if="!showCountOnlyAction"
:icon="TicketCheck"
size="sm"
size="small"
surface="none"
title="当前没有可用重置次数"
description="可刷新列表,重新读取上游状态"
@@ -250,22 +249,22 @@ function handleRequestConsume(creditId: string) {
<template v-if="confirmation || showCountOnlyAction" #footer>
<template v-if="confirmation">
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
<ZButton :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
</ZButton>
<ZButton type="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
{{ confirmation.ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</ZButton>
</template>
<BaseButton
<ZButton
v-else
size="sm"
variant="primary"
size="small"
type="primary"
:disabled="loading || consuming || ambiguous || !canRequestConsume"
@click="requestConsume"
>
使用一次重置(由上游选择)
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
import { ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -59,37 +59,34 @@ watch(hasPersonalInfo, (available) => {
</p>
</div>
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
<BaseIconButton
<ZIconButton
v-if="hasPersonalInfo"
label="查看个人信息"
size="sm"
variant="ghost"
aria-label="查看个人信息"
size="small"
:pressed="profileOpen"
@click="profileOpen = true"
>
<UserRound class="size-3.5" />
</BaseIconButton>
</ZIconButton>
<AccountResetCredits
v-if="account.capabilities.resetCredits"
:account="account"
@consumed="emit('quotaReset', $event)"
/>
<BaseIconButton
<ZIconButton
v-if="hasForecast"
label="预测周/月额度"
size="sm"
variant="ghost"
aria-label="预测周/月额度"
size="small"
aria-haspopup="dialog"
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" />
</BaseIconButton>
<BaseIconButton
</ZIconButton>
<ZIconButton
v-if="account.capabilities.quotaRefresh"
variant="ghost"
size="sm"
label="刷新额度"
size="small"
aria-label="刷新额度"
:loading="refreshing"
:disabled="refreshing"
@click="emit('refreshQuota', account.id)"
@@ -98,12 +95,12 @@ watch(hasPersonalInfo, (available) => {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
<div v-if="!account.capabilities.quota && quotaEntries.length === 0" class="grid flex-1 place-items-center">
<BaseEmpty title="暂不支持查询上游额度" surface="none" />
<ZEmpty title="暂不支持查询上游额度" surface="none" />
</div>
<div v-else class="grid min-h-0 gap-3">
<AccountQuotaPanelEntry
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '@/api'
import { BasePopover } from '@codex-proxy/ui'
import { ZPopover } from '@codex-proxy/ui'
import { computed } from 'vue'
import AccountRequestTimeline from '@/components/account/account-usage-window/AccountRequestTimeline.vue'
@@ -74,8 +74,8 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
</script>
<template>
<BasePopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :hover-delay="240">
<template #trigger="{ open }">
<ZPopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :hover-delay="240">
<template #reference="{ open }">
<button
type="button"
class="col-span-full grid w-full min-w-0 grid-cols-subgrid cursor-pointer rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@@ -184,5 +184,5 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
</article>
</div>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountErrorReason, AccountStatus } from '@/api'
import { BasePopover, BaseScrollbar } from '@codex-proxy/ui'
import { ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { computed } from 'vue'
import { resolveAccountStatusPresentation } from './presenter'
@@ -47,8 +47,8 @@ const presentation = computed(() =>
</script>
<template>
<BasePopover :disabled="!presentation.hasDetail" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<ZPopover :disabled="!presentation.hasDetail" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<component
:is="presentation.hasDetail ? 'button' : 'span'"
:type="presentation.hasDetail ? 'button' : undefined"
@@ -140,15 +140,15 @@ const presentation = computed(() =>
<span class="text-cp-xs leading-none font-heavy text-cp-text-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary">仅供排查</span>
</div>
<BaseScrollbar class="bg-cp-fill-quaternary" max-height="124px">
<ZScrollbar class="bg-cp-fill-quaternary" max-height="124px">
<div class="px-3 py-2">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-xs leading-[1.55] font-emphasis text-cp-text-secondary"
>{{ presentation.errorText }}</pre>
</div>
</BaseScrollbar>
</ZScrollbar>
</div>
</div>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import type { Account, AccountModelAccess } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { batchUpdateAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -64,20 +64,20 @@ export function useAccountBatchEditor(options: {
return
const modelError = accountModelAccessError(modelAccess.value)
if (modelError) {
toast.warning(modelError)
ZNotification.warning({ message: modelError })
return
}
if (!hasChanges.value) {
toast.warning('请选择需要更新的设置')
ZNotification.warning({ message: '请选择需要更新的设置' })
return
}
const scheduling = parseAccountSchedulingForm(editedFields.value.has('concurrencyLimit') ? concurrencyLimit.value : '', editedFields.value.has('weight') ? weight.value : '1')
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
toast.warning('请选择已通过测试的代理')
ZNotification.warning({ message: '请选择已通过测试的代理' })
return
}
if (!scheduling.valid) {
toast.warning(scheduling.message)
ZNotification.warning({ message: scheduling.message })
return
}
@@ -94,7 +94,7 @@ export function useAccountBatchEditor(options: {
})
showBatchEditModal.value = false
options.selectedIds.value = new Set()
toast.success(`已更新 ${accountIds.length} 个账号`)
ZNotification.success({ message: `已更新 ${accountIds.length} 个账号` })
void Promise.allSettled([options.reloadAccounts(), options.reloadGroups()])
}, { onError: () => void options.reloadAccounts() })
}
@@ -1,5 +1,5 @@
import type { Account, AccountModelsResponse } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { CheckCircle2, Clock3, Wifi, XCircle } from '@lucide/vue'
import { useEventSource } from '@vueuse/core'
@@ -424,7 +424,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
return
applyConnectionTestModels(result, refresh)
if (refresh) {
toast.success(`已刷新 ${connectionTestModelOptions.value.length} 个上游模型`)
ZNotification.success({ message: `已刷新 ${connectionTestModelOptions.value.length} 个上游模型` })
}
else if (!connectionTestSelectedModel.value) {
connectionTestError.value = '没有可测试模型'
@@ -1,6 +1,6 @@
import type { Account, AccountModelAccess, ApiKeyConfiguration } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ref, shallowRef, watch } from 'vue'
import { getAccountDetail, updateAccount } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -96,22 +96,22 @@ export function useAccountEditor(options: {
if (isApiKey) {
const error = apiKeyAccountError(apiKey.value, true)
if (error) {
toast.warning(error)
ZNotification.warning({ message: error })
return
}
}
const modelError = accountModelAccessError(modelAccess.value)
if (modelError) {
toast.warning(modelError)
ZNotification.warning({ message: modelError })
return
}
const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value)
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
toast.warning('请选择已通过测试的代理')
ZNotification.warning({ message: '请选择已通过测试的代理' })
return
}
if (!scheduling.valid) {
toast.warning(scheduling.message)
ZNotification.warning({ message: scheduling.message })
return
}
@@ -140,7 +140,7 @@ export function useAccountEditor(options: {
: undefined,
})
showEditModal.value = false
toast.success('账号已更新')
ZNotification.success({ message: '账号已更新' })
void Promise.allSettled([options.reloadAccounts(), options.reloadGroups()])
})
}
@@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import type { Account, AccountImportTask } from '@/api'
import type { RequestOptions } from '@/api/request'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowReactive, watch } from 'vue'
import {
batchUpdateAccounts,
@@ -104,7 +104,7 @@ export function useAccountMutations(options: {
options.selectedIds.value = remaining
showSingleDeleteModal.value = false
await loadAccounts()
toast.success('账号已删除')
ZNotification.success({ message: '账号已删除' })
},
)
}
@@ -128,17 +128,15 @@ export function useAccountMutations(options: {
}
showDeleteModal.value = false
await loadAccounts()
toast.success(`已删除 ${deletedCount} 个账号`)
ZNotification.success({ message: `已删除 ${deletedCount} 个账号` })
},
{
errorText: false,
onError: (error) => {
void loadAccounts().catch(() => undefined)
toast.error(
deletedCount > 0
? `已删除 ${deletedCount} 个账号,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败'),
)
ZNotification.error({ message: deletedCount > 0
? `已删除 ${deletedCount} 个账号,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败') })
},
},
)
@@ -149,11 +147,11 @@ export function useAccountMutations(options: {
return
const selected = [...options.selectedIds.value]
if (selected.length === 0) {
toast.warning('请选择要导出的账号')
ZNotification.warning({ message: '请选择要导出的账号' })
return
}
if (exportDisabledReason.value) {
toast.warning(exportDisabledReason.value)
ZNotification.warning({ message: exportDisabledReason.value })
return
}
@@ -165,7 +163,7 @@ export function useAccountMutations(options: {
})
const fileName = payload.fileName
await downloadJson(payload, fileName)
toast.success(`已导出 ${selected.length} 个账号`)
ZNotification.success({ message: `已导出 ${selected.length} 个账号` })
},
{ errorText: '导出失败' },
)
@@ -178,7 +176,7 @@ export function useAccountMutations(options: {
const plan = account.planTypeDisplay.trim().replace(/[^\p{L}\p{N}_-]/gu, '_') || 'unknown-plan'
const name = account.name.trim().replace(/[^\p{L}\p{N}_-]/gu, '_') || 'account'
await downloadJson(result.catalog, `cpr-model-catalog-${plan}-${name}.json`)
toast.success(`已下载模型目录,共 ${result.modelCount} 个模型`)
ZNotification.success({ message: `已下载模型目录,共 ${result.modelCount} 个模型` })
}
catch {}
})
@@ -194,14 +192,14 @@ export function useAccountMutations(options: {
)
await loadAccounts()
if (result.result === 'skipped') {
toast.warning(result.error || 'Token 正在刷新中')
ZNotification.warning({ message: result.error || 'Token 正在刷新中' })
return
}
if (result.result === 'failed') {
toast.error(result.error || '刷新失败')
ZNotification.error({ message: result.error || '刷新失败' })
return
}
toast.success('Token 已刷新')
ZNotification.success({ message: 'Token 已刷新' })
}
catch {}
})
@@ -212,7 +210,7 @@ export function useAccountMutations(options: {
try {
await batchUpdateAccounts({ accountIds: [account.id], enabled: !account.enabled })
await loadAccounts()
toast.success(account.enabled ? '调度已停用' : '调度已启用')
ZNotification.success({ message: account.enabled ? '调度已停用' : '调度已启用' })
}
catch {}
})
@@ -228,7 +226,7 @@ export function useAccountMutations(options: {
selectedIds.delete(accountId)
options.selectedIds.value = selectedIds
}
toast.success('额度已刷新')
ZNotification.success({ message: '额度已刷新' })
}
catch {}
})
@@ -242,10 +240,7 @@ export function useAccountMutations(options: {
await options.replaceAccount(result.account)
}
catch (error: unknown) {
toast.warning(
`额度已重置,但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`,
{ duration: 5000 },
)
ZNotification.warning({ message: `额度已重置,但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`, ...({ duration: 5000 }) })
}
}
@@ -259,7 +254,7 @@ export function useAccountMutations(options: {
selectedIds.delete(accountId)
options.selectedIds.value = selectedIds
}
toast.success('账号状态已恢复')
ZNotification.success({ message: '账号状态已恢复' })
}
catch {}
})
@@ -1,5 +1,5 @@
import type { Account, AccountImportTask } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { createAccountImportTask, importAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -92,7 +92,7 @@ export function useAccountOnboarding(options: {
})
showCreateModal.value = false
options.onImportTaskCreated(task)
toast.success('导入任务已创建')
ZNotification.success({ message: '导入任务已创建' })
})
}
@@ -119,7 +119,7 @@ export function useAccountOnboarding(options: {
})
}
catch (cause) {
toast.error(errorMessage(cause, '启动授权失败'))
ZNotification.error({ message: errorMessage(cause, '启动授权失败') })
}
}
@@ -142,7 +142,7 @@ export function useAccountOnboarding(options: {
async function finishCreate(message: string) {
showCreateModal.value = false
toast.success(message)
ZNotification.success({ message })
await options.reload()
}
@@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import type { Account, AccountQuotaForecastResponse } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountQuotaForecast, refreshAccountQuota } from '@/api'
@@ -66,7 +66,7 @@ export function useAccountQuotaForecast(
return
onAccountUpdated(result.account)
if (accountId.value === targetAccountId && await load())
toast.success('额度已刷新')
ZNotification.success({ message: '额度已刷新' })
}
catch {
if (!disposed && accountId.value === targetAccountId) {
@@ -1,5 +1,5 @@
import type { AccountResetCredit } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import {
@@ -189,7 +189,7 @@ export function useAccountResetCredits(options: {
|| (result.code === 'already_redeemed' && operation.hasTransportFailure)
target.pendingOperation = null
if (!confirmed) {
toast.error(resetResultMessage(result.code))
ZNotification.error({ message: resetResultMessage(result.code) })
await loadSessionCredits(target, true)
return false
}
@@ -201,7 +201,7 @@ export function useAccountResetCredits(options: {
if (session.value === target)
showConfirm.value = false
options.onConsumed(operation.accountId)
toast.success(successMessage)
ZNotification.success({ message: successMessage })
applyConfirmedConsumption(target, operation)
await loadSessionCredits(target, true)
return true
@@ -212,11 +212,11 @@ export function useAccountResetCredits(options: {
...operation,
hasTransportFailure: true,
}
toast.warning('消费结果暂不确定,重试会复用同一个请求标识', { duration: 5000 })
ZNotification.warning({ message: '消费结果暂不确定,重试会复用同一个请求标识', ...({ duration: 5000 }) })
}
else {
target.pendingOperation = null
toast.error(errorMessage(error, '额度重置失败'))
ZNotification.error({ message: errorMessage(error, '额度重置失败') })
await loadSessionCredits(target, true)
}
return false
@@ -1,4 +1,4 @@
import type { BaseTableSort } from '@codex-proxy/ui'
import type { TableSort } from '@codex-proxy/ui'
import type { Account } from '@/api'
import { computed, onMounted, shallowRef, watch } from 'vue'
@@ -10,7 +10,7 @@ export function useAccountsQuery() {
const providerQuery = shallowRef('')
const statusQuery = shallowRef('')
const groupQuery = shallowRef('')
const sort = shallowRef<BaseTableSort>()
const sort = shallowRef<TableSort>()
const accountSummary = shallowRef({
total: 0,
normal: 0,
@@ -55,7 +55,7 @@ export function useAccountsQuery() {
void query.execute()
}
function handleSortChange(nextSort: BaseTableSort | undefined) {
function handleSortChange(nextSort: TableSort | undefined) {
sort.value = nextSort
query.page.value = 1
void query.execute()
+2 -2
View File
@@ -31,7 +31,7 @@ export const accountColumns = defineTableColumns<Account>([
kind: 'meta',
size: 'md',
align: 'center',
format: value => formatProviderLabel(typeof value === 'string' ? value : null),
formatter: value => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
@@ -49,7 +49,7 @@ export const accountColumns = defineTableColumns<Account>([
label: '令牌过期',
kind: 'datetime',
sortable: 'expiresAt',
format: value => optionalAccountCell(value),
formatter: value => optionalAccountCell(value),
emptyText: '',
},
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg', hideable: false },
+21 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseCheckbox, BaseConfirmModal, BasePageHeader, BaseTable, BaseTableColumnSettings, BaseTablePagination, useTableColumns } from '@codex-proxy/ui'
import { useTableColumns, ZCard, ZCheckbox, ZConfirmDialog, ZPageHeader, ZPagination, ZTable, ZTableColumnSettings } from '@codex-proxy/ui'
import { ChevronDown } from '@lucide/vue'
import { ref } from 'vue'
@@ -195,7 +195,7 @@ const {
<template>
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<BasePageHeader
<ZPageHeader
class="h-17"
title="账号管理"
description="维护账号池,查看可用性、配额与使用状态"
@@ -203,7 +203,7 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
<ZCard
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
>
<template #header>
@@ -227,7 +227,7 @@ const {
@edit-selected="openBatchEdit"
>
<template #actions>
<BaseTableColumnSettings
<ZTableColumnSettings
:options="columnOptions"
@change="setColumnVisible"
@reorder="setColumnOrder"
@@ -239,10 +239,10 @@ const {
<template #body>
<div class="flex min-h-0 flex-col xl:h-full">
<BaseTable
<ZTable
class="h-100! min-h-100 flex-none [--cp-table-row-height:72px] xl:h-auto! xl:min-h-0 xl:flex-1"
:columns="visibleColumns"
:rows="accounts"
:data="accounts"
:loading="loading"
:selected-row-keys="selectedRowKeys"
:expanded-row-keys="expandedRowKeys"
@@ -265,18 +265,18 @@ const {
</template>
<template #header-selection>
<BaseCheckbox
<ZCheckbox
:model-value="allSelected"
:indeterminate="indeterminate"
label="选择当前页账号"
aria-label="选择当前页账号"
@update:model-value="toggleAll"
/>
</template>
<template #selection="{ row }">
<BaseCheckbox
<ZCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
aria-label="选择账号"
@update:model-value="toggleSelection(row.id)"
/>
</template>
@@ -357,16 +357,16 @@ const {
<AccountUsagePanel :account="row" />
</div>
</template>
</BaseTable>
<BaseTablePagination
:pagination="accountPagination"
:loading="loading"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
</ZTable>
<ZPagination
v-bind="accountPagination"
:disabled="loading"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
</template>
</BaseCard>
</ZCard>
<AccountConnectionTestModal
v-model="showConnectionTestModal"
@@ -458,7 +458,7 @@ const {
@save="saveBatchEdit"
/>
<BaseConfirmModal
<ZConfirmDialog
v-model="showDeleteModal"
title="确认删除"
description="删除后该账号将不再参与调度,此操作不可撤销"
@@ -470,9 +470,9 @@ const {
<p class="m-0">
确定要删除选中的 {{ deleteCount }} 个账号吗?此操作不可撤销
</p>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="showSingleDeleteModal"
title="删除账号"
description="删除后该账号将不再参与调度,此操作不可撤销"
@@ -491,6 +491,6 @@ const {
}}
吗?
</p>
</BaseConfirmModal>
</ZConfirmDialog>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { dashboardSnapshotView, MetricTone } from '../presenter'
import { BaseCard, BaseEmpty } from '@codex-proxy/ui'
import { ZCard, ZEmpty } from '@codex-proxy/ui'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
@@ -160,7 +160,7 @@ const statusBars = computed(() => {
</script>
<template>
<BaseCard as="article" class="w-full xl:h-112.5">
<ZCard as="article" class="w-full xl:h-112.5">
<div
class="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7"
>
@@ -225,7 +225,7 @@ const statusBars = computed(() => {
<div
class="mt-5 flex w-full flex-col gap-2 overflow-hidden xl:mt-6.75 xl:h-82.5 xl:gap-2.5"
>
<BaseEmpty
<ZEmpty
v-if="accounts.length === 0"
title="暂无账号请求记录"
surface="inset"
@@ -340,5 +340,5 @@ const statusBars = computed(() => {
</div>
</section>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardSnapshotView, dashboardTrendView } from '../presenter'
import type { DashboardTrendKind } from '@/api/modules/dashboard'
import { BaseIconButton, BasePageHeader } from '@codex-proxy/ui'
import { ZIconButton, ZPageHeader } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
@@ -53,16 +53,16 @@ const trendKind = defineModel<DashboardTrendKind>('trendKind', { required: true
<template>
<div class="w-full">
<BasePageHeader title="系统概览">
<ZPageHeader title="系统概览">
<template #description>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
</template>
<template #actions>
<BaseIconButton
<ZIconButton
class="text-cp-primary-text"
size="md"
label="刷新概览"
size="default"
aria-label="刷新概览"
:loading="loading || refreshing"
:disabled="loading || refreshing"
@click="emit('refresh')"
@@ -71,9 +71,9 @@ const trendKind = defineModel<DashboardTrendKind>('trendKind', { required: true
<RefreshCw class="animate-spin motion-reduce:animate-none" :size="19" />
</template>
<RefreshCw :size="19" />
</BaseIconButton>
</ZIconButton>
</template>
</BasePageHeader>
</ZPageHeader>
<section
class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-4 2xl:gap-6"
@@ -2,7 +2,7 @@
import type { EChartsOption } from 'echarts'
import type { MetricCardView, MetricTone } from '../presenter'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { ZCard, ZMotionIcon } from '@codex-proxy/ui'
import { computed } from 'vue'
import AnimatedMetricValue from '@/components/AnimatedMetricValue.vue'
@@ -71,14 +71,14 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</script>
<template>
<BaseCard as="article" padding="compact" class="relative h-38.5 w-full">
<ZCard as="article" padding="compact" class="relative h-38.5 w-full">
<div class="flex items-start gap-3">
<BaseMotionIcon
<ZMotionIcon
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg"
:class="metricToneIconClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</BaseMotionIcon>
</ZMotionIcon>
<span class="mt-1 text-cp leading-[1.15] font-emphasis text-cp-text-secondary">{{ metric.title }}</span>
</div>
@@ -128,5 +128,5 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</b>
</span>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../presenter'
import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { toRef } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useRequestTrendChart } from '../composables/useRequestTrendChart'
@@ -42,11 +42,11 @@ const {
</script>
<template>
<BaseCard as="article" title="使用趋势" class="min-h-95 w-full">
<ZCard as="article" title="使用趋势" class="min-h-95 w-full">
<template #actions>
<BaseSegmented
<ZSegmented
v-model="activeKind"
label="趋势指标"
aria-label="趋势指标"
:options="tabs"
class="w-full max-w-61.5 sm:w-61.5"
@update:model-value="handleTrendChange"
@@ -95,7 +95,7 @@ const {
<div class="relative h-55 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
<ZEmpty
v-else-if="!loading"
surface="none"
:title="error || '暂无趋势数据'"
@@ -104,5 +104,5 @@ const {
</div>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../presenter'
import { BaseCard } from '@codex-proxy/ui'
import { ZCard } from '@codex-proxy/ui'
import { usageRecordColumns } from '@/components/usage/shared/columns'
import UsageRecordsTable from '@/components/usage/UsageRecordsTable.vue'
@@ -17,7 +17,7 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(
</script>
<template>
<BaseCard
<ZCard
as="article"
title="使用记录"
description="最近 10 条成功请求"
@@ -33,5 +33,5 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(
/>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { Box, CheckCircle2, Monitor, RefreshCw, ShieldCheck, Terminal, TriangleAlert } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -178,16 +178,16 @@ watch(
</script>
<template>
<BaseCard as="article" class="flex min-h-95 w-full flex-col">
<ZCard as="article" class="flex min-h-95 w-full flex-col">
<template #header>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<h2 class="m-0 pt-0.5 text-xl leading-[1.15] font-heavy text-cp-text text-balance">
通用上游身份
</h2>
<div v-if="providerOptions.length > 1" class="min-w-0 max-w-full overflow-x-auto">
<BaseSegmented
<ZSegmented
v-model="activeProvider"
label="上游平台"
aria-label="上游平台"
:options="providerOptions"
:style="{ width: `${providerOptions.length * 40 + 4}px` }"
display="icon"
@@ -197,7 +197,7 @@ watch(
</template>
<template #body>
<BaseEmpty v-if="!profile" title="暂无请求身份" surface="inset" class="min-h-71.75 flex-1 place-content-center" />
<ZEmpty v-if="!profile" title="暂无请求身份" surface="inset" class="min-h-71.75 flex-1 place-content-center" />
<div v-else class="flex flex-1">
<section
@@ -303,5 +303,5 @@ watch(
</section>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountGroup } from '@/api'
import { BaseIconButton } from '@codex-proxy/ui'
import { ZIconButton } from '@codex-proxy/ui'
import { Pencil, Power, Trash2 } from '@lucide/vue'
@@ -18,18 +18,16 @@ const emit = defineEmits<{
<template>
<div class="flex items-center gap-1">
<BaseIconButton
variant="ghost"
size="sm"
label="编辑分组"
<ZIconButton
size="small"
aria-label="编辑分组"
@click.stop="emit('edit', group)"
>
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="sm"
:label="group.enabled ? '禁用分组' : '启用分组'"
</ZIconButton>
<ZIconButton
size="small"
:aria-label="group.enabled ? '禁用分组' : '启用分组'"
:loading="updatingStatus"
@click.stop="emit('toggle', group)"
>
@@ -37,15 +35,14 @@ const emit = defineEmits<{
class="size-3.5"
:class="group.enabled ? 'text-cp-warning' : 'text-cp-success'"
/>
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="sm"
label="删除分组"
</ZIconButton>
<ZIconButton
size="small"
aria-label="删除分组"
:disabled="deleting"
@click.stop="emit('delete', group)"
>
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</ZIconButton>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseInput, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZInput, ZSelect } from '@codex-proxy/ui'
import { Plus, Search, Trash2 } from '@lucide/vue'
import { accountGroupStatusOptions } from '../constants'
@@ -18,7 +18,7 @@ const status = defineModel<string>('status', { required: true })
<template>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
<BaseInput
<ZInput
v-model="search"
class="sm:w-80"
aria-label="搜索账号分组"
@@ -27,17 +27,17 @@ const status = defineModel<string>('status', { required: true })
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
<BaseSelect
</ZInput>
<ZSelect
v-model="status"
:options="accountGroupStatusOptions"
aria-label="按分组状态筛选"
class="w-40"
/>
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
<BaseButton
<ZButton
v-if="selectedCount > 0"
variant="destructive"
type="danger" variant="plain"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
@@ -45,13 +45,13 @@ const status = defineModel<string>('status', { required: true })
<Trash2 class="size-4" />
</template>
删除选中 ({{ selectedCount }})
</BaseButton>
<BaseButton variant="primary" @click="emit('create')">
</ZButton>
<ZButton type="primary" @click="emit('create')">
<template #icon>
<Plus class="size-4" />
</template>
创建分组
</BaseButton>
</ZButton>
</div>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountGroupFormValue } from '../composables/useAccountGroups'
import type { AccountGroup } from '@/api'
import { BaseButton, BaseColorPicker, BaseForm, BaseFormItem, BaseInput, BaseModal, BaseSegmented, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZColorPicker, ZDialog, ZForm, ZFormItem, ZInput, ZSegmented, ZTextarea } from '@codex-proxy/ui'
import { computed } from 'vue'
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../constants'
@@ -22,35 +22,35 @@ const description = computed(() => props.group
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="title"
:description="description"
size="md"
:dismissible="!saving"
width="36rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
>
<BaseForm class="grid gap-5">
<BaseFormItem label="分组名称" required>
<BaseInput
<ZForm class="grid gap-5">
<ZFormItem label="分组名称" required>
<ZInput
v-model="form.name"
aria-label="分组名称"
placeholder="例如:生产账号"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="分组颜色" required>
<BaseColorPicker
</ZFormItem>
<ZFormItem label="分组颜色" required>
<ZColorPicker
v-model="form.color"
label="选择分组颜色"
aria-label="选择分组颜色"
:presets="ACCOUNT_GROUP_COLOR_PRESETS"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="Fast 模式">
<BaseSegmented
</ZFormItem>
<ZFormItem label="Fast 模式">
<ZSegmented
v-model="form.fastMode"
class="w-64 max-w-full"
label="Fast 模式"
aria-label="Fast 模式"
:options="[
{ label: '默认', value: 'default' },
{ label: '开启', value: 'enabled' },
@@ -58,30 +58,30 @@ const description = computed(() => props.group
]"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="描述(可选)">
<BaseTextarea
</ZFormItem>
<ZFormItem label="描述(可选)">
<ZTextarea
v-model="form.description"
aria-label="分组描述"
:rows="4"
placeholder="说明这个分组的用途..."
:disabled="saving"
/>
</BaseFormItem>
</BaseForm>
</ZFormItem>
</ZForm>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="!form.name.trim()"
@click="emit('save')"
>
保存分组
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
import type { AccountGroup, ApiKey, FastMode } from '@/api'
import { normalizeRgbaHexColor, toast } from '@codex-proxy/ui'
import { normalizeRgbaHexColor, ZNotification } from '@codex-proxy/ui'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import {
@@ -131,12 +131,12 @@ export function useAccountGroups() {
return
const name = form.value.name.trim()
if (!name) {
toast.warning('请输入分组名称')
ZNotification.warning({ message: '请输入分组名称' })
return
}
const color = normalizeRgbaHexColor(form.value.color)
if (!color) {
toast.warning('请选择有效的分组颜色')
ZNotification.warning({ message: '请选择有效的分组颜色' })
return
}
@@ -157,7 +157,7 @@ export function useAccountGroups() {
}
showFormModal.value = false
await Promise.all([query.execute(), loadReferenceKeys()])
toast.success(updating ? '分组已更新' : '分组已创建')
ZNotification.success({ message: updating ? '分组已更新' : '分组已创建' })
})
}
@@ -178,7 +178,7 @@ export function useAccountGroups() {
await disableAccountGroup({ id: group.id })
showDisableModal.value = false
await query.execute()
toast.success('分组已禁用')
ZNotification.success({ message: '分组已禁用' })
})
}
@@ -188,7 +188,7 @@ export function useAccountGroups() {
const mutation = group.enabled ? disableAccountGroup : enableAccountGroup
await mutation({ id: group.id })
await query.execute()
toast.success(group.enabled ? '分组已禁用' : '分组已启用')
ZNotification.success({ message: group.enabled ? '分组已禁用' : '分组已启用' })
}
catch {}
})
@@ -210,7 +210,7 @@ export function useAccountGroups() {
selectedIds.value = remaining
showDeleteModal.value = false
await query.execute()
toast.success('分组已删除')
ZNotification.success({ message: '分组已删除' })
}, { onError: () => void query.execute() })
}
@@ -239,16 +239,14 @@ export function useAccountGroups() {
await query.execute()
showBatchDeleteModal.value = false
toast.success(`已删除 ${deletedCount} 个分组`)
ZNotification.success({ message: `已删除 ${deletedCount} 个分组` })
}, {
errorText: false,
onError: (error) => {
void query.execute().catch(() => undefined)
toast.error(
deletedCount > 0
? `已删除 ${deletedCount} 个分组,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败'),
)
ZNotification.error({ message: deletedCount > 0
? `已删除 ${deletedCount} 个分组,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败') })
},
})
}
+22 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseCheckbox, BaseConfirmModal, BasePageHeader, BaseTable, BaseTablePagination } from '@codex-proxy/ui'
import { ZCard, ZCheckbox, ZConfirmDialog, ZPageHeader, ZPagination, ZTable } from '@codex-proxy/ui'
import { Zap, ZapOff } from '@lucide/vue'
import { usePageSelection } from '@/composables/usePageSelection'
import AccountGroupActions from './components/AccountGroupActions.vue'
@@ -49,13 +49,13 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<BasePageHeader
<ZPageHeader
class="h-17"
title="分组管理"
description="将账号归类管理,并为每个 API 密钥指定可使用的账号"
/>
<BaseCard
<ZCard
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
>
<template #header>
@@ -71,26 +71,26 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<template #body>
<div class="flex h-full min-h-0 flex-col">
<BaseTable
<ZTable
class="min-h-0 flex-1"
:columns="accountGroupColumns"
:rows="groups"
:data="groups"
:loading="loading"
:selected-row-keys="selectedRowKeys"
empty-text="暂无分组,请点击创建分组创建"
>
<template #header-selection>
<BaseCheckbox
<ZCheckbox
:model-value="allSelected"
:indeterminate="indeterminate"
label="选择当前页分组"
aria-label="选择当前页分组"
@update:model-value="toggleAll"
/>
</template>
<template #selection="{ row }">
<BaseCheckbox
<ZCheckbox
:model-value="selectedIds.has(row.id)"
label="选择分组"
aria-label="选择分组"
@update:model-value="toggleSelection(row.id)"
/>
</template>
@@ -162,16 +162,16 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
@delete="requestDelete"
/>
</template>
</BaseTable>
<BaseTablePagination
:pagination="pagination"
:loading="loading"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
</ZTable>
<ZPagination
v-bind="pagination"
:disabled="loading"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
</template>
</BaseCard>
</ZCard>
<AccountGroupFormModal
v-model="showFormModal"
@@ -181,7 +181,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
@save="save"
/>
<BaseConfirmModal
<ZConfirmDialog
v-model="showBatchDeleteModal"
title="确认删除"
description="删除后这些分组将立即失效,此操作不可撤销"
@@ -193,9 +193,9 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<p class="m-0">
确定删除选中的 {{ deleteCount }} 个分组吗?账号本身不会被删除
</p>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="showDisableModal"
title="禁用账号分组"
description="禁用后,使用该分组的 API 密钥将无法再使用其中的账号"
@@ -212,9 +212,9 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<p v-else-if="pendingDisableGroup?.clientKeyCount" class="mt-2 mb-0 text-cp-warning-text">
将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥
</p>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="showDeleteModal"
title="删除账号分组"
description="删除后该分组将立即失效,此操作不可撤销"
@@ -226,6 +226,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<p class="m-0">
确定删除“{{ pendingDeleteGroup?.name || '该分组' }}”吗?账号本身不会被删除
</p>
</BaseConfirmModal>
</ZConfirmDialog>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { KeyUsageBudget } from '@/api/modules/key-usage'
import { BaseCard } from '@codex-proxy/ui'
import { ZCard } from '@codex-proxy/ui'
import { Clock3, Gauge, Network } from '@lucide/vue'
import { computed } from 'vue'
import { money } from '../utils/format'
@@ -18,7 +18,7 @@ const windows = computed(() => [
</script>
<template>
<BaseCard title="额度概览">
<ZCard title="额度概览">
<div class="flex flex-1 flex-col justify-between gap-6">
<div class="grid flex-1 gap-6 sm:grid-cols-2">
<div v-for="window in windows" :key="window.label" class="flex min-w-0 flex-col justify-between gap-4">
@@ -32,7 +32,7 @@ const windows = computed(() => [
</div>
</div>
<div>
<div class="grid grid-cols-[repeat(20,minmax(0,1fr))] gap-0.75" :aria-label="window.limited ? `已用 ${window.percentage.toFixed(1)}%` : '不限额'">
<div class="grid grid-cols-20 gap-0.75" :aria-label="window.limited ? `已用 ${window.percentage.toFixed(1)}%` : '不限额'">
<span v-for="block in 20" :key="block" class="h-7 rounded-xs" :class="block <= Math.ceil(Math.min(100, window.percentage) / 5) ? (window.percentage >= 100 ? 'bg-cp-error' : 'bg-cp-success') : 'bg-cp-fill-secondary'" />
</div>
<div class="mt-2 flex justify-between gap-2 font-mono text-cp-xs text-cp-text-secondary">
@@ -54,5 +54,5 @@ const windows = computed(() => [
</div>
</div>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseIconButton, BasePageHeader, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZIconButton, ZPageHeader, ZSelect } from '@codex-proxy/ui'
import { Info, KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
import { shallowRef } from 'vue'
import { useRouter } from 'vue-router'
@@ -28,7 +28,7 @@ async function logout() {
</script>
<template>
<BasePageHeader title="使用统计">
<ZPageHeader title="使用统计">
<template #description>
<span class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<span class="min-w-0 truncate text-cp font-semibold text-cp-text" :title="name">
@@ -42,31 +42,31 @@ async function logout() {
</template>
<template #actions>
<div class="key-usage-actions flex max-w-[calc(100vw-32px)] flex-wrap items-center justify-end gap-2">
<BaseIconButton label="关于" variant="secondary" @click="$emit('openAbout')">
<ZIconButton aria-label="关于" variant="solid" @click="$emit('openAbout')">
<Info class="size-4" />
</BaseIconButton>
<BaseButton variant="secondary" :loading="configuring" @click="$emit('configure')">
</ZIconButton>
<ZButton :loading="configuring" @click="$emit('configure')">
<Terminal class="size-4" />
密钥配置
</BaseButton>
<BaseSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
<BaseSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
<BaseIconButton label="刷新用量" variant="secondary" :loading="refreshing" @click="$emit('refresh')">
</ZButton>
<ZSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
<ZSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
<ZIconButton aria-label="刷新用量" variant="solid" :loading="refreshing" @click="$emit('refresh')">
<template #loading>
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton label="切换主题" variant="secondary" @click="theme.toggleTheme($event)">
</ZIconButton>
<ZIconButton aria-label="切换主题" variant="solid" @click="theme.toggleTheme($event)">
<Sun v-if="theme.effectiveTheme === 'dark'" class="size-4" />
<Moon v-else class="size-4" />
</BaseIconButton>
<BaseIconButton label="退出登录" variant="secondary" :disabled="loggingOut" @click="logout">
</ZIconButton>
<ZIconButton aria-label="退出登录" variant="solid" :disabled="loggingOut" @click="logout">
<LogOut class="size-4" />
</BaseIconButton>
</ZIconButton>
</div>
</template>
</BasePageHeader>
</ZPageHeader>
</template>
<style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { BaseTablePaginationState as Pagination } from '@codex-proxy/ui'
import type { PaginationState as Pagination } from '@codex-proxy/ui'
import type { KeyUsageRecord, KeyUsageRecordKind } from '@/api/modules/key-usage'
import { BaseCard, BaseSegmented, BaseTable, BaseTablePagination, defineTableColumns } from '@codex-proxy/ui'
import { defineTableColumns, ZCard, ZPagination, ZSegmented, ZTable } from '@codex-proxy/ui'
import { computed } from 'vue'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
@@ -31,15 +31,15 @@ const columns = computed(() => defineTableColumns<KeyUsageRecord>([
</script>
<template>
<BaseCard title="请求日志" description="当前请求记录">
<ZCard title="请求日志" description="当前请求记录">
<template #actions>
<BaseSegmented v-model="kind" label="请求结果" :options="[{ label: '成功请求', value: 'success' }, { label: '错误记录', value: 'error' }]" />
<ZSegmented v-model="kind" aria-label="请求结果" :options="[{ label: '成功请求', value: 'success' }, { label: '错误记录', value: 'error' }]" />
</template>
<p v-if="error || stale" role="status" class="mt-0 mb-3 text-cp-sm text-cp-error-text">
{{ error || '请求日志刷新失败,暂时保留上次结果' }}
</p>
<div class="flex h-120 min-h-0 overflow-hidden">
<BaseTable class="min-w-0 flex-1" :columns="columns" :rows="rows" :loading="loading" scrollbar-always-visible :empty-text="error ? '请求日志加载失败,请点击顶部刷新重试' : '所选条件下暂无记录'">
<ZTable class="min-w-0 flex-1" :columns="columns" :data="rows" :loading="loading" scrollbar-always-visible :empty-text="error ? '请求日志加载失败,请点击顶部刷新重试' : '所选条件下暂无记录'">
<template #model="{ row }">
<code class="block max-w-full truncate font-mono text-cp-sm leading-none font-heavy text-cp-text">{{ row.model || '—' }}</code>
</template>
@@ -77,8 +77,8 @@ const columns = computed(() => defineTableColumns<KeyUsageRecord>([
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-cp-sm leading-[1.4] font-emphasis text-cp-text-secondary">{{ row.userAgent || '—' }}</span>
</template>
</BaseTable>
</ZTable>
</div>
<BaseTablePagination :pagination="pagination" :loading="loading" @page-change="$emit('pageChange', $event)" @page-size-change="$emit('pageSizeChange', $event)" />
</BaseCard>
<ZPagination v-bind="pagination" :disabled="loading" @current-change="$emit('pageChange', $event)" @size-change="$emit('pageSizeChange', $event)" />
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseSkeleton } from '@codex-proxy/ui'
import { ZCard, ZSkeleton } from '@codex-proxy/ui'
import { Gauge, Zap } from '@lucide/vue'
import { keyUsageTokenMetrics } from '../utils/metrics'
@@ -12,47 +12,47 @@ const metrics = [
<template>
<span role="status" class="sr-only">正在加载用量</span>
<section aria-label="用量汇总加载中" aria-busy="true" class="grid min-w-0 gap-4 xl:grid-cols-[minmax(340px,0.95fr)_minmax(0,3fr)]">
<BaseCard padding="compact" class="flex min-h-28 min-w-0 flex-col justify-between gap-4" aria-hidden="true">
<ZCard padding="compact" class="flex min-h-28 min-w-0 flex-col justify-between gap-4" aria-hidden="true">
<div class="flex min-w-0 flex-1 items-center gap-3">
<span class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg bg-cp-info-container text-cp-info-on-container"><Zap :size="18" /></span>
<BaseSkeleton class="h-8 w-44 max-w-full" />
<BaseSkeleton shape="text" class="w-12 shrink-0" />
<ZSkeleton class="h-8 w-44 max-w-full" />
<ZSkeleton shape="text" class="w-12 shrink-0" />
</div>
<div class="flex min-h-5 items-center justify-between gap-6">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-30" />
<ZSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-30" />
</div>
</BaseCard>
<BaseCard padding="compact" class="flex min-h-28 min-w-0 items-center" aria-hidden="true">
</ZCard>
<ZCard padding="compact" class="flex min-h-28 min-w-0 items-center" aria-hidden="true">
<div class="grid w-full min-w-0 grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 2xl:grid-cols-6">
<div v-for="item in metrics" :key="item.label" class="min-w-0">
<div class="flex min-w-0 items-center gap-2 text-cp-lg leading-[1.15] font-emphasis text-cp-text-secondary">
<component :is="item.icon" class="size-4.5 shrink-0" :class="item.tone" />{{ item.label }}
</div>
<BaseSkeleton class="mt-5 h-6 w-20 max-w-full" />
<ZSkeleton class="mt-5 h-6 w-20 max-w-full" />
</div>
</div>
</BaseCard>
</ZCard>
</section>
<div class="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(400px,1fr)]">
<BaseCard title="使用趋势" description="用量随时间的变化" aria-busy="true">
<ZCard title="使用趋势" description="用量随时间的变化" aria-busy="true">
<div class="flex h-71.25 flex-col" aria-hidden="true">
<div class="flex h-12 shrink-0 flex-wrap content-start justify-end gap-3">
<BaseSkeleton v-for="item in 6" :key="item" shape="text" class="h-2 w-10" />
<ZSkeleton v-for="item in 6" :key="item" shape="text" class="h-2 w-10" />
</div>
<div class="flex min-h-0 flex-1 flex-col justify-between">
<div v-for="line in 5" :key="line" class="flex items-center gap-3">
<BaseSkeleton shape="text" class="h-2 w-6 shrink-0" />
<ZSkeleton shape="text" class="h-2 w-6 shrink-0" />
<span class="h-px flex-1 bg-cp-split opacity-50" />
</div>
</div>
<div class="mt-3 flex justify-between gap-3 pl-9">
<BaseSkeleton v-for="tick in 6" :key="tick" shape="text" class="h-2 w-8" />
<ZSkeleton v-for="tick in 6" :key="tick" shape="text" class="h-2 w-8" />
</div>
</div>
</BaseCard>
<BaseCard title="额度概览" aria-busy="true">
</ZCard>
<ZCard title="额度概览" aria-busy="true">
<div class="flex flex-1 flex-col justify-between gap-6" aria-hidden="true">
<div class="grid flex-1 gap-6 sm:grid-cols-2">
<div v-for="label in ['今日额度', '周额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
@@ -60,34 +60,34 @@ const metrics = [
<div class="text-cp-sm text-cp-text-secondary">
{{ label }}
</div>
<BaseSkeleton class="mt-3 h-8 w-32 max-w-full" />
<ZSkeleton class="mt-3 h-8 w-32 max-w-full" />
</div>
<div>
<BaseSkeleton class="h-5 w-full" />
<ZSkeleton class="h-5 w-full" />
<div class="mt-2 flex justify-between gap-2">
<BaseSkeleton shape="text" class="h-2.5 w-16" />
<BaseSkeleton shape="text" class="h-2.5 w-8" />
<ZSkeleton shape="text" class="h-2.5 w-16" />
<ZSkeleton shape="text" class="h-2.5 w-8" />
</div>
</div>
<div class="grid gap-2">
<BaseSkeleton shape="text" class="h-2.5 w-16" />
<BaseSkeleton shape="text" class="h-2.5 w-36 max-w-full" />
<ZSkeleton shape="text" class="h-2.5 w-16" />
<ZSkeleton shape="text" class="h-2.5 w-36 max-w-full" />
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<BaseSkeleton v-for="limit in 2" :key="limit" class="h-10" />
<ZSkeleton v-for="limit in 2" :key="limit" class="h-10" />
</div>
</div>
</BaseCard>
</ZCard>
</div>
<BaseCard title="请求健康时间线" description="有效请求可用性" aria-busy="true">
<ZCard title="请求健康时间线" description="有效请求可用性" aria-busy="true">
<template #actions>
<BaseSkeleton shape="text" class="h-3.5 w-56 max-w-full" aria-hidden="true" />
<ZSkeleton shape="text" class="h-3.5 w-56 max-w-full" aria-hidden="true" />
</template>
<div class="grid grid-cols-48 items-center gap-x-0.5 gap-y-1 motion-safe:animate-cp-skeleton motion-reduce:animate-none sm:grid-cols-96" aria-hidden="true">
<span v-for="point in 96" :key="point" class="flex h-5 items-center"><span class="h-3.5 w-full rounded-xs bg-cp-fill-secondary" /></span>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { KeyUsageMetrics } from '@/api/modules/key-usage'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { ZCard, ZMotionIcon } from '@codex-proxy/ui'
import { Gauge, Zap } from '@lucide/vue'
import { computed } from 'vue'
import AnimatedMetricValue from '@/components/AnimatedMetricValue.vue'
@@ -22,11 +22,11 @@ const metrics = computed(() => [
<template>
<section class="grid min-w-0 gap-4 xl:grid-cols-[minmax(340px,0.95fr)_minmax(0,3fr)]" aria-label="用量汇总">
<BaseCard as="article" padding="compact" class="flex min-h-28 min-w-0 flex-col justify-between gap-4">
<ZCard as="article" padding="compact" class="flex min-h-28 min-w-0 flex-col justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-3">
<BaseMotionIcon class="inline-flex size-9 shrink-0 items-center relative -top-0.5 justify-center rounded-cp-lg bg-cp-info-container text-cp-info-on-container" aria-hidden="true">
<ZMotionIcon class="inline-flex size-9 shrink-0 items-center relative -top-0.5 justify-center rounded-cp-lg bg-cp-info-container text-cp-info-on-container" aria-hidden="true">
<Zap :size="19" />
</BaseMotionIcon>
</ZMotionIcon>
<span class="sr-only">消耗 Tokens</span>
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-cp-text" :title="formatCompactNumber(summary.totalTokens)">
@@ -53,8 +53,8 @@ const metrics = computed(() => [
</dd>
</div>
</dl>
</BaseCard>
<BaseCard as="article" padding="compact" class="flex min-h-28 min-w-0 items-center" aria-label="用量明细">
</ZCard>
<ZCard as="article" padding="compact" class="flex min-h-28 min-w-0 items-center" aria-label="用量明细">
<dl class="m-0 grid w-full min-w-0 grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 2xl:grid-cols-6">
<div v-for="item in metrics" :key="item.label" class="min-w-0">
<dt class="flex min-w-0 items-center gap-2 text-cp-lg leading-[1.15] font-emphasis text-cp-text-secondary">
@@ -66,6 +66,6 @@ const metrics = computed(() => [
</dd>
</div>
</dl>
</BaseCard>
</ZCard>
</section>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { EChartsOption, LineSeriesOption } from 'echarts'
import type { KeyUsageTrendPoint } from '@/api/modules/key-usage'
import { BaseCard, BaseEmpty } from '@codex-proxy/ui'
import { ZCard, ZEmpty } from '@codex-proxy/ui'
import { computed } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { chartTooltipStyle } from '@/components/charts/tooltip'
@@ -66,13 +66,13 @@ const option = computed<EChartsOption>(() => {
</script>
<template>
<BaseCard title="使用趋势" description="用量随时间的变化">
<ZCard title="使用趋势" description="用量随时间的变化">
<BaseChart v-if="points.some(point => point.requests > 0)" :option="option" :height="285" />
<BaseEmpty
<ZEmpty
v-else
title="所选时间内暂无请求"
surface="none"
class="h-71 place-content-center"
/>
</BaseCard>
</ZCard>
</template>
+5 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { KeyUsageVersion } from '@/api/modules/key-usage'
import { BaseInput, BaseScrollbar } from '@codex-proxy/ui'
import { ZInput, ZScrollbar } from '@codex-proxy/ui'
import { Search } from '@lucide/vue'
import { shallowRef } from 'vue'
import { getKeyUsageVersion } from '@/api/modules/key-usage'
@@ -43,17 +43,17 @@ async function openAbout() {
<template>
<main class="h-dvh overflow-hidden bg-cp-bg-layout text-cp-text">
<BaseScrollbar>
<ZScrollbar>
<div class="mx-auto flex min-h-full w-full max-w-480 flex-col gap-5 p-4 min-[961px]:p-6">
<div class="flex flex-col gap-2">
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" @open-about="openAbout" />
<div class="flex flex-wrap items-center justify-between gap-3">
<span v-if="overview" class="text-cp-sm text-cp-text-tertiary">更新于 {{ overview.asOfDisplay }}</span>
<BaseInput v-model="model" class="ml-auto w-60 max-w-full" placeholder="输入完整模型名称" aria-label="筛选统计和日志的模型" :maxlength="128">
<ZInput v-model="model" class="ml-auto w-60 max-w-full" placeholder="输入完整模型名称" aria-label="筛选统计和日志的模型" :maxlength="128">
<template #prefix>
<Search class="size-4" />
</template>
</BaseInput>
</ZInput>
</div>
</div>
<p v-if="overviewError" role="alert" class="m-0 rounded-cp-lg bg-cp-error-container px-4 py-3 text-cp-sm text-cp-error-text">
@@ -70,7 +70,7 @@ async function openAbout() {
<KeyUsageSkeleton v-else-if="overviewLoading" />
<KeyUsageRecords v-model:kind="kind" :rows="items" :pagination="{ currentPage, pageSize, total }" :loading="recordsLoading" :error="recordsError" :stale="recordsStale" @page-change="changePage" @page-size-change="changePageSize" />
</div>
</BaseScrollbar>
</ZScrollbar>
<ApiKeyConfigModal v-model="showConfig" title="密钥配置" :api-key="configKey" :api-base-url="apiBaseUrl" @copy="copyConfig" @after-leave="configKey = null" />
<AppAboutModal v-model="aboutOpen" :version="version" />
</main>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ApiKey } from '@/api'
import { BaseIconButton, BaseMenuItem, BasePopover } from '@codex-proxy/ui'
import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui'
import { MoreHorizontal, Pencil, Power, RotateCcw, Terminal, Trash2, Upload } from '@lucide/vue'
@@ -24,59 +24,57 @@ const emit = defineEmits<{
<template>
<div class="flex items-center justify-start gap-0.5">
<BaseIconButton
variant="ghost"
size="sm"
label="编辑密钥"
<ZIconButton
size="small"
aria-label="编辑密钥"
@click.stop="emit('edit', apiKey)"
>
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
size="sm"
label="使用密钥"
</ZIconButton>
<ZIconButton
size="small"
aria-label="使用密钥"
:loading="revealing"
:disabled="revealing"
@click.stop="emit('use', apiKey)"
>
<Terminal class="size-3.5 text-cp-primary-text" />
</BaseIconButton>
</ZIconButton>
<BasePopover placement="bottom-end">
<template #trigger="{ open }">
<BaseIconButton variant="ghost" size="sm" label="更多操作" :pressed="open">
<ZPopover placement="bottom-end">
<template #reference="{ open }">
<ZIconButton size="small" aria-label="更多操作" :pressed="open">
<MoreHorizontal class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<template #default="{ close }">
<div class="w-44 p-1.5">
<BaseMenuItem @click.stop="(close(), emit('resetBudget', apiKey))">
<ZMenuItem @click.stop="(close(), emit('resetBudget', apiKey))">
<template #icon>
<RotateCcw class="size-3.5 text-cp-text-quaternary" />
</template>
重置已用额度
</BaseMenuItem>
<BaseMenuItem :disabled="revealing" @click.stop="(close(), emit('importCcs', apiKey))">
</ZMenuItem>
<ZMenuItem :disabled="revealing" @click.stop="(close(), emit('importCcs', apiKey))">
<template #icon>
<Upload class="size-3.5 text-cp-text-quaternary" />
</template>
导入 CCSwitch
</BaseMenuItem>
<BaseMenuItem :loading="updatingStatus" @click.stop="(close(), emit('toggle', apiKey))">
</ZMenuItem>
<ZMenuItem :loading="updatingStatus" @click.stop="(close(), emit('toggle', apiKey))">
<template #icon>
<Power class="size-3.5" :class="apiKey.enabled ? 'text-cp-warning' : 'text-cp-success'" />
</template>
{{ apiKey.enabled ? '禁用密钥' : '启用密钥' }}
</BaseMenuItem>
<BaseMenuItem tone="destructive" :disabled="deleting" @click.stop="(close(), emit('delete', apiKey))">
</ZMenuItem>
<ZMenuItem type="danger" :disabled="deleting" @click.stop="(close(), emit('delete', apiKey))">
<template #icon>
<Trash2 class="size-3.5" />
</template>
删除密钥
</BaseMenuItem>
</ZMenuItem>
</div>
</template>
</BasePopover>
</ZPopover>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ApiKey } from '@/api'
import { BasePopover } from '@codex-proxy/ui'
import { ZPopover } from '@codex-proxy/ui'
import { computed } from 'vue'
const props = defineProps<{ apiKey: ApiKey }>()
@@ -15,8 +15,8 @@ function amount(value: string) {
</script>
<template>
<BasePopover class="w-full min-w-0" trigger="hover-click" placement="right" :hover-delay="240">
<template #trigger="{ open }">
<ZPopover class="w-full min-w-0" trigger="hover-click" placement="right" :hover-delay="240">
<template #reference="{ open }">
<button
type="button"
class="grid w-full min-w-0 cursor-pointer gap-1 rounded-sm border-0 bg-transparent p-0 text-left text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@@ -49,5 +49,5 @@ function amount(value: string) {
</div>
</div>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ApiKey } from '@/api'
import { BaseButton, BaseModal, BaseSegmented, toast } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZNotification, ZSegmented } from '@codex-proxy/ui'
import { ArrowRight } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { resetApiKeyBudget } from '@/api'
@@ -36,24 +36,24 @@ async function resetBudget() {
await run(async () => {
await resetApiKeyBudget({ id: key.id, period: selectedPeriod })
open.value = false
toast.success('已重置密钥额度')
ZNotification.success({ message: '已重置密钥额度' })
emit('reset')
})
}
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="重置已用额度"
description="清零所选周期的已用金额,下次使用时重新确定重置日期"
size="sm"
:dismissible="!loading"
width="28rem"
:show-close="!loading" :close-on-click-modal="!loading" :close-on-press-escape="!loading"
>
<div class="grid gap-5">
<div class="grid min-w-0 gap-1.5">
<span class="text-cp-xs text-cp-text-quaternary">目标密钥</span>
<p class="m-0 break-words text-cp text-cp-text">
<p class="m-0 wrap-break-word text-cp text-cp-text">
{{ apiKey?.name }}
</p>
</div>
@@ -62,7 +62,7 @@ async function resetBudget() {
<legend class="mb-2.5 p-0 text-cp-sm text-cp-text-secondary">
重置范围
</legend>
<BaseSegmented v-model="period" class="w-full" label="重置范围" :options="periods" :disabled="loading" />
<ZSegmented v-model="period" class="w-full" aria-label="重置范围" :options="periods" :disabled="loading" />
</fieldset>
<div v-if="apiKey" class="rounded-cp bg-cp-fill-quaternary p-3.5" aria-live="polite" aria-atomic="true">
@@ -87,12 +87,12 @@ async function resetBudget() {
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="loading" @click="open = false">
<ZButton :disabled="loading" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" :loading="loading" :disabled="!apiKey" @click="resetBudget">
</ZButton>
<ZButton type="primary" :loading="loading" :disabled="!apiKey" @click="resetBudget">
确认重置
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseIconButton, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton } from '@codex-proxy/ui'
import { Copy, Upload } from '@lucide/vue'
defineProps<{ createdKey: string }>()
@@ -12,13 +12,13 @@ const open = defineModel<boolean>({ default: false })
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="API Key 已创建"
description="复制密钥,或直接导入 CCSwitch"
tone="success"
size="md"
@after-leave="emit('afterLeave')"
type="success"
width="36rem"
@closed="emit('afterLeave')"
>
<div class="flex flex-col gap-4">
<div class="rounded-cp border border-cp-warning-border bg-cp-warning-container px-4 py-3">
@@ -34,29 +34,29 @@ const open = defineModel<boolean>({ default: false })
<code class="flex-1 rounded-cp bg-cp-fill-quaternary px-3 py-2.5 font-mono text-cp break-all text-cp-text">
{{ createdKey }}
</code>
<BaseIconButton size="md" label="复制" @click="emit('copy', createdKey)">
<ZIconButton size="default" aria-label="复制" @click="emit('copy', createdKey)">
<Copy class="size-4" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
</div>
<template #footer>
<BaseButton variant="secondary" @click="emit('copy', createdKey)">
<ZButton @click="emit('copy', createdKey)">
<template #icon>
<Copy class="size-4" />
</template>
复制密钥
</BaseButton>
<BaseButton variant="secondary" @click="emit('importCcs')">
</ZButton>
<ZButton @click="emit('importCcs')">
<template #icon>
<Upload class="size-4" />
</template>
导入 CCSwitch
</BaseButton>
<BaseButton variant="primary" @click="open = false">
</ZButton>
<ZButton type="primary" @click="open = false">
我已保存
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseInput } from '@codex-proxy/ui'
import { ZButton, ZInput } from '@codex-proxy/ui'
import { Plus, Search, Trash2 } from '@lucide/vue'
@@ -23,17 +23,17 @@ const search = defineModel<string>('search', { required: true })
aria-label="API Key 筛选与操作"
>
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
<BaseInput v-model="search" placeholder="搜索名称或标签" aria-label="搜索 API Key 名称或标签" class="w-full">
<ZInput v-model="search" placeholder="搜索名称或标签" aria-label="搜索 API Key 名称或标签" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</ZInput>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 md:ml-auto">
<BaseButton
<ZButton
v-if="selectedCount > 0"
variant="destructive"
type="danger" variant="plain"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
@@ -41,13 +41,13 @@ const search = defineModel<string>('search', { required: true })
<Trash2 class="size-4" />
</template>
删除选中 ({{ selectedCount }})
</BaseButton>
<BaseButton variant="primary" @click="emit('create')">
</ZButton>
<ZButton type="primary" @click="emit('create')">
<template #icon>
<Plus class="size-4" />
</template>
创建 API Key
</BaseButton>
</ZButton>
</div>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ApiKeyFormValue } from '../composables/useApiKeyEditor'
import type { AccountGroupRef } from '@/api'
import { BaseButton, BaseForm, BaseFormItem, BaseInput, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZForm, ZFormItem, ZInput } from '@codex-proxy/ui'
import { DollarSign, KeyRound } from '@lucide/vue'
import { computed } from 'vue'
@@ -28,44 +28,44 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="title"
description="配置密钥信息、分组与使用限制"
tone="info"
size="lg"
:dismissible="!saving"
@after-leave="emit('afterLeave')"
type="info"
width="48rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
@closed="emit('afterLeave')"
>
<template #icon>
<KeyRound class="text-cp-text" :size="20" aria-hidden="true" />
</template>
<BaseForm class="grid gap-6">
<BaseFormItem label="名称" required>
<BaseInput
<ZForm class="grid gap-6">
<ZFormItem label="名称" required>
<ZInput
:model-value="form.name" aria-label="名称"
placeholder="例如:生产环境"
:disabled="saving"
@update:model-value="updateForm('name', $event)"
/>
</BaseFormItem>
</ZFormItem>
<div class="grid gap-6" :class="{ 'sm:grid-cols-2': !editing }">
<BaseFormItem label="标签(可选)">
<BaseInput
<ZFormItem label="标签(可选)">
<ZInput
:model-value="form.label" aria-label="标签(可选)"
placeholder="例如:后端服务"
:disabled="saving"
@update:model-value="updateForm('label', $event)"
/>
</BaseFormItem>
</ZFormItem>
<BaseFormItem
<ZFormItem
v-if="!editing"
label="自定义 Key(可选)"
>
<BaseInput
<ZInput
:model-value="form.customKey" type="password"
autocomplete="new-password"
:spellcheck="false"
@@ -74,30 +74,30 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
:disabled="saving"
@update:model-value="updateForm('customKey', $event)"
/>
</BaseFormItem>
</ZFormItem>
</div>
<BaseFormItem label="分组">
<ZFormItem label="分组">
<AccountGroupCheckboxGrid
:model-value="form.groupIds" :groups="groups"
:loading="groupLoading"
:disabled="saving"
@update:model-value="updateForm('groupIds', $event)"
/>
</BaseFormItem>
</ZFormItem>
<BaseFormItem label="上游身份">
<ZFormItem label="上游身份">
<ProviderRequestProfilesEditor
:model-value="form.providerRequestProfileOverrides" allow-inherit
:active="open"
:disabled="saving"
@update:model-value="updateForm('providerRequestProfileOverrides', $event)"
/>
</BaseFormItem>
</ZFormItem>
<div class="grid gap-6 sm:grid-cols-2">
<BaseFormItem label="日限额">
<BaseInput
<ZFormItem label="日限额">
<ZInput
:model-value="form.dailyLimitUsd" type="number"
min="0"
step="any"
@@ -109,10 +109,10 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
<template #prefix>
<DollarSign class="size-4" aria-hidden="true" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="周限额">
<BaseInput
</ZInput>
</ZFormItem>
<ZFormItem label="周限额">
<ZInput
:model-value="form.weeklyLimitUsd" type="number"
min="0"
step="any"
@@ -124,13 +124,13 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
<template #prefix>
<DollarSign class="size-4" aria-hidden="true" />
</template>
</BaseInput>
</BaseFormItem>
</ZInput>
</ZFormItem>
</div>
<div class="grid gap-6 sm:grid-cols-2">
<BaseFormItem label="最大并发">
<BaseInput
<ZFormItem label="最大并发">
<ZInput
:model-value="form.maxConcurrency" type="number"
aria-label="最大并发"
min="0"
@@ -139,9 +139,9 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
:disabled="saving"
@update:model-value="updateForm('maxConcurrency', $event)"
/>
</BaseFormItem>
<BaseFormItem label="每分钟请求数(RPM)">
<BaseInput
</ZFormItem>
<ZFormItem label="每分钟请求数(RPM)">
<ZInput
:model-value="form.requestsPerMinute" type="number"
aria-label="每分钟请求数(RPM)"
min="0"
@@ -150,22 +150,22 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
:disabled="saving"
@update:model-value="updateForm('requestsPerMinute', $event)"
/>
</BaseFormItem>
</ZFormItem>
</div>
</BaseForm>
</ZForm>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="!form.name.trim()"
@click="emit('save')"
>
{{ editing ? '保存更改' : '创建' }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseIconButton } from '@codex-proxy/ui'
import { ZIconButton } from '@codex-proxy/ui'
import { Copy } from '@lucide/vue'
@@ -18,15 +18,14 @@ const emit = defineEmits<{
<code class="block min-w-0 overflow-hidden whitespace-nowrap font-mono text-cp-sm font-emphasis text-cp-text">
{{ prefix }}••••••••••••••••
</code>
<BaseIconButton
variant="ghost"
size="sm"
label="复制完整密钥"
<ZIconButton
size="small"
aria-label="复制完整密钥"
:loading="revealing"
:disabled="revealing"
@click="emit('copy')"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
</template>
@@ -1,6 +1,6 @@
import type { ApiKey } from '@/api'
import type { ProviderRequestProfiles, ProviderRequestProfileUpdates } from '@/api/modules/settings/profiles'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { cloneDeep } from 'es-toolkit'
import { ref, shallowRef } from 'vue'
import { createApiKey, updateApiKey } from '@/api'
@@ -105,11 +105,11 @@ export function useApiKeyEditor(options: {
showFormModal.value = false
await options.reload()
if (current) {
toast.success('API Key 已更新')
ZNotification.success({ message: 'API Key 已更新' })
}
else if (created) {
options.onCreated(created.key, created.name)
toast.success('API Key 创建成功')
ZNotification.success({ message: 'API Key 创建成功' })
}
},
{ onError: () => void options.reload() },
@@ -119,16 +119,16 @@ export function useApiKeyEditor(options: {
function validateForm() {
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['周限额', form.value.weeklyLimitUsd]]) {
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
toast.warning(`${label}必须是非负金额,最多 10 位小数`)
ZNotification.warning({ message: `${label}必须是非负金额,最多 10 位小数` })
return false
}
}
if (!form.value.name.trim()) {
toast.warning('请输入 API Key 名称')
ZNotification.warning({ message: '请输入 API Key 名称' })
return false
}
if (!editingKey.value && form.value.customKey && !/^[\x21-\x7E]+$/.test(form.value.customKey)) {
toast.warning('自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行')
ZNotification.warning({ message: '自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行' })
return false
}
for (const [label, value] of [
@@ -137,7 +137,7 @@ export function useApiKeyEditor(options: {
] as const) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < 0) {
toast.warning(`${label}必须是非负整数`)
ZNotification.warning({ message: `${label}必须是非负整数` })
return false
}
}
@@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import type { ApiKey } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { shallowRef, watch } from 'vue'
import { deleteApiKey, disableApiKey, enableApiKey } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -46,7 +46,7 @@ export function useApiKeyMutations(options: {
options.selectedIds.value = remaining
showSingleDeleteModal.value = false
await options.reload()
toast.success('删除成功')
ZNotification.success({ message: '删除成功' })
},
{ onError: () => void options.reload() },
)
@@ -67,7 +67,7 @@ export function useApiKeyMutations(options: {
}
showDeleteModal.value = false
await options.reload()
toast.success(`已删除 ${deleteCount} 个 API Key`)
ZNotification.success({ message: `已删除 ${deleteCount} 个 API Key` })
},
{ onError: () => void options.reload() },
)
@@ -79,7 +79,7 @@ export function useApiKeyMutations(options: {
const mutation = key.enabled ? disableApiKey : enableApiKey
await mutation({ id: key.id })
await options.reload()
toast.success(key.enabled ? '已禁用' : '已启用')
ZNotification.success({ message: key.enabled ? '已禁用' : '已启用' })
}
catch {
void options.reload()
@@ -1,5 +1,5 @@
import type { ApiKey } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { revealApiKey } from '@/api'
import { useCopyText } from '@/composables/useCopyText'
@@ -64,7 +64,7 @@ export function useApiKeyUse() {
if (!result)
return undefined
if (!result.plaintextKey) {
toast.error('完整 API Key 不可用')
ZNotification.error({ message: '完整 API Key 不可用' })
return undefined
}
return result.plaintextKey
@@ -1,4 +1,4 @@
import type { BaseTableSort } from '@codex-proxy/ui'
import type { TableSort } from '@codex-proxy/ui'
import type { ApiKey } from '@/api'
import { computed, onMounted, shallowRef, watch } from 'vue'
@@ -7,7 +7,7 @@ import { useRequestState } from '@/composables/useRequestState'
export function useApiKeysQuery() {
const searchQuery = shallowRef('')
const sort = shallowRef<BaseTableSort>()
const sort = shallowRef<TableSort>()
const page = shallowRef(1)
const pageSize = shallowRef(20)
const total = shallowRef(0)
@@ -109,7 +109,7 @@ export function useApiKeysQuery() {
void reloadFromStart()
}
function handleSortChange(nextSort: BaseTableSort | undefined) {
function handleSortChange(nextSort: TableSort | undefined) {
sort.value = nextSort
void reloadFromStart()
}

Some files were not shown because too many files have changed in this diff Show More