用 vite-plugin-pwa 生成 manifest 与 Service Worker,不自己写缓存逻辑。 - 装到桌面:manifest 名字/主题色/三个图标(any + maskable + apple-touch-icon), 图标和页头、安卓桌面是同一枚;首页底部给安装引导,能装就弹「安装」, iOS 给「分享 → 添加到主屏幕」,关掉记在 localStorage - 发版提示:registerType=prompt,收到新版本只在底部提示,点「刷新」才换。 故意不自动刷新——Vditor、图表是懒加载分片,后台换 chunk 会让面板 404 - 缓存边界:只预缓存页面骨架(约 0.8MB),/api/ 一律 NetworkOnly, 离线宁可报错也不端旧数据;/vditor/ 走 CacheFirst,不拖慢首次安装 - 顺手修:UpdatePrompt 居中改用 flex 包一层,animate-slide-up 的 keyframes 自带 transform,会和 -translate-x-1/2 抢属性,手机窄屏会把卡片推出屏幕 验证:web/e2e/pwa.mjs 19 项全绿(manifest/SW 激活/预缓存/安装引导/iOS 文案/ 发版提示/断网仍能打开);tsc 通过、vite build 通过。 已知限制:Service Worker 只在 https 或 localhost 注册,生产入口还是 http, 所以内网 IP 上装不了——需要域名 + 证书反代或隧道,已写进 DEPLOY.md 第 6 节。
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* PWA 验收脚本:装得上、离线开得开、有新版本提醒得了。
|
||||
*
|
||||
* 跑法(必须先有一个"生产同构"的站点:后端用 WEB_DIST_DIR 指向 web/dist):
|
||||
* WEB_DIST_DIR=<repo>/web/dist ../backend/.venv/Scripts/python.exe -m uvicorn app.main:app --port 8101
|
||||
* node web/e2e/pwa.mjs # 默认 BASE=http://127.0.0.1:8101
|
||||
*
|
||||
* 两个坑:
|
||||
* 1. Service Worker 只在安全上下文(https / localhost)注册,所以这里必须走 127.0.0.1,
|
||||
* 不能换成内网 IP——那台机器上要真的装上,得先有 https。
|
||||
* 2. 无头 Chromium 不会触发 beforeinstallprompt,所以"安装"那段用 iPhone 的 UA 走
|
||||
* iOS 分支(分享 → 添加到主屏幕),这是唯一能稳定断言到的安装引导。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.BASE ?? "http://127.0.0.1:8101";
|
||||
const PHONE = process.env.PHONE ?? "13900000001";
|
||||
const CODE = process.env.CODE ?? "123456";
|
||||
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||
const OUT = "e2e/artifacts";
|
||||
const CHROME =
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||
|
||||
const IOS_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
if (ok) {
|
||||
pass += 1;
|
||||
console.log(`PASS ${name}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(`FAIL ${name} ${extra}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function token() {
|
||||
// 先试密码(快、不撞短信节流),不行再发验证码
|
||||
try {
|
||||
const saved = readFileSync(PWD_FILE, "utf8").trim();
|
||||
const r = await fetch(`${BASE}/api/v1/auth/login/password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: PHONE, password: saved }),
|
||||
});
|
||||
const body = await r.json();
|
||||
if (body?.code === 0) return body.data.tokens;
|
||||
} catch {
|
||||
// 没存过密码:往下走验证码
|
||||
}
|
||||
await fetch(`${BASE}/api/v1/auth/sms/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: PHONE }),
|
||||
});
|
||||
const r = await fetch(`${BASE}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: PHONE, code: CODE }),
|
||||
});
|
||||
const body = await r.json();
|
||||
if (body?.code !== 0) throw new Error(`登录失败:${JSON.stringify(body)}`);
|
||||
return body.data.tokens;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const tokens = await token();
|
||||
const browser = await chromium.launch({ executablePath: CHROME });
|
||||
|
||||
// ── 一、桌面尺寸:Service Worker 与 manifest ─────────────────────
|
||||
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
await desktop.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[tokens.access_token, tokens.refresh_token],
|
||||
);
|
||||
const page = await desktop.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||
|
||||
const manifestHref = await page.getAttribute('link[rel="manifest"]', "href");
|
||||
check("页面挂了 manifest", Boolean(manifestHref), String(manifestHref));
|
||||
|
||||
const manifest = await (await fetch(`${BASE}${manifestHref}`)).json();
|
||||
check("manifest 名字是「达道Task」", manifest.short_name === "达道Task", JSON.stringify(manifest.name));
|
||||
check("独立窗口打开(display=standalone)", manifest.display === "standalone", String(manifest.display));
|
||||
check("三个图标齐全", (manifest.icons ?? []).length === 3, JSON.stringify(manifest.icons));
|
||||
check(
|
||||
"有一个 maskable 图标",
|
||||
(manifest.icons ?? []).some((i) => String(i.purpose).includes("maskable")),
|
||||
);
|
||||
|
||||
for (const icon of manifest.icons ?? []) {
|
||||
const res = await fetch(`${BASE}/${String(icon.src).replace(/^\//, "")}`);
|
||||
check(`图标可下载 ${icon.sizes}`, res.ok && res.headers.get("content-type") === "image/png");
|
||||
}
|
||||
|
||||
const sw = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return { scope: reg.scope, state: reg.active?.state ?? "none" };
|
||||
});
|
||||
check("Service Worker 已激活", sw.state === "activated", JSON.stringify(sw));
|
||||
check("作用域是整站", sw.scope.endsWith("/"), sw.scope);
|
||||
|
||||
const shellCached = await page.evaluate(async () => {
|
||||
const cache = await caches.open("workbox-precache-v2-" + location.origin + "/");
|
||||
const keys = await cache.keys();
|
||||
return keys.some((k) => k.url.includes("index.html"));
|
||||
});
|
||||
check("页面骨架已预缓存", shellCached);
|
||||
|
||||
const themeColor = await page.getAttribute('meta[name="theme-color"]', "content");
|
||||
check("主题色是墨绿", themeColor === "#2f6054", String(themeColor));
|
||||
check("桌面尺寸没有控制台报错", errors.length === 0, errors.join(" | "));
|
||||
await desktop.close();
|
||||
|
||||
// ── 二、iPhone 尺寸:安装引导(iOS 只能靠「分享 → 添加到主屏幕」)─────
|
||||
const phone = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
userAgent: IOS_UA,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await phone.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[tokens.access_token, tokens.refresh_token],
|
||||
);
|
||||
const m = await phone.newPage();
|
||||
await m.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||
await m.waitForSelector("text=把达道Task装到桌面", { timeout: 10000 });
|
||||
const hint = await m.locator("text=把达道Task装到桌面").first().isVisible();
|
||||
check("窄屏首页有安装引导", hint);
|
||||
const iosText = await m.locator("text=添加到主屏幕").count();
|
||||
check("iOS 给出的是「分享 → 添加到主屏幕」", iosText > 0);
|
||||
await m.screenshot({ path: `${OUT}/pwa-install-hint.png` });
|
||||
|
||||
await m.getByRole("button", { name: "不再提示安装" }).click();
|
||||
await m.reload({ waitUntil: "networkidle" });
|
||||
const stillThere = await m.locator("text=把达道Task装到桌面").count();
|
||||
check("关掉之后不再打扰(刷新也不回来)", stillThere === 0);
|
||||
|
||||
// ── 三、有新版时提醒,点「稍后」收起 ─────────────────────────────
|
||||
await m.evaluate(() => window.dispatchEvent(new Event("dadao:pwa-update-ready")));
|
||||
await m.waitForSelector("text=有新版了", { timeout: 5000 });
|
||||
check("检测到新版本会提示刷新", (await m.locator("text=有新版了").count()) > 0);
|
||||
await m.screenshot({ path: `${OUT}/pwa-update-prompt.png` });
|
||||
await m.getByRole("button", { name: "稍后" }).click();
|
||||
check("点「稍后」会收起", (await m.locator("text=有新版了").count()) === 0);
|
||||
|
||||
// ── 四、断网还能打开(这是装到桌面的实际价值)─────────────────────
|
||||
await m.evaluate(() => localStorage.clear());
|
||||
await m.reload({ waitUntil: "networkidle" });
|
||||
await phone.setOffline(true);
|
||||
await m.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
const body = await m.locator("body").innerText();
|
||||
check("断网后页面骨架仍能打开", body.length > 0 && /登录|达道/.test(body), body.slice(0, 80));
|
||||
await phone.setOffline(false);
|
||||
await m.screenshot({ path: `${OUT}/pwa-offline.png` });
|
||||
|
||||
await phone.close();
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("脚本异常 —", err?.message ?? err);
|
||||
process.exit(1);
|
||||
});
|
||||
+10
-3
@@ -2,14 +2,21 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>达道Task</title>
|
||||
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
||||
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
|
||||
<!-- 和 App 内左上角、安卓桌面同一枚图标:三段米白线 + 一点陶土 -->
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%232F6054'/%3E%3Cg stroke='%23EFF5F3' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M18 23h28'/%3E%3Cpath d='M18 32h19'/%3E%3Cpath d='M18 41h12'/%3E%3C/g%3E%3Ccircle cx='46' cy='41' r='6' fill='%23C2703D'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- 装到手机主屏之后:状态栏跟品牌色走,iOS 用不透明底图 -->
|
||||
<meta name="theme-color" content="#2f6054" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="达道Task" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+4255
-17
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -29,6 +29,7 @@
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { ToastProvider } from "./components/ui";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt";
|
||||
import { api, authExpired, session } from "./lib/client";
|
||||
import type { User, Workspace } from "./lib/types";
|
||||
import { LoginPage } from "./pages/Login";
|
||||
@@ -98,6 +99,7 @@ export function App() {
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<UpdatePrompt />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download, X } from "./icons";
|
||||
import { isStandalone } from "../lib/pwa";
|
||||
|
||||
/** Chrome/Edge/安卓上那个「安装」事件,TS 的 DOM 库里还没有类型。 */
|
||||
interface InstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
const DISMISS_KEY = "dadao.install-hint-dismissed";
|
||||
|
||||
function isIos(): boolean {
|
||||
const ua = window.navigator.userAgent;
|
||||
return /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把达道Task装到桌面/主屏。
|
||||
*
|
||||
* 有安装事件就自己弹一个按钮(浏览器自己那套横幅经常被无视);
|
||||
* iOS 没有安装事件,只能告诉用户「分享 → 添加到主屏幕」这一步。
|
||||
* 从主屏启动(standalone)或者用户关过之后就不再出现。
|
||||
*/
|
||||
export function InstallHint() {
|
||||
const [promptEvent, setPromptEvent] = useState<InstallPromptEvent | null>(null);
|
||||
const [hidden, setHidden] = useState(() => localStorage.getItem(DISMISS_KEY) === "1");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onPrompt = (event: Event) => {
|
||||
event.preventDefault(); // 拦住浏览器自带的横幅,换成我们自己的入口
|
||||
setPromptEvent(event as InstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => setPromptEvent(null);
|
||||
window.addEventListener("beforeinstallprompt", onPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (hidden || isStandalone()) return null;
|
||||
|
||||
const ios = isIos();
|
||||
// 桌面 Safari、Firefox 既不给安装事件,也没有添加主屏,不必打扰
|
||||
if (!promptEvent && !ios) return null;
|
||||
|
||||
function dismiss() {
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
setHidden(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-xl border border-primary-300/40 bg-primary-50/50 px-4 py-3">
|
||||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary-900 text-paper-50">
|
||||
<Download size={15} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-ink-800">把达道Task装到桌面</p>
|
||||
<p className="mt-1 text-2xs leading-relaxed text-ink-500">
|
||||
{promptEvent
|
||||
? "装好之后是一个独立窗口,不用再敲网址,也不会被浏览器标签页挤掉。"
|
||||
: "在 Safari 里点底部的「分享」,选「添加到主屏幕」,就会和 App 一样有图标。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{promptEvent ? (
|
||||
<button
|
||||
className="rounded-lg bg-ink-900 px-3 py-1.5 text-xs font-medium text-paper-50 transition hover:bg-ink-800 disabled:opacity-50"
|
||||
disabled={installing}
|
||||
onClick={async () => {
|
||||
setInstalling(true);
|
||||
try {
|
||||
await promptEvent.prompt();
|
||||
await promptEvent.userChoice;
|
||||
} catch {
|
||||
// 用户取消或浏览器拒绝:什么都不用说
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
setPromptEvent(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
安装
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="不再提示安装"
|
||||
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-paper-200 hover:text-ink-600"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./ui";
|
||||
import { applyServiceWorkerUpdate, PWA_EVENT } from "../lib/pwa";
|
||||
|
||||
/**
|
||||
* 装到手机/桌面以后,发版就不是"让用户重新输网址"了:
|
||||
* 有新版本时在这里提一句,点"刷新"才换,不点就继续用手上的这版。
|
||||
*
|
||||
* 为什么不用自动刷新(registerType: "autoUpdate"):Vditor、图表这些是懒加载分片,
|
||||
* 后台悄悄换掉之后,用户下一次点开某个面板就是 404 —— 编辑器直接白屏。
|
||||
*/
|
||||
export function UpdatePrompt() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onReady = () => setReady(true);
|
||||
window.addEventListener(PWA_EVENT.updateReady, onReady);
|
||||
return () => window.removeEventListener(PWA_EVENT.updateReady, onReady);
|
||||
}, []);
|
||||
|
||||
if (!ready || dismissed) return null;
|
||||
|
||||
return (
|
||||
// 居中用 flex 包一层:animate-slide-up 的 keyframes 自带 transform,
|
||||
// 会和 -translate-x-1/2 抢同一个属性,手机窄屏上卡片会被推出屏幕右侧。
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[70] flex justify-center px-4">
|
||||
<div
|
||||
role="status"
|
||||
className="animate-slide-up pointer-events-auto w-full max-w-[26rem] rounded-xl border border-line-200 bg-ink-900 px-4 py-3 text-paper-50 shadow-pop"
|
||||
>
|
||||
<p className="text-sm">有新版了。刷新一下就用上,手头的输入会丢,先存了再点。</p>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-paper-300 hover:text-paper-50"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
稍后
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={applying}
|
||||
onClick={() => {
|
||||
setApplying(true);
|
||||
void applyServiceWorkerUpdate();
|
||||
}}
|
||||
>
|
||||
{applying ? "正在刷新…" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Service Worker 的注册与升级。
|
||||
*
|
||||
* 只做三件事:生产环境注册、把"有新版本"和"可以离线用了"两个事件抛给界面、
|
||||
* 以及提供统一的一个"现在就换新版本"入口。注册本身交给 vite-plugin-pwa 生成的
|
||||
* `virtual:pwa-register`,这里不重复实现缓存逻辑。
|
||||
*/
|
||||
|
||||
type ApplyUpdate = (reloadPage?: boolean) => Promise<void>;
|
||||
|
||||
let applyUpdate: ApplyUpdate | null = null;
|
||||
|
||||
/** 界面用的事件名:有新版可换、首次离线可用。 */
|
||||
export const PWA_EVENT = {
|
||||
updateReady: "dadao:pwa-update-ready",
|
||||
offlineReady: "dadao:pwa-offline-ready",
|
||||
} as const;
|
||||
|
||||
export function startServiceWorker(): void {
|
||||
// 开发环境没有 SW(devOptions.enabled = false),动态引入免得无谓地打包
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
void import("virtual:pwa-register")
|
||||
.then(({ registerSW }) => {
|
||||
applyUpdate = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
window.dispatchEvent(new Event(PWA_EVENT.updateReady));
|
||||
},
|
||||
onOfflineReady() {
|
||||
window.dispatchEvent(new Event(PWA_EVENT.offlineReady));
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 注册失败不影响正常使用:下次打开页面还会再试一次
|
||||
});
|
||||
}
|
||||
|
||||
/** 用户点了"刷新":让新 SW 立刻接管并重载页面。 */
|
||||
export async function applyServiceWorkerUpdate(): Promise<void> {
|
||||
if (applyUpdate) {
|
||||
await applyUpdate(true);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/** 这个页面是不是从主屏图标启动的(standalone)。 */
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia?.("(display-mode: standalone)").matches === true ||
|
||||
(window.navigator as { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { startServiceWorker } from "./lib/pwa";
|
||||
import "./index.css";
|
||||
|
||||
// 装到桌面之后才有意义:离线能打开、发版能提示。生产构建里才真的注册。
|
||||
startServiceWorker();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { ArrowRight, ChevronRight, Plus } from "../components/icons";
|
||||
import { InstallHint } from "../components/InstallHint";
|
||||
import { SideRail } from "../components/SideRail";
|
||||
import { Button, EmptyState, Field, InlineAlert } from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
@@ -186,6 +187,7 @@ export function HomePage() {
|
||||
<p className="mt-6 text-2xs leading-relaxed text-ink-400">
|
||||
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,不需要别人手动拉你。
|
||||
</p>
|
||||
<InstallHint />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
+60
-1
@@ -1,9 +1,68 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// 开发端口固定 5180,接口走代理,避免本地跨域配置漂移
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
// 有新版本时先不偷偷换掉:让用户自己点"刷新",
|
||||
// 免得开着看板的时候后台把 chunk 换了,点一下就是 404。
|
||||
registerType: "prompt",
|
||||
injectRegister: null, // main.tsx 里自己注册,好在拿到 onNeedRefresh
|
||||
includeAssets: ["icons/apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "达道Task",
|
||||
short_name: "达道Task",
|
||||
description: "任务、工单、知识库,一条道走到底。",
|
||||
lang: "zh-CN",
|
||||
dir: "ltr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "any",
|
||||
background_color: "#f7f5f0",
|
||||
theme_color: "#2f6054",
|
||||
categories: ["productivity", "business"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{
|
||||
src: "/icons/maskable-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// 只预缓存构建产物与图标;Vditor 那 3.7MB 运行时按需缓存,别拖慢首次安装
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
|
||||
globIgnores: ["vditor/**", "**/*.map"],
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
cleanupOutdatedCaches: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
// 接口一律走网络:离线时宁可报错,也不要把别人的旧数据端出来
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
{
|
||||
// 编辑器的 lute / 语言包 / 图标:用过一次就留着,离线也能写字
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/vditor/"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "vditor",
|
||||
expiration: { maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
|
||||
Reference in New Issue
Block a user