新增: 全局对话框服务 AppDialog,Promise 风格 appAlert/appConfirm/appPrompt 替代原生弹窗
This commit is contained in:
@@ -9,6 +9,7 @@ import { store } from '../../core/store'
|
||||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
import { renderMd } from '../../core/markdown'
|
||||
import { appPrompt, appConfirm } from '../../core/dialog'
|
||||
import OutlinePanel from './OutlinePanel.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -292,7 +293,7 @@ function onStop() {
|
||||
/* ---------- 生成整套 ---------- */
|
||||
async function onGenerate() {
|
||||
if (busy.value) return
|
||||
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」')
|
||||
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
|
||||
if (!topic) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
inputText.value = ''
|
||||
@@ -345,9 +346,9 @@ async function onPolish() {
|
||||
}
|
||||
|
||||
/* ---------- 清空 ---------- */
|
||||
function onClear() {
|
||||
async function onClear() {
|
||||
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
||||
if (!confirm('清空当前 PPT 的对话记录?')) return
|
||||
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
|
||||
chatLog.value = []
|
||||
store.clearChat(currentChatId.value)
|
||||
renderMsgs.value = []
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import { appConfirm } from '../../core/dialog'
|
||||
import ElementView from './ElementView.vue'
|
||||
|
||||
const slides = computed(() => store.slides.value)
|
||||
@@ -12,9 +13,11 @@ const currentIndex = computed(() => store.currentIndex.value)
|
||||
function onClickItem(i: number) {
|
||||
store.setCurrentIndex(i)
|
||||
}
|
||||
function onClickDel(i: number, e: Event) {
|
||||
async function onClickDel(i: number, e: Event) {
|
||||
e.stopPropagation()
|
||||
store.delSlide(i)
|
||||
if (await appConfirm('删除这页幻灯片?', '第 ' + (i + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||
store.delSlide(i)
|
||||
}
|
||||
}
|
||||
function onClickAdd() {
|
||||
store.addSlide(store.getCurrentIndex())
|
||||
@@ -41,6 +44,7 @@ function onClickAdd() {
|
||||
:key="el.id"
|
||||
:el="el"
|
||||
:bg="slide.background"
|
||||
thumb
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { store } from '../../core/store'
|
||||
import { themes } from '../../core/sample'
|
||||
import { appConfirm } from '../../core/dialog'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'present'): void
|
||||
@@ -29,15 +30,17 @@ function onThemeChange() {
|
||||
store.setTheme(currentTheme.value)
|
||||
}
|
||||
|
||||
function action(a: string) {
|
||||
async function action(a: string) {
|
||||
switch (a) {
|
||||
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
|
||||
case 'dup-slide': store.dupSlide(); break
|
||||
case 'del-slide':
|
||||
if (store.delSlide()) { /* ok */ }
|
||||
if (await appConfirm('删除这页幻灯片?', '第 ' + (store.getCurrentIndex() + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||
store.delSlide()
|
||||
}
|
||||
break
|
||||
case 'reset':
|
||||
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)。')) {
|
||||
if (await appConfirm('重置为内置示例?', '当前编辑内容将丢失(可用 Ctrl+Z 撤销)', { danger: true })) {
|
||||
store.reset()
|
||||
}
|
||||
break
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { LibItem, Slide } from '../../core/types'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import { appPrompt, appConfirm } from '../../core/dialog'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
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 name = prompt('重命名为', cur ? cur.name : '')
|
||||
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
|
||||
if (name != null && name.trim()) {
|
||||
store.renameInLibrary(id, name.trim())
|
||||
bump()
|
||||
@@ -106,8 +107,8 @@ function onDuplicate(id: string) {
|
||||
bump()
|
||||
}
|
||||
|
||||
function onDelete(id: string) {
|
||||
if (confirm('删除这份演示?此操作不可撤销。')) {
|
||||
async function onDelete(id: string) {
|
||||
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
|
||||
store.deleteFromLibrary(id)
|
||||
toast('已删除')
|
||||
bump()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import { appConfirm } from '../../core/dialog'
|
||||
import type { PageTemplate } from '../../core/types'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
@@ -65,8 +66,8 @@ function onPick(tpl: PageTemplate) {
|
||||
}
|
||||
|
||||
/* ---------- 删除自存模板 ---------- */
|
||||
function onDelete(tpl: PageTemplate) {
|
||||
if (confirm('删除模板「' + tpl.name + '」?')) {
|
||||
async function onDelete(tpl: PageTemplate) {
|
||||
if (await appConfirm('删除模板', '「' + tpl.name + '」删除后不可恢复', { danger: true, okText: '删除' })) {
|
||||
store.deleteTemplate(tpl.id)
|
||||
bump()
|
||||
emit('toast', '已删除')
|
||||
@@ -162,20 +163,24 @@ function onDelete(tpl: PageTemplate) {
|
||||
.tpl-grid {
|
||||
display: grid;
|
||||
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-scale: 0.166;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 2px solid rgba(100, 116, 139, .2);
|
||||
transition: border-color .15s, transform .15s;
|
||||
border: 1px solid var(--ui-border, #e2e8f0);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .06);
|
||||
transition: border-color .15s, transform .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -192,7 +197,7 @@ function onDelete(tpl: PageTemplate) {
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
transform: scale(0.094); /* 约 120px 宽 */
|
||||
transform: scale(var(--tpl-scale));
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -201,25 +206,29 @@ function onDelete(tpl: PageTemplate) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4em;
|
||||
padding: .5em .6em;
|
||||
padding: .45em .6em;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tpl-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
background: rgba(100, 116, 139, .15);
|
||||
color: var(--muted, #64748b);
|
||||
color: var(--ui-muted, #64748b);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tpl-badge.user {
|
||||
background: rgba(79, 70, 229, .12);
|
||||
color: var(--primary, #4f46e5);
|
||||
background: var(--ui-primary-soft, #eef2ff);
|
||||
color: var(--ui-primary, #4f46e5);
|
||||
}
|
||||
|
||||
.tpl-del {
|
||||
@@ -227,10 +236,13 @@ function onDelete(tpl: PageTemplate) {
|
||||
opacity: .5;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tpl-del:hover {
|
||||
opacity: 1;
|
||||
color: #e11d48;
|
||||
color: var(--ui-danger, #e11d48);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 挂到 body(Teleport)且带 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user