fix: make offline startup responsive
This commit is contained in:
parent
c2b147259e
commit
6c2161f9df
|
|
@ -1,26 +1,40 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted } from "vue";
|
import { onBeforeUnmount, onMounted } from "vue";
|
||||||
import { WifiOff } from "@lucide/vue";
|
import { ref } from "vue";
|
||||||
|
import { RefreshCw, WifiOff } from "@lucide/vue";
|
||||||
import { useAuthStore } from "./stores/auth";
|
import { useAuthStore } from "./stores/auth";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const retrySession = () => {
|
const reconnecting = ref(false);
|
||||||
if (typeof navigator === "undefined" || navigator.onLine) void auth.loadSession(true);
|
const retrySession = async () => {
|
||||||
|
if (reconnecting.value || (typeof navigator !== "undefined" && !navigator.onLine)) return;
|
||||||
|
reconnecting.value = true;
|
||||||
|
try {
|
||||||
|
await auth.loadSession(true);
|
||||||
|
} finally {
|
||||||
|
reconnecting.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
const handleOnline = () => void retrySession();
|
||||||
|
|
||||||
onMounted(() => window.addEventListener("online", retrySession));
|
onMounted(() => window.addEventListener("online", handleOnline));
|
||||||
onBeforeUnmount(() => window.removeEventListener("online", retrySession));
|
onBeforeUnmount(() => window.removeEventListener("online", handleOnline));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<div
|
<button
|
||||||
v-if="auth.unavailable && auth.user"
|
v-if="auth.unavailable && auth.user"
|
||||||
class="offline-status"
|
class="offline-status"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="服务暂不可用,当前显示本地数据"
|
aria-label="服务暂不可用,点击重连"
|
||||||
title="服务暂不可用,当前显示本地数据"
|
title="服务暂不可用,点击重连"
|
||||||
|
type="button"
|
||||||
|
:disabled="reconnecting"
|
||||||
|
@click="void retrySession()"
|
||||||
>
|
>
|
||||||
<WifiOff :size="16" />
|
<WifiOff :size="16" />
|
||||||
</div>
|
<span>{{ reconnecting ? "正在重连" : "服务暂不可用 · 点击重连" }}</span>
|
||||||
|
<RefreshCw :size="14" :class="{ spinning: reconnecting }" />
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,23 @@ export class ApiError extends Error {
|
||||||
|
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: { method?: string; body?: unknown } = {},
|
options: { method?: string; body?: unknown; timeoutMs?: number } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const controller = new AbortController();
|
||||||
method: options.method ?? "GET",
|
const timeoutMs = options.timeoutMs ?? (options.method && options.method !== "GET" ? 10000 : 3000);
|
||||||
credentials: "same-origin",
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
headers: options.body === undefined ? undefined : { "Content-Type": "application/json" },
|
try {
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
const response = await fetch(path, {
|
||||||
});
|
method: options.method ?? "GET",
|
||||||
const payload = await response.json().catch(() => ({})) as { error?: string };
|
credentials: "same-origin",
|
||||||
if (!response.ok) throw new ApiError(payload.error ?? "请求失败", response.status);
|
headers: options.body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||||
return payload as T;
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({})) as { error?: string };
|
||||||
|
if (!response.ok) throw new ApiError(payload.error ?? "请求失败", response.status);
|
||||||
|
return payload as T;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,32 @@
|
||||||
|
|
||||||
.offline-status {
|
.offline-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: max(8px, env(safe-area-inset-top));
|
|
||||||
right: 8px;
|
right: 8px;
|
||||||
z-index: 100;
|
bottom: calc(68px + env(safe-area-inset-bottom) + 4px);
|
||||||
width: 30px;
|
left: 8px;
|
||||||
height: 30px;
|
z-index: 6;
|
||||||
display: grid;
|
height: 28px;
|
||||||
place-items: center;
|
display: flex;
|
||||||
padding: 0;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
border: 1px solid #e9c98d;
|
border: 1px solid #e9c98d;
|
||||||
border-radius: 50%;
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
background: rgba(255, 248, 228, .96);
|
background: rgba(255, 248, 228, .96);
|
||||||
color: #795b19;
|
color: #795b19;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
box-shadow: 0 2px 8px rgba(74, 57, 18, .12);
|
box-shadow: 0 2px 8px rgba(74, 57, 18, .12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-status:disabled { opacity: .8; }
|
||||||
|
.offline-status .spinning { animation: offline-reconnect-spin 800ms linear infinite; }
|
||||||
|
|
||||||
|
@keyframes offline-reconnect-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,12 @@ function offlineServiceWorkerPlugin(): Plugin {
|
||||||
const source = `const CACHE_NAME = "cents-shell-v4";
|
const source = `const CACHE_NAME = "cents-shell-v4";
|
||||||
const PRECACHE = ${JSON.stringify(precache, null, 2)};
|
const PRECACHE = ${JSON.stringify(precache, null, 2)};
|
||||||
|
|
||||||
|
function fetchWithTimeout(request, timeoutMs) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
return fetch(request, { signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()));
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()));
|
||||||
});
|
});
|
||||||
|
|
@ -122,7 +128,7 @@ self.addEventListener("fetch", (event) => {
|
||||||
|
|
||||||
if (event.request.mode === "navigate") {
|
if (event.request.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request).then((response) => {
|
fetchWithTimeout(event.request, 1500).then((response) => {
|
||||||
if (!response.ok) throw new Error("navigation unavailable");
|
if (!response.ok) throw new Error("navigation unavailable");
|
||||||
const copy = response.clone();
|
const copy = response.clone();
|
||||||
void caches.open(CACHE_NAME).then((cache) => cache.put("/", copy));
|
void caches.open(CACHE_NAME).then((cache) => cache.put("/", copy));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue