feat: restore dashboard layout fidelity

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-15 23:31:38 +08:00
co-authored by Codex
parent ff580e57f8
commit 1b7cd7c320
19 changed files with 696 additions and 237 deletions
+6
View File
@@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=JetBrains+Mono:wght@100..800&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<title>Codex Proxy RS</title>
</head>
+1
View File
@@ -13,6 +13,7 @@
"@lucide/vue": "^1.18.0",
"axios": "^1.18.0",
"echarts": "^6.1.0",
"gsap": "^3.15.0",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
+8
View File
@@ -17,6 +17,9 @@ importers:
echarts:
specifier: ^6.1.0
version: 6.1.0
gsap:
specifier: ^3.15.0
version: 3.15.0
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
@@ -571,6 +574,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -1406,6 +1412,8 @@ snapshots:
graceful-fs@4.2.11: {}
gsap@3.15.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
+15 -10
View File
@@ -1,27 +1,32 @@
<script setup lang="ts">
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
as?: keyof HTMLElementTagNameMap
title?: string
description?: string
padded?: boolean
radiusClass?: string
}>(), {
as: 'section',
title: undefined,
description: undefined,
padded: true,
radiusClass: 'rounded-[var(--cp-card-radius)]',
})
</script>
<template>
<section
class="overflow-hidden rounded-[var(--cp-card-radius)] bg-[var(--cp-bg-surface)] shadow-[var(--cp-shadow-card)]"
:class="{ 'p-6 md:px-7': padded }"
<component
:is="props.as"
class="overflow-hidden bg-[var(--cp-bg-surface)] shadow-[var(--cp-shadow-card)]"
:class="[props.radiusClass, props.padded ? 'p-6 md:px-7' : undefined]"
>
<header v-if="title || description || $slots.actions" class="mb-6 flex items-start justify-between gap-5">
<header v-if="props.title || props.description || $slots.actions" class="mb-6 flex items-start justify-between gap-5">
<div class="min-w-0">
<h2 v-if="title" class="m-0 text-xl font-[760] leading-[1.15] text-[var(--cp-text-primary)]">
{{ title }}
<h2 v-if="props.title" class="m-0 text-xl font-[760] leading-[1.15] text-[var(--cp-text-primary)]">
{{ props.title }}
</h2>
<p v-if="description" class="mt-[7px] mb-0 text-[13px] font-semibold leading-[1.15] text-[var(--cp-text-secondary)]">
{{ description }}
<p v-if="props.description" class="mt-[7px] mb-0 text-[13px] font-semibold leading-[1.15] text-[var(--cp-text-secondary)]">
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="shrink-0">
@@ -29,5 +34,5 @@ withDefaults(defineProps<{
</div>
</header>
<slot />
</section>
</component>
</template>
+76 -33
View File
@@ -4,12 +4,27 @@ interface TableColumn {
label: string
width?: string
align?: 'left' | 'right' | 'center'
headerClass?: string
cellClass?: string
}
defineProps<{
withDefaults(defineProps<{
columns: TableColumn[]
rows: object[]
}>()
tableClass?: string
headerRowClass?: string
bodyRowClass?: string
headerCellClass?: string
bodyCellClass?: string
striped?: boolean
}>(), {
tableClass: 'min-w-[620px]',
headerRowClass: 'h-10 rounded-xl bg-[var(--cp-bg-subtle)] text-[11px] font-bold text-[var(--cp-text-muted)]',
bodyRowClass: 'h-[52px] rounded-[10px]',
headerCellClass: 'min-w-0 overflow-hidden bg-[var(--cp-bg-subtle)] px-3 text-ellipsis whitespace-nowrap first:rounded-l-xl last:rounded-r-xl',
bodyCellClass: 'min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap text-xs font-semibold text-[var(--cp-text-primary)] first:rounded-l-[10px] last:rounded-r-[10px]',
striped: true,
})
function cellValue(row: object, key: string) {
return (row as Record<string, unknown>)[key]
@@ -20,44 +35,72 @@ function rowKey(row: object, index: number) {
return typeof id === 'string' || typeof id === 'number' ? id : index
}
function gridColumns(columns: TableColumn[]) {
return columns.map((column) => column.width ?? '1fr').join(' ')
function columnWidth(column: TableColumn) {
return column.width ?? 'auto'
}
function alignClass(column: TableColumn) {
if (column.align === 'center') {
return 'text-center'
}
if (column.align === 'right') {
return 'text-right'
}
return 'text-left'
}
function rowSurfaceClass(index: number, striped: boolean) {
return striped && index % 2 === 1 ? 'bg-[var(--cp-bg-subtle)]' : 'bg-[var(--cp-bg-surface)]'
}
</script>
<template>
<div class="max-w-full overflow-x-auto pb-1">
<div class="grid min-w-[620px] gap-2">
<div
class="grid min-h-10 items-center rounded-xl bg-[var(--cp-bg-subtle)] text-[11px] font-bold text-[var(--cp-text-muted)]"
:style="{ gridTemplateColumns: gridColumns(columns) }"
>
<div
<div class="cp-scrollbar max-w-full overflow-x-auto pb-1">
<table
class="table-fixed border-separate border-spacing-y-2 text-left"
:class="tableClass"
:style="{ width: '100%' }"
>
<colgroup>
<col
v-for="column in columns"
:key="column.key"
class="min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap"
:class="column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'"
:style="{ width: columnWidth(column) }"
>
{{ column.label }}
</div>
</div>
<div
v-for="(row, index) in rows"
:key="rowKey(row, index)"
class="grid min-h-[52px] items-center rounded-[10px] bg-[var(--cp-bg-surface)] even:bg-[var(--cp-bg-subtle)]"
:style="{ gridTemplateColumns: gridColumns(columns) }"
>
<div
v-for="column in columns"
:key="column.key"
class="min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap text-xs font-semibold text-[var(--cp-text-primary)]"
:class="column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'"
</colgroup>
<thead>
<tr :class="headerRowClass">
<th
v-for="column in columns"
:key="column.key"
:class="[headerCellClass, alignClass(column), column.headerClass]"
scope="col"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="bodyRowClass"
>
<slot :name="column.key" :row="row" :value="cellValue(row, column.key)">
{{ cellValue(row, column.key) }}
</slot>
</div>
</div>
</div>
<td
v-for="column in columns"
:key="column.key"
:class="[bodyCellClass, rowSurfaceClass(index, striped), alignClass(column), column.cellClass]"
>
<slot :name="column.key" :row="row" :value="cellValue(row, column.key)">
{{ cellValue(row, column.key) }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+132 -33
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { gsap } from 'gsap'
import { nextTick, onMounted, ref, watch } from 'vue'
import {
Box,
ChartColumn,
KeyRound,
LayoutDashboard,
PanelLeftClose,
PanelLeftOpen,
Radar,
ScrollText,
Settings,
@@ -13,61 +16,157 @@ import {
} from '@lucide/vue'
const navItems = [
{ label: '概览', icon: LayoutDashboard, active: true, topClass: 'top-[112px]' },
{ label: '账号池', icon: Users, active: false, topClass: 'top-[170px]' },
{ label: 'API Keys', icon: KeyRound, active: false, topClass: 'top-[228px]' },
{ label: '日志', icon: ScrollText, active: false, topClass: 'top-[286px]' },
{ label: '用量', icon: ChartColumn, active: false, topClass: 'top-[344px]' },
{ label: '模型', icon: Box, active: false, topClass: 'top-[402px]' },
{ label: '设置', icon: Settings, active: false, topClass: 'top-[460px]' },
{ label: '诊断', icon: Radar, active: false, topClass: 'top-[518px]' },
{ label: '概览', icon: LayoutDashboard, active: true },
{ label: '账号池', icon: Users, active: false },
{ label: 'API Keys', icon: KeyRound, active: false },
{ label: '日志', icon: ScrollText, active: false },
{ label: '用量', icon: ChartColumn, active: false },
{ label: '模型', icon: Box, active: false },
{ label: '设置', icon: Settings, active: false },
{ label: '诊断', icon: Radar, active: false },
]
const props = withDefaults(defineProps<{
collapsed?: boolean
}>(), {
collapsed: false,
})
defineEmits<{
toggle: []
}>()
const sidebarEl = ref<HTMLElement | null>(null)
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function animateSidebarLabels(collapsed: boolean) {
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (!labels?.length) {
return
}
if (prefersReducedMotion()) {
gsap.set(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed ? -6 : 0,
})
return
}
gsap.to(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed ? -6 : 0,
duration: collapsed ? 0.16 : 0.2,
ease: collapsed ? 'power2.in' : 'power3.out',
stagger: collapsed ? 0 : 0.018,
overwrite: true,
})
}
function animateSidebarWidth(collapsed: boolean) {
if (!sidebarEl.value) {
return
}
const targetWidth = collapsed ? 88 : 256
const currentWidth = sidebarEl.value.getBoundingClientRect().width
if (prefersReducedMotion()) {
gsap.set(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
})
return
}
gsap.set(sidebarEl.value, {
width: currentWidth,
flexBasis: currentWidth,
})
gsap.to(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
duration: 0.34,
ease: 'power3.out',
overwrite: true,
})
}
onMounted(() => {
gsap.set(sidebarEl.value, {
width: props.collapsed ? 88 : 256,
flexBasis: props.collapsed ? 88 : 256,
})
animateSidebarLabels(Boolean(props.collapsed))
})
watch(
() => props.collapsed,
async (collapsed) => {
animateSidebarLabels(Boolean(collapsed))
animateSidebarWidth(Boolean(collapsed))
await nextTick()
animateSidebarLabels(Boolean(collapsed))
},
)
</script>
<template>
<aside
class="sticky top-0 z-20 hidden h-screen min-h-screen w-[280px] shrink-0 basis-[280px] bg-white shadow-[var(--cp-shadow-sidebar)] min-[961px]:block"
ref="sidebarEl"
class="z-20 hidden h-screen shrink-0 flex-col overflow-hidden bg-white shadow-[var(--cp-shadow-sidebar)] min-[961px]:flex"
:class="collapsed ? 'w-[88px] basis-[88px] items-center' : 'w-[256px] basis-[256px]'"
>
<div class="absolute left-[28px] top-[31px] inline-flex size-[46px] items-center justify-center rounded-[13px] bg-[#111827] text-white shadow-[0_8px_18px_-16px_#0E172614]">
<SquareTerminal :size="24" />
<div
class="mt-[31px] grid h-[46px] grid-cols-[46px_minmax(0,1fr)] gap-2.5"
:class="collapsed ? 'w-[46px]' : 'ml-6 w-[188px] self-start'"
>
<span class="inline-flex size-[46px] items-center justify-center rounded-[13px] bg-[#111827] text-white shadow-[0_8px_18px_-16px_#0E172614]">
<SquareTerminal :size="24" />
</span>
<span class="sidebar-label grid min-w-[132px] content-center overflow-hidden transition-[opacity,transform] duration-200" :class="collapsed ? 'pointer-events-none w-0' : 'w-auto'">
<strong class="text-[17px] leading-[1.1] font-[720] text-[#111827]">Codex</strong>
<span class="mt-1 text-[12px] leading-[1.1] font-semibold text-[#64748B]">Proxy RS · v0.1.0</span>
</span>
</div>
<div class="absolute left-[84px] top-[34px] text-[17px] font-[720] leading-[1.1] text-[#111827]">
Codex
</div>
<div class="absolute left-[84px] top-[57px] flex h-4 w-[132px] items-center text-xs font-semibold leading-[1.1] text-[#64748B]">
Proxy RS · v0.1.0
</div>
<nav aria-label="主导航">
<nav
class="mt-[35px] grid gap-3"
:class="collapsed ? '' : 'ml-6 self-start'"
aria-label="主导航"
>
<a
v-for="item in navItems"
:key="item.label"
class="absolute left-6 h-[46px] w-[232px] rounded-xl no-underline transition-colors"
class="inline-flex h-[46px] items-center rounded-xl text-[14px] leading-[1.15] no-underline transition-[background-color,color,transform] duration-200 hover:-translate-y-px active:translate-y-0"
:class="[
item.topClass,
collapsed ? 'w-[46px] justify-center' : 'w-[208px] gap-3 pl-[22px]',
item.active
? 'bg-[#E9EEF5] text-[#111827]'
: 'bg-transparent text-[#64748B] hover:bg-[#F8FAFC]',
? 'bg-[#E9EEF5] font-bold text-[#111827]'
: 'bg-transparent font-semibold text-[#64748B] hover:bg-[#F8FAFC]',
]"
href="#"
>
<component :is="item.icon" class="absolute left-[22px] top-[13px]" :size="20" />
<span
class="absolute left-[54px] top-[15px] text-sm leading-[1.15]"
:class="item.active ? 'font-bold' : 'font-semibold'"
>
{{ item.label }}
</span>
<component :is="item.icon" class="shrink-0" :size="20" />
<span class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200" :class="collapsed ? 'pointer-events-none w-0' : 'w-auto'">{{ item.label }}</span>
</a>
</nav>
<button
class="absolute bottom-8 left-[220px] inline-flex size-9 items-center justify-center rounded-xl border-0 bg-[#F8FAFC] text-[#64748B]"
class="mt-auto mb-8 inline-flex items-center justify-center rounded-xl border-0 text-[#64748B] transition-[background-color,transform,color] duration-200 hover:-translate-y-px active:translate-y-0"
:class="collapsed ? 'h-9 w-11 bg-transparent' : 'mr-6 size-9 self-end bg-[#F8FAFC]'"
type="button"
aria-label="收缩侧边栏"
data-sidebar-toggle
:aria-label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="$emit('toggle')"
>
<PanelLeftClose :size="20" />
<PanelLeftOpen v-if="collapsed" :size="20" />
<PanelLeftClose v-else :size="20" />
</button>
</aside>
</template>
+76 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { gsap } from 'gsap'
import { ref } from 'vue'
import {
CalendarDays,
ChevronDown,
@@ -10,21 +12,69 @@ import {
ShieldCheck,
User,
} from '@lucide/vue'
const accountMenuOpen = ref(false)
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function animateAccountMenuEnter(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
gsap.set(target, { opacity: 1, y: 0, scale: 1 })
done()
return
}
gsap.fromTo(
target,
{ opacity: 0, y: 6, scale: 0.98 },
{
opacity: 1,
y: 0,
scale: 1,
duration: 0.22,
ease: 'power3.out',
onComplete: done,
},
)
}
function animateAccountMenuLeave(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
gsap.set(target, { opacity: 0 })
done()
return
}
gsap.to(target, {
opacity: 0,
y: 4,
scale: 0.985,
duration: 0.14,
ease: 'power2.in',
onComplete: done,
})
}
</script>
<template>
<div class="relative z-30 flex h-11 items-start gap-3.5">
<button
class="inline-flex h-11 w-[170px] items-center gap-2.5 rounded-xl border-0 bg-white px-3.5 text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)] shadow-[var(--cp-shadow-control)]"
class="group inline-flex h-11 w-[170px] items-center gap-2.5 whitespace-nowrap rounded-xl border-0 bg-white px-3.5 text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)] shadow-[var(--cp-shadow-control)] transition-[box-shadow,transform] duration-200 hover:-translate-y-px active:translate-y-0"
type="button"
>
<CalendarDays class="text-[var(--cp-info)]" :size="18" />
<span>最近 24 小时</span>
<ChevronDown class="text-[var(--cp-text-secondary)]" :size="16" />
<span class="whitespace-nowrap">最近 24 小时</span>
<ChevronDown class="text-[var(--cp-text-secondary)] transition-transform duration-200 group-hover:rotate-180" :size="16" />
</button>
<button
class="inline-flex size-11 items-center justify-center rounded-xl border-0 bg-white text-[var(--cp-normal)] shadow-[var(--cp-shadow-control)]"
class="inline-flex size-11 items-center justify-center rounded-xl border-0 bg-white text-[var(--cp-normal)] shadow-[var(--cp-shadow-control)] transition-[box-shadow,transform] duration-200 hover:-translate-y-px active:translate-y-0"
type="button"
aria-label="刷新"
>
@@ -33,19 +83,27 @@ import {
<div class="relative ml-0.5">
<button
class="grid h-11 w-[216px] grid-cols-[28px_minmax(0,1fr)_16px] items-center rounded-xl border-0 bg-white px-3 text-[var(--cp-text-secondary)] shadow-[var(--cp-shadow-control)]"
class="group grid h-11 w-[216px] grid-cols-[28px_minmax(0,1fr)_16px] items-center rounded-xl border-0 bg-white px-3 text-[var(--cp-text-secondary)] shadow-[var(--cp-shadow-control)] transition-[box-shadow,transform] duration-200 hover:-translate-y-px active:translate-y-0"
type="button"
data-account-trigger
@click="accountMenuOpen = !accountMenuOpen"
>
<span class="inline-flex size-7 items-center justify-center rounded-full bg-[var(--cp-normal)] text-[12px] leading-[1.15] font-bold text-white">A</span>
<span class="grid gap-[5px] pl-3 text-left">
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">admin</strong>
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">管理员</span>
</span>
<ChevronUp class="text-[var(--cp-text-secondary)]" :size="16" />
<ChevronUp v-if="accountMenuOpen" class="text-[var(--cp-text-secondary)] transition-transform duration-200 group-hover:rotate-180" :size="16" />
<ChevronDown v-else class="text-[var(--cp-text-secondary)] transition-transform duration-200 group-hover:rotate-180" :size="16" />
</button>
<div class="absolute right-0 top-14 h-[304px] w-80 rounded-[18px] bg-white pt-[18px] shadow-[var(--cp-shadow-popover)]">
<div class="mx-[18px] grid h-11 grid-cols-[44px_minmax(0,1fr)_64px] items-center gap-3">
<Transition
:css="false"
@enter="animateAccountMenuEnter"
@leave="animateAccountMenuLeave"
>
<div v-if="accountMenuOpen" data-account-menu class="absolute right-0 top-14 h-[304px] w-80 origin-top-right rounded-[18px] bg-white pt-[18px] shadow-[var(--cp-shadow-popover)]">
<div class="mx-[18px] grid h-11 grid-cols-[44px_minmax(0,1fr)_64px] items-center gap-3">
<span class="inline-flex size-11 items-center justify-center rounded-full bg-[var(--cp-normal)] text-[14px] leading-[1.15] font-bold text-white">A</span>
<span class="grid gap-[5px]">
<strong class="text-[15px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">admin</strong>
@@ -55,9 +113,9 @@ import {
<i class="size-1.5 rounded-full bg-[var(--cp-success)]" />
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-success-text)]">在线</span>
</span>
</div>
</div>
<div class="mx-4 mt-3.5 grid h-11 w-[288px] grid-cols-2 rounded-xl bg-[var(--cp-bg-subtle)] px-4 py-2">
<div class="mx-4 mt-3.5 grid h-11 w-[288px] grid-cols-2 rounded-xl bg-[var(--cp-bg-subtle)] px-4 py-2">
<span class="grid gap-[5px]">
<small class="text-[11px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">MFA</small>
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">已开启</strong>
@@ -66,11 +124,11 @@ import {
<small class="text-[11px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">会话</small>
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">1 台</strong>
</span>
</div>
</div>
<div class="mx-4 mt-3.5 grid w-[288px] gap-1.5">
<div class="mx-4 mt-3.5 grid w-[288px] gap-1.5">
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-[var(--cp-bg-subtle)] px-3 text-left text-[var(--cp-text-primary)]"
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-[var(--cp-bg-subtle)] px-3 text-left text-[var(--cp-text-primary)] transition-[background-color,transform] duration-200 hover:-translate-y-px active:translate-y-0"
type="button"
>
<User :size="20" />
@@ -79,7 +137,7 @@ import {
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_34px_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-text-primary)]"
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_34px_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-text-primary)] transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-bg-subtle)] active:translate-y-0"
type="button"
>
<ShieldCheck :size="20" />
@@ -89,7 +147,7 @@ import {
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-text-primary)]"
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-text-primary)] transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-bg-subtle)] active:translate-y-0"
type="button"
>
<ScrollText :size="20" />
@@ -98,14 +156,15 @@ import {
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-danger-text)]"
class="grid h-9 grid-cols-[20px_minmax(0,1fr)] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-[var(--cp-danger-text)] transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-danger-bg)] active:translate-y-0"
type="button"
>
<LogOut class="text-[var(--cp-danger)]" :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">退出登录</span>
</button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
+6 -3
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
const sidebarCollapsed = ref(false)
</script>
<template>
<div class="flex min-h-screen bg-[var(--cp-bg-page)]">
<AppSidebar />
<main class="min-w-0 flex-1 overflow-x-auto">
<div class="flex h-screen overflow-hidden bg-[var(--cp-bg-page)]">
<AppSidebar :collapsed="sidebarCollapsed" @toggle="sidebarCollapsed = !sidebarCollapsed" />
<main class="cp-scrollbar h-screen min-w-0 flex-1 overflow-auto">
<RouterView />
</main>
</div>
+43 -1
View File
@@ -7,6 +7,7 @@ html {
background: var(--cp-bg-page);
color: var(--cp-text-primary);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -18,7 +19,6 @@ body {
background: var(--cp-bg-page);
}
button,
input,
select,
textarea {
@@ -26,6 +26,7 @@ textarea {
}
button {
font-family: inherit;
cursor: pointer;
}
@@ -36,3 +37,44 @@ button:disabled {
#app {
min-height: 100vh;
}
.cp-scrollbar {
scrollbar-color: var(--cp-scrollbar-thumb) transparent;
scrollbar-width: thin;
}
.cp-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.cp-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.cp-scrollbar::-webkit-scrollbar-thumb {
min-height: 56px;
border: 3px solid transparent;
border-radius: 999px;
background-color: var(--cp-scrollbar-thumb);
background-clip: content-box;
}
.cp-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--cp-scrollbar-thumb-hover);
}
.cp-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+5 -1
View File
@@ -98,9 +98,13 @@
--cp-shadow-control: 0 8px 18px -16px #0e172614;
--cp-shadow-popover: 0 16px 34px -18px #0e172622;
--cp-shadow-sidebar: 2px 0 12px -12px #0e172607;
--cp-scrollbar-track: #eef3f8;
--cp-scrollbar-thumb: #cbd7e6;
--cp-scrollbar-thumb-hover: #aebed0;
--cp-space-page-x: 28px;
--cp-space-page-y: 32px;
--cp-gap-card: 24px;
--cp-sidebar-width: 280px;
--cp-sidebar-width: 256px;
--cp-sidebar-collapsed-width: 88px;
}
+3 -3
View File
@@ -19,7 +19,7 @@ const {
</script>
<template>
<div class="ml-7 w-[1584px] pt-[34px] pb-[60px]">
<div class="w-full min-w-[1180px] px-7 pt-[34px] pb-[34px]">
<header class="flex h-[68px] items-start justify-between">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-[800] mb-0 text-[var(--cp-text-primary)]">
@@ -33,11 +33,11 @@ const {
<AppTopbar class="mt-0.5" />
</header>
<section class="mt-6 grid grid-cols-4 gap-6" aria-label="核心指标">
<section class="mt-6 grid grid-cols-[repeat(4,minmax(0,1fr))] gap-6" aria-label="核心指标">
<MetricCard v-for="metric in metrics" :key="metric.title" :metric="metric" />
</section>
<section class="mt-6 grid grid-cols-[948px_608px] gap-7">
<section class="mt-6 grid grid-cols-[minmax(0,948fr)_minmax(0,608fr)] gap-7">
<RequestTrendCard :points="trendPoints" :summary="trendSummary" />
<ServiceStatusCard :items="serviceStatuses" />
</section>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import type { AccountUsageItem, SemanticTone } from '../types'
defineProps<{
@@ -76,24 +77,24 @@ const loadToneClasses: Record<SemanticTone, string> = {
</script>
<template>
<article class="h-[450px] w-[1584px] rounded-[18px] bg-white shadow-[var(--cp-shadow-card)]">
<div class="grid grid-cols-[360px_556px_556px] gap-7 px-7 pt-6">
<section class="h-[402px] w-[360px]">
<BaseCard as="article" :padded="false" class="h-[450px] w-full">
<div class="grid grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] gap-7 px-7 pt-6">
<section class="h-[402px] w-full">
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">账号调度</h2>
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">容量、并发与分配策略</p>
<div class="mt-[31px] h-[122px] rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-[18px]">
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">可调度容量</span>
<div class="mt-[7px] flex items-end justify-between">
<span class="block h-[14px] text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">可调度容量</span>
<div class="mt-[12px] grid h-[34px] grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
<strong class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-[var(--cp-text-primary)]">81 / 135</strong>
<span class="pb-[3px] text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">60% 已分配</span>
<span class="mt-[14px] text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">60% 已分配</span>
</div>
<div class="mt-[17px] h-2.5 w-[328px] overflow-hidden rounded-full bg-[#E2E8F0]">
<i class="block h-2.5 w-[197px] rounded-full bg-[var(--cp-success)]" />
<div class="mt-[18px] h-2.5 w-full overflow-hidden rounded-full bg-[#E2E8F0]">
<i class="block h-2.5 w-[60%] rounded-full bg-[var(--cp-success)]" />
</div>
</div>
<div class="mt-4 grid h-[90px] grid-cols-3 rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-5">
<div class="mt-4 grid h-[90px] grid-cols-3 gap-4 rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-5">
<div v-for="stat in scheduleStats" :key="stat.label">
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">{{ stat.label }}</span>
<strong class="mt-[13px] block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-[var(--cp-text-primary)]">{{ stat.value }}</strong>
@@ -106,38 +107,36 @@ const loadToneClasses: Record<SemanticTone, string> = {
</div>
</section>
<section class="w-[556px]">
<section class="w-full">
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">活跃账号用量</h2>
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">24h 请求排序</p>
<div class="mt-[51px] grid h-[330px] w-[556px] gap-1.5 overflow-hidden">
<div class="mt-[27px] grid h-[330px] w-full gap-1.5 overflow-hidden">
<article
v-for="account in accounts"
:key="account.name"
class="grid h-[78px] w-[528px] grid-cols-[34px_10px_120px_6px_70px_30px_64px_2px_70px_4px_84px] items-center rounded-[14px] px-3.5"
class="grid h-[78px] w-[calc(100%-28px)] grid-cols-[34px_10px_minmax(220px,1fr)_6px_64px_2px_70px_4px_84px_6px] items-start rounded-[14px] px-3.5 transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-bg-subtle)] active:translate-y-0"
:class="account.name === 'Amy Ops' || account.name === 'Build Bot' ? 'bg-[var(--cp-bg-subtle)]' : 'bg-white'"
>
<span class="inline-flex size-[34px] items-center justify-center rounded-full" :class="rowToneClasses[account.tone]">
<span class="mt-[22px] inline-flex size-[34px] items-center justify-center rounded-full" :class="rowToneClasses[account.tone]">
{{ account.name[0] }}
</span>
<span class="col-start-3 grid">
<span>
<span class="col-start-3 row-start-1 mt-[17px] flex min-w-0 items-start">
<strong class="text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ account.name }}</strong>
<small class="ml-1.5 text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ account.plan }}</small>
</span>
<span class="mt-[12px] text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.email }}</span>
<small class="ml-1.5 mt-[3px] text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ account.plan }}</small>
</span>
<strong class="col-start-7 font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.requests }}</strong>
<strong class="col-start-9 font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.tokens }}</strong>
<span class="col-start-7 row-start-1 mt-11 text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.lastUsed }}</span>
<span class="col-start-11 row-start-1 mt-[19px] block h-1.5 w-[84px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
<span class="col-start-3 row-start-1 mt-[48px] min-w-0 truncate text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.email }}</span>
<strong class="col-start-5 row-start-1 mt-5 w-16 font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.requests }}</strong>
<strong class="col-start-7 row-start-1 mt-5 w-[70px] font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.tokens }}</strong>
<span class="col-start-5 row-start-1 mt-[48px] w-20 text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.lastUsed }}</span>
<span class="col-start-9 row-start-1 mt-9 block h-1.5 w-[84px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
<i class="block h-1.5 rounded-full" :class="loadToneClasses[account.tone]" :style="{ width: `${account.loadWidth}px` }" />
</span>
</article>
</div>
</section>
<section class="h-[402px] w-[556px]">
<section class="h-[402px] w-full">
<header class="flex h-[50px] items-start justify-between">
<div>
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">账号状态</h2>
@@ -149,24 +148,24 @@ const loadToneClasses: Record<SemanticTone, string> = {
</div>
</header>
<div class="mt-[22px] h-[42px] w-[532px]">
<div class="mt-[22px] h-[42px] w-full">
<div class="flex h-4 items-center justify-between">
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">状态分布</span>
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-danger-text)]">不可用 7</span>
</div>
<div class="mt-2.5 flex h-3 w-[532px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
<i class="h-3 w-[372px] bg-[var(--cp-success)]" />
<i class="h-3 w-11 bg-[var(--cp-normal)]" />
<i class="h-3 w-14 bg-[var(--cp-warning)]" />
<i class="h-3 w-[60px] bg-[var(--cp-danger)]" />
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
<i class="h-3 basis-[71.1%] bg-[var(--cp-success)]" />
<i class="h-3 basis-[4.4%] bg-[var(--cp-normal)]" />
<i class="h-3 basis-[13.3%] bg-[var(--cp-warning)]" />
<i class="h-3 basis-[11.2%] bg-[var(--cp-danger)]" />
</div>
</div>
<div class="mt-[26px] grid h-[262px] w-[532px] gap-2.5">
<div class="mt-[26px] grid h-[262px] w-full gap-2.5">
<div
v-for="row in statusRows"
:key="row.label"
class="grid h-[58px] grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5"
class="grid h-[58px] grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5 transition-[background-color,transform] duration-200 hover:-translate-y-px active:translate-y-0"
>
<span class="inline-flex size-7 items-center justify-center rounded-[9px]" :class="rowToneClasses[row.tone]">
<component :is="row.icon" :size="16" />
@@ -180,5 +179,5 @@ const loadToneClasses: Record<SemanticTone, string> = {
</div>
</section>
</div>
</article>
</BaseCard>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseTable from '../../../components/base/BaseTable.vue'
import type { EventLogItem, SemanticTone } from '../types'
defineProps<{
@@ -6,14 +8,14 @@ defineProps<{
}>()
const filters = ['全部', '警告', '错误']
const columns = [
{ label: '时间', className: '' },
{ label: '级别', className: 'col-start-3' },
{ label: '请求 ID', className: 'col-start-5' },
{ label: '路由', className: 'col-start-7' },
{ label: '模型', className: 'col-start-9' },
{ label: '状态码', className: 'col-start-11' },
{ label: '延迟', className: '[grid-column-start:13]' },
const eventLogColumns = [
{ key: 'time', label: '时间', width: '11.5%' },
{ key: 'level', label: '级别', width: '7.5%' },
{ key: 'requestId', label: '请求 ID', width: '17%' },
{ key: 'route', label: '路由', width: '18.5%' },
{ key: 'model', label: '模型', width: '13%' },
{ key: 'statusCode', label: '状态码', width: '8.5%' },
{ key: 'latency', label: '延迟', width: '24%' },
]
const levelToneClasses: Record<SemanticTone, string> = {
@@ -31,10 +33,14 @@ const statusToneClasses: Record<SemanticTone, string> = {
warning: 'text-[var(--cp-warning-text)]',
danger: 'text-[var(--cp-danger-text)]',
}
function rowTone(row: object) {
return (row as EventLogItem).tone
}
</script>
<template>
<article class="h-[350px] w-[1584px] rounded-[18px] bg-white px-7 pt-6 shadow-[var(--cp-shadow-card)]">
<BaseCard as="article" :padded="false" class="h-[350px] w-full px-7 pt-6">
<header class="flex items-start justify-between">
<div>
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">事件日志</h2>
@@ -54,38 +60,37 @@ const statusToneClasses: Record<SemanticTone, string> = {
</div>
</header>
<div class="mt-[26px] h-10 w-[1528px] grid grid-cols-[150px_40px_90px_20px_230px_22px_250px_24px_170px_24px_96px_32px_100px] items-center rounded-xl bg-[var(--cp-bg-subtle)] px-5">
<span
v-for="column in columns"
:key="column.label"
class="text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]"
:class="column.className"
<div class="mt-[17px] flex h-[240px] w-full justify-between overflow-hidden">
<BaseTable
class="min-w-0 flex-1"
:columns="eventLogColumns"
:rows="rows"
table-class="min-w-full"
header-row-class="h-10 rounded-xl bg-[var(--cp-bg-subtle)] text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]"
body-row-class="h-14 rounded-[10px] transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-bg-subtle)] active:translate-y-0"
header-cell-class="min-w-0 overflow-hidden bg-[var(--cp-bg-subtle)] px-5 text-ellipsis whitespace-nowrap first:rounded-l-xl last:rounded-r-xl"
body-cell-class="min-w-0 overflow-hidden px-5 text-ellipsis whitespace-nowrap text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-primary)] first:rounded-l-[10px] last:rounded-r-[10px]"
>
{{ column.label }}
</span>
</div>
<template #time="{ value }">
<span class="font-mono tabular-nums">{{ value }}</span>
</template>
<div class="mt-4 flex h-[184px] w-[1528px] justify-between overflow-hidden">
<div class="grid w-[1476px] gap-2">
<div
v-for="(row, index) in rows"
:key="row.id"
class="grid h-14 w-[1476px] grid-cols-[150px_40px_90px_20px_230px_22px_250px_24px_170px_24px_96px_32px_100px] items-center rounded-[10px] px-5"
:class="index % 2 === 0 ? 'bg-white' : 'bg-[var(--cp-bg-subtle)]'"
>
<span class="font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.time }}</span>
<span class="col-start-3 inline-flex h-6 w-[58px] items-center justify-center rounded-full text-[12px] leading-[1.15] font-bold" :class="levelToneClasses[row.tone]">{{ row.level }}</span>
<span class="col-start-5 font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.requestId }}</span>
<span class="col-start-7 text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ row.route }}</span>
<span class="col-start-9 text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ row.model }}</span>
<span class="col-start-11 font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="statusToneClasses[row.tone]">{{ row.statusCode }}</span>
<span class="[grid-column-start:13] font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.latency }}</span>
</div>
</div>
<template #level="{ row, value }">
<span class="inline-flex h-6 w-[58px] items-center justify-center rounded-full text-[12px] leading-[1.15] font-bold" :class="levelToneClasses[rowTone(row)]">{{ value }}</span>
</template>
<div class="mt-1.5 h-[172px] w-[3px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
<i class="mt-2 block h-16 w-[3px] rounded-full bg-[var(--cp-text-muted)]" />
</div>
<template #requestId="{ value }">
<span class="font-mono tabular-nums">{{ value }}</span>
</template>
<template #statusCode="{ row, value }">
<span class="font-mono font-bold tabular-nums" :class="statusToneClasses[rowTone(row)]">{{ value }}</span>
</template>
<template #latency="{ value }">
<span class="font-mono tabular-nums">{{ value }}</span>
</template>
</BaseTable>
</div>
</article>
</BaseCard>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import type { MetricCardItem, SemanticTone } from '../types'
defineProps<{
@@ -23,7 +24,7 @@ const detailToneClasses: Record<SemanticTone, string> = {
</script>
<template>
<article class="h-[154px] w-[378px] rounded-2xl bg-white px-6 pt-5 shadow-[var(--cp-shadow-card)]">
<BaseCard as="article" :padded="false" radius-class="rounded-2xl" class="h-[154px] w-full px-6 pt-5">
<div class="flex items-start gap-3">
<span class="inline-flex size-[34px] shrink-0 items-center justify-center rounded-[10px]" :class="iconToneClasses[metric.tone]">
<component :is="metric.icon" :size="18" />
@@ -31,19 +32,23 @@ const detailToneClasses: Record<SemanticTone, string> = {
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">{{ metric.title }}</span>
</div>
<strong class="mt-2.5 block w-[330px] font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-[var(--cp-text-primary)]">
<strong class="mt-2.5 block w-full font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-[var(--cp-text-primary)]">
{{ metric.value }}
</strong>
<div class="mt-[16.6px] grid h-[30px] w-[330px] grid-cols-[40px_82px_48px_94px] items-center gap-0 rounded-[10px] bg-[var(--cp-bg-subtle)] px-3">
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[0]?.label }}</span>
<b class="font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined">
{{ metric.details[0]?.value }}
</b>
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[1]?.label }}</span>
<b class="text-right font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined">
{{ metric.details[1]?.value }}
</b>
<div class="mt-[16.6px] grid h-[30px] w-full grid-cols-2 items-center rounded-[10px] bg-[var(--cp-bg-subtle)] px-3">
<span class="inline-flex min-w-0 w-full items-center justify-start gap-3">
<span class="shrink-0 text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[0]?.label }}</span>
<b class="min-w-0 truncate font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined">
{{ metric.details[0]?.value }}
</b>
</span>
<span class="inline-flex min-w-0 w-full items-center justify-start gap-3">
<span class="shrink-0 text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[1]?.label }}</span>
<b class="min-w-0 truncate font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined">
{{ metric.details[1]?.value }}
</b>
</span>
</div>
</article>
</BaseCard>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import type { SemanticTone, TrendPoint, TrendSummaryItem } from '../types'
defineProps<{
@@ -19,7 +20,7 @@ const dotToneClasses: Record<SemanticTone, string> = {
</script>
<template>
<article class="h-[380px] w-[948px] rounded-[18px] bg-white px-7 pt-[22px] shadow-[var(--cp-shadow-card)]">
<BaseCard as="article" :padded="false" class="h-[380px] w-full px-7 pt-[22px]">
<header class="flex items-start justify-between">
<div class="pt-0.5">
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">请求趋势</h2>
@@ -39,9 +40,9 @@ const dotToneClasses: Record<SemanticTone, string> = {
</div>
</header>
<div class="mt-7 grid grid-cols-[682px_180px] gap-[30px]">
<div class="h-[268px] w-[682px] overflow-hidden rounded-[10px] bg-white">
<svg class="block h-[268px] w-[682px]" viewBox="0 0 682 268" role="img" aria-label="最近 24 小时请求趋势">
<div class="mt-[19px] grid grid-cols-[minmax(0,1fr)_minmax(150px,180px)] gap-[30px]">
<div class="h-[268px] w-full overflow-hidden rounded-[10px] bg-white">
<svg class="block h-[268px] w-full" viewBox="0 0 682 268" preserveAspectRatio="none" role="img" aria-label="最近 24 小时请求趋势">
<g transform="translate(0 0)">
<line v-for="y in [18, 62, 106, 150, 194, 238]" :key="y" x1="0" x2="650" :y1="y" :y2="y" stroke="#F1F5F9" />
<path
@@ -78,18 +79,18 @@ const dotToneClasses: Record<SemanticTone, string> = {
</svg>
</div>
<aside class="h-[268px] w-[180px] rounded-2xl bg-[var(--cp-bg-subtle)] px-5 py-[18px]">
<aside class="h-[268px] w-full rounded-2xl bg-[var(--cp-bg-subtle)] px-5 py-[18px]">
<div
v-for="(item, index) in summary"
:key="item.label"
class="grid grid-cols-[100px_8px] items-start gap-3"
:class="index === 0 ? '' : 'mt-[38px]'"
class="grid grid-cols-[minmax(0,1fr)_8px] items-start gap-x-3 gap-y-px"
:class="index === 0 ? '' : 'mt-[36.6px]'"
>
<span class="col-span-2 text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]">{{ item.label }}</span>
<strong class="mt-[7px] font-mono text-[24px] leading-[1.15] font-[760] tabular-nums text-[var(--cp-text-primary)]">{{ item.value }}</strong>
<i class="mt-[14px] size-2 rounded-full" :class="dotToneClasses[item.tone]" />
<i class="mt-[14px] size-2 justify-self-end rounded-full" :class="dotToneClasses[item.tone]" />
</div>
</aside>
</div>
</article>
</BaseCard>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import BaseCard from '../../../components/base/BaseCard.vue'
import type { SemanticTone, ServiceStatusItem } from '../types'
defineProps<{
@@ -23,14 +24,14 @@ const valueToneClasses: Record<SemanticTone, string> = {
</script>
<template>
<article class="h-[380px] w-[608px] rounded-[18px] bg-white px-7 pt-6 shadow-[var(--cp-shadow-card)]">
<BaseCard as="article" :padded="false" class="h-[380px] w-full px-7 pt-6">
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">服务状态</h2>
<div class="mt-[23px] grid gap-2">
<div
v-for="item in items"
:key="item.label"
class="grid h-[50px] w-[552px] grid-cols-[24px_16px_224px_16px_86px_34px_100px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5"
class="grid h-[50px] w-full grid-cols-[24px_16px_minmax(0,224fr)_16px_86px_34px_100px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5"
>
<span class="inline-flex size-6 items-center justify-center rounded-lg" :class="iconToneClasses[item.tone]">
<component :is="item.icon" :size="14" />
@@ -40,5 +41,5 @@ const valueToneClasses: Record<SemanticTone, string> = {
<span class="col-start-7 w-[100px] font-mono text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ item.detail }}</span>
</div>
</div>
</article>
</BaseCard>
</template>
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const tablePath = resolve(root, 'src/components/base/BaseTable.vue')
test('BaseTable uses semantic table elements instead of div grid rows', async () => {
const source = await readFile(tablePath, 'utf8')
for (const token of [
'<table',
'<colgroup>',
'<col',
'<thead>',
'<tbody>',
'<tr',
'<th',
'<td',
'table-fixed',
':style="{ width: \'100%\' }"',
]) {
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
}
assert.doesNotMatch(source, /gridTemplateColumns/)
assert.doesNotMatch(source, /class="grid min-w-\[620px\]/)
assert.doesNotMatch(source, /calc\(/)
})
test('BaseCard is the shared card surface primitive', async () => {
const source = await readFile(resolve(root, 'src/components/base/BaseCard.vue'), 'utf8')
for (const token of [
'as?: keyof HTMLElementTagNameMap',
'const props = withDefaults',
'<component',
':is="props.as"',
'rounded-[var(--cp-card-radius)]',
'shadow-[var(--cp-shadow-card)]',
'padded ?',
]) {
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
}
})
+145 -18
View File
@@ -33,12 +33,12 @@ test('dashboard page recreates the Pencil canvas with flow layout spacing', asyn
assertNoAbsoluteLayout(view, 'DashboardView')
assertTokens(view, [
'ml-7 w-[1584px] pt-[34px] pb-[60px]',
'w-full min-w-[1180px] px-7 pt-[34px] pb-[34px]',
'flex h-[68px] items-start justify-between',
'mt-0 text-[34px] leading-[1.15] font-[800]',
'mt-2.5 text-[15px] leading-[1.15] font-semibold',
'mt-6 grid grid-cols-4 gap-6',
'mt-6 grid grid-cols-[948px_608px] gap-7',
'mt-6 grid grid-cols-[repeat(4,minmax(0,1fr))] gap-6',
'mt-6 grid grid-cols-[minmax(0,948fr)_minmax(0,608fr)] gap-7',
'mt-6',
'MetricCard',
'RequestTrendCard',
@@ -48,12 +48,80 @@ test('dashboard page recreates the Pencil canvas with flow layout spacing', asyn
])
})
test('admin layout keeps sidebar fixed and scrolls only the main region', async () => {
const layout = await source('src/layout/index.vue')
assertTokens(layout, [
'ref(false)',
':collapsed="sidebarCollapsed"',
'@toggle="sidebarCollapsed = !sidebarCollapsed"',
'flex h-screen overflow-hidden bg-[var(--cp-bg-page)]',
'cp-scrollbar h-screen min-w-0 flex-1 overflow-auto',
'AppSidebar',
])
})
test('interactive controls use refined motion and respect reduced motion', async () => {
const sidebar = await source('src/layout/components/AppSidebar.vue')
const topbar = await source('src/layout/components/AppTopbar.vue')
const global = await source('src/styles/base.css')
assertTokens(sidebar, [
"import { gsap } from 'gsap'",
'gsap.to(sidebarEl.value',
'width: targetWidth',
'flexBasis: targetWidth',
"ease: 'power3.out'",
'animateSidebarLabels',
'data-sidebar-toggle',
'transition-[opacity,transform]',
'duration-200',
'hover:-translate-y-px',
'active:translate-y-0',
])
assertTokens(topbar, [
"import { gsap } from 'gsap'",
'animateAccountMenuEnter',
'animateAccountMenuLeave',
'gsap.fromTo(',
"ease: 'power3.out'",
"ease: 'power2.in'",
'hover:-translate-y-px',
'active:translate-y-0',
'group-hover:rotate-180',
])
assertTokens(global, [
'button {\n font-family: inherit;',
'.cp-scrollbar',
'scrollbar-color: var(--cp-scrollbar-thumb) transparent',
'scrollbar-width: thin',
'.cp-scrollbar::-webkit-scrollbar-thumb',
'background-clip: content-box',
'@media (prefers-reduced-motion: reduce)',
'transition-duration: 0.01ms !important',
'animation-duration: 0.01ms !important',
])
assert.doesNotMatch(
global,
/button[\s\S]{0,80}font:\s*inherit/,
'button reset must not override Tailwind text size, weight, and line-height utilities',
)
})
test('topbar controls are normal flex items and only the popover floats', async () => {
const topbar = await source('src/layout/components/AppTopbar.vue')
assert.doesNotMatch(topbar, /absolute left-\[/)
assertTokens(topbar, [
'flex h-11 items-start gap-3.5',
'ref(false)',
'@click="accountMenuOpen = !accountMenuOpen"',
'v-if="accountMenuOpen"',
'data-account-trigger',
'data-account-menu',
'h-11 w-[170px]',
'size-11',
'h-11 w-[216px]',
@@ -89,57 +157,116 @@ test('dashboard cards use grid and flex primitives with exact Pencil dimensions'
['EventLogCard', events],
]) {
assertNoAbsoluteLayout(file, label)
assertTokens(file, [
"import BaseCard from '../../../components/base/BaseCard.vue'",
'<BaseCard',
'as="article"',
':padded="false"',
])
}
assertTokens(metric, [
'h-[154px] w-[378px]',
'h-[154px] w-full',
'px-6 pt-5',
'flex items-start gap-3',
'mt-2.5',
'mt-[16.6px]',
'h-[30px] w-[330px]',
'grid h-[30px] w-full grid-cols-2',
'inline-flex min-w-0 w-full items-center justify-start gap-3',
])
assert.doesNotMatch(
metric,
/grid-cols-\[40px_82px_24px_48px_94px\]|justify-self-end|text-right/,
'MetricCard detail strip must split into equal-width left-aligned halves',
)
assertTokens(trend, [
'h-[380px] w-[948px]',
'h-[380px] w-full',
'px-7 pt-[22px]',
'flex items-start justify-between',
'h-[38px] w-[246px]',
'mt-7 grid grid-cols-[682px_180px] gap-[30px]',
'h-[268px] w-[682px]',
'mt-[19px] grid grid-cols-[minmax(0,1fr)_minmax(150px,180px)] gap-[30px]',
'h-[268px] w-full',
'preserveAspectRatio="none"',
'grid-cols-[minmax(0,1fr)_8px]',
'justify-self-end',
'M0 150c70-14 96-20 136-38 48-24 74-20 108-58 42-42 78-22 116 6 44 32 82 18 124-10 44-24 82-16 120 30 18 22 34 12 46-6',
'M0 70c80-2 122-8 170-6 56 2 116-14 166-8 62 6 94 18 142 2 48-16 108-2 172-10',
'M0 44c72 4 116-8 174-4 58 2 110-6 166-2 66 8 118-8 174-4 60 6 100-4 136-8',
])
assert.doesNotMatch(
trend,
/grid-cols-\[100px_8px\]/,
'RequestTrendCard summary markers must align against the elastic summary panel width',
)
assertTokens(service, [
'h-[380px] w-[608px]',
'h-[380px] w-full',
'px-7 pt-6',
'mt-[23px] grid gap-2',
'h-[50px] w-[552px]',
'h-[50px] w-full',
])
assertTokens(account, [
'h-[450px] w-[1584px]',
'grid grid-cols-[360px_556px_556px] gap-7 px-7 pt-6',
'h-[402px] w-[360px]',
'h-[330px] w-[556px]',
'h-[402px] w-[556px]',
'h-[450px] w-full',
'grid grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] gap-7 px-7 pt-6',
'h-[402px] w-full',
'h-[330px] w-full',
'w-[calc(100%-28px)]',
'grid-cols-[34px_10px_minmax(220px,1fr)_6px_64px_2px_70px_4px_84px_6px]',
'min-w-0',
'block h-[14px] text-[12px] leading-[1.15] font-[650]',
'mt-[12px] grid h-[34px] grid-cols-[minmax(0,1fr)_auto] items-start gap-4',
'mt-[14px] text-[12px] leading-[1.15] font-[650]',
'mt-[18px] h-2.5 w-full',
'w-[60%]',
'grid-cols-3 gap-4',
'81 / 135',
'60% 已分配',
'最少使用优先',
'71.1%',
'basis-[71.1%]',
'basis-[4.4%]',
'basis-[13.3%]',
'basis-[11.2%]',
])
assert.doesNotMatch(
account,
/grid-cols-\[232px_96px\]|w-\[328px\]|w-\[197px\]|grid-cols-\[100px_16px_100px_16px_100px\]|className: 'col-start-5'|w-\[372px\]|w-11|w-14|w-\[60px\]/,
'AccountOverviewCard schedule controls must stretch with the schedule panel width',
)
assertTokens(events, [
'h-[350px] w-[1584px]',
'h-[350px] w-full',
'px-7 pt-6',
'flex items-start justify-between',
'h-9 w-[206px]',
'mt-[26px] h-10 w-[1528px]',
'mt-4 flex h-[184px] w-[1528px] justify-between overflow-hidden',
"import BaseTable from '../../../components/base/BaseTable.vue'",
'const eventLogColumns',
"width: '11.5%'",
"width: '17%'",
"width: '24%'",
':columns="eventLogColumns"',
':rows="rows"',
'table-class="min-w-full"',
'header-row-class="h-10 rounded-xl bg-[var(--cp-bg-subtle)] text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]"',
'body-row-class="h-14 rounded-[10px] transition-[background-color,transform] duration-200 hover:-translate-y-px hover:bg-[var(--cp-bg-subtle)] active:translate-y-0"',
'mt-[17px] flex h-[240px] w-full justify-between overflow-hidden',
'levelToneClasses[rowTone(row)]',
'statusToneClasses[rowTone(row)]',
'请求 ID',
])
assert.doesNotMatch(
events,
/grid-cols-\[[^\]]*fr/,
'EventLogCard table columns must use fixed Pencil tracks so header and rows stay aligned',
)
assert.doesNotMatch(
events,
/w-\[3px\]/,
'EventLogCard should rely on the base table scrollbar and must not render the extra right rail',
)
})
test('dashboard data mirrors Pencil text values', async () => {
+27 -23
View File
@@ -30,34 +30,38 @@ test('sidebar uses Pencil design icons and brand copy', async () => {
test('sidebar locks core Pencil dimensions and coordinates', async () => {
const source = await readFile(sidebarPath, 'utf8')
assert.doesNotMatch(
source,
/\babsolute\b|\bsticky\b|left-\[|top-\[|bottom-/,
'sidebar should recreate Pencil spacing with flex/grid/margin/padding instead of positioned coordinates',
)
for (const token of [
'w-[280px]',
'basis-[280px]',
'left-[28px]',
'top-[31px]',
'w-[256px]',
'basis-[256px]',
'mt-[31px]',
'ml-6',
'grid h-[46px] grid-cols-[46px_minmax(0,1fr)] gap-2.5',
'size-[46px]',
'left-[84px]',
'top-[34px]',
'top-[57px]',
'left-6',
'w-[232px]',
'content-center overflow-hidden',
'text-[17px] leading-[1.1] font-[720]',
'mt-1 text-[12px] leading-[1.1] font-semibold',
'mt-[35px] grid gap-3',
"'ml-6 self-start'",
'w-[208px]',
'w-[88px]',
'basis-[88px]',
'w-[46px]',
'h-[46px]',
'left-[22px]',
'top-[13px]',
'left-[54px]',
'top-[15px]',
'top-[112px]',
'top-[170px]',
'top-[228px]',
'top-[286px]',
'top-[344px]',
'top-[402px]',
'top-[460px]',
'top-[518px]',
'left-[220px]',
'bottom-8',
"'w-[208px] gap-3 pl-[22px]'",
'font-bold text-[#111827]',
'font-semibold text-[#64748B]',
'mt-auto mb-8',
"'mr-6 size-9 self-end bg-[#F8FAFC]'",
'size-9',
]) {
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
}
assert.doesNotMatch(source, /w-\[280px\]|basis-\[280px\]|w-\[232px\]/)
})