liuyun 23e228783c feat: dict-driven rendering — useDict + component dictCode
- 新增 dict store 与 useDict Hook(登录后缓存、登出清空)
- ty-form/ty-table 支持 dictCode 自动解析下拉与标签
- 用户/角色/资源/组织/租户/审计/登录日志页去硬编码映射
- 字典管理页内置标记、value 保护、标签色编辑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 14:29:28 +08:00

288 lines
8.1 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>
</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>