From e3f1310cffe368fe1694b170bea45fe15f6bc488 Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 31 Oct 2026 04:13:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=86=85=E7=BD=91=20http=20?= =?UTF-8?q?=E4=B8=8B=E5=86=99=E8=AF=B7=E6=B1=82=E4=B8=8E=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E9=93=BE=E6=8E=A5=E9=83=BD=E4=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crypto.randomUUID() 与 navigator.clipboard 只在安全上下文(https / localhost)里存在, 生产入口是纯 http,于是: - 所有 POST/PATCH/DELETE 在拼 Idempotency-Key 时直接抛 TypeError, 登录页只显示"登录失败",GET 一切正常,所以这个坑一直没暴露 - 「复制邀请链接」永远回"浏览器不允许自动复制" 改成幂等键自己拼一个 v4(getRandomValues 不受安全上下文限制,退回 Math.random), 复制走 navigator.clipboard → document.execCommand("copy") 两级降级。 顺带说明:这是生产上"能看不能写"的真正原因,不是权限或接口问题。 --- web/e2e/prod-account-check.mjs | 55 ++++++++++++++++++++++++++++++++++ web/src/lib/client.ts | 27 +++++++++++++++-- web/src/lib/clipboard.ts | 34 +++++++++++++++++++++ web/src/pages/Workspace.tsx | 13 ++++---- 4 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 web/e2e/prod-account-check.mjs create mode 100644 web/src/lib/clipboard.ts diff --git a/web/e2e/prod-account-check.mjs b/web/e2e/prod-account-check.mjs new file mode 100644 index 0000000..a4b1cd1 --- /dev/null +++ b/web/e2e/prod-account-check.mjs @@ -0,0 +1,55 @@ +/** + * 生产只读核对:首页外壳、账号页、工作区页的宽度是否一致。 + * + * 这个脚本**不写数据**:不设密码、不改昵称,只用验证码登录看界面。 + * 生产是内测模式(任意手机号 + 123456),所以用真实账号也能进。 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://192.168.8.188:8200"; +const PHONE = process.env.PHONE ?? "13605513683"; +const CODE = process.env.CODE ?? "123456"; + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, +}); +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=我的工作区"); + +const homeRail = await page.locator("aside").first().boundingBox(); +console.log("首页侧栏宽度:", homeRail?.width); +await page.screenshot({ path: "e2e/artifacts/prod-home.png" }); + +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))); + +await page.goto(`${WEB}/account`); +await page.waitForSelector("text=登录密码"); +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)); +await page.screenshot({ path: "e2e/artifacts/prod-account.png" }); + +console.log("控制台报错:", errors.length === 0 ? "无" : errors.slice(0, 3)); +await browser.close(); diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index d00bf8d..51f6d30 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -48,6 +48,29 @@ export const session = { export const authExpired = new EventTarget(); +/** + * 幂等键。 + * + * 别直接用 `crypto.randomUUID()`:它只在**安全上下文**(https 或 localhost)里存在, + * 而内网生产入口是纯 http,那里 `crypto.randomUUID` 是 undefined,调它会把 + * 所有写请求变成 "登录失败" 这种莫名其妙的报错——GET 没事,所以特别难发现。 + * `getRandomValues` 不受这个限制,退回它自己拼一个 v4。 + */ +function newIdempotencyKey(): string { + const c = globalThis.crypto; + if (typeof c?.randomUUID === "function") return c.randomUUID(); + const bytes = new Uint8Array(16); + if (typeof c?.getRandomValues === "function") { + c.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; interface RequestOptions { @@ -68,7 +91,7 @@ async function raw(path: string, opts: RequestOptions = {}): Promise { const isWrite = method !== "GET"; if (isWrite) { - headers["Idempotency-Key"] = opts.idempotencyKey ?? crypto.randomUUID(); + headers["Idempotency-Key"] = opts.idempotencyKey ?? newIdempotencyKey(); } const resp = await fetch(`${BASE}${path}`, { @@ -118,7 +141,7 @@ export async function uploadFile(path: string, file: File): Promise { const headers: Record = { Accept: "application/json" }; const token = session.access; if (token) headers.Authorization = `Bearer ${token}`; - headers["Idempotency-Key"] = crypto.randomUUID(); + headers["Idempotency-Key"] = newIdempotencyKey(); const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form }); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 0000000..b8e63ca --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,34 @@ +/** + * 复制文本。 + * + * `navigator.clipboard` 只在**安全上下文**(https 或 localhost)里有,内网生产入口是 + * 纯 http,那里它是 undefined——直接调会抛错,用户看到的却是"浏览器不允许自动复制"。 + * 所以这里退回 `document.execCommand("copy")`:老,但在 http 下确实能用。 + */ +export async function copyText(text: string): Promise { + if (window.isSecureContext && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 权限被拒也走兜底,别直接判定失败 + } + } + + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.top = "-1000px"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + document.body.removeChild(area); + return ok; +} diff --git a/web/src/pages/Workspace.tsx b/web/src/pages/Workspace.tsx index dcae29a..6d2627a 100644 --- a/web/src/pages/Workspace.tsx +++ b/web/src/pages/Workspace.tsx @@ -12,6 +12,7 @@ import { useToast, } from "../components/ui"; import { ApiError, api } from "../lib/client"; +import { copyText } from "../lib/clipboard"; import type { Invitation, Member, Page, Workspace } from "../lib/types"; const ROLE_LABEL: Record = { @@ -94,14 +95,14 @@ export function WorkspacePage() { : ""; async function copyLink() { - try { - await navigator.clipboard.writeText(inviteLink); - setCopied(true); - toast("邀请链接已复制", "success"); - window.setTimeout(() => setCopied(false), 2000); - } catch { + const ok = await copyText(inviteLink); + if (!ok) { setError("浏览器不允许自动复制,请手动选中下面这行链接"); + return; } + setCopied(true); + toast("邀请链接已复制", "success"); + window.setTimeout(() => setCopied(false), 2000); } return (