feat: 完成客户端联调与通用列表组件

This commit is contained in:
2026-08-01 12:17:33 +08:00
parent be7675954a
commit beb86d53e7
31 changed files with 1326 additions and 378 deletions
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
width?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
width: 'min(560px, calc(100vw - 28px))',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDialog() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-dialog
v-bind="$attrs"
v-model="open"
class="app-dialog"
:title="title"
:width="width"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDialog" :submitting="confirmLoading">
<div class="app-dialog__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDialog">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<style scoped>
.app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DialogConfirmType } from './AppDialog.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
size?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
size: 'min(680px, 94vw)',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDrawer() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-drawer
v-bind="$attrs"
v-model="open"
class="app-drawer"
:title="title"
:size="size"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-drawer__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDrawer" :submitting="confirmLoading">
<div class="app-drawer__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDrawer">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-drawer>
</template>
<style scoped>
:global(.app-drawer) { max-width: calc(100vw - 20px); }
.app-drawer__body { min-height: 0; }
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Copy } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { copyText } from '@/utils/copyText'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
value: string | number | null | undefined
label?: string
buttonText?: string
successText?: string
errorText?: string
disabled?: boolean
notify?: boolean
}>(), {
label: '内容',
buttonText: '复制',
successText: undefined,
errorText: undefined,
disabled: false,
notify: true,
})
const emit = defineEmits<{
copied: [value: string]
'copy-error': [error: unknown]
}>()
const copying = ref(false)
const copyValue = computed(() => props.value == null ? '' : String(props.value))
const isDisabled = computed(() => props.disabled || copying.value || copyValue.value.length === 0)
async function handleCopy() {
if (isDisabled.value) return
copying.value = true
try {
await copyText(copyValue.value)
if (props.notify) ElMessage.success(props.successText || `${props.label}已复制`)
emit('copied', copyValue.value)
} catch (error) {
if (props.notify) ElMessage.error(props.errorText || `${props.label}复制失败,请手动选择复制`)
emit('copy-error', error)
} finally {
copying.value = false
}
}
</script>
<template>
<el-button
v-bind="$attrs"
:disabled="isDisabled"
:aria-label="`复制${label}`"
:title="`复制${label}`"
@click="handleCopy"
>
<Copy :size="14" aria-hidden="true" />
<slot :copying="copying">{{ buttonText }}</slot>
</el-button>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
export interface DataTableColumn {
key?: string | number
label?: string
prop?: string
property?: string
columnKey?: string
slotName?: string
headerSlotName?: string
[attribute: string]: unknown
}
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
export function normalizePageSizes(values: readonly number[]) {
const sizes = values
.filter((value) => Number.isFinite(value))
.map((value) => Math.trunc(value))
.filter((value) => value > 0)
const uniqueSizes = [...new Set(sizes)]
return uniqueSizes.length > 0 ? uniqueSizes : [10]
}
export function normalizePage(value: number, pageCount: number) {
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
if (page >= pageCount - 3) {
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
}
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
}
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
data: object[]
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
pageSizes?: number[]
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
})
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
))
const pageCount = computed(() => (
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
))
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
if (value === nextValue) return
pageSize.value = nextValue
page.value = 1
}, { immediate: true })
watch([page, pageCount], ([value, count]) => {
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
return attrs
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
function hasSlot(name?: string) {
return Boolean(name && slots[name])
}
function updatePageSize(value: number) {
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
if (nextValue === pageSize.value) return
pageSize.value = nextValue
page.value = 1
}
function setPage(value: number) {
page.value = normalizePage(value, pageCount.value)
}
</script>
<template>
<div class="data-table">
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
</template>
<template v-if="hasSlot(column.slotName)" #default="scope">
<slot :name="column.slotName" v-bind="scope" />
</template>
</el-table-column>
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
</el-table>
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
<div class="data-table__pagination-left">
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
<span v-else>共 0 条</span>
</slot>
</div>
<div class="data-table__pagination-controls">
<label class="data-table__page-size">
<span>每页</span>
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
</el-select>
<span>条</span>
</label>
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
<el-button
v-else
class="data-table__page-button"
:type="item === currentPage ? 'primary' : undefined"
:disabled="loading"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`第 ${item} 页`"
:title="`第 ${item} 页`"
@click="setPage(item)"
>{{ item }}</el-button>
</template>
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
</nav>
</div>
</footer>
</div>
</template>
<style scoped>
.data-table { min-width: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { CircleAlert } from '@lucide/vue'
withDefaults(defineProps<{
title: string
message?: string
loading?: boolean
retryText?: string
showRetry?: boolean
compact?: boolean
}>(), {
message: '',
loading: false,
retryText: '重新加载',
showRetry: true,
compact: false,
})
defineEmits<{ retry: [] }>()
</script>
<template>
<section class="load-error-state" :class="{ 'is-compact': compact }" role="alert">
<CircleAlert class="load-error-state__icon" :size="20" aria-hidden="true" />
<div class="load-error-state__content">
<strong>{{ title }}</strong>
<p v-if="message">{{ message }}</p>
<slot name="details" />
</div>
<div v-if="showRetry || $slots.actions" class="load-error-state__actions">
<slot name="actions">
<el-button type="primary" plain :loading="loading" @click="$emit('retry')">{{ retryText }}</el-button>
</slot>
</div>
</section>
</template>
<style scoped>
.load-error-state { display: flex; min-width: 0; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--el-color-danger-light-7); border-radius: 8px; background: var(--el-color-danger-light-9); color: var(--el-text-color-primary); }
.load-error-state.is-compact { padding: 12px; }
.load-error-state__icon { flex: 0 0 auto; margin-top: 1px; color: var(--el-color-danger); }
.load-error-state__content { min-width: 0; flex: 1; }
.load-error-state__content strong { display: block; font-size: 14px; line-height: 1.5; }
.load-error-state__content p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.load-error-state__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.load-error-state__actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .load-error-state { flex-wrap: wrap; } .load-error-state__content { flex-basis: calc(100% - 36px); } .load-error-state__actions { width: 100%; padding-left: 32px; } .load-error-state__actions :deep(.el-button) { min-height: 44px; } }
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
export type QueryModel = Record<string, QueryValue>
export interface QueryOption {
label: string
value: string | number | boolean
disabled?: boolean
}
export interface QueryField {
key: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
}
const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
searchText?: string
resetText?: string
searchOnReset?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
})
const model = defineModel<QueryModel>({ required: true })
const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
}
function defaultPlaceholder(field: QueryField) {
if (field.type === 'input') return '请输入关键词'
if (field.type === 'select') return '全部'
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
}
function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
function search() {
emit('search', model.value)
}
function reset() {
const next = { ...model.value }
for (const field of props.fields) next[field.key] = resetValue(field)
model.value = next
emit('reset', next)
if (props.searchOnReset) emit('search', next)
}
defineExpose({ search, reset })
</script>
<template>
<section
v-bind="$attrs"
class="query-toolbar"
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<template v-for="field in fields" :key="field.key">
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select
v-else-if="field.type === 'select'"
:model-value="model[field.key] as string | number | boolean | undefined"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
</el-select>
<el-date-picker
v-else
:model-value="model[field.key] as string | string[] | undefined"
:type="datePickerType(field)"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
/>
</template>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</div>
</section>
</template>
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>