- 新增 dict store 与 useDict Hook(登录后缓存、登出清空) - ty-form/ty-table 支持 dictCode 自动解析下拉与标签 - 用户/角色/资源/组织/租户/审计/登录日志页去硬编码映射 - 字典管理页内置标记、value 保护、标签色编辑 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
8.1 KiB
Vue
288 lines
8.1 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>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type {
|
||
CrudAction,
|
||
CrudActionItem,
|
||
JsonSchema,
|
||
PageQuery,
|
||
PageResult,
|
||
TableColumn,
|
||
} from '@/types/crud'
|
||
import TyForm from '@/components/ty-form.vue'
|
||
import { showDialog } from '@/utils/dialog'
|
||
import { useRequest } from '@/composables/useRequest'
|
||
|
||
defineOptions({ name: 'TyCrud' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 实体名(用于标题与删除确认,如「用户」)。 */
|
||
entityName: string
|
||
/** 表格列配置。 */
|
||
columns: TableColumn[]
|
||
/** 表单 JSON Schema。 */
|
||
formSchema: JsonSchema
|
||
/** 搜索 JSON Schema(可选)。 */
|
||
searchSchema?: JsonSchema
|
||
/** 数据加载:分页查询(signal 用于取消请求)。 */
|
||
list: (query: PageQuery, signal?: AbortSignal) => 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[]
|
||
/** 编辑模式下按行计算只读字段(优先于 editReadonlyKeys)。 */
|
||
getEditReadonlyKeys?: (row: Record<string, any>) => 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 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))
|
||
|
||
function buildModel(schema?: JsonSchema): Record<string, any> {
|
||
const model: Record<string, any> = reactive({})
|
||
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
|
||
}
|
||
|
||
const { loading, run: fetchList } = useRequest(
|
||
(signal: AbortSignal) => props.list({ page: page.value, pageSize: pageSize.value, ...query }, signal),
|
||
{
|
||
manual: true,
|
||
onSuccess: (result) => {
|
||
list.value = result.list
|
||
total.value = result.total
|
||
},
|
||
},
|
||
)
|
||
|
||
// 分页变化时重新请求
|
||
watch([page, pageSize], () => {
|
||
fetchList()
|
||
})
|
||
|
||
function handleSearch(): void {
|
||
page.value = 1
|
||
fetchList()
|
||
}
|
||
|
||
function handleReset(): void {
|
||
page.value = 1
|
||
fetchList()
|
||
}
|
||
|
||
async function openCreate(): Promise<void> {
|
||
const model = buildModel(props.formSchema)
|
||
const data = await showDialog({
|
||
component: TyForm,
|
||
props: { schema: props.formSchema, modelValue: model, mode: 'create' },
|
||
title: `新增${props.entityName}`,
|
||
})
|
||
if (data == null) return
|
||
await props.create(data)
|
||
ElMessage.success('新增成功')
|
||
fetchList()
|
||
}
|
||
|
||
async function openEdit(row: Record<string, any>): Promise<void> {
|
||
const model = buildModel(props.formSchema)
|
||
for (const key of Object.keys(props.formSchema.properties)) {
|
||
model[key] = row[key] ?? model[key]
|
||
}
|
||
const data = await showDialog({
|
||
component: TyForm,
|
||
props: {
|
||
schema: props.formSchema,
|
||
modelValue: model,
|
||
mode: 'edit',
|
||
readonlyKeys: props.getEditReadonlyKeys ? props.getEditReadonlyKeys(row) : props.editReadonlyKeys,
|
||
},
|
||
title: `编辑${props.entityName}`,
|
||
})
|
||
if (data == null) return
|
||
await props.update(String(row[props.idKey]), data)
|
||
ElMessage.success('更新成功')
|
||
fetchList()
|
||
}
|
||
|
||
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>
|