refactor: split frontend maintenance code

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-03 21:21:25 +08:00
co-authored by Codex
parent 112bd76f66
commit c03baed4f7
12 changed files with 928 additions and 675 deletions
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed } from 'vue'
import BaseSelect from '../BaseSelect.vue'
import {
getCurrentPage,
getPageSizeOptions,
getPagerItems,
getTotalPages,
type BaseTablePagination,
} from './pagination'
const props = defineProps<{
pagination: BaseTablePagination
loading: boolean
}>()
const emit = defineEmits<{
'page-change': [page: number]
'page-size-change': [pageSize: number]
}>()
const totalPages = computed(() => getTotalPages(props.pagination))
const currentPage = computed(() => getCurrentPage(props.pagination, totalPages.value))
const pageSizeOptions = computed(() => getPageSizeOptions(props.pagination))
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
const pageSizeModel = computed({
get: () => String(props.pagination.pageSize),
set: (value: string) => {
if (props.loading) {
return
}
const pageSize = Number(value)
if (Number.isFinite(pageSize) && pageSize > 0) {
emit('page-size-change', pageSize)
}
},
})
function goToPage(page: number) {
if (props.loading || page < 1 || page > totalPages.value || page === currentPage.value) {
return
}
emit('page-change', page)
}
function paginationButtonClass(disabled: boolean) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 bg-(--cp-bg-subtle) text-(--cp-text-secondary) transition-colors duration-150 outline-none',
disabled
? 'cursor-not-allowed opacity-45 shadow-none'
: 'cursor-pointer hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
]
}
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 text-xs font-[720] leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-(--cp-info) text-(--cp-info-on)'
: 'cursor-pointer bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
]
}
</script>
<template>
<footer
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-[650] text-(--cp-text-secondary)"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
<div class="flex items-center gap-2">
<BaseSelect
v-model="pageSizeModel"
:options="pageSizeOptions"
:disabled="loading"
size="compact"
class="w-28"
/>
<div class="flex items-center gap-2">
<button
type="button"
:class="paginationButtonClass(loading || currentPage <= 1)"
:disabled="loading || currentPage <= 1"
title="上一页"
aria-label="上一页"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="size-4" />
</button>
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-[720] text-(--cp-text-muted)"
>
...
</span>
<button
v-else
type="button"
:class="paginationPageClass(item)"
:disabled="loading || item === currentPage"
:aria-current="item === currentPage ? 'page' : undefined"
@click="goToPage(item)"
>
{{ item }}
</button>
</template>
<button
type="button"
:class="paginationButtonClass(loading || currentPage >= totalPages)"
:disabled="loading || currentPage >= totalPages"
title="下一页"
aria-label="下一页"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="size-4" />
</button>
</div>
</div>
</footer>
</template>
+16 -189
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, nextTick, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { computed } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import BaseSelect from '../BaseSelect.vue'
import BaseTablePagination from './BaseTablePagination.vue'
import {
alignClass,
cellContentClass,
@@ -17,13 +15,8 @@ import {
tableStyle as resolveTableStyle,
type BaseTableColumn,
} from './columns'
import {
getCurrentPage,
getPageSizeOptions,
getPagerItems,
getTotalPages,
type BaseTablePagination,
} from './pagination'
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
import { useHorizontalStickyShadow } from './useHorizontalStickyShadow'
type TableRow = Record<string, any>
type RowKey = string | ((row: TableRow, index: number) => string | number)
@@ -39,7 +32,7 @@ const props = withDefaults(
emptyText?: string
maxHeight?: string
minWidth?: number | string
pagination?: BaseTablePagination
pagination?: BaseTablePaginationConfig
expandedRowKeys?: Array<string | number>
}>(),
{
@@ -67,33 +60,13 @@ const bodyCellClass =
'min-w-0 px-4 first:pl-3 text-[13px] text-(--cp-text-primary) first:rounded-l-lg'
const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth))
const totalPages = computed(() => getTotalPages(props.pagination))
const currentPage = computed(() => getCurrentPage(props.pagination, totalPages.value))
const pageSizeOptions = computed(() => getPageSizeOptions(props.pagination))
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
const hasRows = computed(() => props.rows.length > 0)
const pageSizeModel = computed({
get: () => String(props.pagination?.pageSize ?? ''),
set: (value: string) => {
if (props.loading) {
return
}
const pageSize = Number(value)
if (Number.isFinite(pageSize) && pageSize > 0) {
emit('page-size-change', pageSize)
}
},
})
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
const { horizontalScrolled, horizontalCanScrollRight, handleTableScroll } =
useHorizontalStickyShadow({
hasRows,
watchSources: () => [props.rows.length, props.minWidth],
})
function fixedHeaderClass(column: BaseTableColumn) {
if (!column.fixed) {
@@ -164,99 +137,9 @@ function rowClass(row: TableRow, index: number) {
]
}
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
}
function scrollWrap() {
return bodyScrollbarRef.value?.wrapRef ?? null
}
function resetHorizontalScrollbar() {
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
}
function updateHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap || !hasRows.value) {
resetHorizontalScrollbar()
return
}
const scrollRange = maxScrollLeft(wrap)
if (scrollRange <= 0) {
resetHorizontalScrollbar()
return
}
horizontalScrolled.value = wrap.scrollLeft > 0
horizontalCanScrollRight.value = wrap.scrollLeft < scrollRange - 1
}
function handleTableScroll() {
const wrap = scrollWrap()
if (wrap && headerWrapRef.value) {
headerWrapRef.value.scrollLeft = wrap.scrollLeft
}
updateHorizontalScrollbar()
}
onMounted(async () => {
await nextTick()
updateHorizontalScrollbar()
})
useResizeObserver(
() =>
[scrollWrap(), tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
updateHorizontalScrollbar,
)
watch(
() => [props.rows.length, props.minWidth],
async () => {
await nextTick()
updateHorizontalScrollbar()
},
)
function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
}
function goToPage(page: number) {
if (
props.loading ||
!props.pagination ||
page < 1 ||
page > totalPages.value ||
page === currentPage.value
) {
return
}
emit('page-change', page)
}
function paginationButtonClass(disabled: boolean) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 bg-(--cp-bg-subtle) text-(--cp-text-secondary) transition-colors duration-150 outline-none',
disabled
? 'cursor-not-allowed opacity-45 shadow-none'
: 'cursor-pointer hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
]
}
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 text-xs font-[720] leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-(--cp-info) text-(--cp-info-on)'
: 'cursor-pointer bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
]
}
</script>
<template>
@@ -374,68 +257,12 @@ function paginationPageClass(page: number) {
</div>
</div>
<footer
<BaseTablePagination
v-if="pagination"
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-[650] text-(--cp-text-secondary)"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
<div class="flex items-center gap-2">
<BaseSelect
v-model="pageSizeModel"
:options="pageSizeOptions"
:disabled="loading"
size="compact"
class="w-28"
/>
<div class="flex items-center gap-2">
<button
type="button"
:class="paginationButtonClass(loading || currentPage <= 1)"
:disabled="loading || currentPage <= 1"
title="上一页"
aria-label="上一页"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="size-4" />
</button>
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-[720] text-(--cp-text-muted)"
>
...
</span>
<button
v-else
type="button"
:class="paginationPageClass(item)"
:disabled="loading || item === currentPage"
:aria-current="item === currentPage ? 'page' : undefined"
@click="goToPage(item)"
>
{{ item }}
</button>
</template>
<button
type="button"
:class="paginationButtonClass(loading || currentPage >= totalPages)"
:disabled="loading || currentPage >= totalPages"
title="下一页"
aria-label="下一页"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="size-4" />
</button>
</div>
</div>
</footer>
:pagination="pagination"
:loading="loading"
@page-change="emit('page-change', $event)"
@page-size-change="emit('page-size-change', $event)"
/>
</div>
</template>
@@ -0,0 +1,82 @@
import { useResizeObserver } from '@vueuse/core'
import { nextTick, onMounted, shallowRef, useTemplateRef, watch, type Ref } from 'vue'
import type BaseScrollbar from '../BaseScrollbar.vue'
interface UseHorizontalStickyShadowOptions {
hasRows: Ref<boolean>
watchSources: () => unknown[]
}
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
}
function scrollWrap() {
return bodyScrollbarRef.value?.wrapRef ?? null
}
function resetHorizontalScrollbar() {
horizontalScrolled.value = false
horizontalCanScrollRight.value = false
}
function updateHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap || !options.hasRows.value) {
resetHorizontalScrollbar()
return
}
const scrollRange = maxScrollLeft(wrap)
if (scrollRange <= 0) {
resetHorizontalScrollbar()
return
}
horizontalScrolled.value = wrap.scrollLeft > 0
horizontalCanScrollRight.value = wrap.scrollLeft < scrollRange - 1
}
function handleTableScroll() {
const wrap = scrollWrap()
if (wrap && headerWrapRef.value) {
headerWrapRef.value.scrollLeft = wrap.scrollLeft
}
updateHorizontalScrollbar()
}
onMounted(async () => {
await nextTick()
updateHorizontalScrollbar()
})
useResizeObserver(
() =>
[scrollWrap(), tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
updateHorizontalScrollbar,
)
watch(options.watchSources, async () => {
await nextTick()
updateHorizontalScrollbar()
})
return {
headerWrapRef,
bodyScrollbarRef,
tableViewRef,
horizontalScrolled,
horizontalCanScrollRight,
handleTableScroll,
}
}
@@ -0,0 +1,73 @@
import { shallowRef } from 'vue'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
type MaybePromise<T> = T | Promise<T>
type AsyncActionErrorText = string | false | ((error: unknown) => string | false)
interface AsyncActionRunOptions {
errorText?: AsyncActionErrorText
minimumMs?: number
onError?: (error: unknown) => void
rethrow?: boolean
}
function errorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message?: unknown }).message
return typeof message === 'string' ? message : ''
}
return ''
}
function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) {
const fallback = typeof errorText === 'function' ? errorText(error) : errorText
if (fallback === false) {
return ''
}
return errorMessage(error) || fallback || ''
}
export function useAsyncAction() {
const loading = shallowRef(false)
async function run<T>(task: () => MaybePromise<T>, options: AsyncActionRunOptions = {}) {
if (loading.value) {
return undefined
}
loading.value = true
try {
const execute = async () => task()
return options.minimumMs === undefined
? await execute()
: await withMinimumDuration(execute, options.minimumMs)
} catch (error) {
options.onError?.(error)
const message = resolveErrorText(error, options.errorText)
if (message) {
toast.error(message)
}
if (options.rethrow) {
throw error
}
return undefined
} finally {
loading.value = false
}
}
return {
loading,
run,
}
}
+42
View File
@@ -0,0 +1,42 @@
import { shallowRef } from 'vue'
type MaybePromise<T> = T | Promise<T>
export function useIdSet<T extends string | number>() {
const ids = shallowRef<Set<T>>(new Set())
function has(id: T) {
return ids.value.has(id)
}
function add(id: T) {
ids.value = new Set(ids.value).add(id)
}
function remove(id: T) {
const next = new Set(ids.value)
next.delete(id)
ids.value = next
}
async function run<R>(id: T, task: () => MaybePromise<R>) {
if (has(id)) {
return undefined
}
add(id)
try {
return await task()
} finally {
remove(id)
}
}
return {
ids,
has,
add,
remove,
run,
}
}
@@ -14,7 +14,9 @@ import {
updateAccount,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
import { useIdSet } from '@/composables/useIdSet'
import { withMinimumDuration } from '@/utils/async'
export function useAccountMutations(options: {
@@ -40,15 +42,24 @@ export function useAccountMutations(options: {
const editingAccount = ref<any>(null)
const editingScheduleStatus = ref('active')
const pendingDeleteAccount = ref<any>(null)
const refreshingAccountIds = ref<Set<string>>(new Set())
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
const updatingStatusAccountIds = ref<Set<string>>(new Set())
const deletingAccount = ref(false)
const creatingAccount = ref(false)
const authorizingOAuth = ref(false)
const savingAccount = ref(false)
const batchDeleting = ref(false)
const exportingAccounts = ref(false)
const refreshingAccounts = useIdSet<string>()
const refreshingQuotaAccounts = useIdSet<string>()
const updatingStatusAccounts = useIdSet<string>()
const deletingAccountAction = useAsyncAction()
const creatingAccountAction = useAsyncAction()
const authorizingOAuthAction = useAsyncAction()
const savingAccountAction = useAsyncAction()
const batchDeletingAction = useAsyncAction()
const exportingAccountsAction = useAsyncAction()
const refreshingAccountIds = refreshingAccounts.ids
const refreshingQuotaAccountIds = refreshingQuotaAccounts.ids
const updatingStatusAccountIds = updatingStatusAccounts.ids
const deletingAccount = deletingAccountAction.loading
const creatingAccount = creatingAccountAction.loading
const authorizingOAuth = authorizingOAuthAction.loading
const savingAccount = savingAccountAction.loading
const batchDeleting = batchDeletingAction.loading
const exportingAccounts = exportingAccountsAction.loading
const createForm = ref({
mode: 'oauth',
@@ -130,21 +141,19 @@ export function useAccountMutations(options: {
}
if (creatingAccount.value) return
try {
const payload = accountImportPayload()
if (!payload) return
await creatingAccountAction.run(
async () => {
const payload = accountImportPayload()
if (!payload) return
creatingAccount.value = true
const result = await importAccounts(payload)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(importSuccessText(result))
} catch (error) {
toast.error(error instanceof Error ? error.message : '导入失败')
} finally {
creatingAccount.value = false
}
const result = await importAccounts(payload)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(importSuccessText(result))
},
{ errorText: '导入失败' },
)
}
function accountImportPayload() {
@@ -189,45 +198,39 @@ export function useAccountMutations(options: {
async function handleAuthorizeOAuth() {
if (authorizingOAuth.value) return
try {
authorizingOAuth.value = true
const result = await authorizeAccountOAuth()
createForm.value = {
...createForm.value,
mode: 'oauth',
oauthSessionId: result.sessionId,
oauthAuthUrl: result.authUrl,
oauthCallback: '',
}
toast.success('授权链接已生成')
} catch (error: any) {
toast.error(error.message || '授权链接生成失败')
} finally {
authorizingOAuth.value = false
}
await authorizingOAuthAction.run(
async () => {
const result = await authorizeAccountOAuth()
createForm.value = {
...createForm.value,
mode: 'oauth',
oauthSessionId: result.sessionId,
oauthAuthUrl: result.authUrl,
oauthCallback: '',
}
toast.success('授权链接已生成')
},
{ errorText: '授权链接生成失败' },
)
}
async function handleExchangeOAuth() {
if (creatingAccount.value) return
try {
const payload = accountImportPayload()
if (!payload) return
await creatingAccountAction.run(
async () => {
const payload = accountImportPayload()
if (!payload) return
creatingAccount.value = true
const result = await exchangeAccountOAuth(payload)
const successText = oauthSuccessText(result)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(successText)
} catch (error: any) {
toast.error(
error.message || (reauthorizingAccount.value ? '重新授权失败' : 'OAuth 授权导入失败'),
)
} finally {
creatingAccount.value = false
}
const result = await exchangeAccountOAuth(payload)
const successText = oauthSuccessText(result)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(successText)
},
{ errorText: reauthorizingAccount.value ? '重新授权失败' : 'OAuth 授权导入失败' },
)
}
function resetCreateForm() {
@@ -286,24 +289,22 @@ export function useAccountMutations(options: {
const account = editingAccount.value
if (!account) return
try {
savingAccount.value = true
const payload: any = {
id: account.id,
label: nullableTrimmedValue(editForm.value.label),
}
if (editForm.value.status !== editingScheduleStatus.value) {
payload.status = editForm.value.status
}
await updateAccount(payload)
editingAccount.value = null
await loadAccounts()
toast.success('账号已更新')
} catch (error: any) {
toast.error(error.message || '保存失败')
} finally {
savingAccount.value = false
}
await savingAccountAction.run(
async () => {
const payload: any = {
id: account.id,
label: nullableTrimmedValue(editForm.value.label),
}
if (editForm.value.status !== editingScheduleStatus.value) {
payload.status = editForm.value.status
}
await updateAccount(payload)
editingAccount.value = null
await loadAccounts()
toast.success('账号已更新')
},
{ errorText: '保存失败' },
)
}
function nullableTrimmedValue(value: string) {
@@ -322,35 +323,36 @@ export function useAccountMutations(options: {
const accountId = pendingDeleteAccount.value?.id
if (!accountId) return
try {
deletingAccount.value = true
await deleteAccounts({ ids: [accountId] })
showSingleDeleteModal.value = false
pendingDeleteAccount.value = null
await loadAccounts()
toast.success('账号已删除')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
deletingAccount.value = false
}
await deletingAccountAction.run(
async () => {
await deleteAccounts({ ids: [accountId] })
showSingleDeleteModal.value = false
pendingDeleteAccount.value = null
await loadAccounts()
toast.success('账号已删除')
},
{ errorText: '删除失败' },
)
}
async function handleBatchDelete() {
if (batchDeleting.value) return
if (options.selectedIds.value.size === 0) return
try {
batchDeleting.value = true
await deleteAccounts({ ids: [...options.selectedIds.value] })
options.selectedIds.value = new Set()
showDeleteModal.value = false
await loadAccounts()
} catch (error) {
console.error('Failed to batch delete accounts:', error)
} finally {
batchDeleting.value = false
}
await batchDeletingAction.run(
async () => {
await deleteAccounts({ ids: [...options.selectedIds.value] })
options.selectedIds.value = new Set()
showDeleteModal.value = false
await loadAccounts()
},
{
errorText: false,
onError: (error) => {
console.error('Failed to batch delete accounts:', error)
},
},
)
}
async function handleExportAccounts() {
@@ -361,78 +363,64 @@ export function useAccountMutations(options: {
return
}
try {
exportingAccounts.value = true
const payload = await exportAccounts({ ids: selected.join(',') })
await downloadJson(payload, exportFileName(selected.length))
toast.success(`已导出 ${selected.length} 个账号`)
} catch (error: any) {
toast.error(error.message || '导出失败')
} finally {
exportingAccounts.value = false
}
await exportingAccountsAction.run(
async () => {
const payload = await exportAccounts({ ids: selected.join(',') })
await downloadJson(payload, exportFileName(selected.length))
toast.success(`已导出 ${selected.length} 个账号`)
},
{ errorText: '导出失败' },
)
}
async function handleRefresh(accountId: string) {
if (refreshingAccountIds.value.has(accountId)) return
refreshingAccountIds.value = new Set(refreshingAccountIds.value).add(accountId)
try {
await withMinimumDuration(async () => {
await refreshAccount({ id: accountId })
await loadAccounts()
})
toast.success('Token 已刷新')
} catch (error: any) {
toast.error(error.message || '刷新失败')
} finally {
const next = new Set(refreshingAccountIds.value)
next.delete(accountId)
refreshingAccountIds.value = next
}
await refreshingAccounts.run(accountId, async () => {
try {
await withMinimumDuration(async () => {
await refreshAccount({ id: accountId })
await loadAccounts()
})
toast.success('Token 已刷新')
} catch (error: any) {
toast.error(error.message || '刷新失败')
}
})
}
async function handleRefreshQuota(accountId: string) {
if (refreshingQuotaAccountIds.value.has(accountId)) return
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
try {
await withMinimumDuration(async () => {
const result = await getAccountQuota({ id: accountId })
if (result?.quotaData) {
accounts.value = accounts.value.map((account) =>
account.id === accountId
? {
...account,
quota: result.quotaData,
planType: result.planType ?? account.planType,
}
: account,
)
}
})
} catch (error) {
console.error('Failed to refresh account quota:', error)
} finally {
const next = new Set(refreshingQuotaAccountIds.value)
next.delete(accountId)
refreshingQuotaAccountIds.value = next
}
await refreshingQuotaAccounts.run(accountId, async () => {
try {
await withMinimumDuration(async () => {
const result = await getAccountQuota({ id: accountId })
if (result?.quotaData) {
accounts.value = accounts.value.map((account) =>
account.id === accountId
? {
...account,
quota: result.quotaData,
planType: result.planType ?? account.planType,
}
: account,
)
}
})
} catch (error) {
console.error('Failed to refresh account quota:', error)
}
})
}
async function handleToggleSchedule(account: any) {
if (updatingStatusAccountIds.value.has(account.id)) return
const nextStatus = account.status === 'disabled' ? 'active' : 'disabled'
updatingStatusAccountIds.value = new Set(updatingStatusAccountIds.value).add(account.id)
try {
await updateAccount({ id: account.id, status: nextStatus })
await loadAccounts()
toast.success(nextStatus === 'disabled' ? '已禁用调度' : '已启用调度')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
} finally {
const next = new Set(updatingStatusAccountIds.value)
next.delete(account.id)
updatingStatusAccountIds.value = next
}
await updatingStatusAccounts.run(account.id, async () => {
try {
await updateAccount({ id: account.id, status: nextStatus })
await loadAccounts()
toast.success(nextStatus === 'disabled' ? '已禁用调度' : '已启用调度')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
}
})
}
function scheduleActionLabel(account: any) {
@@ -4,6 +4,8 @@ import { onMounted, ref, type Ref } from 'vue'
import { createApiKey, deleteApiKeys, getApiKeys, updateApiKey } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useIdSet } from '@/composables/useIdSet'
export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
const { copy } = useClipboard()
@@ -16,11 +18,16 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
const createdKey = ref('')
const editingLabel = ref<{ id: string; value: string } | null>(null)
const pendingDeleteKey = ref<any | null>(null)
const creatingKey = ref(false)
const deletingKey = ref(false)
const batchDeleting = ref(false)
const updatingStatusKeyIds = ref<Set<string>>(new Set())
const savingLabelKeyIds = ref<Set<string>>(new Set())
const creatingKeyAction = useAsyncAction()
const deletingKeyAction = useAsyncAction()
const batchDeletingAction = useAsyncAction()
const updatingStatusKeys = useIdSet<string>()
const savingLabelKeys = useIdSet<string>()
const creatingKey = creatingKeyAction.loading
const deletingKey = deletingKeyAction.loading
const batchDeleting = batchDeletingAction.loading
const updatingStatusKeyIds = updatingStatusKeys.ids
const savingLabelKeyIds = savingLabelKeys.ids
const createForm = ref({
name: '',
@@ -46,25 +53,23 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
return
}
try {
creatingKey.value = true
const result = await createApiKey({
name: createForm.value.name,
label: createForm.value.label || undefined,
})
await creatingKeyAction.run(
async () => {
const result = await createApiKey({
name: createForm.value.name,
label: createForm.value.label || undefined,
})
createdKey.value = result.key
showCreateModal.value = false
showKeyModal.value = true
createForm.value = { name: '', label: '' }
createdKey.value = result.key
showCreateModal.value = false
showKeyModal.value = true
createForm.value = { name: '', label: '' }
await loadApiKeys()
toast.success('API Key 创建成功')
} catch (error: any) {
toast.error(error.message || '创建失败')
} finally {
creatingKey.value = false
}
await loadApiKeys()
toast.success('API Key 创建成功')
},
{ errorText: '创建失败' },
)
}
function requestDeleteKey(key: any) {
@@ -78,70 +83,58 @@ export function useApiKeyMutations(selectedIds: Ref<Set<string>>) {
const keyId = pendingDeleteKey.value?.id
if (!keyId) return
try {
deletingKey.value = true
await deleteApiKeys({ ids: [keyId] })
showSingleDeleteModal.value = false
pendingDeleteKey.value = null
await loadApiKeys()
toast.success('删除成功')
} catch (error: any) {
toast.error(error.message || '删除失败')
} finally {
deletingKey.value = false
}
await deletingKeyAction.run(
async () => {
await deleteApiKeys({ ids: [keyId] })
showSingleDeleteModal.value = false
pendingDeleteKey.value = null
await loadApiKeys()
toast.success('删除成功')
},
{ errorText: '删除失败' },
)
}
async function handleBatchDelete() {
if (batchDeleting.value) return
if (selectedIds.value.size === 0) return
try {
batchDeleting.value = true
const deleteCount = selectedIds.value.size
await deleteApiKeys({ ids: [...selectedIds.value] })
selectedIds.value = new Set()
showDeleteModal.value = false
await loadApiKeys()
toast.success(`已删除 ${deleteCount} 个 API Key`)
} catch (error: any) {
toast.error(error.message || '批量删除失败')
} finally {
batchDeleting.value = false
}
await batchDeletingAction.run(
async () => {
const deleteCount = selectedIds.value.size
await deleteApiKeys({ ids: [...selectedIds.value] })
selectedIds.value = new Set()
showDeleteModal.value = false
await loadApiKeys()
toast.success(`已删除 ${deleteCount} 个 API Key`)
},
{ errorText: '批量删除失败' },
)
}
async function handleToggleStatus(key: any) {
if (updatingStatusKeyIds.value.has(key.id)) return
updatingStatusKeyIds.value = new Set(updatingStatusKeyIds.value).add(key.id)
try {
await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' })
await loadApiKeys()
toast.success(key.enabled ? '已禁用' : '已启用')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
} finally {
const next = new Set(updatingStatusKeyIds.value)
next.delete(key.id)
updatingStatusKeyIds.value = next
}
await updatingStatusKeys.run(key.id, async () => {
try {
await updateApiKey({ id: key.id, status: key.enabled ? 'disabled' : 'active' })
await loadApiKeys()
toast.success(key.enabled ? '已禁用' : '已启用')
} catch (error: any) {
toast.error(error.message || '状态更新失败')
}
})
}
async function handleUpdateLabel(keyId: string, label: string) {
if (savingLabelKeyIds.value.has(keyId)) return
savingLabelKeyIds.value = new Set(savingLabelKeyIds.value).add(keyId)
try {
await updateApiKey({ id: keyId, label: label || null })
editingLabel.value = null
await loadApiKeys()
toast.success('标签已更新')
} catch (error: any) {
toast.error(error.message || '标签更新失败')
} finally {
const next = new Set(savingLabelKeyIds.value)
next.delete(keyId)
savingLabelKeyIds.value = next
}
await savingLabelKeys.run(keyId, async () => {
try {
await updateApiKey({ id: keyId, label: label || null })
editingLabel.value = null
await loadApiKeys()
toast.success('标签已更新')
} catch (error: any) {
toast.error(error.message || '标签更新失败')
}
})
}
function startEditLabel(key: any) {
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { Copy, KeyRound, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
interface AdminApiKeyStatus {
exists: boolean
maskedKey: string | null
}
defineProps<{
status: AdminApiKeyStatus
loading: boolean
regenerating: boolean
deleting: boolean
generatedKey: string
}>()
const emit = defineEmits<{
regenerate: []
requestDelete: []
copy: []
}>()
</script>
<template>
<BaseCard
:padded="false"
title="管理员 API Key"
description="用于外部系统集成,具有管理员权限。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="default"
:loading="regenerating"
:disabled="loading || deleting"
@click="emit('regenerate')"
>
<template #icon>
<KeyRound class="size-4" />
</template>
{{ status.exists ? '重新生成' : '生成' }}
</BaseButton>
<BaseButton
variant="danger"
:disabled="loading || regenerating || !status.exists"
@click="emit('requestDelete')"
>
<template #icon>
<Trash2 class="size-4" />
</template>
删除
</BaseButton>
</div>
</template>
<div class="grid gap-4">
<div
class="flex min-h-16 items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-normal) shadow-(--cp-shadow-control)"
>
<KeyRound class="size-4" />
</span>
<div class="min-w-0">
<p class="m-0 text-[13px] leading-[1.15] font-[720] text-(--cp-text-primary)">
{{ status.exists ? '已启用' : '未生成' }}
</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>
{{
loading
? '加载中...'
: status.maskedKey || '外部系统暂时无法通过 API Key 调用管理接口'
}}
</p>
</div>
</div>
</div>
<div v-if="generatedKey" class="grid gap-2">
<p class="m-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
完整 Key 仅显示一次,请立即保存。
</p>
<div class="flex min-w-0 items-center gap-2">
<code
class="min-w-0 flex-1 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 font-mono text-[12px] leading-normal font-[650] break-all text-(--cp-text-primary)"
>
{{ generatedKey }}
</code>
<BaseButton icon-only size="md" title="复制" @click="emit('copy')">
<Copy class="size-4" />
</BaseButton>
</div>
</div>
</div>
</BaseCard>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { GitBranch, Plus, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
interface AliasRow {
alias: string
target: string
}
defineProps<{
rows: AliasRow[]
error: string
disabled: boolean
}>()
const emit = defineEmits<{
add: []
update: [index: number, key: keyof AliasRow, value: string]
remove: [index: number]
}>()
</script>
<template>
<BaseCard
:padded="false"
title="模型映射"
description="把客户端可见名称指向真实上游模型。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3">
<div
class="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem] gap-2 px-0.75 text-xs leading-none font-bold text-(--cp-text-secondary) sm:grid"
>
<span>别名</span>
<span>目标模型</span>
<span />
</div>
<div
v-if="rows.length === 0"
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3 text-[13px] font-[650] text-(--cp-text-muted)"
>
还没有模型映射。
</div>
<div
v-for="(row, index) in rows"
:key="index"
class="grid items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
>
<BaseInput
:model-value="row.alias"
placeholder="gpt-5.2"
@update:model-value="emit('update', index, 'alias', $event)"
>
<template #prefix>
<GitBranch class="size-4" />
</template>
</BaseInput>
<BaseInput
:model-value="row.target"
placeholder="gpt-5.5"
@update:model-value="emit('update', index, 'target', $event)"
/>
<BaseButton
variant="ghost"
size="default"
icon-only
label="删除映射"
:disabled="disabled"
@click="emit('remove', index)"
>
<Trash2 class="size-4" />
</BaseButton>
</div>
<div class="flex flex-wrap items-center gap-3 pt-1">
<BaseButton variant="default" :disabled="disabled" @click="emit('add')">
<template #icon>
<Plus class="size-4" />
</template>
添加映射
</BaseButton>
<span v-if="error" class="text-xs font-[650] text-(--cp-danger-text)">
{{ error }}
</span>
</div>
</div>
</BaseCard>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import BaseCard from '@/components/base/BaseCard.vue'
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
interface RotationOption {
label: string
value: RotationStrategy
description: string
}
defineProps<{
options: RotationOption[]
}>()
const model = defineModel<RotationStrategy>({ required: true })
</script>
<template>
<BaseCard
:padded="false"
title="账号选择"
description="决定每次请求如何使用账号池。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3 lg:grid-cols-3">
<button
v-for="option in options"
:key="option.value"
type="button"
class="min-h-25 cursor-pointer rounded-(--cp-input-radius-base) border-0 px-4 py-3.5 text-left shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="
model === option.value
? 'bg-(--cp-info-bg) text-(--cp-info-text) shadow-(--cp-shadow-control)'
: 'bg-(--cp-input-current-bg,var(--cp-input-context-bg)) text-(--cp-text-primary) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover)'
"
:aria-pressed="model === option.value"
@click="model = option.value"
>
<span class="flex items-center gap-2">
<span
class="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-(--cp-bg-surface) shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]"
>
<span
class="size-2 rounded-full transition-opacity duration-150"
:class="model === option.value ? 'bg-(--cp-info) opacity-100' : 'opacity-0'"
/>
</span>
<span class="text-[14px] leading-[1.15] font-[760]">{{ option.label }}</span>
</span>
<span class="mt-2 block text-[13px] leading-normal font-[650] text-(--cp-text-secondary)">
{{ option.description }}
</span>
</button>
</div>
</BaseCard>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { Gauge, Timer, Zap } from '@lucide/vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
const maxConcurrentPerAccount = defineModel<string>('maxConcurrentPerAccount', { required: true })
const refreshMarginSeconds = defineModel<string>('refreshMarginSeconds', { required: true })
const refreshConcurrency = defineModel<string>('refreshConcurrency', { required: true })
const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: true })
</script>
<template>
<BaseCard
:padded="false"
title="运行参数"
description="请求节奏、账号并发和 Token 刷新。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<BaseForm :columns="2">
<BaseFormItem label="单账号最大并发" description="限制单个账号同一时间可承载的请求数。">
<BaseInput v-model="maxConcurrentPerAccount" type="number">
<template #prefix>
<Gauge class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="提前刷新秒数" description="Token 过期前多少秒触发刷新。">
<BaseInput v-model="refreshMarginSeconds" type="number">
<template #prefix>
<Timer class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="刷新并发数" description="同时刷新 Token 的最大请求数,减小可避免限流。">
<BaseInput v-model="refreshConcurrency" type="number">
<template #prefix>
<Zap class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="请求间隔 ms" description="控制同一账号两次调度之间的最小等待时间。">
<BaseInput v-model="requestIntervalMs" type="number">
<template #prefix>
<Timer class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
</BaseForm>
</BaseCard>
</template>
+54 -253
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, shallowRef } from 'vue'
import { useClipboard } from '@vueuse/core'
import { sortBy } from 'es-toolkit'
import { Copy, Gauge, GitBranch, KeyRound, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { Save } from '@lucide/vue'
import {
deleteAdminApiKey,
@@ -12,13 +12,14 @@ import {
updateSettings,
} from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import { toast } from '@/components/base/BaseToast'
import AdminApiKeyCard from './components/AdminApiKeyCard.vue'
import ModelAliasesCard from './components/ModelAliasesCard.vue'
import RotationStrategyCard from './components/RotationStrategyCard.vue'
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
interface AliasRow {
@@ -35,14 +36,25 @@ interface SettingsForm {
rotationStrategy: RotationStrategy
}
const loading = ref(true)
const saving = ref(false)
const aliasError = ref('')
const adminKeyLoading = ref(true)
const adminKeyRegenerating = ref(false)
const adminKeyDeleting = ref(false)
const showDeleteAdminKeyModal = ref(false)
const generatedAdminApiKey = ref('')
interface RotationOption {
label: string
value: RotationStrategy
description: string
}
interface AdminApiKeyStatus {
exists: boolean
maskedKey: string | null
}
const loading = shallowRef(true)
const saving = shallowRef(false)
const aliasError = shallowRef('')
const adminKeyLoading = shallowRef(true)
const adminKeyRegenerating = shallowRef(false)
const adminKeyDeleting = shallowRef(false)
const showDeleteAdminKeyModal = shallowRef(false)
const generatedAdminApiKey = shallowRef('')
const { copy } = useClipboard()
const form = reactive<SettingsForm>({
@@ -55,16 +67,12 @@ const form = reactive<SettingsForm>({
})
const aliasRows = ref<AliasRow[]>([])
const adminApiKeyStatus = reactive({
const adminApiKeyStatus = reactive<AdminApiKeyStatus>({
exists: false,
maskedKey: null as string | null,
maskedKey: null,
})
const rotationOptions: Array<{
label: string
value: RotationStrategy
description: string
}> = [
const rotationOptions: RotationOption[] = [
{
label: '智能分配(推荐)',
value: 'least_used',
@@ -284,241 +292,34 @@ onMounted(loadSettings)
</header>
<div class="mt-5 grid max-w-6xl gap-5">
<BaseCard
:padded="false"
title="管理员 API Key"
description="用于外部系统集成,具有管理员权限。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="default"
:loading="adminKeyRegenerating"
:disabled="adminKeyLoading || adminKeyDeleting"
@click="handleRegenerateAdminApiKey"
>
<template #icon>
<KeyRound class="size-4" />
</template>
{{ adminApiKeyStatus.exists ? '重新生成' : '生成' }}
</BaseButton>
<BaseButton
variant="danger"
:disabled="adminKeyLoading || adminKeyRegenerating || !adminApiKeyStatus.exists"
@click="showDeleteAdminKeyModal = true"
>
<template #icon>
<Trash2 class="size-4" />
</template>
删除
</BaseButton>
</div>
</template>
<AdminApiKeyCard
:status="adminApiKeyStatus"
:loading="adminKeyLoading"
:regenerating="adminKeyRegenerating"
:deleting="adminKeyDeleting"
:generated-key="generatedAdminApiKey"
@regenerate="handleRegenerateAdminApiKey"
@request-delete="showDeleteAdminKeyModal = true"
@copy="copyAdminApiKey"
/>
<div class="grid gap-4">
<div
class="flex min-h-16 items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-normal) shadow-(--cp-shadow-control)"
>
<KeyRound class="size-4" />
</span>
<div class="min-w-0">
<p class="m-0 text-[13px] leading-[1.15] font-[720] text-(--cp-text-primary)">
{{ adminApiKeyStatus.exists ? '已启用' : '未生成' }}
</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>
{{
adminKeyLoading
? '加载中...'
: adminApiKeyStatus.maskedKey || '外部系统暂时无法通过 API Key 调用管理接口'
}}
</p>
</div>
</div>
</div>
<RuntimeSettingsCard
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
v-model:refresh-margin-seconds="refreshMarginSecondsValue"
v-model:refresh-concurrency="refreshConcurrencyValue"
v-model:request-interval-ms="requestIntervalMsValue"
/>
<div v-if="generatedAdminApiKey" class="grid gap-2">
<p class="m-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
完整 Key 仅显示一次,请立即保存。
</p>
<div class="flex min-w-0 items-center gap-2">
<code
class="min-w-0 flex-1 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 font-mono text-[12px] leading-normal font-[650] break-all text-(--cp-text-primary)"
>
{{ generatedAdminApiKey }}
</code>
<BaseButton icon-only size="md" title="复制" @click="copyAdminApiKey">
<Copy class="size-4" />
</BaseButton>
</div>
</div>
</div>
</BaseCard>
<ModelAliasesCard
:rows="aliasRows"
:error="aliasError"
:disabled="saving || loading"
@add="addAliasRow"
@update="updateAliasRow"
@remove="removeAliasRow"
/>
<BaseCard
:padded="false"
title="运行参数"
description="请求节奏、账号并发和 Token 刷新。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<BaseForm :columns="2">
<BaseFormItem label="单账号最大并发" description="限制单个账号同一时间可承载的请求数。">
<BaseInput v-model="maxConcurrentPerAccountValue" type="number">
<template #prefix>
<Gauge class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="提前刷新秒数" description="Token 过期前多少秒触发刷新。">
<BaseInput v-model="refreshMarginSecondsValue" type="number">
<template #prefix>
<Timer class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem
label="刷新并发数"
description="同时刷新 Token 的最大请求数,减小可避免限流。"
>
<BaseInput v-model="refreshConcurrencyValue" type="number">
<template #prefix>
<Zap class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="请求间隔 ms" description="控制同一账号两次调度之间的最小等待时间。">
<BaseInput v-model="requestIntervalMsValue" type="number">
<template #prefix>
<Timer class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
</BaseForm>
</BaseCard>
<BaseCard
:padded="false"
title="模型映射"
description="把客户端可见名称指向真实上游模型。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3">
<div
class="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem] gap-2 px-0.75 text-xs leading-none font-bold text-(--cp-text-secondary) sm:grid"
>
<span>别名</span>
<span>目标模型</span>
<span />
</div>
<div
v-if="aliasRows.length === 0"
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3 text-[13px] font-[650] text-(--cp-text-muted)"
>
还没有模型映射。
</div>
<div
v-for="(row, index) in aliasRows"
:key="index"
class="grid items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
>
<BaseInput
:model-value="row.alias"
placeholder="gpt-5.2"
@update:model-value="updateAliasRow(index, 'alias', $event)"
>
<template #prefix>
<GitBranch class="size-4" />
</template>
</BaseInput>
<BaseInput
:model-value="row.target"
placeholder="gpt-5.5"
@update:model-value="updateAliasRow(index, 'target', $event)"
/>
<BaseButton
variant="ghost"
size="default"
icon-only
label="删除映射"
:disabled="saving || loading"
@click="removeAliasRow(index)"
>
<Trash2 class="size-4" />
</BaseButton>
</div>
<div class="flex flex-wrap items-center gap-3 pt-1">
<BaseButton variant="default" :disabled="saving || loading" @click="addAliasRow">
<template #icon>
<Plus class="size-4" />
</template>
添加映射
</BaseButton>
<span v-if="aliasError" class="text-xs font-[650] text-(--cp-danger-text)">
{{ aliasError }}
</span>
</div>
</div>
</BaseCard>
<BaseCard
:padded="false"
title="账号选择"
description="决定每次请求如何使用账号池。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-3 lg:grid-cols-3">
<button
v-for="option in rotationOptions"
:key="option.value"
type="button"
class="min-h-25 cursor-pointer rounded-(--cp-input-radius-base) border-0 px-4 py-3.5 text-left shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="
form.rotationStrategy === option.value
? 'bg-(--cp-info-bg) text-(--cp-info-text) shadow-(--cp-shadow-control)'
: 'bg-(--cp-input-current-bg,var(--cp-input-context-bg)) text-(--cp-text-primary) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover)'
"
:aria-pressed="form.rotationStrategy === option.value"
@click="form.rotationStrategy = option.value"
>
<span class="flex items-center gap-2">
<span
class="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-(--cp-bg-surface) shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]"
>
<span
class="size-2 rounded-full transition-opacity duration-150"
:class="
form.rotationStrategy === option.value
? 'bg-(--cp-info) opacity-100'
: 'opacity-0'
"
/>
</span>
<span class="text-[14px] leading-[1.15] font-[760]">{{ option.label }}</span>
</span>
<span
class="mt-2 block text-[13px] leading-normal font-[650] text-(--cp-text-secondary)"
>
{{ option.description }}
</span>
</button>
</div>
</BaseCard>
<RotationStrategyCard v-model="form.rotationStrategy" :options="rotationOptions" />
<BaseConfirmModal
v-model="showDeleteAdminKeyModal"