refactor(web): align accounts and api-keys view organization

Extract AccountFilters to mirror the ApiKeyFilters/UsageFilters toolbar convention, move the CCSwitch import and service-URL orchestration out of api-keys/index.vue into a useApiKeyUse composable, adopt useAsyncAction for the admin API key mutations and useIdSet for the connection-test id tracking (both already the sibling-view convention), and drop the unused store barrel re-exports. Markup moved verbatim; built CSS unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-26 21:49:04 +08:00
co-authored by Claude
parent 4634496638
commit 81e51db0b6
7 changed files with 237 additions and 170 deletions
-4
View File
@@ -4,7 +4,3 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 导出所有 store
export * from './modules/auth'
export * from './modules/ui'
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Download, Search, Trash2, Upload } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import ProviderFilterSegmented from '@/components/ProviderFilterSegmented.vue'
import { accountStatusFilterOptions } from '../constants'
defineProps<{
selectedCount: number
batchDeleting: boolean
exportingAccounts: boolean
}>()
const emit = defineEmits<{
deleteSelected: []
exportSelected: []
create: []
}>()
const search = defineModel<string>('search', { required: true })
const status = defineModel<string>('status', { required: true })
const provider = defineModel<string>('provider', { required: true })
</script>
<template>
<div
class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap md:items-center"
role="group"
aria-label="账号筛选与操作"
>
<div class="flex min-w-0 items-center gap-2 md:flex-none md:gap-3">
<BaseInput
v-model="search"
placeholder="搜索邮箱或 ID..."
class="min-w-0 flex-1 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-80 md:flex-none"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseSelect
v-model="status"
:options="accountStatusFilterOptions"
aria-label="按账号状态筛选"
class="w-34 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-40"
/>
<ProviderFilterSegmented
v-model="provider"
class="w-31 shrink-0"
/>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<BaseButton
v-if="selectedCount > 0"
variant="danger"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
<Trash2 class="size-4" />
删除选中 ({{ selectedCount }})
</BaseButton>
<BaseButton
v-if="selectedCount > 0"
variant="default"
:loading="exportingAccounts"
@click="emit('exportSelected')"
>
<Download class="size-4" />
导出选中 ({{ selectedCount }})
</BaseButton>
<BaseButton variant="primary" @click="emit('create')">
<Upload class="size-4" />
导入账号
</BaseButton>
</div>
</div>
</template>
@@ -6,6 +6,7 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { getAccountModels, refreshAccountModels } from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { toast } from '@/components/base/BaseToast'
import { useIdSet } from '@/composables/useIdSet'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { formatDateTime, formatTime } from '@/utils/date'
@@ -49,7 +50,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
const connectionTestStartedAt = shallowRef('')
const connectionTestFinishedAt = shallowRef('')
const connectionTestDurationMs = shallowRef<number | null>(null)
const testingConnectionIds = ref<Set<string>>(new Set())
const testingConnections = useIdSet<string>()
const loadingConnectionTestModels = shallowRef(false)
const refreshingConnectionTestModels = shallowRef(false)
const connectionTestSelectedModel = shallowRef('')
@@ -218,9 +219,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
connectionTestRun = undefined
closeConnectionTestEventSource()
if (run) {
const next = new Set(testingConnectionIds.value)
next.delete(run.accountId)
testingConnectionIds.value = next
testingConnections.remove(run.accountId)
run.resolve()
}
}
@@ -350,7 +349,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
connectionTestError.value = '请先选择测试模型'
return
}
if (testingConnectionIds.value.has(account.id))
if (testingConnections.has(account.id))
return
abortConnectionTest()
connectionTestStatus.value = 'running'
@@ -364,7 +363,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
connectionTestStartedAt.value = formatDateTime()
connectionTestFinishedAt.value = ''
appendConnectionTestLog('准备发送测试请求', 'info')
testingConnectionIds.value = new Set(testingConnectionIds.value).add(account.id)
testingConnections.add(account.id)
try {
await withMinimumDuration(
() =>
@@ -436,7 +435,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
connectionTestStartedAt,
connectionTestFinishedAt,
connectionTestDurationMs,
testingConnectionIds,
testingConnectionIds: testingConnections.ids,
loadingConnectionTestModels,
refreshingConnectionTestModels,
connectionTestSelectedModel,
+14 -60
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { ChevronDown, Download, Search, Trash2, Upload } from '@lucide/vue'
import { ChevronDown } from '@lucide/vue'
import { ref } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BasePageHeader from '@/components/base/BasePageHeader.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import ProviderFilterSegmented from '@/components/ProviderFilterSegmented.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountConnectionTestModal from './components/AccountConnectionTestModal.vue'
import AccountCreateModal from './components/AccountCreateModal.vue'
import AccountFilters from './components/AccountFilters.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
@@ -26,7 +23,7 @@ import { useAccountConnectionTest } from './composables/useAccountConnectionTest
import { useAccountMutations } from './composables/useAccountMutations'
import { useAccountsQuery } from './composables/useAccountsQuery'
import { useAccountsTable } from './composables/useAccountsTable'
import { accountColumns, accountStatusFilterOptions } from './constants'
import { accountColumns } from './constants'
const selectedIds = ref<Set<string>>(new Set())
const {
@@ -137,60 +134,17 @@ const {
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
>
<template #header>
<div
class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap md:items-center"
role="group"
aria-label="账号筛选与操作"
>
<div class="flex min-w-0 items-center gap-2 md:flex-none md:gap-3">
<BaseInput
v-model="searchQuery"
placeholder="搜索邮箱或 ID..."
class="min-w-0 flex-1 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-80 md:flex-none"
>
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
</template>
</BaseInput>
<BaseSelect
v-model="statusQuery"
:options="accountStatusFilterOptions"
aria-label="按账号状态筛选"
class="w-34 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-40"
/>
<ProviderFilterSegmented
v-model="providerQuery"
class="w-31 shrink-0"
/>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<BaseButton
v-if="selectedIds.size > 0"
variant="danger"
:disabled="batchDeleting"
@click="showDeleteModal = true"
>
<Trash2 class="size-4" />
删除选中 ({{ selectedIds.size }})
</BaseButton>
<BaseButton
v-if="selectedIds.size > 0"
variant="default"
:loading="exportingAccounts"
@click="handleExportAccounts"
>
<Download class="size-4" />
导出选中 ({{ selectedIds.size }})
</BaseButton>
<BaseButton variant="primary" @click="openCreateAccount">
<Upload class="size-4" />
导入账号
</BaseButton>
</div>
</div>
<AccountFilters
v-model:search="searchQuery"
v-model:status="statusQuery"
v-model:provider="providerQuery"
:selected-count="selectedIds.size"
:batch-deleting="batchDeleting"
:exporting-accounts="exportingAccounts"
@delete-selected="showDeleteModal = true"
@export-selected="handleExportAccounts"
@create="openCreateAccount"
/>
</template>
<template #body>
@@ -0,0 +1,97 @@
import type { Ref, ShallowRef } from 'vue'
import type { getApiKeys } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { API_BASE_URL } from '@/api/constants'
import { toast } from '@/components/base/BaseToast'
import { errorMessage } from '@/utils/async'
import { buildCodexCcSwitchImportDeeplink } from '../utils/ccswitchImport'
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
// 密钥使用与 CCSwitch 导入编排:服务根地址推导、deeplink 跳转、
// “使用密钥”弹窗的明文补全与打开。
export function useApiKeyUse(options: {
createdKey: Readonly<Ref<string>>
createdKeyName: Readonly<Ref<string>>
createdKeyProviderKind: Readonly<Ref<string>>
revealPlaintextKey: (apiKey: ApiKeyRow) => Promise<string>
}) {
const showUseKeyModal = shallowRef(false)
const selectedUseKey: ShallowRef<ApiKeyRow | null> = shallowRef(null)
const serviceRootUrl = computed(() => resolveServiceRootUrl())
const openAiBaseUrl = computed(() => `${serviceRootUrl.value}/v1`)
function resolveServiceRootUrl() {
const normalizedApiBase = API_BASE_URL.trim().replace(/\/+$/, '')
if (/^https?:\/\//i.test(normalizedApiBase)) {
return normalizedApiBase
}
if (typeof window === 'undefined') {
return normalizedApiBase
}
const origin = window.location.origin.replace(/\/+$/, '')
if (!normalizedApiBase) {
return origin
}
return `${origin}${normalizedApiBase.startsWith('/') ? normalizedApiBase : `/${normalizedApiBase}`}`
}
function importCreatedKeyToCcs() {
if (!options.createdKey.value)
return
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: options.createdKey.value,
baseUrl: openAiBaseUrl.value,
providerName: options.createdKeyName.value || 'codex-proxy-rs',
providerKind: options.createdKeyProviderKind.value,
})
}
async function openUseKeyModal(apiKey: ApiKeyRow) {
try {
const key = await options.revealPlaintextKey(apiKey)
selectedUseKey.value = { ...apiKey, key }
showUseKeyModal.value = true
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
}
async function importToCcs(apiKey: ApiKeyRow) {
try {
const key = await options.revealPlaintextKey(apiKey)
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: key,
baseUrl: openAiBaseUrl.value,
providerName: apiKey.name || apiKey.prefix || 'codex-proxy-rs',
providerKind: apiKey.providerKind,
})
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
}
watch(showUseKeyModal, (open) => {
if (!open)
selectedUseKey.value = null
})
return {
showUseKeyModal,
selectedUseKey,
serviceRootUrl,
openAiBaseUrl,
importCreatedKeyToCcs,
openUseKeyModal,
importToCcs,
}
}
+14 -70
View File
@@ -1,16 +1,13 @@
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { ref } from 'vue'
import { API_BASE_URL } from '@/api/constants'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BasePageHeader from '@/components/base/BasePageHeader.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { toast } from '@/components/base/BaseToast'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { usePageSelection } from '@/composables/usePageSelection'
import { errorMessage } from '@/utils/async'
import ApiKeyActions from './components/ApiKeyActions.vue'
import ApiKeyCreateModal from './components/ApiKeyCreateModal.vue'
import ApiKeyFilters from './components/ApiKeyFilters.vue'
@@ -20,11 +17,10 @@ import ApiKeyStatusBadge from './components/ApiKeyStatusBadge.vue'
import ApiKeyUseModal from './components/ApiKeyUseModal.vue'
import { useApiKeyMutations } from './composables/useApiKeyMutations'
import { useApiKeysQuery } from './composables/useApiKeysQuery'
import { useApiKeyUse } from './composables/useApiKeyUse'
import { apiKeyColumns } from './constants'
import { buildCodexCcSwitchImportDeeplink } from './utils/ccswitchImport'
const selectedIds = ref<Set<string>>(new Set())
const showUseKeyModal = shallowRef(false)
const {
loading,
@@ -63,76 +59,24 @@ const {
revealPlaintextKey,
copyApiKey,
} = useApiKeyMutations({ selectedIds, configRevision, reload: loadApiKeys })
const selectedUseKey = shallowRef<(typeof apiKeys.value)[number] | null>(null)
const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll } = usePageSelection(
apiKeys,
selectedIds,
)
const serviceRootUrl = computed(() => resolveServiceRootUrl())
const openAiBaseUrl = computed(() => `${serviceRootUrl.value}/v1`)
function resolveServiceRootUrl() {
const normalizedApiBase = API_BASE_URL.trim().replace(/\/+$/, '')
if (/^https?:\/\//i.test(normalizedApiBase)) {
return normalizedApiBase
}
if (typeof window === 'undefined') {
return normalizedApiBase
}
const origin = window.location.origin.replace(/\/+$/, '')
if (!normalizedApiBase) {
return origin
}
return `${origin}${normalizedApiBase.startsWith('/') ? normalizedApiBase : `/${normalizedApiBase}`}`
}
function importCreatedKeyToCcs() {
if (!createdKey.value)
return
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: createdKey.value,
baseUrl: openAiBaseUrl.value,
providerName: createdKeyName.value || 'codex-proxy-rs',
providerKind: createdKeyProviderKind.value,
})
}
async function openUseKeyModal(apiKey: (typeof apiKeys.value)[number]) {
try {
const key = await revealPlaintextKey(apiKey)
selectedUseKey.value = { ...apiKey, key }
showUseKeyModal.value = true
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
}
async function importToCcs(apiKey: (typeof apiKeys.value)[number]) {
try {
const key = await revealPlaintextKey(apiKey)
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: key,
baseUrl: openAiBaseUrl.value,
providerName: apiKey.name || apiKey.prefix || 'codex-proxy-rs',
providerKind: apiKey.providerKind,
})
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
}
watch(showUseKeyModal, (open) => {
if (!open)
selectedUseKey.value = null
const {
showUseKeyModal,
selectedUseKey,
openAiBaseUrl,
importCreatedKeyToCcs,
openUseKeyModal,
importToCcs,
} = useApiKeyUse({
createdKey,
createdKeyName,
createdKeyProviderKind,
revealPlaintextKey,
})
</script>
@@ -6,17 +6,20 @@ import {
regenerateAdminApiKey,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useCopyText } from '@/composables/useCopyText'
import { errorMessage } from '@/utils/async'
export function useAdminApiKey() {
const loading = shallowRef(true)
const regenerating = shallowRef(false)
const deleting = shallowRef(false)
const showDeleteModal = shallowRef(false)
const generatedKey = shallowRef('')
const status = reactive({ exists: false })
const copyText = useCopyText()
const regenerateAction = useAsyncAction()
const removeAction = useAsyncAction()
const regenerating = regenerateAction.loading
const deleting = removeAction.loading
async function loadStatus() {
try {
@@ -35,39 +38,31 @@ export function useAdminApiKey() {
if (regenerating.value || deleting.value)
return
try {
regenerating.value = true
const wasEnabled = status.exists
generatedKey.value = (await regenerateAdminApiKey()).key
status.exists = true
toast.success(wasEnabled ? '管理员 API Key 已更新' : '管理员 API Key 已生成')
}
catch (error: unknown) {
toast.error(errorMessage(error, '生成失败'))
}
finally {
regenerating.value = false
}
await regenerateAction.run(
async () => {
const wasEnabled = status.exists
generatedKey.value = (await regenerateAdminApiKey()).key
status.exists = true
toast.success(wasEnabled ? '管理员 API Key 已更新' : '管理员 API Key 已生成')
},
{ errorText: false, onError: error => toast.error(errorMessage(error, '生成失败')) },
)
}
async function remove() {
if (deleting.value || regenerating.value)
return
try {
deleting.value = true
await deleteAdminApiKey()
status.exists = false
generatedKey.value = ''
showDeleteModal.value = false
toast.success('管理员 API Key 已删除')
}
catch (error: unknown) {
toast.error(errorMessage(error, '删除失败'))
}
finally {
deleting.value = false
}
await removeAction.run(
async () => {
await deleteAdminApiKey()
status.exists = false
generatedKey.value = ''
showDeleteModal.value = false
toast.success('管理员 API Key 已删除')
},
{ errorText: false, onError: error => toast.error(errorMessage(error, '删除失败')) },
)
}
async function copyGeneratedKey() {