Files
mihomo-patchbay/web/components/ToastHost.vue
T
im0o f57eb55328 外壳:侧栏改 fixed,请求页改名日志,错误提示点击复制,弹窗锁滚动
侧栏从 sticky 改 fixed:sticky 的位置跟着 scrollTop 走,滚到底时
scrollTop 是 scrollHeight 减 clientHeight,在 Windows 显示缩放下是
小数,部分图标会跳 1px;fixed 按视口摆放,不参与这个运算。网格第一列
只当占位,主区显式站到第二列。

错误提示是拿去粘贴排查的,点击改成复制而不是关闭,关闭交给自动消失;
局域网 http 下没有 clipboard API,退回 execCommand。撤回/重做各加
2.5 秒冷却,窗内连点算一次,顺带避开重做备份名的同秒覆盖。弹窗打开
时锁住背景滚动,滚动条位置由 scrollbar-gutter 常驻,不横向跳。
2026-08-12 13:17:35 +08:00

163 lines
3.9 KiB
Vue

<script setup lang="ts">
/**
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出。
* 点一下:普通提示立刻消掉;错误提示是要拿去粘贴求助的,点击复制而不是关闭,
* 消失交给 toast.ts 的定时器。内容来自 web/toast.ts 的单例队列。
*/
import { ref } from 'vue'
import { dismiss, toasts } from '../toast'
import type { Toast } from '../toast'
import IconOk from '~icons/ri/checkbox-circle-line'
import IconErr from '~icons/ri/error-warning-line'
import IconInfo from '~icons/ri/information-line'
/** 已复制的错误提示:hint 原地换成「已复制到剪贴板」作为反馈。 */
const copied = ref(new Set<number>())
async function onClick(t: Toast) {
if (t.kind !== 'error') {
dismiss(t.id)
return
}
const ok = await copyText([t.message, t.hint].filter(Boolean).join('\n'))
if (ok) copied.value = new Set(copied.value).add(t.id)
}
/** 经 http 从局域网访问时没有 navigator.clipboard(非安全上下文),退回 execCommand。 */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const area = document.createElement('textarea')
area.value = text
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
return ok
}
}
</script>
<template>
<div class="host" aria-live="polite">
<TransitionGroup name="tst">
<div
v-for="t in toasts"
:key="t.id"
:class="['tst', t.kind === 'error' && 'is-error']"
:title="t.kind === 'error' ? '点击复制错误信息' : undefined"
@click="onClick(t)"
>
<IconErr v-if="t.kind === 'error'" class="tico" />
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
<IconOk v-else class="tico" />
<div class="tbody">
<strong class="tmsg">{{ t.message }}</strong>
<span v-if="copied.has(t.id)" class="thint">已复制到剪贴板</span>
<span v-else-if="t.hint" class="thint">{{ t.hint }}</span>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.host {
position: fixed;
top: var(--s-4);
right: var(--s-4);
z-index: 300;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-2);
/* 宿主自己不挡点击,字条才挡 */
pointer-events: none;
}
.tst {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--s-3);
min-width: 240px;
max-width: 380px;
padding: var(--s-3) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-md);
background: var(--c-surface);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
cursor: pointer;
user-select: none;
}
.tst.is-error {
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
}
.tico {
flex: none;
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--c-ok);
}
.is-error .tico {
color: var(--c-danger);
}
.tico.is-info {
color: var(--c-text-soft);
}
.tbody {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
.tmsg {
font-size: var(--t-sm);
font-weight: 500;
overflow-wrap: anywhere;
}
.thint {
font-size: var(--t-xs);
color: var(--c-text-soft);
overflow-wrap: anywhere;
}
/* 进场:从右滑入;退场:上移淡出;队列压缩时其余字条平滑归位 */
.tst-enter-active {
transition:
transform 0.28s cubic-bezier(0.21, 0.8, 0.35, 1),
opacity 0.28s ease;
}
.tst-enter-from {
transform: translateX(120%);
opacity: 0;
}
.tst-leave-active {
transition:
transform 0.4s ease,
opacity 0.4s ease;
/* 退场中脱离布局,后面的字条立刻开始归位 */
position: absolute;
right: 0;
}
.tst-leave-to {
transform: translateY(-14px);
opacity: 0;
}
.tst-move {
transition: transform 0.3s ease;
}
</style>