From 8ef4c436010981e6c1dee88f583687f621d18787 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:00:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E7=95=8C=E9=9D=A2=E6=B2=A1=E6=9C=89=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=9A=84=E5=A4=8D=E7=8E=B0=E7=94=A8=E4=BE=8B=EF=BC=8C?= =?UTF-8?q?mock=20=E5=8A=A0=E6=8B=92=E7=BB=9D=E6=8E=A8=E9=80=81=E6=8F=A1?= =?UTF-8?q?=E6=89=8B=E7=9A=84=E5=BC=80=E5=85=B3=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/e2e/push-banner.spec.ts | 72 ++++++++++++++++++++++++++++ packages/web/mock/routes/test.ts | 16 +++++++ packages/web/mock/server.ts | 6 ++- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/web/e2e/push-banner.spec.ts diff --git a/packages/web/e2e/push-banner.spec.ts b/packages/web/e2e/push-banner.spec.ts new file mode 100644 index 00000000..8e527c73 --- /dev/null +++ b/packages/web/e2e/push-banner.spec.ts @@ -0,0 +1,72 @@ +import { expect, login, openBot, test, type Page } from "./fixtures"; + +declare global { + interface Window { + __pushBannerSeen?: boolean; + } +} + +const GRACE_MS = 2_000; + +const refusePush = async (page: Page, refused: boolean) => { + expect((await page.request.post("/api/test/sockets/refuse", { data: { refused } })).status()).toBe(204); +}; + +const dropPush = async (page: Page) => { + expect((await page.request.post("/api/test/sockets/drop")).status()).toBe(204); +}; + +const postMissed = async (page: Page, text: string) => { + const posted = await page.request.post("/api/test/messages", { data: { groupId: "grp_sales", text } }); + expect(posted.status(), "断线时服务端多了一条推不过来的消息").toBe(204); +}; + +test("推送断线超过宽限期,顶上说连不上、正在重试;连回来提示消失,断线期间的消息补拉出来", async ({ page }) => { + const MISSED = "推送断着的时候别的窗口说的话"; + await login(page); + await openBot(page, "小销"); + const list = page.getByTestId("message-list"); + await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0); + const banner = page.getByTestId("push-banner"); + await expect(banner, "连着的时候不提示").toHaveCount(0); + + await refusePush(page, true); + await dropPush(page); + await expect(banner, "断线超过宽限期就提示").toBeVisible(); + await expect(banner).toContainText("连不上"); + await expect(banner).toContainText("重试"); + await expect( + page.locator("[aria-live=polite]").filter({ has: banner }), + "提示放在礼貌播报的区域里,读屏会念出来", + ).toHaveCount(1); + + await postMissed(page, MISSED); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "断着的时候推不过来").toHaveCount(0); + await expect(banner, "一直连不上就一直提示").toBeVisible(); + + await refusePush(page, false); + await expect(banner, "连回来提示就收起").toHaveCount(0, { timeout: 15_000 }); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "连回来补拉出断线期间的消息").toHaveCount(1); +}); + +test("推送断一下马上连回来,提示不闪出来", async ({ page }) => { + const MISSED = "一闪而过的断线里说的话"; + await page.addInitScript(() => { + window.__pushBannerSeen = false; + new MutationObserver(() => { + if (document.querySelector("[data-testid=push-banner]") !== null) window.__pushBannerSeen = true; + }).observe(document, { childList: true, subtree: true }); + }); + await login(page); + await openBot(page, "小销"); + const list = page.getByTestId("message-list"); + await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0); + + const droppedAt = Date.now(); + await dropPush(page); + await postMissed(page, MISSED); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "推送已经连回来了").toHaveCount(1); + expect(Date.now() - droppedAt, "这一趟重连要快过宽限期,否则测不出闪不闪").toBeLessThan(GRACE_MS); + await page.waitForTimeout(droppedAt + GRACE_MS + 500 - Date.now()); + expect(await page.evaluate(() => window.__pushBannerSeen), "宽限期内连回来,提示一次都没出现过").toBe(false); +}); diff --git a/packages/web/mock/routes/test.ts b/packages/web/mock/routes/test.ts index cba51e57..d7d3e18a 100644 --- a/packages/web/mock/routes/test.ts +++ b/packages/web/mock/routes/test.ts @@ -92,10 +92,14 @@ const TestDeltaRequest = z const LegacyDescriptionRequest = z.object({ botId: BotId, description: z.string() }).strict(); const ManyPreferencesRequest = z.object({ botId: BotId, count: z.number().int().min(1).max(100) }).strict(); const ComputerStatusRequest = z.object({ status: ComputerStatus }).strict(); +const PushRefusalRequest = z.object({ refused: z.boolean() }).strict(); const activeTurns = new Map>(); const turnEpochs = new Map(); +let pushRefused = false; + export const currentTurns = (): Iterable> => activeTurns.values(); +export const refusingPush = (): boolean => pushRefused; export const testRoutes: Route = async ({ req, res, path, method, push }) => { if (method === "GET" && path === "/api/health") { @@ -109,11 +113,23 @@ export const testRoutes: Route = async ({ req, res, path, method, push }) => { resetRegisteredEmails(); resetLive(); activeTurns.clear(); + pushRefused = false; turnEpochs.clear(); send(res, 204); return true; } + // 掐断推送之后客户端一秒内就重连上了,要让断线持续下去,得先让后面的握手都被拒掉。 + if (method === "POST" && path === "/api/test/sockets/refuse") { + const parsed = PushRefusalRequest.safeParse(await readBody(req)); + if (!parsed.success) send(res, 400, { error: "要写明拒不拒推送连接" }); + else { + pushRefused = parsed.data.refused; + send(res, 204); + } + return true; + } + // 写入上限收紧之前存下的长描述,PATCH 造不出来,只能从这里直接种。 if (method === "POST" && path === "/api/test/bot-description") { const parsed = LegacyDescriptionRequest.safeParse(await readBody(req)); diff --git a/packages/web/mock/server.ts b/packages/web/mock/server.ts index 4a2dd284..478c2bd6 100644 --- a/packages/web/mock/server.ts +++ b/packages/web/mock/server.ts @@ -20,7 +20,7 @@ import { hookRoutes, routineRoutes } from "./routes/routines.js"; import { skillRoutes } from "./routes/skills.js"; import { teamOfferRoutes } from "./routes/team-offers.js"; import { templateRoutes } from "./routes/templates.js"; -import { currentTurns, testRoutes } from "./routes/test.js"; +import { currentTurns, refusingPush, testRoutes } from "./routes/test.js"; import { todayRoutes } from "./routes/today.js"; import { toolRoutes } from "./routes/tools.js"; @@ -99,6 +99,10 @@ server.on("upgrade", (req, socket, head) => { socket.destroy(); return; } + if (live === null && refusingPush()) { + socket.destroy(); + return; + } if (live !== null) { wss.handleUpgrade(req, socket, head, (ws) => attachLiveViewer(ws, live[1] ?? "", push)); return; From 5507045470a0f4c73cc28d11c5243d7310fac816 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:04:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=8E=A8=E9=80=81=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=BF=9B=20store=EF=BC=8C=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E8=B6=85=E8=BF=87=202=20=E7=A7=92=E9=A1=B6=E4=B8=8A=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=BF=9E=E4=B8=8D=E4=B8=8A=E3=80=81=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E9=87=8D=E8=AF=95=EF=BC=8C=E8=BF=9E=E5=9B=9E=E6=9D=A5=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/src/components/PushBanner.tsx | 30 ++++++++++++++++++++++ packages/web/src/routes/MainWindow.tsx | 2 ++ packages/web/src/state/app.tsx | 2 ++ packages/web/src/state/reducer.ts | 2 ++ packages/web/src/state/types.ts | 5 ++++ packages/web/src/styles/03-status.css | 27 +++++++++++++++++++ 6 files changed, 68 insertions(+) create mode 100644 packages/web/src/components/PushBanner.tsx diff --git a/packages/web/src/components/PushBanner.tsx b/packages/web/src/components/PushBanner.tsx new file mode 100644 index 00000000..f48090c5 --- /dev/null +++ b/packages/web/src/components/PushBanner.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { WifiOff } from "lucide-react"; +import type { PushLink } from "../state/types.js"; + +// 掐断后第一次重连在 1 秒后,顺利的话这之内就连回来了,不值得打扰人。 +const GRACE_MS = 2_000; + +// 播报区域一直挂着,读屏要先认识这块区域,后来塞进去的字才会念。 +export const PushBanner = ({ link }: { link: PushLink }) => { + const [lagging, setLagging] = useState(false); + useEffect(() => { + if (link === "open") return; + const timer = setTimeout(() => setLagging(true), GRACE_MS); + return () => { + clearTimeout(timer); + setLagging(false); + }; + }, [link]); + + return ( +
+ {lagging ? ( +
+
+ ) : null} +
+ ); +}; diff --git a/packages/web/src/routes/MainWindow.tsx b/packages/web/src/routes/MainWindow.tsx index 3303eb93..4ad95be0 100644 --- a/packages/web/src/routes/MainWindow.tsx +++ b/packages/web/src/routes/MainWindow.tsx @@ -13,6 +13,7 @@ import { SettingsWindow } from "../components/SettingsWindow.js"; import { TaskPanel } from "../components/TaskPanel.js"; import { ToolInspector } from "../components/ToolInspector.js"; import { BotCreatedNotice } from "../components/BotCreatedNotice.js"; +import { PushBanner } from "../components/PushBanner.js"; import { LiveHost } from "../live/LiveHost.js"; import { TodayPage } from "./TodayPage.js"; import { PokerProjectPage } from "./PokerProjectPage.js"; @@ -233,6 +234,7 @@ export const MainWindow = () => { return (
+ { socket = new WebSocket(`${scheme}://${window.location.host}/api/ws`); socket.addEventListener("open", () => { failures = 0; + put({ type: "push_link", link: "open" }); if (hasConnected) { put({ type: "push_reconnected", seq: nextSeq() }); refreshPendingCounts(); @@ -1031,6 +1032,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { // 分不出是登录过期还是断网,拿这次会话打一下 /api/me 问清楚:401 回登录页,别的按退避再连。 socket.addEventListener("close", () => { if (!live()) return; + put({ type: "push_link", link: "connecting" }); scoped.api.me().then( () => { if (live()) retryLater(); diff --git a/packages/web/src/state/reducer.ts b/packages/web/src/state/reducer.ts index dd290638..d8bc991d 100644 --- a/packages/web/src/state/reducer.ts +++ b/packages/web/src/state/reducer.ts @@ -58,6 +58,7 @@ const enteredState = (action: Extract): ReadyState => usage, content: EMPTY_CONTENT, todayVersion: 0, + pushLink: "connecting", reconnectedAt: 0 as Seq, messagesLoadedAt: {}, messageGaps: {}, @@ -91,6 +92,7 @@ export const reduce = (state: AppState, action: Action): AppState => { reconnectedAt: action.seq, messageGaps: rearmGaps(state.messageGaps), }; + if (action.type === "push_link") return { ...state, pushLink: action.link }; if (action.type === "load_status") return { ...state, loads: { ...state.loads, [action.key]: action.status } }; const groups = reduceGroups(state, action); const approvals = reduceApprovals(groups, action); diff --git a/packages/web/src/state/types.ts b/packages/web/src/state/types.ts index ce5f5dc6..536fb6e8 100644 --- a/packages/web/src/state/types.ts +++ b/packages/web/src/state/types.ts @@ -143,6 +143,8 @@ export type GroupTurn = Extract; export type TeamOfferOutcome = "confirmed" | "dismissed"; +export type PushLink = "connecting" | "open"; + export type AppState = | { status: "loading" } | { status: "anonymous"; login: LoginPhase; register: LoginPhase } @@ -188,6 +190,8 @@ export type AppState = usage: UsageResponse; content: ContentBook; todayVersion: number; + // 推送连接没开着时都在连:首次握手、断线后的退避等待和重连握手。 + pushLink: PushLink; // 推送最近一次重连时取的号,messagesLoadedAt 是每个群的历史请求发出时取的号。比重连早的历史可能漏了断线时的推送。 reconnectedAt: Seq; messagesLoadedAt: Record; @@ -293,6 +297,7 @@ export type Action = | { type: "publications_loaded"; publications: Publication[] } | { type: "today_version_bumped" } | { type: "push_reconnected"; seq: Seq } + | { type: "push_link"; link: PushLink } | { type: "load_status"; key: LoadKey; status: LoadStatus } | { type: "from_session"; session: SessionId; action: Action }; diff --git a/packages/web/src/styles/03-status.css b/packages/web/src/styles/03-status.css index 996ce552..4b37918f 100644 --- a/packages/web/src/styles/03-status.css +++ b/packages/web/src/styles/03-status.css @@ -71,3 +71,30 @@ animation: none; } } + +.push-status { + position: fixed; + top: var(--space-6); + left: 50%; + z-index: 3; + max-width: calc(100vw - 2 * var(--space-6)); + pointer-events: none; + transform: translateX(-50%); +} + +.push-banner { + display: flex; + gap: var(--space-4); + align-items: center; + padding: var(--space-3) var(--space-6); + border-radius: var(--radius-full); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); + color: var(--fg-1); + font: var(--type-ui); +} + +.push-banner svg { + flex: none; + color: var(--warning); +} From 5ad62a0ad3def20598ffdfeb2d670e5c8e900c00 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:36:41 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=AE=BD=E9=99=90?= =?UTF-8?q?=E6=9C=9F=E8=B7=9F=E7=9D=80=E9=A6=96=E6=AC=A1=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E7=9A=84=E9=80=80=E9=81=BF=E8=B5=B0=EF=BC=8C=E6=B2=A1=E8=BF=9E?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E7=8A=B6=E6=80=81=E5=8F=AB=20down=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/e2e/push-banner.spec.ts | 3 +-- packages/web/mock/routes/test.ts | 1 - packages/web/src/components/PushBanner.tsx | 6 ++---- packages/web/src/state/app.tsx | 2 +- packages/web/src/state/reconnect.ts | 3 +++ packages/web/src/state/reducer.ts | 2 +- packages/web/src/state/types.ts | 3 +-- 7 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/web/e2e/push-banner.spec.ts b/packages/web/e2e/push-banner.spec.ts index 8e527c73..7b49839e 100644 --- a/packages/web/e2e/push-banner.spec.ts +++ b/packages/web/e2e/push-banner.spec.ts @@ -1,3 +1,4 @@ +import { PUSH_GRACE_MS as GRACE_MS } from "../src/state/reconnect.js"; import { expect, login, openBot, test, type Page } from "./fixtures"; declare global { @@ -6,8 +7,6 @@ declare global { } } -const GRACE_MS = 2_000; - const refusePush = async (page: Page, refused: boolean) => { expect((await page.request.post("/api/test/sockets/refuse", { data: { refused } })).status()).toBe(204); }; diff --git a/packages/web/mock/routes/test.ts b/packages/web/mock/routes/test.ts index d7d3e18a..cd6d44be 100644 --- a/packages/web/mock/routes/test.ts +++ b/packages/web/mock/routes/test.ts @@ -119,7 +119,6 @@ export const testRoutes: Route = async ({ req, res, path, method, push }) => { return true; } - // 掐断推送之后客户端一秒内就重连上了,要让断线持续下去,得先让后面的握手都被拒掉。 if (method === "POST" && path === "/api/test/sockets/refuse") { const parsed = PushRefusalRequest.safeParse(await readBody(req)); if (!parsed.success) send(res, 400, { error: "要写明拒不拒推送连接" }); diff --git a/packages/web/src/components/PushBanner.tsx b/packages/web/src/components/PushBanner.tsx index f48090c5..479f19d5 100644 --- a/packages/web/src/components/PushBanner.tsx +++ b/packages/web/src/components/PushBanner.tsx @@ -1,16 +1,14 @@ import { useEffect, useState } from "react"; import { WifiOff } from "lucide-react"; +import { PUSH_GRACE_MS } from "../state/reconnect.js"; import type { PushLink } from "../state/types.js"; -// 掐断后第一次重连在 1 秒后,顺利的话这之内就连回来了,不值得打扰人。 -const GRACE_MS = 2_000; - // 播报区域一直挂着,读屏要先认识这块区域,后来塞进去的字才会念。 export const PushBanner = ({ link }: { link: PushLink }) => { const [lagging, setLagging] = useState(false); useEffect(() => { if (link === "open") return; - const timer = setTimeout(() => setLagging(true), GRACE_MS); + const timer = setTimeout(() => setLagging(true), PUSH_GRACE_MS); return () => { clearTimeout(timer); setLagging(false); diff --git a/packages/web/src/state/app.tsx b/packages/web/src/state/app.tsx index 98dc56f3..3a595466 100644 --- a/packages/web/src/state/app.tsx +++ b/packages/web/src/state/app.tsx @@ -1032,7 +1032,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { // 分不出是登录过期还是断网,拿这次会话打一下 /api/me 问清楚:401 回登录页,别的按退避再连。 socket.addEventListener("close", () => { if (!live()) return; - put({ type: "push_link", link: "connecting" }); + put({ type: "push_link", link: "down" }); scoped.api.me().then( () => { if (live()) retryLater(); diff --git a/packages/web/src/state/reconnect.ts b/packages/web/src/state/reconnect.ts index 46f3b989..59c7b0b6 100644 --- a/packages/web/src/state/reconnect.ts +++ b/packages/web/src/state/reconnect.ts @@ -3,3 +3,6 @@ const MAX_MS = 30_000; // 推送连着失败 failures 次之后等多久再连:1 秒起,每失败一次翻倍,封顶 30 秒。 export const reconnectDelay = (failures: number): number => Math.min(FIRST_MS * 2 ** failures, MAX_MS); + +// 断线后至少要等过第一次重连,还没连回来才值得提示。 +export const PUSH_GRACE_MS = 2 * reconnectDelay(0); diff --git a/packages/web/src/state/reducer.ts b/packages/web/src/state/reducer.ts index d8bc991d..dfed9dea 100644 --- a/packages/web/src/state/reducer.ts +++ b/packages/web/src/state/reducer.ts @@ -58,7 +58,7 @@ const enteredState = (action: Extract): ReadyState => usage, content: EMPTY_CONTENT, todayVersion: 0, - pushLink: "connecting", + pushLink: "down", reconnectedAt: 0 as Seq, messagesLoadedAt: {}, messageGaps: {}, diff --git a/packages/web/src/state/types.ts b/packages/web/src/state/types.ts index 536fb6e8..33d744df 100644 --- a/packages/web/src/state/types.ts +++ b/packages/web/src/state/types.ts @@ -143,7 +143,7 @@ export type GroupTurn = Extract; export type TeamOfferOutcome = "confirmed" | "dismissed"; -export type PushLink = "connecting" | "open"; +export type PushLink = "down" | "open"; export type AppState = | { status: "loading" } @@ -190,7 +190,6 @@ export type AppState = usage: UsageResponse; content: ContentBook; todayVersion: number; - // 推送连接没开着时都在连:首次握手、断线后的退避等待和重连握手。 pushLink: PushLink; // 推送最近一次重连时取的号,messagesLoadedAt 是每个群的历史请求发出时取的号。比重连早的历史可能漏了断线时的推送。 reconnectedAt: Seq; From eeaacc6e2ffeae945e5324232cc7329a58763b37 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 21:18:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=96=AD=E7=BA=BF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=86=85=E5=AE=B9=E5=8C=BA=E9=A1=B6=E4=B8=8A?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=9A=84=E6=95=B4=E6=9D=A1=E6=A8=AA=E5=B9=85?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8E=8B=E4=BD=8F=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=A4=B4=E6=8C=89=E9=92=AE=EF=BC=8C=E6=B5=AE=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AE=A9=E5=88=B0=E5=AE=83=E4=B8=8B=E9=9D=A2=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/e2e/push-banner.spec.ts | 37 ++++++++++++++++++++++++++ packages/web/src/styles/03-status.css | 30 +++++++++++++-------- packages/web/src/styles/04-sidebar.css | 2 ++ 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/web/e2e/push-banner.spec.ts b/packages/web/e2e/push-banner.spec.ts index 7b49839e..247205c6 100644 --- a/packages/web/e2e/push-banner.spec.ts +++ b/packages/web/e2e/push-banner.spec.ts @@ -1,4 +1,5 @@ import { PUSH_GRACE_MS as GRACE_MS } from "../src/state/reconnect.js"; +import type { Locator } from "@playwright/test"; import { expect, login, openBot, test, type Page } from "./fixtures"; declare global { @@ -15,6 +16,17 @@ const dropPush = async (page: Page) => { expect((await page.request.post("/api/test/sockets/drop")).status()).toBe(204); }; +const boxOf = async (locator: Locator) => { + const box = await locator.boundingBox(); + if (box === null) throw new Error("元素没有渲染出来"); + return box; +}; + +const overlaps = async (a: Locator, b: Locator) => { + const [x, y] = [await boxOf(a), await boxOf(b)]; + return x.x < y.x + y.width && y.x < x.x + x.width && x.y < y.y + y.height && y.y < x.y + x.height; +}; + const postMissed = async (page: Page, text: string) => { const posted = await page.request.post("/api/test/messages", { data: { groupId: "grp_sales", text } }); expect(posted.status(), "断线时服务端多了一条推不过来的消息").toBe(204); @@ -38,6 +50,16 @@ test("推送断线超过宽限期,顶上说连不上、正在重试;连回 page.locator("[aria-live=polite]").filter({ has: banner }), "提示放在礼貌播报的区域里,读屏会念出来", ).toHaveCount(1); + for (const [testId, name] of [ + ["bot-edit", "编辑"], + ["bot-share", "分享"], + ] as const) { + const button = page.getByTestId(testId); + await expect(button, `断线时聊天头的「${name}」还看得见`).toBeVisible(); + await expect(button).toBeEnabled(); + await button.click({ trial: true }); + expect(await overlaps(banner, button), `断线提示不压在「${name}」上`).toBe(false); + } await postMissed(page, MISSED); await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "断着的时候推不过来").toHaveCount(0); @@ -69,3 +91,18 @@ test("推送断一下马上连回来,提示不闪出来", async ({ page }) => await page.waitForTimeout(droppedAt + GRACE_MS + 500 - Date.now()); expect(await page.evaluate(() => window.__pushBannerSeen), "宽限期内连回来,提示一次都没出现过").toBe(false); }); + +test("断线提示和顶上的浮动提示同时出现时各占各的位置,不叠在一起", async ({ page }) => { + await login(page); + await page.goto("/?oauth=x&result=failed"); + const notice = page.getByTestId("oauth-notice"); + await expect(notice).toBeVisible(); + + await refusePush(page, true); + await dropPush(page); + const banner = page.getByTestId("push-banner"); + await expect(banner).toBeVisible(); + await expect(notice, "断线不会把授权结果的提示挤掉").toBeVisible(); + expect(await overlaps(banner, notice), "授权结果的提示让到断线提示下面").toBe(false); + await notice.getByTestId("oauth-notice-close").click({ trial: true }); +}); diff --git a/packages/web/src/styles/03-status.css b/packages/web/src/styles/03-status.css index 4b37918f..bcf9e8fe 100644 --- a/packages/web/src/styles/03-status.css +++ b/packages/web/src/styles/03-status.css @@ -72,29 +72,37 @@ } } +/* 断线提示可能一直挂着,占住内容区顶上一行,不浮在聊天头的按钮上。定高不换行,浮动提示才知道该让多少。 */ .push-status { - position: fixed; - top: var(--space-6); - left: 50%; - z-index: 3; - max-width: calc(100vw - 2 * var(--space-6)); - pointer-events: none; - transform: translateX(-50%); + grid-column: 2 / -1; + grid-row: 1; } .push-banner { display: flex; gap: var(--space-4); align-items: center; - padding: var(--space-3) var(--space-6); - border-radius: var(--radius-full); - background: var(--bg-elevated); - box-shadow: var(--shadow-lg); + justify-content: center; + height: var(--control-h-md); + padding: 0 var(--space-6); + overflow: hidden; + border-bottom: 1px solid var(--border-1); + background: var(--warning-soft); color: var(--fg-1); font: var(--type-ui); + white-space: nowrap; +} + +.push-banner span { + overflow: hidden; + text-overflow: ellipsis; } .push-banner svg { flex: none; color: var(--warning); } + +.window:has(.push-banner) > .oauth-notice { + top: calc(var(--control-h-md) + var(--space-6)); +} diff --git a/packages/web/src/styles/04-sidebar.css b/packages/web/src/styles/04-sidebar.css index 7df17434..7598dc57 100644 --- a/packages/web/src/styles/04-sidebar.css +++ b/packages/web/src/styles/04-sidebar.css @@ -3,12 +3,14 @@ .window { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--panel-w); + grid-template-rows: auto 1fr; height: 100%; background: var(--bg-app); } .sidebar { display: flex; + grid-row: 1 / -1; flex-direction: column; gap: var(--space-1); min-height: 0;