refactor(ui): introduce shared list detail shells

This commit is contained in:
caoxiaozhu
2026-05-28 22:49:58 +08:00
parent b383244a29
commit 064eeb614f
17 changed files with 1163 additions and 1095 deletions

View File

@@ -0,0 +1,22 @@
<template>
<article class="detail-card panel enterprise-detail-card" :class="{ wide }">
<div v-if="title || description || $slots.actions" class="card-head">
<div>
<h3 v-if="title">{{ title }}</h3>
<p v-if="description">{{ description }}</p>
</div>
<slot name="actions"></slot>
</div>
<slot></slot>
</article>
</template>
<script setup>
defineProps({
description: { type: String, default: '' },
title: { type: String, default: '' },
wide: { type: Boolean, default: false }
})
</script>

View File

@@ -0,0 +1,71 @@
<template>
<article class="enterprise-detail-page" :class="variant">
<div class="detail-scroll">
<TableLoadingState
v-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<section v-else-if="error" class="detail-inline-state panel error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
</div>
</slot>
</section>
<template v-else>
<section v-if="$slots.hero" class="detail-hero panel">
<slot name="hero"></slot>
</section>
<slot></slot>
<div v-if="$slots.main || $slots.side" class="detail-grid">
<section v-if="$slots.main" class="detail-main">
<slot name="main"></slot>
</section>
<aside v-if="$slots.side" class="detail-side">
<slot name="side"></slot>
</aside>
</div>
</template>
</div>
<footer v-if="backLabel || $slots.actions" class="detail-actions">
<button v-if="backLabel" class="back-action" type="button" @click="emit('back')">
<i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span>
</button>
<div v-if="$slots.actions" class="detail-action-group">
<slot name="actions"></slot>
</div>
</footer>
</article>
</template>
<script setup>
import TableLoadingState from './TableLoadingState.vue'
defineProps({
backLabel: { type: String, default: '' },
error: { type: String, default: '' },
errorTitle: { type: String, default: '详情加载失败' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '正在加载详情' },
variant: { type: String, default: '' }
})
const emit = defineEmits(['back'])
</script>

View File

@@ -0,0 +1,197 @@
<template>
<article class="enterprise-list-page panel" :class="variant">
<slot name="before"></slot>
<nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel">
<slot
name="tabs"
:tabs="normalizedTabs"
:active-tab="activeTab"
:select-tab="selectTab"
>
<button
v-for="tab in normalizedTabs"
:key="tab.value"
type="button"
:class="{ active: activeTab === tab.value }"
@click="selectTab(tab.value)"
>
<span>{{ tab.label }}</span>
<small v-if="tab.count !== null">{{ tab.count }}</small>
</button>
</slot>
</nav>
<div v-if="hasToolbar" class="list-toolbar">
<slot name="toolbar">
<div v-if="hasFilterRegion" class="filter-set">
<slot name="filters">
<label v-if="searchable" class="list-search">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
</slot>
</div>
<div v-if="$slots.actions" class="toolbar-actions">
<slot name="actions"></slot>
</div>
</slot>
</div>
<p v-if="hasHint" class="hint">
<slot name="hint">
<i v-if="hintIcon" :class="hintIcon"></i>
<span>{{ hint }}</span>
</slot>
</p>
<div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }">
<div v-if="loading" class="table-state">
<TableLoadingState
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
/>
</div>
<div v-else-if="error" class="table-state error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
<button v-if="retryLabel" class="state-action retry-btn" type="button" @click="emit('retry')">
{{ retryLabel }}
</button>
</slot>
</div>
<slot v-else-if="empty" name="empty">
<TableEmptyState
v-if="emptyState"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc || emptyState.description"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips || []"
@action="emit('empty-action')"
/>
</slot>
<slot v-else name="table"></slot>
</div>
<slot name="footer">
<EnterprisePagination
v-if="showPagination"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pages"
:show-page-size="showPageSize"
:summary="summary"
:total="total"
:total-pages="totalPages"
@update:current-page="emit('update:currentPage', $event)"
@update:page-size="emit('update:pageSize', $event)"
@page-size-change="emit('page-size-change', $event)"
/>
</slot>
</article>
</template>
<script setup>
import { computed, useSlots } from 'vue'
import EnterprisePagination from './EnterprisePagination.vue'
import TableEmptyState from './TableEmptyState.vue'
import TableLoadingState from './TableLoadingState.vue'
const props = defineProps({
activeTab: { type: [String, Number], default: '' },
currentPage: { type: Number, default: 1 },
empty: { type: Boolean, default: false },
emptyState: { type: Object, default: null },
error: { type: String, default: '' },
errorTitle: { type: String, default: '列表加载失败' },
hint: { type: String, default: '' },
hintIcon: { type: String, default: 'mdi mdi-information-outline' },
keyword: { type: String, default: '' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '数据同步中' },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
retryLabel: { type: String, default: '重新加载' },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '搜索' },
showPageSize: { type: Boolean, default: true },
showPagination: { type: Boolean, default: false },
summary: { type: String, default: '' },
tabs: {
type: Array,
default: () => []
},
tabsLabel: { type: String, default: '列表视图' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 },
variant: { type: String, default: '' }
})
const emit = defineEmits([
'empty-action',
'page-size-change',
'retry',
'update:activeTab',
'update:currentPage',
'update:keyword',
'update:pageSize'
])
const slots = useSlots()
const normalizedTabs = computed(() =>
props.tabs.map((tab) => {
if (tab && typeof tab === 'object') {
const value = tab.value ?? tab.label ?? ''
return {
value,
label: String(tab.label ?? value),
count: tab.count ?? null
}
}
return {
value: tab,
label: String(tab),
count: null
}
})
)
const hasTabs = computed(() => normalizedTabs.value.length > 0 || Boolean(slots.tabs))
const hasToolbar = computed(() => props.searchable || Boolean(slots.filters || slots.actions || slots.toolbar))
const hasFilterRegion = computed(() => props.searchable || Boolean(slots.filters))
const hasHint = computed(() => Boolean(props.hint || slots.hint))
function selectTab(tab) {
emit('update:activeTab', tab)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<footer class="list-foot enterprise-pagination">
<span class="page-summary">{{ summaryText }}</span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="setPage(currentPage - 1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<template v-for="item in pageItems" :key="String(item)">
<span v-if="item === 'ellipsis'" class="page-ellipsis" aria-hidden="true">...</span>
<button
v-else
class="page-number"
:class="{ active: currentPage === item }"
type="button"
:aria-current="currentPage === item ? 'page' : undefined"
@click="setPage(item)"
>
{{ item }}
</button>
</template>
<button
class="page-nav"
type="button"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="setPage(currentPage + 1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
:model-value="pageSize"
:options="pageSizeOptions"
size="small"
@change="setPageSize"
/>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import EnterpriseSelect from './EnterpriseSelect.vue'
const props = defineProps({
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
showPageSize: { type: Boolean, default: true },
summary: { type: String, default: '' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 }
})
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
const pageItems = computed(() => {
if (props.pages.length) {
return props.pages
}
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
})
const summaryText = computed(() => {
if (props.summary) {
return props.summary
}
return `${props.total} 条,当前第 ${props.currentPage}`
})
function setPage(page) {
if (page === 'ellipsis') {
return
}
const nextPage = Math.min(Math.max(Number(page) || 1, 1), props.totalPages)
if (nextPage !== props.currentPage) {
emit('update:currentPage', nextPage)
}
}
function setPageSize(size) {
emit('update:pageSize', size)
emit('page-size-change', size)
}
</script>