'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import {
  Brush,
  BookOpen,
  ChevronDown,
  Copy,
  Download,
  FileImage,
  FolderOpen,
  Image as ImageIcon,
  Layers,
  Link2,
  Loader2,
  Maximize2,
  MousePointer2,
  Plus,
  RotateCcw,
  Save,
  Sparkles,
  StickyNote,
  Trash2,
  Type,
  Undo2,
  Upload,
  Wand2,
  Redo2,
  RefreshCw,
  Send,
  ShoppingCart,
  UserRound,
  ZoomIn,
  ZoomOut,
  X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/lib/auth-store';
import { useCustomApiKeys } from '@/lib/custom-api-store';
import { useManagedSystemApis } from '@/lib/managed-model-store';
import {
  ASPECT_RATIOS,
  IMG2IMG_ASPECT_RATIOS,
  RESOLUTION_OPTIONS,
  buildCustomModelId,
  buildSystemModelId,
  getCustomKeyId,
  getSystemApiId,
  isCustomModel,
  isSystemModel,
  resolveCustomApiImageSize,
  resolveImageSize,
  resolveImageSizeFromDimensions,
} from '@/lib/model-config';
import { runGenerationJob } from '@/lib/generation-job-client';
import { cn } from '@/lib/utils';
import type { CanvasAsset, CanvasConnection, CanvasLayer, CanvasNode, CanvasNodeType, CanvasProject, CanvasProjectState, CanvasViewport } from '@/lib/canvas-types';
import type { CanvasFlowControls } from './react-flow-canvas';

const ReactFlowCanvas = dynamic(() => import('./react-flow-canvas').then(mod => mod.ReactFlowCanvas), {
  ssr: false,
});

type AddMenu = {
  x: number;
  y: number;
  screenX: number;
  screenY: number;
} | null;

type WorkflowTemplateType =
  | 'text2img'
  | 'img2img'
  | 'image-chain'
  | 'layered'
  | 'multi-angle-storyboard'
  | 'product-ecommerce-full-set'
  | 'drama-character-design'
  | 'drama-scene-background'
  | 'picture-book-generator';

type WorkflowTemplateMeta = {
  id: WorkflowTemplateType;
  name: string;
  description: string;
  badge: string;
  icon: 'storyboard' | 'ecommerce' | 'character' | 'scene' | 'book';
  cover?: string;
};

const LOCAL_DRAFT_KEY = 'miaojing:canvas:last-project-id';
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
const LAYER_CANVAS_SIZE = 1080;
const LAYER_COLORS: Record<CanvasLayer['type'], string> = {
  background: '#f8fafc',
  element: '#38bdf8',
  icon: '#a78bfa',
  text: '#111827',
  effect: '#f59e0b',
};

const HUOBAO_WORKFLOW_TEMPLATES: WorkflowTemplateMeta[] = [
  {
    id: 'multi-angle-storyboard',
    name: '多角度分镜',
    description: '生成角色正视、侧视、后视、俯视四组分镜图。',
    badge: 'Storyboard',
    icon: 'storyboard',
    cover: '/canvas/workflows/workflow01.jpeg',
  },
  {
    id: 'product-ecommerce-full-set',
    name: '通用产品全套电商图',
    description: '根据产品信息和产品图生成模特图、展示图、拆解图。',
    badge: 'E-commerce',
    icon: 'ecommerce',
    cover: '/canvas/workflows/workflow02.jpeg',
  },
  {
    id: 'drama-character-design',
    name: '短剧角色设计',
    description: '从角色描述生成基准角色图，并衍生多角度角色素材。',
    badge: 'Drama',
    icon: 'character',
    cover: '/canvas/workflows/shot01.jpeg',
  },
  {
    id: 'drama-scene-background',
    name: '多时段场景背景',
    description: '为同一场景生成白天、黄昏、夜晚和雨天版本。',
    badge: 'Scene',
    icon: 'scene',
    cover: '/canvas/workflows/scene01.jpeg',
  },
  {
    id: 'picture-book-generator',
    name: '儿童绘本生成',
    description: '把故事设定拆成连续页面提示词和插画生成节点。',
    badge: 'Book',
    icon: 'book',
  },
];

const QUICK_SUGGESTIONS = ['像个魔法森林', '三只不同的小猫', '生成多角度分镜', '夏日田野环绕漫步'];

function nowIso() {
  return new Date().toISOString();
}

function createId(prefix: string) {
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function sameStringArray(a: string[], b: string[]) {
  return a.length === b.length && a.every((value, index) => value === b[index]);
}

function fileToDataUrl(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result || ''));
    reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
    reader.readAsDataURL(file);
  });
}

function getImageDimensions(src: string): Promise<{ width?: number; height?: number }> {
  return new Promise((resolve) => {
    const image = new Image();
    image.onload = () => resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height });
    image.onerror = () => resolve({});
    image.src = src;
  });
}

function createNode(type: CanvasNodeType, x: number, y: number, zIndex: number, patch: Partial<CanvasNode> = {}): CanvasNode {
  const createdAt = nowIso();
  const base: CanvasNode = {
    id: createId(type),
    type,
    x,
    y,
    width: type === 'text' ? 300 : type === 'frame' ? 640 : 360,
    height: type === 'text' ? 190 : type === 'frame' ? 420 : 420,
    zIndex,
    title: type === 'text'
      ? '文本'
      : type === 'image'
        ? '图片'
        : type === 'text2img'
          ? '文生图'
          : type === 'img2img'
            ? '图生图'
            : type === 'frame'
              ? '分组框'
              : '图层图片',
    status: 'idle',
    params: {
      aspectRatio: type === 'img2img' ? 'original' : '1:1',
      resolution: '2K',
      count: 1,
      strength: 0.5,
    },
    createdAt,
    updatedAt: createdAt,
  };
  if (type === 'text') base.text = '双击右侧属性面板编辑文本内容';
  if (type === 'frame') {
    base.color = '#22c55e';
    base.text = '把相关模块拖到这个区域内，用来整理一组创作流程。';
  }
  if (type === 'layeredImage') {
    base.layers = [
      { id: createId('layer'), name: '背景层', type: 'background', visible: true, locked: false, color: '#f8fafc', opacity: 1, x: 0, y: 0, width: 1080, height: 1080 },
      { id: createId('layer'), name: '主体元素层', type: 'element', visible: true, locked: false, color: '#38bdf8', opacity: 0.9, x: 120, y: 180, width: 840, height: 650 },
      { id: createId('layer'), name: '文字层', type: 'text', visible: true, locked: false, color: '#111827', opacity: 1, text: '可编辑文字', x: 120, y: 80, width: 840, height: 120 },
    ];
  }
  return { ...base, ...patch };
}

function getAuthHeaders(accessToken?: string | null) {
  return {
    'Content-Type': 'application/json',
    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
  };
}

function getNodeImageUrl(node?: CanvasNode | null) {
  if (!node) return '';
  if (node.type === 'image') return node.imageUrl || '';
  return node.selectedOutput || node.outputImages?.[0] || '';
}

function getNodeTextValue(node?: CanvasNode | null) {
  if (!node || node.type !== 'text') return '';
  return node.text?.trim() || '';
}

function isEditableTarget(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;
  return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
}

function downloadBlob(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  link.remove();
  window.setTimeout(() => URL.revokeObjectURL(url), 500);
}

function downloadJson(value: unknown, filename: string) {
  downloadBlob(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), filename);
}

function drawLayerPreview(ctx: CanvasRenderingContext2D, layer: CanvasLayer, scale = 1) {
  if (!layer.visible) return;
  const x = layer.x * scale;
  const y = layer.y * scale;
  const width = layer.width * scale;
  const height = layer.height * scale;
  ctx.save();
  ctx.globalAlpha = layer.opacity ?? 1;
  if (layer.type === 'text') {
    ctx.fillStyle = layer.color || LAYER_COLORS.text;
    ctx.font = `${Math.max(18, Math.min(72, height * 0.45))}px sans-serif`;
    ctx.textBaseline = 'middle';
    ctx.fillText(layer.text || layer.name, x + 12 * scale, y + height / 2, Math.max(24, width - 24 * scale));
  } else if (layer.assetUrl) {
    ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
    ctx.fillRect(x, y, width, height);
  } else if (layer.type === 'background') {
    ctx.fillStyle = layer.color || LAYER_COLORS.background;
    ctx.fillRect(x, y, width, height);
  } else {
    const radius = Math.min(24 * scale, width / 5, height / 5);
    ctx.fillStyle = layer.color || LAYER_COLORS[layer.type];
    ctx.beginPath();
    ctx.roundRect(x, y, width, height, radius);
    ctx.fill();
  }
  ctx.restore();
}

function renderLayeredNodeToCanvas(node: CanvasNode, size = LAYER_CANVAS_SIZE) {
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('当前浏览器不支持画布导出');
  ctx.clearRect(0, 0, size, size);
  const scale = size / LAYER_CANVAS_SIZE;
  (node.layers || []).forEach(layer => drawLayerPreview(ctx, layer, scale));
  return canvas;
}

function getCanvasBounds(nodes: CanvasNode[]) {
  if (nodes.length === 0) return null;
  const left = Math.min(...nodes.map(node => node.x));
  const top = Math.min(...nodes.map(node => node.y));
  const right = Math.max(...nodes.map(node => node.x + node.width));
  const bottom = Math.max(...nodes.map(node => node.y + node.height));
  return { left, top, right, bottom, width: right - left, height: bottom - top };
}

function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta['icon']; className?: string }) {
  const Icon = icon === 'ecommerce'
    ? ShoppingCart
    : icon === 'character'
      ? UserRound
      : icon === 'scene'
        ? ImageIcon
        : icon === 'book'
          ? BookOpen
          : Layers;
  return <Icon className={className} />;
}

export function InfiniteCanvasWorkspace() {
  const { user, accessToken } = useAuth();
  const { imageKeys } = useCustomApiKeys();
  const managedSystemApis = useManagedSystemApis();
  const fileInputRef = useRef<HTMLInputElement>(null);
  const importInputRef = useRef<HTMLInputElement>(null);
  const canvasRef = useRef<HTMLDivElement>(null);
  const flowControlsRef = useRef<CanvasFlowControls | null>(null);
  const historyRef = useRef<{ past: CanvasProjectState[]; future: CanvasProjectState[]; skip: boolean }>({ past: [], future: [], skip: false });
  const [projects, setProjects] = useState<CanvasProject[]>([]);
  const [project, setProject] = useState<CanvasProject | null>(null);
  const [state, setState] = useState<CanvasProjectState>({ nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
  const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
  const [addMenu, setAddMenu] = useState<AddMenu>(null);
  const [loading, setLoading] = useState(false);
  const [saving, setSaving] = useState(false);
  const [dirty, setDirty] = useState(false);
  const [creatingProject, setCreatingProject] = useState(false);
  const [connectingFromId, setConnectingFromId] = useState<string | null>(null);
  const [isDraggingFile, setIsDraggingFile] = useState(false);
  const [mounted, setMounted] = useState(false);
  const [showNodeMenu, setShowNodeMenu] = useState(false);
  const [showWorkflowPanel, setShowWorkflowPanel] = useState(false);
  const [workflowTab, setWorkflowTab] = useState<'public' | 'my'>('public');
  const [showProjectMenu, setShowProjectMenu] = useState(false);
  const [chatInput, setChatInput] = useState('');
  const [autoExecute, setAutoExecute] = useState(false);

  const selectedNode = useMemo(
    () => state.nodes.find(node => node.id === selectedNodeId) || null,
    [selectedNodeId, state.nodes],
  );

  const selectedNodes = useMemo(
    () => state.nodes.filter(node => selectedNodeIds.includes(node.id)),
    [selectedNodeIds, state.nodes],
  );

  const selectedNodeConnections = useMemo(
    () => selectedNode
      ? state.connections.filter(connection => connection.sourceNodeId === selectedNode.id || connection.targetNodeId === selectedNode.id)
      : [],
    [selectedNode, state.connections],
  );

  const canvasBounds = useMemo(() => getCanvasBounds(state.nodes), [state.nodes]);
  const selectedBounds = useMemo(() => getCanvasBounds(selectedNodes), [selectedNodes]);

  const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
  const modelOptions = useMemo(() => [
    ...systemImageApis.map(api => ({
      id: buildSystemModelId(api.id),
      label: `${api.name} (系统)`,
      modelName: api.modelName,
      source: 'system' as const,
    })),
    ...imageKeys.map(key => ({
      id: buildCustomModelId(key.id),
      label: `${key.modelName || key.provider} (自定义)`,
      modelName: key.modelName,
      source: 'custom' as const,
    })),
  ], [systemImageApis, imageKeys]);

  useEffect(() => {
    setMounted(true);
  }, []);

  const mutateState = useCallback((updater: (current: CanvasProjectState) => CanvasProjectState, options?: { history?: boolean; dirty?: boolean }) => {
    setState(current => {
      const next = updater(current);
      if (!historyRef.current.skip && options?.history !== false && next !== current) {
        historyRef.current.past = [...historyRef.current.past.slice(-49), current];
        historyRef.current.future = [];
      }
      return next;
    });
    if (options?.dirty !== false) {
      setDirty(true);
    }
  }, []);

  const replaceCanvasState = useCallback((nextState: CanvasProjectState, options?: { dirty?: boolean }) => {
    historyRef.current.skip = true;
    setState(nextState);
    window.setTimeout(() => {
      historyRef.current.skip = false;
    }, 0);
    setDirty(options?.dirty ?? false);
  }, []);

  const resetHistory = useCallback(() => {
    historyRef.current = { past: [], future: [], skip: false };
  }, []);

  const screenToCanvasPoint = useCallback((clientX: number, clientY: number, viewport: CanvasProjectState['viewport'] = state.viewport) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    const left = rect?.left || 0;
    const top = rect?.top || 0;
    return {
      x: (clientX - left - viewport.x) / viewport.zoom,
      y: (clientY - top - viewport.y) / viewport.zoom,
    };
  }, [state.viewport]);

  const getViewportCenterPoint = useCallback(() => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: 160, y: 160 };
    return screenToCanvasPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
  }, [screenToCanvasPoint]);

  const loadProjects = useCallback(async () => {
    if (!accessToken) return;
    setLoading(true);
    try {
      const res = await fetch('/api/canvas/projects', { headers: getAuthHeaders(accessToken) });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '读取画布项目失败');
      const nextProjects = Array.isArray(data.projects) ? data.projects as CanvasProject[] : [];
      setProjects(nextProjects);
      const lastId = window.localStorage.getItem(LOCAL_DRAFT_KEY);
      const nextProject = nextProjects.find(item => item.id === lastId) || nextProjects[0] || null;
      if (nextProject) {
        setProject(nextProject);
        replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] });
        resetHistory();
        setSelectedNodeId(null);
        setSelectedNodeIds([]);
        setDirty(false);
      }
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '读取画布项目失败');
    } finally {
      setLoading(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  useEffect(() => {
    void loadProjects();
  }, [loadProjects]);

  const openProject = useCallback(async (id: string) => {
    if (!accessToken) return;
    setLoading(true);
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(id)}`, { headers: getAuthHeaders(accessToken) });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '读取画布失败');
      setProject(data.project as CanvasProject);
      replaceCanvasState({
        ...(data.project as CanvasProject).state,
        connections: (data.project as CanvasProject).state.connections || [],
      });
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      window.localStorage.setItem(LOCAL_DRAFT_KEY, id);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '读取画布失败');
    } finally {
      setLoading(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  const createProject = useCallback(async () => {
    if (!accessToken) {
      toast.error('请先登录后再使用无限画布');
      return;
    }
    setCreatingProject(true);
    try {
      const res = await fetch('/api/canvas/projects', {
        method: 'POST',
        headers: getAuthHeaders(accessToken),
        body: JSON.stringify({ title: `创作画布 ${new Date().toLocaleDateString('zh-CN')}` }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '创建画布失败');
      const nextProject = data.project as CanvasProject;
      setProjects(prev => [nextProject, ...prev]);
      setProject(nextProject);
      replaceCanvasState({ ...nextProject.state, connections: nextProject.state.connections || [] });
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      toast.success('已创建画布');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '创建画布失败');
    } finally {
      setCreatingProject(false);
    }
  }, [accessToken, replaceCanvasState, resetHistory]);

  const saveProject = useCallback(async () => {
    if (!project || !accessToken) return;
    setSaving(true);
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
        method: 'PUT',
        headers: getAuthHeaders(accessToken),
        body: JSON.stringify({ title: project.title, state }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '保存失败');
      const nextProject = data.project as CanvasProject;
      setProject(nextProject);
      setProjects(prev => prev.map(item => item.id === nextProject.id ? nextProject : item));
      setDirty(false);
      toast.success('画布已保存');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '保存失败');
    } finally {
      setSaving(false);
    }
  }, [accessToken, project, state]);

  const deleteCurrentProject = useCallback(async () => {
    if (!project || !accessToken) return;
    const ok = window.confirm(`确定删除画布“${project.title}”吗？此操作不能撤销。`);
    if (!ok) return;
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(project.id)}`, {
        method: 'DELETE',
        headers: getAuthHeaders(accessToken),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '删除失败');
      const remaining = projects.filter(item => item.id !== project.id);
      setProjects(remaining);
      const nextProject = remaining[0] || null;
      setProject(nextProject);
      replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      if (nextProject) {
        window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      } else {
        window.localStorage.removeItem(LOCAL_DRAFT_KEY);
      }
      toast.success('画布已删除');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '删除失败');
    }
  }, [accessToken, project, projects, replaceCanvasState, resetHistory]);

  const deleteProject = useCallback(async (target: CanvasProject) => {
    if (!accessToken) return;
    const ok = window.confirm(`确定删除工作流“${target.title}”吗？此操作不能撤销。`);
    if (!ok) return;
    try {
      const res = await fetch(`/api/canvas/projects/${encodeURIComponent(target.id)}`, {
        method: 'DELETE',
        headers: getAuthHeaders(accessToken),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || '删除失败');
      const remaining = projects.filter(item => item.id !== target.id);
      setProjects(remaining);

      if (project?.id !== target.id) {
        toast.success('工作流已删除');
        return;
      }

      const nextProject = remaining[0] || null;
      setProject(nextProject);
      replaceCanvasState(nextProject ? { ...nextProject.state, connections: nextProject.state.connections || [] } : { nodes: [], connections: [], assets: [], viewport: { x: 0, y: 0, zoom: 1 } });
      resetHistory();
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      setDirty(false);
      if (nextProject) {
        window.localStorage.setItem(LOCAL_DRAFT_KEY, nextProject.id);
      } else {
        window.localStorage.removeItem(LOCAL_DRAFT_KEY);
      }
      toast.success('工作流已删除');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '删除失败');
    }
  }, [accessToken, project, projects, replaceCanvasState, resetHistory]);

  const exportProjectJson = useCallback(() => {
    if (!project) return;
    const safeTitle = project.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'canvas';
    downloadJson({ version: 1, title: project.title, state }, `${safeTitle}.miaojing-canvas.json`);
  }, [project, state]);

  const importProjectJson = useCallback(async (file: File) => {
    try {
      const text = await file.text();
      const parsed = JSON.parse(text) as { title?: unknown; state?: unknown; nodes?: unknown };
      const importedState = parsed.state || parsed;
      const nextState = {
        ...(importedState as CanvasProjectState),
        connections: Array.isArray((importedState as CanvasProjectState).connections) ? (importedState as CanvasProjectState).connections : [],
        nodes: Array.isArray((importedState as CanvasProjectState).nodes) ? (importedState as CanvasProjectState).nodes : [],
        assets: Array.isArray((importedState as CanvasProjectState).assets) ? (importedState as CanvasProjectState).assets : [],
        viewport: (importedState as CanvasProjectState).viewport || { x: 0, y: 0, zoom: 1 },
      };
      replaceCanvasState(nextState, { dirty: true });
      resetHistory();
      if (project && typeof parsed.title === 'string' && parsed.title.trim()) {
        setProject({ ...project, title: parsed.title.trim() });
      }
      setSelectedNodeId(null);
      setSelectedNodeIds([]);
      toast.success('画布已导入');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '导入失败');
    }
  }, [project, replaceCanvasState, resetHistory]);

  const handleImportFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) return;
    void importProjectJson(file);
  }, [importProjectJson]);

  useEffect(() => {
    if (!dirty || !project || !accessToken) return;
    const timer = window.setTimeout(() => {
      void saveProject();
    }, 2500);
    return () => window.clearTimeout(timer);
  }, [accessToken, dirty, project, saveProject]);

  const updateNode = useCallback((id: string, patch: Partial<CanvasNode>) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === id ? { ...node, ...patch, updatedAt: nowIso() } : node),
    }));
  }, [mutateState]);

  const updateLayer = useCallback((nodeId: string, layerId: string, patch: Partial<CanvasLayer>) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (node.id !== nodeId) return node;
        return {
          ...node,
          updatedAt: nowIso(),
          layers: (node.layers || []).map(layer => layer.id === layerId ? { ...layer, ...patch } : layer),
        };
      }),
    }));
  }, [mutateState]);

  const addLayer = useCallback((nodeId: string, type: CanvasLayer['type']) => {
    const layer: CanvasLayer = {
      id: createId('layer'),
      name: type === 'background' ? '背景层' : type === 'text' ? '文字层' : type === 'icon' ? '图标层' : type === 'effect' ? '效果层' : '元素层',
      type,
      visible: true,
      locked: false,
      color: LAYER_COLORS[type],
      opacity: type === 'background' ? 1 : 0.9,
      text: type === 'text' ? '双击编辑文字' : undefined,
      x: type === 'background' ? 0 : 180,
      y: type === 'background' ? 0 : 180,
      width: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 720 : 360,
      height: type === 'background' ? LAYER_CANVAS_SIZE : type === 'text' ? 120 : 320,
    };
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: [...(node.layers || []), layer], updatedAt: nowIso() } : node),
    }));
  }, [mutateState]);

  const removeLayer = useCallback((nodeId: string, layerId: string) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === nodeId ? { ...node, layers: (node.layers || []).filter(layer => layer.id !== layerId), updatedAt: nowIso() } : node),
    }));
  }, [mutateState]);

  const moveLayer = useCallback((nodeId: string, layerId: string, direction: -1 | 1) => {
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (node.id !== nodeId) return node;
        const layers = [...(node.layers || [])];
        const index = layers.findIndex(layer => layer.id === layerId);
        const nextIndex = index + direction;
        if (index < 0 || nextIndex < 0 || nextIndex >= layers.length) return node;
        const [layer] = layers.splice(index, 1);
        layers.splice(nextIndex, 0, layer);
        return { ...node, layers, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState]);

  const addNode = useCallback((type: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
    const zIndex = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1;
    const node = createNode(type, x, y, zIndex, patch);
    mutateState(current => ({ ...current, nodes: [...current.nodes, node] }));
    setSelectedNodeId(node.id);
    setSelectedNodeIds([node.id]);
    setAddMenu(null);
    return node;
  }, [mutateState, state.nodes]);

  const addWorkflowTemplate = useCallback((type: WorkflowTemplateType, origin?: { x: number; y: number }) => {
    const startX = origin?.x ?? 120;
    const startY = origin?.y ?? 120;
    const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
    const createdAt = nowIso();
    const nodes: CanvasNode[] = [];
    const connections: CanvasConnection[] = [];
    const pushNode = (nodeType: CanvasNodeType, x: number, y: number, patch: Partial<CanvasNode> = {}) => {
      const node = createNode(nodeType, x, y, baseZ + nodes.length + 1, patch);
      nodes.push(node);
      return node;
    };
    const connect = (source: CanvasNode, target: CanvasNode, label?: string) => {
      connections.push({
        id: createId('connection'),
        sourceNodeId: source.id,
        targetNodeId: target.id,
        label,
        createdAt,
      });
    };

    if (type === 'text2img') {
      const prompt = pushNode('text', startX, startY, { title: '提示词草稿', text: '在这里写画面主体、风格、光线、构图和细节要求。' });
      const generator = pushNode('text2img', startX + 380, startY, { title: '文生图生成', prompt: '高质量商业视觉，细节丰富，构图清晰' });
      const result = pushNode('image', startX + 800, startY, { title: '结果图', width: 380, height: 420 });
      connect(prompt, generator, '提示词');
      connect(generator, result, '生成结果');
    } else if (type === 'img2img') {
      const reference = pushNode('image', startX, startY, { title: '参考图', width: 360, height: 360 });
      const prompt = pushNode('text', startX, startY + 420, { title: '改图要求', text: '描述保留哪些内容，以及需要替换的风格、背景、元素和细节。' });
      const generator = pushNode('img2img', startX + 420, startY + 120, { title: '图生图生成', prompt: '保留主体结构，提升质感和细节' });
      const result = pushNode('image', startX + 840, startY + 120, { title: '改图结果', width: 380, height: 420 });
      connect(reference, generator, '参考图');
      connect(prompt, generator, '提示词');
      connect(generator, result, '生成结果');
    } else if (type === 'image-chain') {
      const prompt = pushNode('text', startX, startY, { title: '创意方向', text: '先生成主视觉，再把结果作为参考继续细化。' });
      const text2img = pushNode('text2img', startX + 380, startY, { title: '第一轮文生图', prompt: '主视觉概念图，清晰主体，完整构图' });
      const first = pushNode('image', startX + 800, startY, { title: '第一轮结果', width: 360, height: 380 });
      const img2img = pushNode('img2img', startX + 1220, startY, { title: '第二轮精修', prompt: '在保留构图基础上增强质感、材质、光影和细节' });
      const final = pushNode('image', startX + 1640, startY, { title: '最终结果', width: 380, height: 420 });
      connect(prompt, text2img, '提示词');
      connect(text2img, first, '生成结果');
      connect(first, img2img, '参考图');
      connect(img2img, final, '生成结果');
    } else {
      const prompt = pushNode('text', startX, startY, { title: '图层设计说明', text: '描述背景、主体元素、图标、文字和效果层的编辑要求。' });
      const generator = pushNode('text2img', startX + 380, startY, { title: '视觉生成', prompt: '可拆分图层的海报设计，背景、主体、图标和文字层次清晰' });
      const layered = pushNode('layeredImage', startX + 800, startY, { title: '图层整理与 PSD 导出', width: 460, height: 500 });
      connect(prompt, generator, '提示词');
      connect(generator, layered, '图层整理');
    }

    mutateState(current => ({
      ...current,
      nodes: [...current.nodes, ...nodes],
      connections: [...current.connections, ...connections],
    }));
    setSelectedNodeId(nodes[0]?.id || null);
    setSelectedNodeIds(nodes.map(node => node.id));
    setAddMenu(null);
    toast.success('已添加工作流模板');
  }, [mutateState, state.nodes]);

  const removeSelectedNode = useCallback(() => {
    const ids = selectedNodeIds.length > 0 ? selectedNodeIds : selectedNodeId ? [selectedNodeId] : [];
    if (ids.length === 0) return;
    const idSet = new Set(ids);
    mutateState(current => ({
      ...current,
      nodes: current.nodes.filter(node => !idSet.has(node.id)),
      connections: current.connections.filter(
        connection => !idSet.has(connection.sourceNodeId) && !idSet.has(connection.targetNodeId),
      ),
    }));
    setConnectingFromId(current => current && idSet.has(current) ? null : current);
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [mutateState, selectedNodeId, selectedNodeIds]);

  const duplicateSelectedNode = useCallback(() => {
    const sourceNodes = selectedNodes.length > 0 ? selectedNodes : selectedNode ? [selectedNode] : [];
    if (sourceNodes.length === 0) return;
    const createdAt = nowIso();
    const baseZ = state.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0);
    const duplicates = sourceNodes.map((source, index): CanvasNode => ({
      ...source,
      id: createId(source.type),
      title: `${source.title} 副本`,
      x: source.x + 32,
      y: source.y + 32,
      zIndex: baseZ + index + 1,
      createdAt,
      updatedAt: createdAt,
      layers: source.layers?.map(layer => ({ ...layer, id: createId('layer') })),
    }));
    mutateState(current => ({ ...current, nodes: [...current.nodes, ...duplicates] }));
    setSelectedNodeId(duplicates[0]?.id || null);
    setSelectedNodeIds(duplicates.map(node => node.id));
  }, [mutateState, selectedNode, selectedNodes, state.nodes]);

  const addConnection = useCallback((sourceNodeId: string, targetNodeId: string, options?: { silent?: boolean }) => {
    if (sourceNodeId === targetNodeId) {
      toast.info('不能连接到同一个模块');
      return;
    }
    mutateState(current => {
      const exists = current.connections.some(
        connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId,
      );
      if (exists) return current;
      const connection: CanvasConnection = {
        id: createId('connection'),
        sourceNodeId,
        targetNodeId,
        createdAt: nowIso(),
      };
      return { ...current, connections: [...current.connections, connection] };
    });
    const source = state.nodes.find(node => node.id === sourceNodeId);
    const target = state.nodes.find(node => node.id === targetNodeId);
    if (source?.type === 'image' && source.imageUrl && target?.type === 'img2img') {
      updateNode(target.id, { referenceImage: source.imageUrl });
      if (!options?.silent) toast.success('已连接，并将图片设为图生图参考');
    } else if (!options?.silent) {
      toast.success('已连接模块');
    }
  }, [mutateState, state.nodes, updateNode]);

  const removeConnection = useCallback((connectionId: string) => {
    mutateState(current => ({
      ...current,
      connections: current.connections.filter(connection => connection.id !== connectionId),
    }));
  }, [mutateState]);

  const alignSelectedNodes = useCallback((mode: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
    if (!selectedBounds || selectedNodes.length < 2) return;
    const idSet = new Set(selectedNodes.map(node => node.id));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        if (!idSet.has(node.id)) return node;
        if (mode === 'left') return { ...node, x: selectedBounds.left, updatedAt: nowIso() };
        if (mode === 'center') return { ...node, x: selectedBounds.left + selectedBounds.width / 2 - node.width / 2, updatedAt: nowIso() };
        if (mode === 'right') return { ...node, x: selectedBounds.right - node.width, updatedAt: nowIso() };
        if (mode === 'top') return { ...node, y: selectedBounds.top, updatedAt: nowIso() };
        if (mode === 'middle') return { ...node, y: selectedBounds.top + selectedBounds.height / 2 - node.height / 2, updatedAt: nowIso() };
        return { ...node, y: selectedBounds.bottom - node.height, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState, selectedBounds, selectedNodes]);

  const distributeSelectedNodes = useCallback((axis: 'x' | 'y') => {
    if (selectedNodes.length < 3) return;
    const sorted = [...selectedNodes].sort((a, b) => axis === 'x' ? a.x - b.x : a.y - b.y);
    const first = sorted[0];
    const last = sorted[sorted.length - 1];
    if (!first || !last) return;
    const start = axis === 'x' ? first.x : first.y;
    const end = axis === 'x' ? last.x : last.y;
    const step = (end - start) / (sorted.length - 1);
    const positions = new Map(sorted.map((node, index) => [node.id, start + step * index]));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        const value = positions.get(node.id);
        if (typeof value !== 'number') return node;
        return axis === 'x' ? { ...node, x: value, updatedAt: nowIso() } : { ...node, y: value, updatedAt: nowIso() };
      }),
    }));
  }, [mutateState, selectedNodes]);

  const createFrameAroundSelection = useCallback(() => {
    const targetNodes = selectedNodes.filter(node => node.type !== 'frame');
    const bounds = getCanvasBounds(targetNodes);
    if (!bounds) {
      toast.info('先选择需要整理到分组框里的模块');
      return;
    }
    const padding = 48;
    const frame = addNode('frame', bounds.left - padding, bounds.top - padding, {
      title: '分组框',
      width: bounds.width + padding * 2,
      height: bounds.height + padding * 2,
      color: '#22c55e',
      text: '流程分组',
      zIndex: 0,
    });
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => node.id === frame.id ? { ...node, zIndex: 0 } : node),
    }));
    setSelectedNodeIds([frame.id, ...targetNodes.map(node => node.id)]);
    setSelectedNodeId(frame.id);
  }, [addNode, mutateState, selectedNodes]);

  const handleConnectorClick = useCallback((nodeId: string, role: 'input' | 'output') => {
    if (role === 'output') {
      setConnectingFromId(current => current === nodeId ? null : nodeId);
      setSelectedNodeId(nodeId);
      setSelectedNodeIds([nodeId]);
      return;
    }
    if (!connectingFromId) {
      toast.info('先点击一个模块右侧的输出端口');
      return;
    }
    addConnection(connectingFromId, nodeId);
    setConnectingFromId(null);
    setSelectedNodeId(nodeId);
    setSelectedNodeIds([nodeId]);
  }, [addConnection, connectingFromId]);

  const addImageAsset = useCallback(async (file: File, point?: { x: number; y: number }) => {
    const dataUrl = await fileToDataUrl(file);
    const dimensions = await getImageDimensions(dataUrl);
    const asset: CanvasAsset = {
      id: createId('asset'),
      url: dataUrl,
      name: file.name || '上传图片',
      type: 'image',
      createdAt: nowIso(),
    };
    mutateState(current => ({ ...current, assets: [asset, ...current.assets] }));
    addNode('image', point?.x ?? 120, point?.y ?? 120, {
      title: file.name || '图片',
      imageUrl: dataUrl,
      width: Math.min(520, Math.max(260, (dimensions.width || 600) / 2)),
      height: Math.min(520, Math.max(240, (dimensions.height || 420) / 2 + 80)),
    });
  }, [addNode, mutateState]);

  const renderSingleLayerToCanvas = useCallback((layer: CanvasLayer) => {
    const canvas = document.createElement('canvas');
    canvas.width = LAYER_CANVAS_SIZE;
    canvas.height = LAYER_CANVAS_SIZE;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('当前浏览器不支持 PSD 图层导出');
    drawLayerPreview(ctx, { ...layer, visible: true }, 1);
    return canvas;
  }, []);

  const exportLayeredNode = useCallback(async (node: CanvasNode, format: 'png' | 'json' | 'psd') => {
    try {
      const safeTitle = node.title.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'layered-image';
      if (format === 'json') {
        downloadBlob(
          new Blob([JSON.stringify({ version: 1, title: node.title, layers: node.layers || [] }, null, 2)], { type: 'application/json' }),
          `${safeTitle}.layers.json`,
        );
        return;
      }
      const canvas = renderLayeredNodeToCanvas(node);
      if (format === 'png') {
        canvas.toBlob(blob => {
          if (blob) downloadBlob(blob, `${safeTitle}.png`);
        }, 'image/png');
        return;
      }
      const { writePsd } = await import('ag-psd');
      type AgPsdLayer = {
        name: string;
        top: number;
        left: number;
        bottom: number;
        right: number;
        hidden?: boolean;
        opacity?: number;
        canvas?: HTMLCanvasElement;
        text?: {
          text: string;
          transform: [number, number, number, number, number, number];
          style: {
            font: { name: string };
            fontSize: number;
            fillColor: { r: number; g: number; b: number };
          };
        };
      };
      const children: AgPsdLayer[] = (node.layers || []).map(layer => {
        const top = Math.max(0, Math.round(layer.y));
        const left = Math.max(0, Math.round(layer.x));
        const bottom = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.y + layer.height));
        const right = Math.min(LAYER_CANVAS_SIZE, Math.round(layer.x + layer.width));
        const canvasLayer: AgPsdLayer = {
          name: layer.name,
          top,
          left,
          bottom,
          right,
          hidden: !layer.visible,
          opacity: Math.round((layer.opacity ?? 1) * 255),
          canvas: renderSingleLayerToCanvas(layer),
        };
        if (layer.type === 'text') {
          const color = layer.color || LAYER_COLORS.text;
          const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
          canvasLayer.text = {
            text: layer.text || layer.name,
            transform: [1, 0, 0, 1, layer.x + 12, layer.y + Math.max(18, Math.min(72, layer.height * 0.45))],
            style: {
              font: { name: 'ArialMT' },
              fontSize: Math.max(18, Math.min(72, layer.height * 0.45)),
              fillColor: rgb
                ? { r: parseInt(rgb[1], 16), g: parseInt(rgb[2], 16), b: parseInt(rgb[3], 16) }
                : { r: 17, g: 24, b: 39 },
            },
          };
        }
        return canvasLayer;
      });
      const buffer = writePsd({
        width: LAYER_CANVAS_SIZE,
        height: LAYER_CANVAS_SIZE,
        children,
        canvas,
      }, { noBackground: true, invalidateTextLayers: true, generateThumbnail: true });
      downloadBlob(new Blob([buffer], { type: 'image/vnd.adobe.photoshop' }), `${safeTitle}.psd`);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : '导出失败');
    }
  }, [renderSingleLayerToCanvas]);

  const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      toast.error('请上传图片文件');
      return;
    }
    void addImageAsset(file, { x: 160, y: 160 });
  }, [addImageAsset]);

  const handlePasteImage = useCallback((event: ClipboardEvent) => {
    if (isEditableTarget(event.target)) return;
    const file = Array.from(event.clipboardData?.files || []).find(item => item.type.startsWith('image/'));
    if (!file) return;
    event.preventDefault();
    void addImageAsset(file, screenToCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
  }, [addImageAsset, screenToCanvasPoint]);

  useEffect(() => {
    window.addEventListener('paste', handlePasteImage);
    return () => window.removeEventListener('paste', handlePasteImage);
  }, [handlePasteImage]);

  const buildGenerationPayload = useCallback((node: CanvasNode, canvasState: CanvasProjectState = state) => {
    const incomingNodes = canvasState.connections
      .filter(connection => connection.targetNodeId === node.id)
      .map(connection => canvasState.nodes.find(item => item.id === connection.sourceNodeId))
      .filter((item): item is CanvasNode => !!item);
    const incomingPromptText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
    const incomingImageNode = incomingNodes.find(item => !!getNodeImageUrl(item));
    const referenceImage = node.referenceImage || getNodeImageUrl(incomingImageNode);
    const basePrompt = node.prompt?.trim() || '';
    const prompt = incomingPromptText
      ? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n')
      : basePrompt;
    const selectedModel = node.params?.model || modelOptions[0]?.id || '';
    const aspectRatio = node.params?.aspectRatio || (node.type === 'img2img' ? 'original' : '1:1');
    const resolution = node.params?.resolution || '2K';
    const count = node.params?.count || 1;
    const isExternal = isCustomModel(selectedModel) || isSystemModel(selectedModel);
    const payload: Record<string, unknown> = {
      prompt,
      negativePrompt: node.negativePrompt?.trim() || undefined,
      model: selectedModel,
      aspectRatio,
      resolution,
      quality: resolution,
      count,
    };

    if (node.type === 'img2img') {
      payload.image = referenceImage;
      payload.strength = node.params?.strength ?? 0.5;
      payload.size = aspectRatio === 'original'
        ? resolveImageSizeFromDimensions(undefined, undefined, resolution)
        : isExternal
          ? resolveCustomApiImageSize(aspectRatio, resolution)
          : resolveImageSize(aspectRatio, resolution);
    } else {
      payload.size = isExternal
        ? resolveCustomApiImageSize(aspectRatio, resolution)
        : resolveImageSize(aspectRatio, resolution);
    }

    if (isCustomModel(selectedModel)) {
      const key = imageKeys.find(item => item.id === getCustomKeyId(selectedModel));
      if (key) {
        payload.model = key.modelName;
        payload.customApiConfig = { customApiKeyId: key.id, modelName: key.modelName };
      }
    } else if (isSystemModel(selectedModel)) {
      const api = systemImageApis.find(item => item.id === getSystemApiId(selectedModel));
      if (api) {
        payload.model = api.modelName;
        payload.customApiConfig = { systemApiId: api.id, modelName: api.modelName };
      }
    }

    return payload;
  }, [imageKeys, modelOptions, state, systemImageApis]);

  const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => {
    const canvasState = options?.canvasState || state;
    if (!user) {
      toast.error('请先登录后再生成');
      return null;
    }
    if (!node.prompt?.trim()) {
      const hasIncomingText = canvasState.connections
        .filter(connection => connection.targetNodeId === node.id)
        .some(connection => {
          const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
          return !!getNodeTextValue(source);
        });
      if (!hasIncomingText) {
        toast.error('请输入创作描述，或连接文本模块作为提示词');
        return null;
      }
    }
    const hasIncomingImage = canvasState.connections
      .filter(connection => connection.targetNodeId === node.id)
      .some(connection => {
        const source = canvasState.nodes.find(item => item.id === connection.sourceNodeId);
        return !!getNodeImageUrl(source);
      });
    if (node.type === 'img2img' && !node.referenceImage && !hasIncomingImage) {
      toast.error('图生图模块需要先选择参考图');
      return null;
    }
    updateNode(node.id, { status: 'generating', error: undefined, outputImages: [] });
    try {
      const payload = buildGenerationPayload(node, canvasState);
      const normalizedModel = String(payload.model || '').toLowerCase().replace(/[^a-z0-9]/g, '');
      const timeoutMs = normalizedModel === 'image2' || normalizedModel === 'gptimage2' ? 1_200_000 : 300_000;
      const data = await runGenerationJob<{ images?: string[]; referenceImage?: string; apiType?: 'stream' | 'sync'; error?: string }>(
        'image',
        payload,
        { timeoutMs },
      );
      if (!data.images || data.images.length === 0) {
        throw new Error(data.error || '生成结果为空');
      }
      const images = data.images;
      const apiType = data.apiType === 'stream' ? 'stream' : 'sync';
      const updatedAt = nowIso();
      const sourcePatch: Partial<CanvasNode> = {
        status: 'succeeded',
        outputImages: images,
        selectedOutput: images[0],
        referenceImage: typeof data.referenceImage === 'string' ? data.referenceImage : node.referenceImage,
        params: { ...node.params, apiType },
      };
      const existingOutputNode = canvasState.connections
        .filter(connection => connection.sourceNodeId === node.id)
        .map(connection => canvasState.nodes.find(item => item.id === connection.targetNodeId))
        .find((item): item is CanvasNode => item?.type === 'image');
      const generatedOutputNode = existingOutputNode
        ? {
          ...existingOutputNode,
          imageUrl: images[0],
          outputImages: images,
          selectedOutput: images[0],
          status: 'succeeded' as const,
          updatedAt,
        }
        : createNode('image', node.x + node.width + 40, node.y, canvasState.nodes.reduce((max, item) => Math.max(max, item.zIndex), 0) + 1, {
          title: `${node.title}结果`,
          imageUrl: images[0],
          outputImages: images,
          selectedOutput: images[0],
          width: 380,
          height: 420,
          status: 'succeeded',
        });
      const outputConnection = existingOutputNode
        ? null
        : {
          id: createId('connection'),
          sourceNodeId: node.id,
          targetNodeId: generatedOutputNode.id,
          label: '生成结果',
          createdAt: updatedAt,
        };
      const nextCanvasState: CanvasProjectState = {
        ...canvasState,
        nodes: canvasState.nodes
          .map(item => {
            if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
            if (item.id === generatedOutputNode.id) return generatedOutputNode;
            return item;
          })
          .concat(existingOutputNode ? [] : [generatedOutputNode]),
        connections: outputConnection ? [...canvasState.connections, outputConnection] : canvasState.connections,
      };
      mutateState(current => ({
        ...current,
        nodes: current.nodes
          .map(item => {
            if (item.id === node.id) return { ...item, ...sourcePatch, updatedAt };
            if (item.id === generatedOutputNode.id) return generatedOutputNode;
            return item;
          })
          .concat(current.nodes.some(item => item.id === generatedOutputNode.id) ? [] : [generatedOutputNode]),
        connections: outputConnection && !current.connections.some(item => item.sourceNodeId === outputConnection.sourceNodeId && item.targetNodeId === outputConnection.targetNodeId)
          ? [...current.connections, outputConnection]
          : current.connections,
      }));
      if (options?.selectOutput !== false) {
        setSelectedNodeId(generatedOutputNode.id);
        setSelectedNodeIds([generatedOutputNode.id]);
      }
      toast.success(`已生成 ${images.length} 张图片`);
      return { images, outputNodeId: generatedOutputNode.id, canvasState: nextCanvasState };
    } catch (error) {
      updateNode(node.id, { status: 'failed', error: error instanceof Error ? error.message : '生成失败' });
      toast.error(error instanceof Error ? error.message : '生成失败');
      return null;
    }
  }, [buildGenerationPayload, mutateState, state, updateNode, user]);

  const runDownstream = useCallback(async (node: CanvasNode) => {
    let workingState = state;
    const visited = new Set<string>();
    const queue = [node.id];
    const runnable: CanvasNode[] = node.type === 'text2img' || node.type === 'img2img' ? [node] : [];
    while (queue.length > 0) {
      const currentId = queue.shift();
      if (!currentId || visited.has(currentId)) continue;
      visited.add(currentId);
      const outgoing = workingState.connections.filter(connection => connection.sourceNodeId === currentId);
      for (const connection of outgoing) {
        const target = workingState.nodes.find(item => item.id === connection.targetNodeId);
        if (!target || visited.has(target.id)) continue;
        if (target.type === 'text2img' || target.type === 'img2img') {
          runnable.push(target);
        }
        queue.push(target.id);
      }
    }
    const uniqueRunnable = runnable.filter((item, index, array) => array.findIndex(candidate => candidate.id === item.id) === index);
    if (uniqueRunnable.length === 0) {
      toast.info('下游没有可执行的生图模块');
      return;
    }
    for (const target of uniqueRunnable) {
      const latest = workingState.nodes.find(item => item.id === target.id) || target;
      const result = await generateForNode(latest, { canvasState: workingState, selectOutput: false });
      if (!result) return;
      workingState = result.canvasState;
    }
    const finalNodeId = uniqueRunnable[uniqueRunnable.length - 1]?.id || node.id;
    setSelectedNodeId(finalNodeId);
    setSelectedNodeIds([finalNodeId]);
    toast.success('下游流程已执行完成');
  }, [generateForNode, state]);

  const handleFlowNodeSelect = useCallback((nodeId: string, additive: boolean) => {
    setAddMenu(null);
    setConnectingFromId(null);
    setSelectedNodeId(nodeId);
    setSelectedNodeIds(current => {
      if (!additive) return current.includes(nodeId) ? current : [nodeId];
      return current.includes(nodeId) ? current.filter(id => id !== nodeId) : [...current, nodeId];
    });
  }, []);

  const handleFlowNodesCommit = useCallback((positions: { id: string; x: number; y: number }[], options?: { history?: boolean; dirty?: boolean }) => {
    if (positions.length === 0) return;
    const positionMap = new Map(positions.map(position => [position.id, position]));
    mutateState(current => ({
      ...current,
      nodes: current.nodes.map(node => {
        const position = positionMap.get(node.id);
        if (!position || (node.x === position.x && node.y === position.y)) return node;
        return { ...node, x: position.x, y: position.y, updatedAt: nowIso() };
      }),
    }), { history: options?.history ?? true, dirty: options?.dirty ?? true });
  }, [mutateState]);

  const handleFlowConnect = useCallback((sourceId: string, targetId: string) => {
    addConnection(sourceId, targetId);
    setConnectingFromId(null);
    setSelectedNodeId(targetId);
    setSelectedNodeIds([targetId]);
  }, [addConnection]);

  const handleFlowSelectionChange = useCallback((ids: string[]) => {
    setSelectedNodeIds(current => sameStringArray(current, ids) ? current : ids);
    setSelectedNodeId(current => current === (ids[0] || null) ? current : ids[0] || null);
  }, []);

  const handleRemoveFlowConnections = useCallback((ids: string[]) => {
    if (ids.length === 0) return;
    mutateState(current => ({
      ...current,
      connections: current.connections.filter(connection => !ids.includes(connection.id)),
    }));
  }, [mutateState]);

  const handleFlowStartConnect = useCallback((id: string) => {
    handleConnectorClick(id, 'output');
  }, [handleConnectorClick]);

  const handleViewportCommit = useCallback((viewport: CanvasViewport) => {
    const nextViewport = {
      x: viewport.x,
      y: viewport.y,
      zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, viewport.zoom)),
    };
    mutateState(current => ({ ...current, viewport: nextViewport }), { history: false, dirty: false });
  }, [mutateState]);

  const handlePaneClick = useCallback((point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => {
    setConnectingFromId(null);
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
    setAddMenu(null);
    if (event.detail !== 1) return;
    setAddMenu({ x: point.x, y: point.y, screenX: event.clientX, screenY: event.clientY });
  }, []);

  const handlePaneDoubleClick = useCallback((point: { x: number; y: number }) => {
    addNode('text2img', point.x, point.y);
  }, [addNode]);

  const handleCanvasDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (!Array.from(event.dataTransfer?.items || []).some(item => item.kind === 'file' && item.type.startsWith('image/'))) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = 'copy';
    setIsDraggingFile(true);
  }, []);

  const handleCanvasDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
    setIsDraggingFile(false);
  }, []);

  const handleCanvasDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    const files = Array.from(event.dataTransfer?.files || []).filter(file => file.type.startsWith('image/'));
    if (files.length === 0) return;
    event.preventDefault();
    setIsDraggingFile(false);
    const point = screenToCanvasPoint(event.clientX, event.clientY);
    files.slice(0, 6).forEach((file, index) => {
      void addImageAsset(file, { x: point.x + index * 28, y: point.y + index * 28 });
    });
  }, [addImageAsset, screenToCanvasPoint]);

  const setZoom = useCallback((zoom: number) => {
    const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.zoomTo(nextZoom).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: { ...current.viewport, zoom: nextZoom },
    }));
  }, [handleViewportCommit, mutateState]);

  const undoCanvas = useCallback(() => {
    const previous = historyRef.current.past.pop();
    if (!previous) return;
    historyRef.current.future = [state, ...historyRef.current.future.slice(0, 49)];
    replaceCanvasState(previous, { dirty: true });
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [replaceCanvasState, state]);

  const redoCanvas = useCallback(() => {
    const next = historyRef.current.future.shift();
    if (!next) return;
    historyRef.current.past = [...historyRef.current.past.slice(-49), state];
    replaceCanvasState(next, { dirty: true });
    setSelectedNodeId(null);
    setSelectedNodeIds([]);
  }, [replaceCanvasState, state]);

  const resetView = useCallback(() => {
    const nextViewport = { x: 0, y: 0, zoom: 1 };
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.setViewport(nextViewport).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: nextViewport,
    }));
  }, [handleViewportCommit, mutateState]);

  const fitToContent = useCallback(() => {
    const bounds = getCanvasBounds(state.nodes);
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!bounds || !rect) {
      resetView();
      return;
    }
    const padding = 96;
    const zoom = Math.min(
      MAX_ZOOM,
      Math.max(MIN_ZOOM, Math.min((rect.width - padding * 2) / Math.max(bounds.width, 1), (rect.height - padding * 2) / Math.max(bounds.height, 1))),
    );
    const fitBounds = {
      x: bounds.left - padding,
      y: bounds.top - padding,
      width: bounds.width + padding * 2,
      height: bounds.height + padding * 2,
    };
    const controls = flowControlsRef.current;
    if (controls) {
      void controls.fitBounds(fitBounds, { padding: 0, duration: 120 }).then(() => {
        handleViewportCommit(controls.getViewport());
      });
      return;
    }
    mutateState(current => ({
      ...current,
      viewport: {
        zoom,
        x: rect.width / 2 - (bounds.left + bounds.width / 2) * zoom,
        y: rect.height / 2 - (bounds.top + bounds.height / 2) * zoom,
      },
    }));
  }, [handleViewportCommit, mutateState, resetView, state.nodes]);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (isEditableTarget(event.target)) return;
      if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
        event.preventDefault();
        void saveProject();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && event.shiftKey) {
        event.preventDefault();
        redoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
        event.preventDefault();
        undoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
        event.preventDefault();
        redoCanvas();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
        event.preventDefault();
        duplicateSelectedNode();
      } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
        event.preventDefault();
        const ids = state.nodes.map(node => node.id);
        setSelectedNodeIds(ids);
        setSelectedNodeId(ids[0] || null);
      } else if (event.key === 'Delete' || event.key === 'Backspace') {
        event.preventDefault();
        removeSelectedNode();
      } else if (event.key === 'Escape') {
        setAddMenu(null);
        setConnectingFromId(null);
        setSelectedNodeIds([]);
        setSelectedNodeId(null);
      }
    };
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [duplicateSelectedNode, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]);

  const activeModelValue = selectedNode?.params?.model || modelOptions[0]?.id || '';

  if (!mounted) {
    return (
      <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
        <div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
          <Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
          <p className="mt-3 text-sm text-muted-foreground">正在打开无限画布</p>
        </div>
      </div>
    );
  }

  if (!user) {
    return (
      <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center bg-background px-4">
        <div className="max-w-md rounded-xl border bg-card p-6 text-center shadow-sm">
          <Sparkles className="mx-auto h-10 w-10 text-primary" />
          <h1 className="mt-4 text-xl font-semibold">登录后使用无限画布</h1>
          <p className="mt-2 text-sm text-muted-foreground">画布项目会保存到你的账号下，可用于多轮图像创作和后续编辑。</p>
        </div>
      </div>
    );
  }

  return (
    <div className="flex h-[calc(100vh-4rem)] min-h-[720px] overflow-hidden bg-background">
      <aside className="hidden w-72 shrink-0 flex-col overflow-hidden border-r bg-muted/20 lg:flex">
        <div className="flex shrink-0 items-center justify-between gap-3 p-4 pb-3">
          <div>
            <h1 className="text-lg font-semibold">无限画布</h1>
            <p className="text-xs text-muted-foreground">模块化 AI 创作空间</p>
          </div>
          <Button size="icon" onClick={createProject} disabled={creatingProject} title="新建画布">
            {creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
          </Button>
        </div>

        <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
          <div className="space-y-2">
          <div className="text-xs font-semibold uppercase text-muted-foreground">工作流</div>
          {loading ? (
            <div className="flex items-center gap-2 text-sm text-muted-foreground">
              <Loader2 className="h-4 w-4 animate-spin" /> 读取工作流
            </div>
          ) : projects.length === 0 ? (
            <Button className="w-full justify-start gap-2" variant="outline" onClick={createProject}>
              <Plus className="h-4 w-4" /> 创建第一个工作流
            </Button>
          ) : (
            projects.map(item => (
              <div
                key={item.id}
                className={cn(
                  'group flex w-full items-start gap-2 rounded-lg border bg-background p-2 text-sm transition-colors',
                  project?.id === item.id ? 'border-primary bg-primary/10 text-primary' : 'bg-background hover:bg-muted/60',
                )}
              >
                <button className="min-w-0 flex-1 text-left" onClick={() => void openProject(item.id)}>
                  <div className="truncate font-medium">{item.title}</div>
                  <div className="mt-1 text-xs text-muted-foreground">{item.state.nodes.length} 个模块 · {(item.state.connections || []).length} 条连线</div>
                </button>
                <button
                  type="button"
                  className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
                  title="删除工作流"
                  onClick={(event) => {
                    event.stopPropagation();
                    void deleteProject(item);
                  }}
                >
                  <Trash2 className="h-3.5 w-3.5" />
                </button>
              </div>
            ))
          )}
        </div>

        <div className="mt-6">
          <div className="mb-2 text-xs font-semibold uppercase text-muted-foreground">工作流模板</div>
          <div className="grid grid-cols-2 gap-2">
            <Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('text2img', getViewportCenterPoint())} disabled={!project}>
              <Brush className="h-4 w-4" /> 文生图
            </Button>
            <Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('img2img', getViewportCenterPoint())} disabled={!project}>
              <FileImage className="h-4 w-4" /> 图生图
            </Button>
            <Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('image-chain', getViewportCenterPoint())} disabled={!project}>
              <Wand2 className="h-4 w-4" /> 多轮精修
            </Button>
            <Button size="sm" variant="outline" className="justify-start gap-2" onClick={() => addWorkflowTemplate('layered', getViewportCenterPoint())} disabled={!project}>
              <Layers className="h-4 w-4" /> 图层 PSD
            </Button>
          </div>
        </div>

        <div className="mt-6">
          <div className="mb-2 text-xs font-semibold uppercase text-muted-foreground">素材库</div>
          <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
          <input ref={importInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleImportFileChange} />
          <Button className="w-full justify-start gap-2" variant="outline" onClick={() => fileInputRef.current?.click()}>
            <Upload className="h-4 w-4" /> 上传图片
          </Button>
          <div className="mt-3 grid grid-cols-2 gap-2">
            {state.assets.slice(0, 8).map(asset => (
              <button
                key={asset.id}
                className="aspect-square overflow-hidden rounded-lg border bg-background"
                onClick={() => {
                  const point = getViewportCenterPoint();
                  addNode('image', point.x, point.y, { title: asset.name, imageUrl: asset.url });
                }}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img src={asset.url} alt={asset.name} className="h-full w-full object-cover" />
              </button>
            ))}
          </div>
        </div>
        </div>
      </aside>

      <section className="flex min-w-0 flex-1 flex-col">
        <div className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-3">
          <div className="flex min-w-0 items-center gap-2">
            <Button variant="outline" size="sm" className="gap-2 lg:hidden" onClick={createProject}>
              <Plus className="h-4 w-4" /> 新建
            </Button>
            <Input
              className="h-9 w-56 border-transparent bg-muted/50 font-medium focus-visible:border-border md:w-80"
              value={project?.title || ''}
              placeholder="未命名画布"
              onChange={(event) => {
                if (!project) return;
                setProject({ ...project, title: event.target.value });
                setDirty(true);
              }}
              disabled={!project}
            />
            {dirty ? <Badge variant="outline">未保存</Badge> : <Badge variant="secondary">已保存</Badge>}
          </div>

          <div className="flex items-center gap-2">
            <Button variant="outline" size="icon" onClick={undoCanvas} title="撤销">
              <Undo2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={redoCanvas} title="重做">
              <Redo2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={fitToContent} title="适应内容">
              <Maximize2 className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={resetView} title="重置视图">
              <RotateCcw className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom - 0.1)} title="缩小">
              <ZoomOut className="h-4 w-4" />
            </Button>
            <span className="w-12 text-center text-xs text-muted-foreground">{Math.round(state.viewport.zoom * 100)}%</span>
            <Button variant="outline" size="icon" onClick={() => setZoom(state.viewport.zoom + 0.1)} title="放大">
              <ZoomIn className="h-4 w-4" />
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={() => void saveProject()} disabled={!project || saving}>
              {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} 保存
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={exportProjectJson} disabled={!project}>
              <Download className="h-4 w-4" /> 导出
            </Button>
            <Button variant="outline" size="sm" className="gap-2" onClick={() => importInputRef.current?.click()} disabled={!project}>
              <Upload className="h-4 w-4" /> 导入
            </Button>
            <Button variant="outline" size="icon" className="text-destructive" onClick={() => void deleteCurrentProject()} disabled={!project} title="删除画布">
              <Trash2 className="h-4 w-4" />
            </Button>
          </div>
        </div>

        <div
          ref={canvasRef}
          className="relative flex-1 overflow-hidden bg-[linear-gradient(135deg,hsl(var(--background))_0%,hsl(var(--muted))_55%,hsl(var(--background))_100%)]"
          onDragOver={handleCanvasDragOver}
          onDragLeave={handleCanvasDragLeave}
          onDrop={handleCanvasDrop}
        >
          <ReactFlowCanvas
            nodes={state.nodes}
            connections={state.connections}
            viewport={state.viewport}
            selectedNodeIds={selectedNodeIds}
            connectingFromId={connectingFromId}
            editable={!!project}
            minZoom={MIN_ZOOM}
            maxZoom={MAX_ZOOM}
            layerCanvasSize={LAYER_CANVAS_SIZE}
            layerColors={LAYER_COLORS}
            onSelectNode={handleFlowNodeSelect}
            onSelectionChange={handleFlowSelectionChange}
            onStartConnect={handleFlowStartConnect}
            onConnect={handleFlowConnect}
            onRemoveConnection={removeConnection}
            onRemoveConnections={handleRemoveFlowConnections}
            onNodesCommit={handleFlowNodesCommit}
            onViewportCommit={handleViewportCommit}
            onReady={(controls) => {
              flowControlsRef.current = controls;
            }}
            onPaneClick={handlePaneClick}
            onPaneDoubleClick={handlePaneDoubleClick}
          />

          {isDraggingFile ? (
            <div className="pointer-events-none absolute inset-4 z-40 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/10 text-sm font-medium text-primary backdrop-blur-sm">
              松开鼠标，把图片添加到画布
            </div>
          ) : null}
          <div className="pointer-events-none absolute left-6 top-6 z-20 rounded-lg border bg-background/70 px-3 py-2 text-xs text-muted-foreground shadow-sm backdrop-blur">
            空白点击添加模块 · 双击添加文生图 · Ctrl+Z 撤销 · Ctrl+D 复制 · Delete 删除
          </div>
          {!project ? (
            <div className="absolute inset-0 z-30 flex items-center justify-center bg-background/40 backdrop-blur-[1px]">
              <Button className="gap-2" onClick={createProject} disabled={creatingProject}>
                {creatingProject ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
                创建画布开始
              </Button>
            </div>
          ) : null}

          <div className="absolute bottom-4 left-1/2 z-20 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur" data-canvas-menu>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              setSelectedNodeId(null);
              setSelectedNodeIds([]);
            }}>
              <MousePointer2 className="h-4 w-4" /> 选择
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              const point = getViewportCenterPoint();
              addNode('text', point.x, point.y);
            }}>
              <Type className="h-4 w-4" /> 文本
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => fileInputRef.current?.click()}>
              <Upload className="h-4 w-4" /> 图片
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              const point = getViewportCenterPoint();
              addNode('text2img', point.x, point.y);
            }}>
              <Brush className="h-4 w-4" /> 文生图
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              const point = getViewportCenterPoint();
              addNode('img2img', point.x, point.y);
            }}>
              <FileImage className="h-4 w-4" /> 图生图
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              const point = getViewportCenterPoint();
              addNode('layeredImage', point.x, point.y);
            }}>
              <Layers className="h-4 w-4" /> 图层
            </Button>
            <Button size="sm" variant="ghost" className="gap-2" onClick={() => {
              const point = getViewportCenterPoint();
              addNode('frame', point.x, point.y);
            }}>
              <Maximize2 className="h-4 w-4" /> 分组框
            </Button>
            {selectedNodes.length > 1 ? (
              <>
                <Button size="sm" variant="ghost" className="gap-2" onClick={createFrameAroundSelection}>
                  <Maximize2 className="h-4 w-4" /> 框住所选
                </Button>
                <Button size="sm" variant="ghost" onClick={() => distributeSelectedNodes('x')} disabled={selectedNodes.length < 3}>
                  横向分布
                </Button>
              </>
            ) : null}
          </div>

          {addMenu ? (
            <div
              data-canvas-menu
              className="absolute z-50 w-56 rounded-lg border bg-popover p-1 shadow-lg"
              style={{ left: addMenu.screenX, top: addMenu.screenY }}
            >
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text', addMenu.x, addMenu.y)}>
                <Type className="h-4 w-4" /> 文本
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('text2img', addMenu.x, addMenu.y)}>
                <Brush className="h-4 w-4" /> 文生图
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('img2img', addMenu.x, addMenu.y)}>
                <FileImage className="h-4 w-4" /> 图生图
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => fileInputRef.current?.click()}>
                <ImageIcon className="h-4 w-4" /> 上传图片
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('layeredImage', addMenu.x, addMenu.y)}>
                <Layers className="h-4 w-4" /> 图层图片
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addNode('frame', addMenu.x, addMenu.y)}>
                <Maximize2 className="h-4 w-4" /> 分组框
              </button>
              <div className="my-1 h-px bg-border" />
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('text2img', { x: addMenu.x, y: addMenu.y })}>
                <Brush className="h-4 w-4" /> 文生图流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('img2img', { x: addMenu.x, y: addMenu.y })}>
                <FileImage className="h-4 w-4" /> 图生图流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('image-chain', { x: addMenu.x, y: addMenu.y })}>
                <Wand2 className="h-4 w-4" /> 多轮精修流程
              </button>
              <button className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted" onClick={() => addWorkflowTemplate('layered', { x: addMenu.x, y: addMenu.y })}>
                <Layers className="h-4 w-4" /> 图层 PSD 流程
              </button>
            </div>
          ) : null}
        </div>

      </section>

      <aside className="hidden w-80 shrink-0 overflow-y-auto border-l bg-background p-4 lg:block">
        {selectedNodes.length > 1 ? (
          <div className="space-y-4">
            <div className="rounded-lg border bg-muted/20 p-3">
              <div className="text-sm font-medium">已选择 {selectedNodes.length} 个模块</div>
              <div className="mt-1 text-xs text-muted-foreground">
                {selectedBounds ? `${Math.round(selectedBounds.width)} × ${Math.round(selectedBounds.height)}` : '批量编辑'}
              </div>
            </div>

            <div className="rounded-lg border p-3">
              <Label>对齐</Label>
              <div className="mt-2 grid grid-cols-3 gap-2">
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('left')}>左</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('center')}>中</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('right')}>右</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('top')}>上</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('middle')}>中线</Button>
                <Button variant="outline" size="sm" onClick={() => alignSelectedNodes('bottom')}>下</Button>
              </div>
            </div>

            <div className="rounded-lg border p-3">
              <Label>分布与整理</Label>
              <div className="mt-2 grid grid-cols-2 gap-2">
                <Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('x')} disabled={selectedNodes.length < 3}>横向分布</Button>
                <Button variant="outline" size="sm" onClick={() => distributeSelectedNodes('y')} disabled={selectedNodes.length < 3}>纵向分布</Button>
              </div>
              <Button variant="outline" className="mt-2 w-full gap-2" onClick={createFrameAroundSelection}>
                <Maximize2 className="h-4 w-4" /> 创建分组框
              </Button>
            </div>

            <div className="grid grid-cols-2 gap-2">
              <Button variant="outline" className="gap-2" onClick={duplicateSelectedNode}>
                <Copy className="h-4 w-4" /> 复制
              </Button>
              <Button variant="destructive" className="gap-2" onClick={removeSelectedNode}>
                <Trash2 className="h-4 w-4" /> 删除
              </Button>
            </div>
          </div>
        ) : !selectedNode ? (
          <div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
            <StickyNote className="mb-3 h-10 w-10 opacity-40" />
            点击画布模块后编辑参数
          </div>
        ) : (
          <div className="space-y-4">
            <div className="rounded-lg border bg-muted/20 p-3">
              <div className="mb-2 flex items-center justify-between">
                <div className="flex items-center gap-2 text-sm font-medium">
                  <Link2 className="h-4 w-4" /> 节点连线
                </div>
                {connectingFromId === selectedNode.id ? <Badge variant="outline">选择目标</Badge> : null}
              </div>
              <div className="space-y-2 text-xs text-muted-foreground">
                <Button
                  type="button"
                  variant={connectingFromId === selectedNode.id ? 'default' : 'outline'}
                  size="sm"
                  className="w-full gap-2"
                  onClick={() => handleConnectorClick(selectedNode.id, 'output')}
                >
                  <Link2 className="h-4 w-4" />
                  {connectingFromId === selectedNode.id ? '取消连线' : '从此模块开始连线'}
                </Button>
                {selectedNodeConnections.length === 0 ? (
                  <div className="rounded-md border border-dashed p-2 text-center">暂无连线</div>
                ) : (
                  selectedNodeConnections.map(connection => {
                    const source = state.nodes.find(node => node.id === connection.sourceNodeId);
                    const target = state.nodes.find(node => node.id === connection.targetNodeId);
                    return (
                      <div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border bg-background px-2 py-1.5">
                        <span className="min-w-0 truncate">
                          {source?.title || '未知模块'} → {target?.title || '未知模块'}
                        </span>
                        <button className="text-destructive hover:underline" onClick={() => removeConnection(connection.id)}>删除</button>
                      </div>
                    );
                  })
                )}
              </div>
            </div>

            <div>
              <div className="mb-1 flex items-center justify-between">
                <Label>模块标题</Label>
                <div className="flex gap-1">
                  <Button variant="outline" size="icon" className="h-7 w-7" onClick={duplicateSelectedNode} title="复制模块">
                    <Copy className="h-3.5 w-3.5" />
                  </Button>
                  <Button variant="outline" size="icon" className="h-7 w-7 text-destructive" onClick={removeSelectedNode} title="删除模块">
                    <Trash2 className="h-3.5 w-3.5" />
                  </Button>
                </div>
              </div>
              <Input value={selectedNode.title} onChange={(event) => updateNode(selectedNode.id, { title: event.target.value })} />
            </div>

            {selectedNode.type === 'text' ? (
              <div>
                <Label>文本内容</Label>
                <Textarea className="min-h-40" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
              </div>
            ) : null}

            {selectedNode.type === 'image' ? (
              <div className="space-y-2">
                <Label>图片地址</Label>
                <Input value={selectedNode.imageUrl || ''} onChange={(event) => updateNode(selectedNode.id, { imageUrl: event.target.value })} />
                {selectedNode.imageUrl ? (
                  <Button
                    variant="outline"
                    className="w-full gap-2"
                    onClick={() => addNode('img2img', selectedNode.x + selectedNode.width + 40, selectedNode.y, { referenceImage: selectedNode.imageUrl })}
                  >
                    <Wand2 className="h-4 w-4" /> 用作图生图参考
                  </Button>
                ) : null}
              </div>
            ) : null}

            {selectedNode.type === 'frame' ? (
              <div className="space-y-3">
                <div>
                  <Label>说明文字</Label>
                  <Textarea className="min-h-24" value={selectedNode.text || ''} onChange={(event) => updateNode(selectedNode.id, { text: event.target.value })} />
                </div>
                <div>
                  <Label>边框颜色</Label>
                  <Input value={selectedNode.color || '#22c55e'} onChange={(event) => updateNode(selectedNode.id, { color: event.target.value })} />
                </div>
              </div>
            ) : null}

            {selectedNode.type === 'text2img' || selectedNode.type === 'img2img' ? (
              <>
                <div>
                  <Label>生成模型</Label>
                  <Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
                    <SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
                    <SelectContent>
                      {modelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
                    </SelectContent>
                  </Select>
                </div>

                <div>
                  <Label>创作描述</Label>
                  <Textarea className="min-h-32" value={selectedNode.prompt || ''} onChange={(event) => updateNode(selectedNode.id, { prompt: event.target.value })} />
                </div>

                <div>
                  <Label>负面提示词</Label>
                  <Textarea className="min-h-20" value={selectedNode.negativePrompt || ''} onChange={(event) => updateNode(selectedNode.id, { negativePrompt: event.target.value })} />
                </div>

                <div className="grid grid-cols-2 gap-3">
                  <div>
                    <Label>比例</Label>
                    <Select value={selectedNode.params?.aspectRatio || (selectedNode.type === 'img2img' ? 'original' : '1:1')} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, aspectRatio: value } })}>
                      <SelectTrigger><SelectValue /></SelectTrigger>
                      <SelectContent>
                        {(selectedNode.type === 'img2img' ? IMG2IMG_ASPECT_RATIOS : ASPECT_RATIOS).map(item => (
                          <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                  <div>
                    <Label>分辨率</Label>
                    <Select value={selectedNode.params?.resolution || '2K'} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, resolution: value } })}>
                      <SelectTrigger><SelectValue /></SelectTrigger>
                      <SelectContent>
                        {RESOLUTION_OPTIONS.map(item => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
                      </SelectContent>
                    </Select>
                  </div>
                </div>

                {selectedNode.type === 'img2img' ? (
                  <div className="space-y-2">
                    <Label>参考图</Label>
                    <Select value={selectedNode.referenceImage || ''} onValueChange={(value) => updateNode(selectedNode.id, { referenceImage: value })}>
                      <SelectTrigger><SelectValue placeholder="选择画布图片" /></SelectTrigger>
                      <SelectContent>
                        {state.nodes.filter(node => node.type === 'image' && node.imageUrl).map(node => (
                          <SelectItem key={node.id} value={node.imageUrl || ''}>{node.title}</SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                ) : null}

                <Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
                  {selectedNode.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
                  生成
                </Button>
                {state.connections.some(connection => connection.sourceNodeId === selectedNode.id) ? (
                  <Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
                    <Wand2 className="h-4 w-4" /> 运行本模块及下游
                  </Button>
                ) : null}
              </>
            ) : null}

            {selectedNode.type === 'layeredImage' ? (
              <div className="space-y-3">
                <div className="grid grid-cols-3 gap-2">
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'element')}>元素</Button>
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'text')}>文字</Button>
                  <Button variant="outline" size="sm" onClick={() => addLayer(selectedNode.id, 'icon')}>图标</Button>
                </div>

                <div className="space-y-2">
                  {(selectedNode.layers || []).map((layer, index) => (
                    <div key={layer.id} className="rounded-lg border p-3">
                      <div className="mb-2 flex items-center justify-between gap-2">
                        <Input
                          className="h-8 min-w-0"
                          value={layer.name}
                          disabled={layer.locked}
                          onChange={(event) => updateLayer(selectedNode.id, layer.id, { name: event.target.value })}
                        />
                        <Badge variant="outline">{layer.type}</Badge>
                      </div>
                      <div className="mb-2 flex gap-1">
                        <Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { visible: !layer.visible })}>
                          {layer.visible ? '隐藏' : '显示'}
                        </Button>
                        <Button variant="outline" size="sm" onClick={() => updateLayer(selectedNode.id, layer.id, { locked: !layer.locked })}>
                          {layer.locked ? '解锁' : '锁定'}
                        </Button>
                        <Button variant="outline" size="sm" disabled={index === 0} onClick={() => moveLayer(selectedNode.id, layer.id, -1)}>上移</Button>
                        <Button variant="outline" size="sm" disabled={index === (selectedNode.layers || []).length - 1} onClick={() => moveLayer(selectedNode.id, layer.id, 1)}>下移</Button>
                      </div>
                      <div className="grid grid-cols-4 gap-2">
                        <div>
                          <Label className="text-[11px]">X</Label>
                          <Input className="h-8" type="number" value={layer.x} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { x: Number(event.target.value) || 0 })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">Y</Label>
                          <Input className="h-8" type="number" value={layer.y} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { y: Number(event.target.value) || 0 })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">宽</Label>
                          <Input className="h-8" type="number" value={layer.width} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { width: Math.max(1, Number(event.target.value) || 1) })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">高</Label>
                          <Input className="h-8" type="number" value={layer.height} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { height: Math.max(1, Number(event.target.value) || 1) })} />
                        </div>
                      </div>
                      <div className="mt-2 grid grid-cols-[1fr_88px] gap-2">
                        <div>
                          <Label className="text-[11px]">颜色</Label>
                          <Input className="h-8" value={layer.color || LAYER_COLORS[layer.type]} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { color: event.target.value })} />
                        </div>
                        <div>
                          <Label className="text-[11px]">透明度</Label>
                          <Input className="h-8" type="number" min="0" max="1" step="0.1" value={layer.opacity ?? 1} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { opacity: Math.min(1, Math.max(0, Number(event.target.value))) })} />
                        </div>
                      </div>
                      {layer.type === 'text' ? (
                        <div className="mt-2">
                          <Label className="text-[11px]">文字内容</Label>
                          <Textarea className="min-h-16" value={layer.text || ''} disabled={layer.locked} onChange={(event) => updateLayer(selectedNode.id, layer.id, { text: event.target.value })} />
                        </div>
                      ) : null}
                      <Button variant="ghost" size="sm" className="mt-2 w-full text-destructive" onClick={() => removeLayer(selectedNode.id, layer.id)}>
                        删除图层
                      </Button>
                    </div>
                  ))}
                </div>

                <div className="grid grid-cols-3 gap-2">
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'png')}>
                    <Download className="h-3 w-3" /> PNG
                  </Button>
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'json')}>
                    <Download className="h-3 w-3" /> 图层包
                  </Button>
                  <Button variant="outline" size="sm" className="gap-1" onClick={() => exportLayeredNode(selectedNode, 'psd')}>
                    <Download className="h-3 w-3" /> PSD
                  </Button>
                </div>
              </div>
            ) : null}

            <Button variant="destructive" className="w-full gap-2" onClick={removeSelectedNode}>
              <Trash2 className="h-4 w-4" /> 删除模块
            </Button>
          </div>
        )}
      </aside>
    </div>
  );
}
