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:
parent
16b512a3f1
commit
35892486e8
4
components.d.ts
vendored
4
components.d.ts
vendored
@ -46,9 +46,9 @@ declare module 'vue' {
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.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']
|
||||
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']
|
||||
TyMixbar: typeof import('./src/components/ty-mixbar.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 TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.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 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 TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
<ty-dialog />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
|
||||
@ -63,17 +63,6 @@
|
||||
</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>
|
||||
|
||||
@ -88,6 +77,8 @@ import type {
|
||||
PageResult,
|
||||
TableColumn,
|
||||
} from '@/types/crud'
|
||||
import TyForm from '@/components/ty-form.vue'
|
||||
import { showDialog } from '@/utils/dialog'
|
||||
|
||||
defineOptions({ name: 'TyCrud' })
|
||||
|
||||
@ -144,12 +135,6 @@ 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
|
||||
@ -183,39 +168,38 @@ function handleReset(): void {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
Object.assign(form, buildModel(props.formSchema))
|
||||
dialogVisible.value = true
|
||||
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()
|
||||
}
|
||||
|
||||
function openEdit(row: Record<string, any>): void {
|
||||
dialogMode.value = 'edit'
|
||||
editingId.value = String(row[props.idKey])
|
||||
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]
|
||||
}
|
||||
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
|
||||
}
|
||||
const data = await showDialog({
|
||||
component: TyForm,
|
||||
props: {
|
||||
schema: props.formSchema,
|
||||
modelValue: model,
|
||||
mode: 'edit',
|
||||
readonlyKeys: 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> {
|
||||
|
||||
61
src/components/ty-dialog.vue
Normal file
61
src/components/ty-dialog.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -147,7 +147,13 @@ function widgetStyle(prop: JsonSchemaProperty): Record<string, string> {
|
||||
return { width: prop.width ?? (props.inline ? '200px' : '100%') }
|
||||
}
|
||||
|
||||
async function submit(): Promise<Record<string, any>> {
|
||||
await formRef.value?.validate()
|
||||
return { ...props.modelValue }
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit,
|
||||
validate: () => formRef.value?.validate(),
|
||||
resetFields: () => formRef.value?.resetFields(),
|
||||
clearValidate: () => formRef.value?.clearValidate(),
|
||||
|
||||
@ -25,32 +25,14 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 = {
|
||||
enabled: { label: '启用', type: 'success' },
|
||||
@ -130,7 +112,7 @@ const formSchema: JsonSchema = {
|
||||
const actions: CrudAction[] = [
|
||||
'edit',
|
||||
'delete',
|
||||
{ label: '重置密码', type: 'warning', onClick: (row) => openResetPwd(row) },
|
||||
{ label: '重置密码', type: 'warning', onClick: (row) => resetPwd(row) },
|
||||
]
|
||||
|
||||
// ---- 占位数据(后续对接后端时替换为 http 调用)----
|
||||
@ -305,20 +287,16 @@ const remove = (id: string): Promise<unknown> =>
|
||||
return null
|
||||
})
|
||||
|
||||
const resetPwd = reactive({ visible: false, id: '', username: '', password: '' })
|
||||
|
||||
function openResetPwd(row: Record<string, any>): void {
|
||||
resetPwd.visible = true
|
||||
resetPwd.id = String(row.id)
|
||||
resetPwd.username = String(row.username)
|
||||
resetPwd.password = ''
|
||||
}
|
||||
|
||||
async function submitResetPwd(): Promise<void> {
|
||||
async function resetPwd(row: Record<string, any>): Promise<void> {
|
||||
const result = await showDialog({
|
||||
component: ResetPasswordForm,
|
||||
props: { username: String(row.username) },
|
||||
title: '重置密码',
|
||||
width: '420px',
|
||||
})
|
||||
if (result == null) return
|
||||
// mock:无真实密码存储,仅模拟
|
||||
await delay(() => null)
|
||||
ElMessage.success('密码已重置')
|
||||
resetPwd.visible = false
|
||||
}
|
||||
|
||||
const crudRef = ref()
|
||||
|
||||
27
src/pages/system/user/reset-password-form.vue
Normal file
27
src/pages/system/user/reset-password-form.vue
Normal 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
50
src/utils/dialog.ts
Normal 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
13
typed-router.d.ts
vendored
@ -58,6 +58,13 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<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:
|
||||
| never
|
||||
}
|
||||
'src/pages/system/user/reset-password-form.vue': {
|
||||
routes:
|
||||
| '/system/user/reset-password-form'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user