① 问题:删除任务的确认框是浏览器自带弹窗,与全站统一样式不一致。
排查确认这是 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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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('已删除')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('工单已删除')
|
||||
|
||||
Reference in New Issue
Block a user