feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动
This commit is contained in:
@@ -32,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag
|
||||
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
|
||||
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
|
||||
import { useAgentPanelLayout } from "./use-agent-panel-layout";
|
||||
import { useAgentLauncherPosition } from "./use-agent-launcher-position";
|
||||
import { AgentWelcome } from "./canvas-agent-welcome";
|
||||
import "./canvas-cloud-agent.css";
|
||||
|
||||
@@ -797,17 +798,18 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
}
|
||||
|
||||
function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) {
|
||||
const { position, dragging, handlers } = useAgentLauncherPosition(onOpen);
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="打开云端 Agent"
|
||||
title={approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"}
|
||||
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
|
||||
style={{ color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
|
||||
className={cn("canvas-agent-launcher fixed z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging")}
|
||||
style={{ ...position, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
data-canvas-no-zoom
|
||||
onClick={onOpen}
|
||||
whileHover={reducedMotion ? undefined : { y: -3, scale: 1.035 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
|
||||
{...handlers}
|
||||
whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }}
|
||||
whileTap={reducedMotion || dragging ? undefined : { scale: 0.96 }}
|
||||
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||
>
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
|
||||
@@ -522,10 +522,16 @@
|
||||
background: transparent;
|
||||
padding: 7px;
|
||||
box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent);
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher::before {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
|
||||
99
web/src/components/canvas/use-agent-launcher-position.ts
Normal file
99
web/src/components/canvas/use-agent-launcher-position.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
|
||||
import { AGENT_LAUNCHER_POSITION_KEY, clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition, type AgentLauncherGesture, type AgentLauncherPosition } from "@/lib/canvas/agent-launcher-position";
|
||||
|
||||
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
|
||||
|
||||
export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
const [position, setPosition] = useState(() => {
|
||||
try {
|
||||
return restoreAgentLauncherPosition(localStorage.getItem(AGENT_LAUNCHER_POSITION_KEY), viewport());
|
||||
} catch (error) {
|
||||
console.warn("Agent 入口位置无法读取", error);
|
||||
return restoreAgentLauncherPosition(null, viewport());
|
||||
}
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const gestureRef = useRef<(AgentLauncherGesture & { pointerId: number }) | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
const save = (next: AgentLauncherPosition) => {
|
||||
try {
|
||||
localStorage.setItem(AGENT_LAUNCHER_POSITION_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("Agent 入口位置无法保存", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => setPosition((current) => clampAgentLauncherPosition(current, viewport()));
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0 || !event.isPrimary || gestureRef.current) return;
|
||||
suppressClick.current = false;
|
||||
gestureRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: position, dragged: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
const next = moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
|
||||
gesture.dragged = next.dragged;
|
||||
if (!next.dragged) return;
|
||||
event.preventDefault();
|
||||
suppressClick.current = true;
|
||||
setDragging(true);
|
||||
setPosition(next.position);
|
||||
};
|
||||
const finish = (event: PointerEvent<HTMLButtonElement>, cancelled: boolean) => {
|
||||
event.stopPropagation();
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gestureRef.current = null;
|
||||
const next = cancelled ? { dragged: gesture.dragged, position: clampAgentLauncherPosition(position, viewport()) } : moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
|
||||
suppressClick.current = cancelled || next.dragged;
|
||||
setDragging(false);
|
||||
setPosition(next.position);
|
||||
if (next.dragged) save(next.position);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
const onClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
// Keyboard/assistive activation remains available after a drag or cancellation.
|
||||
if (event.detail !== 0 && suppressClick.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const delta = event.shiftKey ? 40 : 10;
|
||||
const offsets: Record<string, [number, number]> = { ArrowLeft: [delta, 0], ArrowRight: [-delta, 0], ArrowUp: [0, delta], ArrowDown: [0, -delta] };
|
||||
const offset = offsets[event.key];
|
||||
if (!offset) return;
|
||||
event.preventDefault();
|
||||
const next = clampAgentLauncherPosition({ right: position.right + offset[0], bottom: position.bottom + offset[1] }, viewport());
|
||||
setPosition(next);
|
||||
save(next);
|
||||
};
|
||||
|
||||
return {
|
||||
position,
|
||||
dragging,
|
||||
handlers: {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: (event: PointerEvent<HTMLButtonElement>) => finish(event, false),
|
||||
onPointerCancel: (event: PointerEvent<HTMLButtonElement>) => finish(event, true),
|
||||
onLostPointerCapture: (event: PointerEvent<HTMLButtonElement>) => finish(event, true),
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onDoubleClick: (event: MouseEvent<HTMLButtonElement>) => event.stopPropagation(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user