v1.2.64:删除等操作的原生 confirm/alert 统一改为项目 AppDialog
ci/woodpecker/tag/build Pipeline was successful

① 问题:删除任务的确认框是浏览器自带弹窗,与全站统一样式不一致。
   排查确认这是 v1.2.55「弹窗统一化」的遗留缺口——那次只扫描并迁移了手写
   role="dialog" 元素(提交信息称「全项目手写 role=dialog 残留归零」),
   而 confirm()/alert() 是浏览器原生调用、不产生 role="dialog" 元素,
   故从未被纳入扫描范围。「归零」对 role="dialog" 属实,但原生弹窗不在其内。

② 全项目原生弹窗共 9 处 / 4 文件:
   HomeView:删日志、删任务(confirm);保存失败 ×2、获取表单失败(alert);
   TicketList:关闭工单、删除工单(confirm);
   LeaveView:删日志(confirm);AdminAnnouncements:删公告(confirm)。

③ 修复:
   - 新增 composables/useConfirm.ts:模块级单例服务(对齐既有 useMessage 风格),
     confirmDialog()/alertDialog() 返回 Promise;重复调用时旧的以 false 结清,
     避免 Promise 悬挂;respond 延迟 200ms 清空 state 以配合离场动画。
   - 新增 components/ConfirmHost.vue:全站唯一弹窗宿主,挂到 App.vue,
     z-index 取 z-[300](高于版本更新弹窗的 z-[200])。
     视觉对齐现有居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮);
     danger=true 时红色图标底 + destructive 确认按钮(--color-destructive #ef4444)。
   - 9 处调用点改为 await confirmDialog/alertDialog;
     删除类置 danger:true,关闭工单等非破坏性操作不置。

④ 验证(CDP 驱动真实 Chrome 实测,非仅静态检查):
   - 弹窗为 role="dialog" 的项目组件,原生 confirm/alert 调用次数均为 0;
   - 取消返回 false、确定返回 true、Esc 视为取消(false),按钮文案/颜色符合预期;
   - 截图确认三种形态(危险确认/普通确认/单按钮提示)视觉与现有弹窗一致;
   - 源码与构建产物双重扫描:代码级原生弹窗残留 0 处;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
This commit is contained in:
becomer
2026-09-18 17:20:20 +08:00
parent e979e3cc9f
commit 2effdbaaf9
7 changed files with 199 additions and 10 deletions
+4
View File
@@ -3,6 +3,7 @@ import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -30,6 +31,9 @@ watch(
<!-- 全局新版本检测(无 UI,发现更新时弹窗提示刷新) -->
<UpdateChecker />
<!-- 全局确认/提示弹窗宿主(confirmDialog / alertDialog) -->
<ConfirmHost />
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
// ConfirmHost —— 全局确认/提示弹窗宿主,挂在 App.vue,全站仅此一个实例。
//
// 与 useConfirm 的单例状态配合:任何组件 await confirmDialog()/alertDialog() 都会
// 反映到这里渲染的 AppDialog 上。视觉对齐 HomeView「确认再次打卡 / 签到次数不足」那类
// 居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮)。
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { Button } from '@/components/ui/button'
import { AlertTriangle, CircleAlert } from 'lucide-vue-next'
import { useConfirm } from '@/composables/useConfirm'
const { state, respond } = useConfirm()
</script>
<template>
<AppDialog
:open="state?.open || false"
variant="center"
z-class="z-[300]"
panel-class="w-[85%] sm:max-w-sm"
:fade="true"
:title="state?.title || '提示'"
@update:open="(v: boolean) => { if (!v) respond(false) }"
>
<div v-if="state" class="p-6 text-center">
<div
:class="[
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
state.danger ? 'bg-red-100' : 'bg-blue-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
<CircleAlert v-else class="w-7 h-7 text-blue-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ state.title }}</h3>
<p
v-if="state.content"
class="text-sm text-gray-500 leading-relaxed whitespace-pre-wrap"
>
{{ state.content }}
</p>
<div class="mt-5 flex gap-2">
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-10 text-sm font-medium"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-10 text-sm font-medium"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
</Button>
</div>
</div>
</AppDialog>
</template>
+76
View File
@@ -0,0 +1,76 @@
import { ref } from 'vue'
// 全局确认/提示弹窗服务。
//
// 背景:项目此前用浏览器原生 confirm()/alert()(v1.2.55「弹窗统一化」只迁移了手写
// role="dialog" 元素,原生调用不在其扫描范围内,故遗留至今)。这里提供与 useMessage
// 同风格的模块级单例服务:任意组件调用 confirmDialog/alertDialog 即弹出统一的 AppDialog。
//
// 用法:
// const ok = await confirmDialog({ title: '删除任务', content: '确定要删除此任务吗?', danger: true })
// if (!ok) return
// await alertDialog({ title: '保存失败', content: msg })
export interface ConfirmOptions {
/** 标题(同时作为读屏标题) */
title: string
/** 说明文字(可选,支持换行) */
content?: string
/** 确认按钮文案,默认「确定」 */
confirmText?: string
/** 取消按钮文案,默认「取消」 */
cancelText?: string
/** true 时用警示样式(红色图标 + destructive 确认按钮),用于删除等破坏性操作 */
danger?: boolean
/** true 时只显示确认按钮(提示框形态,对应原 alert) */
alertOnly?: boolean
}
interface ConfirmState extends ConfirmOptions {
open: boolean
}
const state = ref<ConfirmState | null>(null)
let resolver: ((ok: boolean) => void) | null = null
/**
* 弹出确认框,返回用户是否确认(取消/遮罩/Esc 均为 false)。
* 同一时刻只保留一个确认框:若上一个尚未结清,先以 false 结清,避免 Promise 悬挂。
*/
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
if (resolver) {
const prev = resolver
resolver = null
prev(false)
}
state.value = { ...options, open: true }
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
/** 弹出纯提示框(单按钮,对应原 alert),用户关闭后 resolve(true) */
export function alertDialog(
options: Omit<ConfirmOptions, 'cancelText' | 'alertOnly'>,
): Promise<boolean> {
return confirmDialog({
...options,
alertOnly: true,
confirmText: options.confirmText || '我知道了',
})
}
export function useConfirm() {
function respond(ok: boolean) {
state.value = state.value ? { ...state.value, open: false } : null
const r = resolver
resolver = null
r?.(ok)
// 等离场动画结束再清空内容,避免关闭过程中文字/图标突然消失
setTimeout(() => {
if (state.value && !state.value.open) state.value = null
}, 200)
}
return { state, respond }
}
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -134,7 +135,13 @@ async function handleDelete(id: number) {
message.info('系统自动生成的公告不能删除')
return
}
if (!confirm('确定要删除此公告吗?')) return
const ok = await confirmDialog({
title: '删除公告',
content: '确定要删除此公告吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await adminApi.deleteAnnouncement(id)
message.success('已删除')
+28 -6
View File
@@ -8,6 +8,7 @@ import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import { CPD_PASSWORD_ERROR } from '@/api'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
const message = useMessage()
const router = useRouter()
@@ -137,7 +138,13 @@ async function loadTaskLogs() {
}
async function removeTaskLog(id: number) {
if (!confirm('确定要删除此条日志吗?')) return
const ok = await confirmDialog({
title: '删除日志',
content: '确定要删除此条日志吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await authApi.deleteTaskLog(id)
taskLogs.value = taskLogs.value.filter(l => l.id !== id)
@@ -718,7 +725,10 @@ async function saveForm() {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
await alertDialog({
title: '保存失败',
content: e?.response?.data?.message || e.message || '保存失败',
})
} finally {
savingForm.value = false
}
@@ -747,9 +757,12 @@ async function openEditModal(task: authApi.TaskInfo) {
// 回退:从 cpdaily 拉取表单(会丢失用户已填值)
try {
const res = await authApi.getTaskForm(task.id)
editFormItems.value = res.data.data.formItems || []
editFormItems.value = res.data.data?.formItems || []
} catch (e: any) {
alert('获取表单失败: ' + (e?.response?.data?.message || e.message))
await alertDialog({
title: '获取表单失败',
content: e?.response?.data?.message || e.message,
})
showEditModal.value = false
return
} finally {
@@ -772,7 +785,10 @@ async function saveEdit() {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
await alertDialog({
title: '保存失败',
content: e?.response?.data?.message || e.message || '保存失败',
})
} finally {
savingEdit.value = false
}
@@ -902,7 +918,13 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
// ======== 删除任务 ========
async function removeTask(id: number) {
if (!confirm('确定要删除此任务吗?')) return
const ok = await confirmDialog({
title: '删除任务',
content: '确定要删除此任务吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
deletingId.value = id
try {
await authApi.deleteTask(id)
+8 -1
View File
@@ -626,6 +626,7 @@ import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, M
import * as leaveApi from '@/api/leave'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const message = useMessage()
@@ -653,7 +654,13 @@ async function loadLogs() {
}
async function removeLog(id: number) {
if (!confirm('确定要删除此条日志吗?')) return
const ok = await confirmDialog({
title: '删除日志',
content: '确定要删除此条日志吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await leaveApi.deleteLeaveLog(id)
leaveLogs.value = leaveLogs.value.filter(l => l.id !== id)
+14 -2
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import * as ticketApi from '@/api/ticket'
import type { TicketListItem } from '@/api/ticket'
@@ -75,7 +76,12 @@ async function handleCreate() {
async function handleClose(t: TicketListItem, e: Event) {
e.stopPropagation()
if (!confirm('确定要关闭此工单吗?')) return
const ok = await confirmDialog({
title: '关闭工单',
content: '确定要关闭此工单吗?',
confirmText: '关闭',
})
if (!ok) return
try {
await ticketApi.closeTicket(t.id)
message.success('工单已关闭')
@@ -87,7 +93,13 @@ async function handleClose(t: TicketListItem, e: Event) {
async function handleDelete(t: TicketListItem, e: Event) {
e.stopPropagation()
if (!confirm('确定要删除此工单吗?删除后不可恢复。')) return
const ok = await confirmDialog({
title: '删除工单',
content: '确定要删除此工单吗?删除后不可恢复。',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await ticketApi.deleteTicket(t.id)
message.success('工单已删除')