ConfirmAction.vue
4.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<script setup>
/**
* 二次确认处置按钮 —— 把「点击 → 确认 → 调一个 server 端点 → 按返回更新视图」的处置时序
* 抽成可测组件。
*
* 本组件**不直接发请求**:它只在确认后调用注入的 `action`(page 传入、内部委托 api 层的单个
* 端点方法)。`action` 必须是「发一个指令」的 thunk,副作用归 server。
*
* 触发器用 `Button`、确认框用 `Modal`,不自己画。原先这两样都是手写的:触发器是一个裸
* `<button>` 加一套自己的样式,和旁边的 `Button` 并排就是两个东西;确认框是 `position: fixed`
* 的浮层,坐标在点击那一刻算一次,于是
*
* - 点别处不关、Esc 不关,表格里一行点一个就**叠一堆同时挂着**,没法用
* - 页面一滚,浮层留在原地
* - 没有焦点管理
*
* `Modal` 这三样都有(居中、backdrop 关闭、Esc、焦点陷阱),所以这里只负责时序。
* 对外的 props / 事件 / data-testid 一个没改,8 处调用方不用动。
*/
import { ref } from 'vue'
import Button from './Button.vue'
import Modal from './Modal.vue'
const props = defineProps({
label: { type: String, required: true },
/** 确认后执行的处置(返回 Promise)。必须只发一个 server 指令。 */
action: { type: Function, required: true },
/** 确认弹框里的问句。 */
confirmText: { type: String, default: 'Are you sure?' },
/** 取消按钮文案。 */
cancelLabel: { type: String, default: 'Cancel' },
/** 确认按钮文案。 */
okLabel: { type: String, default: 'Confirm' },
/** 处置进行中的确认按钮文案。 */
pendingLabel: { type: String, default: 'Working…' },
/** 危险处置:触发器与确认按钮转 danger 色。 */
danger: { type: Boolean, default: false },
})
const emit = defineEmits(['done', 'error'])
const open = ref(false)
const pending = ref(false)
const error = ref('')
function ask() {
error.value = ''
open.value = true
}
/** 处置进行中不许关:关掉之后请求还在飞,结果落在一个已经消失的框上。 */
function setOpen(next) {
if (pending.value) return
open.value = next
}
async function confirm() {
pending.value = true
error.value = ''
try {
const result = await props.action()
pending.value = false
open.value = false
emit('done', result)
} catch (e) {
// 失败不关框:错误就显示在原地,人能直接再点一次确认。
pending.value = false
error.value = e?.message || String(e)
emit('error', e)
}
}
</script>
<template>
<!-- 单根:调用方会给它传 class(apps/admin 的 UserDetail 就传了),
多根组件的 class 不会自动落到任何元素上,会被静默丢掉。
Modal 自己 Teleport 到 body,放在这层里不影响布局。 -->
<span class="confirm-action">
<Button
:variant="danger ? 'danger' : 'secondary'"
size="sm"
data-testid="confirm-trigger"
@click="ask"
>
{{ label }}
</Button>
<Modal
:open="open"
width="420px"
:close-on-backdrop="!pending"
@update:open="setOpen"
>
<div data-testid="confirm-dialog">
<p class="confirm-action__text">{{ confirmText }}</p>
<p
v-if="error"
class="confirm-action__error"
data-testid="confirm-error"
>
{{ error }}
</p>
</div>
<template #footer>
<Button
variant="ghost"
:disabled="pending"
data-testid="confirm-cancel"
@click="setOpen(false)"
>
{{ cancelLabel }}
</Button>
<Button
:variant="danger ? 'danger' : 'primary'"
:loading="pending"
data-testid="confirm-ok"
@click="confirm"
>
{{ pending ? pendingLabel : okLabel }}
</Button>
</template>
</Modal>
</span>
</template>
<style scoped>
.confirm-action {
display: inline-block;
}
.confirm-action__text {
margin: 0;
font-size: var(--drama-font-size-md);
line-height: var(--drama-line-height-normal, 1.6);
}
.confirm-action__error {
margin: var(--drama-space-md) 0 0;
color: var(--drama-color-danger);
font-size: var(--drama-font-size-sm);
}
</style>