liuyun d88b807638 chore: import full Element Plus CSS, narrow action column, format
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:59:43 +08:00

297 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="ty-crud">
<el-card v-if="searchSchema" class="ty-crud__search" shadow="never">
<ty-search-form
:schema="searchSchema"
:model-value="query"
@search="handleSearch"
@reset="handleReset"
/>
</el-card>
<el-card class="ty-crud__table" shadow="never">
<div class="ty-crud__toolbar">
<div class="ty-crud__toolbar-left">
<el-button type="primary" @click="openCreate">新增{{ entityName }}</el-button>
<slot name="toolbar" />
</div>
<div class="ty-crud__toolbar-right">
<slot name="toolbar-right" :selected-rows="selectedRows" />
</div>
</div>
<div class="ty-crud__table-body">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:selection="selection"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
:actions-width="actionsWidth"
@selection-change="onSelectionChange"
>
<template #actions="{ row }">
<el-button
v-for="a in displayActions(row)"
:key="a.label"
link
:type="a.type"
@click="a.onClick(row)"
>
{{ a.label }}
</el-button>
<el-dropdown
v-if="overflowActions(row).length > 0"
trigger="click"
@command="handleCommand($event, row)"
>
<el-button link type="primary">
更多
<icon type="iconify" icon="mdi:chevron-down" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="a in overflowActions(row)" :key="a.label" :command="a">
{{ a.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</ty-table>
</div>
</el-card>
<ty-form-dialog
v-model:visible="dialogVisible"
:title="dialogMode === 'edit' ? `编辑${entityName}` : `新增${entityName}`"
:schema="formSchema"
:model-value="form"
:mode="dialogMode"
:readonly-keys="dialogMode === 'edit' ? editReadonlyKeys : []"
:submitting="submitting"
@submit="handleSubmit"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type {
CrudAction,
CrudActionItem,
JsonSchema,
PageQuery,
PageResult,
TableColumn,
} from '@/types/crud'
defineOptions({ name: 'TyCrud' })
const props = withDefaults(
defineProps<{
/** 实体名(用于标题与删除确认,如「用户」)。 */
entityName: string
/** 表格列配置。 */
columns: TableColumn[]
/** 表单 JSON Schema。 */
formSchema: JsonSchema
/** 搜索 JSON Schema(可选)。 */
searchSchema?: JsonSchema
/** 数据加载:分页查询。 */
list: (query: PageQuery) => Promise<PageResult<Record<string, any>>>
/** 新增。 */
create: (data: Record<string, any>) => Promise<unknown>
/** 更新。 */
update: (id: string, data: Record<string, any>) => Promise<unknown>
/** 删除。 */
remove: (id: string) => Promise<unknown>
/** 主键字段名。 */
idKey?: string
/** 删除确认展示的字段名。 */
nameKey?: string
/** 编辑模式下强制只读的字段。 */
editReadonlyKeys?: string[]
/** 操作列动作(内置 'edit'/'delete' 或自定义动作)。 */
actions?: CrudAction[]
/** 操作列按钮超过该数量时折叠为「更多」。 */
collapseCount?: number
/** 是否启用多选列(配合 #toolbar-right 做批量操作)。 */
selection?: boolean
actionsWidth?: number | string
}>(),
{
searchSchema: undefined,
idKey: 'id',
nameKey: 'name',
editReadonlyKeys: () => [],
actions: () => ['edit', 'delete'],
collapseCount: 2,
selection: false,
actionsWidth: 140,
},
)
const loading = ref(false)
const list = ref<Record<string, any>[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const selectedRows = ref<Record<string, any>[]>([])
const query = reactive<Record<string, any>>(buildModel(props.searchSchema))
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const editingId = ref<string | null>(null)
const submitting = ref(false)
const form = reactive<Record<string, any>>({})
function buildModel(schema?: JsonSchema): Record<string, any> {
const model: Record<string, any> = {}
if (!schema) return model
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) model[key] = prop.default
else if (prop.enum?.length) model[key] = prop.enum[0]
else if (prop.type === 'boolean') model[key] = false
else model[key] = ''
}
return model
}
async function fetchList(): Promise<void> {
loading.value = true
try {
const result = await props.list({ page: page.value, pageSize: pageSize.value, ...query })
list.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
function handleSearch(): void {
page.value = 1
fetchList()
}
function handleReset(): void {
page.value = 1
fetchList()
}
function openCreate(): void {
dialogMode.value = 'create'
editingId.value = null
Object.assign(form, buildModel(props.formSchema))
dialogVisible.value = true
}
function openEdit(row: Record<string, any>): void {
dialogMode.value = 'edit'
editingId.value = String(row[props.idKey])
const model = buildModel(props.formSchema)
for (const key of Object.keys(props.formSchema.properties)) {
model[key] = row[key] ?? model[key]
}
Object.assign(form, model)
dialogVisible.value = true
}
async function handleSubmit(): Promise<void> {
submitting.value = true
try {
if (dialogMode.value === 'edit') {
await props.update(editingId.value!, { ...form })
ElMessage.success('更新成功')
} else {
await props.create({ ...form })
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList()
} finally {
submitting.value = false
}
}
async function handleDelete(row: Record<string, any>): Promise<void> {
const name = row[props.nameKey] ?? ''
await ElMessageBox.confirm(`确认删除${props.entityName}「${name}」?`, '提示', {
type: 'warning',
})
await props.remove(String(row[props.idKey]))
ElMessage.success('删除成功')
fetchList()
}
const resolvedActions = computed<CrudActionItem[]>(() =>
props.actions.map((a): CrudActionItem => {
if (a === 'edit') return { label: '编辑', type: 'primary', onClick: (row) => openEdit(row) }
if (a === 'delete')
return { label: '删除', type: 'danger', onClick: (row) => handleDelete(row) }
return a
}),
)
function visibleActions(row: Record<string, any>): CrudActionItem[] {
return resolvedActions.value.filter((a) => !a.show || a.show(row))
}
function displayActions(row: Record<string, any>): CrudActionItem[] {
const visible = visibleActions(row)
return visible.length > props.collapseCount ? visible.slice(0, 1) : visible
}
function overflowActions(row: Record<string, any>): CrudActionItem[] {
const visible = visibleActions(row)
return visible.length > props.collapseCount ? visible.slice(1) : []
}
function handleCommand(action: CrudActionItem, row: Record<string, any>): void {
action.onClick(row)
}
function onSelectionChange(rows: Record<string, any>[]): void {
selectedRows.value = rows
}
defineExpose({ reload: fetchList })
onMounted(fetchList)
</script>
<style scoped lang="scss">
.ty-crud {
@apply flex h-full flex-col;
}
.ty-crud__search {
@apply mb-md flex-shrink-0;
}
.ty-crud__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.ty-crud__toolbar {
@apply mb-md flex flex-shrink-0 items-center justify-between;
}
.ty-crud__toolbar-left,
.ty-crud__toolbar-right {
@apply flex items-center gap-2;
}
.ty-crud__table-body {
@apply min-h-0 flex-1;
}
</style>