refactor(ui): standardize card spacing and layouts

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-03 17:01:23 +08:00
co-authored by Codex
parent 27cf037309
commit 34656a3f07
30 changed files with 105 additions and 120 deletions
+26 -20
View File
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
type CardVariant = 'default' | 'dashboard' | 'elevated'
type CardPadding = 'default' | 'compact'
type CardLayout = 'content' | 'flush'
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
padded?: boolean
variant?: CardVariant
padding?: CardPadding
layout?: CardLayout
title?: string
description?: string
headerCollapseAt?: HeaderCollapseAt
@@ -17,8 +18,8 @@ const props = withDefaults(
}>(),
{
as: 'section',
padded: true,
variant: 'default',
padding: 'default',
layout: 'content',
title: undefined,
description: undefined,
headerCollapseAt: 'sm',
@@ -36,16 +37,9 @@ const slots = defineSlots<{
default?: () => unknown
}>()
const paddingClasses: Record<CardVariant, string> = {
default: 'px-5 py-3',
dashboard: 'px-7 py-5.5',
elevated: 'px-5 py-3',
}
const shadowClasses: Record<CardVariant, string> = {
default: 'shadow-(--cp-shadow-card)',
dashboard: 'shadow-(--cp-shadow-card)',
elevated: 'shadow-(--cp-shadow-popover)',
const paddingClasses: Record<CardPadding, string> = {
default: 'p-5.5',
compact: 'p-4',
}
const hasManagedHeader = computed(
@@ -53,6 +47,12 @@ const hasManagedHeader = computed(
!!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'
@@ -69,14 +69,17 @@ const managedHeaderLayoutClasses = computed(() => {
<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 bg-(--cp-bg-surface) rounded-(--cp-card-radius)"
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="[
shadowClasses[props.variant],
props.padded ? paddingClasses[props.variant] : undefined,
outerPaddingClass,
]"
>
<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"
:class="props.headerClass"
>
<slot name="header">
<div :class="managedHeaderLayoutClasses">
<div class="min-w-0 pt-0.5">
@@ -105,7 +108,10 @@ const managedHeaderLayoutClasses = computed(() => {
</slot>
</header>
<div v-if="$slots.body || $slots.default" :class="props.bodyClass">
<div
v-if="$slots.body || $slots.default"
:class="props.bodyClass"
>
<slot name="body">
<slot />
</slot>
@@ -1,12 +1,20 @@
<script setup lang="ts">
defineProps<{
const props = defineProps<{
title: string
description?: string
actionsInline?: boolean
}>()
</script>
<template>
<header class="grid min-h-17 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-3">
<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'
"
>
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
{{ title }}
@@ -23,6 +31,7 @@ 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>
@@ -54,10 +54,13 @@ const provider = defineModel<string>('provider', { required: true })
/>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<div
class="grid w-full grid-cols-2 gap-2 md:flex md:w-auto md:shrink-0 md:self-end md:items-center md:justify-end md:ml-auto"
>
<BaseButton
v-if="selectedCount > 0"
variant="danger"
class="w-full whitespace-nowrap md:w-auto"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
@@ -67,13 +70,19 @@ const provider = defineModel<string>('provider', { required: true })
<BaseButton
v-if="selectedCount > 0"
variant="default"
class="w-full whitespace-nowrap md:w-auto"
:loading="exportingAccounts"
@click="emit('exportSelected')"
>
<Download class="size-4" />
导出选中 ({{ selectedCount }})
</BaseButton>
<BaseButton variant="primary" @click="emit('create')">
<BaseButton
variant="primary"
class="whitespace-nowrap md:w-auto"
:class="selectedCount > 0 ? 'col-span-2 w-full' : 'col-span-2 justify-self-end'"
@click="emit('create')"
>
<Upload class="size-4" />
导入账号
</BaseButton>
@@ -65,16 +65,15 @@ function overviewIconClass(tone: string) {
v-for="item in overviewItems"
:key="item.label"
as="article"
:padded="false"
class="h-24"
padding="compact"
>
<div class="flex h-full items-stretch justify-between gap-3 px-5 py-3">
<div class="flex min-w-0 flex-col justify-between">
<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)">
{{ item.label }}
</p>
<strong
class="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-text-primary)"
>
{{ item.value }}
</strong>
+2 -4
View File
@@ -126,10 +126,8 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
:padded="false"
class="mt-4 flex h-[calc(100dvh-248px)] min-h-125 flex-col"
header-class="px-4 pt-4 pb-2 md:px-5"
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
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
@@ -19,11 +19,11 @@ const search = defineModel<string>('search', { required: true })
<template>
<div
class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap md:items-center"
class="flex w-full flex-wrap items-center gap-3"
role="group"
aria-label="API Key 筛选与操作"
>
<div class="min-w-0 w-full md:w-96 md:flex-none">
<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)" />
@@ -31,7 +31,7 @@ const search = defineModel<string>('search', { required: true })
</BaseInput>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<div class="flex shrink-0 items-center justify-end gap-2 md:ml-auto">
<BaseButton
v-if="selectedCount > 0"
variant="danger"
+1 -3
View File
@@ -88,10 +88,8 @@ const {
/>
<BaseCard
:padded="false"
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-0 flex-1 px-5 py-3"
body-class="mt-3 flex min-h-0 flex-1"
>
<template #header>
<ApiKeyFilters
@@ -90,7 +90,9 @@ const statusRows = computed(() => {
}
const active = p?.active ?? 0
const refreshing = p?.refreshing ?? 0
const quota = p?.quotaExhausted ?? 0
const rateLimited = p?.rateLimited ?? 0
const quotaExhausted = p?.quotaExhausted ?? 0
const quotaLimited = rateLimited + quotaExhausted
const expired = p?.expired ?? 0
const invalid = p?.invalid ?? 0
const disabled = p?.disabled ?? 0
@@ -113,16 +115,9 @@ const statusRows = computed(() => {
icon: RefreshCw,
},
{
label: '限流中',
description: '任一额度窗口已触顶',
value: String(p?.rateLimited ?? 0),
tone: 'warning',
icon: TriangleAlert,
},
{
label: '配额耗尽',
description: '已确认 402 耗尽',
value: String(quota),
label: '额度受限',
description: `限流中 ${rateLimited} · 配额耗尽 ${quotaExhausted}`,
value: String(quotaLimited),
tone: 'warning',
icon: TriangleAlert,
},
@@ -149,23 +144,21 @@ const statusBars = computed(() => {
return []
const active = (p.active / p.total) * 100
const refreshing = ((p.refreshing ?? 0) / p.total) * 100
const rateLimited = ((p.rateLimited ?? 0) / p.total) * 100
const quota = (p.quotaExhausted / p.total) * 100
const quotaLimited = (((p.rateLimited ?? 0) + p.quotaExhausted) / p.total) * 100
const unavailable = ((p.expired + (p.invalid ?? 0) + p.disabled + p.banned) / p.total) * 100
return [
{ pct: active, cls: 'bg-(--cp-success)' },
{ pct: refreshing, cls: 'bg-(--cp-normal)' },
{ pct: rateLimited, cls: 'bg-(--cp-warning)' },
{ pct: quota, cls: 'bg-(--cp-warning)' },
{ pct: quotaLimited, cls: 'bg-(--cp-warning)' },
{ pct: unavailable, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
})
</script>
<template>
<BaseCard as="article" :padded="false" class="w-full xl:h-112.5">
<BaseCard as="article" class="w-full xl:h-112.5">
<div
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
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)">
@@ -69,7 +69,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</script>
<template>
<BaseCard as="article" :padded="false" class="relative h-38.5 w-full px-6 pt-5">
<BaseCard as="article" padding="compact" class="relative h-38.5 w-full">
<div class="flex items-start gap-3">
<BaseMotionIcon
aria-hidden="true"
@@ -207,7 +207,6 @@ onBeforeUnmount(() => {
<template>
<BaseCard
as="article"
variant="dashboard"
:title="timeline.title"
:description="timeline.description"
header-collapse-at="lg"
@@ -39,7 +39,7 @@ const {
</script>
<template>
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
<BaseCard as="article" title="使用趋势" class="min-h-95 w-full">
<template #actions>
<BaseSegmented
v-model="activeKind"
@@ -17,7 +17,6 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(column => column.k
<template>
<BaseCard
as="article"
variant="dashboard"
title="使用记录"
description="最近 10 条成功请求"
class="h-117 w-full"
@@ -171,7 +171,6 @@ function providerLabel(provider: string) {
<template>
<BaseCard
as="article"
variant="dashboard"
title="上游请求身份"
body-class="flex min-h-0 flex-1 flex-col"
class="flex min-h-95 w-full flex-col"
+1 -1
View File
@@ -35,7 +35,7 @@ const {
<template>
<div class="w-full">
<BasePageHeader title="系统概览">
<BasePageHeader title="系统概览" actions-inline>
<template #description>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
@@ -48,8 +48,7 @@ function togglePasswordVisible(): void {
<template>
<BaseCard
as="form"
:padded="false"
variant="elevated"
layout="flush"
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')"
>
@@ -26,11 +26,9 @@ const emit = defineEmits<{
<template>
<BaseCard
:padded="false"
title="管理员 API Key"
description="用于外部系统集成,具有管理员权限"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
@@ -23,11 +23,9 @@ const emit = defineEmits<{
<template>
<BaseCard
:padded="false"
title="模型映射"
description="配置请求模型与上游模型的映射关系"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-3">
@@ -14,11 +14,9 @@ const model = defineModel<RotationStrategy | ''>({ required: true })
<template>
<BaseCard
:padded="false"
title="调度策略"
description="决定每次请求如何调度账号池"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<div class="grid max-w-6xl gap-3 lg:grid-cols-4">
<button
@@ -14,11 +14,9 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
<template>
<BaseCard
:padded="false"
title="运行参数"
description="请求节奏、账号并发和 Token 刷新"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<BaseForm :columns="2" class="max-w-6xl">
<BaseFormItem
@@ -90,11 +90,9 @@ function canDelete(record: BackupRecord): boolean {
<template>
<BaseCard
:padded="false"
title="备份记录"
description="创建手动备份和管理已有备份记录"
header-class="px-5 pt-4"
body-class="px-5 py-3"
body-class="mt-3"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
@@ -40,11 +40,9 @@ const TIMEZONE_OPTIONS = [
<template>
<BaseCard
:padded="false"
title="定时备份"
description="配置自动定时备份"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #actions>
<BaseButton variant="primary" :loading="saving" :disabled="loading" @click="emit('save')">
@@ -51,10 +51,8 @@ function toggleSecretVisible(): void {
<template>
<BaseCard
:padded="false"
title="S3 存储配置"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #description>
<span class="text-(--cp-text-secondary)">
+14 -17
View File
@@ -71,24 +71,9 @@ watch(
<template>
<div class="w-full">
<BasePageHeader title="系统设置" description="管理运行参数、调度策略、模型映射与备份配置">
<template #actions>
<BaseButton
v-if="section === 'runtime'"
variant="primary"
:loading="saving"
:disabled="loading"
@click="saveSettings"
>
<template #icon>
<Save class="size-4" />
</template>
{{ saving ? '保存中...' : '保存' }}
</BaseButton>
</template>
</BasePageHeader>
<BasePageHeader title="系统设置" description="管理运行参数、调度策略、模型映射与备份配置" />
<div class="mt-4">
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
:model-value="section"
class="bg-(--cp-input-soft-bg)!"
@@ -99,6 +84,18 @@ watch(
aria-label="设置分区"
@update:model-value="switchSection"
/>
<BaseButton
v-if="section === 'runtime'"
variant="primary"
:loading="saving"
:disabled="loading"
@click="saveSettings"
>
<template #icon>
<Save class="size-4" />
</template>
{{ saving ? '保存中...' : '保存' }}
</BaseButton>
</div>
<div v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
@@ -239,12 +239,10 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="成本效率"
description="预估费用、Token 与缓存收益"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #actions>
@@ -114,12 +114,10 @@ function diagnosticNameDisplay(name: string) {
<template>
<BaseCard
as="article"
:padded="false"
title="热点诊断"
:description="`按${selectedDimensionLabel}定位错误、慢请求与费用热点`"
header-collapse-at="lg"
header-class="px-5 pt-4"
body-class="min-h-0 min-w-0 px-5 pt-3"
body-class="mt-3 min-h-0 min-w-0"
class="grid h-105 min-h-105 max-h-105 min-w-0 w-full grid-rows-[auto_minmax(0,1fr)] lg:h-90 lg:min-h-90 lg:max-h-90"
>
<template #actions>
@@ -146,11 +146,9 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="请求健康"
:description="`按${granularityText}观察请求量、成功率与失败时段`"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #body>
@@ -167,12 +167,10 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="响应速度"
description="总耗时或首字的分位耗时,越低越快"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #actions>
@@ -3,6 +3,7 @@ import type { getUsageRecordSummary } from '@/api'
import { Activity, Database, FileText, Timer } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
const props = defineProps<{
@@ -54,10 +55,12 @@ const items = computed(() => [
class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"
aria-label="使用概览"
>
<article
<BaseCard
v-for="item in items"
:key="item.key"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-4 py-3 shadow-(--cp-shadow-card)"
as="article"
padding="compact"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3"
>
<BaseMotionIcon
aria-hidden="true"
@@ -79,6 +82,6 @@ const items = computed(() => [
{{ item.detail }}
</span>
</div>
</article>
</BaseCard>
</section>
</template>
+1
View File
@@ -5,6 +5,7 @@ export const usageRecordColumns = [
key: 'accountEmail',
label: '账号',
width: '280px',
fixed: false as const,
ellipsis: true,
},
{
+1 -3
View File
@@ -79,10 +79,8 @@ watch(timeRange, () => {
/>
<BaseCard
:padded="false"
class="mt-5 flex flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-0 flex-col px-5 pt-3 pb-4"
body-class="mt-3 flex min-h-0 flex-col"
>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">