- )}
-
- {onExternalAccessReject ? (
- // 「外部接入」触发:三档决策 拒绝 / 本会话允许 / 安装(设计 §3)。安装 = 下方 install-split
- // 主按钮;此处只补前两档,替换普通「关闭」——关闭窗口本身不算决定(op 挂起至 TTL/断开)。
- <>
-
);
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 (
diff --git a/src/pages/install/components/InstallLayout.test.tsx b/src/pages/install/components/InstallLayout.test.tsx
index 3ff704590..84d490931 100644
--- a/src/pages/install/components/InstallLayout.test.tsx
+++ b/src/pages/install/components/InstallLayout.test.tsx
@@ -1,77 +1,193 @@
-import { describe, it, expect, afterEach } from "vitest";
-import { render, screen, cleanup, within } from "@testing-library/react";
-import { InstallLayout } from "./InstallLayout";
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, within, act, fireEvent } from "@testing-library/react";
+import { initTestLanguage } from "@Tests/initTestLanguage";
+import { mockMatchMedia } from "@Tests/mockMatchMedia";
+import { mockIntersectionObserver } from "@Tests/mockIntersectionObserver";
+import { InstallLayout, type InstallLayoutProps } from "./InstallLayout";
+
+let io: ReturnType
;
+
+const layoutProps = (over: Partial = {}): InstallLayoutProps => ({
+ title: "脚本安装",
+ actions: (placement) => ,
+ summary: {"迷你身份"},
+ identity: (slots) => (
+
+ {"身份卡"}
+ {slots && (
+ <>
+ {slots.actions}
+ {slots.alert}
+ {slots.note}
+ >
+ )}
+
+ ),
+ children: {"正文内容"}
,
+ ...over,
+});
+
+const scrollActionsOut = () =>
+ act(() => {
+ io.setIntersecting(screen.getByTestId("card-actions"), false);
+ });
+
+beforeAll(() => initTestLanguage("zh-CN"));
+
+beforeEach(() => {
+ mockMatchMedia(false);
+ io = mockIntersectionObserver();
+});
afterEach(cleanup);
describe("InstallLayout 安装页外壳", () => {
- it("渲染品牌标识、上下文标题与内容", () => {
- render(
- {"install"}}>
- {"正文内容"}
-
- );
+ it("渲染品牌标识、上下文标题、身份卡与正文", () => {
+ render();
expect(screen.getByText("ScriptCat")).toBeInTheDocument();
expect(screen.getByText("脚本安装")).toBeInTheDocument();
+ expect(screen.getByText("身份卡")).toBeInTheDocument();
expect(screen.getByText("正文内容")).toBeInTheDocument();
});
it("左上角应渲染真实 logo 图片(而非占位圆点)", () => {
- render(
- {"install"}}>
- {"x"}
-
- );
+ render();
const logo = screen.getByAltText("ScriptCat");
expect(logo.tagName).toBe("IMG");
expect(logo.getAttribute("src")).toContain("assets/logo.png");
});
- it("在吸底操作栏渲染 actions", () => {
- render(
- {"do-update"}}>
- {"x"}
-
- );
- const bar = screen.getByTestId("action-bar");
- expect(within(bar).getByText("do-update").closest("button")).toBeInTheDocument();
- });
-
it("ribbon 挂在顶栏与滚动区之间,不随正文滚走", () => {
- render(
- {"install"}} ribbon={{"成功条"}
}>
- {"x"}
-
- );
- const ribbon = screen.getByText("成功条");
- expect(ribbon).toBeInTheDocument();
+ render({"成功条"} })} />);
+ expect(screen.getByText("成功条")).toBeInTheDocument();
expect(within(screen.getByTestId("content-area")).queryByText("成功条")).not.toBeInTheDocument();
});
- it("alert 渲染在吸底操作栏内、按钮行之上", () => {
- render(
-
{"install"}} alert={{"错误条"}
}>
- {"x"}
-
- );
- const bar = screen.getByTestId("action-bar");
- expect(within(bar).getByText("错误条")).toBeInTheDocument();
- });
-
it("closing 时整页淡出,并让「减少动态效果」用户直接跳过动画", () => {
- const { rerender } = render(
-
{"install"}}>
- {"x"}
-
- );
+ const { rerender } = render(
);
const shell = screen.getByTestId("install-layout");
expect(shell.className).not.toContain("opacity-0");
- rerender(
-
{"install"}} closing>
- {"x"}
-
- );
+ rerender(
);
expect(shell.className).toContain("motion-safe:opacity-0");
});
});
+
+describe("InstallLayout 桌面端:操作组在身份卡内,滚出后吸底", () => {
+ it("首屏操作组渲染在身份卡内,不渲染吸底栏", () => {
+ render(
);
+ const identity = screen.getByTestId("identity");
+ expect(within(identity).getByRole("button", { name: "install" })).toHaveAttribute("data-placement", "card");
+ expect(within(identity).getByText("信任提示")).toBeInTheDocument();
+ expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument();
+ });
+
+ it("身份卡操作组滚出可视区后出现吸底栏(迷你身份 + 操作组),滚回后移除", () => {
+ render(
);
+ scrollActionsOut();
+ const bar = screen.getByTestId("action-bar");
+ expect(within(bar).getByText("迷你身份")).toBeInTheDocument();
+ expect(within(bar).getByRole("button", { name: "install" })).toHaveAttribute("data-placement", "sticky");
+
+ 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("一次回调批量送达多条记录时以最后一条为准:滚出又滚回不出现吸底栏", () => {
+ render(
);
+ act(() => {
+ io.setIntersecting(screen.getByTestId("card-actions"), [false, true]);
+ });
+ expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument();
+ });
+
+ it("退出动画中的吸底栏不可交互、不进入无障碍树,失败条与操作只由身份卡承担", () => {
+ render(
);
+ scrollActionsOut();
+ const bar = screen.getByTestId("action-bar");
+ expect(bar).not.toHaveAttribute("inert");
+
+ act(() => {
+ io.setIntersecting(screen.getByTestId("card-actions"), true);
+ });
+ expect(bar).toHaveAttribute("inert");
+ expect(within(screen.getByTestId("identity")).getByText("解析失败")).toBeInTheDocument();
+ });
+
+ it("跨过移动断点再回到桌面时不沿用旧的滚出状态,也不补播吸底栏退出动画", () => {
+ const { rerender } = render(
);
+ scrollActionsOut();
+ expect(screen.getByTestId("action-bar")).toBeInTheDocument();
+
+ mockMatchMedia(true);
+ rerender(
);
+ mockMatchMedia(false);
+ rerender(
);
+ expect(screen.queryByTestId("action-bar")).not.toBeInTheDocument();
+ });
+
+ it("失败条只渲染一份,跟随当前可见的那组操作", () => {
+ const onRetry = vi.fn();
+ render(
);
+ expect(screen.getAllByTestId("install-error-bar")).toHaveLength(1);
+ expect(within(screen.getByTestId("identity")).getByText("解析失败")).toBeInTheDocument();
+
+ scrollActionsOut();
+ expect(screen.getAllByTestId("install-error-bar")).toHaveLength(1);
+ const bar = screen.getByTestId("action-bar");
+ expect(within(bar).getByText("解析失败")).toBeInTheDocument();
+ fireEvent.click(within(bar).getByTestId("install-error-retry"));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("InstallLayout 移动端保持吸底操作栏", () => {
+ beforeEach(() => mockMatchMedia(true));
+
+ it("身份卡不接收操作插槽,操作组与失败条常驻吸底栏,不渲染说明文字", () => {
+ render(
);
+ const identity = screen.getByTestId("identity");
+ expect(within(identity).queryByRole("button")).not.toBeInTheDocument();
+ const bar = screen.getByTestId("action-bar");
+ expect(within(bar).getByRole("button", { name: "install" })).toHaveAttribute("data-placement", "footer");
+ expect(within(bar).getByText("解析失败")).toBeInTheDocument();
+ expect(screen.queryByText("信任提示")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/install/components/InstallLayout.tsx b/src/pages/install/components/InstallLayout.tsx
index 168760f98..8ac242b69 100644
--- a/src/pages/install/components/InstallLayout.tsx
+++ b/src/pages/install/components/InstallLayout.tsx
@@ -1,6 +1,25 @@
-import type { LucideIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Info, type LucideIcon } from "lucide-react";
import { cn } from "@App/pkg/utils/cn";
+import { useIsMobile } from "@App/pages/components/use-is-mobile";
import { InstallTopBar } from "./InstallTopBar";
+import { InstallErrorBar } from "./InstallErrorBar";
+
+/** 操作组的渲染位置:桌面身份卡内 / 移动端常驻吸底栏 / 桌面身份卡滚出后才出现的吸底栏 */
+export type ActionPlacement = "card" | "footer" | "sticky";
+
+/** 桌面端交给身份卡自行摆放的插槽;移动端为 null,身份卡保持纯展示 */
+export interface IdentitySlots {
+ actions: React.ReactNode;
+ alert: React.ReactNode;
+ note: React.ReactNode;
+}
+
+export interface InstallFailure {
+ message: string;
+ /** 不提供则失败条不出现重试入口(如外部接入请求失败) */
+ onRetry?: () => void;
+}
export interface InstallLayoutProps {
/** 顶部右侧上下文标题(如「脚本安装」「脚本更新」),由调用方按场景翻译 */
@@ -11,25 +30,116 @@ export interface InstallLayoutProps {
titleTone?: "default" | "skill" | "watching";
/** 顶栏与滚动区之间的常驻条(安装成功 ribbon):不随正文滚动,始终可读 */
ribbon?: React.ReactNode;
- /** 吸底操作栏内、按钮行正上方的内联提示条(安装失败错误条) */
- alert?: React.ReactNode;
+ /** 安装失败:原因与重试入口须常驻可读,跟随当前可见的那组操作只渲染一份 */
+ failure?: InstallFailure;
/** 安装完成且即将自动关闭:整页淡出,「减少动态效果」下直接跳过该动画 */
closing?: boolean;
- /** 吸底操作栏内容 */
- actions: React.ReactNode;
+ /** 按位置渲染操作组;sticky 是滚动中途才挂载的副本,不应抢焦点 */
+ actions: (placement: ActionPlacement) => React.ReactNode;
+ /** 操作说明(信任提示等),桌面端显示在身份卡底部 */
+ note?: React.ReactNode;
+ /** 桌面吸底栏左侧的迷你身份(图标 + 名称 + 版本) */
+ summary: React.ReactNode;
+ /** 正文首块的身份卡 */
+ identity: (slots: IdentitySlots | null) => React.ReactNode;
children: React.ReactNode;
}
+/** 桌面吸底栏左侧的迷你身份:沿用身份卡的图标底色,滚到下方仍能认出是哪个脚本/技能 */
+export function InstallSummary({
+ icon,
+ tone = "primary",
+ name,
+ version,
+}: {
+ icon: React.ReactNode;
+ tone?: "primary" | "skill";
+ name: string;
+ version?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
{name}
+ {version && (
+
{`v${version}`}
+ )}
+
+ );
+}
+
export function InstallLayout({
title,
titleIcon,
titleTone = "default",
ribbon,
- alert,
+ failure,
closing,
actions,
+ note,
+ summary,
+ identity,
children,
}: InstallLayoutProps) {
+ const isMobile = useIsMobile();
+ const contentRef = useRef
(null);
+ const [cardActions, setCardActions] = useState(null);
+ // 观测结果绑定到被观测的那个节点:跨过移动断点后操作组节点重建,旧节点的「已滚出」不能沿用;
+ // 新节点在首次回调前按「可见」处理,身份卡在正文顶部,不应闪出吸底栏
+ const [observed, setObserved] = useState<{ target: Element; visible: boolean } | null>(null);
+ const cardActionsVisible = observed?.target !== cardActions || observed.visible;
+
+ useEffect(() => {
+ if (!cardActions) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ // 回调排队期间同一节点可能累积多条记录,按时间先后排列,只有最后一条是当前状态
+ const latest = entries[entries.length - 1];
+ setObserved({ target: latest.target, visible: latest.isIntersecting });
+ },
+ { root: contentRef.current }
+ );
+ observer.observe(cardActions);
+ return () => observer.disconnect();
+ }, [cardActions]);
+
+ const sticky = !isMobile && !cardActionsVisible;
+ // 吸底栏消失也要走退出动画:sticky 转 false 后保留节点,直到它自身的动画结束才卸载
+ const [stickyMounted, setStickyMounted] = useState(false);
+ if (sticky && !stickyMounted) {
+ setStickyMounted(true);
+ }
+ // 移动端换成常驻吸底栏,桌面吸底栏直接卸掉,回到桌面时不补播一次退出动画
+ if (isMobile && stickyMounted) {
+ setStickyMounted(false);
+ }
+ const errorBar = (inset: boolean) =>
+ failure && ;
+
+ const slots: IdentitySlots | null = isMobile
+ ? null
+ : {
+ actions: (
+
+ {actions("card")}
+
+ ),
+ alert: sticky ? null : errorBar(true),
+ note: note && (
+
+
+ {note}
+
+ ),
+ };
+
return (
-
{children}
+
+
+ {identity(slots)}
+ {children}
+
-
+ {isMobile ? (
+
+ ) : (
+ stickyMounted && (
+
+ )
+ )}
);
}
diff --git a/src/pages/install/components/ScriptIdentity.test.tsx b/src/pages/install/components/ScriptIdentity.test.tsx
index 82d1d86f7..81a9ff52f 100644
--- a/src/pages/install/components/ScriptIdentity.test.tsx
+++ b/src/pages/install/components/ScriptIdentity.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
-import { render, screen, cleanup, fireEvent } from "@testing-library/react";
+import { render, screen, cleanup, fireEvent, within } from "@testing-library/react";
import { initTestLanguage } from "@Tests/initTestLanguage";
import { ScriptIdentity } from "./ScriptIdentity";
@@ -89,3 +89,29 @@ describe("ScriptIdentity 身份卡", () => {
expect(onEnabledChange).toHaveBeenCalledWith(false);
});
});
+
+describe("ScriptIdentity 桌面端插槽", () => {
+ const slots = {
+ actions: ,
+ alert: {"安装失败"}
,
+ note: {"信任提示"}
,
+ };
+
+ it("传入插槽时渲染操作组、失败条与说明,启用开关仍可切换", () => {
+ const onEnabledChange = vi.fn();
+ render(
+
+ );
+ const card = screen.getByTestId("script-identity");
+ expect(within(card).getByRole("button", { name: "安装脚本" })).toBeInTheDocument();
+ expect(within(card).getByRole("alert")).toHaveTextContent("安装失败");
+ expect(within(card).getByText("信任提示")).toBeInTheDocument();
+ fireEvent.click(within(card).getByRole("switch"));
+ expect(onEnabledChange).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/pages/install/components/ScriptIdentity.tsx b/src/pages/install/components/ScriptIdentity.tsx
index 5ba173045..2a9b346d1 100644
--- a/src/pages/install/components/ScriptIdentity.tsx
+++ b/src/pages/install/components/ScriptIdentity.tsx
@@ -3,6 +3,7 @@ import { User, Globe, ArrowRight, Timer, Clock, Power, FileCode } from "lucide-r
import { Switch } from "@App/pages/components/ui/switch";
import { cn } from "@App/pkg/utils/cn";
import type { VersionDisplay, AntifeatureType, ScheduleInfo } from "../model";
+import type { IdentitySlots } from "./InstallLayout";
const ANTIFEATURE_TITLE_KEY: Record = {
"referral-link": "install:referral_link_title",
@@ -48,6 +49,8 @@ export interface ScriptIdentityProps {
description?: string;
enabled: boolean;
onEnabledChange: (v: boolean) => void;
+ /** 桌面端由 InstallLayout 注入:操作组放右上、启用开关移到其下,失败条与说明随卡片 */
+ slots?: IdentitySlots | null;
}
export function ScriptIdentity({
@@ -62,11 +65,15 @@ export function ScriptIdentity({
description,
enabled,
onEnabledChange,
+ slots,
}: ScriptIdentityProps) {
const { t } = useTranslation(["install", "common"]);
return (
-
+
{iconUrl ? (
@@ -78,7 +85,7 @@ export function ScriptIdentity({
-
{name}
+ {name}
{version.kind === "install" ? (
{author && (
<>
-
-
- {author}
+
+
+ {author}
>
@@ -139,13 +146,27 @@ export function ScriptIdentity({
)}
-
-
- {t("install:enabled_label")}
-
+ {slots ? (
+
+ {slots.actions}
+
+ {t("install:enabled_label")}
+
+
+
+ ) : (
+
+
+ {t("install:enabled_label")}
+
+ )}
- {description &&
{description}
}
+ {slots?.alert}
+
+ {description && (
+
{description}
+ )}
{schedule?.kind === "cron" && (
@@ -172,6 +193,8 @@ export function ScriptIdentity({
{t("install:schedule_background_desc")}
)}
+
+ {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..23dfe61f3 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,104 @@ 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..fe47aa226 100644
--- a/tests/mockIntersectionObserver.ts
+++ b/tests/mockIntersectionObserver.ts
@@ -1,14 +1,59 @@
-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, states: boolean[]) {
+ const entries = states.map((isIntersecting) => ({
+ target,
+ isIntersecting,
+ intersectionRatio: isIntersecting ? 1 : 0,
+ }));
+ this.callback(entries as unknown as IntersectionObserverEntry[], this as unknown as IntersectionObserver);
+ }
+ }
+
+ vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
+
+ return {
+ setIntersecting(target: Element, isIntersecting: boolean | boolean[]) {
+ const states = Array.isArray(isIntersecting) ? isIntersecting : [isIntersecting];
+ for (const observer of [...observers]) {
+ if (observer.targets.has(target)) observer.notify(target, states);
+ }
+ },
+ };
}