feat(frontend): unify API key import dialog

Route API key imports through a shared CC Switch dialog with a GitHub link. Simplify key creation actions and narrow the action menu.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-23 17:25:56 +08:00
co-authored by Codex
parent 6b4b8faaf4
commit bfea3e1d6f
11 changed files with 129 additions and 39 deletions
+2 -2
View File
@@ -80,7 +80,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CC Switch
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
@@ -93,7 +93,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,合并配置后重启 Codex;也可通过「导入到」把服务连接与密钥添加到 CC Switch
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
+2 -2
View File
@@ -647,8 +647,8 @@ KeyUsageService 从 AuthService 的服务端身份或 Core 的入口认证结果
ObservabilityStore 的范围查询;数据面额度查询不执行推理准入或开启窗口,入口认证和适用的请求中间件仍会执行。
API 只输出各入口所需的字段白名单,不复用管理员的宽响应。
客户端配置通过 ClientKeyStore 显式读取当前会话绑定 Key 的明文,不进入用量响应。
前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口;配置弹窗和 Codex / CCSwitch
配置生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器
前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口;配置弹窗、Codex 配置及 CC Switch
导入链接生成逻辑与管理端共用,明文仅在打开弹窗时获取,关闭后清除,不持久化到浏览器
## 6. 路由、账号范围与 continuation
@@ -6,6 +6,7 @@ import { Apple, Copy, Monitor, Upload } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildCodexCcSwitchImportDeeplink, buildCodexConfig, CODEX_WEBSOCKET_ENABLED_BY_DEFAULT } from '@/utils/client'
import ClientImportModal from './ClientImportModal.vue'
const props = defineProps<{
title?: string
@@ -18,11 +19,13 @@ const props = defineProps<{
const emit = defineEmits<{
copy: [text: string]
afterLeave: []
}>()
const { t } = useI18n({ useScope: 'global' })
const open = defineModel<boolean>({ default: false })
const showImport = shallowRef(false)
const activePlatform = shallowRef('unix')
const websocketEnabled = shallowRef(CODEX_WEBSOCKET_ENABLED_BY_DEFAULT)
@@ -44,7 +47,7 @@ const codexConfig = computed(() => buildCodexConfig({
websocketEnabled: websocketEnabled.value,
}))
function importToCcs() {
function importToClient() {
if (!keyValue.value)
return
window.location.href = buildCodexCcSwitchImportDeeplink({
@@ -61,6 +64,7 @@ function importToCcs() {
:title="title ?? t('keys.configuration.title')"
:description="t('keys.configuration.description')"
width="48rem"
@closed="showImport = false; emit('afterLeave')"
>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
@@ -103,13 +107,14 @@ function importToCcs() {
</div>
<template #footer>
<ZButton :disabled="!keyValue" @click="importToCcs">
<ZButton :disabled="!keyValue" @click="showImport = true">
<Upload class="size-4" />
{{ t('keys.configuration.importCcSwitch') }}
{{ t('keys.configuration.importTo') }}
</ZButton>
<ZButton type="primary" @click="open = false">
{{ t('common.actions.close') }}
</ZButton>
</template>
</ZDialog>
<ClientImportModal v-if="open" v-model="showImport" @select="importToClient" />
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { Github } from '@boxicons/vue'
import { ZDialog, ZMenuItem } from '@codex-proxy/ui'
import { AppWindow } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
select: []
afterLeave: []
}>()
const open = defineModel<boolean>({ default: false })
const { t } = useI18n({ useScope: 'global' })
function select() {
emit('select')
open.value = false
}
</script>
<template>
<ZDialog
v-model="open"
:title="t('keys.configuration.importTo')"
:description="t('keys.configuration.selectImportTool')"
width="24rem"
@closed="emit('afterLeave')"
>
<div class="grid gap-2">
<div
class="flex items-center gap-1 rounded-cp-sm pr-1 transition-colors hover:bg-cp-bg-text-hover has-[:focus-visible]:bg-cp-bg-text-hover motion-reduce:transition-none"
>
<ZMenuItem class="min-h-18 min-w-0 py-3 hover:bg-transparent!" @click="select">
<template #icon>
<span class="grid size-9 place-items-center rounded-cp bg-cp-fill-quaternary text-cp-text-secondary">
<AppWindow class="size-5" aria-hidden="true" />
</span>
</template>
<span class="grid gap-1 whitespace-normal">
<span>CC Switch</span>
<span class="text-xs leading-5 font-normal text-cp-text-secondary">{{ t('keys.configuration.ccSwitchDescription') }}</span>
</span>
</ZMenuItem>
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
:title="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
class="inline-grid size-9 shrink-0 place-items-center rounded-cp-sm text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-inset motion-reduce:transition-none"
>
<Github class="size-6" aria-hidden="true" />
</a>
</div>
</div>
</ZDialog>
</template>
+5 -3
View File
@@ -4,7 +4,10 @@
"description": "Save or merge the configuration below and then restart Codex",
"platform": "Platform",
"toggleWebSocket": "Switch WebSocket configuration",
"importCcSwitch": "Add to CC Switch",
"importTo": "Import to",
"selectImportTool": "Choose a tool to import into",
"ccSwitchDescription": "Manage AI client configurations",
"viewOnGitHub": "View {name} on GitHub",
"copiedToClipboard": "Copied to clipboard",
"fullApiKeyNotAvailable": "Full API key not available",
"title": "Use API key"
@@ -40,9 +43,8 @@
},
"created": {
"apiKeyCreated": "API key created",
"description": "Copy the API key or import it into CC Switch",
"description": "Copy the API key or import it into a client",
"accessNotice": "This key grants gateway access, share it only with trusted callers",
"copyKey": "Copy key",
"iHaveSaved": "I have saved"
},
"filters": {
+5 -3
View File
@@ -4,7 +4,10 @@
"description": "保存或合并下方配置后重启 Codex",
"platform": "配置平台",
"toggleWebSocket": "切换 WebSocket 配置",
"importCcSwitch": "添加到 CC Switch",
"importTo": "导入到",
"selectImportTool": "选择要导入的工具",
"ccSwitchDescription": "管理和切换 AI 客户端配置",
"viewOnGitHub": "在 GitHub 查看 {name}",
"copiedToClipboard": "已复制到剪贴板",
"fullApiKeyNotAvailable": "完整 API Key 不可用",
"title": "使用密钥"
@@ -40,9 +43,8 @@
},
"created": {
"apiKeyCreated": "API Key 已创建",
"description": "复制密钥,或直接导入 CCSwitch",
"description": "复制密钥,或导入到客户端",
"accessNotice": "该密钥具有网关访问权限,请仅发送给可信调用方",
"copyKey": "复制密钥",
"iHaveSaved": "我已保存"
},
"filters": {
+5 -3
View File
@@ -4,7 +4,10 @@
"description": "保存或合併下方配置後重啓 Codex",
"platform": "配置平臺",
"toggleWebSocket": "切換 WebSocket 配置",
"importCcSwitch": "添加到 CC Switch",
"importTo": "導入到",
"selectImportTool": "選擇要導入的工具",
"ccSwitchDescription": "管理和切換 AI 客戶端配置",
"viewOnGitHub": "在 GitHub 查看 {name}",
"copiedToClipboard": "已複製到剪貼板",
"fullApiKeyNotAvailable": "完整 API Key 不可用",
"title": "使用密鑰"
@@ -40,9 +43,8 @@
},
"created": {
"apiKeyCreated": "API Key 已創建",
"description": "複製密鑰,或直接導入 CCSwitch",
"description": "複製密鑰,或導入到客戶端",
"accessNotice": "該密鑰具有網關訪問權限,請僅發送給可信調用方",
"copyKey": "複製密鑰",
"iHaveSaved": "我已保存"
},
"filters": {
@@ -16,7 +16,7 @@ defineProps<{
const emit = defineEmits<{
use: [apiKey: ApiKey]
importCcs: [apiKey: ApiKey]
importClient: [apiKey: ApiKey]
toggle: [apiKey: ApiKey]
delete: [apiKey: ApiKey]
edit: [apiKey: ApiKey]
@@ -52,18 +52,18 @@ const { t } = useI18n({ useScope: 'global' })
</ZIconButton>
</template>
<template #default="{ close }">
<div class="w-44 p-1.5">
<div class="w-36 p-1.5">
<ZMenuItem @click.stop="(close(), emit('resetBudget', apiKey))">
<template #icon>
<RotateCcw class="size-3.5 text-cp-text-quaternary" />
</template>
{{ t('keys.actions.resetUsedCredit') }}
</ZMenuItem>
<ZMenuItem :disabled="revealing" @click.stop="(close(), emit('importCcs', apiKey))">
<ZMenuItem :disabled="revealing" @click.stop="(close(), emit('importClient', apiKey))">
<template #icon>
<Upload class="size-3.5 text-cp-text-quaternary" />
</template>
{{ t('keys.configuration.importCcSwitch') }}
{{ t('keys.configuration.importTo') }}
</ZMenuItem>
<ZMenuItem :loading="updatingStatus" @click.stop="(close(), emit('toggle', apiKey))">
<template #icon>
@@ -2,19 +2,22 @@
import { ZButton, ZDialog, ZIconButton } from '@codex-proxy/ui'
import { Copy, Upload } from '@lucide/vue'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ClientImportModal from '@/components/ClientImportModal.vue'
defineProps<{ createdKey: string }>()
const emit = defineEmits<{
copy: [text: string]
importCcs: []
importClient: []
afterLeave: []
}>()
const { t } = useI18n({ useScope: 'global' })
const open = defineModel<boolean>({ default: false })
const showImport = shallowRef(false)
</script>
<template>
@@ -24,10 +27,10 @@ const open = defineModel<boolean>({ default: false })
:description="t('keys.created.description')"
type="success"
width="36rem"
@closed="emit('afterLeave')"
@closed="showImport = false; 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">
<div class="rounded-cp bg-cp-warning-container px-4 py-3">
<p class="m-0 text-cp font-semibold text-cp-warning-on-container">
{{ t('keys.created.accessNotice') }}
</p>
@@ -48,21 +51,16 @@ const open = defineModel<boolean>({ default: false })
</div>
<template #footer>
<ZButton @click="emit('copy', createdKey)">
<template #icon>
<Copy class="size-4" />
</template>
{{ t('keys.created.copyKey') }}
</ZButton>
<ZButton @click="emit('importCcs')">
<ZButton :disabled="!createdKey" @click="showImport = true">
<template #icon>
<Upload class="size-4" />
</template>
{{ t('keys.configuration.importCcSwitch') }}
{{ t('keys.configuration.importTo') }}
</ZButton>
<ZButton type="primary" @click="open = false">
{{ t('keys.created.iHaveSaved') }}
</ZButton>
</template>
</ZDialog>
<ClientImportModal v-if="open" v-model="showImport" @select="emit('importClient')" />
</template>
@@ -18,6 +18,8 @@ export function useApiKeyUse() {
const showCreatedKeyModal = shallowRef(false)
const createdKey = shallowRef('')
const createdKeyName = shallowRef('')
const showImportKeyModal = shallowRef(false)
const selectedImportKey = shallowRef<ApiKey | null>(null)
function showCreatedKey(key: string, name: string) {
createdKey.value = key
@@ -27,7 +29,7 @@ export function useApiKeyUse() {
const openAiBaseUrl = `${resolveServiceRootUrl()}/v1`
function importCreatedKeyToCcs() {
function importCreatedKey() {
if (!createdKey.value)
return
@@ -45,7 +47,16 @@ export function useApiKeyUse() {
})
}
function importToCcs(apiKey: ApiKey) {
function openImportKeyModal(apiKey: ApiKey) {
revealRequest.invalidate()
selectedImportKey.value = apiKey
showImportKeyModal.value = true
}
function importSelectedKey() {
const apiKey = selectedImportKey.value
if (!apiKey)
return
return withPlaintextKey(apiKey, (key) => {
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: key,
@@ -93,6 +104,7 @@ export function useApiKeyUse() {
onScopeDispose(() => {
selectedUseKey.value = null
selectedImportKey.value = null
clearCreatedKey()
})
@@ -101,14 +113,17 @@ export function useApiKeyUse() {
selectedUseKey,
showCreatedKeyModal,
createdKey,
showImportKeyModal,
selectedImportKey,
revealingKeyIds: revealingKeys.ids,
openAiBaseUrl,
showCreatedKey,
clearCreatedKey,
copyToClipboard,
copyApiKey,
importCreatedKeyToCcs,
importCreatedKey,
openUseKeyModal,
importToCcs,
openImportKeyModal,
importSelectedKey,
}
}
+14 -4
View File
@@ -6,6 +6,7 @@ import { ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiKeyConfigModal from '@/components/ApiKeyConfigModal.vue'
import ClientImportModal from '@/components/ClientImportModal.vue'
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
import { useAccountGroupCatalog } from '@/composables/useAccountGroupCatalog'
import { usePageSelection } from '@/composables/usePageSelection'
@@ -58,15 +59,18 @@ const {
selectedUseKey,
showCreatedKeyModal,
createdKey,
showImportKeyModal,
selectedImportKey,
revealingKeyIds,
openAiBaseUrl,
showCreatedKey,
clearCreatedKey,
copyToClipboard,
copyApiKey,
importCreatedKeyToCcs,
importCreatedKey,
openUseKeyModal,
importToCcs,
openImportKeyModal,
importSelectedKey,
} = useApiKeyUse()
const {
@@ -202,7 +206,7 @@ watch(
@edit="openEdit"
@reset-budget="openBudgetReset"
@delete="requestDeleteKey"
@import-ccs="importToCcs"
@import-client="openImportKeyModal"
@toggle="handleToggleStatus"
@use="openUseKeyModal"
/>
@@ -233,7 +237,7 @@ watch(
v-model="showCreatedKeyModal"
:created-key="createdKey"
@copy="copyToClipboard"
@import-ccs="importCreatedKeyToCcs"
@import-client="importCreatedKey"
@after-leave="clearCreatedKey"
/>
@@ -251,6 +255,12 @@ watch(
@after-leave="selectedUseKey = null"
/>
<ClientImportModal
v-model="showImportKeyModal"
@select="importSelectedKey"
@after-leave="selectedImportKey = null"
/>
<ZConfirmDialog
v-model="showAllAccountsConfirm"
:title="t('keys.scope.allAccounts.title')"