This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
withDefaults(defineProps<{
as?: string
width?: 'full' | 'comfortable'
spacing?: 'compact' | 'normal' | 'relaxed'
}>(), {
as: 'main',
width: 'full',
spacing: 'normal',
})
</script>
<template>
<component :is="as" class="admin-page" :class="[`is-width-${width}`, `is-spacing-${spacing}`]">
<slot />
</component>
</template>
<style scoped>
.admin-page { display: flex; min-width: 0; flex-direction: column; margin: 0 auto; }
.admin-page.is-width-comfortable { width: min(100%, 1440px); }
.admin-page.is-spacing-compact { gap: 12px; }
.admin-page.is-spacing-normal { gap: 16px; }
.admin-page.is-spacing-relaxed { gap: 20px; }
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(defineProps<{
ariaLabel?: string
}>(), {
ariaLabel: '详情页面',
})
</script>
<template>
<article class="detail-page-layout" :aria-label="ariaLabel">
<header v-if="$slots.header" class="detail-page-layout__header"><slot name="header" /></header>
<div v-if="$slots.summary" class="detail-page-layout__summary"><slot name="summary" /></div>
<div class="detail-page-layout__content"><slot /></div>
<footer v-if="$slots.footer" class="detail-page-layout__footer"><slot name="footer" /></footer>
</article>
</template>
<style scoped>
.detail-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer { min-width: 0; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
description?: string
bordered?: boolean
}>(), {
description: '',
bordered: true,
})
</script>
<template>
<section class="form-section" :class="{ 'is-bordered': bordered }">
<div class="form-section__heading">
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div class="form-section__content"><slot /></div>
</section>
</template>
<style scoped>
.form-section { min-width: 0; padding: 20px 0; }
.form-section.is-bordered { border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.form-section:first-child { padding-top: 0; }
.form-section:last-child { padding-bottom: 0; border-bottom: 0; }
.form-section__heading { margin-bottom: 16px; }
.form-section__heading h2 { margin: 0; color: var(--cc-text, #172033); font-size: 14px; line-height: 1.4; }
.form-section__heading p { margin: 4px 0 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.5; }
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(defineProps<{
ariaLabel?: string
}>(), {
ariaLabel: '列表页面',
})
</script>
<template>
<section class="list-page-layout" :aria-label="ariaLabel">
<div v-if="$slots.toolbar" class="list-page-layout__toolbar"><slot name="toolbar" /></div>
<div v-if="$slots.metrics" class="list-page-layout__metrics"><slot name="metrics" /></div>
<div v-if="$slots.state" class="list-page-layout__state"><slot name="state" /></div>
<div v-if="$slots.default" class="list-page-layout__content"><slot /></div>
</section>
</template>
<style scoped>
.list-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
.list-page-layout__toolbar, .list-page-layout__metrics, .list-page-layout__state, .list-page-layout__content { min-width: 0; }
</style>
+55
View File
@@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AdminPage from './AdminPage.vue'
import DetailPageLayout from './DetailPageLayout.vue'
import FormSection from './FormSection.vue'
import ListPageLayout from './ListPageLayout.vue'
import PageToolbar from './PageToolbar.vue'
describe('admin page layout components', () => {
it('renders a semantic page root with stable layout classes', () => {
const wrapper = mount(AdminPage, {
props: { width: 'comfortable', spacing: 'compact' },
slots: { default: '<p>内容</p>' },
})
expect(wrapper.element.tagName).toBe('MAIN')
expect(wrapper.classes()).toEqual(expect.arrayContaining(['is-width-comfortable', 'is-spacing-compact']))
expect(wrapper.text()).toBe('内容')
})
it('keeps page title and actions in separate regions', () => {
const wrapper = mount(PageToolbar, {
props: { title: '客户管理', description: '维护客户资料' },
slots: { actions: '<button>新增</button>' },
})
expect(wrapper.get('h1').text()).toBe('客户管理')
expect(wrapper.get('.page-toolbar__description').text()).toBe('维护客户资料')
expect(wrapper.get('.page-toolbar__actions').text()).toBe('新增')
})
it('renders optional list regions only when supplied', () => {
const wrapper = mount(ListPageLayout, {
slots: { toolbar: '<div>筛选</div>', default: '<div>表格</div>' },
})
expect(wrapper.find('.list-page-layout__toolbar').exists()).toBe(true)
expect(wrapper.find('.list-page-layout__metrics').exists()).toBe(false)
expect(wrapper.text()).toContain('筛选')
expect(wrapper.text()).toContain('表格')
})
it('supports detail regions and form section descriptions', () => {
const detail = mount(DetailPageLayout, {
slots: { header: '<h1>详情</h1>', default: '<p>正文</p>', footer: '<button>保存</button>' },
})
expect(detail.find('.detail-page-layout__header').exists()).toBe(true)
expect(detail.find('.detail-page-layout__footer').text()).toBe('保存')
const form = mount(FormSection, {
props: { title: '基础信息', description: '填写必填字段' },
slots: { default: '<input aria-label="名称" />' },
})
expect(form.get('h2').text()).toBe('基础信息')
expect(form.get('.form-section__heading p').text()).toBe('填写必填字段')
expect(form.find('input').exists()).toBe(true)
})
})
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
description?: string
eyebrow?: string
as?: 'header' | 'section'
}>(), {
description: '',
eyebrow: '',
as: 'header',
})
</script>
<template>
<component :is="as" class="page-toolbar">
<div class="page-toolbar__copy">
<p v-if="eyebrow" class="page-toolbar__eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p v-if="description" class="page-toolbar__description">{{ description }}</p>
</div>
<div v-if="$slots.default || $slots.actions" class="page-toolbar__actions">
<slot />
<slot name="actions" />
</div>
</component>
</template>
<style scoped>
.page-toolbar { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 16px; }
.page-toolbar__copy { min-width: 0; flex: 1; }
.page-toolbar h1 { margin: 0; color: var(--cc-text, #172033); font-size: 20px; line-height: 1.35; }
.page-toolbar__eyebrow { margin: 0 0 5px; color: var(--cc-primary); font-size: 11px; font-weight: 700; }
.page-toolbar__description { margin: 5px 0 0; color: var(--cc-muted, #667085); font-size: 13px; line-height: 1.55; }
.page-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.page-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.page-toolbar { flex-direction: column; }
.page-toolbar__actions { width: 100%; justify-content: flex-start; }
.page-toolbar__actions :deep(.el-button) { min-height: 44px; }
}
</style>