fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
219 lines
6.4 KiB
Vue
219 lines
6.4 KiB
Vue
<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>
|