refactor(ui): introduce shared list detail shells
This commit is contained in:
22
web/src/components/shared/EnterpriseDetailCard.vue
Normal file
22
web/src/components/shared/EnterpriseDetailCard.vue
Normal 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>
|
||||
71
web/src/components/shared/EnterpriseDetailPage.vue
Normal file
71
web/src/components/shared/EnterpriseDetailPage.vue
Normal 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>
|
||||
197
web/src/components/shared/EnterpriseListPage.vue
Normal file
197
web/src/components/shared/EnterpriseListPage.vue
Normal 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>
|
||||
107
web/src/components/shared/EnterprisePagination.vue
Normal file
107
web/src/components/shared/EnterprisePagination.vue
Normal 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>
|
||||
Reference in New Issue
Block a user