starmusic-pc
1<script setup>
2/**
3 * UiFeedback — 全局反馈层
4 *
5 * 承载 uni 兼容层的三类反馈,使 common/* 里的 uni.showToast / showModal / showLoading
6 * 在桌面端拥有统一、精致的呈现:
7 * - Toast :右上角堆叠卡片,自动消失
8 * - Loading :居中毛玻璃卡片 + 旋转指示
9 * - Modal :居中弹窗(缩放弹入),Esc 取消 / Enter 确认
10 */
11import { onMounted, onUnmounted } from 'vue'
12import { uiState, closeToast, resolveModal } from '../uni/ui-state.js'
13import Icon from './Icon.vue'
14
15let sweepTimer = null
16
17/** 定期清理到期 toast(比每条单独计时更省、也更容易在批量推送时保持顺序) */
18onMounted(() => {
19 sweepTimer = setInterval(() => {
20 const now = Date.now()
21 for (const t of [...uiState.toasts]) {
22 if (now - t.createdAt >= t.duration) closeToast(t.id)
23 }
24 }, 120)
25})
26
27onUnmounted(() => {
28 if (sweepTimer) clearInterval(sweepTimer)
29})
30
31const iconName = (icon) => {
32 switch (icon) {
33 case 'success':
34 return 'check'
35 case 'error':
36 return 'close'
37 case 'loading':
38 return 'refresh'
39 default:
40 return ''
41 }
42}
43
44const onModalKey = (e, confirmOnEnter) => {
45 if (!uiState.modal) return
46 if (e.key === 'Escape') {
47 e.preventDefault()
48 resolveModal(false)
49 } else if (e.key === 'Enter' && confirmOnEnter) {
50 e.preventDefault()
51 resolveModal(true)
52 }
53}
54
55const modalAccent = (m) =>
56 m.danger || m.confirmColor === '#f56c6c' ? 'var(--color-danger)' : 'var(--theme-color)'
57</script>
58
59<template>
60 <div class="feedback-root">
61 <!-- ============ Toast ============ -->
62 <div class="toast-layer">
63 <TransitionGroup name="toast">
64 <div
65 v-for="t in uiState.toasts"
66 :key="t.id"
67 class="toast"
68 :class="[`is-${t.icon}`, { 'has-icon': !!iconName(t.icon) }]"
69 role="status"
70 >
71 <span v-if="iconName(t.icon)" class="toast-icon">
72 <Icon :name="iconName(t.icon)" :size="15" :width="2.2" :spin="t.icon === 'loading'" />
73 </span>
74 <span class="toast-text">{{ t.title }}</span>
75 <button class="toast-close" title="关闭" @click="closeToast(t.id)">
76 <Icon name="close" :size="13" :width="2.2" />
77 </button>
78 </div>
79 </TransitionGroup>
80 </div>
81
82 <!-- ============ Loading ============ -->
83 <Transition name="fade">
84 <div v-if="uiState.loading" class="loading-mask" :class="{ 'is-solid': uiState.loading.mask }">
85 <div class="loading-card">
86 <span class="loading-spinner">
87 <Icon name="refresh" :size="22" :width="2" spin />
88 </span>
89 <span class="loading-title">{{ uiState.loading.title }}</span>
90 </div>
91 </div>
92 </Transition>
93
94 <!-- ============ Modal ============ -->
95 <Transition name="fade">
96 <div
97 v-if="uiState.modal"
98 class="modal-mask"
99 @click.self="resolveModal(false)"
100 @keydown="onModalKey($event, !uiState.modal.editable)"
101 tabindex="-1"
102 >
103 <div class="modal-card" role="dialog" aria-modal="true">
104 <h3 class="modal-title">{{ uiState.modal.title }}</h3>
105 <p v-if="uiState.modal.content" class="modal-content">{{ uiState.modal.content }}</p>
106 <div class="modal-actions">
107 <button
108 v-if="uiState.modal.showCancel"
109 class="modal-btn is-ghost press"
110 @click="resolveModal(false)"
111 >
112 {{ uiState.modal.cancelText }}
113 </button>
114 <button
115 class="modal-btn is-primary press"
116 :style="{ background: modalAccent(uiState.modal) }"
117 @click="resolveModal(true)"
118 >
119 {{ uiState.modal.confirmText }}
120 </button>
121 </div>
122 </div>
123 </div>
124 </Transition>
125 </div>
126</template>
127
128<style scoped>
129.feedback-root {
130 position: relative;
131 z-index: var(--z-toast);
132}
133
134/* ---------- Toast ---------- */
135.toast-layer {
136 position: fixed;
137 top: calc(var(--h-titlebar) + 14px);
138 right: 18px;
139 z-index: var(--z-toast);
140 display: flex;
141 flex-direction: column;
142 align-items: flex-end;
143 gap: 9px;
144 pointer-events: none;
145}
146
147.toast {
148 display: flex;
149 align-items: center;
150 gap: 9px;
151 max-width: 380px;
152 padding: 10px 12px 10px 14px;
153 border-radius: var(--r-md);
154 background: var(--surface-glass-strong);
155 backdrop-filter: var(--blur-md);
156 -webkit-backdrop-filter: var(--blur-md);
157 box-shadow: var(--sh-lg);
158 border: 1px solid rgba(255, 255, 255, 0.6);
159 color: var(--text-1);
160 font-size: 13.5px;
161 line-height: 1.45;
162 pointer-events: auto;
163}
164
165.toast.has-icon {
166 padding-left: 12px;
167}
168
169.toast-icon {
170 display: grid;
171 place-items: center;
172 width: 20px;
173 height: 20px;
174 flex: none;
175 border-radius: var(--r-full);
176 color: #fff;
177 background: var(--text-4);
178}
179
180.toast.is-success .toast-icon {
181 background: var(--color-success);
182}
183
184.toast.is-error .toast-icon {
185 background: var(--color-danger);
186}
187
188.toast.is-loading .toast-icon {
189 background: var(--theme-color);
190}
191
192.toast.is-success {
193 border-color: rgba(34, 197, 94, 0.28);
194}
195
196.toast.is-error {
197 border-color: rgba(244, 63, 94, 0.28);
198}
199
200.toast-text {
201 flex: 1;
202 min-width: 0;
203 word-break: break-word;
204}
205
206.toast-close {
207 flex: none;
208 display: grid;
209 place-items: center;
210 width: 20px;
211 height: 20px;
212 border-radius: var(--r-xs);
213 color: var(--text-4);
214 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
215}
216
217.toast-close:hover {
218 background: var(--surface-3);
219 color: var(--text-2);
220}
221
222.toast-enter-active {
223 animation: kToastIn var(--t-base) var(--ease-overshoot);
224}
225
226.toast-leave-active {
227 animation: kToastOut var(--t-fast) var(--ease-out) forwards;
228 position: absolute;
229}
230
231.toast-move {
232 transition: transform var(--t-base) var(--ease-out);
233}
234
235/* ---------- Loading ---------- */
236.loading-mask {
237 position: fixed;
238 inset: 0;
239 z-index: var(--z-modal);
240 display: grid;
241 place-items: center;
242 background: transparent;
243 pointer-events: none;
244}
245
246.loading-mask.is-solid {
247 background: rgba(18, 22, 28, 0.18);
248 pointer-events: auto;
249}
250
251.loading-card {
252 display: flex;
253 align-items: center;
254 gap: 11px;
255 padding: 14px 20px;
256 border-radius: var(--r-lg);
257 background: var(--surface-glass-strong);
258 backdrop-filter: var(--blur-md);
259 -webkit-backdrop-filter: var(--blur-md);
260 box-shadow: var(--sh-lg);
261 border: 1px solid rgba(255, 255, 255, 0.6);
262 animation: kPopIn var(--t-base) var(--ease-overshoot) both;
263}
264
265.loading-spinner {
266 display: grid;
267 place-items: center;
268 color: var(--theme-color);
269}
270
271.loading-title {
272 font-size: 13.5px;
273 color: var(--text-1);
274}
275
276/* ---------- Modal ---------- */
277.modal-mask {
278 position: fixed;
279 inset: 0;
280 z-index: var(--z-modal);
281 display: grid;
282 place-items: center;
283 background: var(--surface-overlay);
284 backdrop-filter: blur(3px);
285 -webkit-backdrop-filter: blur(3px);
286 animation: kMaskIn var(--t-base) ease both;
287}
288
289.modal-card {
290 width: min(420px, calc(100vw - 64px));
291 padding: 22px 22px 16px;
292 border-radius: var(--r-xl);
293 background: var(--surface-1);
294 box-shadow: var(--sh-xl);
295 animation: kPopIn var(--t-slow) var(--ease-overshoot) both;
296}
297
298.modal-title {
299 font-size: 16px;
300 font-weight: 600;
301 color: var(--text-1);
302 letter-spacing: 0.2px;
303}
304
305.modal-content {
306 margin-top: 10px;
307 font-size: 13.5px;
308 line-height: 1.7;
309 color: var(--text-3);
310 white-space: pre-wrap;
311 word-break: break-word;
312 max-height: 46vh;
313 overflow-y: auto;
314}
315
316.modal-actions {
317 margin-top: 20px;
318 display: flex;
319 justify-content: flex-end;
320 gap: 9px;
321}
322
323.modal-btn {
324 min-width: 82px;
325 height: 36px;
326 padding: 0 16px;
327 border-radius: var(--r-sm);
328 font-size: 13.5px;
329 font-weight: 500;
330 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out),
331 box-shadow var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
332}
333
334.modal-btn.is-ghost {
335 color: var(--text-2);
336 background: var(--surface-3);
337}
338
339.modal-btn.is-ghost:hover {
340 background: var(--surface-4);
341}
342
343.modal-btn.is-primary {
344 color: #fff;
345 box-shadow: var(--sh-accent);
346}
347
348.modal-btn.is-primary:hover {
349 filter: brightness(1.06);
350}
351
352.fade-enter-active,
353.fade-leave-active {
354 transition: opacity var(--t-base) var(--ease-out);
355}
356
357.fade-enter-from,
358.fade-leave-to {
359 opacity: 0;
360}
361</style>