Compare commits
2
Commits
429905892c
...
483b554c3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
483b554c3c | ||
|
|
370d8923c8 |
+31
-3
@@ -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`,或将来到对象存储 |
|
||||
|
||||
@@ -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,等产品定
|
||||
|
||||
@@ -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
@@ -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