feat: unified dialog manager (showDialog service + ty-dialog host)

- 新增 showDialog({component, props, title,...}) 服务,返回 Promise
- 内容组件 defineExpose({submit}) 校验并返回数据,校验失败对话框不关闭
- <ty-dialog /> 宿主挂 App.vue,统一渲染对话框栈(支持嵌套)
- ty-crud 改用 showDialog 打开表单(移除 ty-form-dialog)
- ty-form 增加 submit();用户管理页重置密码抽成独立组件 + showDialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 10:17:09 +08:00
parent 16b512a3f1
commit 35892486e8
11 changed files with 201 additions and 146 deletions

4
components.d.ts vendored
View File

@ -46,9 +46,9 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default'] TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
TyCrud: typeof import('./src/components/ty-crud.vue')['default'] TyCrud: typeof import('./src/components/ty-crud.vue')['default']
TyDialog: typeof import('./src/components/ty-dialog.vue')['default']
TyFooter: typeof import('./src/components/ty-footer.vue')['default'] TyFooter: typeof import('./src/components/ty-footer.vue')['default']
TyForm: typeof import('./src/components/ty-form.vue')['default'] TyForm: typeof import('./src/components/ty-form.vue')['default']
TyFormDialog: typeof import('./src/components/ty-form-dialog.vue')['default']
TyHeader: typeof import('./src/components/ty-header.vue')['default'] TyHeader: typeof import('./src/components/ty-header.vue')['default']
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default'] TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
TySearchForm: typeof import('./src/components/ty-search-form.vue')['default'] TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
@ -99,9 +99,9 @@ declare global {
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default'] const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
const TyCrud: typeof import('./src/components/ty-crud.vue')['default'] const TyCrud: typeof import('./src/components/ty-crud.vue')['default']
const TyDialog: typeof import('./src/components/ty-dialog.vue')['default']
const TyFooter: typeof import('./src/components/ty-footer.vue')['default'] const TyFooter: typeof import('./src/components/ty-footer.vue')['default']
const TyForm: typeof import('./src/components/ty-form.vue')['default'] const TyForm: typeof import('./src/components/ty-form.vue')['default']
const TyFormDialog: typeof import('./src/components/ty-form-dialog.vue')['default']
const TyHeader: typeof import('./src/components/ty-header.vue')['default'] const TyHeader: typeof import('./src/components/ty-header.vue')['default']
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default'] const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default'] const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']

View File

@ -1,6 +1,7 @@
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
<router-view /> <router-view />
<ty-dialog />
</el-config-provider> </el-config-provider>
</template> </template>

View File

@ -63,17 +63,6 @@
</ty-table> </ty-table>
</div> </div>
</el-card> </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> </div>
</template> </template>
@ -88,6 +77,8 @@ import type {
PageResult, PageResult,
TableColumn, TableColumn,
} from '@/types/crud' } from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
defineOptions({ name: 'TyCrud' }) defineOptions({ name: 'TyCrud' })
@ -144,12 +135,6 @@ const selectedRows = ref<Record<string, any>[]>([])
const query = reactive<Record<string, any>>(buildModel(props.searchSchema)) 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> { function buildModel(schema?: JsonSchema): Record<string, any> {
const model: Record<string, any> = {} const model: Record<string, any> = {}
if (!schema) return model if (!schema) return model
@ -183,39 +168,38 @@ function handleReset(): void {
fetchList() fetchList()
} }
function openCreate(): void { async function openCreate(): Promise<void> {
dialogMode.value = 'create' const model = buildModel(props.formSchema)
editingId.value = null const data = await showDialog({
Object.assign(form, buildModel(props.formSchema)) component: TyForm,
dialogVisible.value = true props: { schema: props.formSchema, modelValue: model, mode: 'create' },
title: `新增${props.entityName}`,
})
if (data == null) return
await props.create(data)
ElMessage.success('新增成功')
fetchList()
} }
function openEdit(row: Record<string, any>): void { async function openEdit(row: Record<string, any>): Promise<void> {
dialogMode.value = 'edit'
editingId.value = String(row[props.idKey])
const model = buildModel(props.formSchema) const model = buildModel(props.formSchema)
for (const key of Object.keys(props.formSchema.properties)) { for (const key of Object.keys(props.formSchema.properties)) {
model[key] = row[key] ?? model[key] model[key] = row[key] ?? model[key]
} }
Object.assign(form, model) const data = await showDialog({
dialogVisible.value = true component: TyForm,
} props: {
schema: props.formSchema,
async function handleSubmit(): Promise<void> { modelValue: model,
submitting.value = true mode: 'edit',
try { readonlyKeys: props.editReadonlyKeys,
if (dialogMode.value === 'edit') { },
await props.update(editingId.value!, { ...form }) title: `编辑${props.entityName}`,
})
if (data == null) return
await props.update(String(row[props.idKey]), data)
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else {
await props.create({ ...form })
ElMessage.success('新增成功')
}
dialogVisible.value = false
fetchList() fetchList()
} finally {
submitting.value = false
}
} }
async function handleDelete(row: Record<string, any>): Promise<void> { async function handleDelete(row: Record<string, any>): Promise<void> {

View File

@ -0,0 +1,61 @@
<template>
<el-dialog
v-for="dialog in dialogs"
:key="dialog.id"
:model-value="true"
:title="dialog.options.title"
:width="dialog.options.width ?? '480px'"
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="handleClose(dialog)"
>
<component
:is="dialog.options.component"
v-bind="dialog.options.props ?? {}"
:ref="setRef(dialog.id)"
/>
<template v-if="dialog.options.showFooter !== false" #footer>
<el-button @click="handleCancel(dialog)">{{ dialog.options.cancelText ?? '取消' }}</el-button>
<el-button type="primary" @click="handleConfirm(dialog)">{{ dialog.options.confirmText ?? '确定' }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { closeDialog, dialogs, type DialogInstance } from '@/utils/dialog'
defineOptions({ name: 'TyDialog' })
const componentRefs = reactive<Record<number, any>>({})
function setRef(id: number): (el: unknown) => void {
return (el) => {
if (el) componentRefs[id] = el
else delete componentRefs[id]
}
}
async function handleConfirm(dialog: DialogInstance): Promise<void> {
const ref = componentRefs[dialog.id]
let data: any
if (ref?.submit) {
try {
data = await ref.submit()
} catch {
// 校验失败,保持对话框打开
return
}
}
if (dialog.options.onConfirm) await dialog.options.onConfirm(data)
closeDialog(dialog.id, data)
}
function handleCancel(dialog: DialogInstance): void {
closeDialog(dialog.id, null)
}
function handleClose(dialog: DialogInstance): void {
closeDialog(dialog.id, null)
}
</script>

View File

@ -1,65 +0,0 @@
<template>
<el-dialog v-model="visible" :title="title" :width="width" destroy-on-close @closed="onClosed">
<ty-form
ref="formRef"
:schema="schema"
:model-value="modelValue"
:readonly-keys="readonlyKeys"
:mode="mode"
/>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { JsonSchema } from '@/types/crud'
defineOptions({ name: 'TyFormDialog' })
const visible = defineModel<boolean>('visible', { default: false })
withDefaults(
defineProps<{
title?: string
/** 表单 JSON Schema。 */
schema: JsonSchema
/** 表单数据(就地绑定)。 */
modelValue: Record<string, any>
readonlyKeys?: string[]
/** 表单模式(create/edit),透传给 schema form 用于 showOn。 */
mode?: 'create' | 'edit'
submitting?: boolean
width?: string
}>(),
{
title: '',
readonlyKeys: () => [],
mode: undefined,
submitting: false,
width: '480px',
},
)
const emit = defineEmits<{
(e: 'submit'): void
}>()
const formRef = ref<{ validate: () => Promise<unknown>; resetFields?: () => void }>()
async function handleSubmit(): Promise<void> {
try {
await formRef.value?.validate()
} catch {
return
}
emit('submit')
}
function onClosed(): void {
formRef.value?.resetFields?.()
}
</script>

View File

@ -147,7 +147,13 @@ function widgetStyle(prop: JsonSchemaProperty): Record<string, string> {
return { width: prop.width ?? (props.inline ? '200px' : '100%') } return { width: prop.width ?? (props.inline ? '200px' : '100%') }
} }
async function submit(): Promise<Record<string, any>> {
await formRef.value?.validate()
return { ...props.modelValue }
}
defineExpose({ defineExpose({
submit,
validate: () => formRef.value?.validate(), validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields(), resetFields: () => formRef.value?.resetFields(),
clearValidate: () => formRef.value?.clearValidate(), clearValidate: () => formRef.value?.clearValidate(),

View File

@ -25,32 +25,14 @@
</el-button> </el-button>
</template> </template>
</ty-crud> </ty-crud>
<el-dialog v-model="resetPwd.visible" title="重置密码" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="用户名"
><span>{{ resetPwd.username }}</span></el-form-item
>
<el-form-item label="新密码">
<el-input
v-model="resetPwd.password"
type="password"
show-password
placeholder="请输入新密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwd.visible = false">取消</el-button>
<el-button type="primary" @click="submitResetPwd">确定</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud' import type { CrudAction, JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud'
import { showDialog } from '@/utils/dialog'
import ResetPasswordForm from './reset-password-form.vue'
const STATUS_MAP = { const STATUS_MAP = {
enabled: { label: '启用', type: 'success' }, enabled: { label: '启用', type: 'success' },
@ -130,7 +112,7 @@ const formSchema: JsonSchema = {
const actions: CrudAction[] = [ const actions: CrudAction[] = [
'edit', 'edit',
'delete', 'delete',
{ label: '重置密码', type: 'warning', onClick: (row) => openResetPwd(row) }, { label: '重置密码', type: 'warning', onClick: (row) => resetPwd(row) },
] ]
// ---- 占位数据(后续对接后端时替换为 http 调用)---- // ---- 占位数据(后续对接后端时替换为 http 调用)----
@ -305,20 +287,16 @@ const remove = (id: string): Promise<unknown> =>
return null return null
}) })
const resetPwd = reactive({ visible: false, id: '', username: '', password: '' }) async function resetPwd(row: Record<string, any>): Promise<void> {
const result = await showDialog({
function openResetPwd(row: Record<string, any>): void { component: ResetPasswordForm,
resetPwd.visible = true props: { username: String(row.username) },
resetPwd.id = String(row.id) title: '重置密码',
resetPwd.username = String(row.username) width: '420px',
resetPwd.password = '' })
} if (result == null) return
async function submitResetPwd(): Promise<void> {
// mock:无真实密码存储,仅模拟 // mock:无真实密码存储,仅模拟
await delay(() => null)
ElMessage.success('密码已重置') ElMessage.success('密码已重置')
resetPwd.visible = false
} }
const crudRef = ref() const crudRef = ref()

View File

@ -0,0 +1,27 @@
<template>
<div>
<div class="mb-md text-sm text-text-regular">用户:{{ username }}</div>
<el-input v-model="password" type="password" show-password placeholder="请输入新密码" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
defineOptions({ name: 'ResetPasswordForm' })
defineProps<{ username: string }>()
const password = ref('')
function submit(): { password: string } {
if (!password.value) {
ElMessage.warning('请输入新密码')
throw new Error('请输入新密码')
}
return { password: password.value }
}
defineExpose({ submit })
</script>

50
src/utils/dialog.ts Normal file
View File

@ -0,0 +1,50 @@
import { markRaw, reactive, type Component } from 'vue'
export interface DialogOptions {
/** 对话框内容组件。 */
component: Component
title?: string
width?: string
/** 传给内容组件的 props。 */
props?: Record<string, any>
confirmText?: string
cancelText?: string
/** 是否显示默认 取消/确定 底部(false 时内容组件自带底部)。 */
showFooter?: boolean
/** 确认回调,参数为内容组件 submit() 返回的数据。 */
onConfirm?: (data: any) => void | Promise<void>
}
export interface DialogInstance {
id: number
options: DialogOptions
resolve: (value: any) => void
}
/** 当前打开的对话框栈(由 <ty-dialog /> 宿主渲染)。 */
export const dialogs = reactive<DialogInstance[]>([])
let seq = 0
/**
* 打开一个对话框。返回 Promise:确认时 resolve 内容组件 submit() 返回的数据,
* 取消/关闭时 resolve null。
*/
export function showDialog(options: DialogOptions): Promise<any> {
return new Promise((resolve) => {
dialogs.push({
id: ++seq,
options: { ...options, component: markRaw(options.component) },
resolve,
})
})
}
/** 关闭指定对话框并 resolve。 */
export function closeDialog(id: number, result?: any): void {
const idx = dialogs.findIndex((d) => d.id === id)
if (idx >= 0) {
const [dialog] = dialogs.splice(idx, 1)
dialog.resolve(result)
}
}

File diff suppressed because one or more lines are too long

13
typed-router.d.ts vendored
View File

@ -58,6 +58,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/system/user/reset-password-form': RouteRecordInfo<
'/system/user/reset-password-form',
'/system/user/reset-password-form',
Record<never, never>,
Record<never, never>,
| never
>,
} }
/** /**
@ -101,6 +108,12 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/system/user/reset-password-form.vue': {
routes:
| '/system/user/reset-password-form'
views:
| never
}
} }
/** /**