mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -32,11 +32,11 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-2.5"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="size-3.5 shrink-0 rounded-[4px]"
|
||||
class="size-3.5 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: group.color }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</span>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 text-[11px] font-emphasis text-(--cp-text-muted)"
|
||||
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
|
||||
</p>
|
||||
|
||||
@@ -21,11 +21,11 @@ const fullNames = computed(() => props.groups
|
||||
<span
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="size-4 shrink-0 rounded-[4px]"
|
||||
class="size-4 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: group.color }"
|
||||
:title="group.name"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-xs text-(--cp-text-muted)">—</span>
|
||||
<span v-else class="text-xs text-cp-muted-text">—</span>
|
||||
</template>
|
||||
|
||||
@@ -90,72 +90,73 @@ function providerSummary(group: AccountGroup) {
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<BaseScrollbar
|
||||
v-if="visibleGroups.length > 0"
|
||||
max-height="260px"
|
||||
view-class="grid gap-2 pr-2"
|
||||
>
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id"
|
||||
class="flex w-full items-start gap-3 rounded-(--cp-input-radius-base) border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
:class="selectedIds.includes(group.id)
|
||||
? 'bg-(--cp-info-bg)'
|
||||
: 'bg-(--cp-bg-subtle) hover:bg-(--cp-default-bg-hover)'"
|
||||
>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.includes(group.id)"
|
||||
:disabled="disabled || loading"
|
||||
:label="`选择${group.name}`"
|
||||
@update:model-value="toggle(group.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<strong class="min-w-0 truncate text-[13px] text-(--cp-text-primary)">
|
||||
{{ group.name }}
|
||||
</strong>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 rounded-md bg-(--cp-warning-bg) px-1.5 py-1 text-[10px] leading-none font-bold text-(--cp-warning-text)"
|
||||
>
|
||||
已禁用
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id"
|
||||
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:class="selectedIds.includes(group.id)
|
||||
? 'bg-cp-info-bg'
|
||||
: 'bg-cp-subtle hover:bg-cp-default-hover'"
|
||||
>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.includes(group.id)"
|
||||
:disabled="disabled || loading"
|
||||
:label="`选择${group.name}`"
|
||||
@update:model-value="toggle(group.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
|
||||
{{ group.name }}
|
||||
</strong>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 rounded-md bg-cp-warning-bg px-1.5 py-1 text-[10px] leading-none font-bold text-cp-warning-text"
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
|
||||
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] font-emphasis text-(--cp-text-secondary)">
|
||||
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
|
||||
<BaseEmpty
|
||||
v-else-if="groups.length === 0 && !loading"
|
||||
compact
|
||||
size="sm"
|
||||
title="暂无可选分组"
|
||||
description="请先在分组管理中创建分组。"
|
||||
/>
|
||||
<BaseEmpty
|
||||
v-else-if="!loading"
|
||||
compact
|
||||
size="sm"
|
||||
title="没有匹配分组"
|
||||
description="请调整搜索关键词。"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectedIds.length === 0"
|
||||
class="rounded-(--cp-input-radius-base) px-3.5 py-3"
|
||||
class="rounded-cp-control px-3.5 py-3"
|
||||
:class="emptyCopy.tone === 'warning'
|
||||
? 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'"
|
||||
? 'bg-cp-warning-bg text-cp-warning-text'
|
||||
: 'bg-cp-subtle text-cp-secondary'"
|
||||
>
|
||||
<p class="m-0 text-[12px] font-bold">
|
||||
{{ emptyCopy.title }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-[1.5] font-emphasis">
|
||||
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
|
||||
{{ emptyCopy.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,9 @@ const providerOptions = [
|
||||
<template>
|
||||
<BaseSegmented
|
||||
v-model="provider"
|
||||
label="按平台筛选"
|
||||
:options="providerOptions"
|
||||
:disabled="disabled"
|
||||
icon-only
|
||||
size="input"
|
||||
aria-label="按平台筛选"
|
||||
display="icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -41,18 +41,18 @@ const authenticationLabel = computed(() => {
|
||||
<template>
|
||||
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
:class="iconContainerClass"
|
||||
:title="providerLabel"
|
||||
>
|
||||
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
|
||||
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
|
||||
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showAuthenticationKind"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
:class="iconContainerClass"
|
||||
:title="authenticationLabel"
|
||||
>
|
||||
@@ -66,7 +66,7 @@ const authenticationLabel = computed(() => {
|
||||
:class="iconClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
</span>
|
||||
|
||||
<span class="sr-only">
|
||||
|
||||
@@ -1,119 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
|
||||
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
|
||||
type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
spinIconOnLoading?: boolean
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
iconOnly?: boolean
|
||||
label?: string
|
||||
title?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'default',
|
||||
variant: 'secondary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
spinIconOnLoading: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
iconOnly: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const slots = useSlots()
|
||||
defineSlots<{
|
||||
icon?: () => unknown
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
|
||||
success:
|
||||
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
|
||||
warning:
|
||||
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
|
||||
danger:
|
||||
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
secondary:
|
||||
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
|
||||
ghost:
|
||||
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
destructive:
|
||||
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
}
|
||||
|
||||
const iconOnlyDefaultClasses
|
||||
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'h-7 gap-1.5 px-3 text-xs',
|
||||
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
|
||||
md: 'h-11 gap-2 px-4 text-[13px]',
|
||||
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
|
||||
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
|
||||
}
|
||||
|
||||
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'size-8 rounded-(--cp-icon-button-radius)',
|
||||
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
|
||||
md: 'size-11 rounded-(--cp-icon-button-radius)',
|
||||
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
|
||||
}
|
||||
|
||||
const loadingIconSize: Record<ButtonSize, number> = {
|
||||
const spinnerSizes: Record<ButtonSize, number> = {
|
||||
sm: 14,
|
||||
default: 15,
|
||||
md: 16,
|
||||
md: 15,
|
||||
lg: 17,
|
||||
}
|
||||
|
||||
const loadingIndicatorStyle = computed(() => {
|
||||
const iconSize = loadingIconSize[props.size]
|
||||
return {
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
|
||||
props.iconOnly ? 'shrink-0' : undefined,
|
||||
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
|
||||
? iconOnlyDefaultClasses
|
||||
: variantClasses[props.variant],
|
||||
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
|
||||
])
|
||||
|
||||
const ariaLabel = computed(() => props.label || props.title)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const defaultSlot = slots.default?.()
|
||||
const text = defaultSlot
|
||||
?.map(node => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
return text || ''
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
|
||||
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
|
||||
])
|
||||
|
||||
const loadingIconClasses = computed(() => [
|
||||
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
|
||||
props.loading
|
||||
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
|
||||
: undefined,
|
||||
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -122,31 +65,24 @@ const loadingIconClasses = computed(() => [
|
||||
:type="type"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="iconOnly ? ariaLabel : undefined"
|
||||
:title="title || (iconOnly ? label : undefined)"
|
||||
:aria-busy="loading"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<span
|
||||
v-if="loading && !spinIconOnLoading"
|
||||
:class="loadingIconClasses"
|
||||
:style="loadingIndicatorStyle"
|
||||
v-if="loading"
|
||||
class="inline-grid shrink-0 place-items-center leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LoaderCircle
|
||||
:size="loadingIconSize[size]"
|
||||
/>
|
||||
<slot name="loading">
|
||||
<LoaderCircle
|
||||
class="animate-spin motion-reduce:animate-none"
|
||||
:size="spinnerSizes[size]"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
|
||||
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
|
||||
:class="loadingIconClasses"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex min-w-0 items-center justify-center gap-2">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type CardPadding = 'default' | 'compact'
|
||||
type CardLayout = 'content' | 'flush'
|
||||
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
|
||||
|
||||
type CardPadding = 'none' | 'compact' | 'default'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
padding?: CardPadding
|
||||
layout?: CardLayout
|
||||
title?: string
|
||||
description?: string
|
||||
headerCollapseAt?: HeaderCollapseAt
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
}>(),
|
||||
{
|
||||
as: 'section',
|
||||
padding: 'default',
|
||||
layout: 'content',
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
headerCollapseAt: 'sm',
|
||||
headerClass: undefined,
|
||||
bodyClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -37,55 +26,36 @@ const slots = defineSlots<{
|
||||
default?: () => unknown
|
||||
}>()
|
||||
|
||||
const paddingClasses: Record<CardPadding, string> = {
|
||||
default: 'p-5.5',
|
||||
const paddingClasses: Record<CardPadding, string | undefined> = {
|
||||
none: undefined,
|
||||
compact: 'p-4',
|
||||
default: 'p-5.5',
|
||||
}
|
||||
|
||||
const hasManagedHeader = computed(
|
||||
() =>
|
||||
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
|
||||
() => Boolean(props.title || props.description || slots.actions || slots.title || slots.description),
|
||||
)
|
||||
|
||||
const outerPaddingClass = computed(() => {
|
||||
if (props.layout === 'flush')
|
||||
return undefined
|
||||
return paddingClasses[props.padding]
|
||||
})
|
||||
|
||||
const managedHeaderLayoutClasses = computed(() => {
|
||||
if (props.headerCollapseAt === 'none') {
|
||||
return 'flex items-start justify-between gap-3'
|
||||
}
|
||||
|
||||
if (props.headerCollapseAt === 'lg') {
|
||||
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
|
||||
}
|
||||
|
||||
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
|
||||
})
|
||||
const hasHeader = computed(() => Boolean(slots.header || hasManagedHeader.value))
|
||||
const contentClasses = computed(() => [
|
||||
hasHeader.value ? 'mt-4' : undefined,
|
||||
'flex min-h-0 min-w-0 flex-1 flex-col',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.as"
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
|
||||
:class="[
|
||||
outerPaddingClass,
|
||||
]"
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
|
||||
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
|
||||
>
|
||||
<template v-if="$slots.header || hasManagedHeader || $slots.body">
|
||||
<header
|
||||
v-if="$slots.header || hasManagedHeader"
|
||||
class="shrink-0"
|
||||
:class="props.headerClass"
|
||||
>
|
||||
<header v-if="$slots.header || hasManagedHeader" class="shrink-0">
|
||||
<slot name="header">
|
||||
<div :class="managedHeaderLayoutClasses">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 pt-0.5">
|
||||
<h2
|
||||
v-if="props.title || $slots.title"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ props.title }}
|
||||
@@ -93,14 +63,13 @@ const managedHeaderLayoutClasses = computed(() => {
|
||||
</h2>
|
||||
<p
|
||||
v-if="props.description || $slots.description"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ props.description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -108,10 +77,7 @@ const managedHeaderLayoutClasses = computed(() => {
|
||||
</slot>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="$slots.body || $slots.default"
|
||||
:class="props.bodyClass"
|
||||
>
|
||||
<div v-if="$slots.body || $slots.default" :class="contentClasses">
|
||||
<slot name="body">
|
||||
<slot />
|
||||
</slot>
|
||||
|
||||
@@ -1,70 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Minus } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef, watchEffect } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
indeterminate?: boolean
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
indeterminate: false,
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
showLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
|
||||
const inputRef = useTemplateRef<HTMLInputElement>('input')
|
||||
const checked = computed(() => model.value || props.indeterminate)
|
||||
|
||||
watchEffect(() => {
|
||||
if (inputRef.value)
|
||||
inputRef.value.indeterminate = props.indeterminate
|
||||
})
|
||||
|
||||
const boxClasses = computed(() => [
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
|
||||
? 'bg-cp-disabled text-cp-disabled-icon'
|
||||
: checked.value
|
||||
? 'bg-(--cp-info-bg) text-(--cp-info)'
|
||||
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
|
||||
? 'bg-cp-accent text-cp-accent-on'
|
||||
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
|
||||
])
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
model.value = !model.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-emphasis transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
|
||||
role="checkbox"
|
||||
:aria-checked="indeterminate ? 'mixed' : model"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<span :class="boxClasses">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span
|
||||
:class="boxClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Minus
|
||||
class="absolute transition-opacity duration-150 size-3"
|
||||
:class="[indeterminate ? 'opacity-100' : 'opacity-0']"
|
||||
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
:class="indeterminate ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<Check
|
||||
class="absolute transition-opacity duration-150 size-3"
|
||||
:class="[!indeterminate && model ? 'opacity-100' : 'opacity-0']"
|
||||
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="showLabel && label"
|
||||
class="text-[13px] leading-none font-emphasis"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -244,31 +244,29 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
v-model="open"
|
||||
class="p-0.75"
|
||||
placement="bottom-start"
|
||||
:width="304"
|
||||
:disabled="props.disabled"
|
||||
panel-class="p-3!"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-[4px] border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="props.disabled"
|
||||
:aria-label="props.label"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-[4px]" aria-hidden="true">
|
||||
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
|
||||
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
|
||||
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-3" role="dialog" :aria-label="props.label">
|
||||
<div class="grid w-76 gap-3 p-3" role="dialog" :aria-label="props.label">
|
||||
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
|
||||
<div
|
||||
ref="svPanel"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:style="{ backgroundColor: hueColor }"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -290,7 +288,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
|
||||
<div
|
||||
ref="hueSlider"
|
||||
class="base-color-picker__hue relative touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="色相"
|
||||
@@ -303,7 +301,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
@keydown="handleHueKeydown"
|
||||
>
|
||||
<span
|
||||
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-[2px] bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
|
||||
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-xs bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
|
||||
:style="hueThumbStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -313,7 +311,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
|
||||
<div
|
||||
ref="alphaSlider"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:style="alphaTrackStyle"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -333,7 +331,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-right font-mono text-[11px] tabular-nums text-(--cp-text-secondary)">
|
||||
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
|
||||
{{ Math.round(alpha * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -348,7 +346,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
v-for="color in props.presets"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-[4px] border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
||||
:style="{ backgroundColor: color }"
|
||||
role="radio"
|
||||
:aria-label="color"
|
||||
@@ -366,18 +364,28 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
class="min-w-0 flex-1"
|
||||
aria-label="RGBA 颜色"
|
||||
placeholder="rgba(96, 165, 250, 1)"
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:error="inputError"
|
||||
@change="updateFromInput"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<BaseButton size="default" variant="default" :disabled="!parseRgbaColor(customInput)" @click="confirm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
aria-label="RGBA 颜色"
|
||||
placeholder="rgba(96, 165, 250, 1)"
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:aria-invalid="Boolean(inputError) || undefined"
|
||||
aria-describedby="color-input-error"
|
||||
@change="updateFromInput"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<p
|
||||
v-if="inputError"
|
||||
id="color-input-error"
|
||||
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ inputError }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
|
||||
确定
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
@@ -2,27 +2,22 @@
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
|
||||
type ConfirmVariant = 'info' | 'warning' | 'danger' | 'success'
|
||||
type ButtonVariant = 'primary' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
variant?: ConfirmVariant
|
||||
destructive?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
loading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
width?: string | number
|
||||
}>(),
|
||||
{
|
||||
variant: 'warning',
|
||||
destructive: false,
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
loading: false,
|
||||
confirmDisabled: false,
|
||||
width: '480px',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -31,13 +26,6 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const confirmVariantMap: Record<ConfirmVariant, ButtonVariant> = {
|
||||
info: 'primary',
|
||||
warning: 'warning',
|
||||
danger: 'danger',
|
||||
success: 'success',
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (props.loading)
|
||||
return
|
||||
@@ -57,23 +45,24 @@ function handleConfirm() {
|
||||
v-model="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:variant="variant"
|
||||
:width="width"
|
||||
:close-disabled="loading"
|
||||
size="sm"
|
||||
:tone="destructive ? 'danger' : 'warning'"
|
||||
role="alertdialog"
|
||||
:dismissible="!loading"
|
||||
>
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="text-[14px] leading-[1.55] font-emphasis text-(--cp-text-secondary)"
|
||||
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="default" :disabled="loading" @click="handleCancel">
|
||||
<BaseButton variant="secondary" :disabled="loading" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:variant="confirmVariantMap[variant]"
|
||||
:variant="destructive ? 'destructive' : 'primary'"
|
||||
:loading="loading"
|
||||
:disabled="confirmDisabled"
|
||||
@click="handleConfirm"
|
||||
|
||||
@@ -3,13 +3,22 @@ import type { Component } from 'vue'
|
||||
import { Inbox } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
compact?: boolean
|
||||
plain?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
size?: 'sm' | 'md'
|
||||
surface?: 'subtle' | 'none'
|
||||
}>(),
|
||||
{
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
icon: undefined,
|
||||
size: 'md',
|
||||
surface: 'subtle',
|
||||
},
|
||||
)
|
||||
|
||||
const resolvedTitle = computed(() => props.title ?? '暂无数据')
|
||||
const resolvedIcon = computed(() => props.icon ?? Inbox)
|
||||
@@ -19,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
|
||||
<div
|
||||
class="grid justify-items-center text-center"
|
||||
:class="[
|
||||
plain ? 'bg-transparent' : 'rounded-(--cp-panel-radius) bg-(--cp-bg-subtle)',
|
||||
compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
|
||||
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
|
||||
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-muted)"
|
||||
:class="compact ? 'size-8' : 'size-10'"
|
||||
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
|
||||
:class="size === 'sm' ? 'size-8' : 'size-10'"
|
||||
>
|
||||
<component :is="resolvedIcon" :size="compact ? 16 : 18" />
|
||||
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
|
||||
</span>
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
|
||||
{{ resolvedTitle }}
|
||||
</p>
|
||||
<p
|
||||
v-if="description"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-(--cp-text-secondary)"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
import { computed, provide, useId } from 'vue'
|
||||
import { formFieldKey } from './context'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
description?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
controlId?: string
|
||||
}>(),
|
||||
{
|
||||
label: undefined,
|
||||
description: undefined,
|
||||
error: undefined,
|
||||
required: false,
|
||||
controlId: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const generatedId = useId()
|
||||
const resolvedControlId = computed(() => props.controlId ?? `field-${generatedId}`)
|
||||
const descriptionId = computed(() => props.description ? `${resolvedControlId.value}-description` : undefined)
|
||||
const errorId = computed(() => props.error ? `${resolvedControlId.value}-error` : undefined)
|
||||
const describedBy = computed(() => [descriptionId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
provide(formFieldKey, {
|
||||
controlId: resolvedControlId,
|
||||
describedBy,
|
||||
invalid: computed(() => Boolean(props.error)),
|
||||
required: computed(() => props.required),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,33 +39,36 @@ withDefaults(
|
||||
v-if="label || $slots.label || $slots.extra"
|
||||
class="mb-2 flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<slot name="label">
|
||||
<span
|
||||
v-if="label"
|
||||
class="min-w-0 text-[13px] leading-none font-medium text-(--cp-text-secondary)"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</slot>
|
||||
<span v-if="required" class="text-[13px] leading-none font-bold text-(--cp-danger)">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<!-- The runtime id comes from the injected field context. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
:for="resolvedControlId"
|
||||
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
|
||||
>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
|
||||
<span v-if="required" class="sr-only">必填</span>
|
||||
</label>
|
||||
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-(--cp-text-muted)"
|
||||
:id="descriptionId"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
<p v-if="error" class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-(--cp-danger-text)">
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
interface FormFieldContext {
|
||||
controlId: ComputedRef<string>
|
||||
describedBy: ComputedRef<string | undefined>
|
||||
invalid: ComputedRef<boolean>
|
||||
required: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export const formFieldKey: InjectionKey<FormFieldContext> = Symbol('form-field')
|
||||
@@ -1,52 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type FormGap = 'compact' | 'default' | 'loose'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns?: number | string
|
||||
gap?: FormGap
|
||||
}>(),
|
||||
{
|
||||
columns: 1,
|
||||
gap: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
const gapClasses: Record<FormGap, string> = {
|
||||
compact: 'gap-3',
|
||||
default: 'gap-4',
|
||||
loose: 'gap-5',
|
||||
}
|
||||
|
||||
const formClasses = computed(() => ['base-form grid min-w-0 grid-cols-1', gapClasses[props.gap]])
|
||||
|
||||
const formStyle = computed<CSSProperties>(() => {
|
||||
if (typeof props.columns === 'number') {
|
||||
return {
|
||||
'--cp-form-columns':
|
||||
props.columns > 1 ? `repeat(${props.columns}, minmax(0, 1fr))` : 'minmax(0, 1fr)',
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
return {
|
||||
'--cp-form-columns': props.columns,
|
||||
} as CSSProperties
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="formClasses" :style="formStyle" @submit.prevent>
|
||||
<form class="grid min-w-0 gap-4" @submit.prevent>
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 640px) {
|
||||
.base-form {
|
||||
grid-template-columns: var(--cp-form-columns);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type IconButtonVariant = 'primary' | 'secondary' | 'success' | 'ghost' | 'destructive'
|
||||
type IconButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
variant?: IconButtonVariant
|
||||
size?: IconButtonSize
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
pressed?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
pressed: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
defineSlots<{
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const variantClasses: Record<IconButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
secondary:
|
||||
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
|
||||
success:
|
||||
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
|
||||
ghost:
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
destructive:
|
||||
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
}
|
||||
|
||||
const sizeClasses: Record<IconButtonSize, string> = {
|
||||
sm: 'size-cp-control-sm [&>svg]:size-3.5',
|
||||
md: 'size-cp-control-md [&>svg]:size-4',
|
||||
lg: 'size-cp-control-lg [&>svg]:size-4.5',
|
||||
}
|
||||
|
||||
const spinnerSizes: Record<IconButtonSize, number> = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="label"
|
||||
:aria-pressed="pressed || undefined"
|
||||
:aria-busy="loading || undefined"
|
||||
:title="label"
|
||||
>
|
||||
<span v-if="loading" class="inline-grid place-items-center" aria-hidden="true">
|
||||
<slot name="loading">
|
||||
<LoaderCircle
|
||||
class="animate-spin motion-reduce:animate-none"
|
||||
:size="spinnerSizes[size]"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else class="inline-grid place-items-center" aria-hidden="true">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,97 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject, useAttrs } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
type InputSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
type?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
autocomplete?: string
|
||||
name?: string
|
||||
ariaLabel?: string
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
size?: InputSize
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
autocomplete: undefined,
|
||||
name: undefined,
|
||||
ariaLabel: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
// number 输入也保持字符串契约,避免原生 v-model 隐式转换为 number。
|
||||
function updateModel(event: Event) {
|
||||
model.value = (event.target as HTMLInputElement).value
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const controlAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeClasses: Record<InputSize, string> = {
|
||||
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
|
||||
}
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
|
||||
sizeClasses[props.size],
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
|
||||
: props.error
|
||||
? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)'
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
: invalid.value
|
||||
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
|
||||
: [
|
||||
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
|
||||
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-(--cp-shadow-input-focus)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
|
||||
],
|
||||
])
|
||||
|
||||
const iconClasses = computed(() => [
|
||||
'inline-flex shrink-0',
|
||||
props.disabled
|
||||
? 'text-(--cp-disabled-icon)'
|
||||
: props.error
|
||||
? 'text-(--cp-danger)'
|
||||
: 'text-(--cp-text-muted)',
|
||||
? 'text-cp-disabled-icon'
|
||||
: invalid.value
|
||||
? 'text-cp-danger'
|
||||
: 'text-cp-muted-text',
|
||||
])
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'h-full min-w-0 flex-1 border-0 bg-transparent text-[13px] font-emphasis leading-[1.15] outline-0',
|
||||
'placeholder:text-(--cp-text-muted) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)',
|
||||
props.error
|
||||
? 'text-(--cp-danger-text)'
|
||||
: props.disabled
|
||||
? 'text-(--cp-disabled-text)'
|
||||
: 'text-(--cp-text-primary)',
|
||||
])
|
||||
function updateModel(event: Event) {
|
||||
model.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div v-bind="rootAttrs" class="min-w-0">
|
||||
<span class="base-input__control" :class="containerClasses">
|
||||
<span v-if="$slots.prefix" :class="iconClasses">
|
||||
<span v-if="$slots.prefix" :class="iconClasses" aria-hidden="true">
|
||||
<slot name="prefix" />
|
||||
</span>
|
||||
<input
|
||||
v-bind="controlAttrs"
|
||||
:id="controlId"
|
||||
:value="model"
|
||||
class="base-input__field"
|
||||
:class="inputClasses"
|
||||
:name="name"
|
||||
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:required="field?.required.value || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
@input="updateModel"
|
||||
>
|
||||
<span v-if="$slots.suffix" :class="iconClasses">
|
||||
<slot name="suffix" />
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="error" class="text-xs font-emphasis leading-[1.15] text-(--cp-danger-text)">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'neutral' | 'destructive'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
tone: 'neutral',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineSlots<{
|
||||
icon?: () => unknown
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const classes = computed(() => [
|
||||
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
|
||||
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
|
||||
props.tone === 'destructive'
|
||||
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
|
||||
: 'text-cp-primary hover:bg-cp-default-hover',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<span v-if="loading" class="inline-grid size-3.5 shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="loading">
|
||||
<LoaderCircle class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,26 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
import BaseScrollbar from './BaseScrollbar.vue'
|
||||
|
||||
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
width?: string | number
|
||||
variant?: ModalVariant
|
||||
closeDisabled?: boolean
|
||||
bodyMaxHeight?: string
|
||||
bodyViewClass?: string
|
||||
hideFooter?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
size?: ModalSize
|
||||
tone?: ModalTone
|
||||
dismissible?: boolean
|
||||
role?: 'dialog' | 'alertdialog'
|
||||
}>(),
|
||||
{
|
||||
description: undefined,
|
||||
size: 'md',
|
||||
tone: 'neutral',
|
||||
dismissible: true,
|
||||
role: 'dialog',
|
||||
},
|
||||
)
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const panel = useTemplateRef<HTMLElement>('panel')
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
let ownsScrollLock = false
|
||||
|
||||
@@ -33,57 +43,46 @@ const focusableSelector = [
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const variant = computed(() => props.variant ?? 'default')
|
||||
|
||||
const modalStyle = computed(() => {
|
||||
const preferredWidth
|
||||
= typeof props.width === 'number' ? `${props.width}px` : (props.width ?? '560px')
|
||||
|
||||
return {
|
||||
width: `min(${preferredWidth}, calc(100dvw - 1.5rem))`,
|
||||
}
|
||||
})
|
||||
const bodyClass = computed(() => [
|
||||
'min-h-0 overflow-hidden py-4 pr-4 pl-3.25 sm:py-7 sm:pr-7 sm:pl-6.25',
|
||||
props.description || variant.value !== 'default' ? 'pt-4 sm:pt-5' : 'pt-4 sm:pt-6',
|
||||
])
|
||||
const scrollViewClass = computed(() =>
|
||||
['pb-0.75 pl-0.75 pr-3 sm:pr-4', props.bodyViewClass].filter(Boolean).join(' '),
|
||||
)
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
default: Info,
|
||||
neutral: Info,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
danger: AlertCircle,
|
||||
success: CheckCircle2,
|
||||
}
|
||||
|
||||
const variantClasses: Record<ModalVariant, { iconBg: string, icon: string }> = {
|
||||
default: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
|
||||
neutral: {
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
info: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-(--cp-warning-bg)',
|
||||
icon: 'text-(--cp-warning)',
|
||||
iconBg: 'bg-cp-warning-bg',
|
||||
icon: 'text-cp-warning',
|
||||
},
|
||||
danger: {
|
||||
iconBg: 'bg-(--cp-danger-bg)',
|
||||
icon: 'text-(--cp-danger)',
|
||||
iconBg: 'bg-cp-danger-bg',
|
||||
icon: 'text-cp-danger',
|
||||
},
|
||||
success: {
|
||||
iconBg: 'bg-(--cp-success-bg)',
|
||||
icon: 'text-(--cp-success)',
|
||||
iconBg: 'bg-cp-success-bg',
|
||||
icon: 'text-cp-success',
|
||||
},
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (props.closeDisabled)
|
||||
if (!props.dismissible)
|
||||
return
|
||||
open.value = false
|
||||
}
|
||||
@@ -187,74 +186,68 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 cursor-default border-0 bg-(--cp-overlay-scrim) p-0"
|
||||
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
|
||||
@click="closeModal"
|
||||
/>
|
||||
<section
|
||||
ref="panel"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover) sm:max-h-[calc(100dvh-3rem)]"
|
||||
:style="modalStyle"
|
||||
role="dialog"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
|
||||
:class="sizeClasses[size]"
|
||||
:role="role"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="description ? descriptionId : undefined"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_28px] gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
|
||||
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
|
||||
:class="tone === 'neutral'
|
||||
? 'grid-cols-[minmax(0,1fr)_28px]'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
|
||||
>
|
||||
<span
|
||||
v-if="variant !== 'default'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
|
||||
:class="$slots.icon ? 'bg-(--cp-bg-subtle)' : variantClasses[variant].iconBg"
|
||||
v-if="tone !== 'neutral'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-cp-control"
|
||||
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
|
||||
>
|
||||
<slot name="icon">
|
||||
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
|
||||
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
|
||||
</slot>
|
||||
</span>
|
||||
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
|
||||
<h2 class="m-0 text-lg leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<div class="min-w-0">
|
||||
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-(--cp-text-secondary)"
|
||||
:id="descriptionId"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
icon-only
|
||||
class="col-start-3"
|
||||
<BaseIconButton
|
||||
label="关闭"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="closeDisabled"
|
||||
:disabled="!dismissible"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="16" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</header>
|
||||
<div :class="bodyClass">
|
||||
<BaseScrollbar
|
||||
class="h-full -mr-3 sm:-mr-4"
|
||||
:max-height="bodyMaxHeight"
|
||||
:view-class="scrollViewClass"
|
||||
>
|
||||
<slot />
|
||||
<div class="min-h-0 overflow-hidden px-4 py-4 sm:px-7 sm:py-6">
|
||||
<BaseScrollbar class="h-full">
|
||||
<div class="p-0.75">
|
||||
<slot />
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
<footer
|
||||
v-if="!hideFooter"
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-7 sm:pb-7"
|
||||
>
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">
|
||||
确认
|
||||
</BaseButton>
|
||||
</slot>
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
actionsInline?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="grid min-h-17 shrink-0 items-start"
|
||||
:class="
|
||||
props.actionsInline
|
||||
? 'grid-cols-[minmax(0,1fr)_auto] gap-x-4'
|
||||
: 'grid-cols-1 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-x-4'
|
||||
"
|
||||
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
<div class="min-w-0 flex-1 basis-64">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="description || $slots.description"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
@@ -31,7 +25,6 @@ const props = defineProps<{
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
|
||||
:class="props.actionsInline ? undefined : 'max-sm:w-full'"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
@@ -27,12 +27,9 @@ const props = withDefaults(
|
||||
placement?: PopoverPlacement
|
||||
trigger?: PopoverTrigger
|
||||
offset?: number
|
||||
width?: number | string
|
||||
disabled?: boolean
|
||||
/** 包含 hover 的触发方式打开弹窗的延迟(毫秒);click 触发不生效。 */
|
||||
hoverDelay?: number
|
||||
panelClass?: string
|
||||
triggerClass?: string
|
||||
anchorElement?: HTMLElement | null
|
||||
animatePosition?: boolean
|
||||
}>(),
|
||||
@@ -42,8 +39,6 @@ const props = withDefaults(
|
||||
offset: 6,
|
||||
disabled: false,
|
||||
hoverDelay: 400,
|
||||
panelClass: '',
|
||||
triggerClass: '',
|
||||
anchorElement: null,
|
||||
animatePosition: false,
|
||||
},
|
||||
@@ -53,7 +48,6 @@ const open = defineModel<boolean>({ default: false })
|
||||
const attrs = useAttrs()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const popoverStyle = ref<CSSProperties>({})
|
||||
const popoverArrowStyle = ref<CSSProperties>({})
|
||||
@@ -62,39 +56,21 @@ const hoverOpenTimer = shallowRef<number>()
|
||||
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
|
||||
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
|
||||
props.animatePosition
|
||||
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
|
||||
: undefined,
|
||||
props.panelClass,
|
||||
])
|
||||
|
||||
const sizeStyle = computed<CSSProperties>(() => {
|
||||
const width = toCssLength(props.width)
|
||||
return width ? { width } : {}
|
||||
})
|
||||
|
||||
function toCssLength(value?: number | string) {
|
||||
if (typeof value === 'number') {
|
||||
return `${value}px`
|
||||
}
|
||||
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const anchorElement = props.anchorElement ?? triggerRef.value
|
||||
const anchorElement = props.anchorElement ?? rootRef.value
|
||||
if (!open.value || !anchorElement)
|
||||
return
|
||||
|
||||
const viewportPadding = 8
|
||||
const triggerRect = anchorElement.getBoundingClientRect()
|
||||
const panelRect = popoverRef.value?.getBoundingClientRect()
|
||||
const panelWidth = Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
toCssLength(props.width) ? 0 : triggerRect.width,
|
||||
)
|
||||
const panelWidth = panelRect?.width ?? 0
|
||||
const panelHeight = panelRect?.height ?? 0
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
|
||||
@@ -113,7 +89,6 @@ function updatePopoverPosition() {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
|
||||
...(toCssLength(props.width) ? {} : { minWidth: `${triggerRect.width}px` }),
|
||||
}
|
||||
popoverArrowStyle.value = popoverArrowPosition({
|
||||
placement: position.placement,
|
||||
@@ -355,12 +330,12 @@ watch(
|
||||
)
|
||||
|
||||
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
|
||||
useEventListener(triggerRef, 'click', (event) => {
|
||||
useEventListener(rootRef, 'click', (event) => {
|
||||
event.stopPropagation()
|
||||
togglePopover()
|
||||
})
|
||||
useEventListener(triggerRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(triggerRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(rootRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(rootRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(popoverRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(popoverRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(viewportTarget, 'keydown', (event) => {
|
||||
@@ -377,14 +352,8 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="relative inline-block overflow-visible" v-bind="attrs">
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="inline-flex"
|
||||
:class="props.triggerClass"
|
||||
>
|
||||
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
</div>
|
||||
<div ref="rootRef" class="relative inline-flex overflow-visible" v-bind="attrs">
|
||||
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
@@ -399,7 +368,7 @@ onBeforeUnmount(() => {
|
||||
v-if="open"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="[sizeStyle, popoverStyle]"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
|
||||
|
||||
@@ -5,20 +5,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplate
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
viewClass?: string
|
||||
maxHeight?: string
|
||||
height?: string
|
||||
horizontal?: boolean
|
||||
vertical?: boolean
|
||||
trackInset?: 'default' | 'none'
|
||||
}>(),
|
||||
{
|
||||
viewClass: '',
|
||||
maxHeight: undefined,
|
||||
height: undefined,
|
||||
horizontal: false,
|
||||
vertical: true,
|
||||
trackInset: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -61,7 +57,6 @@ let horizontalDragStartScrollLeft = 0
|
||||
let scrollTopPosition = 0
|
||||
let scrollLeftPosition = 0
|
||||
let scrollFrameId: number | undefined
|
||||
let rootHovering = false
|
||||
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
|
||||
immediate: false,
|
||||
})
|
||||
@@ -91,16 +86,10 @@ const wrapClasses = computed(() => [
|
||||
props.maxHeight ? undefined : 'h-full',
|
||||
])
|
||||
const verticalTrackClass = computed(() =>
|
||||
props.horizontal && canScrollX.value ? 'bottom-3' : 'bottom-1',
|
||||
props.horizontal && canScrollX.value ? 'bottom-1.5' : 'bottom-0',
|
||||
)
|
||||
const horizontalTrackClass = computed(() =>
|
||||
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
|
||||
)
|
||||
const verticalTrackInsetClass = computed(() =>
|
||||
props.trackInset === 'none' ? 'right-0' : 'right-1',
|
||||
)
|
||||
const horizontalTrackInsetClass = computed(() =>
|
||||
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
|
||||
props.vertical && canScrollY.value ? 'right-1.5' : 'right-0',
|
||||
)
|
||||
|
||||
function showScrollbar() {
|
||||
@@ -113,7 +102,10 @@ function clearHideTimer() {
|
||||
|
||||
function scheduleHideScrollbar() {
|
||||
clearHideTimer()
|
||||
if (!rootHovering) {
|
||||
if (!dragging.value
|
||||
&& !horizontalDragging.value
|
||||
&& !verticalTrackHovering.value
|
||||
&& !horizontalTrackHovering.value) {
|
||||
startHideTimer()
|
||||
}
|
||||
}
|
||||
@@ -129,12 +121,10 @@ function activateScrollbar() {
|
||||
}
|
||||
|
||||
function handleRootMouseEnter() {
|
||||
rootHovering = true
|
||||
activateScrollbar()
|
||||
}
|
||||
|
||||
function handleRootMouseLeave() {
|
||||
rootHovering = false
|
||||
hideScrollbar()
|
||||
}
|
||||
|
||||
@@ -152,7 +142,7 @@ function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
|
||||
function handleTrackMouseEnter(axis: ScrollbarAxis) {
|
||||
setTrackHovering(axis, true)
|
||||
showScrollbar()
|
||||
scheduleHideScrollbar()
|
||||
clearHideTimer()
|
||||
}
|
||||
|
||||
function handleTrackMouseLeave(axis: ScrollbarAxis) {
|
||||
@@ -182,7 +172,12 @@ function measureVerticalScrollbar(wrap: HTMLElement) {
|
||||
const clientHeight = wrap.clientHeight
|
||||
const scrollHeight = wrap.scrollHeight
|
||||
const scrollRange = clamp(scrollHeight - clientHeight, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackHeight = clamp(clientHeight - 8, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackHeight = trackLength(
|
||||
verticalTrackRef.value,
|
||||
clientHeight,
|
||||
'top',
|
||||
'bottom',
|
||||
)
|
||||
if (scrollRange <= overflowTolerance || availableTrackHeight <= 0) {
|
||||
wrap.scrollTop = 0
|
||||
verticalMetrics.scrollRange = 0
|
||||
@@ -209,7 +204,12 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
|
||||
const clientWidth = wrap.clientWidth
|
||||
const scrollWidth = wrap.scrollWidth
|
||||
const scrollRange = clamp(scrollWidth - clientWidth, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackWidth = clamp(clientWidth - 8, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackWidth = trackLength(
|
||||
horizontalTrackRef.value,
|
||||
clientWidth,
|
||||
'left',
|
||||
'right',
|
||||
)
|
||||
if (scrollRange <= overflowTolerance || availableTrackWidth <= 0) {
|
||||
wrap.scrollLeft = 0
|
||||
horizontalMetrics.scrollRange = 0
|
||||
@@ -225,6 +225,28 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
|
||||
horizontalThumbWidth.value = nextThumbWidth
|
||||
}
|
||||
|
||||
function trackLength(
|
||||
track: HTMLElement | null,
|
||||
viewportLength: number,
|
||||
startProperty: 'top' | 'left',
|
||||
endProperty: 'bottom' | 'right',
|
||||
) {
|
||||
if (!track)
|
||||
return clamp(viewportLength - 8, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
const style = getComputedStyle(track)
|
||||
const start = Number.parseFloat(style[startProperty])
|
||||
const end = Number.parseFloat(style[endProperty])
|
||||
const inset = (Number.isFinite(start) ? start : 4) + (Number.isFinite(end) ? end : 4)
|
||||
const root = rootRef.value
|
||||
const containerLength = root
|
||||
? startProperty === 'top'
|
||||
? root.clientHeight
|
||||
: root.clientWidth
|
||||
: viewportLength
|
||||
return clamp(containerLength - inset, 0, Number.POSITIVE_INFINITY)
|
||||
}
|
||||
|
||||
function updateVerticalThumbPosition(scrollTop: number) {
|
||||
const { scrollRange, thumbRange } = verticalMetrics
|
||||
thumbTop.value
|
||||
@@ -448,6 +470,7 @@ useEventListener(wrapRef, 'scroll', handleScroll, { passive: true })
|
||||
useEventListener(dragDocument, 'pointermove', handleDocumentPointerMove)
|
||||
useEventListener(dragDocument, ['pointerup', 'pointercancel'], handleDocumentPointerUp)
|
||||
useEventListener(rootRef, 'mouseenter', handleRootMouseEnter)
|
||||
useEventListener(rootRef, 'mousemove', activateScrollbar, { passive: true })
|
||||
useEventListener(rootRef, 'mouseleave', handleRootMouseLeave)
|
||||
useEventListener(verticalTrackRef, 'mouseenter', () => handleTrackMouseEnter('vertical'))
|
||||
useEventListener(verticalTrackRef, 'mouseleave', () => handleTrackMouseLeave('vertical'))
|
||||
@@ -471,7 +494,7 @@ defineExpose({
|
||||
:style="{ maxHeight, height }"
|
||||
>
|
||||
<div ref="wrap" :class="wrapClasses" tabindex="0">
|
||||
<div ref="view" :class="viewClass">
|
||||
<div ref="view">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,15 +503,14 @@ defineExpose({
|
||||
v-show="canScrollY"
|
||||
ref="verticalTrack"
|
||||
aria-hidden="true"
|
||||
class="absolute top-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
|
||||
class="base-scrollbar-track-y absolute right-0 bottom-0 z-40 flex w-3 justify-end transition-opacity duration-200"
|
||||
:class="[
|
||||
verticalTrackInsetClass,
|
||||
verticalTrackClass,
|
||||
verticalScrollbarVisible ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
:style="thumbStyle"
|
||||
@pointerdown="handleThumbPointerDown"
|
||||
@@ -499,15 +521,14 @@ defineExpose({
|
||||
v-show="canScrollX"
|
||||
ref="horizontalTrack"
|
||||
aria-hidden="true"
|
||||
class="absolute z-40 h-1.5 rounded-full transition-opacity duration-200"
|
||||
class="base-scrollbar-track-x absolute bottom-0 left-0 z-40 flex h-3 items-end transition-opacity duration-200"
|
||||
:class="[
|
||||
horizontalTrackInsetClass,
|
||||
horizontalTrackClass,
|
||||
horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
:style="horizontalThumbStyle"
|
||||
@pointerdown="handleHorizontalThumbPointerDown"
|
||||
@@ -526,4 +547,8 @@ defineExpose({
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.base-scrollbar-track-y {
|
||||
top: var(--cp-scrollbar-track-top, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface SegmentedOption {
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, nextTick } from 'vue'
|
||||
|
||||
type SegmentedSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SegmentedSize = 'default' | 'input'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
options: SegmentedOption[]
|
||||
disabled?: boolean
|
||||
iconOnly?: boolean
|
||||
display?: 'label' | 'icon'
|
||||
size?: SegmentedSize
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
iconOnly: false,
|
||||
size: 'default',
|
||||
display: 'label',
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -31,73 +34,106 @@ const activeIndex = computed(() => {
|
||||
const index = props.options.findIndex(option => option.value === model.value)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
|
||||
}))
|
||||
|
||||
const isInputSize = computed(() => props.size === 'input')
|
||||
|
||||
const indicatorStyle = computed(() => ({
|
||||
width: isInputSize.value
|
||||
? `calc((100% - 6px) / ${optionCount.value})`
|
||||
: `calc((100% - 8px) / ${optionCount.value})`,
|
||||
width: `calc((100% - 6px) / ${optionCount.value})`,
|
||||
transform: `translateX(${activeIndex.value * 100}%)`,
|
||||
}))
|
||||
|
||||
const rootClasses = computed(() => isInputSize.value
|
||||
? 'h-(--cp-input-height-default) rounded-(--cp-input-radius-base) p-0.75'
|
||||
: 'h-9 rounded-(--cp-icon-button-radius) p-1')
|
||||
const rootClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-cp-control-sm',
|
||||
md: 'h-cp-control-md',
|
||||
lg: 'h-cp-control-lg',
|
||||
}
|
||||
const optionClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-6.5 px-2 text-[11px]',
|
||||
md: 'h-8 px-3 text-xs',
|
||||
lg: 'h-9.5 px-4 text-[13px]',
|
||||
}
|
||||
|
||||
const indicatorClasses = computed(() => isInputSize.value
|
||||
? 'inset-y-0.75 left-0.75 rounded-(--cp-input-radius-base)'
|
||||
: 'top-1 bottom-1 left-1 rounded-(--cp-input-radius-base)')
|
||||
function enabledIndexes() {
|
||||
return props.options.flatMap((option, index) => option.disabled ? [] : [index])
|
||||
}
|
||||
|
||||
const optionHeightClass = computed(() => isInputSize.value ? 'h-8' : 'h-7')
|
||||
|
||||
function selectOption(value: string) {
|
||||
if (props.disabled || value === model.value)
|
||||
function selectOption(index: number) {
|
||||
const option = props.options[index]
|
||||
if (props.disabled || !option || option.disabled || option.value === model.value)
|
||||
return
|
||||
model.value = value
|
||||
model.value = option.value
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
const indexes = enabledIndexes()
|
||||
if (indexes.length === 0)
|
||||
return
|
||||
|
||||
const current = indexes.indexOf(index)
|
||||
let nextIndex: number | undefined
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown')
|
||||
nextIndex = indexes[(current + 1 + indexes.length) % indexes.length]
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
|
||||
nextIndex = indexes[(current - 1 + indexes.length) % indexes.length]
|
||||
else if (event.key === 'Home')
|
||||
nextIndex = indexes[0]
|
||||
else if (event.key === 'End')
|
||||
nextIndex = indexes[indexes.length - 1]
|
||||
else
|
||||
return
|
||||
|
||||
if (nextIndex === undefined)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
selectOption(nextIndex)
|
||||
await nextTick()
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-grid items-center bg-(--cp-bg-muted)"
|
||||
:class="rootClasses"
|
||||
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
|
||||
:class="rootClasses[size]"
|
||||
:style="gridStyle"
|
||||
role="tablist"
|
||||
role="radiogroup"
|
||||
:aria-label="label"
|
||||
:aria-disabled="disabled || undefined"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200 ease-out"
|
||||
:class="indicatorClasses"
|
||||
v-if="options.length > 0"
|
||||
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:style="indicatorStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
v-for="option in options"
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
class="relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-emphasis transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
|
||||
:class="[
|
||||
optionHeightClass,
|
||||
optionClasses[size],
|
||||
model === option.value
|
||||
? 'text-(--cp-text-primary)'
|
||||
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)',
|
||||
disabled ? 'cursor-not-allowed opacity-60 hover:text-(--cp-text-secondary)' : undefined,
|
||||
iconOnly ? 'px-0' : undefined,
|
||||
? 'text-cp-primary'
|
||||
: 'text-cp-secondary hover:text-cp-primary',
|
||||
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
|
||||
display === 'icon' ? 'px-0' : undefined,
|
||||
]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="model === option.value"
|
||||
:aria-label="iconOnly ? option.label : undefined"
|
||||
:title="iconOnly ? option.label : undefined"
|
||||
:disabled="disabled"
|
||||
@click="selectOption(option.value)"
|
||||
role="radio"
|
||||
:aria-checked="model === option.value"
|
||||
:aria-label="display === 'icon' ? option.label : undefined"
|
||||
:title="display === 'icon' ? option.label : undefined"
|
||||
:tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1"
|
||||
:disabled="disabled || option.disabled"
|
||||
@click="selectOption(index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
|
||||
<span v-if="!iconOnly">{{ option.label }}</span>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span v-if="display === 'label'" class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
|
||||
import { computed, inject, nextTick, ref, useAttrs, useId, watch } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
interface SelectOption {
|
||||
type SelectSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SelectSize = 'default' | 'compact'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
@@ -24,19 +26,18 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'default',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
placeholder: '请选择',
|
||||
emptyText: '暂无选项',
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -44,7 +45,26 @@ const popoverRef = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const popoverStyle = ref<CSSProperties>({})
|
||||
const selectId = `base-select-${Math.random().toString(36).slice(2)}`
|
||||
const selectId = `base-select-${useId()}`
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : (field?.controlId.value ?? selectId))
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const triggerAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeConfig: Record<
|
||||
SelectSize,
|
||||
@@ -54,37 +74,44 @@ const sizeConfig: Record<
|
||||
icon: number
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
md: {
|
||||
trigger:
|
||||
'h-(--cp-input-height-default) px-3.5 pr-9 text-[13px] rounded-(--cp-input-radius-base)',
|
||||
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
|
||||
option: 'h-8.5 px-3 text-[13px]',
|
||||
icon: 16,
|
||||
},
|
||||
compact: {
|
||||
trigger: 'h-8 px-2.5 pr-7 text-xs rounded-(--cp-input-radius-base)',
|
||||
sm: {
|
||||
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
|
||||
option: 'h-8 px-2.5 text-xs',
|
||||
icon: 14,
|
||||
},
|
||||
lg: {
|
||||
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
|
||||
option: 'h-10 px-3.5 text-[14px]',
|
||||
icon: 17,
|
||||
},
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => props.options.find(option => option.value === model.value))
|
||||
|
||||
const triggerClasses = computed(() => [
|
||||
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
sizeConfig[props.size].trigger,
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
|
||||
: open.value
|
||||
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-(--cp-text-primary) shadow-(--cp-shadow-input-focus)'
|
||||
: [
|
||||
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-(--cp-text-primary)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
|
||||
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-(--cp-shadow-input-focus)',
|
||||
],
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
: invalid.value
|
||||
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
|
||||
: open.value
|
||||
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
|
||||
: [
|
||||
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
|
||||
],
|
||||
])
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 flex flex-col gap-1 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1 shadow-(--cp-shadow-popover)',
|
||||
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
|
||||
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
|
||||
])
|
||||
|
||||
@@ -228,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
|
||||
function optionClasses(option: SelectOption, index: number) {
|
||||
return [
|
||||
'flex w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors',
|
||||
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
|
||||
sizeConfig[props.size].option,
|
||||
option.disabled
|
||||
? 'cursor-not-allowed bg-transparent text-(--cp-disabled-text)'
|
||||
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
|
||||
: option.value === model.value
|
||||
? 'cursor-pointer bg-(--cp-info-bg) text-(--cp-info-text)'
|
||||
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
|
||||
: activeIndex.value === index
|
||||
? 'cursor-pointer bg-(--cp-default-bg-hover) text-(--cp-text-primary)'
|
||||
: 'cursor-pointer bg-transparent text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover)',
|
||||
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
|
||||
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -263,19 +290,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="relative inline-block text-left"
|
||||
v-bind="attrs"
|
||||
v-bind="rootAttrs"
|
||||
>
|
||||
<button
|
||||
:id="selectId"
|
||||
v-bind="triggerAttrs"
|
||||
:id="controlId"
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
:class="triggerClasses"
|
||||
:disabled="disabled"
|
||||
role="combobox"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="`${selectId}-listbox`"
|
||||
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
@click="toggleMenu"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
@@ -286,21 +316,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
|
||||
:class="
|
||||
disabled
|
||||
? 'text-(--cp-disabled-icon)'
|
||||
? 'text-cp-disabled-icon'
|
||||
: open
|
||||
? 'rotate-180 text-(--cp-info)'
|
||||
: 'text-(--cp-text-muted)'
|
||||
? 'rotate-180 text-cp-info'
|
||||
: 'text-cp-muted-text'
|
||||
"
|
||||
:size="sizeConfig[size].icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
|
||||
enter-from-class="-translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
@@ -311,11 +342,11 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
:class="popoverClasses"
|
||||
:style="popoverStyle"
|
||||
role="listbox"
|
||||
:aria-labelledby="selectId"
|
||||
:aria-labelledby="controlId"
|
||||
>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="flex h-8.5 items-center rounded-(--cp-input-radius-small) px-3 text-[13px] leading-none font-emphasis text-(--cp-text-muted)"
|
||||
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
@@ -338,8 +369,9 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-(--cp-info)"
|
||||
:size="size === 'compact' ? 13 : 15"
|
||||
class="shrink-0 text-cp-info"
|
||||
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -3,57 +3,53 @@ import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
showLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
'group inline-flex rounded-full border-0 bg-transparent p-0 outline-none transition-[background-color,box-shadow,opacity] duration-180',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
props.disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
|
||||
])
|
||||
|
||||
const trackClasses = computed(() => [
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180',
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-(--cp-disabled-bg) shadow-none'
|
||||
? 'bg-cp-disabled shadow-none'
|
||||
: model.value
|
||||
? 'bg-(--cp-info) shadow-(--cp-shadow-control) group-hover:bg-(--cp-info-hover)'
|
||||
: 'bg-(--cp-bg-muted) shadow-(--cp-shadow-control) group-hover:bg-(--cp-default-bg-active)',
|
||||
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
|
||||
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
|
||||
])
|
||||
|
||||
const thumbClasses = computed(() => [
|
||||
'size-5 rounded-full bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-180 ease-out',
|
||||
model.value ? 'translate-x-5' : 'translate-x-0',
|
||||
])
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled)
|
||||
return
|
||||
|
||||
model.value = !model.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="model"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
:class="buttonClasses"
|
||||
@click="toggle"
|
||||
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex items-center gap-2.5"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'"
|
||||
>
|
||||
<span :class="trackClasses">
|
||||
<span :class="thumbClasses" />
|
||||
<input
|
||||
v-model="model"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
class="peer sr-only"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span
|
||||
:class="trackClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
|
||||
:class="model ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { BaseTablePagination } from './pagination'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import BaseSelect from '../BaseSelect.vue'
|
||||
import {
|
||||
|
||||
getCurrentPage,
|
||||
getPagerItems,
|
||||
getPageSizeOptions,
|
||||
@@ -49,21 +49,12 @@ function goToPage(page: number) {
|
||||
emit('pageChange', 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-bold leading-none transition-colors duration-150 outline-none',
|
||||
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold 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)',
|
||||
? 'cursor-default bg-cp-accent text-cp-accent-on'
|
||||
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -73,7 +64,7 @@ function paginationPageClass(page: number) {
|
||||
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-emphasis text-(--cp-text-secondary)"
|
||||
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
|
||||
</div>
|
||||
@@ -84,28 +75,27 @@ function paginationPageClass(page: number) {
|
||||
aria-label="每页条数"
|
||||
:options="pageSizeOptions"
|
||||
:disabled="loading"
|
||||
size="compact"
|
||||
size="sm"
|
||||
class="w-28"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(loading || currentPage <= 1)"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading || currentPage <= 1"
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
label="上一页"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
</BaseIconButton>
|
||||
|
||||
<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-bold text-(--cp-text-muted)"
|
||||
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
|
||||
>
|
||||
...
|
||||
…
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@@ -119,16 +109,15 @@ function paginationPageClass(page: number) {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(loading || currentPage >= totalPages)"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading || currentPage >= totalPages"
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
label="下一页"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,113 +1,235 @@
|
||||
import { clamp, sumBy } from 'es-toolkit'
|
||||
|
||||
// 行类型保持结构化约束;动态列 key 的取值统一走 cellValue。
|
||||
export type TableRow = object
|
||||
|
||||
export type TableColumnKind
|
||||
= | 'text'
|
||||
| 'identity'
|
||||
| 'meta'
|
||||
| 'status'
|
||||
| 'numeric'
|
||||
| 'datetime'
|
||||
| 'mono'
|
||||
| 'index'
|
||||
| 'selection'
|
||||
| 'expander'
|
||||
| 'actions'
|
||||
| 'custom'
|
||||
|
||||
export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
|
||||
export type TableColumnAlign = 'left' | 'right' | 'center'
|
||||
type TableColumnSticky = 'left' | 'right'
|
||||
|
||||
export interface BaseTableColumn<Row extends TableRow = TableRow> {
|
||||
key: string
|
||||
label?: string
|
||||
sortable?: boolean
|
||||
sortKey?: string
|
||||
kind?: TableColumnKind
|
||||
size?: TableColumnSize
|
||||
align?: TableColumnAlign
|
||||
sortable?: boolean | string
|
||||
format?: (value: unknown, row: Row) => unknown
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
maxWidth?: number | string
|
||||
flex?: number
|
||||
fixed?: 'left' | 'right' | false
|
||||
align?: 'left' | 'right' | 'center'
|
||||
ellipsis?: boolean
|
||||
emptyText?: string
|
||||
headerClass?: string
|
||||
cellClass?: string
|
||||
}
|
||||
|
||||
type BaseTableSortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface BaseTableSort {
|
||||
key: string
|
||||
direction: BaseTableSortDirection
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type ResolvedTableColumn<Row extends TableRow = TableRow> = Omit<
|
||||
BaseTableColumn<Row>,
|
||||
'fixed'
|
||||
> & {
|
||||
fixed?: 'left' | 'right'
|
||||
resolvedWidth: string
|
||||
resolvedMinWidth?: string
|
||||
resolvedMaxWidth?: string
|
||||
export interface BaseTableProps<Row extends TableRow> {
|
||||
columns: BaseTableColumn<Row>[]
|
||||
rows: Row[]
|
||||
rowKey?: string | ((row: Row, index: number) => string | number)
|
||||
selectedRowKeys?: Array<string | number>
|
||||
expandedRowKeys?: Array<string | number>
|
||||
density?: 'compact' | 'default'
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
sort?: BaseTableSort
|
||||
}
|
||||
|
||||
interface ColumnRecipe {
|
||||
size: TableColumnSize
|
||||
basisWidth?: number
|
||||
align: TableColumnAlign
|
||||
truncate: boolean
|
||||
contentClass?: string
|
||||
paddingClass?: string
|
||||
sticky?: TableColumnSticky
|
||||
}
|
||||
|
||||
const columnWidths: Record<TableColumnSize, number> = {
|
||||
'xs': 64,
|
||||
'sm': 88,
|
||||
'md': 112,
|
||||
'lg': 144,
|
||||
'xl': 184,
|
||||
'2xl': 240,
|
||||
'3xl': 288,
|
||||
}
|
||||
|
||||
const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
|
||||
text: {
|
||||
size: '2xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
},
|
||||
identity: {
|
||||
size: '2xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
},
|
||||
meta: {
|
||||
size: 'lg',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
contentClass: 'text-cp-secondary',
|
||||
},
|
||||
status: {
|
||||
size: 'md',
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
},
|
||||
numeric: {
|
||||
size: 'md',
|
||||
align: 'right',
|
||||
truncate: false,
|
||||
contentClass: 'font-mono tabular-nums text-cp-secondary',
|
||||
},
|
||||
datetime: {
|
||||
size: 'xl',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
|
||||
},
|
||||
mono: {
|
||||
size: 'xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
contentClass: 'font-mono text-[12px] font-emphasis',
|
||||
},
|
||||
index: {
|
||||
size: 'xs',
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
contentClass: 'font-mono tabular-nums text-cp-secondary',
|
||||
},
|
||||
selection: {
|
||||
size: 'xs',
|
||||
basisWidth: 48,
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
paddingClass: 'px-2',
|
||||
sticky: 'left',
|
||||
},
|
||||
expander: {
|
||||
size: 'xs',
|
||||
basisWidth: 40,
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
paddingClass: 'px-2',
|
||||
sticky: 'left',
|
||||
},
|
||||
actions: {
|
||||
size: 'md',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
paddingClass: 'px-3',
|
||||
sticky: 'right',
|
||||
},
|
||||
custom: {
|
||||
size: 'lg',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
},
|
||||
}
|
||||
|
||||
export interface ResolvedTableColumn<Row extends TableRow = TableRow>
|
||||
extends BaseTableColumn<Row> {
|
||||
kind: TableColumnKind
|
||||
basisWidth: number
|
||||
align: TableColumnAlign
|
||||
truncate: boolean
|
||||
contentClass?: string
|
||||
paddingClass?: string
|
||||
sticky?: TableColumnSticky
|
||||
stickyOffset?: number
|
||||
}
|
||||
|
||||
export function defineTableColumns<Row extends TableRow>(columns: BaseTableColumn<Row>[]) {
|
||||
return columns
|
||||
}
|
||||
|
||||
export function resolveColumns<Row extends TableRow>(
|
||||
columns: BaseTableColumn<Row>[],
|
||||
minWidth?: number | string,
|
||||
): ResolvedTableColumn<Row>[] {
|
||||
const actionColumnIndex = columns.findIndex(column => column.key === 'actions')
|
||||
const fixedColumnIndex = actionColumnIndex >= 0 ? actionColumnIndex : 0
|
||||
const fixedPercentTotal = sumBy(columns, column =>
|
||||
column.width === undefined ? 0 : numericPercentWidth(column.width))
|
||||
const fixedPixelTotal = sumBy(columns, column =>
|
||||
column.width === undefined ? 0 : numericPixelWidth(column.width))
|
||||
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
|
||||
const flexibleColumns = columns.filter(column => column.width === undefined)
|
||||
const flexTotal = sumBy(flexibleColumns, column => column.flex ?? 1)
|
||||
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
|
||||
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
return columns.map((column, index) => {
|
||||
const flex = column.flex ?? 1
|
||||
const automaticallyFixed
|
||||
= index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined
|
||||
const width
|
||||
= column.width === undefined
|
||||
? minWidthPixels > 0 && fixedPercentTotal === 0
|
||||
? flexTotal > 0
|
||||
? `${(availablePixels * flex) / flexTotal}px`
|
||||
: `${availablePixels / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}px`
|
||||
: flexTotal > 0
|
||||
? `${(available * flex) / flexTotal}%`
|
||||
: `${available / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}%`
|
||||
: normalizeWidth(column.width)
|
||||
const resolved = columns.map((column): ResolvedTableColumn<Row> => {
|
||||
const kind = column.kind ?? 'text'
|
||||
const recipe = columnRecipes[kind]
|
||||
const basisWidth = recipe.basisWidth ?? columnWidths[column.size ?? recipe.size]
|
||||
|
||||
return {
|
||||
...column,
|
||||
fixed: column.fixed === false ? undefined : (column.fixed ?? automaticallyFixed),
|
||||
resolvedWidth: width,
|
||||
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
|
||||
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
|
||||
kind,
|
||||
basisWidth,
|
||||
align: column.align ?? recipe.align,
|
||||
truncate: recipe.truncate,
|
||||
contentClass: recipe.contentClass,
|
||||
paddingClass: recipe.paddingClass,
|
||||
sticky: recipe.sticky,
|
||||
}
|
||||
})
|
||||
|
||||
let leftOffset = 0
|
||||
for (const column of resolved) {
|
||||
if (column.sticky !== 'left')
|
||||
continue
|
||||
column.stickyOffset = leftOffset
|
||||
leftOffset += column.basisWidth
|
||||
}
|
||||
|
||||
let rightOffset = 0
|
||||
for (const column of [...resolved].reverse()) {
|
||||
if (column.sticky !== 'right')
|
||||
continue
|
||||
column.stickyOffset = rightOffset
|
||||
rightOffset += column.basisWidth
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function normalizeWidth(width: number | string) {
|
||||
return typeof width === 'number' ? `${width}px` : width
|
||||
export function minimumTableWidth<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
|
||||
return columns.reduce((total, column) => total + column.basisWidth, 0)
|
||||
}
|
||||
|
||||
export function columnStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
export function tableStyle<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
|
||||
return { width: `max(100%, ${minimumTableWidth(columns)}px)` }
|
||||
}
|
||||
|
||||
export function columnStyle<Row extends TableRow>(
|
||||
column: ResolvedTableColumn<Row>,
|
||||
columns: ResolvedTableColumn<Row>[],
|
||||
) {
|
||||
const tableWidth = minimumTableWidth(columns)
|
||||
const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
|
||||
|
||||
return {
|
||||
width: column.resolvedWidth,
|
||||
minWidth: column.resolvedMinWidth,
|
||||
maxWidth: column.resolvedMaxWidth,
|
||||
width: `${widthPercent}%`,
|
||||
minWidth: `${column.basisWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
export function tableStyle(minWidth?: number | string) {
|
||||
if (minWidth === undefined) {
|
||||
export function stickyStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (!column.sticky)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { width: `max(100%, ${normalizeWidth(minWidth)})` }
|
||||
return { [column.sticky]: `${column.stickyOffset ?? 0}px` }
|
||||
}
|
||||
|
||||
export function alignClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
if (column.align === 'center') {
|
||||
export function alignClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (column.align === 'center')
|
||||
return 'text-center'
|
||||
}
|
||||
|
||||
if (column.align === 'right') {
|
||||
if (column.align === 'right')
|
||||
return 'text-right'
|
||||
}
|
||||
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
@@ -125,21 +247,15 @@ export function cellDisplayValue<Row extends TableRow>(column: BaseTableColumn<R
|
||||
return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value
|
||||
}
|
||||
|
||||
export function cellTitle<Row extends TableRow>(column: BaseTableColumn<Row>, row: Row) {
|
||||
if (column.ellipsis === false || column.key === 'selection' || column.key === 'actions') {
|
||||
export function cellTitle<Row extends TableRow>(column: ResolvedTableColumn<Row>, row: Row) {
|
||||
if (!column.truncate)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const value = cellDisplayValue(column, row)
|
||||
if (isEmptyCellValue(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
|
||||
}
|
||||
|
||||
export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
if (column.key === 'selection') {
|
||||
export function cellContentClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (column.kind === 'selection' || column.kind === 'expander') {
|
||||
return [
|
||||
'flex min-w-0 items-center overflow-visible leading-none',
|
||||
column.align === 'right'
|
||||
@@ -149,36 +265,11 @@ export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<R
|
||||
: 'justify-start',
|
||||
]
|
||||
}
|
||||
|
||||
if (column.key === 'actions') {
|
||||
if (column.kind === 'actions')
|
||||
return 'min-w-0 overflow-visible'
|
||||
}
|
||||
|
||||
return ['min-w-0', column.ellipsis === false ? undefined : 'truncate']
|
||||
return ['min-w-0', column.truncate ? 'truncate' : undefined]
|
||||
}
|
||||
|
||||
function numericPercentWidth(width: number | string) {
|
||||
if (typeof width === 'number') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (width.endsWith('%')) {
|
||||
const parsed = Number.parseFloat(width)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function numericPixelWidth(width: number | string) {
|
||||
if (typeof width === 'number') {
|
||||
return width
|
||||
}
|
||||
|
||||
if (width.endsWith('px')) {
|
||||
const parsed = Number.parseFloat(width)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
export function columnSortKey<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
return typeof column.sortable === 'string' ? column.sortable : column.key
|
||||
}
|
||||
|
||||
@@ -1,154 +1,111 @@
|
||||
<script setup lang="ts" generic="Row extends object = Record<string, unknown>">
|
||||
import type { BaseTableColumn, BaseTableSort, ResolvedTableColumn } from './columns'
|
||||
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
|
||||
import type { BaseTableProps, BaseTableSort, ResolvedTableColumn } from './columns'
|
||||
|
||||
import { Triangle } from '@lucide/vue'
|
||||
import { computed, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
|
||||
import BaseEmpty from '../BaseEmpty.vue'
|
||||
import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import BaseTablePagination from './BaseTablePagination.vue'
|
||||
import {
|
||||
alignClass,
|
||||
|
||||
cellContentClass,
|
||||
cellDisplayValue,
|
||||
cellTitle,
|
||||
cellValue,
|
||||
columnSortKey,
|
||||
columnStyle,
|
||||
resolveColumns,
|
||||
|
||||
tableStyle as resolveTableStyle,
|
||||
stickyStyle,
|
||||
tableStyle,
|
||||
} from './columns'
|
||||
import { useHorizontalStickyShadow } from './useHorizontalStickyShadow'
|
||||
|
||||
type RowKey = string | ((row: Row, index: number) => string | number)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: BaseTableColumn<Row>[]
|
||||
rows: Row[]
|
||||
rowKey?: RowKey
|
||||
selectedRowKeys?: Array<string | number>
|
||||
stripe?: boolean
|
||||
compact?: boolean
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
minWidth?: number | string
|
||||
pagination?: BaseTablePaginationConfig
|
||||
expandedRowKeys?: Array<string | number>
|
||||
sort?: BaseTableSort
|
||||
}>(),
|
||||
{
|
||||
rowKey: 'id',
|
||||
selectedRowKeys: () => [],
|
||||
expandedRowKeys: () => [],
|
||||
stripe: true,
|
||||
compact: false,
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
maxHeight: undefined,
|
||||
minWidth: undefined,
|
||||
},
|
||||
)
|
||||
const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
|
||||
rowKey: 'id',
|
||||
selectedRowKeys: () => [],
|
||||
expandedRowKeys: () => [],
|
||||
density: 'default',
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
sort: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
pageSizeChange: [pageSize: number]
|
||||
sortChange: [sort: BaseTableSort | undefined]
|
||||
}>()
|
||||
const slots = useSlots()
|
||||
const computedColumns = computed(() => resolveColumns(props.columns))
|
||||
const resolvedTableStyle = computed(() => tableStyle(computedColumns.value))
|
||||
|
||||
const headerRowClass = computed(() => [
|
||||
props.compact ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
|
||||
'font-bold text-(--cp-text-muted)',
|
||||
])
|
||||
const bodyRowClass = computed(() => (props.compact ? 'h-10' : 'h-13'))
|
||||
const headerCellClass = computed(() => [
|
||||
'min-w-0 bg-(--cp-bg-subtle) first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]',
|
||||
props.compact ? 'px-3' : 'px-4',
|
||||
])
|
||||
const bodyCellClass = computed(() => [
|
||||
'min-w-0 first:pl-3 text-(--cp-text-primary) first:rounded-l-lg',
|
||||
props.compact ? 'px-3 text-[12px]' : 'px-4 text-[13px]',
|
||||
])
|
||||
|
||||
const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth))
|
||||
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
|
||||
const retainedRows = shallowRef<Row[]>([])
|
||||
watch(
|
||||
[() => props.rows, () => props.loading],
|
||||
([rows, loading]) => {
|
||||
if (rows.length > 0 || !loading) {
|
||||
if (rows.length > 0 || !loading)
|
||||
retainedRows.value = rows
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const displayRows = computed(() =>
|
||||
props.loading && props.rows.length === 0 ? retainedRows.value : props.rows,
|
||||
)
|
||||
const displayRows = computed(() => props.loading && props.rows.length === 0 ? retainedRows.value : props.rows)
|
||||
const hasRows = computed(() => displayRows.value.length > 0)
|
||||
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
|
||||
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
|
||||
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
|
||||
const { horizontalScrolled, horizontalCanScrollRight, handleTableScroll }
|
||||
= useHorizontalStickyShadow({
|
||||
hasRows,
|
||||
headerWrapRef,
|
||||
bodyScrollbarRef,
|
||||
tableViewRef,
|
||||
watchSources: () => [displayRows.value.length, props.minWidth],
|
||||
})
|
||||
|
||||
function fixedHeaderClass(column: ResolvedTableColumn<Row>) {
|
||||
if (!column.fixed) {
|
||||
return undefined
|
||||
const scrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('scrollbar')
|
||||
const tableRef = useTemplateRef<HTMLTableElement>('table')
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
|
||||
function measureHorizontalScroll() {
|
||||
const wrap = scrollbarRef.value?.wrapRef
|
||||
if (!wrap) {
|
||||
horizontalScrolled.value = false
|
||||
horizontalCanScrollRight.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const showShadow
|
||||
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
|
||||
return [
|
||||
'sticky z-30 bg-(--cp-bg-subtle)',
|
||||
column.fixed === 'left' ? 'left-0' : 'right-0',
|
||||
showShadow
|
||||
? column.fixed === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
]
|
||||
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
horizontalScrolled.value = wrap.scrollLeft > 1
|
||||
horizontalCanScrollRight.value = wrap.scrollLeft < range - 1
|
||||
}
|
||||
|
||||
function fixedBodyClass(column: ResolvedTableColumn<Row>, row: Row, index: number) {
|
||||
if (!column.fixed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const showShadow
|
||||
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
|
||||
return [
|
||||
'sticky z-20',
|
||||
column.fixed === 'left' ? 'left-0' : 'right-0',
|
||||
showShadow
|
||||
? column.fixed === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
isRowSelected(row, index)
|
||||
? 'bg-(--cp-bg-tertiary)'
|
||||
: props.stripe && index % 2 === 1
|
||||
? 'bg-(--cp-bg-subtle)'
|
||||
: 'bg-(--cp-bg-surface)',
|
||||
]
|
||||
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
|
||||
const wrap = scrollbarRef.value?.wrapRef
|
||||
if (!wrap)
|
||||
return
|
||||
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
horizontalScrolled.value = payload.scrollLeft > 1
|
||||
horizontalCanScrollRight.value = payload.scrollLeft < range - 1
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
})
|
||||
useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), measureHorizontalScroll)
|
||||
watch([() => displayRows.value.length, () => props.columns], async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
})
|
||||
|
||||
const headerRowClass = computed(() => [
|
||||
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
|
||||
'font-bold text-cp-secondary',
|
||||
])
|
||||
const bodyRowClass = computed(() =>
|
||||
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
|
||||
)
|
||||
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
|
||||
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
|
||||
const bodyCellFrameClass = computed(() => props.density === 'compact'
|
||||
? 'border-y-2 border-transparent bg-clip-padding'
|
||||
: 'border-y-[3px] border-transparent bg-clip-padding')
|
||||
const firstRowTopGapClass = computed(() =>
|
||||
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
|
||||
)
|
||||
const bodyCellContentClass = computed(() => props.density === 'compact'
|
||||
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
|
||||
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
|
||||
|
||||
function getRowKey(row: Row, index: number) {
|
||||
if (typeof props.rowKey === 'function') {
|
||||
if (typeof props.rowKey === 'function')
|
||||
return props.rowKey(row, index)
|
||||
}
|
||||
|
||||
const value = cellValue(row, props.rowKey)
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : index
|
||||
}
|
||||
@@ -161,46 +118,56 @@ function isRowExpanded(row: Row, index: number) {
|
||||
return props.expandedRowKeys.includes(getRowKey(row, index))
|
||||
}
|
||||
|
||||
function rowClass(row: Row, index: number) {
|
||||
function rowBackgroundClass(row: Row, index: number) {
|
||||
if (isRowSelected(row, index))
|
||||
return 'bg-cp-tertiary-bg'
|
||||
if (index % 2 === 1)
|
||||
return 'bg-cp-subtle'
|
||||
return 'bg-cp-surface'
|
||||
}
|
||||
|
||||
function rowClass() {
|
||||
return [
|
||||
bodyRowClass.value,
|
||||
'hover:[&>td]:bg-(--cp-default-bg-hover)',
|
||||
props.stripe && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : undefined,
|
||||
isRowSelected(row, index) ? 'bg-(--cp-bg-tertiary)' : undefined,
|
||||
'hover:[&>td]:bg-cp-default-hover',
|
||||
]
|
||||
}
|
||||
|
||||
function isLastColumn(index: number) {
|
||||
return index === computedColumns.value.length - 1
|
||||
function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
|
||||
if (!column.sticky)
|
||||
return undefined
|
||||
const showShadow = column.sticky === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
return [
|
||||
'sticky',
|
||||
header ? 'z-40' : 'z-20',
|
||||
showShadow
|
||||
? column.sticky === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
]
|
||||
}
|
||||
|
||||
function bodyCellTitle(column: BaseTableColumn<Row>, row: Row) {
|
||||
function bodyCellTitle(column: ResolvedTableColumn<Row>, row: Row) {
|
||||
return slots[column.key] ? undefined : cellTitle(column, row)
|
||||
}
|
||||
|
||||
function columnSortKey(column: BaseTableColumn<Row>) {
|
||||
return column.sortKey || column.key
|
||||
}
|
||||
|
||||
function columnSortDirection(column: BaseTableColumn<Row>) {
|
||||
function columnSortDirection(column: ResolvedTableColumn<Row>) {
|
||||
return props.sort?.key === columnSortKey(column) ? props.sort.direction : undefined
|
||||
}
|
||||
|
||||
function toggleColumnSort(column: BaseTableColumn<Row>) {
|
||||
function toggleColumnSort(column: ResolvedTableColumn<Row>) {
|
||||
const key = columnSortKey(column)
|
||||
const direction = columnSortDirection(column)
|
||||
if (!direction) {
|
||||
if (!direction)
|
||||
emit('sortChange', { key, direction: 'asc' })
|
||||
}
|
||||
else if (direction === 'asc') {
|
||||
else if (direction === 'asc')
|
||||
emit('sortChange', { key, direction: 'desc' })
|
||||
}
|
||||
else {
|
||||
else
|
||||
emit('sortChange', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function columnAriaSort(column: BaseTableColumn<Row>) {
|
||||
function columnAriaSort(column: ResolvedTableColumn<Row>) {
|
||||
const direction = columnSortDirection(column)
|
||||
if (direction === 'asc')
|
||||
return 'ascending'
|
||||
@@ -209,7 +176,7 @@ function columnAriaSort(column: BaseTableColumn<Row>) {
|
||||
return column.sortable ? 'none' : undefined
|
||||
}
|
||||
|
||||
function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
function sortButtonLabel(column: ResolvedTableColumn<Row>) {
|
||||
const direction = columnSortDirection(column)
|
||||
if (!direction)
|
||||
return `${column.label || column.key}:升序排列`
|
||||
@@ -221,126 +188,101 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="relative flex min-h-0 max-w-full flex-1 overflow-hidden"
|
||||
>
|
||||
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div ref="headerWrap" class="max-w-full overflow-hidden">
|
||||
<table
|
||||
class="w-full shrink-0 table-fixed border-separate text-left"
|
||||
:class="compact ? 'border-spacing-y-0.5' : 'border-spacing-y-1'"
|
||||
:style="tableViewStyle"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column)"
|
||||
>
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr :class="headerRowClass">
|
||||
<th
|
||||
v-for="(column, index) in computedColumns"
|
||||
:key="column.key"
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
:class="[
|
||||
headerCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(index) ? 'rounded-r-lg pr-3' : undefined,
|
||||
fixedHeaderClass(column),
|
||||
column.headerClass,
|
||||
]"
|
||||
scope="col"
|
||||
:aria-sort="columnAriaSort(column)"
|
||||
>
|
||||
<div :class="cellContentClass(column)">
|
||||
<button
|
||||
v-if="column.sortable"
|
||||
type="button"
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-(--cp-text-primary) focus-visible:text-(--cp-info)"
|
||||
:aria-label="sortButtonLabel(column)"
|
||||
:title="sortButtonLabel(column)"
|
||||
@click="toggleColumnSort(column)"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex shrink-0 -translate-y-px flex-col gap-px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Triangle
|
||||
class="size-1.25 fill-current"
|
||||
:class="
|
||||
columnSortDirection(column) === 'asc'
|
||||
? 'text-(--cp-info)'
|
||||
: 'text-(--cp-text-tertiary)'
|
||||
"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
<Triangle
|
||||
class="size-1.25 rotate-180 fill-current"
|
||||
:class="
|
||||
columnSortDirection(column) === 'desc'
|
||||
? 'text-(--cp-info)'
|
||||
: 'text-(--cp-text-tertiary)'
|
||||
"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<slot v-else :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BaseScrollbar
|
||||
v-if="hasRows"
|
||||
ref="bodyScrollbar"
|
||||
class="min-h-0 flex-1"
|
||||
horizontal
|
||||
:max-height="maxHeight"
|
||||
track-inset="none"
|
||||
@scroll="handleTableScroll"
|
||||
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
v-if="hasRows"
|
||||
ref="scrollbar"
|
||||
class="min-h-0 flex-1"
|
||||
:class="density === 'compact'
|
||||
? '[--cp-scrollbar-track-top:2.25rem]'
|
||||
: '[--cp-scrollbar-track-top:2.75rem]'"
|
||||
horizontal
|
||||
@scroll="handleTableScroll"
|
||||
>
|
||||
<table
|
||||
ref="table"
|
||||
class="table-fixed border-separate border-spacing-0 text-left"
|
||||
:style="resolvedTableStyle"
|
||||
>
|
||||
<table
|
||||
ref="tableView"
|
||||
class="w-full table-fixed border-separate text-left"
|
||||
:class="compact ? 'border-spacing-y-1' : 'border-spacing-y-2'"
|
||||
:style="tableViewStyle"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column, computedColumns)"
|
||||
>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr :class="headerRowClass">
|
||||
<th
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column)"
|
||||
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
|
||||
:class="[
|
||||
column.paddingClass ?? cellPaddingClass,
|
||||
alignClass(column),
|
||||
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
|
||||
stickyClass(column, true),
|
||||
]"
|
||||
:style="stickyStyle(column)"
|
||||
scope="col"
|
||||
:aria-sort="columnAriaSort(column)"
|
||||
>
|
||||
</colgroup>
|
||||
|
||||
<tbody>
|
||||
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
|
||||
<tr :class="rowClass(row, index)">
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
bodyCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
|
||||
fixedBodyClass(column, row, index),
|
||||
column.cellClass,
|
||||
]"
|
||||
<div :class="cellContentClass(column)">
|
||||
<button
|
||||
v-if="column.sortable"
|
||||
type="button"
|
||||
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
|
||||
:aria-label="sortButtonLabel(column)"
|
||||
:title="sortButtonLabel(column)"
|
||||
@click="toggleColumnSort(column)"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot :name="`header-${column.key}`" :column="column">{{ column.label }}</slot>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
|
||||
<Triangle
|
||||
class="size-1.25 fill-current"
|
||||
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
<Triangle
|
||||
class="size-1.25 rotate-180 fill-current"
|
||||
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<slot v-else :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
|
||||
<tr :class="rowClass()" :aria-selected="isRowSelected(row, index) || undefined">
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
class="min-w-0"
|
||||
:class="[
|
||||
column.paddingClass ?? cellPaddingClass,
|
||||
bodyTextClass,
|
||||
bodyCellFrameClass,
|
||||
column.contentClass,
|
||||
alignClass(column),
|
||||
index === 0 ? firstRowTopGapClass : undefined,
|
||||
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
|
||||
stickyClass(column),
|
||||
rowBackgroundClass(row, index),
|
||||
]"
|
||||
:style="stickyStyle(column)"
|
||||
>
|
||||
<div class="grid content-center" :class="bodyCellContentClass">
|
||||
<div :class="cellContentClass(column)" :title="bodyCellTitle(column, row)">
|
||||
<slot
|
||||
:name="column.key"
|
||||
@@ -352,33 +294,25 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
{{ cellDisplayValue(column, row) }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="isRowExpanded(row, index)">
|
||||
<td
|
||||
:colspan="computedColumns.length"
|
||||
class="rounded-(--cp-input-radius-base) bg-(--cp-info-bg) p-0"
|
||||
>
|
||||
<slot name="expanded" :row="row" :index="index" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseScrollbar>
|
||||
|
||||
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
|
||||
<BaseEmpty v-if="!loading" :title="emptyText" plain class="w-full max-w-80" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="isRowExpanded(row, index)">
|
||||
<td
|
||||
:colspan="computedColumns.length"
|
||||
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
|
||||
:class="bodyCellFrameClass"
|
||||
>
|
||||
<slot name="expanded" :row="row" :index="index" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseScrollbar>
|
||||
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
|
||||
<BaseEmpty v-if="!loading" :title="emptyText" surface="none" class="w-full max-w-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseTablePagination
|
||||
v-if="pagination"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
@page-change="emit('pageChange', $event)"
|
||||
@page-size-change="emit('pageSizeChange', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type BaseScrollbar from '../BaseScrollbar.vue'
|
||||
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { nextTick, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
interface UseHorizontalStickyShadowOptions {
|
||||
hasRows: Ref<boolean>
|
||||
headerWrapRef: Readonly<Ref<HTMLDivElement | null>>
|
||||
bodyScrollbarRef: Readonly<Ref<InstanceType<typeof BaseScrollbar> | null>>
|
||||
tableViewRef: Readonly<Ref<HTMLTableElement | null>>
|
||||
watchSources: () => unknown[]
|
||||
}
|
||||
|
||||
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
let horizontalScrollRange = 0
|
||||
let scrollLeftPosition = 0
|
||||
|
||||
function maxScrollLeft(wrap: HTMLElement) {
|
||||
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
}
|
||||
|
||||
function scrollWrap() {
|
||||
return options.bodyScrollbarRef.value?.wrapRef ?? null
|
||||
}
|
||||
|
||||
function resetHorizontalScrollbar() {
|
||||
horizontalScrollRange = 0
|
||||
scrollLeftPosition = 0
|
||||
horizontalScrolled.value = false
|
||||
horizontalCanScrollRight.value = false
|
||||
syncHeaderScroll(0)
|
||||
}
|
||||
|
||||
function syncHeaderScroll(scrollLeft: number) {
|
||||
const headerWrap = options.headerWrapRef.value
|
||||
if (headerWrap && headerWrap.scrollLeft !== scrollLeft) {
|
||||
headerWrap.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function updateHorizontalScrollPosition(scrollLeft: number) {
|
||||
horizontalScrolled.value = scrollLeft > 0
|
||||
horizontalCanScrollRight.value = scrollLeft < horizontalScrollRange - 1
|
||||
}
|
||||
|
||||
function measureHorizontalScrollbar() {
|
||||
const wrap = scrollWrap()
|
||||
if (!wrap || !options.hasRows.value) {
|
||||
resetHorizontalScrollbar()
|
||||
return
|
||||
}
|
||||
|
||||
const scrollRange = maxScrollLeft(wrap)
|
||||
if (scrollRange <= 0) {
|
||||
resetHorizontalScrollbar()
|
||||
return
|
||||
}
|
||||
|
||||
horizontalScrollRange = scrollRange
|
||||
scrollLeftPosition = wrap.scrollLeft
|
||||
syncHeaderScroll(scrollLeftPosition)
|
||||
updateHorizontalScrollPosition(scrollLeftPosition)
|
||||
}
|
||||
|
||||
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
|
||||
if (payload.scrollLeft === scrollLeftPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollLeftPosition = payload.scrollLeft
|
||||
syncHeaderScroll(scrollLeftPosition)
|
||||
updateHorizontalScrollPosition(scrollLeftPosition)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScrollbar()
|
||||
})
|
||||
|
||||
useResizeObserver(
|
||||
() =>
|
||||
[scrollWrap(), options.tableViewRef.value].filter(
|
||||
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
|
||||
),
|
||||
measureHorizontalScrollbar,
|
||||
)
|
||||
|
||||
watch(options.watchSources, async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScrollbar()
|
||||
})
|
||||
|
||||
return {
|
||||
horizontalScrolled,
|
||||
horizontalCanScrollRight,
|
||||
handleTableScroll,
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject, useAttrs } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
type TextareaSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
size?: TextareaSize
|
||||
name?: string
|
||||
ariaLabel?: string
|
||||
rows?: number
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
size: 'md',
|
||||
name: undefined,
|
||||
ariaLabel: undefined,
|
||||
rows: 5,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const controlAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeClasses: Record<TextareaSize, string> = {
|
||||
sm: 'h-28',
|
||||
md: 'h-40',
|
||||
lg: 'h-56',
|
||||
sm: 'px-3 py-2.5 text-xs',
|
||||
md: 'px-3.5 py-3 text-[13px]',
|
||||
lg: 'px-4 py-3.5 text-[14px]',
|
||||
}
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
'cp-scrollbar w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-(--cp-text-muted)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)',
|
||||
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
'cp-scrollbar w-full resize-y rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
|
||||
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
'leading-[1.55] font-emphasis',
|
||||
sizeClasses[props.size],
|
||||
'text-[13px] leading-[1.55] font-emphasis',
|
||||
props.error ? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)' : undefined,
|
||||
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div v-bind="rootAttrs" class="min-w-0">
|
||||
<textarea
|
||||
v-bind="controlAttrs"
|
||||
:id="controlId"
|
||||
v-model="model"
|
||||
:class="textareaClasses"
|
||||
:name="name"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:required="field?.required.value || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
/>
|
||||
<span v-if="error" class="text-xs leading-[1.15] font-emphasis text-(--cp-danger-text)">
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import { toast } from './toast'
|
||||
|
||||
const iconMap = {
|
||||
@@ -19,35 +20,37 @@ const titleMap = {
|
||||
|
||||
const colorClasses = {
|
||||
success: {
|
||||
iconBg: 'bg-(--cp-success-bg)',
|
||||
icon: 'text-(--cp-success)',
|
||||
iconBg: 'bg-cp-success-bg',
|
||||
icon: 'text-cp-success',
|
||||
},
|
||||
error: {
|
||||
iconBg: 'bg-(--cp-danger-bg)',
|
||||
icon: 'text-(--cp-danger)',
|
||||
iconBg: 'bg-cp-danger-bg',
|
||||
icon: 'text-cp-danger',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-(--cp-warning-bg)',
|
||||
icon: 'text-(--cp-warning)',
|
||||
iconBg: 'bg-cp-warning-bg',
|
||||
icon: 'text-cp-warning',
|
||||
},
|
||||
info: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-6 right-6 z-9999 flex flex-col gap-3 pointer-events-none">
|
||||
<div class="pointer-events-none fixed inset-x-3 top-3 z-9999 flex flex-col items-end gap-3 sm:inset-x-auto sm:top-6 sm:right-6">
|
||||
<TransitionGroup name="toast" tag="div" class="flex flex-col gap-3">
|
||||
<div
|
||||
<article
|
||||
v-for="message in toast.messages"
|
||||
:key="message.id"
|
||||
class="pointer-events-auto flex min-h-16 w-90 items-center gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-popover) transition-all"
|
||||
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
|
||||
:role="message.type === 'error' ? 'alert' : 'status'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center shrink-0 w-8.5 h-8.5 rounded-(--cp-icon-button-radius)"
|
||||
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
|
||||
:class="colorClasses[message.type].iconBg"
|
||||
>
|
||||
<component
|
||||
@@ -58,25 +61,25 @@ const colorClasses = {
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">
|
||||
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
|
||||
{{ message.title ?? titleMap[message.type] }}
|
||||
</p>
|
||||
<p
|
||||
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-(--cp-text-secondary)"
|
||||
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ message.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center shrink-0 p-0 border-0 bg-transparent w-7 h-7 rounded-(--cp-button-radius-base) cursor-pointer opacity-60 transition-opacity hover:bg-(--cp-bg-subtle) hover:opacity-100"
|
||||
:aria-label="`关闭${message.title ?? titleMap[message.type]}通知`"
|
||||
<BaseIconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
|
||||
@click="toast.remove(message.id)"
|
||||
>
|
||||
<X :size="16" class="text-(--cp-text-muted)" />
|
||||
</button>
|
||||
</div>
|
||||
<X :size="16" class="text-cp-muted-text" />
|
||||
</BaseIconButton>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -91,6 +94,13 @@ const colorClasses = {
|
||||
animation: toast-out 0.2s ease-in;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -28,7 +28,7 @@ const OVERLAY_BASE_CLASS = [
|
||||
'place-items-center',
|
||||
'rounded-[inherit]',
|
||||
].join(' ')
|
||||
const OVERLAY_MASK_CLASS = 'bg-(--cp-bg-surface)/82 backdrop-blur-[4px]'
|
||||
const OVERLAY_MASK_CLASS = 'bg-cp-surface/82 backdrop-blur-[4px]'
|
||||
const PANEL_CLASS = [
|
||||
'inline-flex',
|
||||
'min-h-10',
|
||||
@@ -41,7 +41,7 @@ const PANEL_CLASS = [
|
||||
'text-xs',
|
||||
'leading-[1.15]',
|
||||
'font-heavy',
|
||||
'text-(--cp-text-secondary)',
|
||||
'text-cp-secondary',
|
||||
].join(' ')
|
||||
const MARK_CLASS = [
|
||||
'inline-block',
|
||||
|
||||
@@ -49,19 +49,19 @@ const linkItems = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="关于" width="420px" hide-footer>
|
||||
<BaseModal v-model="open" title="关于" size="sm">
|
||||
<div class="grid gap-5">
|
||||
<section 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-subtle) font-mono text-[15px] leading-none font-extrabold text-(--cp-text-primary)"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-subtle font-mono text-[15px] leading-none font-extrabold text-cp-primary"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-[15px] leading-none font-heavy text-(--cp-text-primary)">
|
||||
<p class="m-0 truncate text-[15px] leading-none font-heavy text-cp-primary">
|
||||
{{ author }}
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-(--cp-text-secondary)">
|
||||
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-cp-secondary">
|
||||
Built by Zyy · Codex Proxy RS
|
||||
</p>
|
||||
</div>
|
||||
@@ -71,20 +71,20 @@ const linkItems = [
|
||||
<div
|
||||
v-for="item in linkItems"
|
||||
:key="item.label"
|
||||
class="group flex min-w-0 items-center justify-between gap-3 rounded-(--cp-input-radius-base) px-1 py-2.5 transition-colors hover:bg-(--cp-bg-subtle)"
|
||||
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp-control px-1 py-2.5 transition-colors hover:bg-cp-subtle"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-muted text-cp-primary"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-muted)">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-(--cp-text-primary)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-cp-primary"
|
||||
:title="item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
@@ -96,7 +96,7 @@ const linkItems = [
|
||||
:href="item.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) text-(--cp-text-muted) transition-colors hover:bg-(--cp-bg-muted) hover:text-(--cp-info)"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp-control text-cp-muted-text transition-colors hover:bg-cp-muted hover:text-cp-info"
|
||||
:aria-label="`打开 ${item.label}`"
|
||||
>
|
||||
<ExternalLink class="size-3.5" />
|
||||
@@ -104,7 +104,7 @@ const linkItems = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-(--cp-text-muted)">
|
||||
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-cp-muted-text">
|
||||
{{ versionLine }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
@@ -328,7 +328,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<aside
|
||||
ref="sidebarEl"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar)"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-cp-surface px-4 shadow-cp-sidebar"
|
||||
:class="[
|
||||
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
||||
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
|
||||
@@ -341,7 +341,7 @@ onBeforeUnmount(() => {
|
||||
<BaseMotionIcon
|
||||
aria-hidden="true"
|
||||
variant="brand"
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius)"
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
|
||||
>
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
@@ -355,27 +355,27 @@ onBeforeUnmount(() => {
|
||||
ref="brandLabelEl"
|
||||
class="grid min-w-33 content-center overflow-hidden"
|
||||
>
|
||||
<strong class="text-base leading-[1.1] font-heavy text-(--cp-text-primary)">
|
||||
<strong class="text-base leading-[1.1] font-heavy text-cp-primary">
|
||||
Codex Proxy
|
||||
</strong>
|
||||
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 text-xs leading-none font-emphasis text-(--cp-text-secondary)">
|
||||
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-secondary">
|
||||
Rust build
|
||||
</span>
|
||||
<button
|
||||
v-if="hasVersionLabel"
|
||||
type="button"
|
||||
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-control-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)',
|
||||
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
|
||||
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
|
||||
]"
|
||||
:title="updateButtonLabel"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<span>{{ versionLabel }}</span>
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-cp-success" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
@@ -388,7 +388,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-(--cp-icon-button-radius) bg-(--cp-bg-nav-active) transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
:style="activeNavIndicatorStyle"
|
||||
>
|
||||
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
|
||||
@@ -397,16 +397,16 @@ onBeforeUnmount(() => {
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
||||
:class="[
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? navFeedbackMuted
|
||||
? 'bg-transparent font-bold text-(--cp-text-primary) transition-none'
|
||||
: 'bg-transparent font-bold text-(--cp-text-primary) transition-colors duration-200'
|
||||
? 'bg-transparent font-bold text-cp-primary transition-none'
|
||||
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
|
||||
: navFeedbackMuted
|
||||
? 'bg-transparent font-semibold text-(--cp-text-secondary) transition-none'
|
||||
: 'bg-transparent font-semibold text-(--cp-text-secondary) transition-colors duration-200 hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
|
||||
? 'bg-transparent font-semibold text-cp-secondary transition-none'
|
||||
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
|
||||
]"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
@@ -422,88 +422,81 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
|
||||
<div
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
class="bg-cp-subtle"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'grid gap-1 rounded-(--cp-icon-button-radius) p-1'
|
||||
: 'flex h-11 items-center justify-between rounded-(--cp-panel-radius) px-2'
|
||||
? 'grid gap-1 rounded-cp-control p-1'
|
||||
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="!isCollapsed"
|
||||
class="inline-flex whitespace-nowrap h-7 items-center gap-1.5 rounded-lg bg-(--cp-success-bg) px-2.5 text-xs leading-none font-emphasis text-(--cp-success-text)"
|
||||
class="inline-flex whitespace-nowrap h-7 items-center gap-1.5 rounded-lg bg-cp-success-bg px-2.5 text-xs leading-none font-emphasis text-cp-success-text"
|
||||
>
|
||||
<i class="size-1.5 rounded-full bg-(--cp-success)" />
|
||||
<i class="size-1.5 rounded-full bg-cp-success" />
|
||||
在线
|
||||
</span>
|
||||
|
||||
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
<BaseIconButton
|
||||
v-if="isCollapsed && hasUpdate"
|
||||
icon-only
|
||||
:variant="hasUpdate ? 'success' : 'ghost'"
|
||||
size="default"
|
||||
variant="success"
|
||||
size="md"
|
||||
:label="updateButtonLabel"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<ArrowUpCircle :size="19" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
<BaseIconButton
|
||||
:size="isCollapsed ? 'md' : 'sm'"
|
||||
label="退出登录"
|
||||
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
|
||||
variant="destructive"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'md' : 'sm'"
|
||||
:label="themeToggleLabel"
|
||||
@click="toggleTheme($event)"
|
||||
>
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
|
||||
<Moon v-else :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'md' : 'sm'"
|
||||
label="关于"
|
||||
@click="emit('openAbout')"
|
||||
>
|
||||
<Info :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
<BaseIconButton
|
||||
v-if="mobile"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="关闭侧边栏"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<PanelLeftClose :size="18" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
<BaseIconButton
|
||||
v-else
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'md' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<PanelLeftOpen v-if="isCollapsed" :size="19" />
|
||||
<PanelLeftClose v-else :size="18" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@ onScopeDispose(() => {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-cp-control border-0 bg-cp-surface text-cp-primary shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-default-hover hover:text-cp-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-page"
|
||||
:class="
|
||||
isDragging
|
||||
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
|
||||
|
||||
@@ -55,55 +55,55 @@ const statusView = computed(() => {
|
||||
return {
|
||||
label: '重启中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
iconClass: 'text-(--cp-info)',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updating.value) {
|
||||
return {
|
||||
label: '更新中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
iconClass: 'text-(--cp-info)',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updateError.value || updateInfo.value?.warning) {
|
||||
return {
|
||||
label: '异常',
|
||||
icon: XCircle,
|
||||
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
iconClass: 'text-(--cp-danger)',
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
iconClass: 'text-cp-danger',
|
||||
}
|
||||
}
|
||||
if (updateSuccess.value) {
|
||||
return {
|
||||
label: '已更新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (hasUpdate.value) {
|
||||
return {
|
||||
label: '有新版本',
|
||||
icon: ArrowUpCircle,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (updateInfo.value) {
|
||||
return {
|
||||
label: '已是最新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
|
||||
iconClass: 'text-(--cp-text-muted)',
|
||||
badge: 'bg-cp-muted text-cp-secondary',
|
||||
iconClass: 'text-cp-muted-text',
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,22 +193,22 @@ function formatLogTime(value: string) {
|
||||
|
||||
function logMarkerClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-(--cp-success)'
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-(--cp-warning)'
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-(--cp-danger)'
|
||||
return 'text-(--cp-info)'
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-info'
|
||||
}
|
||||
|
||||
function logTextClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-(--cp-success)'
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-(--cp-warning)'
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-(--cp-danger)'
|
||||
return 'text-(--cp-text-primary)'
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-primary'
|
||||
}
|
||||
|
||||
function normalizeUpdateVersion(value: unknown) {
|
||||
@@ -327,20 +327,18 @@ watch(
|
||||
v-model="open"
|
||||
title="系统更新"
|
||||
description="检查版本、查看发布说明并执行在线更新"
|
||||
variant="success"
|
||||
width="820px"
|
||||
body-max-height="72dvh"
|
||||
body-view-class="pr-3"
|
||||
:close-disabled="updating || restarting"
|
||||
tone="success"
|
||||
size="lg"
|
||||
:dismissible="!updating && !restarting"
|
||||
>
|
||||
<div class="grid gap-3.5">
|
||||
<section class="grid gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
|
||||
<section class="grid gap-4 rounded-cp-surface bg-cp-subtle px-4 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-muted)">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
|
||||
Codex Proxy RS
|
||||
</p>
|
||||
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-(--cp-text-primary)">
|
||||
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-primary">
|
||||
应用包更新
|
||||
</p>
|
||||
</div>
|
||||
@@ -357,10 +355,10 @@ watch(
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.key"
|
||||
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
|
||||
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||
<p class="m-0 truncate text-[11px] leading-none font-heavy text-(--cp-text-muted)">
|
||||
<p class="m-0 truncate text-[11px] leading-none font-heavy text-cp-muted-text">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<a
|
||||
@@ -368,14 +366,14 @@ watch(
|
||||
:href="item.releaseUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-(--cp-info-text) transition-colors hover:text-(--cp-info)"
|
||||
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-cp-info-text transition-colors hover:text-cp-info"
|
||||
>
|
||||
发布页
|
||||
<ExternalLink class="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-(--cp-text-primary)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-cp-primary"
|
||||
:title="item.title || item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
@@ -385,7 +383,7 @@ watch(
|
||||
|
||||
<p
|
||||
v-if="updateError || updateInfo?.warning"
|
||||
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2 text-[12px] leading-normal font-bold text-(--cp-danger-text)"
|
||||
class="m-0 rounded-cp-control bg-cp-danger-bg px-3 py-2 text-[12px] leading-normal font-bold text-cp-danger-text"
|
||||
>
|
||||
{{ updateError || updateInfo?.warning }}
|
||||
</p>
|
||||
@@ -393,39 +391,41 @@ watch(
|
||||
|
||||
<section
|
||||
v-if="renderedReleaseNotes"
|
||||
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
class="grid gap-2 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="m-0 text-[13px] font-heavy text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[13px] font-heavy text-cp-primary">
|
||||
发布说明
|
||||
</p>
|
||||
<span class="font-mono text-[11px] font-emphasis text-(--cp-text-muted)">
|
||||
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
|
||||
<div class="release-notes" v-html="renderedReleaseNotes" />
|
||||
<BaseScrollbar class="-mx-4" max-height="160px">
|
||||
<div class="px-4">
|
||||
<div class="release-notes" v-html="renderedReleaseNotes" />
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="showUpdateProgress"
|
||||
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
|
||||
class="overflow-hidden rounded-cp-surface bg-cp-subtle"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
|
||||
<p class="m-0 text-[13px] leading-none font-heavy text-(--cp-text-primary)">
|
||||
<Terminal class="size-4 shrink-0 text-cp-success" />
|
||||
<p class="m-0 text-[13px] leading-none font-heavy text-cp-primary">
|
||||
更新进度
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-(--cp-bg-subtle) px-2 text-[11px] leading-none font-bold text-(--cp-text-secondary)"
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
|
||||
:title="updateStreamError || streamStatusLabel"
|
||||
>
|
||||
<i
|
||||
class="size-1.5 rounded-full"
|
||||
:class="updateStreaming ? 'bg-(--cp-success)' : 'bg-(--cp-text-muted)'"
|
||||
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
|
||||
/>
|
||||
{{ streamStatusLabel }}
|
||||
</span>
|
||||
@@ -435,40 +435,42 @@ watch(
|
||||
v-if="updateLogRows.length"
|
||||
ref="updateLogScrollbar"
|
||||
height="260px"
|
||||
view-class="min-h-full px-4 pb-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div class="grid min-h-full gap-2 px-4 pb-4">
|
||||
<div
|
||||
v-for="log in updateLogRows"
|
||||
:key="log.id"
|
||||
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2 font-mono text-[11px] leading-[1.55]"
|
||||
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp-control bg-cp-surface px-3 py-2 font-mono text-[11px] leading-[1.55]"
|
||||
>
|
||||
<span class="tabular-nums text-(--cp-text-muted)">{{ log.time }}</span>
|
||||
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
|
||||
<Circle
|
||||
class="mt-1 size-2.5"
|
||||
:class="logMarkerClass(log.level)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
|
||||
<span v-if="log.step" class="mr-1 text-(--cp-text-muted)">[{{ log.step }}]</span>
|
||||
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
|
||||
{{ log.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
<div v-else class="grid h-30 place-items-center px-4 pb-4">
|
||||
<BaseEmpty title="暂无进度" :icon="Terminal" compact plain />
|
||||
<BaseEmpty title="暂无进度" :icon="Terminal" size="sm" surface="none" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
:loading="checking"
|
||||
:disabled="loading || updating || restarting"
|
||||
@click="handleCheckUpdates(true)"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
@@ -476,7 +478,7 @@ watch(
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="updateSuccess && needRestart"
|
||||
variant="success"
|
||||
variant="primary"
|
||||
:loading="restarting"
|
||||
:disabled="updating"
|
||||
@click="handleRestart"
|
||||
@@ -488,7 +490,7 @@ watch(
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
variant="success"
|
||||
variant="primary"
|
||||
:loading="preparingUpdate || updating"
|
||||
:disabled="!canUpdate || preparingUpdate"
|
||||
@click="handleUpdateRequest"
|
||||
@@ -505,30 +507,29 @@ watch(
|
||||
v-model="updateConfirmOpen"
|
||||
title="发现新的更新版本"
|
||||
description="检测到远端 latest 与当前显示的目标版本不一致"
|
||||
variant="warning"
|
||||
confirm-text="确认更新"
|
||||
:loading="updating"
|
||||
:confirm-disabled="!updateConfirmInfo?.latestVersion"
|
||||
@confirm="handleConfirmUpdate"
|
||||
>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) p-3">
|
||||
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
|
||||
<div
|
||||
v-for="item in updateConfirmRows"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<span class="text-[12px] leading-none font-bold text-(--cp-text-muted)">
|
||||
<span class="text-[12px] leading-none font-bold text-cp-muted-text">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
class="truncate font-mono text-[13px] leading-none font-heavy text-(--cp-text-primary)"
|
||||
class="truncate font-mono text-[13px] leading-none font-heavy text-cp-primary"
|
||||
>
|
||||
{{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-(--cp-text-muted)">
|
||||
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-cp-muted-text">
|
||||
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
|
||||
</p>
|
||||
</div>
|
||||
@@ -603,7 +604,7 @@ watch(
|
||||
.release-notes :deep(pre) {
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--cp-input-radius-base);
|
||||
border-radius: var(--cp-radius-control);
|
||||
background: var(--cp-bg-surface);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex h-dvh overflow-hidden bg-(--cp-bg-page)">
|
||||
<div class="relative flex h-dvh overflow-hidden bg-cp-page">
|
||||
<AppSidebar
|
||||
:collapsed="sidebarCollapsed"
|
||||
@toggle="toggleSidebar"
|
||||
@@ -78,14 +78,13 @@ watch(
|
||||
@open-system-update="openSystemUpdate"
|
||||
/>
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
ref="pageScrollbarRef"
|
||||
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar ref="pageScrollbarRef">
|
||||
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
@import './tokens.css';
|
||||
@import './base.css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
@@ -14,11 +14,79 @@
|
||||
--color-cp-surface: var(--cp-bg-surface);
|
||||
--color-cp-subtle: var(--cp-bg-subtle);
|
||||
--color-cp-muted: var(--cp-bg-muted);
|
||||
--color-cp-tertiary-bg: var(--cp-bg-tertiary);
|
||||
--color-cp-nav-active: var(--cp-bg-nav-active);
|
||||
--color-cp-primary: var(--cp-text-primary);
|
||||
--color-cp-strong: var(--cp-text-strong);
|
||||
--color-cp-secondary: var(--cp-text-secondary);
|
||||
--color-cp-tertiary: var(--cp-text-tertiary);
|
||||
--color-cp-muted-text: var(--cp-text-muted);
|
||||
--color-cp-accent: var(--cp-accent);
|
||||
--color-cp-accent-hover: var(--cp-accent-hover);
|
||||
--color-cp-accent-pressed: var(--cp-accent-pressed);
|
||||
--color-cp-accent-border: var(--cp-accent-border);
|
||||
--color-cp-accent-on: var(--cp-accent-on);
|
||||
--color-cp-default-hover: var(--cp-default-bg-hover);
|
||||
--color-cp-default-active: var(--cp-default-bg-active);
|
||||
--color-cp-default-border: var(--cp-default-border);
|
||||
--color-cp-default-border-hover: var(--cp-default-border-hover);
|
||||
--color-cp-border: var(--cp-border-primary);
|
||||
--color-cp-divider: var(--cp-divider-subtle);
|
||||
--color-cp-progress: var(--cp-progress-track);
|
||||
--color-cp-overlay: var(--cp-overlay-scrim);
|
||||
--color-cp-disabled: var(--cp-disabled-bg);
|
||||
--color-cp-disabled-text: var(--cp-disabled-text);
|
||||
--color-cp-disabled-icon: var(--cp-disabled-icon);
|
||||
--color-cp-info: var(--cp-info);
|
||||
--color-cp-info-hover: var(--cp-info-hover);
|
||||
--color-cp-info-pressed: var(--cp-info-pressed);
|
||||
--color-cp-info-bg: var(--cp-info-bg);
|
||||
--color-cp-info-border: var(--cp-info-border);
|
||||
--color-cp-info-text: var(--cp-info-text);
|
||||
--color-cp-info-on: var(--cp-info-on);
|
||||
--color-cp-normal: var(--cp-normal);
|
||||
--color-cp-normal-hover: var(--cp-normal-hover);
|
||||
--color-cp-normal-pressed: var(--cp-normal-pressed);
|
||||
--color-cp-normal-bg: var(--cp-normal-bg);
|
||||
--color-cp-normal-border: var(--cp-normal-border);
|
||||
--color-cp-normal-text: var(--cp-normal-text);
|
||||
--color-cp-success: var(--cp-success);
|
||||
--color-cp-success-bg: var(--cp-success-bg);
|
||||
--color-cp-success-bg-hover: var(--cp-success-bg-hover);
|
||||
--color-cp-success-bg-active: var(--cp-success-bg-active);
|
||||
--color-cp-success-border: var(--cp-success-border);
|
||||
--color-cp-success-text: var(--cp-success-text);
|
||||
--color-cp-warning: var(--cp-warning);
|
||||
--color-cp-warning-bg: var(--cp-warning-bg);
|
||||
--color-cp-warning-bg-hover: var(--cp-warning-bg-hover);
|
||||
--color-cp-warning-bg-active: var(--cp-warning-bg-active);
|
||||
--color-cp-warning-border: var(--cp-warning-border);
|
||||
--color-cp-warning-text: var(--cp-warning-text);
|
||||
--color-cp-danger: var(--cp-danger);
|
||||
--color-cp-danger-bg: var(--cp-danger-bg);
|
||||
--color-cp-danger-bg-hover: var(--cp-danger-bg-hover);
|
||||
--color-cp-danger-bg-active: var(--cp-danger-bg-active);
|
||||
--color-cp-danger-border: var(--cp-danger-border);
|
||||
--color-cp-danger-text: var(--cp-danger-text);
|
||||
--color-cp-plan-pro-bg: var(--cp-plan-pro-bg);
|
||||
--color-cp-plan-pro-text: var(--cp-plan-pro-text);
|
||||
--color-cp-plan-prolite-bg: var(--cp-plan-prolite-bg);
|
||||
--color-cp-plan-prolite-text: var(--cp-plan-prolite-text);
|
||||
--radius-cp-control-sm: var(--cp-radius-control-sm);
|
||||
--radius-cp-control: var(--cp-radius-control);
|
||||
--radius-cp-overlay: var(--cp-radius-overlay);
|
||||
--radius-cp-surface: var(--cp-radius-surface);
|
||||
--shadow-cp-card: var(--cp-shadow-card);
|
||||
--shadow-cp-control: var(--cp-shadow-control);
|
||||
--shadow-cp-popover: var(--cp-shadow-popover);
|
||||
--shadow-cp-input: var(--cp-shadow-input);
|
||||
--shadow-cp-input-hover: var(--cp-shadow-input-hover);
|
||||
--shadow-cp-input-focus: var(--cp-shadow-input-focus);
|
||||
--shadow-cp-input-error: var(--cp-shadow-input-error);
|
||||
--shadow-cp-sidebar: var(--cp-shadow-sidebar);
|
||||
--spacing-cp-control-sm: var(--cp-control-height-sm);
|
||||
--spacing-cp-control-md: var(--cp-control-height-md);
|
||||
--spacing-cp-control-lg: var(--cp-control-height-lg);
|
||||
--spacing-cp-table-row-min: var(--cp-table-row-min-height);
|
||||
--spacing-cp-table-row-min-compact: var(--cp-table-row-min-height-compact);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
--cp-text-secondary: #64748b;
|
||||
--cp-text-tertiary: #94a3b8;
|
||||
--cp-text-muted: #94a3b8;
|
||||
--cp-accent-primary: #2563eb;
|
||||
--cp-brand-accent: #2563eb;
|
||||
--cp-accent: #2563eb;
|
||||
--cp-accent-hover: #1d4ed8;
|
||||
--cp-accent-pressed: #1e40af;
|
||||
--cp-accent-border: #93c5fd;
|
||||
--cp-accent-on: #ffffff;
|
||||
--cp-reasoning: #8b5cf6;
|
||||
--cp-plan-pro-bg: #ede9fe;
|
||||
--cp-plan-pro-text: #5b21b6;
|
||||
@@ -80,15 +85,15 @@
|
||||
--cp-danger-bg-active: #fecaca;
|
||||
--cp-danger-border: #fca5a5;
|
||||
--cp-danger-text: #b91c1c;
|
||||
--cp-input-height-default: 38px;
|
||||
--cp-input-radius-small: 6px;
|
||||
--cp-input-radius-base: 8px;
|
||||
--cp-popover-radius: 4px;
|
||||
--cp-checkbox-radius: 5px;
|
||||
--cp-button-radius-base: 6px;
|
||||
--cp-icon-button-radius: 10px;
|
||||
--cp-card-radius: 18px;
|
||||
--cp-panel-radius: 16px;
|
||||
--cp-control-height-sm: 32px;
|
||||
--cp-control-height-md: 38px;
|
||||
--cp-control-height-lg: 44px;
|
||||
--cp-table-row-min-height: 60px;
|
||||
--cp-table-row-min-height-compact: 40px;
|
||||
--cp-radius-control-sm: 6px;
|
||||
--cp-radius-control: 8px;
|
||||
--cp-radius-overlay: 12px;
|
||||
--cp-radius-surface: 18px;
|
||||
|
||||
--cp-shadow-card: 0 10px 22px -18px #0e172614;
|
||||
--cp-shadow-control: 0 9px 18px -14px #0e172616;
|
||||
@@ -122,7 +127,12 @@ html[data-theme='dark'] {
|
||||
--cp-text-secondary: #8fa0b7;
|
||||
--cp-text-tertiary: #71839c;
|
||||
--cp-text-muted: #64748b;
|
||||
--cp-accent-primary: #6ea8ff;
|
||||
--cp-brand-accent: #6ea8ff;
|
||||
--cp-accent: #3b82f6;
|
||||
--cp-accent-hover: #4f8ee8;
|
||||
--cp-accent-pressed: #2563eb;
|
||||
--cp-accent-border: #2e5e92;
|
||||
--cp-accent-on: var(--cp-text-primary);
|
||||
--cp-reasoning: #a78bfa;
|
||||
--cp-plan-pro-bg: #35136f;
|
||||
--cp-plan-pro-text: #ddd6fe;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AccountGroup } from '@/api'
|
||||
import { Pencil, Power, Trash2 } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
|
||||
defineProps<{
|
||||
group: AccountGroup
|
||||
@@ -18,17 +18,15 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="编辑分组"
|
||||
@click.stop="emit('edit', group)"
|
||||
>
|
||||
<Pencil class="size-3.5 text-(--cp-info)" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<Pencil class="size-3.5 text-cp-info" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="group.enabled ? '禁用分组' : '启用分组'"
|
||||
@@ -37,18 +35,17 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Power
|
||||
class="size-3.5"
|
||||
:class="group.enabled ? 'text-(--cp-warning)' : 'text-(--cp-success)'"
|
||||
:class="group.enabled ? 'text-cp-warning' : 'text-cp-success'"
|
||||
/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
icon-only
|
||||
</BaseIconButton>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="删除分组"
|
||||
:disabled="deleting"
|
||||
@click.stop="emit('delete', group)"
|
||||
>
|
||||
<Trash2 class="size-3.5 text-(--cp-danger)" />
|
||||
</BaseButton>
|
||||
<Trash2 class="size-3.5 text-cp-danger" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,7 @@ const status = defineModel<string>('status', { required: true })
|
||||
placeholder="搜索分组名称..."
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4.5 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
<BaseSelect
|
||||
@@ -39,7 +39,7 @@ const status = defineModel<string>('status', { required: true })
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
|
||||
<BaseButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="danger"
|
||||
variant="destructive"
|
||||
:disabled="batchDeleting"
|
||||
@click="emit('deleteSelected')"
|
||||
>
|
||||
|
||||
@@ -32,8 +32,8 @@ const description = computed(() => props.group
|
||||
v-model="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
width="560px"
|
||||
:close-disabled="saving"
|
||||
size="md"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<BaseForm class="grid gap-5">
|
||||
<BaseFormItem label="分组名称" required>
|
||||
@@ -56,7 +56,7 @@ const description = computed(() => props.group
|
||||
<BaseTextarea
|
||||
v-model="form.description"
|
||||
aria-label="分组描述"
|
||||
size="sm"
|
||||
:rows="4"
|
||||
placeholder="说明这个分组的用途..."
|
||||
:disabled="saving"
|
||||
/>
|
||||
|
||||
@@ -27,11 +27,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const toneClasses: Record<MetricTone, string> = {
|
||||
primary: 'text-(--cp-text-primary)',
|
||||
secondary: 'text-(--cp-text-secondary)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
warning: 'text-(--cp-warning)',
|
||||
active: 'text-(--cp-success)',
|
||||
primary: 'text-cp-primary',
|
||||
secondary: 'text-cp-secondary',
|
||||
success: 'text-cp-success-text',
|
||||
warning: 'text-cp-warning',
|
||||
active: 'text-cp-success',
|
||||
}
|
||||
|
||||
const metrics = computed<MetricItem[]>(() => {
|
||||
@@ -82,7 +82,7 @@ const metrics = computed<MetricItem[]>(() => {
|
||||
class="m-0 grid grid-cols-[auto_auto] justify-start gap-x-2 gap-y-0.5 text-xs leading-4"
|
||||
>
|
||||
<template v-for="metric in metrics" :key="metric.label">
|
||||
<dt class="text-(--cp-text-muted)">
|
||||
<dt class="text-cp-muted-text">
|
||||
{{ metric.label }}
|
||||
</dt>
|
||||
<dd class="m-0 font-mono font-semibold tabular-nums" :class="toneClasses[metric.tone]">
|
||||
|
||||
@@ -1,76 +1,17 @@
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { BaseTableColumn } from '@/components/base/BaseTable/columns'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
|
||||
const cellClass = 'py-2 align-middle'
|
||||
|
||||
export const accountGroupColumns = [
|
||||
{
|
||||
key: 'selection',
|
||||
label: '',
|
||||
width: '48px',
|
||||
minWidth: '48px',
|
||||
align: 'center' as const,
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'identity',
|
||||
label: '分组',
|
||||
minWidth: '250px',
|
||||
flex: 1.2,
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
label: '颜色',
|
||||
width: '76px',
|
||||
minWidth: '76px',
|
||||
align: 'center' as const,
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: '状态',
|
||||
width: '100px',
|
||||
minWidth: '100px',
|
||||
align: 'center' as const,
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'accountCount',
|
||||
label: '账号数',
|
||||
width: '150px',
|
||||
minWidth: '150px',
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: '容量',
|
||||
width: '112px',
|
||||
minWidth: '112px',
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: '用量',
|
||||
width: '144px',
|
||||
minWidth: '144px',
|
||||
cellClass,
|
||||
},
|
||||
{
|
||||
key: 'updatedAtDisplay',
|
||||
label: '更新时间',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: `${cellClass} font-mono text-[12px] text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '136px',
|
||||
minWidth: '136px',
|
||||
cellClass,
|
||||
},
|
||||
] satisfies BaseTableColumn<AccountGroup>[]
|
||||
export const accountGroupColumns = defineTableColumns<AccountGroup>([
|
||||
{ key: 'selection', kind: 'selection' },
|
||||
{ key: 'identity', label: '分组', kind: 'identity' },
|
||||
{ key: 'color', label: '颜色', kind: 'status', size: 'sm' },
|
||||
{ key: 'enabled', label: '状态', kind: 'status' },
|
||||
{ key: 'accountCount', label: '账号数', kind: 'custom' },
|
||||
{ key: 'capacity', label: '容量', kind: 'custom', size: 'md' },
|
||||
{ key: 'usage', label: '用量', kind: 'custom' },
|
||||
{ key: 'updatedAtDisplay', label: '更新时间', kind: 'datetime' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg' },
|
||||
])
|
||||
|
||||
export const accountGroupStatusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { usePageSelection } from '@/composables/usePageSelection'
|
||||
import AccountGroupActions from './components/AccountGroupActions.vue'
|
||||
@@ -59,7 +60,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
|
||||
<BaseCard
|
||||
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
|
||||
body-class="mt-3 flex min-h-0 flex-1"
|
||||
>
|
||||
<template #header>
|
||||
<AccountGroupFilters
|
||||
@@ -73,87 +73,91 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="accountGroupColumns"
|
||||
:rows="groups"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:pagination="pagination"
|
||||
empty-text="暂无分组,请点击创建分组创建"
|
||||
min-width="1200px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页分组"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择分组"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #identity="{ row }">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<strong class="truncate text-[13px] text-(--cp-text-primary)">
|
||||
{{ row.name }}
|
||||
</strong>
|
||||
<span class="truncate text-[11px] font-emphasis text-(--cp-text-muted)">
|
||||
{{ row.description || '未填写描述' }}
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="accountGroupColumns"
|
||||
:rows="groups"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
empty-text="暂无分组,请点击创建分组创建"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页分组"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择分组"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #identity="{ row }">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<strong class="truncate text-[13px] text-cp-primary">
|
||||
{{ row.name }}
|
||||
</strong>
|
||||
<span class="truncate text-[11px] font-emphasis text-cp-muted-text">
|
||||
{{ row.description || '未填写描述' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #color="{ row }">
|
||||
<span
|
||||
class="mx-auto block size-4 rounded-sm"
|
||||
:style="{ backgroundColor: row.color }"
|
||||
:title="row.color"
|
||||
:aria-label="`分组颜色 ${row.color}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #enabled="{ row }">
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded-lg px-2 text-[11px] font-bold"
|
||||
:class="row.enabled
|
||||
? 'bg-cp-success-bg text-cp-success-text'
|
||||
: 'bg-cp-muted text-cp-muted-text'"
|
||||
>
|
||||
{{ row.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #color="{ row }">
|
||||
<span
|
||||
class="mx-auto block size-4 rounded-sm"
|
||||
:style="{ backgroundColor: row.color }"
|
||||
:title="row.color"
|
||||
:aria-label="`分组颜色 ${row.color}`"
|
||||
/>
|
||||
</template>
|
||||
<template #accountCount="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="accounts" />
|
||||
</template>
|
||||
|
||||
<template #enabled="{ row }">
|
||||
<span
|
||||
class="inline-flex h-6 items-center rounded-lg px-2 text-[11px] font-bold"
|
||||
:class="row.enabled
|
||||
? 'bg-(--cp-success-bg) text-(--cp-success-text)'
|
||||
: 'bg-(--cp-bg-muted) text-(--cp-text-muted)'"
|
||||
>
|
||||
{{ row.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #capacity="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="capacity" />
|
||||
</template>
|
||||
|
||||
<template #accountCount="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="accounts" />
|
||||
</template>
|
||||
<template #usage="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="usage" />
|
||||
</template>
|
||||
|
||||
<template #capacity="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="capacity" />
|
||||
</template>
|
||||
|
||||
<template #usage="{ row }">
|
||||
<AccountGroupMetricsCell :group="row" kind="usage" />
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<AccountGroupActions
|
||||
:group="row"
|
||||
:deleting="deleting"
|
||||
:updating-status="updatingStatusGroupIds.has(row.id)"
|
||||
@edit="openEdit"
|
||||
@toggle="requestToggle"
|
||||
@delete="requestDelete"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<template #actions="{ row }">
|
||||
<AccountGroupActions
|
||||
:group="row"
|
||||
:deleting="deleting"
|
||||
:updating-status="updatingStatusGroupIds.has(row.id)"
|
||||
@edit="openEdit"
|
||||
@toggle="requestToggle"
|
||||
@delete="requestDelete"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
@@ -169,7 +173,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
v-model="showBatchDeleteModal"
|
||||
title="确认删除"
|
||||
description="删除后这些分组将立即失效,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="batchDeleting"
|
||||
@confirm="confirmBatchDelete"
|
||||
@@ -183,7 +187,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
v-model="showDisableModal"
|
||||
title="禁用账号分组"
|
||||
description="禁用后,使用该分组的 API 密钥将无法再使用其中的账号。"
|
||||
variant="warning"
|
||||
confirm-text="确认禁用"
|
||||
:loading="disabling"
|
||||
@confirm="confirmDisable"
|
||||
@@ -191,10 +194,10 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
<p class="m-0">
|
||||
确定禁用“{{ pendingDisableGroup?.name }}”吗?
|
||||
</p>
|
||||
<p v-if="referencedKeyNames.length > 0" class="mt-2 mb-0 text-(--cp-warning-text)">
|
||||
<p v-if="referencedKeyNames.length > 0" class="mt-2 mb-0 text-cp-warning-text">
|
||||
将影响 {{ referencedKeyNames.length }} 个 API 密钥:{{ referencedKeyNames.join('、') }}
|
||||
</p>
|
||||
<p v-else-if="pendingDisableGroup?.clientKeyCount" class="mt-2 mb-0 text-(--cp-warning-text)">
|
||||
<p v-else-if="pendingDisableGroup?.clientKeyCount" class="mt-2 mb-0 text-cp-warning-text">
|
||||
将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥。
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
@@ -203,7 +206,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
v-model="showDeleteModal"
|
||||
title="删除账号分组"
|
||||
description="删除后该分组将立即失效,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
|
||||
@@ -31,12 +31,12 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model="open"
|
||||
title="批量编辑账号"
|
||||
:description="`统一调整选中的 ${selectedCount} 个账号,保存后将覆盖它们当前的调度参数和分组设置。`"
|
||||
width="640px"
|
||||
:close-disabled="saving"
|
||||
size="md"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<div class="grid gap-5">
|
||||
<div class="flex min-h-6 items-center justify-between gap-3">
|
||||
<span class="text-[13px] leading-none font-medium text-(--cp-text-secondary)">调度</span>
|
||||
<span class="text-[13px] leading-none font-medium text-cp-secondary">调度</span>
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
label="切换所选账号调度"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { useAccountConnectionTest } from '../composables/useAccountConnecti
|
||||
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
@@ -35,12 +36,12 @@ const selectedModel = defineModel<string>('selectedModel', { required: true })
|
||||
|
||||
function connectionLogClass(tone: string) {
|
||||
if (tone === 'success')
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-success-text'
|
||||
if (tone === 'danger')
|
||||
return 'text-(--cp-danger-text)'
|
||||
return 'text-cp-danger-text'
|
||||
if (tone === 'info')
|
||||
return 'text-(--cp-info-text)'
|
||||
return 'text-(--cp-text-secondary)'
|
||||
return 'text-cp-info-text'
|
||||
return 'text-cp-secondary'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,12 +50,12 @@ function connectionLogClass(tone: string) {
|
||||
v-model="open"
|
||||
title="测试连接"
|
||||
description="验证账号凭据、身份绑定与上游模型端点是否可用"
|
||||
variant="info"
|
||||
width="720px"
|
||||
tone="info"
|
||||
size="lg"
|
||||
>
|
||||
<div v-if="account" class="flex flex-col gap-4">
|
||||
<section
|
||||
class="flex items-center justify-between gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3"
|
||||
class="flex items-center justify-between gap-4 rounded-cp-surface bg-cp-subtle px-4 py-3"
|
||||
>
|
||||
<AccountIdentityCell :account="account" size="lg" show-plan />
|
||||
<AccountStatusBadge
|
||||
@@ -65,25 +66,25 @@ function connectionLogClass(tone: string) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3">
|
||||
<section class="rounded-cp-surface bg-cp-subtle px-4 py-3">
|
||||
<div class="grid gap-2">
|
||||
<div class="flex min-h-8 items-center justify-between gap-3">
|
||||
<span class="text-[12px] font-heavy text-(--cp-text-muted)">
|
||||
<span class="text-[12px] font-heavy text-cp-muted-text">
|
||||
测试模型
|
||||
</span>
|
||||
<BaseButton
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-only
|
||||
label="刷新上游模型"
|
||||
:loading="refreshingModels"
|
||||
:disabled="status === 'running' || loadingModels"
|
||||
@click="emit('refreshModels')"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
<BaseSelect
|
||||
v-model="selectedModel"
|
||||
@@ -96,7 +97,7 @@ function connectionLogClass(tone: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) p-4">
|
||||
<section class="rounded-cp-surface bg-cp-subtle p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
@@ -110,11 +111,11 @@ function connectionLogClass(tone: string) {
|
||||
/>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[16px] font-heavy text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[16px] font-heavy text-cp-primary">
|
||||
{{ statusView.label }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-1.5 mb-0 text-[13px] leading-normal font-emphasis text-(--cp-text-secondary)"
|
||||
class="mt-1.5 mb-0 text-[13px] leading-normal font-emphasis text-cp-secondary"
|
||||
>
|
||||
{{ statusView.description }}
|
||||
</p>
|
||||
@@ -129,70 +130,74 @@ function connectionLogClass(tone: string) {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-(--cp-text-muted)">
|
||||
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
开始时间
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-(--cp-text-primary)">
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
|
||||
{{ startedAt || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-(--cp-text-muted)">
|
||||
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
完成时间
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-(--cp-text-primary)">
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
|
||||
{{ finishedAt || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-(--cp-text-muted)">
|
||||
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
响应耗时
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-(--cp-text-primary)">
|
||||
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
|
||||
{{ durationMs !== null ? `${durationMs}ms` : '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-(--cp-text-muted)">
|
||||
<div class="mt-3 rounded-lg bg-cp-surface px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
测试模型
|
||||
</p>
|
||||
<p
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-emphasis text-(--cp-text-primary)"
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-emphasis text-cp-primary"
|
||||
:title="model || '-'"
|
||||
>
|
||||
{{ model || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg bg-(--cp-bg-surface) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-(--cp-text-muted)">
|
||||
<div class="mt-3 rounded-lg bg-cp-surface px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
|
||||
事件轨迹
|
||||
</p>
|
||||
<BaseScrollbar max-height="260px" view-class="pt-2 pr-2">
|
||||
<div v-if="logs.length === 0" class="text-[12px] font-emphasis text-(--cp-text-muted)">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="item in logs"
|
||||
:key="item.key"
|
||||
class="grid grid-cols-[54px_minmax(0,1fr)] gap-2 text-[12px] leading-[1.45] font-emphasis"
|
||||
>
|
||||
<span class="font-mono text-(--cp-text-muted)">{{ item.time }}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 wrap-break-word" :class="connectionLogClass(item.tone)">
|
||||
{{ item.text }}
|
||||
</p>
|
||||
<div v-if="item.detail" class="mt-2 rounded-lg bg-(--cp-bg-subtle) px-3 py-2">
|
||||
<BaseScrollbar max-height="138px" view-class="pr-2">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-emphasis text-(--cp-text-primary)"
|
||||
v-text="item.detail"
|
||||
/>
|
||||
</BaseScrollbar>
|
||||
<BaseScrollbar max-height="260px">
|
||||
<div class="pt-2">
|
||||
<div v-if="logs.length === 0" class="text-[12px] font-emphasis text-cp-muted-text">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="item in logs"
|
||||
:key="item.key"
|
||||
class="grid grid-cols-[54px_minmax(0,1fr)] gap-2 text-[12px] leading-[1.45] font-emphasis"
|
||||
>
|
||||
<span class="font-mono text-cp-muted-text">{{ item.time }}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 wrap-break-word" :class="connectionLogClass(item.tone)">
|
||||
{{ item.text }}
|
||||
</p>
|
||||
<div v-if="item.detail" class="mt-2 rounded-lg bg-cp-subtle px-3 py-2">
|
||||
<BaseScrollbar max-height="138px">
|
||||
<div>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-emphasis text-cp-primary"
|
||||
v-text="item.detail"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, ref } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
@@ -196,15 +197,14 @@ async function copyText(value: string, successText: string) {
|
||||
v-model="open"
|
||||
:title="modalTitle"
|
||||
:description="description"
|
||||
:variant="isChoosingProvider ? 'default' : 'info'"
|
||||
:width="isChoosingProvider ? '420px' : '620px'"
|
||||
:close-disabled="saving"
|
||||
:hide-footer="isChoosingProvider"
|
||||
:tone="isChoosingProvider ? 'neutral' : 'info'"
|
||||
:size="isChoosingProvider ? 'sm' : 'md'"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<template #icon>
|
||||
<LayoutGrid v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
|
||||
<LayoutGrid v-if="isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<Xai v-else-if="isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
<Openai v-else class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
|
||||
</template>
|
||||
|
||||
<AccountProviderChooser
|
||||
@@ -217,23 +217,24 @@ async function copyText(value: string, successText: string) {
|
||||
<BaseSegmented
|
||||
v-if="!reauthorizing && !isBatch"
|
||||
v-model="mode"
|
||||
label="账号添加方式"
|
||||
:options="modeOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
|
||||
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
|
||||
<div class="rounded-cp-control bg-cp-subtle px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-info)"
|
||||
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-surface text-cp-info"
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[13px] font-bold text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[13px] font-bold text-cp-primary">
|
||||
{{ oauthPanelTitle }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-(--cp-text-secondary)">
|
||||
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-cp-secondary">
|
||||
{{ oauthPanelDescription }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -242,7 +243,7 @@ async function copyText(value: string, successText: string) {
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
:loading="oauthLoading"
|
||||
:disabled="!canGenerateOauth"
|
||||
@click="emit('generateOauth')"
|
||||
@@ -254,9 +255,8 @@ async function copyText(value: string, successText: string) {
|
||||
<BaseForm v-if="oauthAuthUrl">
|
||||
<BaseFormItem label="授权链接">
|
||||
<template #extra>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="default"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="复制链接"
|
||||
label="复制链接"
|
||||
@@ -264,16 +264,17 @@ async function copyText(value: string, successText: string) {
|
||||
@click="copyText(oauthAuthUrl, '授权链接已复制')"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
<BaseScrollbar
|
||||
max-height="92px"
|
||||
view-class="rounded-(--cp-input-radius-base) bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 shadow-(--cp-shadow-input)"
|
||||
>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-emphasis text-(--cp-text-secondary)"
|
||||
v-text="oauthAuthUrl"
|
||||
/>
|
||||
<div class="rounded-cp-control bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 shadow-cp-input">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-emphasis text-cp-secondary"
|
||||
v-text="oauthAuthUrl"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
@@ -283,7 +284,7 @@ async function copyText(value: string, successText: string) {
|
||||
<BaseTextarea
|
||||
v-model="oauthCallback"
|
||||
:aria-label="isXai ? '回调地址或授权码' : '回调地址'"
|
||||
size="sm"
|
||||
:rows="4"
|
||||
:placeholder="isXai ? '回调地址、?code=...&state=... 或授权码' : 'http://localhost:1455/auth/callback?code=...&state=...'"
|
||||
:disabled="saving"
|
||||
/>
|
||||
@@ -298,7 +299,7 @@ async function copyText(value: string, successText: string) {
|
||||
:error="fileError || undefined"
|
||||
>
|
||||
<template #extra>
|
||||
<BaseButton variant="default" size="sm" :disabled="saving" @click="openImportFile()">
|
||||
<BaseButton variant="secondary" size="sm" :disabled="saving" @click="openImportFile()">
|
||||
<template #icon>
|
||||
<Upload class="size-3.5" />
|
||||
</template>
|
||||
@@ -308,7 +309,7 @@ async function copyText(value: string, successText: string) {
|
||||
<BaseTextarea
|
||||
v-model="importText"
|
||||
:aria-label="importFileLabel"
|
||||
size="lg"
|
||||
:rows="9"
|
||||
:placeholder="importFilePlaceholder"
|
||||
:disabled="saving"
|
||||
/>
|
||||
@@ -316,12 +317,11 @@ async function copyText(value: string, successText: string) {
|
||||
</BaseForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="!isChoosingProvider" #footer>
|
||||
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!isChoosingProvider"
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
|
||||
@@ -35,12 +35,12 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model="open"
|
||||
title="编辑账号"
|
||||
description="查看账号信息,并调整调度与所属分组。"
|
||||
width="640px"
|
||||
:close-disabled="saving"
|
||||
size="md"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<div v-if="account" class="grid gap-5">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-cp-control bg-cp-subtle px-4 py-3.5"
|
||||
>
|
||||
<AccountIdentityCell
|
||||
class="min-w-0 flex-1"
|
||||
@@ -57,7 +57,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-6 items-center justify-between gap-3">
|
||||
<span class="text-[13px] leading-none font-medium text-(--cp-text-secondary)">调度</span>
|
||||
<span class="text-[13px] leading-none font-medium text-cp-secondary">调度</span>
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
label="切换账号调度"
|
||||
|
||||
@@ -47,11 +47,11 @@ const groupOptions = computed(() => [
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 md:flex-none md:gap-3">
|
||||
<BaseInput
|
||||
v-model="search"
|
||||
placeholder="搜索邮箱或 ID..."
|
||||
placeholder="搜索账号"
|
||||
class="min-w-0 flex-1 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-80 md:flex-none"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4.5 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
@@ -67,7 +67,7 @@ const groupOptions = computed(() => [
|
||||
:options="groupOptions"
|
||||
:disabled="groupsLoading"
|
||||
aria-label="按账号分组筛选"
|
||||
class="w-44 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)]"
|
||||
class="w-34 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-40"
|
||||
/>
|
||||
|
||||
<ProviderFilterSegmented
|
||||
@@ -81,16 +81,16 @@ const groupOptions = computed(() => [
|
||||
>
|
||||
<BaseButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
class="w-full whitespace-nowrap md:w-auto"
|
||||
@click="emit('editSelected')"
|
||||
>
|
||||
<Pencil class="size-4 text-(--cp-info)" />
|
||||
<Pencil class="size-4 text-cp-info" />
|
||||
批量编辑账号
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="danger"
|
||||
variant="destructive"
|
||||
class="w-full whitespace-nowrap md:w-auto"
|
||||
:disabled="batchDeleting"
|
||||
@click="emit('deleteSelected')"
|
||||
@@ -100,7 +100,7 @@ const groupOptions = computed(() => [
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
class="w-full whitespace-nowrap md:w-auto"
|
||||
:loading="exportingAccounts"
|
||||
@click="emit('exportSelected')"
|
||||
|
||||
@@ -50,16 +50,16 @@ const avatarSizeClass = computed(() =>
|
||||
|
||||
const secondaryClass = computed(() =>
|
||||
props.size === 'lg'
|
||||
? 'mt-1 text-[12px] text-(--cp-text-secondary)'
|
||||
: 'mt-0.5 font-mono text-[11px] text-(--cp-text-muted)',
|
||||
? 'mt-1 text-[12px] text-cp-secondary'
|
||||
: 'mt-0.5 font-mono text-[11px] text-cp-muted-text',
|
||||
)
|
||||
|
||||
const avatarClass = computed(() => {
|
||||
const palettes = [
|
||||
'bg-(--cp-info-bg) text-(--cp-info-text) shadow-[inset_0_0_0_1px_var(--cp-info-border)]',
|
||||
'bg-(--cp-success-bg) text-(--cp-success-text) shadow-[inset_0_0_0_1px_var(--cp-success-border)]',
|
||||
'bg-(--cp-normal-bg) text-(--cp-normal-text) shadow-[inset_0_0_0_1px_var(--cp-normal-border)]',
|
||||
'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
|
||||
'bg-cp-info-bg text-cp-info-text shadow-[inset_0_0_0_1px_var(--cp-info-border)]',
|
||||
'bg-cp-success-bg text-cp-success-text shadow-[inset_0_0_0_1px_var(--cp-success-border)]',
|
||||
'bg-cp-normal-bg text-cp-normal-text shadow-[inset_0_0_0_1px_var(--cp-normal-border)]',
|
||||
'bg-cp-warning-bg text-cp-warning-text shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
|
||||
]
|
||||
const key = String(props.account.id || props.account.email || displayTitle.value)
|
||||
const hash = sumBy([...key], char => char.charCodeAt(0))
|
||||
@@ -77,7 +77,7 @@ const avatarClass = computed(() => {
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-[14px] font-heavy text-(--cp-text-primary)">
|
||||
<span class="min-w-0 flex-1 truncate text-[14px] font-heavy text-cp-primary">
|
||||
{{ displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -47,15 +47,15 @@ function formatCount(value: number) {
|
||||
|
||||
function overviewIconClass(tone: string) {
|
||||
if (tone === 'success') {
|
||||
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
|
||||
return 'bg-cp-success-bg text-cp-success-text'
|
||||
}
|
||||
if (tone === 'warning') {
|
||||
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
|
||||
return 'bg-cp-warning-bg text-cp-warning-text'
|
||||
}
|
||||
if (tone === 'danger') {
|
||||
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
|
||||
return 'bg-cp-danger-bg text-cp-danger-text'
|
||||
}
|
||||
return 'bg-(--cp-info-bg) text-(--cp-info-text)'
|
||||
return 'bg-cp-info-bg text-cp-info-text'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -69,15 +69,15 @@ function overviewIconClass(tone: string) {
|
||||
>
|
||||
<div class="flex items-stretch justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<p class="m-0 text-[12px] leading-none font-heavy text-(--cp-text-secondary)">
|
||||
<p class="m-0 text-[12px] leading-none font-heavy text-cp-secondary">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<strong
|
||||
class="my-2 block font-mono text-[26px] leading-none font-extrabold text-(--cp-text-primary)"
|
||||
class="my-2 block font-mono text-[26px] leading-none font-extrabold text-cp-primary"
|
||||
>
|
||||
{{ item.value }}
|
||||
</strong>
|
||||
<p class="m-0 truncate text-[12px] leading-none font-emphasis text-(--cp-text-muted)">
|
||||
<p class="m-0 truncate text-[12px] leading-none font-emphasis text-cp-muted-text">
|
||||
{{ item.caption }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,15 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const fallbackPalettes = [
|
||||
'bg-(--cp-info-bg) text-(--cp-info-text) shadow-(--cp-shadow-input)',
|
||||
'bg-(--cp-success-bg) text-(--cp-success-text) shadow-(--cp-shadow-input)',
|
||||
'bg-(--cp-normal-bg) text-(--cp-normal-text) shadow-(--cp-shadow-input)',
|
||||
'bg-(--cp-warning-bg) text-(--cp-warning-text) shadow-(--cp-shadow-input)',
|
||||
'bg-cp-info-bg text-cp-info-text shadow-cp-input',
|
||||
'bg-cp-success-bg text-cp-success-text shadow-cp-input',
|
||||
'bg-cp-normal-bg text-cp-normal-text shadow-cp-input',
|
||||
'bg-cp-warning-bg text-cp-warning-text shadow-cp-input',
|
||||
]
|
||||
|
||||
const usageTierPalettes: Record<string, string> = {
|
||||
pro: 'bg-(--cp-plan-pro-bg) text-(--cp-plan-pro-text) shadow-(--cp-shadow-input)',
|
||||
prolite: 'bg-(--cp-plan-prolite-bg) text-(--cp-plan-prolite-text) shadow-(--cp-shadow-input)',
|
||||
pro: 'bg-cp-plan-pro-bg text-cp-plan-pro-text shadow-cp-input',
|
||||
prolite: 'bg-cp-plan-prolite-bg text-cp-plan-prolite-text shadow-cp-input',
|
||||
}
|
||||
|
||||
const label = computed(() => props.planType?.trim() || 'Free')
|
||||
|
||||
@@ -44,14 +44,14 @@ const providers = [
|
||||
v-for="provider in providers"
|
||||
:key="provider.value"
|
||||
type="button"
|
||||
class="group inline-flex size-[88px] cursor-pointer items-center justify-center rounded-(--cp-panel-radius) border-0 bg-transparent text-(--cp-text-primary) outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(--cp-default-border-hover) disabled:cursor-not-allowed disabled:opacity-55"
|
||||
class="group inline-flex size-[88px] cursor-pointer items-center justify-center rounded-cp-overlay border-0 bg-transparent text-cp-primary outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-default-border-hover disabled:cursor-not-allowed disabled:opacity-55"
|
||||
:disabled="disabled"
|
||||
:aria-label="`导入 ${provider.label} 账号`"
|
||||
:title="provider.label"
|
||||
@click="emit('select', provider.value)"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-16 items-center justify-center rounded-(--cp-panel-radius) bg-(--cp-bg-subtle) transition-colors duration-150 group-hover:bg-(--cp-bg-muted)"
|
||||
class="inline-flex size-16 items-center justify-center rounded-cp-overlay bg-cp-subtle transition-colors duration-150 group-hover:bg-cp-muted"
|
||||
>
|
||||
<component :is="provider.icon" aria-hidden="true" :width="36" :height="36" />
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AccountRow } from '../constants'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import { orderedPanelQuotaWindows } from '../constants'
|
||||
import AccountPlanBadge from './AccountPlanBadge.vue'
|
||||
import AccountUsageWindow from './AccountUsageWindow.vue'
|
||||
@@ -21,14 +21,14 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control)">
|
||||
<section class="rounded-lg bg-cp-surface p-4 shadow-cp-control">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-[14px] font-heavy text-(--cp-text-primary)">
|
||||
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
|
||||
账号额度
|
||||
</h3>
|
||||
<p
|
||||
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
|
||||
<template v-if="account.provider === 'openai'">
|
||||
@@ -40,18 +40,19 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
|
||||
<span>最近刷新: {{ account.quota.refreshedAtDisplay }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="刷新额度"
|
||||
spin-icon-on-loading
|
||||
label="刷新额度"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click="emit('refreshQuota', account.id)"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
|
||||
@@ -14,7 +14,9 @@ const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindo
|
||||
const summaryClass = computed(
|
||||
() =>
|
||||
`grid w-full min-w-0 gap-1.5 py-0.5 ${
|
||||
visibleQuotaWindows.value.length <= 1 ? 'min-h-13 content-center' : ''
|
||||
visibleQuotaWindows.value.length <= 1
|
||||
? 'min-h-13 content-center'
|
||||
: 'min-h-16.5 content-center'
|
||||
}`,
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,34 +25,34 @@ const props = withDefaults(
|
||||
|
||||
const statusStyles = {
|
||||
success: {
|
||||
text: 'text-(--cp-success-text)',
|
||||
dot: 'bg-(--cp-success)',
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
icon: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
text: 'text-cp-success-text',
|
||||
dot: 'bg-cp-success',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
icon: 'bg-cp-success-bg text-cp-success-text',
|
||||
},
|
||||
danger: {
|
||||
text: 'text-(--cp-danger-text)',
|
||||
dot: 'bg-(--cp-danger)',
|
||||
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
icon: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
text: 'text-cp-danger-text',
|
||||
dot: 'bg-cp-danger',
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
icon: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
},
|
||||
warning: {
|
||||
text: 'text-(--cp-warning-text)',
|
||||
dot: 'bg-(--cp-warning)',
|
||||
badge: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
|
||||
icon: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
|
||||
text: 'text-cp-warning-text',
|
||||
dot: 'bg-cp-warning',
|
||||
badge: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
icon: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
},
|
||||
info: {
|
||||
text: 'text-(--cp-info-text)',
|
||||
dot: 'bg-(--cp-info)',
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
icon: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
text: 'text-cp-info-text',
|
||||
dot: 'bg-cp-info',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
icon: 'bg-cp-info-bg text-cp-info-text',
|
||||
},
|
||||
normal: {
|
||||
text: 'text-(--cp-text-secondary)',
|
||||
dot: 'bg-(--cp-text-muted)',
|
||||
badge: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
|
||||
icon: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
|
||||
text: 'text-cp-secondary',
|
||||
dot: 'bg-cp-muted-text',
|
||||
badge: 'bg-cp-subtle text-cp-secondary',
|
||||
icon: 'bg-cp-subtle text-cp-secondary',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -135,8 +135,6 @@ const cardIcon = computed(() => {
|
||||
:disabled="!hasDetail"
|
||||
trigger="hover-click"
|
||||
placement="top-start"
|
||||
width="352px"
|
||||
panel-class="!p-0 text-(--cp-text-primary)"
|
||||
>
|
||||
<template #trigger="{ open }">
|
||||
<component
|
||||
@@ -165,21 +163,21 @@ const cardIcon = computed(() => {
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<section class="overflow-hidden rounded-(--cp-popover-radius)">
|
||||
<header class="flex items-start gap-3 bg-(--cp-bg-subtle) px-4 py-3">
|
||||
<section class="w-88 overflow-hidden rounded-cp-overlay">
|
||||
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius)"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
|
||||
:class="statusStyle.icon"
|
||||
>
|
||||
<component :is="cardIcon" class="size-4.5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-(--cp-text-tertiary)">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
|
||||
账号状态
|
||||
</p>
|
||||
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-(--cp-text-primary) text-balance">
|
||||
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
|
||||
{{ detailTitle }}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -193,39 +191,41 @@ const cardIcon = computed(() => {
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
|
||||
<p class="m-0 text-pretty font-emphasis text-(--cp-text-secondary)">
|
||||
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
|
||||
{{ detailDescription }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="isRateLimited"
|
||||
class="flex items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2"
|
||||
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
|
||||
>
|
||||
<span class="font-heavy text-(--cp-text-tertiary)">预计恢复</span>
|
||||
<span class="font-mono font-emphasis tabular-nums text-(--cp-text-primary)">
|
||||
<span class="font-heavy text-cp-tertiary">预计恢复</span>
|
||||
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
|
||||
{{ rateLimitedRelative ?? props.rateLimitedUntil }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-(--cp-text-tertiary)">
|
||||
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
|
||||
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
|
||||
建议操作
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-(--cp-text-primary)">
|
||||
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
|
||||
{{ recoveryHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorText"
|
||||
class="overflow-hidden rounded-(--cp-input-radius-base) bg-(--cp-bg-muted)"
|
||||
class="overflow-hidden rounded-cp-control bg-cp-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
|
||||
<span class="text-[11px] leading-none font-heavy text-(--cp-text-tertiary)">上游原始反馈</span>
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-(--cp-text-muted)">仅供排查</span>
|
||||
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
|
||||
</div>
|
||||
<BaseScrollbar class="bg-(--cp-bg-subtle)" max-height="124px" view-class="px-3 py-2 pr-2">
|
||||
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-(--cp-text-secondary)">{{ errorText }}</pre>
|
||||
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
|
||||
<div class="px-3 py-2">
|
||||
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ errorText }}</pre>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import type { AccountRow } from '../constants'
|
||||
import { KeyRound, MoreHorizontal, Pencil, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseMenuItem from '@/components/base/BaseMenuItem.vue'
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -25,83 +26,74 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-start gap-1">
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="编辑账号"
|
||||
@click.stop="emit('edit', account)"
|
||||
>
|
||||
<Pencil class="size-3.5 text-(--cp-info)" />
|
||||
</BaseButton>
|
||||
<Pencil class="size-3.5 text-cp-info" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="删除账号"
|
||||
label="删除账号"
|
||||
:disabled="deleting"
|
||||
@click.stop="emit('delete', account)"
|
||||
>
|
||||
<Trash2 class="size-3.5 text-(--cp-danger)" />
|
||||
</BaseButton>
|
||||
<Trash2 class="size-3.5 text-cp-danger" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BasePopover placement="bottom-end" width="160px">
|
||||
<BasePopover placement="bottom-end">
|
||||
<template #trigger="{ open }">
|
||||
<BaseButton icon-only variant="ghost" size="sm" title="更多操作" :active="open">
|
||||
<BaseIconButton variant="ghost" size="sm" label="更多操作" :pressed="open">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
|
||||
<template #default="{ close }">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
@click.stop="(close(), emit('test', account))"
|
||||
>
|
||||
<template #icon>
|
||||
<Wifi class="size-3.5 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
测试连接
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click.stop="(close(), emit('refresh', account.id))"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-3.5 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
刷新令牌
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
@click.stop="(close(), emit('reauthorize', account))"
|
||||
>
|
||||
<KeyRound class="size-3.5 text-(--cp-text-muted)" />
|
||||
重新授权
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8.5! w-full justify-start! gap-2! rounded-(--cp-input-radius-small)! px-3! text-left text-[13px] leading-none! font-emphasis! text-(--cp-text-primary)!"
|
||||
:loading="recovering"
|
||||
:disabled="recovering"
|
||||
@click.stop="(close(), emit('recover', account.id))"
|
||||
>
|
||||
<template #icon>
|
||||
<RotateCcw class="size-3.5 text-(--cp-text-muted)" />
|
||||
</template>
|
||||
恢复状态
|
||||
</BaseButton>
|
||||
<div class="w-40 p-1.5">
|
||||
<BaseMenuItem
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
@click.stop="(close(), emit('test', account))"
|
||||
>
|
||||
<template #icon>
|
||||
<Wifi class="size-3.5 text-cp-muted-text" />
|
||||
</template>
|
||||
测试连接
|
||||
</BaseMenuItem>
|
||||
<BaseMenuItem
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click.stop="(close(), emit('refresh', account.id))"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin text-cp-muted-text motion-reduce:animate-none" />
|
||||
</template>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-3.5 text-cp-muted-text" />
|
||||
</template>
|
||||
刷新令牌
|
||||
</BaseMenuItem>
|
||||
<BaseMenuItem @click.stop="(close(), emit('reauthorize', account))">
|
||||
<template #icon>
|
||||
<KeyRound class="size-3.5 text-cp-muted-text" />
|
||||
</template>
|
||||
重新授权
|
||||
</BaseMenuItem>
|
||||
<BaseMenuItem
|
||||
:loading="recovering"
|
||||
:disabled="recovering"
|
||||
@click.stop="(close(), emit('recover', account.id))"
|
||||
>
|
||||
<template #icon>
|
||||
<RotateCcw class="size-3.5 text-cp-muted-text" />
|
||||
</template>
|
||||
恢复状态
|
||||
</BaseMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
</BasePopover>
|
||||
</div>
|
||||
|
||||
@@ -9,43 +9,43 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="grid gap-4 rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control) xl:grid-cols-[0.52fr_1.48fr]"
|
||||
class="grid gap-4 rounded-lg bg-cp-surface p-4 shadow-cp-control xl:grid-cols-[0.52fr_1.48fr]"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 class="m-0 text-[14px] font-heavy text-(--cp-text-primary)">
|
||||
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
|
||||
Token 结构
|
||||
</h3>
|
||||
<span class="text-[11px] font-emphasis text-(--cp-text-muted)">当前额度窗口</span>
|
||||
<span class="text-[11px] font-emphasis text-cp-muted-text">当前额度窗口</span>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between rounded-lg bg-(--cp-success-bg) px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-(--cp-success-text)">输入 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
<div class="flex items-center justify-between rounded-lg bg-cp-success-bg px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-cp-success-text">输入 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-cp-primary">
|
||||
{{ account.usage.inputTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-(--cp-warning-bg) px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-(--cp-warning-text)">输出 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
<div class="flex items-center justify-between rounded-lg bg-cp-warning-bg px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-cp-warning-text">输出 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-cp-primary">
|
||||
{{ account.usage.outputTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-(--cp-normal-bg) px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-(--cp-normal-text)">缓存 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
<div class="flex items-center justify-between rounded-lg bg-cp-normal-bg px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-cp-normal-text">缓存 Tokens</span>
|
||||
<strong class="font-mono text-[13px] text-cp-primary">
|
||||
{{ account.usage.cachedTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-(--cp-info-bg) px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-(--cp-info-text)">创建</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
<div class="flex items-center justify-between rounded-lg bg-cp-info-bg px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-cp-info-text">创建</span>
|
||||
<strong class="font-mono text-[13px] text-cp-primary">
|
||||
{{ account.usage.createdTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-(--cp-info-bg) px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-(--cp-info-text)">读取</span>
|
||||
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
|
||||
<div class="flex items-center justify-between rounded-lg bg-cp-info-bg px-3 py-2">
|
||||
<span class="text-[12px] font-bold text-cp-info-text">读取</span>
|
||||
<strong class="font-mono text-[13px] text-cp-primary">
|
||||
{{ account.usage.readTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
@@ -56,14 +56,14 @@ defineProps<{
|
||||
class="min-w-0 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_var(--cp-divider-subtle)]"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-[14px] font-heavy text-(--cp-text-primary)">
|
||||
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
|
||||
模型使用排行
|
||||
</h3>
|
||||
<span class="text-[11px] font-emphasis text-(--cp-text-muted)">当前额度窗口</span>
|
||||
<span class="text-[11px] font-emphasis text-cp-muted-text">当前额度窗口</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pb-2 text-[11px] font-heavy text-(--cp-text-secondary) shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pb-2 text-[11px] font-heavy text-cp-secondary shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
|
||||
>
|
||||
<span>模型</span>
|
||||
<span>调用</span>
|
||||
@@ -77,7 +77,7 @@ defineProps<{
|
||||
</div>
|
||||
<div
|
||||
v-if="account.usage.models.length === 0"
|
||||
class="pt-3 text-[12px] font-emphasis text-(--cp-text-muted)"
|
||||
class="pt-3 text-[12px] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
-
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ defineProps<{
|
||||
<div
|
||||
v-for="model in account.usage.models"
|
||||
:key="model.model"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pt-3 text-[12px] font-emphasis text-(--cp-text-primary)"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pt-3 text-[12px] font-emphasis text-cp-primary"
|
||||
>
|
||||
<span class="truncate">{{ model.model }}</span>
|
||||
<span>{{ model.requestCountDisplay }}</span>
|
||||
|
||||
@@ -68,7 +68,7 @@ const rootClass = computed(() =>
|
||||
variantValue(
|
||||
'grid min-w-0 grid-rows-[14px_14px] gap-1',
|
||||
'flex min-w-0 flex-col',
|
||||
'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
'rounded-lg bg-cp-subtle p-2',
|
||||
),
|
||||
)
|
||||
const headerClass = computed(() =>
|
||||
@@ -80,10 +80,10 @@ const headerClass = computed(() =>
|
||||
)
|
||||
const labelClass = computed(() => {
|
||||
if (isMetric.value)
|
||||
return 'truncate text-(--cp-text-muted)'
|
||||
return 'truncate text-cp-muted-text'
|
||||
if (isCompact.value)
|
||||
return 'truncate text-(--cp-text-muted)'
|
||||
return 'text-(--cp-text-secondary)'
|
||||
return 'truncate text-cp-muted-text'
|
||||
return 'text-cp-secondary'
|
||||
})
|
||||
const valueClass = computed(() =>
|
||||
variantValue(
|
||||
@@ -102,7 +102,7 @@ const trackShapeClass = computed(() =>
|
||||
'h-2 overflow-hidden rounded-full',
|
||||
),
|
||||
)
|
||||
const trackClass = computed(() => `${trackShapeClass.value} bg-(--cp-default-border)`)
|
||||
const trackClass = computed(() => `${trackShapeClass.value} bg-cp-default-border`)
|
||||
const barStyle = computed(() => {
|
||||
if (!props.window)
|
||||
return undefined
|
||||
@@ -184,7 +184,7 @@ const requestBars = computed(() => {
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-right text-(--cp-text-muted)"
|
||||
class="text-right text-cp-muted-text"
|
||||
:class="valueClass"
|
||||
:title="`本地 Token 用量:${quotaLocalUsageDisplay}`"
|
||||
>
|
||||
@@ -205,7 +205,7 @@ const requestBars = computed(() => {
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-(--cp-text-muted)"
|
||||
class="text-cp-muted-text"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ quotaLocalUsageDisplay }}
|
||||
@@ -234,7 +234,7 @@ const requestBars = computed(() => {
|
||||
</div>
|
||||
<div
|
||||
v-if="!isCompact && window.resetAtDisplay !== '—'"
|
||||
class="mt-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
重置时间: {{ window.resetAtDisplay }}
|
||||
</div>
|
||||
@@ -247,7 +247,7 @@ const requestBars = computed(() => {
|
||||
</span>
|
||||
<strong
|
||||
v-if="localRequestValueVisible"
|
||||
class="shrink-0 font-mono tabular-nums text-(--cp-text-primary)"
|
||||
class="shrink-0 font-mono tabular-nums text-cp-primary"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ localRequestDisplay }}
|
||||
@@ -263,11 +263,11 @@ const requestBars = computed(() => {
|
||||
<span
|
||||
v-for="bar in requestBars"
|
||||
:key="bar.key"
|
||||
class="relative min-w-0 flex-1 bg-(--cp-default-border)"
|
||||
class="relative min-w-0 flex-1 bg-cp-default-border"
|
||||
:title="bar.title"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-x-0 bottom-0 bg-(--cp-success)"
|
||||
class="absolute inset-x-0 bottom-0 bg-cp-success"
|
||||
:style="{ height: bar.height }"
|
||||
/>
|
||||
</span>
|
||||
@@ -275,8 +275,8 @@ const requestBars = computed(() => {
|
||||
</template>
|
||||
|
||||
<div v-else :class="headerClass">
|
||||
<span class="min-w-0 text-(--cp-text-secondary)">额度待观测</span>
|
||||
<span class="shrink-0 font-mono text-(--cp-text-muted)" :class="valueClass">—</span>
|
||||
<span class="min-w-0 text-cp-secondary">额度待观测</span>
|
||||
<span class="shrink-0 font-mono text-cp-muted-text" :class="valueClass">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,8 +105,8 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
label: '正在测试',
|
||||
description: '正在向所选模型发送请求并接收流式响应',
|
||||
icon: Clock3,
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
iconClass: 'text-(--cp-info)',
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (connectionTestStatus.value === 'success') {
|
||||
@@ -114,8 +114,8 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
label: '连接正常',
|
||||
description: '请求已完成,可在下方查看模型、耗时和事件轨迹',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (connectionTestStatus.value === 'error') {
|
||||
@@ -123,16 +123,16 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
label: '测试失败',
|
||||
description: '请求未完成,请在下方查看上游错误与事件轨迹',
|
||||
icon: XCircle,
|
||||
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
iconClass: 'text-(--cp-danger)',
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
iconClass: 'text-cp-danger',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '准备测试',
|
||||
description: '选择模型后,点击“开始测试”发送真实请求',
|
||||
icon: Wifi,
|
||||
badge: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
|
||||
iconClass: 'text-(--cp-text-muted)',
|
||||
badge: 'bg-cp-subtle text-cp-secondary',
|
||||
iconClass: 'text-cp-muted-text',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AccountErrorReason, AccountStatus, getAccounts } from '@/api'
|
||||
import type { BaseTableColumn } from '@/components/base/BaseTable/columns'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
|
||||
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
@@ -25,106 +25,45 @@ const quotaGroupOrder = new Map([
|
||||
['other', 2],
|
||||
])
|
||||
|
||||
const relaxedCellClass = 'py-2 align-middle'
|
||||
|
||||
export const accountColumns = [
|
||||
{
|
||||
key: 'expander',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
{
|
||||
key: 'selection',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
export const accountColumns = defineTableColumns<AccountRow>([
|
||||
{ key: 'expander', kind: 'expander' },
|
||||
{ key: 'selection', kind: 'selection' },
|
||||
{
|
||||
key: 'identity',
|
||||
label: '邮箱',
|
||||
sortable: true,
|
||||
sortKey: 'email',
|
||||
width: '270px',
|
||||
minWidth: '270px',
|
||||
cellClass: relaxedCellClass,
|
||||
label: '账号',
|
||||
kind: 'identity',
|
||||
size: '3xl',
|
||||
sortable: 'email',
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: '平台/类型',
|
||||
width: '120px',
|
||||
minWidth: '120px',
|
||||
align: 'center' as const,
|
||||
kind: 'meta',
|
||||
size: 'md',
|
||||
align: 'center',
|
||||
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
sortable: true,
|
||||
flex: 0.6,
|
||||
minWidth: '60px',
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{
|
||||
key: 'planType',
|
||||
label: '套餐',
|
||||
sortable: true,
|
||||
flex: 0.5,
|
||||
minWidth: '90px',
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: '用量',
|
||||
sortable: true,
|
||||
flex: 1.3,
|
||||
minWidth: '220px',
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
label: '账号分组',
|
||||
flex: 1,
|
||||
width: '112px',
|
||||
minWidth: '112px',
|
||||
align: 'center' as const,
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
|
||||
{ key: 'planType', label: '套餐', kind: 'status', sortable: true },
|
||||
{ key: 'usage', label: '用量', kind: 'custom', size: '2xl', sortable: true },
|
||||
{ key: 'groups', label: '账号分组', kind: 'status' },
|
||||
{
|
||||
key: 'lastUsedAt',
|
||||
label: '最后使用',
|
||||
kind: 'datetime',
|
||||
sortable: true,
|
||||
flex: 1,
|
||||
minWidth: '120px',
|
||||
emptyText: '',
|
||||
headerClass: '!pl-8',
|
||||
cellClass: `${relaxedCellClass} !pl-8 text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'accessTokenExpiresAtDisplay',
|
||||
label: '过期时间',
|
||||
sortable: true,
|
||||
sortKey: 'expiresAt',
|
||||
flex: 1.2,
|
||||
minWidth: '160px',
|
||||
kind: 'datetime',
|
||||
sortable: 'expiresAt',
|
||||
format: value => optionalAccountCell(value),
|
||||
emptyText: '',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '136px',
|
||||
minWidth: '136px',
|
||||
},
|
||||
] satisfies BaseTableColumn<AccountRow>[]
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg' },
|
||||
])
|
||||
|
||||
export const statusLabels: Record<AccountStatus, string> = {
|
||||
normal: '正常',
|
||||
@@ -193,34 +132,34 @@ export function quotaWindowBarStyle(window: AccountQuotaWindow, minimumWidth = '
|
||||
|
||||
export function quotaWindowBarClass(window: AccountQuotaWindow) {
|
||||
if (window.usedPercent === null)
|
||||
return 'bg-(--cp-default-border-hover)'
|
||||
return 'bg-cp-default-border-hover'
|
||||
if (window.usedPercent >= 95)
|
||||
return 'bg-(--cp-danger)'
|
||||
return 'bg-cp-danger'
|
||||
if (window.usedPercent >= 80)
|
||||
return 'bg-(--cp-warning)'
|
||||
return 'bg-(--cp-success)'
|
||||
return 'bg-cp-warning'
|
||||
return 'bg-cp-success'
|
||||
}
|
||||
|
||||
export function quotaWindowPercentTextClass(window: AccountQuotaWindow) {
|
||||
if (window.usedPercent === null)
|
||||
return 'text-(--cp-text-muted)'
|
||||
return 'text-cp-muted-text'
|
||||
if (window.usedPercent >= 95)
|
||||
return 'text-(--cp-danger-text)'
|
||||
return 'text-cp-danger-text'
|
||||
if (window.usedPercent >= 80)
|
||||
return 'text-(--cp-warning-text)'
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-warning-text'
|
||||
return 'text-cp-success-text'
|
||||
}
|
||||
|
||||
export function modelSuccessRateTextClass(successRate: number | null) {
|
||||
if (successRate === null)
|
||||
return 'text-(--cp-text-muted)'
|
||||
return 'text-cp-muted-text'
|
||||
if (successRate >= 99.5)
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-success-text'
|
||||
if (successRate >= 98)
|
||||
return 'text-(--cp-normal-text)'
|
||||
return 'text-cp-normal-text'
|
||||
if (successRate >= 95)
|
||||
return 'text-(--cp-warning-text)'
|
||||
return 'text-(--cp-danger-text)'
|
||||
return 'text-cp-warning-text'
|
||||
return 'text-cp-danger-text'
|
||||
}
|
||||
|
||||
function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
@@ -167,7 +168,6 @@ const {
|
||||
|
||||
<BaseCard
|
||||
class="mt-4 flex h-[calc(100dvh-250px)] min-h-125 flex-col"
|
||||
body-class="mt-3 flex min-h-0 flex-1"
|
||||
>
|
||||
<template #header>
|
||||
<AccountFilters
|
||||
@@ -188,117 +188,121 @@ const {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="accountColumns"
|
||||
:rows="accounts"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
:pagination="accountPagination"
|
||||
:sort="sort"
|
||||
empty-text="暂无账号数据"
|
||||
min-width="1480px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<template #expander="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-(--cp-text-secondary) transition hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary)"
|
||||
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
|
||||
@click.stop="toggleExpanded(row.id)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform"
|
||||
:class="expandedAccountIds.has(row.id) ? '' : '-rotate-90'"
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="accountColumns"
|
||||
:rows="accounts"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
:sort="sort"
|
||||
empty-text="暂无账号数据"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<template #expander="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-cp-secondary transition hover:bg-cp-default-hover hover:text-cp-primary"
|
||||
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
|
||||
@click.stop="toggleExpanded(row.id)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform"
|
||||
:class="expandedAccountIds.has(row.id) ? '' : '-rotate-90'"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页账号"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页账号"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择账号"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择账号"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #identity="{ row }">
|
||||
<AccountIdentityCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #identity="{ row }">
|
||||
<AccountIdentityCell :account="row" />
|
||||
</template>
|
||||
<template #provider="{ row }">
|
||||
<ProviderIconGroup
|
||||
:provider="row.provider"
|
||||
:authentication-kind="row.authenticationKind"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #provider="{ row }">
|
||||
<ProviderIconGroup
|
||||
:provider="row.provider"
|
||||
:authentication-kind="row.authenticationKind"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<AccountStatusBadge
|
||||
:status="derivedAccountStatus(row)"
|
||||
:error-reason="row.errorReason"
|
||||
:error-message="row.errorMessage"
|
||||
:rate-limited-until="row.quota.rateLimitedUntil"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<AccountStatusBadge
|
||||
:status="derivedAccountStatus(row)"
|
||||
:error-reason="row.errorReason"
|
||||
:error-message="row.errorMessage"
|
||||
:rate-limited-until="row.quota.rateLimitedUntil"
|
||||
/>
|
||||
</template>
|
||||
<template #planType="{ row }">
|
||||
<AccountPlanBadge :plan-type="row.planType" />
|
||||
</template>
|
||||
|
||||
<template #planType="{ row }">
|
||||
<AccountPlanBadge :plan-type="row.planType" />
|
||||
</template>
|
||||
<template #usage="{ row }">
|
||||
<AccountQuotaSummaryCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #usage="{ row }">
|
||||
<AccountQuotaSummaryCell :account="row" />
|
||||
</template>
|
||||
<template #groups="{ row }">
|
||||
<div class="flex w-full justify-center">
|
||||
<AccountGroupMarks :groups="row.groups" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #groups="{ row }">
|
||||
<div class="flex w-full justify-center">
|
||||
<AccountGroupMarks :groups="row.groups" />
|
||||
</div>
|
||||
</template>
|
||||
<template #lastUsedAt="{ row }">
|
||||
<LastUsedAtCell :value="row.usage.lastUsedAt" />
|
||||
</template>
|
||||
|
||||
<template #lastUsedAt="{ row }">
|
||||
<LastUsedAtCell :value="row.usage.lastUsedAt" />
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<AccountTableActions
|
||||
:account="row"
|
||||
:deleting="deletingAccount"
|
||||
:recovering="recoveringAccountIds.has(row.id)"
|
||||
:refreshing="refreshingAccountIds.has(row.id)"
|
||||
:testing="testingConnectionIds.has(row.id)"
|
||||
@edit="openAccountEdit"
|
||||
@delete="requestDeleteAccount"
|
||||
@recover="handleRecover"
|
||||
@refresh="handleRefresh"
|
||||
@reauthorize="openReauthorizeAccount"
|
||||
@test="openConnectionTest"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #expanded="{ row }">
|
||||
<div class="grid gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr]">
|
||||
<AccountQuotaPanel
|
||||
<template #actions="{ row }">
|
||||
<AccountTableActions
|
||||
:account="row"
|
||||
:refreshing="refreshingQuotaAccountIds.has(row.id)"
|
||||
@refresh-quota="handleRefreshQuota"
|
||||
:deleting="deletingAccount"
|
||||
:recovering="recoveringAccountIds.has(row.id)"
|
||||
:refreshing="refreshingAccountIds.has(row.id)"
|
||||
:testing="testingConnectionIds.has(row.id)"
|
||||
@edit="openAccountEdit"
|
||||
@delete="requestDeleteAccount"
|
||||
@recover="handleRecover"
|
||||
@refresh="handleRefresh"
|
||||
@reauthorize="openReauthorizeAccount"
|
||||
@test="openConnectionTest"
|
||||
/>
|
||||
<AccountUsagePanel :account="row" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
<template #expanded="{ row }">
|
||||
<div class="grid gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr]">
|
||||
<AccountQuotaPanel
|
||||
:account="row"
|
||||
:refreshing="refreshingQuotaAccountIds.has(row.id)"
|
||||
@refresh-quota="handleRefreshQuota"
|
||||
/>
|
||||
<AccountUsagePanel :account="row" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination
|
||||
:pagination="accountPagination"
|
||||
:loading="loading"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
@@ -362,10 +366,9 @@ const {
|
||||
v-model="showDeleteModal"
|
||||
title="确认删除"
|
||||
description="删除后该账号将不再参与调度,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="batchDeleting"
|
||||
width="480px"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<p class="m-0">
|
||||
@@ -377,10 +380,9 @@ const {
|
||||
v-model="showSingleDeleteModal"
|
||||
title="删除账号"
|
||||
description="删除后该账号将不再参与调度,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="deletingAccount"
|
||||
width="480px"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<p class="m-0">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { getApiKeys } from '@/api'
|
||||
import { Pencil, Power, Terminal, Trash2, Upload } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
|
||||
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
|
||||
|
||||
@@ -23,18 +23,16 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<BaseButton
|
||||
icon-only
|
||||
<div class="flex items-center justify-start gap-0.5">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="编辑密钥"
|
||||
@click.stop="emit('edit', apiKey)"
|
||||
>
|
||||
<Pencil class="size-3.5 text-(--cp-info)" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<Pencil class="size-3.5 text-cp-info" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="使用密钥"
|
||||
@@ -42,22 +40,20 @@ const emit = defineEmits<{
|
||||
:disabled="revealing"
|
||||
@click.stop="emit('use', apiKey)"
|
||||
>
|
||||
<Terminal class="size-3.5 text-(--cp-normal)" />
|
||||
</BaseButton>
|
||||
<Terminal class="size-3.5 text-cp-normal" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="导入 CCSwitch"
|
||||
:disabled="revealing"
|
||||
@click.stop="emit('importCcs', apiKey)"
|
||||
>
|
||||
<Upload class="size-3.5 text-(--cp-info)" />
|
||||
</BaseButton>
|
||||
<Upload class="size-3.5 text-cp-info" />
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="apiKey.enabled ? '禁用密钥' : '启用密钥'"
|
||||
@@ -66,19 +62,18 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Power
|
||||
class="size-3.5"
|
||||
:class="apiKey.enabled ? 'text-(--cp-warning)' : 'text-(--cp-success)'"
|
||||
:class="apiKey.enabled ? 'text-cp-warning' : 'text-cp-success'"
|
||||
/>
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="删除密钥"
|
||||
:disabled="deleting"
|
||||
@click.stop="emit('delete', apiKey)"
|
||||
>
|
||||
<Trash2 class="size-3.5 text-(--cp-danger)" />
|
||||
</BaseButton>
|
||||
<Trash2 class="size-3.5 text-cp-danger" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
|
||||
@@ -34,10 +35,9 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
|
||||
v-model="open"
|
||||
:title="title"
|
||||
description="设置调用方可用的账号分组、并发和每分钟请求上限"
|
||||
variant="info"
|
||||
width="680px"
|
||||
body-max-height="min(680px, calc(100dvh - 190px))"
|
||||
:close-disabled="saving"
|
||||
tone="info"
|
||||
size="lg"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<BaseForm class="grid gap-5">
|
||||
<BaseFormItem label="名称" required>
|
||||
@@ -106,38 +106,38 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
|
||||
v-model="createdOpen"
|
||||
title="API Key 已创建"
|
||||
description="复制密钥,或直接导入 CCSwitch"
|
||||
variant="success"
|
||||
width="540px"
|
||||
tone="success"
|
||||
size="md"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-(--cp-input-radius-base) border border-(--cp-warning-border) bg-(--cp-warning-bg) px-4 py-3">
|
||||
<p class="m-0 text-[13px] font-semibold text-(--cp-warning-text)">
|
||||
<div class="rounded-cp-control border border-cp-warning-border bg-cp-warning-bg px-4 py-3">
|
||||
<p class="m-0 text-[13px] font-semibold text-cp-warning-text">
|
||||
该密钥具有网关访问权限,请仅发送给可信调用方
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-[13px] font-medium text-(--cp-text-secondary)">
|
||||
<p class="mb-2 text-[13px] font-medium text-cp-secondary">
|
||||
API Key
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3 py-2.5 font-mono text-[13px] break-all text-(--cp-text-primary)">
|
||||
<code class="flex-1 rounded-cp-control bg-cp-subtle px-3 py-2.5 font-mono text-[13px] break-all text-cp-primary">
|
||||
{{ createdKey }}
|
||||
</code>
|
||||
<BaseButton icon-only size="md" title="复制" @click="emit('copy', createdKey)">
|
||||
<BaseIconButton size="md" label="复制" @click="emit('copy', createdKey)">
|
||||
<Copy class="size-4" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="default" @click="emit('copy', createdKey)">
|
||||
<BaseButton variant="secondary" @click="emit('copy', createdKey)">
|
||||
<template #icon>
|
||||
<Copy class="size-4" />
|
||||
</template>
|
||||
复制密钥
|
||||
</BaseButton>
|
||||
<BaseButton variant="default" @click="emit('importCcs')">
|
||||
<BaseButton variant="secondary" @click="emit('importCcs')">
|
||||
<template #icon>
|
||||
<Upload class="size-4" />
|
||||
</template>
|
||||
|
||||
@@ -26,7 +26,7 @@ const search = defineModel<string>('search', { required: true })
|
||||
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
|
||||
<BaseInput v-model="search" placeholder="搜索名称、标签或 ID" class="w-full">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4.5 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ const search = defineModel<string>('search', { required: true })
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 md:ml-auto">
|
||||
<BaseButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="danger"
|
||||
variant="destructive"
|
||||
:disabled="batchDeleting"
|
||||
@click="emit('deleteSelected')"
|
||||
>
|
||||
|
||||
@@ -10,10 +10,10 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[13px] font-bold text-(--cp-text-primary)">
|
||||
<span class="text-[13px] font-bold text-cp-primary">
|
||||
{{ apiKey.name }}
|
||||
</span>
|
||||
<span v-if="apiKey.label" class="text-[12px] font-emphasis text-(--cp-text-tertiary)">
|
||||
<span v-if="apiKey.label" class="text-[12px] font-emphasis text-cp-tertiary">
|
||||
{{ apiKey.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Copy } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
|
||||
defineProps<{
|
||||
prefix: string
|
||||
@@ -15,11 +15,10 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="block min-w-0 truncate font-mono text-[12px] font-emphasis text-(--cp-text-primary)">
|
||||
<code class="block min-w-0 truncate font-mono text-[12px] font-emphasis text-cp-primary">
|
||||
{{ prefix }}••••••••••••••••
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制完整密钥"
|
||||
@@ -28,6 +27,6 @@ const emit = defineEmits<{
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps<{
|
||||
<template>
|
||||
<div class="grid w-full justify-items-center gap-1.5">
|
||||
<div v-if="apiKey.routingScope === 'all'" class="flex items-center justify-center gap-2">
|
||||
<span class="inline-flex h-6 items-center rounded-lg bg-(--cp-warning-bg) px-2 text-[11px] font-bold text-(--cp-warning-text)">
|
||||
<span class="inline-flex h-6 items-center rounded-lg bg-cp-warning-bg px-2 text-[11px] font-bold text-cp-warning-text">
|
||||
全部账号权限
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ defineProps<{
|
||||
<span
|
||||
class="inline-flex h-6 min-w-14 items-center justify-center rounded-full px-2 text-[12px] leading-none font-bold"
|
||||
:class="{
|
||||
'bg-(--cp-success-bg) text-(--cp-success-text)': apiKey.enabled,
|
||||
'bg-(--cp-bg-subtle) text-(--cp-text-secondary)': !apiKey.enabled,
|
||||
'bg-cp-success-bg text-cp-success-text': apiKey.enabled,
|
||||
'bg-cp-subtle text-cp-secondary': !apiKey.enabled,
|
||||
}"
|
||||
>
|
||||
{{ apiKey.enabled ? '已启用' : '已禁用' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Apple, Copy, Monitor } from '@lucide/vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
@@ -71,43 +72,43 @@ const visibleFiles = computed(() => [
|
||||
v-model="open"
|
||||
title="使用密钥"
|
||||
description="将下方内容分别保存到显示的 Codex CLI 配置文件"
|
||||
width="760px"
|
||||
size="lg"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<BaseSegmented v-model="activePlatform" :options="platformOptions" />
|
||||
<BaseSegmented v-model="activePlatform" label="配置平台" :options="platformOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<section
|
||||
v-for="file in visibleFiles"
|
||||
:key="file.path"
|
||||
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle) shadow-(--cp-shadow-control)"
|
||||
class="overflow-hidden rounded-cp-surface bg-cp-subtle shadow-cp-control"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="min-w-0 truncate font-mono text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
{{ file.path }}
|
||||
</span>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="default"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="复制"
|
||||
@click="emit('copy', file.content)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
<BaseScrollbar
|
||||
max-height="360px"
|
||||
view-class="mx-3 mb-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-input)"
|
||||
>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] font-emphasis text-(--cp-text-primary)"
|
||||
v-text="file.content"
|
||||
/>
|
||||
<div class="mx-3 mb-3 rounded-cp-control bg-cp-surface px-3.5 py-3 shadow-cp-input">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] font-emphasis text-cp-primary"
|
||||
v-text="file.content"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
const relaxedActionCellClass = '!px-4'
|
||||
import type { getApiKeys } from '@/api'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
|
||||
export const apiKeyColumns = [
|
||||
{ key: 'selection', label: '', width: '48px', minWidth: '48px', align: 'center' as const },
|
||||
{
|
||||
key: 'identity',
|
||||
label: '名称',
|
||||
sortable: true,
|
||||
sortKey: 'name',
|
||||
minWidth: '220px',
|
||||
flex: 0.9,
|
||||
},
|
||||
{ key: 'prefix', label: '密钥前缀', minWidth: '180px', flex: 1 },
|
||||
{
|
||||
key: 'enabled',
|
||||
label: '状态',
|
||||
sortable: true,
|
||||
width: '112px',
|
||||
minWidth: '112px',
|
||||
align: 'center' as const,
|
||||
},
|
||||
{ key: 'scope', label: '分组', width: '112px', minWidth: '112px', align: 'center' as const },
|
||||
{
|
||||
key: 'createdAtDisplay',
|
||||
label: '创建时间',
|
||||
sortable: true,
|
||||
sortKey: 'createdAt',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: 'font-mono text-[12px] font-emphasis tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number] & {
|
||||
createdAtDisplay: string
|
||||
}
|
||||
|
||||
export const apiKeyColumns = defineTableColumns<ApiKeyRow>([
|
||||
{ key: 'selection', kind: 'selection' },
|
||||
{ key: 'identity', label: '名称', kind: 'identity', size: 'xl', sortable: 'name' },
|
||||
{ key: 'prefix', label: '密钥前缀', kind: 'mono', size: '2xl' },
|
||||
{ key: 'enabled', label: '状态', kind: 'status', sortable: true },
|
||||
{ key: 'scope', label: '分组', kind: 'status' },
|
||||
{ key: 'createdAtDisplay', label: '创建时间', kind: 'datetime', sortable: 'createdAt' },
|
||||
{
|
||||
key: 'lastUsedAt',
|
||||
label: '最后使用',
|
||||
kind: 'datetime',
|
||||
sortable: true,
|
||||
sortKey: 'lastUsedAt',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
emptyText: '',
|
||||
headerClass: '!pl-8',
|
||||
cellClass: '!pl-8 text-(--cp-text-secondary)',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '208px',
|
||||
minWidth: '208px',
|
||||
headerClass: relaxedActionCellClass,
|
||||
cellClass: relaxedActionCellClass,
|
||||
},
|
||||
]
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'xl' },
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
|
||||
import { useAccountGroupCatalog } from '@/composables/useAccountGroupCatalog'
|
||||
@@ -105,7 +106,6 @@ watch(
|
||||
|
||||
<BaseCard
|
||||
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
|
||||
body-class="mt-3 flex min-h-0 flex-1"
|
||||
>
|
||||
<template #header>
|
||||
<ApiKeyFilters
|
||||
@@ -118,68 +118,72 @@ watch(
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="apiKeyColumns"
|
||||
:rows="apiKeys"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:pagination="apiKeyPagination"
|
||||
:sort="sort"
|
||||
empty-text="暂无 API Key"
|
||||
min-width="1500px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页密钥"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择密钥"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #identity="{ row }">
|
||||
<ApiKeyIdentityCell :api-key="row" />
|
||||
</template>
|
||||
<template #prefix="{ row }">
|
||||
<ApiKeyPrefixCell
|
||||
:prefix="row.prefix"
|
||||
:revealing="revealingKeyIds.has(row.id)"
|
||||
@copy="copyApiKey(row)"
|
||||
/>
|
||||
</template>
|
||||
<template #scope="{ row }">
|
||||
<ApiKeyScopeCell :api-key="row" />
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<ApiKeyStatusBadge :api-key="row" />
|
||||
</template>
|
||||
<template #lastUsedAt="{ row }">
|
||||
<LastUsedAtCell :value="row.lastUsedAt" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<ApiKeyActions
|
||||
:api-key="row"
|
||||
:deleting="deletingKey"
|
||||
:revealing="revealingKeyIds.has(row.id)"
|
||||
:updating-status="updatingStatusKeyIds.has(row.id)"
|
||||
@edit="openEdit"
|
||||
@delete="requestDeleteKey"
|
||||
@import-ccs="importToCcs"
|
||||
@toggle="handleToggleStatus"
|
||||
@use="openUseKeyModal"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="apiKeyColumns"
|
||||
:rows="apiKeys"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:sort="sort"
|
||||
empty-text="暂无 API Key"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<template #header-selection>
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="indeterminate"
|
||||
label="选择当前页密钥"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #selection="{ row }">
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.has(row.id)"
|
||||
label="选择密钥"
|
||||
@update:model-value="toggleSelection(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #identity="{ row }">
|
||||
<ApiKeyIdentityCell :api-key="row" />
|
||||
</template>
|
||||
<template #prefix="{ row }">
|
||||
<ApiKeyPrefixCell
|
||||
:prefix="row.prefix"
|
||||
:revealing="revealingKeyIds.has(row.id)"
|
||||
@copy="copyApiKey(row)"
|
||||
/>
|
||||
</template>
|
||||
<template #scope="{ row }">
|
||||
<ApiKeyScopeCell :api-key="row" />
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<ApiKeyStatusBadge :api-key="row" />
|
||||
</template>
|
||||
<template #lastUsedAt="{ row }">
|
||||
<LastUsedAtCell :value="row.lastUsedAt" />
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<ApiKeyActions
|
||||
:api-key="row"
|
||||
:deleting="deletingKey"
|
||||
:revealing="revealingKeyIds.has(row.id)"
|
||||
:updating-status="updatingStatusKeyIds.has(row.id)"
|
||||
@edit="openEdit"
|
||||
@delete="requestDeleteKey"
|
||||
@import-ccs="importToCcs"
|
||||
@toggle="handleToggleStatus"
|
||||
@use="openUseKeyModal"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination
|
||||
:pagination="apiKeyPagination"
|
||||
:loading="loading"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
@@ -208,7 +212,6 @@ watch(
|
||||
v-model="showAllAccountsConfirm"
|
||||
title="授予全部账号权限"
|
||||
description="保存后,该密钥可以使用所有账号。"
|
||||
variant="danger"
|
||||
confirm-text="确认授予全部账号"
|
||||
:loading="savingKey"
|
||||
@confirm="confirmAllAccountsScope"
|
||||
@@ -222,7 +225,7 @@ watch(
|
||||
v-model="showDeleteModal"
|
||||
title="确认删除"
|
||||
description="删除后这些 API Key 将立即失效,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="batchDeleting"
|
||||
@confirm="handleBatchDelete"
|
||||
@@ -236,7 +239,7 @@ watch(
|
||||
v-model="showSingleDeleteModal"
|
||||
title="删除 API Key"
|
||||
description="删除后该 API Key 将立即失效,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="deletingKey"
|
||||
@confirm="handleDelete"
|
||||
|
||||
@@ -149,10 +149,10 @@ const statusBars = computed(() => {
|
||||
if (!counts || counts.total === 0)
|
||||
return []
|
||||
return [
|
||||
{ pct: (counts.normal / counts.total) * 100, cls: 'bg-(--cp-success)' },
|
||||
{ pct: (counts.quotaExhausted / counts.total) * 100, cls: 'bg-(--cp-warning)' },
|
||||
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-(--cp-normal)' },
|
||||
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-(--cp-danger)' },
|
||||
{ pct: (counts.normal / counts.total) * 100, cls: 'bg-cp-success' },
|
||||
{ pct: (counts.quotaExhausted / counts.total) * 100, cls: 'bg-cp-warning' },
|
||||
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-cp-normal' },
|
||||
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-cp-danger' },
|
||||
].filter(b => b.pct > 0)
|
||||
})
|
||||
</script>
|
||||
@@ -163,27 +163,27 @@ const statusBars = computed(() => {
|
||||
class="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7"
|
||||
>
|
||||
<section class="min-w-0 w-full pb-6 xl:h-100.5 xl:pb-0">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
|
||||
账号调度
|
||||
</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-cp-secondary">
|
||||
容量、并发与分配策略
|
||||
</p>
|
||||
|
||||
<div class="mt-6.75 grid gap-4 xl:h-82.5 xl:grid-rows-[122px_90px_minmax(0,1fr)]">
|
||||
<div class="grid h-30.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-emphasis text-(--cp-text-secondary)">槽位占用</span>
|
||||
<div class="grid h-30.5 content-between rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-emphasis text-cp-secondary">槽位占用</span>
|
||||
<div>
|
||||
<div class="h-8.5">
|
||||
<strong
|
||||
class="font-mono text-[32px] leading-[1.05] font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="font-mono text-[32px] leading-[1.05] font-heavy tabular-nums text-cp-primary"
|
||||
>
|
||||
{{ usedRatio }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-cp-progress">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
class="block h-2.5 rounded-full bg-cp-success"
|
||||
:style="usedProgressStyle"
|
||||
/>
|
||||
</div>
|
||||
@@ -191,21 +191,21 @@ const statusBars = computed(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto"
|
||||
class="grid h-22.5 grid-cols-3 gap-4 rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto"
|
||||
>
|
||||
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-(--cp-text-secondary)">{{ stat.label }}</span>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">{{ stat.label }}</span>
|
||||
<strong
|
||||
class="block font-mono text-[21px] leading-[1.1] font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="block font-mono text-[21px] leading-[1.1] font-heavy tabular-nums text-cp-primary"
|
||||
>
|
||||
{{ stat.value }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid h-20.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4 xl:h-auto">
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-(--cp-text-secondary)">分配策略</span>
|
||||
<strong class="block text-[17px] leading-[1.15] font-emphasis text-(--cp-text-primary)">
|
||||
<div class="grid h-20.5 content-between rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto">
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">分配策略</span>
|
||||
<strong class="block text-[17px] leading-[1.15] font-emphasis text-cp-primary">
|
||||
{{ strategyLabel }}
|
||||
</strong>
|
||||
</div>
|
||||
@@ -213,10 +213,10 @@ const statusBars = computed(() => {
|
||||
</section>
|
||||
|
||||
<section class="min-w-0 w-full">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
|
||||
活跃账号用量
|
||||
</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-cp-secondary">
|
||||
最近使用排序
|
||||
</p>
|
||||
|
||||
@@ -225,7 +225,7 @@ const statusBars = computed(() => {
|
||||
>
|
||||
<BaseEmpty
|
||||
v-if="accounts.length === 0"
|
||||
compact
|
||||
size="sm"
|
||||
title="暂无账号用量"
|
||||
description="暂无账号请求记录"
|
||||
class="min-h-40 place-content-center xl:h-full"
|
||||
@@ -234,7 +234,7 @@ const statusBars = computed(() => {
|
||||
<article
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-(--cp-bg-subtle) px-3.5 py-3.5 transition-colors hover:bg-(--cp-bg-muted) xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
|
||||
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-subtle px-3.5 py-3.5 transition-colors xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
|
||||
>
|
||||
<AccountIdentityCell
|
||||
:account="account"
|
||||
@@ -256,20 +256,20 @@ const statusBars = computed(() => {
|
||||
class="grid min-w-0 grid-cols-[minmax(0,0.82fr)_minmax(0,0.64fr)_minmax(104px,1fr)] items-start gap-3 xl:contents"
|
||||
>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
<span class="text-[11px] leading-none font-bold text-cp-muted-text">
|
||||
{{ account.metricLabel }}
|
||||
</span>
|
||||
<strong
|
||||
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-primary"
|
||||
>
|
||||
{{ account.metricValue }}
|
||||
</strong>
|
||||
</span>
|
||||
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="text-[11px] leading-none font-bold text-(--cp-text-muted)">最近</span>
|
||||
<span class="text-[11px] leading-none font-bold text-cp-muted-text">最近</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-(--cp-text-secondary) xl:whitespace-nowrap"
|
||||
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-secondary xl:whitespace-nowrap"
|
||||
>
|
||||
{{ account.lastUsed }}
|
||||
</span>
|
||||
@@ -289,29 +289,29 @@ const statusBars = computed(() => {
|
||||
<section class="min-w-0 w-full pt-6 xl:h-100.5 xl:pt-0">
|
||||
<header class="flex h-12.5 items-start justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
|
||||
账号状态
|
||||
</h2>
|
||||
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)">
|
||||
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-emphasis text-cp-secondary">
|
||||
账号池健康结构
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid justify-items-end">
|
||||
<strong
|
||||
class="font-mono text-2xl leading-[1.05] font-heavy tabular-nums text-(--cp-success-text)"
|
||||
class="font-mono text-2xl leading-[1.05] font-heavy tabular-nums text-cp-success-text"
|
||||
>
|
||||
{{ normalRate }}
|
||||
</strong>
|
||||
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">正常率</span>
|
||||
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-cp-secondary">正常率</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-5.5 h-10.5 w-full">
|
||||
<div class="flex h-4 items-center justify-between">
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-(--cp-text-secondary)">状态分布</span>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-(--cp-danger-text)">不可用 {{ accountStatusCounts?.unavailable ?? '—' }}</span>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">状态分布</span>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-cp-danger-text">不可用 {{ accountStatusCounts?.unavailable ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)">
|
||||
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-cp-muted">
|
||||
<i
|
||||
v-for="(bar, bi) in statusBars"
|
||||
:key="bi"
|
||||
@@ -326,19 +326,19 @@ const statusBars = computed(() => {
|
||||
<div
|
||||
v-for="row in statusRows"
|
||||
:key="row.label"
|
||||
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-cp-overlay bg-cp-subtle px-3.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-7 items-center justify-center rounded-[9px]"
|
||||
class="inline-flex size-7 items-center justify-center rounded-cp-control"
|
||||
:class="toneIconClass(row.tone)"
|
||||
>
|
||||
<component :is="row.icon" :size="16" />
|
||||
</span>
|
||||
<span class="col-start-3 grid gap-1">
|
||||
<strong class="text-sm leading-[1.15] font-emphasis text-(--cp-text-primary)">
|
||||
<strong class="text-sm leading-[1.15] font-emphasis text-cp-primary">
|
||||
{{ row.label }}
|
||||
</strong>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-(--cp-text-secondary)">{{ row.description }}</span>
|
||||
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">{{ row.description }}</span>
|
||||
</span>
|
||||
<strong
|
||||
class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-heavy tabular-nums"
|
||||
|
||||
@@ -71,8 +71,8 @@ onBeforeUnmount(() => {
|
||||
aria-hidden="true"
|
||||
class="relative inline-flex size-2 shrink-0 items-center justify-center"
|
||||
>
|
||||
<span ref="heartbeatRing" class="absolute size-2 rounded-full bg-(--cp-success) opacity-0" />
|
||||
<span ref="heartbeatDot" class="relative size-1.5 rounded-full bg-(--cp-success)" />
|
||||
<span ref="heartbeatRing" class="absolute size-2 rounded-full bg-cp-success opacity-0" />
|
||||
<span ref="heartbeatDot" class="relative size-1.5 rounded-full bg-cp-success" />
|
||||
</span>
|
||||
<span aria-hidden="true">自动刷新 30s</span>
|
||||
</span>
|
||||
|
||||
@@ -23,26 +23,26 @@ const outcomeMeta = computed(() => [
|
||||
{
|
||||
label: '成功',
|
||||
value: props.point.successRequests,
|
||||
dotClass: 'bg-(--cp-success)',
|
||||
valueClass: 'text-(--cp-success-text)',
|
||||
dotClass: 'bg-cp-success',
|
||||
valueClass: 'text-cp-success-text',
|
||||
},
|
||||
{
|
||||
label: '服务失败',
|
||||
value: props.point.failedRequests,
|
||||
dotClass: 'bg-(--cp-danger)',
|
||||
valueClass: 'text-(--cp-danger-text)',
|
||||
dotClass: 'bg-cp-danger',
|
||||
valueClass: 'text-cp-danger-text',
|
||||
},
|
||||
{
|
||||
label: '客户端取消',
|
||||
value: props.point.cancelledRequests,
|
||||
dotClass: 'bg-(--cp-normal)',
|
||||
valueClass: 'text-(--cp-normal-text)',
|
||||
dotClass: 'bg-cp-normal',
|
||||
valueClass: 'text-cp-normal-text',
|
||||
},
|
||||
{
|
||||
label: '调用方错误',
|
||||
value: props.point.callerErrorRequests,
|
||||
dotClass: 'bg-(--cp-info)',
|
||||
valueClass: 'text-(--cp-info-text)',
|
||||
dotClass: 'bg-cp-info',
|
||||
valueClass: 'text-cp-info-text',
|
||||
},
|
||||
])
|
||||
const outcomeSegments = computed(() => {
|
||||
@@ -65,12 +65,12 @@ const metricItems = outcomeMeta
|
||||
<section
|
||||
role="dialog"
|
||||
:aria-label="`${point.time} 请求健康详情`"
|
||||
class="overflow-hidden rounded-(--cp-popover-radius)"
|
||||
class="overflow-hidden rounded-cp-overlay"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 bg-(--cp-bg-subtle) px-3.5 py-3">
|
||||
<header class="flex items-center justify-between gap-3 bg-cp-subtle px-3.5 py-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="size-2 shrink-0 rounded-full" :class="status.cellClass" />
|
||||
<strong class="font-mono text-[13px] leading-none font-heavy text-(--cp-text-primary)">
|
||||
<strong class="font-mono text-[13px] leading-none font-heavy text-cp-primary">
|
||||
{{ point.time }}
|
||||
</strong>
|
||||
<span
|
||||
@@ -80,7 +80,7 @@ const metricItems = outcomeMeta
|
||||
{{ status.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-(--cp-text-muted)">
|
||||
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">
|
||||
15 分钟
|
||||
</span>
|
||||
</header>
|
||||
@@ -88,7 +88,7 @@ const metricItems = outcomeMeta
|
||||
<div class="grid gap-3.5 px-3.5 py-3.5">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="m-0 text-[11px] leading-none font-emphasis text-(--cp-text-muted)">
|
||||
<p class="m-0 text-[11px] leading-none font-emphasis text-cp-muted-text">
|
||||
有效请求可用性
|
||||
</p>
|
||||
<strong
|
||||
@@ -99,11 +99,11 @@ const metricItems = outcomeMeta
|
||||
</strong>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="block text-[10px] leading-none font-emphasis text-(--cp-text-muted)">
|
||||
<span class="block text-[10px] leading-none font-emphasis text-cp-muted-text">
|
||||
有效请求
|
||||
</span>
|
||||
<strong
|
||||
class="mt-2 block font-mono text-[15px] leading-none font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="mt-2 block font-mono text-[15px] leading-none font-heavy tabular-nums text-cp-primary"
|
||||
>
|
||||
{{ formatHealthCount(eligibleRequests) }}
|
||||
</strong>
|
||||
@@ -111,7 +111,7 @@ const metricItems = outcomeMeta
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
|
||||
class="flex h-1.5 w-full overflow-hidden rounded-full bg-cp-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
@@ -127,10 +127,10 @@ const metricItems = outcomeMeta
|
||||
<div
|
||||
v-for="item in metricItems"
|
||||
:key="item.label"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-2.5 py-2.5"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-cp-control bg-cp-subtle px-2.5 py-2.5"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" :class="item.dotClass" />
|
||||
<span class="truncate text-[10px] leading-none font-emphasis text-(--cp-text-secondary)">
|
||||
<span class="truncate text-[10px] leading-none font-emphasis text-cp-secondary">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<strong
|
||||
@@ -143,7 +143,7 @@ const metricItems = outcomeMeta
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="m-0 border-t border-(--cp-divider-subtle) pt-3 text-[10px] leading-[1.45] font-emphasis text-(--cp-text-muted)"
|
||||
class="m-0 border-t border-cp-divider pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
客户端取消与调用方错误单独记录,不计入可用性。
|
||||
</p>
|
||||
|
||||
@@ -15,11 +15,11 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const trendToneClasses: Record<MetricTone, string> = {
|
||||
normal: 'bg-(--cp-normal-text)',
|
||||
info: 'bg-(--cp-info-text)',
|
||||
success: 'bg-(--cp-success-text)',
|
||||
warning: 'bg-(--cp-warning-text)',
|
||||
danger: 'bg-(--cp-danger-text)',
|
||||
normal: 'bg-cp-normal-text',
|
||||
info: 'bg-cp-info-text',
|
||||
success: 'bg-cp-success-text',
|
||||
warning: 'bg-cp-warning-text',
|
||||
danger: 'bg-cp-danger-text',
|
||||
}
|
||||
|
||||
const sparklineColors: Record<MetricTone, string> = {
|
||||
@@ -73,17 +73,17 @@ const sparklineOption = computed<EChartsOption | null>(() => {
|
||||
<div class="flex items-start gap-3">
|
||||
<BaseMotionIcon
|
||||
aria-hidden="true"
|
||||
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
|
||||
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-overlay"
|
||||
:class="metricToneIconClasses[metric.tone]"
|
||||
>
|
||||
<component :is="metric.icon" :size="18" />
|
||||
</BaseMotionIcon>
|
||||
<span class="mt-1 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)">{{ metric.title }}</span>
|
||||
<span class="mt-1 text-[13px] leading-[1.15] font-emphasis text-cp-secondary">{{ metric.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3.25 flex h-7.75 items-end gap-2">
|
||||
<strong
|
||||
class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-cp-primary"
|
||||
>
|
||||
<AnimatedMetricValue
|
||||
:value="metric.value"
|
||||
@@ -108,12 +108,12 @@ const sparklineOption = computed<EChartsOption | null>(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-[10px] bg-(--cp-bg-subtle) px-3"
|
||||
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-overlay bg-cp-subtle px-3"
|
||||
>
|
||||
<span
|
||||
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5"
|
||||
>
|
||||
<span class="shrink-0 text-[11px] leading-none font-emphasis text-(--cp-text-muted)">{{ metric.details[0]?.label }}</span>
|
||||
<span class="shrink-0 text-[11px] leading-none font-emphasis text-cp-muted-text">{{ metric.details[0]?.label }}</span>
|
||||
<b
|
||||
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
|
||||
:class="metric.details[0]?.tone ? metricToneValueClasses[metric.details[0].tone] : undefined"
|
||||
@@ -124,7 +124,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
|
||||
<span
|
||||
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,auto)] items-baseline justify-end gap-2.5"
|
||||
>
|
||||
<span class="shrink-0 text-[11px] leading-none font-emphasis text-(--cp-text-muted)">{{ metric.details[1]?.label }}</span>
|
||||
<span class="shrink-0 text-[11px] leading-none font-emphasis text-cp-muted-text">{{ metric.details[1]?.label }}</span>
|
||||
<b
|
||||
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
|
||||
:class="metric.details[1]?.tone ? metricToneValueClasses[metric.details[1].tone] : undefined"
|
||||
|
||||
@@ -209,13 +209,12 @@ onBeforeUnmount(() => {
|
||||
as="article"
|
||||
:title="timeline.title"
|
||||
:description="timeline.description"
|
||||
header-collapse-at="lg"
|
||||
class="w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div
|
||||
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-none font-emphasis text-(--cp-text-muted)"
|
||||
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-none font-emphasis text-cp-muted-text"
|
||||
>
|
||||
<span
|
||||
v-for="item in healthLegend"
|
||||
@@ -242,19 +241,16 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="mt-4.25">
|
||||
<div>
|
||||
<BasePopover
|
||||
v-model="popoverOpen"
|
||||
trigger="hover"
|
||||
placement="top"
|
||||
:offset="12"
|
||||
width="288px"
|
||||
:anchor-element="activeAnchor"
|
||||
:disabled="!activePoint"
|
||||
animate-position
|
||||
panel-class="!p-0 text-(--cp-text-primary)"
|
||||
trigger-class="w-full"
|
||||
class="block! min-w-0 w-full"
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
@@ -295,7 +291,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
|
||||
<div class="w-72 overflow-hidden rounded-cp-overlay">
|
||||
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
|
||||
</div>
|
||||
</BasePopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-model="activeKind"
|
||||
label="趋势指标"
|
||||
:options="tabs"
|
||||
class="w-full max-w-61.5 sm:w-61.5"
|
||||
@update:model-value="handleTrendChange"
|
||||
@@ -50,9 +51,9 @@ const {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="mt-4.5 grid gap-3.5">
|
||||
<div class="grid gap-3.5">
|
||||
<div
|
||||
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-(--cp-bg-subtle)/45 p-1.5"
|
||||
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-cp-subtle/45 p-1.5"
|
||||
>
|
||||
<button
|
||||
v-for="item in props.summary"
|
||||
@@ -60,7 +61,7 @@ const {
|
||||
type="button"
|
||||
:aria-label="`突出显示${item.label}曲线`"
|
||||
:aria-pressed="pinnedSummaryLabel === item.label"
|
||||
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
@click="toggleSummarySeries(item.label)"
|
||||
>
|
||||
<i
|
||||
@@ -70,20 +71,20 @@ const {
|
||||
:class="[
|
||||
isSummarySeriesActive(item.label) ? 'scale-125' : undefined,
|
||||
{
|
||||
'bg-(--cp-info)': item.tone === 'info',
|
||||
'bg-(--cp-success)': item.tone === 'success',
|
||||
'bg-(--cp-warning)': item.tone === 'warning',
|
||||
'bg-(--cp-danger)': item.tone === 'danger',
|
||||
'bg-(--cp-normal)': item.tone === 'normal',
|
||||
'bg-cp-info': item.tone === 'info',
|
||||
'bg-cp-success': item.tone === 'success',
|
||||
'bg-cp-warning': item.tone === 'warning',
|
||||
'bg-cp-danger': item.tone === 'danger',
|
||||
'bg-cp-normal': item.tone === 'normal',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span class="truncate text-[10px] leading-none font-bold text-(--cp-text-secondary)">
|
||||
<span class="truncate text-[10px] leading-none font-bold text-cp-secondary">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<strong
|
||||
class="truncate font-mono text-[15px] leading-none font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="truncate font-mono text-[15px] leading-none font-heavy tabular-nums text-cp-primary"
|
||||
:title="item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
@@ -96,10 +97,11 @@ const {
|
||||
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
|
||||
<BaseEmpty
|
||||
v-if="!hasSamples"
|
||||
compact
|
||||
size="sm"
|
||||
surface="none"
|
||||
title="暂无趋势数据"
|
||||
description="当日暂无请求日志"
|
||||
class="h-full place-content-center bg-transparent"
|
||||
class="h-full place-content-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,13 +22,12 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(column => column.k
|
||||
class="h-117 w-full"
|
||||
>
|
||||
<template #body>
|
||||
<div class="mt-4.25 flex h-91 w-full overflow-hidden">
|
||||
<div class="flex h-91 w-full overflow-hidden">
|
||||
<UsageRecordsTable
|
||||
class="min-w-0 flex-1"
|
||||
:columns="dashboardUsageRecordColumns"
|
||||
:rows="rows"
|
||||
empty-text="暂无成功记录"
|
||||
min-width="1824px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,7 +77,7 @@ const releaseStatus = computed(() => {
|
||||
return {
|
||||
label: '当前生效',
|
||||
title: '当前 Provider 请求正在使用此运行时画像',
|
||||
tone: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
tone: 'bg-cp-info-bg text-cp-info-text',
|
||||
icon: ShieldCheck,
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ const releaseStatus = computed(() => {
|
||||
return {
|
||||
label: '制品一致',
|
||||
title: '当前生效版本与官方最新发布一致',
|
||||
tone: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
tone: 'bg-cp-success-bg text-cp-success-text',
|
||||
icon: CheckCircle2,
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ const releaseStatus = computed(() => {
|
||||
return {
|
||||
label: '发现新版',
|
||||
title: `官方最新版本 ${releaseLabel.value}`,
|
||||
tone: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
|
||||
tone: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
icon: TriangleAlert,
|
||||
}
|
||||
}
|
||||
@@ -103,14 +103,14 @@ const releaseStatus = computed(() => {
|
||||
return {
|
||||
label: '检查失败',
|
||||
title: current.release.error || '官方版本检查失败',
|
||||
tone: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
tone: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
icon: TriangleAlert,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '待检查',
|
||||
title: '尚未检查官方发布渠道',
|
||||
tone: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
|
||||
tone: 'bg-cp-normal-bg text-cp-normal-text',
|
||||
icon: RefreshCw,
|
||||
}
|
||||
})
|
||||
@@ -182,15 +182,15 @@ function providerLabel(provider: string) {
|
||||
<BaseCard
|
||||
as="article"
|
||||
title="上游请求身份"
|
||||
body-class="flex min-h-0 flex-1 flex-col"
|
||||
class="flex min-h-95 w-full flex-col"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-if="providerOptions.length > 1"
|
||||
v-model="activeProvider"
|
||||
label="上游平台"
|
||||
:options="providerOptions"
|
||||
icon-only
|
||||
display="icon"
|
||||
class="w-21"
|
||||
/>
|
||||
</template>
|
||||
@@ -198,25 +198,25 @@ function providerLabel(provider: string) {
|
||||
<template #body>
|
||||
<BaseEmpty
|
||||
v-if="!profile"
|
||||
compact
|
||||
size="sm"
|
||||
title="暂无请求身份"
|
||||
class="mt-5 min-h-71.75 flex-1 place-content-center"
|
||||
class="min-h-71.75 flex-1 place-content-center"
|
||||
/>
|
||||
|
||||
<div v-else class="mt-5 flex flex-1">
|
||||
<div v-else class="flex flex-1">
|
||||
<section
|
||||
aria-label="请求身份组成"
|
||||
class="grid min-w-0 flex-1 content-between gap-6 rounded-[14px] bg-(--cp-bg-subtle) px-5 py-5.5 sm:px-6 sm:py-5"
|
||||
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-overlay bg-cp-subtle px-5 py-5.5 sm:px-6 sm:py-5"
|
||||
:class="verifiedLabel || checkedLabel
|
||||
? 'sm:grid-rows-[auto_minmax(0,1fr)_auto_auto]'
|
||||
: 'sm:grid-rows-[auto_minmax(0,1fr)_auto]'"
|
||||
>
|
||||
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-(--cp-text-primary)">
|
||||
<div class="flex min-w-0 items-center gap-2 text-cp-primary">
|
||||
<span
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-(--cp-bg-muted)"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-cp-muted"
|
||||
>
|
||||
<Box aria-hidden="true" class="size-3.75 text-(--cp-text-secondary)" />
|
||||
<Box aria-hidden="true" class="size-3.75 text-cp-secondary" />
|
||||
</span>
|
||||
<span class="truncate text-[11px] leading-none font-heavy">{{ profile.product }}</span>
|
||||
</div>
|
||||
@@ -237,20 +237,20 @@ function providerLabel(provider: string) {
|
||||
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
|
||||
:title="profile.version"
|
||||
>
|
||||
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-(--cp-text-primary)">
|
||||
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-cp-primary">
|
||||
{{ versionParts.release }}
|
||||
</span>
|
||||
<span
|
||||
v-if="versionParts.prerelease"
|
||||
aria-hidden="true"
|
||||
class="truncate text-[12px] font-bold text-(--cp-text-secondary)"
|
||||
class="truncate text-[12px] font-bold text-cp-secondary"
|
||||
>
|
||||
{{ versionParts.prerelease }}
|
||||
</span>
|
||||
</strong>
|
||||
<span
|
||||
v-if="profile.build"
|
||||
class="shrink-0 font-mono text-[10px] leading-none font-emphasis tabular-nums text-(--cp-text-muted)"
|
||||
class="shrink-0 font-mono text-[10px] leading-none font-emphasis tabular-nums text-cp-muted-text"
|
||||
>
|
||||
Build {{ profile.build }}
|
||||
</span>
|
||||
@@ -265,13 +265,13 @@ function providerLabel(provider: string) {
|
||||
>
|
||||
<div v-if="profile.provider === 'xai'" class="min-w-0">
|
||||
<dt
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-(--cp-text-muted)"
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
|
||||
>
|
||||
<ShieldCheck aria-hidden="true" class="size-3.25 text-(--cp-info)" />
|
||||
<ShieldCheck aria-hidden="true" class="size-3.25 text-cp-info" />
|
||||
认证协议
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-(--cp-text-primary)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-cp-primary"
|
||||
:title="authProtocol"
|
||||
>
|
||||
{{ authProtocol }}
|
||||
@@ -280,19 +280,19 @@ function providerLabel(provider: string) {
|
||||
|
||||
<div class="min-w-0">
|
||||
<dt
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-(--cp-text-muted)"
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
|
||||
>
|
||||
<Monitor aria-hidden="true" class="size-3.25 text-(--cp-normal)" />
|
||||
<Monitor aria-hidden="true" class="size-3.25 text-cp-normal" />
|
||||
{{ profile.provider === 'openai' ? '模拟运行环境' : '运行环境' }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-(--cp-text-primary)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-cp-primary"
|
||||
:title="runtimeEnvironment.title"
|
||||
>
|
||||
{{ runtimeEnvironment.primary }}
|
||||
<span
|
||||
v-if="runtimeEnvironment.details.length"
|
||||
class="text-[11px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="text-[11px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<template v-for="detail in runtimeEnvironment.details" :key="detail">
|
||||
· {{ detail }}
|
||||
@@ -303,13 +303,13 @@ function providerLabel(provider: string) {
|
||||
|
||||
<div class="min-w-0">
|
||||
<dt
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-(--cp-text-muted)"
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
|
||||
>
|
||||
<Terminal aria-hidden="true" class="size-3.25 text-(--cp-info)" />
|
||||
<Terminal aria-hidden="true" class="size-3.25 text-cp-info" />
|
||||
客户端标识
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-cp-primary"
|
||||
:title="clientIdentity"
|
||||
>
|
||||
{{ clientIdentity }}
|
||||
@@ -319,7 +319,7 @@ function providerLabel(provider: string) {
|
||||
|
||||
<footer
|
||||
v-if="verifiedLabel || checkedLabel"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-emphasis text-(--cp-text-muted)"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-emphasis text-cp-muted-text"
|
||||
>
|
||||
<span v-if="verifiedLabel" :title="profile.userAgent">{{ verifiedLabel }}</span>
|
||||
<span v-if="checkedLabel">{{ checkedLabel }}</span>
|
||||
|
||||
@@ -2,19 +2,19 @@ import type { MetricTone } from './composables/presenter'
|
||||
|
||||
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
|
||||
export const metricToneIconClasses: Record<MetricTone, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success)',
|
||||
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
|
||||
normal: 'bg-cp-normal-bg text-cp-normal',
|
||||
info: 'bg-cp-info-bg text-cp-info',
|
||||
success: 'bg-cp-success-bg text-cp-success',
|
||||
warning: 'bg-cp-warning-bg text-cp-warning',
|
||||
danger: 'bg-cp-danger-bg text-cp-danger',
|
||||
}
|
||||
|
||||
export const metricToneValueClasses: Record<MetricTone, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-info-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
warning: 'text-(--cp-warning-text)',
|
||||
danger: 'text-(--cp-danger-text)',
|
||||
normal: 'text-cp-normal-text',
|
||||
info: 'text-cp-info-text',
|
||||
success: 'text-cp-success-text',
|
||||
warning: 'text-cp-warning-text',
|
||||
danger: 'text-cp-danger-text',
|
||||
}
|
||||
|
||||
export type HealthStatus
|
||||
@@ -59,49 +59,49 @@ export const healthLegend = [
|
||||
export const healthStatusMeta: Record<HealthStatus, HealthStatusMeta> = {
|
||||
future: {
|
||||
label: '未来',
|
||||
cellClass: 'bg-(--cp-disabled-bg) opacity-60',
|
||||
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-muted)',
|
||||
cellClass: 'bg-cp-disabled opacity-60',
|
||||
badgeClass: 'bg-cp-muted text-cp-muted-text',
|
||||
},
|
||||
no_data: {
|
||||
label: '无有效样本',
|
||||
cellClass: 'bg-(--cp-default-border-hover)',
|
||||
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
|
||||
cellClass: 'bg-cp-default-border-hover',
|
||||
badgeClass: 'bg-cp-muted text-cp-secondary',
|
||||
},
|
||||
unavailable: {
|
||||
label: '不可达',
|
||||
cellClass: 'bg-(--cp-danger)',
|
||||
badgeClass: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
cellClass: 'bg-cp-danger',
|
||||
badgeClass: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
},
|
||||
unstable: {
|
||||
label: '不稳定',
|
||||
cellClass: 'bg-(--cp-warning)',
|
||||
badgeClass: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
|
||||
cellClass: 'bg-cp-warning',
|
||||
badgeClass: 'bg-cp-warning-bg text-cp-warning-text',
|
||||
},
|
||||
low_sample: {
|
||||
label: '低样本',
|
||||
cellClass: 'bg-(--cp-normal)',
|
||||
badgeClass: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
|
||||
cellClass: 'bg-cp-normal',
|
||||
badgeClass: 'bg-cp-normal-bg text-cp-normal-text',
|
||||
},
|
||||
stable: {
|
||||
label: '稳定',
|
||||
cellClass: 'bg-(--cp-success)',
|
||||
badgeClass: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
cellClass: 'bg-cp-success',
|
||||
badgeClass: 'bg-cp-success-bg text-cp-success-text',
|
||||
},
|
||||
}
|
||||
|
||||
export function healthReliabilityValueClass(successRequests: number, failedRequests: number) {
|
||||
const eligibleRequests = Math.max(0, successRequests) + Math.max(0, failedRequests)
|
||||
if (eligibleRequests === 0)
|
||||
return 'text-(--cp-text-muted)'
|
||||
return 'text-cp-muted-text'
|
||||
|
||||
const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100
|
||||
if (reliability >= 99.5)
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-success-text'
|
||||
if (reliability >= 98)
|
||||
return 'text-(--cp-normal-text)'
|
||||
return 'text-cp-normal-text'
|
||||
if (reliability >= 95)
|
||||
return 'text-(--cp-warning-text)'
|
||||
return 'text-(--cp-danger-text)'
|
||||
return 'text-cp-warning-text'
|
||||
return 'text-cp-danger-text'
|
||||
}
|
||||
|
||||
export function formatHealthCount(value: number) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
|
||||
import AccountOverviewCard from './components/AccountOverviewCard.vue'
|
||||
@@ -35,24 +35,25 @@ const {
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<BasePageHeader title="系统概览" actions-inline>
|
||||
<BasePageHeader title="系统概览">
|
||||
<template #description>
|
||||
<span>当日统计</span>
|
||||
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<BaseButton
|
||||
icon-only
|
||||
class="text-(--cp-normal)"
|
||||
<BaseIconButton
|
||||
class="text-cp-normal"
|
||||
size="md"
|
||||
label="刷新概览"
|
||||
spin-icon-on-loading
|
||||
:loading="loading || refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="animate-spin motion-reduce:animate-none" :size="19" />
|
||||
</template>
|
||||
<RefreshCw :size="19" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
||||
|
||||
@@ -48,7 +49,7 @@ function togglePasswordVisible(): void {
|
||||
<template>
|
||||
<BaseCard
|
||||
as="form"
|
||||
layout="flush"
|
||||
padding="none"
|
||||
class="login-form relative grid min-h-129.25 w-[min(440px,100%)] gap-3 rounded-lg px-7.5 pt-6.5 pb-6 max-[560px]:min-h-auto max-[560px]:gap-3 max-[560px]:p-5.5"
|
||||
@submit.prevent="emit('submit')"
|
||||
>
|
||||
@@ -139,8 +140,7 @@ function togglePasswordVisible(): void {
|
||||
<KeyRound :size="17" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="login-password-toggle"
|
||||
@@ -150,7 +150,7 @@ function togglePasswordVisible(): void {
|
||||
>
|
||||
<EyeOff v-if="isPasswordVisible" :size="16" />
|
||||
<Eye v-else :size="16" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
@@ -194,11 +194,11 @@ function togglePasswordVisible(): void {
|
||||
--cp-input-current-bg: var(--login-input-bg);
|
||||
--cp-input-current-bg-hover: var(--login-input-bg-hover);
|
||||
--cp-input-soft-bg-focus: var(--login-input-focus);
|
||||
--cp-button-radius-base: 6px;
|
||||
--cp-icon-button-radius: 6px;
|
||||
--cp-radius-control-sm: 6px;
|
||||
--cp-radius-control: 6px;
|
||||
--cp-shadow-control: none;
|
||||
--cp-shadow-popover: none;
|
||||
--cp-input-height-default: 43px;
|
||||
--cp-control-height-md: 43px;
|
||||
|
||||
background:
|
||||
linear-gradient(118deg, var(--login-form-bg-a), var(--login-form-bg-b) 56%, var(--login-form-bg-c)),
|
||||
|
||||
@@ -91,7 +91,7 @@ async function handleSubmit(): Promise<void> {
|
||||
--login-route-stream: color-mix(in srgb, var(--cp-info) 17%, transparent);
|
||||
--login-route-audit: color-mix(in srgb, var(--cp-text-tertiary) 18%, transparent);
|
||||
--login-semantic: color-mix(in srgb, var(--cp-info) 26%, transparent);
|
||||
--login-particle: var(--cp-accent-primary);
|
||||
--login-particle: var(--cp-brand-accent);
|
||||
--login-particle-glow: color-mix(in srgb, var(--cp-info) 62%, transparent);
|
||||
--login-watermark: color-mix(in srgb, var(--cp-text-primary) 55%, transparent);
|
||||
--login-stack-text: color-mix(in srgb, var(--cp-text-primary) 84%, transparent);
|
||||
@@ -108,7 +108,7 @@ async function handleSubmit(): Promise<void> {
|
||||
--login-form-shadow: color-mix(in srgb, var(--cp-text-primary) 20%, transparent);
|
||||
--login-form-line: color-mix(in srgb, var(--cp-info-border) 54%, transparent);
|
||||
--login-logo-bg: var(--cp-brand-mark-bg);
|
||||
--login-logo-text: var(--cp-accent-primary);
|
||||
--login-logo-text: var(--cp-brand-accent);
|
||||
--login-title: var(--cp-text-primary);
|
||||
--login-brand-title: var(--cp-text-strong);
|
||||
--login-brand-caption: var(--cp-text-secondary);
|
||||
@@ -122,10 +122,10 @@ async function handleSubmit(): Promise<void> {
|
||||
--login-error-bg: var(--cp-danger-bg);
|
||||
--login-error-icon: var(--cp-danger);
|
||||
--login-error-text: var(--cp-danger-text);
|
||||
--login-button: var(--cp-accent-primary);
|
||||
--login-button: var(--cp-brand-accent);
|
||||
--login-button-hover: var(--cp-info-hover);
|
||||
--login-button-active: var(--cp-info-pressed);
|
||||
--login-button-shadow: color-mix(in srgb, var(--cp-accent-primary) 30%, transparent);
|
||||
--login-button-shadow: color-mix(in srgb, var(--cp-brand-accent) 30%, transparent);
|
||||
--login-footer: color-mix(in srgb, var(--cp-text-secondary) 82%, var(--cp-text-tertiary));
|
||||
--login-toggle-bg: color-mix(in srgb, var(--cp-bg-muted) 74%, transparent);
|
||||
--login-toggle-sun: var(--login-logo-text);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Copy, KeyRound, Trash2 } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
||||
|
||||
interface AdminApiKeyStatus {
|
||||
@@ -28,12 +29,11 @@ const emit = defineEmits<{
|
||||
<BaseCard
|
||||
title="管理员 API Key"
|
||||
description="用于外部系统集成,具有管理员权限"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
:loading="regenerating"
|
||||
:disabled="loading || deleting"
|
||||
@click="emit('regenerate')"
|
||||
@@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||
{{ status.exists ? '重新生成' : '生成' }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="danger"
|
||||
variant="destructive"
|
||||
:disabled="loading || regenerating || !status.exists"
|
||||
@click="emit('requestDelete')"
|
||||
>
|
||||
@@ -58,21 +58,21 @@ const emit = defineEmits<{
|
||||
|
||||
<div class="grid max-w-6xl 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"
|
||||
class="flex min-h-16 items-center justify-between gap-4 rounded-cp-control bg-cp-subtle px-4 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<BaseMotionIcon
|
||||
aria-hidden="true"
|
||||
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)"
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-surface text-cp-normal shadow-cp-control"
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
</BaseMotionIcon>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-bold text-cp-primary">
|
||||
{{ status.exists ? '已启用' : '未生成' }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-1.5 mb-0 truncate text-[12px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
|
||||
class="mt-1.5 mb-0 truncate text-[12px] leading-[1.15] font-emphasis text-cp-secondary"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
@@ -87,18 +87,18 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
|
||||
<div v-if="generatedKey" class="grid gap-2">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-emphasis text-cp-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-emphasis break-all text-(--cp-text-primary)"
|
||||
class="min-w-0 flex-1 rounded-cp-control bg-cp-subtle px-3 py-2.5 font-mono text-[12px] leading-normal font-emphasis break-all text-cp-primary"
|
||||
>
|
||||
{{ generatedKey }}
|
||||
</code>
|
||||
<BaseButton icon-only size="md" title="复制" @click="emit('copy')">
|
||||
<BaseIconButton size="md" label="复制" @click="emit('copy')">
|
||||
<Copy class="size-4" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitBranch, Plus, Trash2 } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
@@ -25,38 +26,37 @@ const emit = defineEmits<{
|
||||
<BaseCard
|
||||
title="模型映射"
|
||||
description="配置请求模型与上游模型的映射关系"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<BaseButton variant="default" :disabled="loading" @click="emit('addMapping')">
|
||||
<BaseButton variant="secondary" :disabled="loading" @click="emit('addMapping')">
|
||||
<template #icon>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
添加映射
|
||||
</BaseButton>
|
||||
<span v-if="error" class="text-xs font-emphasis text-(--cp-danger-text)">{{ error }}</span>
|
||||
<span v-if="error" class="text-xs font-emphasis text-cp-danger-text">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[12px] font-emphasis text-(--cp-text-secondary)">
|
||||
<GitBranch class="size-4 text-(--cp-info)" />
|
||||
<div class="flex items-center gap-2 text-[12px] font-emphasis text-cp-secondary">
|
||||
<GitBranch class="size-4 text-cp-info" />
|
||||
全局模型映射
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-4 text-[13px] font-emphasis text-(--cp-text-muted)"
|
||||
class="rounded-cp-control bg-cp-subtle px-4 py-4 text-[13px] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
正在加载模型映射...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mappings.length === 0"
|
||||
class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-4 text-[13px] font-emphasis text-(--cp-text-muted)"
|
||||
class="rounded-cp-control bg-cp-subtle px-4 py-4 text-[13px] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
暂无模型映射
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-3 px-1 text-[12px] font-bold text-(--cp-text-muted)">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-3 px-1 text-[12px] font-bold text-cp-muted-text">
|
||||
<span>请求模型</span>
|
||||
<span />
|
||||
<span>上游模型</span>
|
||||
@@ -65,7 +65,7 @@ const emit = defineEmits<{
|
||||
<div
|
||||
v-for="(row, index) in mappings"
|
||||
:key="index"
|
||||
class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) p-3"
|
||||
class="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-3 rounded-cp-surface bg-cp-subtle p-3"
|
||||
>
|
||||
<BaseInput
|
||||
:model-value="row.requestedModel"
|
||||
@@ -73,21 +73,20 @@ const emit = defineEmits<{
|
||||
aria-label="请求模型"
|
||||
@update:model-value="emit('updateMapping', index, 'requestedModel', $event)"
|
||||
/>
|
||||
<span class="text-(--cp-text-muted)">→</span>
|
||||
<span class="text-cp-muted-text">→</span>
|
||||
<BaseInput
|
||||
:model-value="row.upstreamModel"
|
||||
placeholder="gpt-5.5"
|
||||
aria-label="上游模型名称"
|
||||
@update:model-value="emit('updateMapping', index, 'upstreamModel', $event)"
|
||||
/>
|
||||
<BaseButton
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
icon-only
|
||||
label="删除映射"
|
||||
@click="emit('removeMapping', index)"
|
||||
>
|
||||
<Trash2 class="size-4 text-(--cp-danger)" />
|
||||
</BaseButton>
|
||||
<Trash2 class="size-4 text-cp-danger" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,34 +16,33 @@ const model = defineModel<RotationStrategy | ''>({ required: true })
|
||||
<BaseCard
|
||||
title="调度策略"
|
||||
description="决定每次请求如何调度账号池"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<div class="grid max-w-6xl gap-3 lg:grid-cols-4">
|
||||
<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="min-h-25 cursor-pointer rounded-cp-control border-0 px-4 py-3.5 text-left shadow-cp-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)'
|
||||
? 'bg-cp-info-bg text-cp-info-text shadow-cp-control'
|
||||
: 'bg-(--cp-input-current-bg,var(--cp-input-context-bg)) text-cp-primary hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-cp-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)]"
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-cp-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'"
|
||||
:class="model === option.value ? 'bg-cp-info opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-[14px] leading-[1.15] font-heavy">{{ option.label }}</span>
|
||||
</span>
|
||||
<span class="mt-2 block text-[13px] leading-normal font-emphasis text-(--cp-text-secondary)">
|
||||
<span class="mt-2 block text-[13px] leading-normal font-emphasis text-cp-secondary">
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -16,9 +16,8 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
|
||||
<BaseCard
|
||||
title="运行参数"
|
||||
description="请求节奏、账号并发和 Token 刷新"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<BaseForm :columns="2" class="max-w-6xl">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem
|
||||
label="单账号默认最大并发"
|
||||
description="账号未单独设置时使用的并发上限"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupRecord } from '@/api'
|
||||
|
||||
import type { BaseTableColumn } from '@/components/base/BaseTable/columns'
|
||||
|
||||
import { Download, Play, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
@@ -21,6 +22,7 @@ defineProps<{
|
||||
error: string
|
||||
activeBackup: boolean
|
||||
creating: boolean
|
||||
refreshing: boolean
|
||||
deleting: boolean
|
||||
deleteTarget: BackupRecord | null
|
||||
downloadStates: Record<string, boolean>
|
||||
@@ -37,16 +39,16 @@ const emit = defineEmits<{
|
||||
cancelDelete: []
|
||||
}>()
|
||||
|
||||
const columns: BaseTableColumn<BackupRecord>[] = [
|
||||
{ key: 'id', label: 'ID', width: 150 },
|
||||
{ key: 'status', label: '状态', width: 110 },
|
||||
{ key: 'fileName', label: '文件名', width: '28%', ellipsis: true },
|
||||
{ key: 'sizeBytes', label: '大小', width: 100 },
|
||||
{ key: 'expiresAt', label: '过期时间', width: 170 },
|
||||
{ key: 'trigger', label: '触发方式', width: 100 },
|
||||
{ key: 'startedAt', label: '开始时间', width: 170 },
|
||||
{ key: 'actions', label: '操作', width: 112, minWidth: 112, fixed: 'right' },
|
||||
]
|
||||
const columns = defineTableColumns<BackupRecord>([
|
||||
{ key: 'id', label: 'ID', kind: 'mono', size: 'lg' },
|
||||
{ key: 'status', label: '状态', kind: 'status' },
|
||||
{ key: 'fileName', label: '文件名', kind: 'text' },
|
||||
{ key: 'sizeBytes', label: '大小', kind: 'numeric' },
|
||||
{ key: 'expiresAt', label: '过期时间', kind: 'datetime' },
|
||||
{ key: 'trigger', label: '触发方式', kind: 'status' },
|
||||
{ key: 'startedAt', label: '开始时间', kind: 'datetime' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions' },
|
||||
])
|
||||
|
||||
function triggerLabel(value: string): string {
|
||||
return value === 'manual' ? '手动' : '计划'
|
||||
@@ -92,7 +94,6 @@ function canDelete(record: BackupRecord): boolean {
|
||||
<BaseCard
|
||||
title="备份记录"
|
||||
description="创建手动备份和管理已有备份记录"
|
||||
body-class="mt-3"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -107,7 +108,15 @@ function canDelete(record: BackupRecord): boolean {
|
||||
</template>
|
||||
{{ creating ? '创建中...' : '创建备份' }}
|
||||
</BaseButton>
|
||||
<BaseButton variant="default" :disabled="loading" @click="emit('refresh')">
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:loading="refreshing"
|
||||
:disabled="loading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" />
|
||||
</template>
|
||||
@@ -116,95 +125,86 @@ function canDelete(record: BackupRecord): boolean {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="columns"
|
||||
:rows="records"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
}"
|
||||
empty-text="暂无备份记录"
|
||||
min-width="1180px"
|
||||
@page-change="emit('pageChange', $event)"
|
||||
@page-size-change="emit('pageSizeChange', $event)"
|
||||
>
|
||||
<template #id="{ row }">
|
||||
<span class="text-(--cp-text-secondary)" :title="row.id">{{ shortId(row.id) }}</span>
|
||||
</template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="columns"
|
||||
:rows="records"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
empty-text="暂无备份记录"
|
||||
>
|
||||
<template #id="{ row }">
|
||||
<span class="text-cp-secondary" :title="row.id">{{ shortId(row.id) }}</span>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<BackupStatusBadge :status="row.status" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<BackupStatusBadge :status="row.status" />
|
||||
</template>
|
||||
|
||||
<template #fileName="{ row }">
|
||||
<span class="text-(--cp-text-secondary)" :title="row.objectKey">{{ formatFileName(row) }}</span>
|
||||
</template>
|
||||
<template #fileName="{ row }">
|
||||
<span class="text-cp-secondary" :title="row.objectKey">{{ formatFileName(row) }}</span>
|
||||
</template>
|
||||
|
||||
<template #sizeBytes="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">{{ formatSize(row.sizeBytes) }}</span>
|
||||
</template>
|
||||
<template #sizeBytes="{ row }">
|
||||
<span class="text-cp-secondary">{{ formatSize(row.sizeBytes) }}</span>
|
||||
</template>
|
||||
|
||||
<template #expiresAt="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ row.expiresAt ? formatDateTime(row.expiresAt) : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #expiresAt="{ row }">
|
||||
<span class="text-cp-secondary">
|
||||
{{ row.expiresAt ? formatDateTime(row.expiresAt) : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #trigger="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ triggerLabel(row.triggerKind) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #trigger="{ row }">
|
||||
<span class="text-cp-secondary">
|
||||
{{ triggerLabel(row.triggerKind) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #startedAt="{ row }">
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
{{ row.startedAt ? formatDateTime(row.startedAt) : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #startedAt="{ row }">
|
||||
<span class="text-cp-secondary">
|
||||
{{ row.startedAt ? formatDateTime(row.startedAt) : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
icon-only
|
||||
:disabled="!canDownload(row)"
|
||||
:loading="Boolean(downloadStates[row.id])"
|
||||
title="下载备份"
|
||||
aria-label="下载备份"
|
||||
@click="emit('download', row)"
|
||||
>
|
||||
<template #icon>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
:disabled="!canDownload(row)"
|
||||
:loading="Boolean(downloadStates[row.id])"
|
||||
label="下载备份"
|
||||
@click="emit('download', row)"
|
||||
>
|
||||
<Download class="size-4" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
icon-only
|
||||
:disabled="!canDelete(row)"
|
||||
title="删除备份"
|
||||
aria-label="删除备份"
|
||||
@click="emit('requestDelete', row)"
|
||||
>
|
||||
<template #icon>
|
||||
</BaseIconButton>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
:disabled="!canDelete(row)"
|
||||
label="删除备份"
|
||||
@click="emit('requestDelete', row)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination
|
||||
:pagination="{ page, pageSize, total }"
|
||||
:loading="loading"
|
||||
@page-change="emit('pageChange', $event)"
|
||||
@page-size-change="emit('pageSizeChange', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseConfirmModal
|
||||
:model-value="deleteTarget !== null"
|
||||
title="删除备份"
|
||||
description="将删除远端对象并移除记录,此操作不可撤销"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="deleting"
|
||||
width="480px"
|
||||
@update:model-value="emit('cancelDelete')"
|
||||
@confirm="emit('confirmDelete')"
|
||||
>
|
||||
|
||||
@@ -42,7 +42,6 @@ const TIMEZONE_OPTIONS = [
|
||||
<BaseCard
|
||||
title="定时备份"
|
||||
description="配置自动定时备份"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="loading" @click="emit('save')">
|
||||
@@ -54,7 +53,7 @@ const TIMEZONE_OPTIONS = [
|
||||
</template>
|
||||
|
||||
<div class="@container">
|
||||
<BaseForm :columns="2" class="max-w-6xl @max-[640px]:grid-cols-1!">
|
||||
<BaseForm class="max-w-6xl @min-[640px]:grid-cols-2">
|
||||
<div class="col-span-2 flex items-center gap-4 @max-[640px]:col-span-1">
|
||||
<BaseCheckbox
|
||||
v-model="schedule.scheduleEnabled"
|
||||
|
||||
@@ -37,30 +37,30 @@ const label = computed(() => STATUS_LABELS[props.status] || props.status)
|
||||
const textClass = computed(() => {
|
||||
switch (tone.value) {
|
||||
case 'success':
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-success-text'
|
||||
case 'danger':
|
||||
return 'text-(--cp-danger-text)'
|
||||
return 'text-cp-danger-text'
|
||||
case 'warning':
|
||||
return 'text-(--cp-warning-text)'
|
||||
return 'text-cp-warning-text'
|
||||
case 'info':
|
||||
return 'text-(--cp-info-text)'
|
||||
return 'text-cp-info-text'
|
||||
default:
|
||||
return 'text-(--cp-text-secondary)'
|
||||
return 'text-cp-secondary'
|
||||
}
|
||||
})
|
||||
|
||||
const dotClass = computed(() => {
|
||||
switch (tone.value) {
|
||||
case 'success':
|
||||
return 'bg-(--cp-success)'
|
||||
return 'bg-cp-success'
|
||||
case 'danger':
|
||||
return 'bg-(--cp-danger)'
|
||||
return 'bg-cp-danger'
|
||||
case 'warning':
|
||||
return 'bg-(--cp-warning)'
|
||||
return 'bg-cp-warning'
|
||||
case 'info':
|
||||
return 'bg-(--cp-info)'
|
||||
return 'bg-cp-info'
|
||||
default:
|
||||
return 'bg-(--cp-text-muted)'
|
||||
return 'bg-cp-muted-text'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
|
||||
interface StorageForm {
|
||||
@@ -52,14 +53,13 @@ function toggleSecretVisible(): void {
|
||||
<template>
|
||||
<BaseCard
|
||||
title="S3 存储配置"
|
||||
body-class="mt-5"
|
||||
>
|
||||
<template #description>
|
||||
<span class="text-(--cp-text-secondary)">
|
||||
<span class="text-cp-secondary">
|
||||
配置 S3 兼容存储(支持
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-(--cp-info) underline underline-offset-2"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 text-cp-info underline underline-offset-2"
|
||||
@click="emit('openR2Guide')"
|
||||
>
|
||||
Cloudflare R2
|
||||
@@ -71,7 +71,7 @@ function toggleSecretVisible(): void {
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
:loading="testing"
|
||||
:disabled="loading"
|
||||
:title="verified ? '已通过连接测试' : '尚未通过连接测试'"
|
||||
@@ -80,12 +80,12 @@ function toggleSecretVisible(): void {
|
||||
<template #icon>
|
||||
<CircleCheck
|
||||
v-if="verified"
|
||||
class="size-4 text-(--cp-success-text)"
|
||||
class="size-4 text-cp-success-text"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CircleAlert
|
||||
v-else
|
||||
class="size-4 text-(--cp-warning-text)"
|
||||
class="size-4 text-cp-warning-text"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
@@ -101,7 +101,7 @@ function toggleSecretVisible(): void {
|
||||
</template>
|
||||
|
||||
<div class="@container">
|
||||
<BaseForm :columns="2" class="max-w-6xl @max-[640px]:grid-cols-1!">
|
||||
<BaseForm class="max-w-6xl @min-[640px]:grid-cols-2">
|
||||
<BaseFormItem label="端点地址" description="S3 兼容服务的 HTTPS 地址">
|
||||
<BaseInput
|
||||
v-model="storage.endpoint"
|
||||
@@ -134,8 +134,7 @@ function toggleSecretVisible(): void {
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #suffix>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="accessKeyVisible ? '隐藏 Access Key ID' : '显示 Access Key ID'"
|
||||
@@ -144,7 +143,7 @@ function toggleSecretVisible(): void {
|
||||
>
|
||||
<EyeOff v-if="accessKeyVisible" :size="16" />
|
||||
<Eye v-else :size="16" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
@@ -157,8 +156,7 @@ function toggleSecretVisible(): void {
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<template #suffix>
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="secretVisible ? '隐藏 Secret Access Key' : '显示 Secret Access Key'"
|
||||
@@ -167,7 +165,7 @@ function toggleSecretVisible(): void {
|
||||
>
|
||||
<EyeOff v-if="secretVisible" :size="16" />
|
||||
<Eye v-else :size="16" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
|
||||
defineProps<{
|
||||
width?: string | number
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
@@ -13,50 +9,50 @@ const open = defineModel<boolean>({ default: false })
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="Cloudflare R2 接入指南"
|
||||
:width="width ?? '640px'"
|
||||
size="lg"
|
||||
>
|
||||
<div class="flex flex-col gap-4 text-[13px] leading-relaxed text-(--cp-text-secondary)">
|
||||
<div class="flex flex-col gap-4 text-[13px] leading-relaxed text-cp-secondary">
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h3 class="text-[14px] font-heavy text-(--cp-normal)">
|
||||
<h3 class="text-[14px] font-heavy text-cp-normal">
|
||||
1. 创建存储桶
|
||||
</h3>
|
||||
<p class="m-0">
|
||||
在 Cloudflare Dashboard 进入 R2,创建一个私有存储桶(例如
|
||||
<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">codex-proxy-backups</code>)。
|
||||
<code class="rounded bg-cp-subtle px-1.5 py-0.5">codex-proxy-backups</code>)。
|
||||
桶必须保持私有,不要开放公共读取。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h3 class="text-[14px] font-heavy text-(--cp-normal)">
|
||||
<h3 class="text-[14px] font-heavy text-cp-normal">
|
||||
2. 创建 API Token
|
||||
</h3>
|
||||
<p class="m-0">
|
||||
在「管理 API Token」创建对象存储专用 Token,只授予目标桶的对象
|
||||
<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">对象读</code>、
|
||||
<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">对象写</code>、
|
||||
<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">对象查看</code> 与
|
||||
<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">对象删除</code> 权限,
|
||||
<code class="rounded bg-cp-subtle px-1.5 py-0.5">对象读</code>、
|
||||
<code class="rounded bg-cp-subtle px-1.5 py-0.5">对象写</code>、
|
||||
<code class="rounded bg-cp-subtle px-1.5 py-0.5">对象查看</code> 与
|
||||
<code class="rounded bg-cp-subtle px-1.5 py-0.5">对象删除</code> 权限,
|
||||
不要授予整个账号权限。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h3 class="text-[14px] font-heavy text-(--cp-normal)">
|
||||
<h3 class="text-[14px] font-heavy text-cp-normal">
|
||||
3. 填写连接参数
|
||||
</h3>
|
||||
<ul class="m-0 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Endpoint:<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">https://{ACCOUNT_ID}.r2.cloudflarestorage.com</code>
|
||||
Endpoint:<code class="rounded bg-cp-subtle px-1.5 py-0.5">https://{ACCOUNT_ID}.r2.cloudflarestorage.com</code>
|
||||
</li>
|
||||
<li>Region:<code class="rounded bg-(--cp-bg-subtle) px-1.5 py-0.5">auto</code>(R2 固定值)</li>
|
||||
<li>Region:<code class="rounded bg-cp-subtle px-1.5 py-0.5">auto</code>(R2 固定值)</li>
|
||||
<li>Access Key ID / Secret Access Key:上一步生成的 Token</li>
|
||||
<li>Force Path Style:关闭</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<h3 class="text-[14px] font-heavy text-(--cp-normal)">
|
||||
<h3 class="text-[14px] font-heavy text-cp-normal">
|
||||
4. 完成连接测试
|
||||
</h3>
|
||||
<p class="m-0">
|
||||
|
||||
@@ -36,9 +36,11 @@ const {
|
||||
error,
|
||||
activeBackup,
|
||||
creating,
|
||||
refreshing,
|
||||
deleting,
|
||||
deleteTarget,
|
||||
downloadStates,
|
||||
load: loadRecords,
|
||||
refresh,
|
||||
changePage,
|
||||
changePageSize,
|
||||
@@ -72,7 +74,7 @@ watch(
|
||||
async (isActive) => {
|
||||
if (isActive) {
|
||||
await loadSettings()
|
||||
await refresh()
|
||||
await loadRecords()
|
||||
startPolling()
|
||||
}
|
||||
else {
|
||||
@@ -113,6 +115,7 @@ watch(
|
||||
:error="error"
|
||||
:active-backup="activeBackup"
|
||||
:creating="creating"
|
||||
:refreshing="refreshing"
|
||||
:deleting="deleting"
|
||||
:delete-target="deleteTarget"
|
||||
:download-states="downloadStates"
|
||||
|
||||
@@ -21,6 +21,7 @@ function isActive(status: BackupStatus): boolean {
|
||||
/** 备份记录:分页、可见时轮询、手动创建、下载与请求删除。 */
|
||||
export function useBackupRecords() {
|
||||
const creating = shallowRef(false)
|
||||
const refreshing = shallowRef(false)
|
||||
const deleting = shallowRef(false)
|
||||
const deleteTarget = shallowRef<BackupRecord | null>(null)
|
||||
const downloadStates = shallowRef<Record<string, boolean>>({})
|
||||
@@ -60,7 +61,7 @@ export function useBackupRecords() {
|
||||
timer = undefined
|
||||
if (stopped)
|
||||
return
|
||||
await paged.execute()
|
||||
await paged.execute({ silent: true })
|
||||
scheduleNextPoll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
@@ -82,10 +83,22 @@ export function useBackupRecords() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
async function load(): Promise<void> {
|
||||
await paged.execute()
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (refreshing.value)
|
||||
return
|
||||
refreshing.value = true
|
||||
try {
|
||||
await paged.execute({ silent: true })
|
||||
}
|
||||
finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
paged.page.value = page
|
||||
await paged.execute()
|
||||
@@ -104,7 +117,7 @@ export function useBackupRecords() {
|
||||
try {
|
||||
await createBackup()
|
||||
toast.success('备份任务已创建')
|
||||
await paged.execute()
|
||||
await paged.execute({ silent: true })
|
||||
}
|
||||
catch (cause) {
|
||||
toast.error(errorMessage(cause, '创建备份失败'))
|
||||
@@ -148,7 +161,7 @@ export function useBackupRecords() {
|
||||
try {
|
||||
await deleteBackup(target.id)
|
||||
toast.success('已请求删除备份')
|
||||
await paged.execute()
|
||||
await paged.execute({ silent: true })
|
||||
}
|
||||
catch (cause) {
|
||||
toast.error(errorMessage(cause, '删除备份失败'))
|
||||
@@ -174,9 +187,11 @@ export function useBackupRecords() {
|
||||
error: paged.error,
|
||||
activeBackup,
|
||||
creating,
|
||||
refreshing,
|
||||
deleting,
|
||||
deleteTarget,
|
||||
downloadStates,
|
||||
load,
|
||||
refresh,
|
||||
changePage,
|
||||
changePageSize,
|
||||
|
||||
@@ -73,15 +73,15 @@ watch(
|
||||
<div class="w-full">
|
||||
<BasePageHeader title="系统设置" description="管理运行参数、调度策略、模型映射与备份配置" />
|
||||
|
||||
<div class="mt-4 flex min-h-(--cp-input-height-default) flex-wrap items-center justify-between gap-3">
|
||||
<div class="mt-4 flex min-h-cp-control-md flex-wrap items-center justify-between gap-3">
|
||||
<BaseSegmented
|
||||
:model-value="section"
|
||||
label="设置分区"
|
||||
class="bg-(--cp-input-soft-bg)!"
|
||||
:options="[
|
||||
{ label: '运行设置', value: 'runtime' },
|
||||
{ label: '备份', value: 'backup' },
|
||||
]"
|
||||
aria-label="设置分区"
|
||||
@update:model-value="switchSection"
|
||||
/>
|
||||
<BaseButton
|
||||
@@ -132,10 +132,9 @@ watch(
|
||||
v-model="showDeleteAdminKeyModal"
|
||||
title="删除管理员 API Key"
|
||||
description="删除后外部系统将无法继续使用该 Key 调用管理接口"
|
||||
variant="danger"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="adminKeyDeleting"
|
||||
width="480px"
|
||||
@confirm="handleDeleteAdminApiKey"
|
||||
>
|
||||
<p class="m-0">
|
||||
|
||||
@@ -59,28 +59,27 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
v-model="open"
|
||||
title="错误明细"
|
||||
description="状态码、失败分类与诊断信息"
|
||||
variant="danger"
|
||||
width="920px"
|
||||
body-max-height="min(76dvh,760px)"
|
||||
tone="danger"
|
||||
size="xl"
|
||||
>
|
||||
<template v-if="record">
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<section class="rounded-cp-surface bg-cp-subtle px-4 py-3.5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<UsageStatusCodeBadge :status-code="record.clientStatusCode" />
|
||||
<span class="text-[12px] font-bold text-(--cp-text-secondary)">
|
||||
<span class="text-[12px] font-bold text-cp-secondary">
|
||||
客户端 {{ display(record.clientStatusCode) }}
|
||||
</span>
|
||||
<span class="text-[12px] font-bold text-(--cp-text-secondary)">
|
||||
<span class="text-[12px] font-bold text-cp-secondary">
|
||||
上游 {{ display(record.upstreamStatusCode) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 mb-0 text-[13px] leading-relaxed font-bold text-(--cp-text-primary)">
|
||||
<p class="mt-3 mb-0 text-[13px] leading-relaxed font-bold text-cp-primary">
|
||||
{{ display(record.message) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-1 gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5 sm:grid-cols-2"
|
||||
class="mt-3 grid grid-cols-1 gap-3 rounded-cp-surface bg-cp-subtle px-4 py-3.5 sm:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="field in fields"
|
||||
@@ -88,11 +87,11 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
class="min-w-0"
|
||||
:class="field.wide ? 'sm:col-span-2' : undefined"
|
||||
>
|
||||
<dt class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
<dt class="text-[11px] leading-none font-bold text-cp-muted-text">
|
||||
{{ field.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-normal font-emphasis text-(--cp-text-primary)"
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-normal font-emphasis text-cp-primary"
|
||||
:title="display(field.value)"
|
||||
>
|
||||
{{ display(field.value) }}
|
||||
@@ -102,20 +101,21 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
|
||||
|
||||
<section
|
||||
v-if="metadataText"
|
||||
class="mt-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
class="mt-3 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
|
||||
>
|
||||
<h3 class="m-0 text-[12px] leading-none font-heavy text-(--cp-text-secondary)">
|
||||
<h3 class="m-0 text-[12px] leading-none font-heavy text-cp-secondary">
|
||||
Metadata
|
||||
</h3>
|
||||
<BaseScrollbar
|
||||
class="mt-3"
|
||||
max-height="260px"
|
||||
view-class="rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
|
||||
>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-(--cp-text-primary)"
|
||||
v-text="metadataText"
|
||||
/>
|
||||
<div class="rounded-cp-control bg-cp-surface px-3 py-2.5">
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-cp-primary"
|
||||
v-text="metadataText"
|
||||
/>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { getOpsErrors } from '@/api'
|
||||
|
||||
import { Eye, RefreshCw, Search } from '@lucide/vue'
|
||||
import { shallowRef, toRef } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
|
||||
import { opsErrorColumns } from '../constants'
|
||||
@@ -41,7 +42,7 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-h-130 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<div class="grid min-h-130 min-w-0 w-full flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<div
|
||||
class="flex w-full flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center"
|
||||
role="group"
|
||||
@@ -55,7 +56,7 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
class="min-w-0 sm:col-span-2 lg:min-w-64 lg:flex-1 lg:max-w-96"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4.5 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
<BaseInput v-model="failureClass" placeholder="失败分类(精确)" class="min-w-0" />
|
||||
@@ -64,67 +65,71 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 self-end items-center justify-end gap-2 lg:ml-auto">
|
||||
<BaseButton
|
||||
icon-only
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label="刷新错误明细"
|
||||
spin-icon-on-loading
|
||||
:loading="refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<template #loading>
|
||||
<RefreshCw class="size-4.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-4.5" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="opsErrorColumns"
|
||||
:rows="records"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
empty-text="暂无错误明细"
|
||||
min-width="1900px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #upstreamStatusCode="{ row }">
|
||||
<UsageStatusCodeBadge
|
||||
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
|
||||
/>
|
||||
</template>
|
||||
<template #clientStatusCode="{ row }">
|
||||
<UsageStatusCodeBadge
|
||||
:status-code="typeof row.clientStatusCode === 'number' ? row.clientStatusCode : null"
|
||||
/>
|
||||
</template>
|
||||
<template #failureClass="{ row }">
|
||||
<span class="font-mono text-[12px] font-bold text-(--cp-danger-text)">
|
||||
{{ row.failureClass || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #accountId="{ row }">
|
||||
<span
|
||||
class="block max-w-full truncate font-mono text-[12px] font-bold text-(--cp-text-primary)"
|
||||
:title="row.accountId || ''"
|
||||
>
|
||||
{{ row.metadata.accountLabel || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="查看错误详情"
|
||||
@click="showDetail(row)"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<div class="flex min-h-0 min-w-0 flex-col">
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
:columns="opsErrorColumns"
|
||||
:rows="records"
|
||||
:loading="loading"
|
||||
empty-text="暂无错误明细"
|
||||
>
|
||||
<template #upstreamStatusCode="{ row }">
|
||||
<UsageStatusCodeBadge
|
||||
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
|
||||
/>
|
||||
</template>
|
||||
<template #clientStatusCode="{ row }">
|
||||
<UsageStatusCodeBadge
|
||||
:status-code="typeof row.clientStatusCode === 'number' ? row.clientStatusCode : null"
|
||||
/>
|
||||
</template>
|
||||
<template #failureClass="{ row }">
|
||||
<span class="font-mono text-[12px] font-bold text-cp-danger-text">
|
||||
{{ row.failureClass || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #accountId="{ row }">
|
||||
<span
|
||||
class="block max-w-full truncate font-mono text-[12px] font-bold text-cp-primary"
|
||||
:title="row.accountId || ''"
|
||||
>
|
||||
{{ row.metadata.accountLabel || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="查看错误详情"
|
||||
@click="showDetail(row)"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OpsErrorDetailModal v-model="detailOpen" :record="selectedRecord" />
|
||||
|
||||
@@ -41,21 +41,21 @@ const amountItems = computed(() => {
|
||||
|
||||
function itemValueClass(tone?: string, accent?: boolean) {
|
||||
if (tone === 'success')
|
||||
return 'text-(--cp-success-text)'
|
||||
return 'text-cp-success-text'
|
||||
if (tone === 'info' || accent)
|
||||
return 'text-(--cp-info-text)'
|
||||
return 'text-(--cp-text-primary)'
|
||||
return 'text-cp-info-text'
|
||||
return 'text-cp-primary'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="font-mono text-[12px] font-heavy tabular-nums text-(--cp-success-text)">
|
||||
<span class="font-mono text-[12px] font-heavy tabular-nums text-cp-success-text">
|
||||
{{ usageBillingText(record) }}
|
||||
</span>
|
||||
|
||||
<UsageDetailPopover v-if="billing" title="计费明细" width="248px" trigger-label="查看费用明细">
|
||||
<div class="grid gap-1.5 text-(--cp-text-secondary)">
|
||||
<UsageDetailPopover v-if="billing" title="计费明细" trigger-label="查看费用明细">
|
||||
<div class="grid gap-1.5 text-cp-secondary">
|
||||
<div v-for="item in amountItems" :key="item.label" class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-4">
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
<span class="justify-self-end whitespace-nowrap font-mono font-heavy" :class="itemValueClass(undefined, item.accent)">
|
||||
@@ -63,7 +63,7 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1.5 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) p-2 text-(--cp-text-secondary)">
|
||||
<div class="mt-1 grid gap-1.5 rounded-cp-control bg-cp-subtle p-2 text-cp-secondary">
|
||||
<div v-for="item in billingItems" :key="item.label" class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-4">
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
<span class="justify-self-end whitespace-nowrap font-mono font-heavy" :class="itemValueClass(item.tone)">
|
||||
|
||||
@@ -13,7 +13,7 @@ const clientIp = computed(() => usageClientIp(props.record))
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex h-6 max-w-full items-center rounded-full bg-(--cp-info-bg) px-2.5 font-mono text-[12px] leading-none font-bold text-(--cp-info-text)"
|
||||
class="inline-flex h-6 max-w-full items-center rounded-full bg-cp-info-bg px-2.5 font-mono text-[12px] leading-none font-bold text-cp-info-text"
|
||||
:title="clientIp"
|
||||
>
|
||||
<span class="min-w-0 truncate">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user