297 lines
8.2 KiB
Vue
297 lines
8.2 KiB
Vue
<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>
|