From 0d9991c47fed0e43418d314d3ce62d2a80d8efd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 10:50:22 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=92=84=20=E5=A4=96=E9=83=A8=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=9B=B4=E6=96=B0=E8=AF=B7=E6=B1=82=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E6=9C=AC=E4=BC=9A=E8=AF=9D=E5=85=81=E8=AE=B8=E3=80=8D=E6=94=B6?= =?UTF-8?q?=E8=BF=9B=E4=B8=BB=E6=8C=89=E9=92=AE=E4=B8=8B=E6=8B=89=E8=8F=9C?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/external_access.json | 1 + src/locales/en-US/external_access.json | 1 + src/locales/ja-JP/external_access.json | 1 + src/locales/ko-KR/external_access.json | 1 + src/locales/pt-BR/external_access.json | 1 + src/locales/ru-RU/external_access.json | 1 + src/locales/tr-TR/external_access.json | 1 + src/locales/vi-VN/external_access.json | 1 + src/locales/zh-CN/external_access.json | 1 + src/locales/zh-TW/external_access.json | 1 + .../components/InstallActions.test.tsx | 30 +++++++++++++- .../install/components/InstallActions.tsx | 40 ++++++++----------- 12 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index 43e1e569f..d50871751 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Das Lesen des Quelltexts legt den Skriptinhalt offen. Bestätige, dass du dieser Anfrage vertraust.", "decision_reject": "Ablehnen", "decision_session_allow": "Für diese Sitzung erlauben", + "decision_update_session_allow": "Aktualisieren und für diese Sitzung erlauben", "decision_allow": "Erlauben", "err_operation_expired": "Diese Anfrage ist abgelaufen oder nicht mehr ausstehend.", "approve_install_banner": "Über „Externer Zugriff“ wurde die Installation dieses Skripts angefragt – deine Entscheidung ist erforderlich.", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index e8751aa31..b61350483 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Reading source exposes the script’s content. Confirm you trust this request.", "decision_reject": "Reject", "decision_session_allow": "Allow this session", + "decision_update_session_allow": "Update and allow this session", "decision_allow": "Allow", "err_operation_expired": "This request has expired or is no longer pending.", "approve_install_banner": "Requested to install this script via External Access — your decision is required.", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index e98c0b00a..08af92c23 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "ソースを読み取るとスクリプトの内容が公開されます。この要求を信頼できるか確認してください。", "decision_reject": "拒否", "decision_session_allow": "このセッションで許可", + "decision_update_session_allow": "更新してこのセッションで許可", "decision_allow": "許可", "err_operation_expired": "この要求は期限切れか、保留中ではありません。", "approve_install_banner": "外部アクセスからこのスクリプトのインストールが要求されました。判断が必要です。", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index 64d95097a..531adb908 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "소스를 읽으면 스크립트 내용이 노출됩니다. 이 요청을 신뢰하는지 확인하세요.", "decision_reject": "거부", "decision_session_allow": "이 세션에서 허용", + "decision_update_session_allow": "업데이트하고 이 세션에서 허용", "decision_allow": "허용", "err_operation_expired": "이 요청이 만료되었거나 더 이상 대기 중이 아닙니다.", "approve_install_banner": "외부 접근에서 이 스크립트 설치를 요청했습니다. 결정이 필요합니다.", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index c4eefc547..575d40a2c 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Ler o código-fonte expõe o conteúdo do script. Confirme que você confia nesta solicitação.", "decision_reject": "Rejeitar", "decision_session_allow": "Permitir nesta sessão", + "decision_update_session_allow": "Atualizar e permitir nesta sessão", "decision_allow": "Permitir", "err_operation_expired": "Esta solicitação expirou ou não está mais pendente.", "approve_install_banner": "Instalação deste script solicitada via «Acesso externo» — sua decisão é necessária.", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index f9c45fe1c..972ece9ee 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Чтение исходного кода раскрывает содержимое скрипта. Подтвердите, что доверяете этому запросу.", "decision_reject": "Отклонить", "decision_session_allow": "Разрешить в этом сеансе", + "decision_update_session_allow": "Обновить и разрешить в этом сеансе", "decision_allow": "Разрешить", "err_operation_expired": "Этот запрос истёк или больше не ожидает обработки.", "approve_install_banner": "Через «Внешний доступ» запрошена установка этого скрипта — требуется ваше решение.", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index d7bb436bf..9026fce72 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Kaynağı okumak betiğin içeriğini açığa çıkarır. Bu isteğe güvendiğinizi onaylayın.", "decision_reject": "Reddet", "decision_session_allow": "Bu oturumda izin ver", + "decision_update_session_allow": "Güncelle ve bu oturumda izin ver", "decision_allow": "İzin ver", "err_operation_expired": "Bu istek süresi doldu veya artık beklemede değil.", "approve_install_banner": "«Harici Erişim» üzerinden bu betiğin kurulması istendi — kararınız gerekiyor.", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index 20b2ac195..fa650861b 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "Đọc mã nguồn sẽ tiết lộ nội dung tập lệnh. Hãy xác nhận rằng bạn tin tưởng yêu cầu này.", "decision_reject": "Từ chối", "decision_session_allow": "Cho phép phiên này", + "decision_update_session_allow": "Cập nhật và cho phép phiên này", "decision_allow": "Cho phép", "err_operation_expired": "Yêu cầu này đã hết hạn hoặc không còn đang chờ xử lý.", "approve_install_banner": "Yêu cầu cài đặt tập lệnh này qua «Truy cập bên ngoài» — cần bạn quyết định.", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index b1e291cbd..b9a5a58a4 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "读取源码会暴露脚本内容,请确认你信任此请求。", "decision_reject": "拒绝", "decision_session_allow": "本会话允许", + "decision_update_session_allow": "更新并本会话允许", "decision_allow": "允许", "err_operation_expired": "该请求已过期或不再处于待处理状态。", "approve_install_banner": "通过「外部接入」请求安装此脚本 —— 需你决定。", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index f04c91fe0..9646ce778 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -55,6 +55,7 @@ "source_privacy_hint": "讀取原始碼會暴露腳本內容,請確認你信任此請求。", "decision_reject": "拒絕", "decision_session_allow": "本工作階段允許", + "decision_update_session_allow": "更新並於本工作階段允許", "decision_allow": "允許", "err_operation_expired": "此請求已逾期或不再處於待處理狀態。", "approve_install_banner": "透過「外部接入」請求安裝此腳本 —— 需你決定。", diff --git a/src/pages/install/components/InstallActions.test.tsx b/src/pages/install/components/InstallActions.test.tsx index 9d3bac7ef..9893094da 100644 --- a/src/pages/install/components/InstallActions.test.tsx +++ b/src/pages/install/components/InstallActions.test.tsx @@ -103,6 +103,32 @@ describe("InstallActions 操作区", () => { expect(onExternalAccessReject).toHaveBeenCalledTimes(1); }); + it("外部接入更新时「本会话允许」不再是独立按钮,而是主按钮下拉的首项,选中触发会话允许", async () => { + const p = baseProps(); + const onExternalAccessSessionAllow = vi.fn(); + render( + + ); + expect(screen.queryByTestId("external-access-session-allow")).not.toBeInTheDocument(); + await open(screen.getByTestId("install-more")); + const items = screen.getAllByRole("menuitem"); + expect(items[0]).toHaveTextContent("更新并本会话允许"); + fireEvent.click(items[0]); + expect(onExternalAccessSessionAllow).toHaveBeenCalledTimes(1); + expect(p.onInstall).not.toHaveBeenCalled(); + }); + + it("未提供会话允许回调时下拉不含「更新并本会话允许」", async () => { + render(); + await open(screen.getByTestId("install-more")); + expect(screen.queryByText("更新并本会话允许")).not.toBeInTheDocument(); + }); + it("外部接入触发时操作栏提示语切换为渠道说明(设计稿 ActionNote)", () => { render(); expect(screen.getByTestId("action-bar-note")).toHaveTextContent("由「外部接入」触发"); @@ -139,13 +165,15 @@ describe("InstallActions 主按钮就地状态机", () => { render( ); expect(screen.getByTestId("external-access-reject")).toBeDisabled(); - expect(screen.getByTestId("external-access-session-allow")).toBeDisabled(); + // 本会话允许位于主按钮下拉中,下拉触发器锁住即该入口锁住 + expect(screen.getByTestId("install-more")).toBeDisabled(); }); it("未传 phase 时保持原有的安装文案与可点状态", () => { diff --git a/src/pages/install/components/InstallActions.tsx b/src/pages/install/components/InstallActions.tsx index 0c93dfe4c..e433b12fd 100644 --- a/src/pages/install/components/InstallActions.tsx +++ b/src/pages/install/components/InstallActions.tsx @@ -148,30 +148,16 @@ export function InstallActions({ {onExternalAccessReject ? ( // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split - // 主按钮;此处只补前两档,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 - <> - - {onExternalAccessSessionAllow && ( - - )} - + // 主按钮,本会话允许收在其下拉首项;此处只补拒绝,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 + ) : isUpdate ? (
- )} +
+ {localFile && ( + + )} - {onExternalAccessReject ? ( - // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split - // 主按钮,本会话允许收在其下拉首项;此处只补拒绝,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 + {onExternalAccessReject ? ( + // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split + // 主按钮,本会话允许收在其下拉首项;此处只补拒绝,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。 + + ) : isUpdate ? ( +
- ) : isUpdate ? ( -
- - - onClose({ noMoreUpdates: true })}> - {t("install:close_update_script_no_more_update")} - - -
- ) : ( - - )} - -
- - {onExternalAccessSessionAllow && ( - - - {t("external_access:decision_update_session_allow")} - - )} - onInstall({ closeAfterInstall: false })}>{noCloseLabel} - {!isSubscribe && ( - onInstall({ noMoreUpdates: true })}> - {noMoreUpdateLabel} - - )} + onClose({ noMoreUpdates: true })}> + {t("install:close_update_script_no_more_update")} +
+ ) : ( + + )} + +
+ + + {onExternalAccessSessionAllow && ( + + + {t("external_access:decision_update_session_allow")} + + )} + onInstall({ closeAfterInstall: false })}>{noCloseLabel} + {!isSubscribe && ( + onInstall({ noMoreUpdates: true })}>{noMoreUpdateLabel} + )} +
); diff --git a/src/pages/install/components/InstallErrorBar.tsx b/src/pages/install/components/InstallErrorBar.tsx index 4460182c1..e383c1ed1 100644 --- a/src/pages/install/components/InstallErrorBar.tsx +++ b/src/pages/install/components/InstallErrorBar.tsx @@ -1,24 +1,30 @@ import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; +import { cn } from "@App/pkg/utils/cn"; export interface InstallErrorBarProps { message: string; onRetry?: () => void; + /** 嵌在内容列内(身份卡 / 桌面吸底栏)时用圆角框;否则贴满移动端吸底栏宽度 */ + inset?: boolean; } /** - * 贴在吸底操作栏正上方的安装失败条。失败时用户必须留在页面上做决定, + * 紧贴操作按钮的安装失败条。失败时用户必须留在页面上做决定, * 所以失败原因和重试入口都得常驻可读,不能交给会自动消失的飘窗。 */ -export function InstallErrorBar({ message, onRetry }: InstallErrorBarProps) { +export function InstallErrorBar({ message, onRetry, inset }: InstallErrorBarProps) { const { t } = useTranslation(["install", "common"]); return (
)} + + {slots?.note} ); } diff --git a/src/pages/install/components/SkillInstallView.test.tsx b/src/pages/install/components/SkillInstallView.test.tsx index 559f436a6..a68471184 100644 --- a/src/pages/install/components/SkillInstallView.test.tsx +++ b/src/pages/install/components/SkillInstallView.test.tsx @@ -1,5 +1,7 @@ -import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, within, act } from "@testing-library/react"; +import { mockMatchMedia } from "@Tests/mockMatchMedia"; +import { mockIntersectionObserver } from "@Tests/mockIntersectionObserver"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { SkillInstallView } from "./SkillInstallView"; @@ -89,3 +91,47 @@ describe("SkillInstallView 技能安装视图", () => { expect(screen.getByTestId("skill-warning-card")).toBeInTheDocument(); }); }); + +describe("SkillInstallView 操作位置", () => { + let io: ReturnType; + beforeEach(() => { + mockMatchMedia(false); + io = mockIntersectionObserver(); + }); + + it("桌面端关闭、安装与警示语位于技能身份卡内,首屏不渲染吸底栏", () => { + render(); + const card = screen.getByTestId("skill-identity"); + expect(within(card).getByTestId("skill-install")).toBeInTheDocument(); + expect(within(card).getByTestId("skill-cancel")).toBeInTheDocument(); + expect(within(card).getByTestId("action-note")).toHaveTextContent("技能会向 AI 注入提示词"); + expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument(); + }); + + it("桌面端身份卡操作组滚出后,吸底栏给出技能名与可用的安装按钮", () => { + const p = baseProps(); + render(); + act(() => { + io.setIntersecting(screen.getByTestId("card-actions"), false); + }); + const bar = screen.getByTestId("action-bar"); + expect(within(bar).getByText("网页摘要技能")).toBeInTheDocument(); + fireEvent.click(within(bar).getByTestId("skill-install")); + expect(p.onInstall).toHaveBeenCalledTimes(1); + }); + + it("桌面端安装失败时失败条在技能身份卡内", () => { + render(); + const card = screen.getByTestId("skill-identity"); + expect(within(card).getByTestId("install-error-bar-message")).toHaveTextContent("技能写入失败"); + }); + + it("移动端安装按钮与警示语仍在吸底栏", () => { + mockMatchMedia(true); + render(); + const bar = screen.getByTestId("action-bar"); + expect(within(bar).getByTestId("skill-install")).toBeInTheDocument(); + expect(within(bar).getByText(/技能会向 AI 注入提示词/)).toBeInTheDocument(); + expect(within(screen.getByTestId("skill-identity")).queryByTestId("skill-install")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/SkillInstallView.tsx b/src/pages/install/components/SkillInstallView.tsx index 04da7d412..58371fffa 100644 --- a/src/pages/install/components/SkillInstallView.tsx +++ b/src/pages/install/components/SkillInstallView.tsx @@ -17,7 +17,7 @@ import { Button } from "@App/pages/components/ui/button"; import { cn } from "@App/pkg/utils/cn"; import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; import type { SkillConfigField } from "@App/app/service/agent/core/types"; -import { InstallLayout } from "./InstallLayout"; +import { InstallLayout, InstallSummary, type ActionPlacement, type InstallFailure } from "./InstallLayout"; export interface SkillInstallViewProps { metadata: { name: string; description: string; version?: string; config?: Record }; @@ -30,8 +30,8 @@ export interface SkillInstallViewProps { phase?: "idle" | "installing" | "installed" | "failed"; /** 安装完成且即将自动关闭:整页淡出 */ closing?: boolean; - /** 吸底操作栏上方的内联失败条 */ - alert?: React.ReactNode; + /** 安装失败:失败条跟随当前可见的那组操作 */ + failure?: InstallFailure; onInstall: () => void; onCancel: () => void; } @@ -72,7 +72,7 @@ export function SkillInstallView({ installUrl, phase = "idle", closing, - alert, + failure, onInstall, onCancel, }: SkillInstallViewProps) { @@ -92,74 +92,102 @@ export function SkillInstallView({ ? t("install:skill_update") : t("install:skill_install"); + const buttons = (placement: ActionPlacement) => { + const size = placement === "card" ? "lg" : "default"; + return ( +
+ + +
+ ); + }; + return ( -

{t("install:skill_warning")}

-
- - -
-
+ note={t("install:skill_warning")} + summary={ + } + tone="skill" + name={metadata.name} + version={metadata.version} + /> } - > - {/* 身份卡 */} -
-
-
- + actions={(placement) => + // 移动端不渲染 note,警示语沿用吸底栏里与按钮同行的位置 + placement === "footer" ? ( +
+

{t("install:skill_warning")}

+ {buttons(placement)}
-
-
-

{metadata.name}

- {"Skill"} - {metadata.version && ( - {`v${metadata.version}`} - )} - {isUpdate && ( - - {t("install:update_script")} - - )} + ) : ( + buttons(placement) + ) + } + identity={(slots) => ( +
+
+
+
-
- {t("install:skill_kind")} - {installUrl && ( - - - {installUrl} - +
+
+

{metadata.name}

+ {"Skill"} + {metadata.version && ( + {`v${metadata.version}`} + )} + {isUpdate && ( + + {t("install:update_script")} + + )} +
+
+ {t("install:skill_kind")} + {installUrl && ( + + + {installUrl} + + )} +
+ {metadata.description && ( +

+ {metadata.description} +

)}
- {metadata.description && ( -

- {metadata.description} -

- )} + {slots?.actions}
-
-
- + {slots?.alert} + {slots?.note} +
+ )} + > {/* 提示词卡 */} {prompt && ( diff --git a/tests/mockIntersectionObserver.ts b/tests/mockIntersectionObserver.ts index ee0fe853c..3d6d1f6ae 100644 --- a/tests/mockIntersectionObserver.ts +++ b/tests/mockIntersectionObserver.ts @@ -1,14 +1,53 @@ -export function mockIntersectionObserver(): void { - globalThis.IntersectionObserver = class { - readonly root = null; +import { vi } from "vitest"; + +/** + * 可控的 IntersectionObserver 桩:happy-dom 不做布局,原生实现永远不会回调, + * 测试用 setIntersecting 手动推送某个被观察元素进出可视区。调用方负责用 act 包裹。 + */ +export function mockIntersectionObserver() { + const observers = new Set(); + + class MockIntersectionObserver { + readonly root: Element | Document | null; readonly rootMargin = ""; - readonly thresholds: ReadonlyArray = []; + readonly thresholds: ReadonlyArray = [0]; + readonly targets = new Set(); + + constructor( + private readonly callback: IntersectionObserverCallback, + init?: IntersectionObserverInit + ) { + this.root = init?.root ?? null; + observers.add(this); + } - observe() {} - unobserve() {} - disconnect() {} + observe(el: Element) { + this.targets.add(el); + } + unobserve(el: Element) { + this.targets.delete(el); + } + disconnect() { + this.targets.clear(); + observers.delete(this); + } takeRecords() { return []; } - } as unknown as typeof IntersectionObserver; + + notify(target: Element, isIntersecting: boolean) { + const entry = { target, isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }; + this.callback([entry as unknown as IntersectionObserverEntry], this as unknown as IntersectionObserver); + } + } + + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + + return { + setIntersecting(target: Element, isIntersecting: boolean) { + for (const observer of [...observers]) { + if (observer.targets.has(target)) observer.notify(target, isIntersecting); + } + }, + }; } From 5f2a60156acd2cab66281ef67186872985e78556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 2 Nov 2026 11:09:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=92=84=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E5=90=B8=E5=BA=95=E6=A0=8F=E6=BB=9A=E5=9B=9E=E6=97=B6=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E9=80=80=E5=87=BA=E8=BF=87=E6=B8=A1=EF=BC=8C=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E7=BB=93=E6=9D=9F=E5=90=8E=E5=86=8D=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=20DOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../install/components/InstallLayout.test.tsx | 35 +++++++++++++++++++ .../install/components/InstallLayout.tsx | 20 +++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/pages/install/components/InstallLayout.test.tsx b/src/pages/install/components/InstallLayout.test.tsx index a3fa60d45..496af61f3 100644 --- a/src/pages/install/components/InstallLayout.test.tsx +++ b/src/pages/install/components/InstallLayout.test.tsx @@ -92,9 +92,44 @@ describe("InstallLayout 桌面端:操作组在身份卡内,滚出后吸底", act(() => { io.setIntersecting(screen.getByTestId("card-actions"), true); }); + fireEvent.animationEnd(bar); expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument(); }); + it("吸底栏进出都有过渡:滚回时先播退出动画,吸底栏自身动画结束后才移出 DOM", () => { + render(); + scrollActionsOut(); + const bar = screen.getByTestId("action-bar"); + expect(bar.className).toContain("animate-in"); + + act(() => { + io.setIntersecting(screen.getByTestId("card-actions"), true); + }); + expect(bar).toBeInTheDocument(); + expect(bar.className).toContain("animate-out"); + + // 子元素(如下拉菜单)的动画事件会冒泡上来,不能误当成吸底栏自己的退出结束 + fireEvent.animationEnd(within(bar).getByRole("button", { name: "install" })); + expect(screen.getByTestId("action-bar")).toBe(bar); + + fireEvent.animationEnd(bar); + expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument(); + }); + + it("退出动画中途再次滚出时吸底栏恢复显示,不被随后的动画结束移除", () => { + render(); + scrollActionsOut(); + act(() => { + io.setIntersecting(screen.getByTestId("card-actions"), true); + }); + scrollActionsOut(); + const bar = screen.getByTestId("action-bar"); + expect(bar.className).not.toContain("animate-out"); + + fireEvent.animationEnd(bar); + expect(screen.getByTestId("action-bar")).toBeInTheDocument(); + }); + it("失败条只渲染一份,跟随当前可见的那组操作", () => { const onRetry = vi.fn(); render(); diff --git a/src/pages/install/components/InstallLayout.tsx b/src/pages/install/components/InstallLayout.tsx index 85ab30978..3510debff 100644 --- a/src/pages/install/components/InstallLayout.tsx +++ b/src/pages/install/components/InstallLayout.tsx @@ -104,6 +104,11 @@ export function InstallLayout({ }, [cardActions]); const sticky = !isMobile && !cardActionsVisible; + // 吸底栏消失也要走退出动画:sticky 转 false 后保留节点,直到它自身的动画结束才卸载 + const [stickyMounted, setStickyMounted] = useState(false); + if (sticky && !stickyMounted) { + setStickyMounted(true); + } const errorBar = (inset: boolean) => failure && ; @@ -154,10 +159,21 @@ export function InstallLayout({
{actions("footer")}
) : ( - sticky && ( + stickyMounted && (