From b8d0b25e3c1f46490365363cadcd026e8c33b596 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=E8=B4=A8=E9=87=8F=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=20-=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E6=BC=94=E5=8F=B0=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=90=8E=20Web=20=E4=B8=8E=E5=90=8E=E7=AB=AF=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补回预演台模式切换、模式门控时间轴与非模态引导挂载,取景切换器使用激活态 token,模板场景补齐 environment 字段;E2E 改为验证新版 FloatingDock 与模式切换下的时间轴,工作流改用重命名后的 test:previs:e2e;修正 canvas_share.go 的 gofmt 格式,并按新布局与欢迎页外观行为校准合同测试。 --- .github/workflows/quality.yml | 4 +- backend/internal/canvas/canvas_share.go | 2 +- web/scripts/previs-p0-chrome-e2e.mjs | 33 ++++++++------ .../canvas/previs/canvas-previs-workbench.css | 4 +- .../canvas/previs/canvas-previs-workbench.tsx | 43 ++++++++++++++----- .../canvas/previs/previs-view-toolbar.tsx | 2 +- web/src/lib/canvas/previs/previs-templates.ts | 1 + web/test/application-bootstrap.test.mjs | 3 +- .../director-template-mode-wiring.test.ts | 12 +++++- web/test/helpers/module-group-source.ts | 1 + 10 files changed, 73 insertions(+), 32 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 60d991e58..7e3e7af46 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -152,7 +152,7 @@ jobs: run: bun run test # ubuntu-latest 预装 google-chrome;脚本的解析顺序已覆盖该路径, # 因此不引入第三方 setup action,也不需要任何 secret。 - - name: Director P0 Chrome E2E + - name: Previs P0 Chrome E2E run: | google-chrome --version || chromium --version - bun run test:director:e2e + bun run test:previs:e2e diff --git a/backend/internal/canvas/canvas_share.go b/backend/internal/canvas/canvas_share.go index ce9a5364f..02cf940d8 100644 --- a/backend/internal/canvas/canvas_share.go +++ b/backend/internal/canvas/canvas_share.go @@ -234,7 +234,7 @@ func publicCanvasProject(project *model.CanvasProject, token string) (map[string "connections": publicCanvasConnections(source["connections"]), "chatSessions": []any{}, "activeChatId": nil, - "previsScenes": []any{}, + "previsScenes": []any{}, } rawNodes, _ := source["nodes"].([]any) nodes := make([]any, 0, len(rawNodes)) diff --git a/web/scripts/previs-p0-chrome-e2e.mjs b/web/scripts/previs-p0-chrome-e2e.mjs index b57fc7a14..8703f54b8 100644 --- a/web/scripts/previs-p0-chrome-e2e.mjs +++ b/web/scripts/previs-p0-chrome-e2e.mjs @@ -405,35 +405,42 @@ async function smokeWorkbench(cdp, baseUrl) { assert(defaultLayoutMode === "true", "A5a 统一预演台默认进入布景上下文", `got ${JSON.stringify(defaultLayoutMode)}`); await openUnifiedWorkbench(cdp, "A"); - // 使用真实产品 dock 验证 Tooltip 的 hover 与键盘触发,不能只验证包装 span 存在。 - const tooltipButton = 'button[aria-label="移动对象"]'; - const hoverPoint = await cdp.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(tooltipButton)}).getBoundingClientRect(); return {x:r.x+r.width/2, y:r.y+r.height/2}; })()`); + // 使用真实产品 dock(FloatingDock)验证提示的 hover 与键盘触发,不能只验证按钮存在。 + const dockSelector = '[role="toolbar"][aria-label="预演台视口工具"]'; + assert(await cdp.poll(`!!document.querySelector(${JSON.stringify(dockSelector)})`, "previs dock", 10000), "A5b 预演台视口工具条已渲染"); + assert(await cdp.evaluate(`!!document.querySelector('${dockSelector} [role="radiogroup"][aria-label="变换模式"] [role="radio"][aria-label="移动对象 (W)"]')`), "A5b 变换模式切换包含移动对象 (W)"); + const tooltipButton = `${dockSelector} button[aria-label="适配全部对象"]`; + const hoverPoint = await cdp.evaluate(`(() => { const b = document.querySelector(${JSON.stringify(tooltipButton)}); if (!b) return null; const r = b.getBoundingClientRect(); return {x:r.x+r.width/2, y:r.y+r.height/2}; })()`); + if (!hoverPoint) throw new Error("A: 适配全部对象 dock button not found"); await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...hoverPoint }); - const describedTooltip = `(() => { const b = document.querySelector(${JSON.stringify(tooltipButton)}); const ids = (b?.getAttribute('aria-describedby') || '').split(/\\s+/); return ids.some(id => document.getElementById(id)?.textContent.includes('移动对象')); })()`; + const describedTooltip = `(() => { const b = document.querySelector(${JSON.stringify(tooltipButton)}); return !!b && [...b.querySelectorAll('.aceternity-dock-tooltip')].some((t) => (t.textContent || '').includes('适配全部对象')); })()`; assert(await cdp.poll(describedTooltip, "tooltip on hover", 5000), "A5b Tooltip hover describes the actual control"); await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1, y: 1 }); - await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); - await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); - assert(await cdp.poll(`!document.querySelector('[role="tooltip"]')`, "tooltip dismiss", 5000), "A5b Escape dismisses Tooltip"); + assert(await cdp.poll(`!(${describedTooltip})`, "tooltip dismiss", 5000), "A5b Tooltip dismisses when pointer leaves"); await cdp.evaluate(`document.querySelector(${JSON.stringify(tooltipButton)}).focus()`); for (const modifiers of [8, 0]) { await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, modifiers }); await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, modifiers }); } assert(await cdp.poll(`document.activeElement === document.querySelector(${JSON.stringify(tooltipButton)}) && (${describedTooltip})`, "tooltip on keyboard focus", 5000), "A5c Tab focus opens Tooltip without an extra wrapper stop"); - await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); - await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); await cdp.evaluate("document.activeElement?.blur()"); - // 统一工作台默认同时提供镜头设计与可收起的动画时间轴;自动关键帧初始关闭。 - const layoutGating = await cdp.evaluate(`(() => ({ + // 摆场模式不显示时间轴;切到动画模式才出现时间轴,且自动关键帧初始关闭。 + const readGating = `(() => ({ workbench: !!document.querySelector('[data-canvas-previs-workbench]'), layout: !!document.querySelector('.pv-layout'), sequencer: document.querySelectorAll('.previs-sequencer').length, autoKey: document.querySelector('button[title="自动关键帧"]')?.getAttribute('aria-pressed') ?? null, - }))()`); + }))()`; + const layoutGating = await cdp.evaluate(readGating); assert(layoutGating.workbench && layoutGating.layout, "A6 统一预演台布局上下文已就绪", JSON.stringify(layoutGating)); - assert(layoutGating.sequencer === 1 && layoutGating.autoKey === "false", "A7 时间轴可见且 AutoKey 默认关闭", JSON.stringify(layoutGating)); + assert(layoutGating.sequencer === 0, "A6a 摆场模式不显示时间轴", JSON.stringify(layoutGating)); + if (!(await cdp.click('button[data-mode="animate"]'))) throw new Error("A: 动画模式按钮不可点击"); + assert(await cdp.poll(`document.querySelectorAll('.previs-sequencer').length === 1`, "animate sequencer", 10000), "A7 动画模式显示时间轴"); + const animateGating = await cdp.evaluate(readGating); + assert(animateGating.autoKey === "false", "A7a AutoKey 默认关闭", JSON.stringify(animateGating)); + if (!(await cdp.click('button[data-mode="layout"]'))) throw new Error("A: 摆场模式按钮不可点击"); + assert(await cdp.poll(`document.querySelectorAll('.previs-sequencer').length === 0`, "layout hides sequencer", 10000), "A7b 回到摆场模式隐藏时间轴"); const propMenuOpened = await cdp.click('[aria-label="添加道具"]'); if (!propMenuOpened) throw new Error("A: 添加道具 button not clickable"); diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.css b/web/src/components/canvas/previs/canvas-previs-workbench.css index 8bf8b70f3..379310110 100644 --- a/web/src/components/canvas/previs/canvas-previs-workbench.css +++ b/web/src/components/canvas/previs/canvas-previs-workbench.css @@ -124,7 +124,7 @@ gap: 6px; height: var(--pd-topbar); min-width: 0; - overflow: hidden; + /* 窄屏下横向滚动(overflow-x-auto),避免右侧保存/回写按钮被裁掉。 */ padding: 0 12px; border-bottom: 1px solid var(--pd-b0); background: var(--pd-s1); @@ -1406,6 +1406,8 @@ backdrop-filter: blur(16px); color: var(--pd-t0); font-size: var(--pd-ts-xs); font-weight: 600; pointer-events: none; } .pv-cam-hud__meta { color: var(--pd-t2); font-weight: 400; font-variant-numeric: tabular-nums; } +/* 非模态引导:贴视口右上角,不挡中央构图。 */ +.pv-onboarding { position: absolute; top: 12px; right: 12px; z-index: 7; width: min(340px, calc(100% - 24px)); } @media (prefers-reduced-motion: reduce) { .pv-canvas-dock__view-grid button, .pv-canvas-dock__camera-list button { transition: none; } } diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.tsx b/web/src/components/canvas/previs/canvas-previs-workbench.tsx index d1fa4799b..fb0df779f 100644 --- a/web/src/components/canvas/previs/canvas-previs-workbench.tsx +++ b/web/src/components/canvas/previs/canvas-previs-workbench.tsx @@ -31,6 +31,7 @@ import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs- import "./canvas-previs-workbench.css"; import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; import { usePrevisWorkbenchStore } from "@/stores/canvas/use-previs-workbench-store"; +import { PREVIS_MODES, previsModeCapabilities } from "@/lib/canvas/previs/previs-modes"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; import type { PrevisCameraMove, PrevisRenderMode, PrevisScene, PrevisSceneOutput, PrevisTransform, PrevisVec3 } from "@/types/previs"; @@ -70,6 +71,8 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa return () => media.removeEventListener("change", update); }, []); const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0); + const mode = usePrevisWorkbenchStore((state) => state.mode); + const setMode = usePrevisWorkbenchStore((state) => state.setMode); const viewMode = usePrevisWorkbenchStore((state) => state.viewMode); const setViewMode = usePrevisWorkbenchStore((state) => state.setViewMode); const selectedObjectId = usePrevisWorkbenchStore((state) => state.selectedObjectId); @@ -117,8 +120,8 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa const addAsset = useAssetStore((state) => state.addAsset); const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]); - const capabilities = { timeline: true, keyframes: sequencerVisible, bones: true, cameraTools: Boolean(selectedCameraId && !selectedObjectId && !selectedLightId), renderModes: PREVIS_RENDER_MODE_LABELS.map((option) => option.value) }; - const renderModeOptions = PREVIS_RENDER_MODE_LABELS; + const capabilities = previsModeCapabilities(mode); + const renderModeOptions = PREVIS_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value)); const draftRef = useRef(null); const stagedRef = useRef(null); @@ -1096,8 +1099,9 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa setSelectedBone(null); } setPlaying(false); + setMode("animate"); setSequencerVisible(true); - }, [selectedActor, setPlaying, setSelectedBone, setSelectedLightId, setSelectedObjectId, setSequencerVisible, stagedTransaction]); + }, [selectedActor, setMode, setPlaying, setSelectedBone, setSelectedLightId, setSelectedObjectId, setSequencerVisible, stagedTransaction]); useEffect(() => { const onPreviewRequested = (event: Event) => { @@ -1150,7 +1154,7 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa return (
-
+
影策 @@ -1167,6 +1171,24 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa {selectedActor ? `角色 · ${selectedActor.name}` : selectedLight ? `灯光 · ${selectedLight.name}` : selectedCameraId ? `机位 · ${selectedCamera?.name || activeCamera?.name || "摄影机"}` : `镜头 · ${activeShot.name}`}
+
{saveIndicator.label} {saveIndicator.retryable ? : null} @@ -1378,7 +1400,7 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa renderMode={renderMode} playhead={playhead} playing={playing} - showMotionPaths={draft.objects.some((object) => object.keyframes.length > 1) || draft.cameras.some((camera) => camera.keyframes.length > 1)} + showMotionPaths={capabilities.timeline} trajectoryDrawing={trajectoryDrawing} onTrajectoryComplete={handleTrajectoryComplete} viewMode={viewMode} @@ -1394,6 +1416,7 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa showModelLoadNotice showNavigation={false} /> + {viewMode === "camera" && (selectedCamera || activeCamera) ? (
@@ -1417,7 +1440,7 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa onFocusSelected={() => viewportRef.current?.focusSelected()} onFrameScene={() => viewportRef.current?.frameScene()} onZoom={(factor) => viewportRef.current?.zoom(factor)} - timelineOpen={sequencerVisible} + timelineOpen={capabilities.timeline && sequencerVisible} onOpenTimeline={enterAnimationMode} scenePanelOpen={compactLayout ? scenePanelOpen : sceneDocked} onToggleScenePanel={() => compactLayout ? setScenePanelOpen((value) => !value) : setSceneDocked((value) => !value)} @@ -1540,11 +1563,9 @@ export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboa
- {sequencerVisible ? ( -
- setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> -
- ) : null} +
+ {capabilities.timeline ? setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> : null} +
); diff --git a/web/src/components/canvas/previs/previs-view-toolbar.tsx b/web/src/components/canvas/previs/previs-view-toolbar.tsx index a1cb2fa88..baebff3b1 100644 --- a/web/src/components/canvas/previs/previs-view-toolbar.tsx +++ b/web/src/components/canvas/previs/previs-view-toolbar.tsx @@ -39,7 +39,7 @@ export function PrevisViewToolbar({ viewMode, onViewModeChange, inline = false } aria-label={`${item.label} ${item.hint}`} title={item.hint} className="previs-view-toolbar-button inline-flex h-8 min-w-11 items-center justify-center rounded-[var(--r-md)] px-2 text-[var(--fs-tiny)] font-semibold tracking-wide transition-colors hover:bg-[var(--previs-control-hover)] hover:text-[var(--previs-dock-fg-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--control-focus-ring)] motion-reduce:transition-none" - style={active ? { color: "var(--previs-dock-fg-strong)" } : undefined} + style={active ? { background: "var(--previs-dock-active-surface)", color: "var(--previs-dock-fg-strong)" } : undefined} onClick={(event) => { onViewModeChange(item.mode); // 焦点留在按钮上会让交互控件守卫吃掉 W/E/R 变换快捷键。 diff --git a/web/src/lib/canvas/previs/previs-templates.ts b/web/src/lib/canvas/previs/previs-templates.ts index 25e6372ab..57d202b50 100644 --- a/web/src/lib/canvas/previs/previs-templates.ts +++ b/web/src/lib/canvas/previs/previs-templates.ts @@ -132,6 +132,7 @@ export function createPrevisSceneFromTemplate(templateId: PrevisTemplateId, titl title, background: "#d8dde3", environmentIntensity: 0.7, + environment: { mode: "color" }, gridVisible: true, objects: blueprint.objects, cameras: [blueprint.camera], diff --git a/web/test/application-bootstrap.test.mjs b/web/test/application-bootstrap.test.mjs index c4ae1bd73..b1c7a894b 100644 --- a/web/test/application-bootstrap.test.mjs +++ b/web/test/application-bootstrap.test.mjs @@ -64,7 +64,8 @@ for (const pathname of ["/welcome", "/welcome/"]) { test(`public film entry remains independent: ${pathname}`, async () => { const entry = await prepareEntry(false, pathname); await entry.loaded; - expect(entry.events).toEqual(["./welcome-application"]); + // 欢迎页沿用站点外观(#683),但外观请求不阻塞欢迎页入口加载。 + expect(entry.events).toEqual(["appearance", "./welcome-application"]); }); } diff --git a/web/test/director-template-mode-wiring.test.ts b/web/test/director-template-mode-wiring.test.ts index 93c8aff9f..40e2244ff 100644 --- a/web/test/director-template-mode-wiring.test.ts +++ b/web/test/director-template-mode-wiring.test.ts @@ -129,7 +129,11 @@ describe("模式接线", () => { expect(dock).toContain("RENDER_VIEW_BUTTONS.filter((item) => renderModes.includes(item.mode))"); // 写死的按钮会绕过门控。 expect(dock).not.toContain('onClick={() => onRenderModeChange("pose")}'); - expect(workbench).toContain("renderModes={capabilities.renderModes}"); + // 工作台当前挂的是 PrevisCanvasDock:它不提供渲染视图入口,唯一入口是已按模式过滤的顶栏下拉。 + const canvasDock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/previs-canvas-dock.tsx"), "utf8"); + expect(workbench).toContain(" { @@ -148,7 +152,11 @@ describe("模式接线", () => { }); test("小屏把属性检查器放到下方而不是隐藏,姿态与骨骼入口仍可达", () => { - expect(workbench).toContain("max-lg:col-span-2 max-lg:max-h-[40vh] max-lg:border-l-0 max-lg:border-t"); + // 窄屏下检查器改为可开合的侧滑面板,dock 提供开关入口,而不是直接隐藏。 + const workbenchCss = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/canvas-previs-workbench.css"), "utf8"); + expect(workbench).toContain('className={`pv-panel pv-panel--right thin-scrollbar ${inspectorOpen ? "is-open" : ""}`}'); + expect(workbench).toContain("onToggleInspector={() => compactLayout ? setInspectorOpen((value) => !value) : setInspectorDocked((value) => !value)}"); + expect(workbenchCss).toContain(".pv-panel--right.is-open { transform: translateX(0); }"); expect(workbench).not.toContain("border-l max-lg:hidden"); }); diff --git a/web/test/helpers/module-group-source.ts b/web/test/helpers/module-group-source.ts index aa616ed03..9047c1b7e 100644 --- a/web/test/helpers/module-group-source.ts +++ b/web/test/helpers/module-group-source.ts @@ -22,6 +22,7 @@ export const SPLIT_MODULE_GROUPS: Record = { "pages/admin/settings/appearance-settings-page.tsx": ["pages/admin/settings/appearance-settings-parts.tsx"], "pages/admin/settings/storage-settings-page.tsx": ["pages/admin/settings/storage-settings-model.ts"], "pages/assets/index.tsx": ["pages/assets/asset-library-cards.tsx", "pages/assets/asset-library-format.ts", "pages/assets/asset-library-panels.tsx"], + "components/canvas/previs/canvas-previs-workbench.tsx": ["components/canvas/previs/previs-inspectors.tsx"], "pages/canvas/project.tsx": ["pages/canvas/canvas-clipboard.ts"], "pages/create/creation-workspace.tsx": ["pages/create/creation-workspace-empty.tsx", "pages/create/creation-workspace-history.tsx", "pages/create/creation-workspace-messages.tsx"], "services/user-data-sync.ts": ["services/user-data-sync-media.ts"],