Files
CarLog/client/src/components/PwaToasts.vue
T
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删
- AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3
- 化学品 / Grocy 实例对接 + 库存镜像同步
- 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护
- 月度报表:Excel 6 sheet + PDF
- PWA:manifest / SW / 离线缓存 / iOS 引导
- 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿
- 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就
- 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等
- 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications)
- 部署:宝塔面板文档 + PM2 + Nginx
2026-06-20 21:11:54 +08:00

219 lines
6.4 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="pwa-toast-stack" role="status" aria-live="polite">
<!-- 新版本可用 -->
<transition name="slide-up">
<div v-if="pwa.needRefresh" class="pwa-toast pwa-toast--update" role="alert">
<span class="pwa-toast__icon">🔄</span>
<div class="pwa-toast__body">
<strong>新版本可用</strong>
<span>点击刷新以加载最新内容</span>
</div>
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="pwa.applyUpdate()">刷新</button>
<button class="pwa-toast__btn" @click="pwa.dismissNeedRefresh()" aria-label="稍后">×</button>
</div>
</transition>
<!-- 离线就绪 -->
<transition name="slide-up">
<div v-if="pwa.offlineReady" class="pwa-toast pwa-toast--offline">
<span class="pwa-toast__icon">📦</span>
<div class="pwa-toast__body">
<strong>已可离线使用</strong>
<span>无网络时仍能打开</span>
</div>
<button class="pwa-toast__btn" @click="pwa.dismissOfflineReady()" aria-label="知道了">×</button>
</div>
</transition>
<!-- Android/桌面安装引导 -->
<transition name="slide-up">
<div
v-if="pwa.installPromptEvent && !pwa.isInstalled"
class="pwa-toast pwa-toast--install"
role="dialog"
aria-label="安装应用"
>
<span class="pwa-toast__icon"></span>
<div class="pwa-toast__body">
<strong>安装 CarLog</strong>
<span>添加到主屏幕 App 一样使用</span>
</div>
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="onInstall">安装</button>
<button class="pwa-toast__btn" @click="dismissInstall" aria-label="稍后">×</button>
</div>
</transition>
<!-- iOS Safari 引导 -->
<transition name="slide-up">
<div
v-if="showIosHint"
class="pwa-toast pwa-toast--ios"
role="dialog"
aria-label="iOS 安装提示"
>
<span class="pwa-toast__icon">📱</span>
<div class="pwa-toast__body">
<strong>添加到主屏幕</strong>
<span>点击底部分享 <span class="pwa-ios-share"></span>添加到主屏幕</span>
</div>
<button class="pwa-toast__btn" @click="dismissIos" aria-label="知道了">×</button>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { usePwaStore } from '../stores/pwa';
const pwa = usePwaStore();
// 用 sessionStorage 标记这次会话已关过 iOS 提示,避免反复弹
const IOS_HINT_KEY = 'pwa.iosHint.dismissed';
const showIosHint = ref(false);
const dismissed = ref(false);
onMounted(() => {
// iOS Safari + 未安装 + 没关过 → 弹一次
if (pwa.isIosSafari && !pwa.isInstalled) {
dismissed.value = sessionStorage.getItem(IOS_HINT_KEY) === '1';
showIosHint.value = !dismissed.value;
}
});
onUnmounted(() => {});
const canShowIos = computed(() => showIosHint.value && !dismissed.value);
void canShowIos;
function dismissInstall() {
// Pinia 自动解包 ref,直接赋值即可,不要用 .value
// 原写法 pwa.installPromptEvent.value = null 会抛 TypeError(对 null)或静默无效(对 Event)
pwa.installPromptEvent = null;
}
function dismissIos() {
sessionStorage.setItem(IOS_HINT_KEY, '1');
showIosHint.value = false;
dismissed.value = true;
}
async function onInstall() {
const accepted = await pwa.promptInstall();
if (!accepted) {
// 用户拒绝,3 天内不再弹
const ts = Date.now();
localStorage.setItem('pwa.install.dismissedAt', String(ts));
}
}
</script>
<style scoped>
.pwa-toast-stack {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
width: min(420px, calc(100vw - 24px));
pointer-events: none;
}
.pwa-toast {
pointer-events: auto;
background: var(--bg-elev, #fff);
color: var(--text, #1f2937);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.18);
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
line-height: 1.35;
}
.pwa-toast--update {
border-color: var(--brand, #1b6ef3);
}
.pwa-toast--install {
border-color: #10b981;
}
.pwa-toast__icon {
font-size: 22px;
flex: 0 0 22px;
text-align: center;
}
.pwa-toast__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
}
.pwa-toast__body strong {
font-weight: 600;
color: var(--text-strong, #111827);
}
.pwa-toast__body span {
color: var(--text-soft, #6b7280);
font-size: 12.5px;
}
.pwa-toast__btn {
appearance: none;
border: 0;
background: transparent;
color: var(--text-soft, #6b7280);
font-size: 18px;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
flex: 0 0 28px;
line-height: 1;
}
.pwa-toast__btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.pwa-toast__btn--primary {
background: var(--brand, #1b6ef3);
color: #fff;
font-size: 13px;
font-weight: 600;
width: auto;
height: 30px;
padding: 0 12px;
border-radius: 6px;
}
.pwa-toast__btn--primary:hover {
background: #1858c4;
}
.pwa-ios-share {
display: inline-block;
transform: translateY(1px);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
@media (prefers-color-scheme: dark) {
.pwa-toast {
background: #1f2937;
border-color: #374151;
color: #f3f4f6;
}
.pwa-toast__body strong {
color: #f9fafb;
}
.pwa-toast__body span {
color: #9ca3af;
}
.pwa-toast__btn:hover {
background: rgba(255, 255, 255, 0.08);
}
}
</style>