Compare commits

..
2 Commits
Author SHA1 Message Date
Steven 483b554c3c feat(web): Web 装到桌面(PWA),带安装引导与发版提示
CI / backend (push) Canceled after 0s
用 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 节。
2026-10-31 04:33:43 +08:00
Steven 370d8923c8 test(web): 生产核对脚本自己等节流,别把 429 当成登录坏了 2026-10-31 04:33:34 +08:00
19 changed files with 4831 additions and 43 deletions
+31 -3
View File
@@ -152,7 +152,35 @@ curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.a
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
装完再启动,`version_code` 已是最新,接口返回 `has_update=false`,不会再弹。
## 6. 运维
## 6. Web 装到桌面(PWA)
前端已经打成 PWA:`/manifest.webmanifest` 与 `/sw.js` 由后端静态层直接返回(`_SPAStaticFiles` 挂在 `/`,
不需要额外配置),`node_modules/.bin` 之外没有额外依赖。发布流程不变。
**装的条件是安全上下文**——浏览器只允许 `https` 或 `localhost` 注册 Service Worker,也就是说:
| 入口 | 能不能装 |
|---|---|
| `https://<域名>/` | 能,手机和电脑都会出现安装入口 |
| `http://127.0.0.1:8200/`(本机打开) | 能,仅限这台机器 |
| `http://192.168.8.188:8200/` | **不能**,非安全上下文:不弹安装、SW 也不注册 |
内网 IP 目前就是这种情况,所以真要在手机上装到桌面,得给入口套一层 HTTPS:
在能装 nginx 的机器上(或 fnsvr-01 的 1Panel OpenResty)加一个 `443 → 127.0.0.1:8200` 的反代就好,
应用本身不用改;示例见 `deploy/prod/nginx.dadaotask.conf.example`。也可以用 Cloudflare Tunnel 之类的
隧道拿一个 https 域名,同样不用碰应用代码。
装好之后的两个行为:
- **图标**:来自 `web/public/icons/`(any + maskable 两套,iOS 吃 `apple-touch-icon.png`),
与 Web 页头、安卓桌面图标是同一枚。
- **发版提示**:SW 采用 `registerType: "prompt"`,检测到新版本只在页面底部提示「有新版了」,
用户点「刷新」才切换。**故意不自动刷新**:Vditor、图表是懒加载分片,后台把 chunk 换掉之后,
用户下一次点开面板就是 404(编辑器白屏)。
- **离线**:只预缓存页面骨架(构建产物 + 图标,约 0.8MB),`/api/` 一律 NetworkOnly——
宁可报错,也不拿旧数据糊弄人。
## 7. 运维
```bash
systemctl --user status dadaotask-api # 状态
@@ -164,11 +192,11 @@ journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
## 7. 已知限制(当前阶段)
## 8. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
+40
View File
@@ -501,3 +501,43 @@
### 7.6 没做的
- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出)
- 手机号换绑、头像上传未做(头像现在只有首字母)
## 8. Web 装到桌面(PWA,2026-10-31)
### 8.1 要解决的问题
"web版本帮我封装成PWA,这样方便安装到手机桌面和电脑桌面。"(用户原话)
现在每次用都要敲地址、开浏览器、找标签页;手机上还给不了"点了图标就进来"的感觉。
### 8.2 决定(写进注释了,别以后翻案)
- 用 `vite-plugin-pwa`(本就在 devDependencies 里,2.0.0)生成 SW 与 manifest,不自己写
- `registerType: "prompt"` + `injectRegister: null`:**有新版只提示,点「刷新」才换**。
理由:Vditor、图表是懒加载分片,`autoUpdate` 会在用户开着页面时把 chunk 换掉,
下一次点开面板就是 404(编辑器白屏)
- 预缓存只放构建产物与图标(约 0.8MB);`/api/` 一律 NetworkOnly——离线宁可报错,也不端旧数据
- 图标复用 Web Logo 的 32 单位坐标生成,和页头、安卓桌面图标同一枚
### 8.3 实现
| 位置 | 内容 |
|---|---|
| `web/vite.config.ts` | manifest(standalone、主题色 `#2f6054`、三个图标)+ workbox(`navigateFallback` 回退 `index.html`,`/vditor/` 走 CacheFirst,`/api/` NetworkOnly) |
| `web/index.html` | favicon 改成同款 Logo(内联 SVG)、`theme-color`、`apple-touch-icon`、`viewport-fit=cover` |
| `web/src/lib/pwa.ts` | 生产环境注册 SW、`dadao:pwa-update-ready` 事件、`applyServiceWorkerUpdate()`、`isStandalone()` |
| `web/src/components/UpdatePrompt.tsx` | 底部卡片「有新版了」+ 稍后 / 刷新(**居中用 flex 包一层**:`animate-slide-up` 的 keyframes 自带 transform,会和 `-translate-x-1/2` 抢属性,手机窄屏上卡片会被推出屏幕右侧) |
| `web/src/components/InstallHint.tsx` | 首页底部安装引导:能装就给「安装」按钮(拦 `beforeinstallprompt`),iOS 给"分享 → 添加到主屏幕",关掉记在 localStorage |
| `web/public/icons/` | `icon-192` / `icon-512`(any)、`maskable-512`、`apple-touch-icon`(180,iOS 不吃透明) |
### 8.4 验证
- `web/e2e/pwa.mjs`(**19 项全绿**):manifest 名字/standalone/三个图标 → 图标都能下载 200 →
SW 已 `activated` 且作用域是整站 → 页面骨架已进预缓存 → 主题色 → 桌面尺寸控制台零报错 →
窄屏有安装引导 → iOS 文案是「分享 → 添加到主屏幕」→ 关掉后刷新不再出现 →
有新版会提示 → 点「稍后」收起 → **断网后页面骨架仍能打开**
- 跑法:后端用 `WEB_DIST_DIR=<repo>/web/dist` 起一个同构站点(生产同形),
然后 `node web/e2e/pwa.mjs`;`BASE` 可换环境
- 截图:`web/e2e/artifacts/pwa-install-hint.png`、`pwa-update-prompt.png`、`pwa-offline.png`
### 8.5 还没解开的
- **生产入口还是 http**:Service Worker 只在 https / localhost 注册,所以内网 IP 上装不了、
也看不到安装提示(桌面用 `127.0.0.1` 打开可以)。要么加域名 + 证书反代(`443 → 127.0.0.1:8200`),
要么用 Cloudflare Tunnel 之类拿个 https 域名;应用代码不用改。已写进 `DEPLOY.md` 第 6 节
- 安卓端还在走自己的 OTA(原生包),和 PWA 是两条路;要不要让安卓也改成加载 Web,等产品定
+33 -17
View File
@@ -1,8 +1,8 @@
/**
* 生产只读核对:首页外壳、账号页、工作区页的宽度是否一致。
* 生产只读核对:首页、工作区页、账号页是不是同一套外壳,宽度是否一致。
*
* 这个脚本**不写数据**:不设密码、不改昵称,只用验证码登录看界面。
* 生产是内测模式(任意手机号 + 123456),所以用真实账号也能进。
* 只读:不设密码、不改昵称,只看界面。生产是内测模式(验证码 123456)。
* 注意发码有 60 秒节流:被挡住时要等,别把它当成登录坏了。
*/
import { chromium } from "playwright-core";
@@ -18,21 +18,26 @@ const browser = await chromium.launch({
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("request", (r) => r.url().includes("/api") && console.log("req:", r.method(), r.url()));
page.on("response", (r) => r.url().includes("/auth/") && console.log("resp:", r.status(), r.url()));
page.on("requestfailed", (r) => r.url().includes("/auth/") && console.log("reqfailed:", r.failure()?.errorText, r.url()));
await page.goto(`${WEB}/login`);
await page.getByLabel("手机号").fill(PHONE);
await page.getByRole("button", { name: "获取验证码" }).click();
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForTimeout(4000);
console.log("url after login:", page.url());
console.log("body:", (await page.locator("body").innerText()).replace(/\s+/g, " | ").slice(0, 400));
await page.waitForURL(`${WEB}/`, { timeout: 20_000 });
await page.waitForSelector("text=我的工作区");
for (let attempt = 1; attempt <= 3 && new URL(page.url()).pathname === "/login"; attempt += 1) {
await page.getByRole("button", { name: /获取验证码|重新获取|秒/ }).click();
await page.waitForTimeout(1500);
const text = await page.locator("body").innerText();
if (/秒后再试|太频繁/.test(text)) {
console.log(`发码被节流,等 61 秒(第 ${attempt} 次)`);
await page.waitForTimeout(61_000);
continue;
}
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForTimeout(2500);
}
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
await page.waitForSelector("text=我的工作区");
const homeRail = await page.locator("aside").first().boundingBox();
console.log("首页侧栏宽度:", homeRail?.width);
await page.screenshot({ path: "e2e/artifacts/prod-home.png" });
@@ -41,14 +46,25 @@ await page.getByRole("button", { name: /我的备忘/ }).first().click();
await page.waitForURL(/\/w\//);
await page.waitForSelector("aside");
const wsRail = await page.locator("aside").first().boundingBox();
console.log("工作区侧栏宽度:", wsRail?.width, "差值:", Math.abs((wsRail?.width ?? 0) - (homeRail?.width ?? 0)));
console.log(
"工作区侧栏宽度:",
wsRail?.width,
"与首页差值:",
Math.abs((wsRail?.width ?? 0) - (homeRail?.width ?? 0)),
);
await page.screenshot({ path: "e2e/artifacts/prod-workspace.png" });
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
await page.waitForFunction(
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
null,
{ timeout: 10_000 },
);
const accountRail = await page.locator("aside").first().boundingBox();
console.log("账号页侧栏宽度:", accountRail?.width);
const bodyText = await page.locator("main").innerText();
console.log("账号页文案片段:", bodyText.replace(/\n+/g, " | ").slice(0, 220));
const accountText = await page.locator("main").innerText();
console.log("账号页:", accountText.replace(/\s+/g, " | ").slice(0, 260));
await page.screenshot({ path: "e2e/artifacts/prod-account.png" });
console.log("控制台报错:", errors.length === 0 ? "无" : errors.slice(0, 3));
+180
View File
@@ -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
View File
@@ -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>
+4255 -17
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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

+2
View File
@@ -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>
);
+99
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+54
View File
@@ -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
);
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -13,7 +13,7 @@
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["vite/client"]
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src"]
}
+60 -1
View File
@@ -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,