mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: restore dashboard layout fidelity
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+8
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '\\$&')))
|
||||
}
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user