- 新增 ObjectPickerOverlay 组件,支持点击图片拾取物品 - 新增 /api/pick-object API,调用 Qwen-VL 识别物品 - Canvas Store 扩展拾取模式状态管理 - ImageNode 工具栏新增拾取按钮 - CanvasChatPanel 集成物品标签,发送时自动构建精准prompt - 图片压缩至 600px 以内加速 AI 分析 - 物品名称限制 8 字,位置固定 4 字标准词库
503 lines
15 KiB
TypeScript
503 lines
15 KiB
TypeScript
import { create } from 'zustand';
|
||
import { immer } from 'zustand/middleware/immer';
|
||
import { devtools, persist } from 'zustand/middleware';
|
||
import type {
|
||
CanvasNode,
|
||
CanvasEdge,
|
||
CanvasState,
|
||
Viewport,
|
||
ToolType,
|
||
Position,
|
||
Size,
|
||
NodeType,
|
||
} from '@/types/canvas';
|
||
|
||
// 生成唯一 ID
|
||
const generateId = () => crypto.randomUUID();
|
||
|
||
// 默认视口
|
||
const defaultViewport: Viewport = {
|
||
x: 0,
|
||
y: 0,
|
||
zoom: 1,
|
||
};
|
||
|
||
// 待添加到对话框的图片附件
|
||
interface PendingChatAttachment {
|
||
url: string;
|
||
name: string;
|
||
contentType: string;
|
||
}
|
||
|
||
// 🆕 拾取的物品信息
|
||
export interface PickedObject {
|
||
// 原图信息
|
||
imageUrl: string;
|
||
imageNodeId: string;
|
||
|
||
// 点击位置(相对坐标 0-1)
|
||
clickX: number;
|
||
clickY: number;
|
||
|
||
// AI 识别结果
|
||
objectName: string; // ≤8字,格式:[颜色/材质]+[物品]
|
||
objectPosition: string; // 固定4字,如"左上区域"
|
||
confidence: number; // 置信度 0-1
|
||
|
||
// 物品边界框
|
||
boundingBox: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
}
|
||
|
||
// Store 状态接口
|
||
interface CanvasStore {
|
||
// 项目信息
|
||
projectId: string | null;
|
||
projectTitle: string;
|
||
|
||
// 画布状态
|
||
nodes: CanvasNode[];
|
||
edges: CanvasEdge[];
|
||
selectedNodeIds: string[];
|
||
viewport: Viewport;
|
||
tool: ToolType;
|
||
|
||
// 历史记录(用于撤销/重做)
|
||
history: CanvasState[];
|
||
historyIndex: number;
|
||
|
||
// UI 状态
|
||
isPanelOpen: boolean;
|
||
isGenerating: boolean;
|
||
|
||
// 🆕 待添加到对话框的图片(用于从画布添加图片到对话)
|
||
pendingChatAttachments: PendingChatAttachment[];
|
||
|
||
// 🆕 物品拾取模式
|
||
isPickerMode: boolean;
|
||
pickerTargetNodeId: string | null;
|
||
isPickerLoading: boolean;
|
||
pickedObject: PickedObject | null;
|
||
pendingPickedObjects: PickedObject[];
|
||
|
||
// 项目操作
|
||
setProject: (id: string | null, title?: string) => void;
|
||
setProjectTitle: (title: string) => void;
|
||
|
||
// 节点操作
|
||
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||
deleteNode: (id: string) => void;
|
||
deleteSelectedNodes: () => void;
|
||
|
||
// 选择操作
|
||
selectNode: (id: string, multi?: boolean) => void;
|
||
selectNodes: (ids: string[]) => void;
|
||
clearSelection: () => void;
|
||
selectAll: () => void;
|
||
|
||
// 边操作
|
||
addEdge: (edge: Omit<CanvasEdge, 'id'>) => string;
|
||
deleteEdge: (id: string) => void;
|
||
|
||
// 视口操作
|
||
setViewport: (viewport: Partial<Viewport>) => void;
|
||
zoomIn: () => void;
|
||
zoomOut: () => void;
|
||
zoomTo: (zoom: number) => void;
|
||
fitToScreen: () => void;
|
||
centerNode: (nodeId: string) => void;
|
||
|
||
// 工具操作
|
||
setTool: (tool: ToolType) => void;
|
||
|
||
// 历史操作
|
||
undo: () => void;
|
||
redo: () => void;
|
||
pushHistory: () => void;
|
||
|
||
// UI 操作
|
||
togglePanel: () => void;
|
||
setGenerating: (isGenerating: boolean) => void;
|
||
|
||
// 批量操作
|
||
loadCanvas: (state: Partial<CanvasState>) => void;
|
||
clearCanvas: () => void;
|
||
getCanvasState: () => CanvasState;
|
||
|
||
// 节点位置操作
|
||
moveNode: (id: string, position: Position) => void;
|
||
resizeNode: (id: string, size: Size) => void;
|
||
|
||
// 辅助方法
|
||
getNode: (id: string) => CanvasNode | undefined;
|
||
getSelectedNodes: () => CanvasNode[];
|
||
getNodesByType: (type: NodeType) => CanvasNode[];
|
||
|
||
// 🆕 对话框附件操作
|
||
addPendingChatAttachment: (attachment: PendingChatAttachment) => void;
|
||
clearPendingChatAttachments: () => void;
|
||
|
||
// 🆕 物品拾取操作
|
||
setPickerMode: (enabled: boolean, nodeId?: string) => void;
|
||
setPickerLoading: (loading: boolean) => void;
|
||
setPickedObject: (obj: PickedObject | null) => void;
|
||
addPendingPickedObject: (obj: PickedObject) => void;
|
||
clearPendingPickedObjects: () => void;
|
||
}
|
||
|
||
export const useCanvasStore = create<CanvasStore>()(
|
||
devtools(
|
||
persist(
|
||
immer((set, get) => ({
|
||
// 初始状态
|
||
projectId: null,
|
||
projectTitle: '未命名画布',
|
||
nodes: [],
|
||
edges: [],
|
||
selectedNodeIds: [],
|
||
viewport: defaultViewport,
|
||
tool: 'select',
|
||
history: [],
|
||
historyIndex: -1,
|
||
isPanelOpen: true,
|
||
isGenerating: false,
|
||
pendingChatAttachments: [], // 🆕 待添加到对话框的图片
|
||
|
||
// 🆕 物品拾取模式初始状态
|
||
isPickerMode: false,
|
||
pickerTargetNodeId: null,
|
||
isPickerLoading: false,
|
||
pickedObject: null,
|
||
pendingPickedObjects: [],
|
||
|
||
// 项目操作
|
||
setProject: (id, title) => set((state) => {
|
||
state.projectId = id;
|
||
if (title) state.projectTitle = title;
|
||
}),
|
||
|
||
setProjectTitle: (title) => set((state) => {
|
||
state.projectTitle = title;
|
||
}),
|
||
|
||
// 节点操作
|
||
addNode: (nodeData) => {
|
||
const id = generateId();
|
||
const now = new Date();
|
||
const node: CanvasNode = {
|
||
...nodeData,
|
||
id,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
set((state) => {
|
||
state.nodes.push(node);
|
||
});
|
||
|
||
// 添加到历史
|
||
get().pushHistory();
|
||
|
||
return id;
|
||
},
|
||
|
||
updateNode: (id, updates) => set((state) => {
|
||
const index = state.nodes.findIndex(n => n.id === id);
|
||
if (index !== -1) {
|
||
state.nodes[index] = {
|
||
...state.nodes[index],
|
||
...updates,
|
||
updatedAt: new Date(),
|
||
};
|
||
}
|
||
}),
|
||
|
||
deleteNode: (id) => {
|
||
set((state) => {
|
||
state.nodes = state.nodes.filter(n => n.id !== id);
|
||
state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
|
||
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
|
||
});
|
||
get().pushHistory();
|
||
},
|
||
|
||
deleteSelectedNodes: () => {
|
||
const { selectedNodeIds } = get();
|
||
set((state) => {
|
||
state.nodes = state.nodes.filter(n => !selectedNodeIds.includes(n.id));
|
||
state.edges = state.edges.filter(
|
||
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
|
||
);
|
||
state.selectedNodeIds = [];
|
||
});
|
||
get().pushHistory();
|
||
},
|
||
|
||
// 选择操作
|
||
selectNode: (id, multi = false) => set((state) => {
|
||
if (multi) {
|
||
const index = state.selectedNodeIds.indexOf(id);
|
||
if (index === -1) {
|
||
state.selectedNodeIds.push(id);
|
||
} else {
|
||
state.selectedNodeIds.splice(index, 1);
|
||
}
|
||
} else {
|
||
state.selectedNodeIds = [id];
|
||
}
|
||
}),
|
||
|
||
selectNodes: (ids) => set((state) => {
|
||
state.selectedNodeIds = ids;
|
||
}),
|
||
|
||
clearSelection: () => set((state) => {
|
||
state.selectedNodeIds = [];
|
||
}),
|
||
|
||
selectAll: () => set((state) => {
|
||
state.selectedNodeIds = state.nodes.map(n => n.id);
|
||
}),
|
||
|
||
// 边操作
|
||
addEdge: (edgeData) => {
|
||
const id = generateId();
|
||
const edge: CanvasEdge = { ...edgeData, id };
|
||
|
||
set((state) => {
|
||
// 检查是否已存在相同的边
|
||
const exists = state.edges.some(
|
||
e => e.source === edge.source && e.target === edge.target
|
||
);
|
||
if (!exists) {
|
||
state.edges.push(edge);
|
||
}
|
||
});
|
||
|
||
return id;
|
||
},
|
||
|
||
deleteEdge: (id) => set((state) => {
|
||
state.edges = state.edges.filter(e => e.id !== id);
|
||
}),
|
||
|
||
// 视口操作
|
||
setViewport: (viewport) => set((state) => {
|
||
state.viewport = { ...state.viewport, ...viewport };
|
||
}),
|
||
|
||
zoomIn: () => set((state) => {
|
||
state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4);
|
||
}),
|
||
|
||
zoomOut: () => set((state) => {
|
||
state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1);
|
||
}),
|
||
|
||
zoomTo: (zoom) => set((state) => {
|
||
state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4));
|
||
}),
|
||
|
||
fitToScreen: () => set((state) => {
|
||
// TODO: 计算所有节点的边界并调整视口
|
||
state.viewport = { x: 0, y: 0, zoom: 1 };
|
||
}),
|
||
|
||
centerNode: (nodeId) => {
|
||
const node = get().getNode(nodeId);
|
||
if (node) {
|
||
set((state) => {
|
||
state.viewport.x = -node.position.x - node.size.width / 2;
|
||
state.viewport.y = -node.position.y - node.size.height / 2;
|
||
});
|
||
}
|
||
},
|
||
|
||
// 工具操作
|
||
setTool: (tool) => set((state) => {
|
||
state.tool = tool;
|
||
}),
|
||
|
||
// 历史操作
|
||
undo: () => set((state) => {
|
||
if (state.historyIndex > 0) {
|
||
state.historyIndex -= 1;
|
||
const prevState = state.history[state.historyIndex];
|
||
state.nodes = prevState.nodes;
|
||
state.edges = prevState.edges;
|
||
state.selectedNodeIds = prevState.selectedNodeIds;
|
||
}
|
||
}),
|
||
|
||
redo: () => set((state) => {
|
||
if (state.historyIndex < state.history.length - 1) {
|
||
state.historyIndex += 1;
|
||
const nextState = state.history[state.historyIndex];
|
||
state.nodes = nextState.nodes;
|
||
state.edges = nextState.edges;
|
||
state.selectedNodeIds = nextState.selectedNodeIds;
|
||
}
|
||
}),
|
||
|
||
pushHistory: () => set((state) => {
|
||
const currentState: CanvasState = {
|
||
nodes: JSON.parse(JSON.stringify(state.nodes)),
|
||
edges: JSON.parse(JSON.stringify(state.edges)),
|
||
selectedNodeIds: [...state.selectedNodeIds],
|
||
viewport: { ...state.viewport },
|
||
tool: state.tool,
|
||
};
|
||
|
||
// 移除当前位置之后的历史
|
||
state.history = state.history.slice(0, state.historyIndex + 1);
|
||
state.history.push(currentState);
|
||
state.historyIndex = state.history.length - 1;
|
||
|
||
// 限制历史记录数量
|
||
if (state.history.length > 50) {
|
||
state.history = state.history.slice(-50);
|
||
state.historyIndex = state.history.length - 1;
|
||
}
|
||
}),
|
||
|
||
// UI 操作
|
||
togglePanel: () => set((state) => {
|
||
state.isPanelOpen = !state.isPanelOpen;
|
||
}),
|
||
|
||
setGenerating: (isGenerating) => set((state) => {
|
||
state.isGenerating = isGenerating;
|
||
}),
|
||
|
||
// 批量操作
|
||
loadCanvas: (canvasState) => set((state) => {
|
||
if (canvasState.nodes) state.nodes = canvasState.nodes;
|
||
if (canvasState.edges) state.edges = canvasState.edges;
|
||
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
|
||
if (canvasState.viewport) state.viewport = canvasState.viewport;
|
||
if (canvasState.tool) state.tool = canvasState.tool;
|
||
}),
|
||
|
||
clearCanvas: () => set((state) => {
|
||
state.nodes = [];
|
||
state.edges = [];
|
||
state.selectedNodeIds = [];
|
||
state.viewport = defaultViewport;
|
||
state.history = [];
|
||
state.historyIndex = -1;
|
||
}),
|
||
|
||
getCanvasState: () => {
|
||
const state = get();
|
||
return {
|
||
nodes: state.nodes,
|
||
edges: state.edges,
|
||
selectedNodeIds: state.selectedNodeIds,
|
||
viewport: state.viewport,
|
||
tool: state.tool,
|
||
};
|
||
},
|
||
|
||
// 节点位置操作
|
||
moveNode: (id, position) => set((state) => {
|
||
const node = state.nodes.find(n => n.id === id);
|
||
if (node) {
|
||
node.position = position;
|
||
node.updatedAt = new Date();
|
||
}
|
||
}),
|
||
|
||
resizeNode: (id, size) => set((state) => {
|
||
const node = state.nodes.find(n => n.id === id);
|
||
if (node) {
|
||
node.size = size;
|
||
node.updatedAt = new Date();
|
||
}
|
||
}),
|
||
|
||
// 辅助方法
|
||
getNode: (id) => get().nodes.find(n => n.id === id),
|
||
|
||
getSelectedNodes: () => {
|
||
const { nodes, selectedNodeIds } = get();
|
||
return nodes.filter(n => selectedNodeIds.includes(n.id));
|
||
},
|
||
|
||
getNodesByType: (type) => get().nodes.filter(n => n.type === type),
|
||
|
||
// 🆕 对话框附件操作
|
||
addPendingChatAttachment: (attachment) => set((state) => {
|
||
// 避免重复添加
|
||
if (!state.pendingChatAttachments.some(a => a.url === attachment.url)) {
|
||
state.pendingChatAttachments.push(attachment);
|
||
}
|
||
}),
|
||
|
||
clearPendingChatAttachments: () => set((state) => {
|
||
state.pendingChatAttachments = [];
|
||
}),
|
||
|
||
// 🆕 物品拾取操作
|
||
setPickerMode: (enabled, nodeId) => set((state) => {
|
||
state.isPickerMode = enabled;
|
||
state.pickerTargetNodeId = enabled ? (nodeId || null) : null;
|
||
if (!enabled) {
|
||
state.pickedObject = null;
|
||
state.isPickerLoading = false;
|
||
}
|
||
}),
|
||
|
||
setPickerLoading: (loading) => set((state) => {
|
||
state.isPickerLoading = loading;
|
||
}),
|
||
|
||
setPickedObject: (obj) => set((state) => {
|
||
state.pickedObject = obj;
|
||
}),
|
||
|
||
addPendingPickedObject: (obj) => set((state) => {
|
||
// 避免重复添加
|
||
if (!state.pendingPickedObjects.some(o =>
|
||
o.imageUrl === obj.imageUrl &&
|
||
o.clickX === obj.clickX &&
|
||
o.clickY === obj.clickY
|
||
)) {
|
||
state.pendingPickedObjects.push(obj);
|
||
}
|
||
}),
|
||
|
||
clearPendingPickedObjects: () => set((state) => {
|
||
state.pendingPickedObjects = [];
|
||
}),
|
||
})),
|
||
{
|
||
name: 'canvas-storage',
|
||
partialize: (state) => ({
|
||
// 只持久化部分状态
|
||
projectId: state.projectId,
|
||
projectTitle: state.projectTitle,
|
||
}),
|
||
}
|
||
),
|
||
{ name: 'canvas-store' }
|
||
)
|
||
);
|
||
|
||
// 导出选择器
|
||
export const canvasSelectors = {
|
||
nodes: (state: CanvasStore) => state.nodes,
|
||
edges: (state: CanvasStore) => state.edges,
|
||
selectedNodeIds: (state: CanvasStore) => state.selectedNodeIds,
|
||
viewport: (state: CanvasStore) => state.viewport,
|
||
tool: (state: CanvasStore) => state.tool,
|
||
isGenerating: (state: CanvasStore) => state.isGenerating,
|
||
hasSelection: (state: CanvasStore) => state.selectedNodeIds.length > 0,
|
||
canUndo: (state: CanvasStore) => state.historyIndex > 0,
|
||
canRedo: (state: CanvasStore) => state.historyIndex < state.history.length - 1,
|
||
};
|