mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor: split frontend maintenance code
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user