新增: 全局对话框服务 AppDialog,Promise 风格 appAlert/appConfirm/appPrompt 替代原生弹窗

This commit is contained in:
lxy
2026-08-25 12:30:12 +08:00
parent 0907a104cf
commit 8eef4606c5
9 changed files with 253 additions and 25 deletions
+4 -3
View File
@@ -9,6 +9,7 @@ import { store } from '../../core/store'
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai' import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
import { elementTypes } from '../../core/sample' import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown' import { renderMd } from '../../core/markdown'
import { appPrompt, appConfirm } from '../../core/dialog'
import OutlinePanel from './OutlinePanel.vue' import OutlinePanel from './OutlinePanel.vue'
const emit = defineEmits<{ const emit = defineEmits<{
@@ -292,7 +293,7 @@ function onStop() {
/* ---------- 生成整套 ---------- */ /* ---------- 生成整套 ---------- */
async function onGenerate() { async function onGenerate() {
if (busy.value) return if (busy.value) return
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」') const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
if (!topic) return if (!topic) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return } if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = '' inputText.value = ''
@@ -345,9 +346,9 @@ async function onPolish() {
} }
/* ---------- 清空 ---------- */ /* ---------- 清空 ---------- */
function onClear() { async function onClear() {
if (!chatLog.value.length) { toast('对话已是空的'); return } if (!chatLog.value.length) { toast('对话已是空的'); return }
if (!confirm('清空当前 PPT 的对话记录')) return if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
chatLog.value = [] chatLog.value = []
store.clearChat(currentChatId.value) store.clearChat(currentChatId.value)
renderMsgs.value = [] renderMsgs.value = []
+66
View File
@@ -0,0 +1,66 @@
<!-- =====================================================================
AppDialog.vue 全局统一对话框宿主替代原生 alert/confirm/prompt
core/dialog.ts 的模块级状态驱动 App.vue 模板放置 <AppDialog /> 接线
===================================================================== -->
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { dialogState, resolveDialog } from '../../core/dialog'
const inputValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
watch(() => dialogState.open.value, async (v) => {
if (!v) return
inputValue.value = dialogState.options.value?.defaultValue || ''
await nextTick()
if (dialogState.options.value?.type === 'prompt') {
inputRef.value?.focus()
inputRef.value?.select()
}
})
function onMaskKeydown(e: KeyboardEvent) {
// confirm/alert 无输入框,键盘焦点在 mask 上时也支持 Enter/Esc
if (e.key === 'Enter') {
e.preventDefault(); e.stopPropagation()
resolveDialog({ ok: true, value: inputValue.value })
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation()
resolveDialog({ ok: false, value: '' })
}
}
</script>
<template>
<Teleport to="body">
<div
v-if="dialogState.open.value && dialogState.options.value"
class="modal-mask"
tabindex="-1"
@keydown="onMaskKeydown"
@click.self="resolveDialog({ ok: false, value: '' })"
>
<div class="modal app-dialog">
<h3>{{ dialogState.options.value.title }}</h3>
<p v-if="dialogState.options.value.message" class="modal-tip">{{ dialogState.options.value.message }}</p>
<div v-if="dialogState.options.value.type === 'prompt'" class="form-row">
<input
ref="inputRef"
v-model="inputValue"
:placeholder="dialogState.options.value.placeholder || ''"
@keydown.enter.prevent="resolveDialog({ ok: true, value: inputValue })"
@keydown.esc.prevent="resolveDialog({ ok: false, value: '' })"
/>
</div>
<div class="modal-actions">
<button v-if="dialogState.options.value.type !== 'alert'" class="btn" @click="resolveDialog({ ok: false, value: '' })">取消</button>
<button
class="btn"
:class="dialogState.options.value.danger ? 'danger' : 'primary'"
@click="resolveDialog({ ok: true, value: inputValue })"
>{{ dialogState.options.value.okText || '确定' }}</button>
</div>
</div>
</div>
</Teleport>
</template>
+5 -1
View File
@@ -4,6 +4,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { store, resolveBg } from '../../core/store' import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog'
import ElementView from './ElementView.vue' import ElementView from './ElementView.vue'
const slides = computed(() => store.slides.value) const slides = computed(() => store.slides.value)
@@ -12,10 +13,12 @@ const currentIndex = computed(() => store.currentIndex.value)
function onClickItem(i: number) { function onClickItem(i: number) {
store.setCurrentIndex(i) store.setCurrentIndex(i)
} }
function onClickDel(i: number, e: Event) { async function onClickDel(i: number, e: Event) {
e.stopPropagation() e.stopPropagation()
if (await appConfirm('删除这页幻灯片?', '第 ' + (i + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delSlide(i) store.delSlide(i)
} }
}
function onClickAdd() { function onClickAdd() {
store.addSlide(store.getCurrentIndex()) store.addSlide(store.getCurrentIndex())
} }
@@ -41,6 +44,7 @@ function onClickAdd() {
:key="el.id" :key="el.id"
:el="el" :el="el"
:bg="slide.background" :bg="slide.background"
thumb
/> />
</div> </div>
</div> </div>
+6 -3
View File
@@ -6,6 +6,7 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { store } from '../../core/store' import { store } from '../../core/store'
import { themes } from '../../core/sample' import { themes } from '../../core/sample'
import { appConfirm } from '../../core/dialog'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'present'): void (e: 'present'): void
@@ -29,15 +30,17 @@ function onThemeChange() {
store.setTheme(currentTheme.value) store.setTheme(currentTheme.value)
} }
function action(a: string) { async function action(a: string) {
switch (a) { switch (a) {
case 'add-slide': store.addSlide(store.getCurrentIndex()); break case 'add-slide': store.addSlide(store.getCurrentIndex()); break
case 'dup-slide': store.dupSlide(); break case 'dup-slide': store.dupSlide(); break
case 'del-slide': case 'del-slide':
if (store.delSlide()) { /* ok */ } if (await appConfirm('删除这页幻灯片?', '第 ' + (store.getCurrentIndex() + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delSlide()
}
break break
case 'reset': case 'reset':
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)')) { if (await appConfirm('重置为内置示例?', '当前编辑内容将丢失(可用 Ctrl+Z 撤销)', { danger: true })) {
store.reset() store.reset()
} }
break break
+5 -4
View File
@@ -5,6 +5,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { LibItem, Slide } from '../../core/types' import type { LibItem, Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store' import { store, resolveBg } from '../../core/store'
import { appPrompt, appConfirm } from '../../core/dialog'
import ElementView from '../editor/ElementView.vue' import ElementView from '../editor/ElementView.vue'
const props = defineProps<{ visible: boolean }>() const props = defineProps<{ visible: boolean }>()
@@ -91,9 +92,9 @@ function onOpen(id: string) {
} }
} }
function onRename(id: string) { async function onRename(id: string) {
const cur = store.getLibrary().find(x => x.id === id) const cur = store.getLibrary().find(x => x.id === id)
const name = prompt('重命名', cur ? cur.name : '') const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
if (name != null && name.trim()) { if (name != null && name.trim()) {
store.renameInLibrary(id, name.trim()) store.renameInLibrary(id, name.trim())
bump() bump()
@@ -106,8 +107,8 @@ function onDuplicate(id: string) {
bump() bump()
} }
function onDelete(id: string) { async function onDelete(id: string) {
if (confirm('删除这份演示?此操作不可撤销')) { if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
store.deleteFromLibrary(id) store.deleteFromLibrary(id)
toast('已删除') toast('已删除')
bump() bump()
+25 -13
View File
@@ -4,6 +4,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { store, resolveBg } from '../../core/store' import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog'
import type { PageTemplate } from '../../core/types' import type { PageTemplate } from '../../core/types'
import ElementView from '../editor/ElementView.vue' import ElementView from '../editor/ElementView.vue'
@@ -65,8 +66,8 @@ function onPick(tpl: PageTemplate) {
} }
/* ---------- 删除自存模板 ---------- */ /* ---------- 删除自存模板 ---------- */
function onDelete(tpl: PageTemplate) { async function onDelete(tpl: PageTemplate) {
if (confirm('删除模板「' + tpl.name + '」')) { if (await appConfirm('删除模板', '「' + tpl.name + '」删除后不可恢复', { danger: true, okText: '删除' })) {
store.deleteTemplate(tpl.id) store.deleteTemplate(tpl.id)
bump() bump()
emit('toast', '已删除') emit('toast', '已删除')
@@ -162,20 +163,24 @@ function onDelete(tpl: PageTemplate) {
.tpl-grid { .tpl-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1em; gap: 12px;
} }
/* 卡片宽 ≈ (720 - 48 padding - 2×12 gap) / 3 ≈ 212px → scale = 212/1280 ≈ 0.166 */
.tpl-card { .tpl-card {
--tpl-scale: 0.166;
cursor: pointer; cursor: pointer;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
border: 2px solid rgba(100, 116, 139, .2); border: 1px solid var(--ui-border, #e2e8f0);
transition: border-color .15s, transform .15s; box-shadow: 0 1px 3px rgba(15, 23, 42, .06);
transition: border-color .15s, transform .15s, box-shadow .15s;
} }
.tpl-card:hover { .tpl-card:hover {
border-color: var(--primary, #4f46e5); border-color: var(--ui-primary, #4f46e5);
box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -192,7 +197,7 @@ function onDelete(tpl: PageTemplate) {
left: 0; left: 0;
width: 1280px; width: 1280px;
height: 720px; height: 720px;
transform: scale(0.094); /* 约 120px 宽 */ transform: scale(var(--tpl-scale));
transform-origin: top left; transform-origin: top left;
pointer-events: none; pointer-events: none;
} }
@@ -201,25 +206,29 @@ function onDelete(tpl: PageTemplate) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: .4em; gap: .4em;
padding: .5em .6em; padding: .45em .6em;
font-size: 13px; font-size: 13px;
} }
.tpl-name { .tpl-name {
flex: 1; flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.tpl-badge { .tpl-badge {
font-size: 11px; font-size: 11px;
padding: 1px 6px; padding: 1px 6px;
border-radius: 3px; border-radius: 4px;
background: rgba(100, 116, 139, .15); background: rgba(100, 116, 139, .15);
color: var(--muted, #64748b); color: var(--ui-muted, #64748b);
flex: none;
} }
.tpl-badge.user { .tpl-badge.user {
background: rgba(79, 70, 229, .12); background: var(--ui-primary-soft, #eef2ff);
color: var(--primary, #4f46e5); color: var(--ui-primary, #4f46e5);
} }
.tpl-del { .tpl-del {
@@ -227,10 +236,13 @@ function onDelete(tpl: PageTemplate) {
opacity: .5; opacity: .5;
cursor: pointer; cursor: pointer;
padding: 2px 4px; padding: 2px 4px;
border: none;
background: transparent;
flex: none;
} }
.tpl-del:hover { .tpl-del:hover {
opacity: 1; opacity: 1;
color: #e11d48; color: var(--ui-danger, #e11d48);
} }
</style> </style>
+66
View File
@@ -0,0 +1,66 @@
/* =====================================================================
* dialog.ts — 全局对话框服务(替代原生 alert/confirm/prompt
*
* Promise 风格:await appConfirm('删除?') → boolean
* 渲染走 AppDialogHost(主 app 内 Teleport 到 body),
* App.vue 模板放置 <AppDialogHost /> 即接线,无需手动 mount
* ===================================================================== */
import { ref } from 'vue'
export interface DialogOptions {
type: 'alert' | 'confirm' | 'prompt'
title: string
message?: string
/** prompt 默认值 */
defaultValue?: string
/** input placeholder */
placeholder?: string
/** 确认按钮文案(默认「确定」) */
okText?: string
/** 危险操作样式(确认按钮红色) */
danger?: boolean
}
interface DialogResult { ok: boolean; value: string }
type Resolver = (r: DialogResult) => void
/* ---------- 模块级响应式状态(AppDialogHost 渲染消费) ---------- */
export const dialogState = {
open: ref(false),
options: ref<DialogOptions | null>(null)
}
let resolver: Resolver | null = null
/** 由 AppDialogHost 的 close 事件回调 */
export function resolveDialog(r: DialogResult) {
dialogState.open.value = false
dialogState.options.value = null
if (resolver) { resolver(r); resolver = null }
}
function show(opts: DialogOptions): Promise<DialogResult> {
return new Promise(resolve => {
resolver = resolve
dialogState.options.value = opts
dialogState.open.value = true
})
}
/* ---------- 对外 API ---------- */
/** 信息提示(替代 alert */
export function appAlert(title: string, message?: string) {
return show({ type: 'alert', title, message })
}
/** 确认(替代 confirm)→ Promise<boolean> */
export function appConfirm(title: string, message?: string, opts?: { okText?: string; danger?: boolean }) {
return show({ type: 'confirm', title, message, okText: opts?.okText, danger: opts?.danger }).then(r => r.ok)
}
/** 输入(替代 prompt)→ Promise<string | null>(取消为 null */
export function appPrompt(title: string, opts?: { message?: string; defaultValue?: string; placeholder?: string }) {
return show({ type: 'prompt', title, message: opts?.message, defaultValue: opts?.defaultValue, placeholder: opts?.placeholder })
.then(r => (r.ok ? r.value : null))
}
+66
View File
@@ -0,0 +1,66 @@
/* dialog.test.ts — 全局对话框服务与宿主组件闭环验证(Teleport 后从 document 查询) */
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import AppDialog from '../src/components/common/AppDialog.vue'
import { appAlert, appConfirm, appPrompt, dialogState, resolveDialog } from '../src/core/dialog'
/** 触发 body 上(Teleport)按钮点击 */
function clickBtn(idx: number) {
const btns = document.body.querySelectorAll('.modal-actions button')
;(btns[idx] as HTMLElement).click()
}
describe('dialog 服务 + AppDialog 宿主', () => {
it('appPrompt 打开对话框 → 输入值提交', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p = appPrompt('生成整套', { placeholder: '主题' })
await flushPromises()
expect(dialogState.open.value).toBe(true)
const input = document.body.querySelector('.modal-mask input') as HTMLInputElement
expect(input).not.toBeNull()
input.value = '远程办公'
input.dispatchEvent(new Event('input'))
await flushPromises()
clickBtn(1) // 确定
await expect(p).resolves.toBe('远程办公')
expect(dialogState.open.value).toBe(false)
wrapper.unmount()
})
it('appConfirm 取消 → false;确定 → true', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p1 = appConfirm('删除?', '不可恢复', { danger: true })
await flushPromises()
clickBtn(0) // 取消
await expect(p1).resolves.toBe(false)
const p2 = appConfirm('删除?')
await flushPromises()
clickBtn(1) // 确定
await expect(p2).resolves.toBe(true)
wrapper.unmount()
})
it('appAlert 无输入框,单按钮关闭', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p = appAlert('提示', '内容')
await flushPromises()
expect(document.body.querySelector('.modal-mask input')).toBeNull()
expect(document.body.querySelectorAll('.modal-actions button').length).toBe(1)
clickBtn(0)
await expect(p).resolves.toEqual({ ok: true, value: '' })
wrapper.unmount()
})
it('对话框 DOM 挂到 bodyTeleport)且带 modal-mask/modal 类', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
appAlert('标题')
await flushPromises()
const mask = document.body.querySelector('.modal-mask')
expect(mask).not.toBeNull()
expect(mask!.querySelector('.modal')).not.toBeNull()
resolveDialog({ ok: true, value: '' })
await flushPromises()
wrapper.unmount()
})
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
},
})