From e054dcabccdec9033153933f5b78f67bb713c77a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 11 Apr 2026 22:28:18 +0800 Subject: [PATCH] feat: update canvas and flow board features --- app/api/flow-board/_utils.ts | 50 + app/api/flow-board/draft/route.ts | 47 +- app/api/flow-board/files/[id]/route.ts | 116 ++ app/api/flow-board/files/route.ts | 67 + app/canvas/[id]/page.tsx | 507 ++++++ components/canvas/CanvasWorkspace.tsx | 260 ++- components/canvas/nodes/TextNode.tsx | 157 +- components/flow-board/drawnix-workspace.tsx | 1622 ++++++++++++++++- .../product-planning-page.tsx | 1353 +++++++++----- lib/canvas/video-canvas-markers.ts | 177 ++ .../0070_flow_board_cloud_files.sql | 12 + lib/db/migrations/meta/_journal.json | 7 + lib/db/queries.ts | 146 +- lib/db/schema.ts | 6 +- lib/flow-board/mind-map-import.ts | 349 ++++ lib/video-workflow/catalog.ts | 3 + package.json | 1 + pnpm-lock.yaml | 3 + tsconfig.tsbuildinfo | 2 +- 19 files changed, 4215 insertions(+), 670 deletions(-) create mode 100644 app/api/flow-board/_utils.ts create mode 100644 app/api/flow-board/files/[id]/route.ts create mode 100644 app/api/flow-board/files/route.ts create mode 100644 lib/canvas/video-canvas-markers.ts create mode 100644 lib/db/migrations/0070_flow_board_cloud_files.sql create mode 100644 lib/flow-board/mind-map-import.ts diff --git a/app/api/flow-board/_utils.ts b/app/api/flow-board/_utils.ts new file mode 100644 index 000000000..61a5de0f4 --- /dev/null +++ b/app/api/flow-board/_utils.ts @@ -0,0 +1,50 @@ +import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +export function normalizeFlowBoardTitle(value: unknown) { + const title = String(value || "").trim(); + return title.slice(0, 256) || "未命名流程白板"; +} + +export function normalizeFlowBoardSnapshot( + value: unknown +): FlowBoardSnapshotData { + const raw = isRecord(value) ? value : {}; + const updatedAt = + typeof raw.updatedAt === "string" && raw.updatedAt.trim() + ? raw.updatedAt + : new Date().toISOString(); + + return { + children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [], + viewport: normalizeRecord(raw.viewport), + theme: normalizeRecord(raw.theme), + updatedAt, + }; +} + +export function mapFlowBoardDraft(draft: FlowBoardDraft) { + return { + id: draft.id, + title: draft.title || "未命名流程白板", + snapshot: draft.snapshot ?? { children: [] }, + createdAt: draft.createdAt?.toISOString() ?? null, + updatedAt: draft.updatedAt?.toISOString() ?? null, + }; +} + +export function mapFlowBoardDraftSummary(draft: FlowBoardDraft) { + return { + id: draft.id, + title: draft.title || "未命名流程白板", + createdAt: draft.createdAt?.toISOString() ?? null, + updatedAt: draft.updatedAt?.toISOString() ?? null, + }; +} diff --git a/app/api/flow-board/draft/route.ts b/app/api/flow-board/draft/route.ts index 19ef9b066..26484017f 100644 --- a/app/api/flow-board/draft/route.ts +++ b/app/api/flow-board/draft/route.ts @@ -4,34 +4,11 @@ import { getFlowBoardDraftByUserId, upsertFlowBoardDraftByUserId, } from "@/lib/db/queries"; -import type { FlowBoardSnapshotData } from "@/lib/db/schema"; +import { mapFlowBoardDraft, normalizeFlowBoardSnapshot } from "../_utils"; export const runtime = "nodejs"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function normalizeFlowBoardSnapshot(value: unknown): FlowBoardSnapshotData { - const raw = isRecord(value) ? value : {}; - const updatedAt = - typeof raw.updatedAt === "string" && raw.updatedAt.trim() - ? raw.updatedAt - : new Date().toISOString(); - - return { - children: Array.isArray(raw.children) - ? raw.children.filter(isRecord) - : [], - viewport: normalizeRecord(raw.viewport), - theme: normalizeRecord(raw.theme), - updatedAt, - }; -} +export const dynamic = "force-dynamic"; +export const revalidate = 0; export async function GET() { const session = await auth(); @@ -48,10 +25,15 @@ export async function GET() { return NextResponse.json({ success: true, - data: { - snapshot: draft?.snapshot ?? { children: [] }, - updatedAt: draft?.updatedAt?.toISOString() ?? null, - }, + data: draft + ? mapFlowBoardDraft(draft) + : { + id: null, + title: "流程白板", + snapshot: { children: [] }, + createdAt: null, + updatedAt: null, + }, }); } @@ -82,9 +64,6 @@ export async function PUT(request: Request) { return NextResponse.json({ success: true, - data: { - snapshot: savedDraft.snapshot, - updatedAt: savedDraft.updatedAt?.toISOString() ?? snapshot.updatedAt, - }, + data: mapFlowBoardDraft(savedDraft), }); } diff --git a/app/api/flow-board/files/[id]/route.ts b/app/api/flow-board/files/[id]/route.ts new file mode 100644 index 000000000..181b09862 --- /dev/null +++ b/app/api/flow-board/files/[id]/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + deleteFlowBoardDraftById, + getFlowBoardDraftById, + updateFlowBoardDraftById, +} from "@/lib/db/queries"; +import { + mapFlowBoardDraft, + normalizeFlowBoardSnapshot, + normalizeFlowBoardTitle, +} from "../../_utils"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function GET(_request: Request, { params }: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { id } = await params; + const draft = await getFlowBoardDraftById({ + userId: session.user.id, + draftId: id, + }); + + if (!draft) { + return NextResponse.json( + { success: false, error: "流程白板文件不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: mapFlowBoardDraft(draft), + }); +} + +export async function PUT(request: Request, { params }: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { id } = await params; + const body = (await request.json().catch(() => ({}))) as { + title?: unknown; + snapshot?: unknown; + }; + const draft = await updateFlowBoardDraftById({ + userId: session.user.id, + draftId: id, + title: + body.title !== undefined + ? normalizeFlowBoardTitle(body.title) + : undefined, + snapshot: + body.snapshot !== undefined + ? normalizeFlowBoardSnapshot(body.snapshot) + : undefined, + }); + + if (!draft) { + return NextResponse.json( + { success: false, error: "流程白板文件不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: mapFlowBoardDraft(draft), + }); +} + +export async function DELETE(_request: Request, { params }: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { id } = await params; + const draft = await deleteFlowBoardDraftById({ + userId: session.user.id, + draftId: id, + }); + + if (!draft) { + return NextResponse.json( + { success: false, error: "流程白板文件不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: mapFlowBoardDraft(draft), + }); +} diff --git a/app/api/flow-board/files/route.ts b/app/api/flow-board/files/route.ts new file mode 100644 index 000000000..6d00000ae --- /dev/null +++ b/app/api/flow-board/files/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + createFlowBoardDraft, + listFlowBoardDraftsByUserId, +} from "@/lib/db/queries"; +import { + mapFlowBoardDraft, + mapFlowBoardDraftSummary, + normalizeFlowBoardSnapshot, + normalizeFlowBoardTitle, +} from "../_utils"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const drafts = await listFlowBoardDraftsByUserId({ + userId: session.user.id, + }); + + return NextResponse.json({ + success: true, + data: drafts.map(mapFlowBoardDraftSummary), + }); +} + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body = (await request.json().catch(() => ({}))) as { + title?: unknown; + snapshot?: unknown; + }; + const draft = await createFlowBoardDraft({ + userId: session.user.id, + title: normalizeFlowBoardTitle(body.title), + snapshot: normalizeFlowBoardSnapshot(body.snapshot), + }); + + if (!draft) { + return NextResponse.json( + { success: false, error: "创建流程白板失败" }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + data: mapFlowBoardDraft(draft), + }); +} diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index ed001fc6f..9fa788b54 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -2,6 +2,7 @@ import { AlertCircle, + AlertTriangle, ArrowLeft, Bot, Box, @@ -9,6 +10,7 @@ import { ChevronLeft, ChevronRight, Clapperboard, + CheckCircle2, Crosshair, Download, Eye, @@ -16,11 +18,15 @@ import { FileText, Film, FolderOpen, + Flag, GitBranch, Grid3X3, Hand, + Heart, + HelpCircle, ImagePlus, Layers, + Lightbulb, Loader2, type LucideIcon, MessageCircle, @@ -29,6 +35,7 @@ import { Palette, Pencil, PenTool, + Pin, Plus, Redo2, Save, @@ -36,13 +43,17 @@ import { Scissors, Settings, Sparkles, + Star, Table2, + Tag, Trash2, Type, Undo2, Upload, + UserRound, Video, Wand2, + Zap, ZoomIn, ZoomOut, } from "lucide-react"; @@ -146,6 +157,19 @@ import { import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config"; import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-config"; import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config"; +import { + compactVideoCanvasMarkers, + VIDEO_CANVAS_MARKER_COLORS, + VIDEO_CANVAS_MARKER_METADATA_KEY, + VIDEO_CANVAS_PRIORITY_MARKERS, + VIDEO_CANVAS_SYMBOL_MARKERS, + VIDEO_CANVAS_TASK_MARKERS, + normalizeVideoCanvasMarkers, + type VideoCanvasMarkerKey, + type VideoCanvasMarkerState, + type VideoCanvasMarkerValue, + type VideoCanvasSymbolMarkerId, +} from "@/lib/canvas/video-canvas-markers"; import { buildAgentWorkflowComposerContext, getAgentWorkflowProgress, @@ -293,6 +317,8 @@ const IMPORTABLE_CANVAS_TOOL_TYPES = new Set([ "text", ]); +const DEFAULT_CANVAS_TEXT_COLOR = "#111827"; + const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [ { id: "image", label: "图片", icon: FileImage, templateType: "input-image" }, { id: "text", label: "文字", icon: FileText, templateType: "text-node" }, @@ -792,6 +818,284 @@ const VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS = "border-[#d6def7] bg-[#eef2ff] text-[#3954db] shadow-[0_0_0_1px_rgba(91,118,254,0.14)] hover:bg-[#e7edff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"; const VIDEO_CANVAS_COMPACT_CONTROLS_CLASS = "rounded-full border border-[#dbe2ee] bg-white px-2 py-1 shadow-[0_0_0_1px_rgba(219,226,238,0.36),0_12px_26px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950"; +const VIDEO_CANVAS_MARKER_PANEL_ICON_MAP: Record< + VideoCanvasSymbolMarkerId, + LucideIcon +> = { + idea: Lightbulb, + question: HelpCircle, + warning: AlertTriangle, + pin: Pin, + heart: Heart, + bolt: Zap, +}; + +interface VideoCanvasMarkerPanelProps { + hasSelection: boolean; + markers: VideoCanvasMarkerState; + onApply: (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => void; + onClear: () => void; + onClose: () => void; + selectedLabel: string; +} + +function VideoCanvasMarkerPanel({ + hasSelection, + markers, + onApply, + onClear, + onClose, + selectedLabel, +}: VideoCanvasMarkerPanelProps) { + const isActive = (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => + (markers as Record)[key] === value; + + return ( +
+
+
+
标记
+
+ {hasSelection ? selectedLabel : "先选中一个模块"} +
+
+ +
+ +
+ {["标记", "贴纸", "插画"].map((tab) => ( + + ))} +
+ + +
+
+
+ + 标签 +
+
+ {VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( +
+
+ +
+
+ + 优先级 +
+
+ {VIDEO_CANVAS_PRIORITY_MARKERS.map((entry) => ( + + ))} +
+
+ +
+
+ + 任务 +
+
+ {VIDEO_CANVAS_TASK_MARKERS.map((entry) => ( + + ))} +
+
+ +
+
+ + 旗帜 +
+
+ {VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( + + ))} +
+
+ +
+
+ + 星星 +
+
+ {VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( + + ))} +
+
+ +
+
+ + 人像 +
+
+ {VIDEO_CANVAS_MARKER_COLORS.map((entry) => ( + + ))} +
+
+ +
+
+ + 符号 +
+
+ {VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => { + const Icon = VIDEO_CANVAS_MARKER_PANEL_ICON_MAP[entry.id]; + return ( + + ); + })} +
+
+
+
+ +
+ +
+
+ ); +} function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") { const normalized = String(projectId || "") @@ -1097,6 +1401,7 @@ export default function CanvasEditorPage({ params }: PageProps) { const addEdge = useCanvasStore((state) => state.addEdge); const selectNodes = useCanvasStore((state) => state.selectNodes); const updateNode = useCanvasStore((state) => state.updateNode); + const pushHistory = useCanvasStore((state) => state.pushHistory); const setGenerating = useCanvasStore((state) => state.setGenerating); const setPickerMode = useCanvasStore((state) => state.setPickerMode); const requestImageNodeAction = useCanvasStore( @@ -1125,6 +1430,10 @@ export default function CanvasEditorPage({ params }: PageProps) { }); const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] = useState(null); + const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] = + useState(false); + const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = + useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat"); @@ -1150,6 +1459,8 @@ export default function CanvasEditorPage({ params }: PageProps) { useEffect(() => { if (!isVideoMode) { setVideoCanvasInsertMenu(null); + setIsVideoCanvasFileListOpen(false); + setIsVideoCanvasMarkerPanelOpen(false); } }, [isVideoMode]); @@ -1360,6 +1671,26 @@ export default function CanvasEditorPage({ params }: PageProps) { : null, [selectedVideoWorkflowType] ); + const selectedVideoCanvasMarkers = useMemo(() => { + const metadata = (selectedVideoWorkflowNode?.data as + | { metadata?: unknown } + | undefined)?.metadata; + return normalizeVideoCanvasMarkers( + isPlainObject(metadata) + ? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] + : undefined + ); + }, [selectedVideoWorkflowNode]); + const selectedVideoCanvasMarkerLabel = useMemo(() => { + if (!selectedVideoWorkflowNode) return "未选择模块"; + const data = selectedVideoWorkflowNode.data as { + content?: unknown; + title?: unknown; + }; + const title = String(data.title || data.content || "").trim(); + if (title) return title.slice(0, 36); + return selectedVideoWorkflowDefinition?.label || "当前模块"; + }, [selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode]); const selectedVideoWorkflowQuickConnects = useMemo( () => selectedVideoWorkflowType @@ -1423,6 +1754,73 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode, ]); + const handleApplyVideoCanvasMarker = useCallback( + (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => { + if (!selectedVideoWorkflowNode) { + toast.info("请先选中一个模块"); + return; + } + + const currentData = selectedVideoWorkflowNode.data; + const currentMetadata = (currentData as { metadata?: unknown }).metadata; + const metadata = isPlainObject(currentMetadata) + ? { ...currentMetadata } + : {}; + const currentMarkers = normalizeVideoCanvasMarkers( + metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] + ); + const nextMarkers: VideoCanvasMarkerState = { ...currentMarkers }; + const nextMarkerRecord = nextMarkers as Record; + + if (nextMarkerRecord[key] === value) { + delete nextMarkerRecord[key]; + } else { + nextMarkerRecord[key] = value; + } + + const compactMarkers = compactVideoCanvasMarkers(nextMarkers); + if (Object.keys(compactMarkers).length > 0) { + metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] = compactMarkers; + } else { + delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]; + } + + updateNode(selectedVideoWorkflowNode.id, { + data: { + ...currentData, + metadata, + } as CanvasNode["data"], + }); + pushHistory(); + }, + [pushHistory, selectedVideoWorkflowNode, updateNode] + ); + const handleClearVideoCanvasMarkers = useCallback(() => { + if (!selectedVideoWorkflowNode) { + toast.info("请先选中一个模块"); + return; + } + + const currentData = selectedVideoWorkflowNode.data; + const currentMetadata = (currentData as { metadata?: unknown }).metadata; + const metadata = isPlainObject(currentMetadata) + ? { ...currentMetadata } + : {}; + + if (!(VIDEO_CANVAS_MARKER_METADATA_KEY in metadata)) { + return; + } + + delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]; + updateNode(selectedVideoWorkflowNode.id, { + data: { + ...currentData, + metadata, + } as CanvasNode["data"], + }); + pushHistory(); + toast.success("已清空当前模块标记"); + }, [pushHistory, selectedVideoWorkflowNode, updateNode]); const workflowGraph = useMemo( () => createWorkflowGraphContext(nodes, edges), [edges, nodes] @@ -5613,6 +6011,7 @@ export default function CanvasEditorPage({ params }: PageProps) { position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }, size: { width: 200, height: 100 }, data: { + color: DEFAULT_CANVAS_TEXT_COLOR, content: "双击编辑文本", }, connections: [], @@ -8898,6 +9297,51 @@ export default function CanvasEditorPage({ params }: PageProps) { )} + {isVideoMode && isVideoCanvasFileListOpen ? ( +
+ setIsVideoCanvasFileListOpen(false)} + workspaceLabel={workspaceLabel} + workspaceType={activeCanvasWorkspaceType} + /> +
+ ) : null} + + {isVideoMode && isVideoCanvasMarkerPanelOpen ? ( +
+ setIsVideoCanvasMarkerPanelOpen(false)} + selectedLabel={selectedVideoCanvasMarkerLabel} + /> +
+ ) : null} + {/* 5. Floating Toolbar (Bottom) */}
平移 (H) + {isVideoMode ? ( + + + + + 保存到云端 + + ) : null} + + {isVideoMode ? ( + + + + + 云文件列表 + + ) : null} + + {isVideoMode ? ( + + + + + 标记 + + ) : null} + {isVideoMode ? ( diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index e9d3f81b6..2829bd908 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -15,7 +15,145 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { imageGenerationService } from "@/services/canvas"; -import { Download, GitBranch, Minus, Plus } from "lucide-react"; +import { + AlertTriangle, + Check, + Download, + Flag, + GitBranch, + Heart, + HelpCircle, + Lightbulb, + Minus, + Pin, + Plus, + Star, + Tag, + type LucideIcon, + UserRound, + Zap, +} from "lucide-react"; +import { + getVideoCanvasMarkerColor, + getVideoCanvasSymbolMarker, + getVideoCanvasTaskMarker, + normalizeVideoCanvasMarkers, + VIDEO_CANVAS_MARKER_METADATA_KEY, + VIDEO_CANVAS_PRIORITY_MARKERS, +} from "@/lib/canvas/video-canvas-markers"; + +const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record = { + idea: Lightbulb, + question: HelpCircle, + warning: AlertTriangle, + pin: Pin, + heart: Heart, + bolt: Zap, +}; + +function getNodeVideoCanvasMarkers(node: CanvasNode) { + const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata; + return normalizeVideoCanvasMarkers( + metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as Record)[VIDEO_CANVAS_MARKER_METADATA_KEY] + : undefined + ); +} + +function VideoCanvasNodeMarkers({ node }: { node: CanvasNode }) { + const markers = getNodeVideoCanvasMarkers(node); + const tagColor = getVideoCanvasMarkerColor(markers.tagColor); + const flagColor = getVideoCanvasMarkerColor(markers.flagColor); + const starColor = getVideoCanvasMarkerColor(markers.starColor); + const avatarColor = getVideoCanvasMarkerColor(markers.avatarColor); + const task = getVideoCanvasTaskMarker(markers.task); + const priority = VIDEO_CANVAS_PRIORITY_MARKERS.find( + (entry) => entry.value === markers.priority + ); + const symbol = getVideoCanvasSymbolMarker(markers.symbol); + const SymbolIcon = markers.symbol + ? VIDEO_CANVAS_SYMBOL_ICON_MAP[markers.symbol] + : null; + const hasMarkers = Boolean( + tagColor || flagColor || starColor || avatarColor || task || priority || symbol + ); + + if (!hasMarkers) { + return null; + } + + return ( +
+ {tagColor ? ( + + + + ) : null} + {priority ? ( + + {priority.value} + + ) : null} + {task ? ( + + + {task.progress >= 100 ? : null} + + + ) : null} + {flagColor ? ( + + + + ) : null} + {starColor ? ( + + + + ) : null} + {avatarColor ? ( + + + + ) : null} + {symbol && SymbolIcon ? ( + + + + ) : null} +
+ ); +} // 性能优化:判断节点是否在视口内 function isNodeInViewport( @@ -505,36 +643,84 @@ export function CanvasWorkspace({ return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`; }, []); - const renderedEdges = useMemo(() => { - return edges + const { renderedEdges, renderedMergeTrunks } = useMemo(() => { + type RenderedWorkflowEdgeBase = { + edge: CanvasEdge; + sourcePoint: Position; + targetPoint: Position; + }; + + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const baseEdges = edges .map((edge) => { - const sourceNode = nodes.find((node) => node.id === edge.source); - const targetNode = nodes.find((node) => node.id === edge.target); + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); if (!sourceNode || !targetNode) return null; - const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode)); - const targetPoint = toScreenPosition(getNodeInputAnchor(targetNode)); return { edge, - sourcePoint, - targetPoint, - path: buildWorkflowPath(sourcePoint, targetPoint), - midPoint: { - x: (sourcePoint.x + targetPoint.x) / 2, - y: (sourcePoint.y + targetPoint.y) / 2, - }, + sourcePoint: toScreenPosition(getNodeOutputAnchor(sourceNode)), + targetPoint: toScreenPosition(getNodeInputAnchor(targetNode)), }; }) - .filter( - ( - item - ): item is { - edge: CanvasEdge; - sourcePoint: Position; - targetPoint: Position; - path: string; - midPoint: Position; - } => Boolean(item) - ); + .filter((item): item is RenderedWorkflowEdgeBase => Boolean(item)); + + const incomingEdges = new Map(); + baseEdges.forEach((item) => { + const group = incomingEdges.get(item.edge.target) ?? []; + group.push(item); + incomingEdges.set(item.edge.target, group); + }); + + const mergePointByTarget = new Map(); + const renderedMergeTrunks: { + targetNodeId: string; + targetPoint: Position; + mergePoint: Position; + path: string; + edgeIds: string[]; + }[] = []; + + incomingEdges.forEach((items, targetNodeId) => { + if (items.length <= 1) return; + + const targetPoint = items[0].targetPoint; + const averageSourceX = + items.reduce((total, item) => total + item.sourcePoint.x, 0) / items.length; + const averageDeltaX = + items.reduce((total, item) => total + Math.abs(targetPoint.x - item.sourcePoint.x), 0) / + items.length; + const mergeDistance = Math.max(56, Math.min(180, averageDeltaX * 0.32)); + const direction = averageSourceX <= targetPoint.x ? -1 : 1; + const mergePoint = { + x: targetPoint.x + direction * mergeDistance, + y: targetPoint.y, + }; + + mergePointByTarget.set(targetNodeId, mergePoint); + renderedMergeTrunks.push({ + targetNodeId, + targetPoint, + mergePoint, + path: buildWorkflowPath(mergePoint, targetPoint), + edgeIds: items.map((item) => item.edge.id), + }); + }); + + const renderedEdges = baseEdges.map((item) => { + const mergePoint = mergePointByTarget.get(item.edge.target); + const visualTargetPoint = mergePoint ?? item.targetPoint; + return { + ...item, + mergePoint, + path: buildWorkflowPath(item.sourcePoint, visualTargetPoint), + midPoint: { + x: (item.sourcePoint.x + visualTargetPoint.x) / 2, + y: (item.sourcePoint.y + visualTargetPoint.y) / 2, + }, + }; + }); + + return { renderedEdges, renderedMergeTrunks }; }, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]); // 处理画布点击(取消选择) @@ -1300,6 +1486,7 @@ export function CanvasWorkspace({ /> ) : null} + {isWorkflowCanvas ? : null} {node.type === 'image' && ( )} @@ -1483,6 +1670,29 @@ export function CanvasWorkspace({ {isWorkflowCanvas ? ( + {renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => { + const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId)); + return ( + + {isSelected ? ( + + ) : null} + + + ); + })} {renderedEdges.map(({ edge, path }) => { const isSelected = selectedEdgeId === edge.id; return ( diff --git a/components/canvas/nodes/TextNode.tsx b/components/canvas/nodes/TextNode.tsx index e98166d6e..5c2248bb9 100644 --- a/components/canvas/nodes/TextNode.tsx +++ b/components/canvas/nodes/TextNode.tsx @@ -1,24 +1,77 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { Trash2 } from "lucide-react"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, TextNodeData } from "@/types/canvas"; import { cn } from "@/lib/utils"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { toast } from "sonner"; interface TextNodeProps { node: CanvasNode; isSelected: boolean; } +const DEFAULT_TEXT_NODE_COLOR = "#111827"; +const PASTED_IMAGE_MAX_BYTES = 50 * 1024 * 1024; +const PASTED_IMAGE_MAX_SIZE = 360; +const PASTED_IMAGE_MIN_SIZE = 140; + +function getPastedImageDimensions(file: File): Promise<{ + width: number; + height: number; + originalWidth: number; + originalHeight: number; +}> { + return new Promise((resolve) => { + if (typeof window === "undefined") { + resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 }); + return; + } + + const url = URL.createObjectURL(file); + const img = new window.Image(); + img.onload = () => { + const originalWidth = img.naturalWidth || 300; + const originalHeight = img.naturalHeight || 240; + let width = originalWidth; + let height = originalHeight; + + if (width > PASTED_IMAGE_MAX_SIZE || height > PASTED_IMAGE_MAX_SIZE) { + const scale = Math.min(PASTED_IMAGE_MAX_SIZE / width, PASTED_IMAGE_MAX_SIZE / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + if (width < PASTED_IMAGE_MIN_SIZE && height < PASTED_IMAGE_MIN_SIZE) { + const scale = PASTED_IMAGE_MIN_SIZE / Math.max(1, Math.min(width, height)); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + URL.revokeObjectURL(url); + resolve({ width, height, originalWidth, originalHeight }); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 }); + }; + img.src = url; + }); +} + export function TextNode({ node, isSelected }: TextNodeProps) { const data = node.data as TextNodeData; const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(data.content); + const [isUploadingPastedImage, setIsUploadingPastedImage] = useState(false); const textareaRef = useRef(null); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); + const addNode = useCanvasStore((state) => state.addNode); + const selectNode = useCanvasStore((state) => state.selectNode); // 进入编辑模式时聚焦 useEffect(() => { @@ -31,7 +84,11 @@ export function TextNode({ node, isSelected }: TextNodeProps) { // 保存编辑 const handleSave = () => { updateNode(node.id, { - data: { ...data, content: editContent }, + data: { + ...data, + color: data.color || DEFAULT_TEXT_NODE_COLOR, + content: editContent, + }, }); setIsEditing(false); }; @@ -48,6 +105,83 @@ export function TextNode({ node, isSelected }: TextNodeProps) { setIsEditing(true); }; + const handlePasteImages = useCallback(async (files: File[]) => { + if (files.length === 0) return; + + setIsUploadingPastedImage(true); + let addedCount = 0; + + try { + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + if (!file.type.startsWith("image/")) continue; + if (file.size > PASTED_IMAGE_MAX_BYTES) { + toast.error("图片大小不能超过 50MB"); + continue; + } + + const ext = file.type.split("/")[1] || "png"; + const fileName = + (file.name || "").trim() || `粘贴图片-${Date.now()}-${index + 1}.${ext}`; + const title = fileName.replace(/\.[^.]+$/, "") || "粘贴图片"; + const dimensions = await getPastedImageDimensions(file); + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetchUploadWithRetry(formData); + if (!response.ok) { + throw new Error("上传失败"); + } + + const payload = await response.json(); + const imageUrl = typeof payload?.url === "string" ? payload.url : ""; + if (!imageUrl) { + throw new Error("上传结果缺少图片地址"); + } + + const assetId = + typeof payload?.assetId === "string" && payload.assetId.trim() + ? payload.assetId.trim() + : undefined; + const newNodeId = addNode({ + type: "image", + position: { + x: node.position.x + node.size.width + 32 + index * 24, + y: node.position.y + index * 24, + }, + size: { width: dimensions.width, height: dimensions.height }, + data: { + src: imageUrl, + assetId, + status: "completed", + title, + prompt: title, + metadata: { + source: "text-node-paste", + sourceTextNodeId: node.id, + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + }, + }, + connections: [], + }); + + selectNode(newNodeId); + addedCount += 1; + } catch (_error) { + toast.error("图片上传失败"); + } + } + + if (addedCount > 0) { + toast.success(addedCount === 1 ? "已粘贴图片到画布" : `已粘贴 ${addedCount} 张图片到画布`); + } + } finally { + setIsUploadingPastedImage(false); + } + }, [addNode, node.id, node.position.x, node.position.y, node.size.width, selectNode]); + const containerStyle = data.transparent ? undefined : { @@ -74,7 +208,7 @@ export function TextNode({ node, isSelected }: TextNodeProps) { const textStyle = { fontSize: data.fontSize || 14, fontFamily: data.fontFamily || undefined, - color: data.color || "inherit", + color: data.color || DEFAULT_TEXT_NODE_COLOR, fontWeight: data.fontWeight || "normal", lineHeight: data.lineHeight || 1.35, letterSpacing: @@ -100,6 +234,17 @@ export function TextNode({ node, isSelected }: TextNodeProps) { ref={textareaRef} value={editContent} onChange={(e) => setEditContent(e.target.value)} + onPaste={(e) => { + const imageFiles = Array.from(e.clipboardData?.items || []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length === 0) return; + + e.preventDefault(); + void handlePasteImages(imageFiles); + }} onBlur={handleSave} onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { @@ -120,6 +265,12 @@ export function TextNode({ node, isSelected }: TextNodeProps) { {data.content || '双击编辑文本'}
)} + + {isEditing && isUploadingPastedImage ? ( +
+ 正在粘贴图片... +
+ ) : null} {!isEditing && isSelected ? ( <> diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index 4e09f52f1..17184a40b 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -1,16 +1,61 @@ "use client"; import { Drawnix, type DrawnixProps } from "@drawnix/drawnix"; -import type { BoardChangeData } from "@plait-board/react-board"; -import type { - PlaitElement, - PlaitTheme, - ThemeColorMode, - Viewport, +import { memorizeLatest } from "@plait/common"; +import { + DEFAULT_COLOR, + PlaitBoard, + type PlaitElement, + type PlaitTheme, + type Point, + type Selection, + type ThemeColorMode, + Transforms, + type Viewport, + toHostPoint, + toViewBoxPoint, } from "@plait/core"; -import { ChevronLeft, Save } from "lucide-react"; +import { DrawTransforms, MemorizeKey } from "@plait/draw"; +import { + getSelectedMindElements, + type MindElement, +} from "@plait/mind"; +import type { BoardChangeData } from "@plait-board/react-board"; +import { + FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT, + getMindMapImportTitle, + isSupportedMindMapImportFile, + parseMindMapFileToDrawnixMind, +} from "@/lib/flow-board/mind-map-import"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + CheckCircle2, + ChevronDown, + ChevronLeft, + FileText, + FolderOpen, + Flag, + Loader2, + type LucideIcon, + Plus, + Save, + Star, + Tag, + Trash2, + Upload, + UserRound, + X, +} from "lucide-react"; import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ChangeEvent, + type ClipboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "sonner"; type FlowBoardWorkspaceProps = { @@ -26,7 +71,275 @@ type FlowBoardSnapshot = { updatedAt?: string; }; -const FLOW_BOARD_DRAFT_API = "/api/flow-board/draft"; +type FlowBoardCloudFile = { + id: string; + title: string; + createdAt?: string | null; + updatedAt?: string | null; +}; + +type FlowBoardCloudFilePayload = FlowBoardCloudFile & { + snapshot?: unknown; +}; + +type FlowBoardMarkerGroupId = + | "tag" + | "priority" + | "task" + | "flag" + | "star" + | "avatar" + | "symbol"; + +type FlowBoardMarkerItem = { + name: string; + label: string; +}; + +type FlowBoardMarkerGroup = { + id: FlowBoardMarkerGroupId; + label: string; + icon: LucideIcon; + markers: FlowBoardMarkerItem[]; +}; + +type FlowBoardMarkerSelectionState = { + count: number; + names: string[]; +}; + +const FLOW_BOARD_FILES_API = "/api/flow-board/files"; +const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR; +const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; +const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); +const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [ + { + id: "tag", + label: "标签", + icon: Tag, + markers: [ + { name: "🔴", label: "红色" }, + { name: "🟠", label: "橙色" }, + { name: "🟡", label: "黄色" }, + { name: "🟢", label: "绿色" }, + { name: "🔵", label: "蓝色" }, + { name: "🟣", label: "紫色" }, + { name: "⚪", label: "灰色" }, + ], + }, + { + id: "priority", + label: "优先级", + icon: CheckCircle2, + markers: [ + { name: "1️⃣", label: "优先级 1" }, + { name: "2️⃣", label: "优先级 2" }, + { name: "3️⃣", label: "优先级 3" }, + { name: "4️⃣", label: "优先级 4" }, + { name: "5️⃣", label: "优先级 5" }, + { name: "6️⃣", label: "优先级 6" }, + { name: "7️⃣", label: "优先级 7" }, + ], + }, + { + id: "task", + label: "任务", + icon: CheckCircle2, + markers: [ + { name: "▶️", label: "开始" }, + { name: "◔", label: "25%" }, + { name: "◑", label: "50%" }, + { name: "◕", label: "75%" }, + { name: "🚧", label: "处理中" }, + { name: "✅", label: "完成" }, + ], + }, + { + id: "flag", + label: "旗帜", + icon: Flag, + markers: [ + { name: "🚩", label: "红旗" }, + { name: "🏁", label: "终点" }, + { name: "🏳️", label: "白旗" }, + { name: "🎯", label: "目标" }, + { name: "📍", label: "定位" }, + { name: "📌", label: "固定" }, + ], + }, + { + id: "star", + label: "星星", + icon: Star, + markers: [ + { name: "⭐", label: "星标" }, + { name: "🌟", label: "重点" }, + { name: "✨", label: "亮点" }, + { name: "💫", label: "灵感" }, + { name: "🔥", label: "高热" }, + { name: "💎", label: "价值" }, + ], + }, + { + id: "avatar", + label: "人像", + icon: UserRound, + markers: [ + { name: "👤", label: "成员" }, + { name: "🙋", label: "负责人" }, + { name: "👥", label: "多人" }, + { name: "🧑‍💻", label: "执行" }, + { name: "🧑‍🎨", label: "创意" }, + { name: "🧑‍💼", label: "业务" }, + ], + }, + { + id: "symbol", + label: "符号", + icon: Tag, + markers: [ + { name: "❗", label: "注意" }, + { name: "❓", label: "疑问" }, + { name: "💡", label: "想法" }, + { name: "❤️", label: "喜欢" }, + { name: "⚡", label: "快速" }, + { name: "🔗", label: "关联" }, + ], + }, +]; +const FLOW_BOARD_MARKER_NAMES_BY_GROUP = new Map( + FLOW_BOARD_MARKER_GROUPS.map((group) => [ + group.id, + new Set(group.markers.map((marker) => marker.name)), + ]) +); + +function isFlowBoardRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function shouldUseDefaultTextColor(value: unknown) { + if (typeof value !== "string") { + return true; + } + const normalized = value.trim().toUpperCase(); + return ( + normalized === "" || + normalized === "NO_COLOR" || + normalized === "TRANSPARENT" + ); +} + +function normalizeFlowBoardTextPayload(value: unknown): unknown { + if (Array.isArray(value)) { + let changed = false; + const nextValue = value.map((item) => { + const nextItem = normalizeFlowBoardTextPayload(item); + if (nextItem !== item) { + changed = true; + } + return nextItem; + }); + return changed ? nextValue : value; + } + + if (!isFlowBoardRecord(value)) { + return value; + } + + let nextValue: Record | null = null; + const ensureNextValue = () => { + if (!nextValue) { + nextValue = { ...value }; + } + return nextValue; + }; + + if ( + typeof value.text === "string" && + shouldUseDefaultTextColor(value.color) + ) { + ensureNextValue().color = FLOW_BOARD_DEFAULT_TEXT_COLOR; + } else if (Array.isArray(value.text) || isFlowBoardRecord(value.text)) { + const nextText = normalizeFlowBoardTextPayload(value.text); + if (nextText !== value.text) { + ensureNextValue().text = nextText; + } + } + + if (Array.isArray(value.children)) { + const nextChildren = normalizeFlowBoardTextPayload(value.children); + if (nextChildren !== value.children) { + ensureNextValue().children = nextChildren; + } + } + + return nextValue || value; +} + +function normalizeFlowBoardElementText(element: unknown): PlaitElement { + if (!isFlowBoardRecord(element)) { + return element as PlaitElement; + } + + let nextElement: Record | null = null; + const ensureNextElement = () => { + if (!nextElement) { + nextElement = { ...element }; + } + return nextElement; + }; + + if (Array.isArray(element.text) || isFlowBoardRecord(element.text)) { + const nextText = normalizeFlowBoardTextPayload(element.text); + if (nextText !== element.text) { + ensureNextElement().text = nextText; + } + } + + if (Array.isArray(element.texts)) { + const nextTexts = normalizeFlowBoardTextPayload(element.texts); + if (nextTexts !== element.texts) { + ensureNextElement().texts = nextTexts; + } + } + + if ( + element.shape === "text" && + shouldUseDefaultTextColor(element.strokeColor) + ) { + ensureNextElement().strokeColor = FLOW_BOARD_DEFAULT_TEXT_COLOR; + } + + return (nextElement || element) as PlaitElement; +} + +function normalizeFlowBoardChildren(children: unknown): PlaitElement[] { + return Array.isArray(children) + ? children.map((element) => normalizeFlowBoardElementText(element)) + : []; +} + +function seedFlowBoardDefaultTextColor() { + const remember = memorizeLatest as ( + memorizedKey: string, + propertyKey: string, + propertyValue: unknown + ) => void; + [ + MemorizeKey.text, + MemorizeKey.basicShape, + MemorizeKey.flowchart, + MemorizeKey.arrowLine, + MemorizeKey.UML, + ].forEach((memorizedKey) => { + remember(memorizedKey, "text", { color: FLOW_BOARD_DEFAULT_TEXT_COLOR }); + }); +} function normalizeSnapshot(value: unknown): FlowBoardSnapshot { const raw = @@ -35,7 +348,7 @@ function normalizeSnapshot(value: unknown): FlowBoardSnapshot { : {}; return { - children: Array.isArray(raw.children) ? raw.children : [], + children: normalizeFlowBoardChildren(raw.children), viewport: raw.viewport, theme: raw.theme, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined, @@ -43,13 +356,178 @@ function normalizeSnapshot(value: unknown): FlowBoardSnapshot { } function formatSavedAt(value: Date | null) { - if (!value) return "数据库自动保存"; + if (!value) { + return "数据库自动保存"; + } return `已保存 ${value.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", })}`; } +function formatFileUpdatedAt(value?: string | null) { + if (!value) return "刚刚"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "刚刚"; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function normalizeCloudFile(value: unknown): FlowBoardCloudFilePayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Partial; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) return null; + + return { + id, + title: + typeof raw.title === "string" && raw.title.trim() + ? raw.title.trim() + : "未命名流程白板", + snapshot: raw.snapshot, + createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null, + updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, + }; +} + +function getFlowBoardFileUrl(fileId: string) { + return `${FLOW_BOARD_FILES_API}/${encodeURIComponent(fileId)}`; +} + +function sortCloudFiles(files: FlowBoardCloudFile[]) { + return [...files].sort( + (a, b) => + new Date(b.updatedAt || b.createdAt || 0).getTime() - + new Date(a.updatedAt || a.createdAt || 0).getTime() + ); +} + +function getFlowBoardElementEmojiNames(element: MindElement) { + const emojis = Array.isArray(element.data?.emojis) + ? element.data.emojis + : []; + return emojis + .map((emoji) => String(emoji?.name || "").trim()) + .filter(Boolean); +} + +function getFlowBoardMarkerSelectionState( + board: PlaitBoard | null +): FlowBoardMarkerSelectionState { + if (!board) { + return { count: 0, names: [] }; + } + + const selectedElements = getSelectedMindElements(board); + const names = new Set(); + selectedElements.forEach((element) => { + getFlowBoardElementEmojiNames(element).forEach((name) => { + names.add(name); + }); + }); + + return { + count: selectedElements.length, + names: Array.from(names), + }; +} + +function updateFlowBoardElementMarker( + board: PlaitBoard, + element: MindElement, + group: FlowBoardMarkerGroup, + marker: FlowBoardMarkerItem +) { + const groupMarkerNames = + FLOW_BOARD_MARKER_NAMES_BY_GROUP.get(group.id) || new Set(); + const currentEmojis = Array.isArray(element.data?.emojis) + ? element.data.emojis + : []; + const isAlreadyApplied = currentEmojis.some( + (emoji) => emoji.name === marker.name + ); + const nextEmojis = isAlreadyApplied + ? currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name)) + : [ + ...currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name)), + { name: marker.name }, + ]; + const nextData = { ...element.data }; + + if (nextEmojis.length > 0) { + nextData.emojis = nextEmojis; + } else { + delete nextData.emojis; + } + + Transforms.setNode( + board, + { + data: nextData, + }, + PlaitBoard.findPath(board, element) + ); +} + +function getFlowBoardPastePoint(board: PlaitBoard): Point { + const movingPoint = PlaitBoard.getMovingPointInBoard(board); + if (movingPoint) { + return toViewBoxPoint(board, toHostPoint(board, movingPoint[0], movingPoint[1])); + } + + const container = PlaitBoard.getBoardContainer(board); + const rect = container.getBoundingClientRect(); + return toViewBoxPoint( + board, + toHostPoint(board, rect.left + rect.width / 2, rect.top + rect.height / 2) + ); +} + +function getFlowBoardImageDimensions(file: File) { + return new Promise<{ width: number; height: number }>((resolve) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + const originalWidth = image.naturalWidth || 400; + const originalHeight = image.naturalHeight || 300; + const width = Math.min(originalWidth, 400); + const height = Math.round((width / originalWidth) * originalHeight); + URL.revokeObjectURL(url); + resolve({ width, height }); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + resolve({ width: 400, height: 300 }); + }; + image.src = url; + }); +} + +async function uploadFlowBoardImageFile(file: File) { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetchUploadWithRetry(formData); + if (!response.ok) { + throw new Error(`Flow board image upload failed: ${response.status}`); + } + + const payload = (await response.json()) as { url?: unknown }; + const url = typeof payload.url === "string" ? payload.url : ""; + if (!url) { + throw new Error("Flow board image upload response missing url"); + } + + return url; +} + export function DrawnixWorkspace({ siteName, siteLogo, @@ -60,9 +538,26 @@ export function DrawnixWorkspace({ children: [], }); const [isReady, setIsReady] = useState(false); + const [currentFileId, setCurrentFileId] = useState(null); + const [currentTitle, setCurrentTitle] = useState("流程白板"); + const [cloudFiles, setCloudFiles] = useState([]); + const [isFilePanelOpen, setIsFilePanelOpen] = useState(false); + const [isFileListLoading, setIsFileListLoading] = useState(false); + const [isSavingNow, setIsSavingNow] = useState(false); const [lastSavedAt, setLastSavedAt] = useState(null); const snapshotRef = useRef({ children: [] }); + const currentFileIdRef = useRef(null); + const currentTitleRef = useRef("流程白板"); const saveTimerRef = useRef | null>(null); + const boardRef = useRef(null); + const mindMapFileInputRef = useRef(null); + const [isMarkerPanelOpen, setIsMarkerPanelOpen] = useState(false); + const [isImportingMindMap, setIsImportingMindMap] = useState(false); + const [markerSelectionState, setMarkerSelectionState] = + useState({ + count: 0, + names: [], + }); const displayLogo = useMemo(() => { if (resolvedTheme === "dark" && siteLogoDark) { @@ -71,36 +566,197 @@ export function DrawnixWorkspace({ return siteLogo || siteLogoDark; }, [resolvedTheme, siteLogo, siteLogoDark]); + const refreshMarkerSelectionState = useCallback(() => { + setMarkerSelectionState(getFlowBoardMarkerSelectionState(boardRef.current)); + }, []); + + const handleDrawnixInit = useCallback((board: PlaitBoard) => { + boardRef.current = board; + seedFlowBoardDefaultTextColor(); + setMarkerSelectionState(getFlowBoardMarkerSelectionState(board)); + }, []); + + useEffect(() => { + seedFlowBoardDefaultTextColor(); + }, []); + + useEffect(() => { + currentFileIdRef.current = currentFileId; + }, [currentFileId]); + + useEffect(() => { + currentTitleRef.current = currentTitle; + }, [currentTitle]); + + const updateBrowserFileParam = useCallback((fileId: string | null) => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + if (fileId) { + url.searchParams.set("file", fileId); + } else { + url.searchParams.delete("file"); + } + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + }, []); + + const loadCloudFiles = useCallback(async () => { + setIsFileListLoading(true); + try { + const response = await fetch(FLOW_BOARD_FILES_API, { + cache: "no-store", + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Failed to load flow board files: ${response.status}`); + } + const result = (await response.json()) as { data?: unknown[] }; + const files = sortCloudFiles( + (Array.isArray(result.data) ? result.data : []) + .map(normalizeCloudFile) + .filter((file): file is FlowBoardCloudFile => Boolean(file)) + ); + setCloudFiles(files); + return files; + } finally { + setIsFileListLoading(false); + } + }, []); + + const hydrateCloudFile = useCallback( + (file: FlowBoardCloudFilePayload) => { + const nextSnapshot = normalizeSnapshot(file.snapshot); + snapshotRef.current = nextSnapshot; + setSnapshot(nextSnapshot); + setCurrentFileId(file.id); + setCurrentTitle(file.title || "未命名流程白板"); + setLastSavedAt( + file.updatedAt + ? new Date(file.updatedAt) + : nextSnapshot.updatedAt + ? new Date(nextSnapshot.updatedAt) + : null + ); + setMarkerSelectionState({ count: 0, names: [] }); + updateBrowserFileParam(file.id); + }, + [updateBrowserFileParam] + ); + + const persistSnapshot = useCallback( + async ( + nextSnapshot: FlowBoardSnapshot, + options: { fileId?: string | null; title?: string; toast?: boolean } = {} + ) => { + const targetFileId = + options.fileId !== undefined ? options.fileId : currentFileIdRef.current; + const nextTitle = + String((options.title ?? currentTitleRef.current) || "").trim() || + "未命名流程白板"; + const response = await fetch( + targetFileId ? getFlowBoardFileUrl(targetFileId) : FLOW_BOARD_FILES_API, + { + body: JSON.stringify({ + snapshot: nextSnapshot, + title: nextTitle, + }), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + method: targetFileId ? "PUT" : "POST", + } + ); + if (!response.ok) { + throw new Error(`Failed to save flow board file: ${response.status}`); + } + + const result = (await response.json()) as { data?: unknown }; + const savedFile = normalizeCloudFile(result.data); + if (!savedFile) { + throw new Error("Flow board save response is invalid"); + } + + currentFileIdRef.current = savedFile.id; + currentTitleRef.current = savedFile.title; + setCurrentFileId(savedFile.id); + setCurrentTitle(savedFile.title); + setLastSavedAt( + savedFile.updatedAt + ? new Date(savedFile.updatedAt) + : new Date(nextSnapshot.updatedAt || Date.now()) + ); + updateBrowserFileParam(savedFile.id); + setCloudFiles((current) => + sortCloudFiles([ + { + id: savedFile.id, + title: savedFile.title, + createdAt: savedFile.createdAt, + updatedAt: savedFile.updatedAt, + }, + ...current.filter((file) => file.id !== savedFile.id), + ]) + ); + + if (options.toast) { + toast.success("流程白板已保存到云端"); + } + + return savedFile; + }, + [updateBrowserFileParam] + ); + useEffect(() => { let cancelled = false; const loadDraft = async () => { try { - const response = await fetch(FLOW_BOARD_DRAFT_API, { + const files = await loadCloudFiles(); + const requestedFileId = + typeof window === "undefined" + ? "" + : new URLSearchParams(window.location.search).get("file") || ""; + const initialFile = + files.find((file) => file.id === requestedFileId) || files[0] || null; + + if (!initialFile) { + if (!cancelled) { + const emptySnapshot = { children: [] }; + snapshotRef.current = emptySnapshot; + setSnapshot(emptySnapshot); + setCurrentFileId(null); + setCurrentTitle("流程白板"); + setLastSavedAt(null); + setMarkerSelectionState({ count: 0, names: [] }); + updateBrowserFileParam(null); + } + return; + } + + const response = await fetch(getFlowBoardFileUrl(initialFile.id), { cache: "no-store", credentials: "include", }); if (!response.ok) { - throw new Error(`Failed to load flow board draft: ${response.status}`); - } - const result = (await response.json()) as { - data?: { snapshot?: unknown; updatedAt?: string | null }; - }; - if (!cancelled) { - const nextSnapshot = normalizeSnapshot(result.data?.snapshot); - snapshotRef.current = nextSnapshot; - setSnapshot(nextSnapshot); - setLastSavedAt( - result.data?.updatedAt - ? new Date(result.data.updatedAt) - : nextSnapshot.updatedAt - ? new Date(nextSnapshot.updatedAt) - : null + throw new Error( + `Failed to load flow board file: ${response.status}` ); } + const result = (await response.json()) as { + data?: unknown; + }; + const file = normalizeCloudFile(result.data); + if (!file) { + throw new Error("Flow board file response is invalid"); + } + if (!cancelled) { + hydrateCloudFile(file); + } } catch (error) { - console.error("[FlowBoard] Failed to load draft:", error); - toast.error("流程白板草稿加载失败"); + console.error("[FlowBoard] Failed to load cloud file:", error); + toast.error("流程白板云文件加载失败"); } finally { if (!cancelled) { setIsReady(true); @@ -108,7 +764,7 @@ export function DrawnixWorkspace({ } }; - void loadDraft(); + loadDraft().catch(console.error); return () => { cancelled = true; @@ -118,38 +774,26 @@ export function DrawnixWorkspace({ }; }, []); - const scheduleSave = useCallback((nextSnapshot: FlowBoardSnapshot) => { - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - } - - saveTimerRef.current = setTimeout(async () => { - try { - const response = await fetch(FLOW_BOARD_DRAFT_API, { - body: JSON.stringify({ snapshot: nextSnapshot }), - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - method: "PUT", - }); - if (!response.ok) { - throw new Error(`Failed to save flow board draft: ${response.status}`); - } - const result = (await response.json()) as { - data?: { updatedAt?: string | null }; - }; - setLastSavedAt( - result.data?.updatedAt - ? new Date(result.data.updatedAt) - : new Date(nextSnapshot.updatedAt || Date.now()) - ); - } catch (error) { - console.error("[FlowBoard] Failed to save draft:", error); - toast.error("流程白板草稿保存到数据库失败"); + const scheduleSave = useCallback( + (nextSnapshot: FlowBoardSnapshot) => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); } - }, 350); - }, []); + + saveTimerRef.current = setTimeout(async () => { + setIsSavingNow(true); + try { + await persistSnapshot(nextSnapshot); + } catch (error) { + console.error("[FlowBoard] Failed to save cloud file:", error); + toast.error("流程白板保存到云端失败"); + } finally { + setIsSavingNow(false); + } + }, 350); + }, + [persistSnapshot] + ); const updateSnapshot = useCallback( (partialSnapshot: Partial) => { @@ -170,22 +814,205 @@ export function DrawnixWorkspace({ const shouldPersist = value.operations.some( (operation) => operation.type !== "set_selection" ); - if (!shouldPersist) return; + if (!shouldPersist) { + refreshMarkerSelectionState(); + return; + } updateSnapshot({ children: value.children, theme: value.theme, viewport: value.viewport, }); + refreshMarkerSelectionState(); }, - [updateSnapshot] + [refreshMarkerSelectionState, updateSnapshot] + ); + + const handleDrawnixSelectionChange = useCallback( + (_selection: Selection | null) => { + refreshMarkerSelectionState(); + }, + [refreshMarkerSelectionState] + ); + + const handleApplyFlowBoardMarker = useCallback( + (group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => { + const board = boardRef.current; + if (!board) { + return; + } + + const selectedElements = getSelectedMindElements(board); + if (selectedElements.length === 0) { + toast.info("请先选中一个思维导图节点"); + return; + } + + selectedElements.forEach((element) => { + updateFlowBoardElementMarker(board, element, group, marker); + }); + refreshMarkerSelectionState(); + }, + [refreshMarkerSelectionState] + ); + + const handleClearFlowBoardMarkers = useCallback(() => { + const board = boardRef.current; + if (!board) { + return; + } + + const selectedElements = getSelectedMindElements(board); + if (selectedElements.length === 0) { + toast.info("请先选中一个思维导图节点"); + return; + } + + const allMarkerNames = new Set( + FLOW_BOARD_MARKER_GROUPS.flatMap((group) => + group.markers.map((marker) => marker.name) + ) + ); + + selectedElements.forEach((element) => { + const currentEmojis = Array.isArray(element.data?.emojis) + ? element.data.emojis + : []; + const nextEmojis = currentEmojis.filter( + (emoji) => !allMarkerNames.has(emoji.name) + ); + if (nextEmojis.length === currentEmojis.length) { + return; + } + + const nextData = { ...element.data }; + if (nextEmojis.length > 0) { + nextData.emojis = nextEmojis; + } else { + delete nextData.emojis; + } + Transforms.setNode( + board, + { data: nextData }, + PlaitBoard.findPath(board, element) + ); + }); + refreshMarkerSelectionState(); + toast.success("已清空所选节点标记"); + }, [refreshMarkerSelectionState]); + + const handleFlowBoardPastedImages = useCallback(async ( + board: PlaitBoard, + files: File[] + ) => { + const supportedFiles = files.filter((file) => + FLOW_BOARD_IMAGE_UPLOAD_TYPES.has(file.type) + ); + + if (supportedFiles.length !== files.length) { + toast.error("流程白板粘贴图片仅支持 JPEG、PNG 或 WebP"); + } + + if (supportedFiles.length === 0) { + return; + } + + const basePoint = getFlowBoardPastePoint(board); + let insertedCount = 0; + + for (let index = 0; index < supportedFiles.length; index += 1) { + const file = supportedFiles[index]; + if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) { + toast.error("图片大小不能超过 50MB"); + continue; + } + + try { + const [url, dimensions] = await Promise.all([ + uploadFlowBoardImageFile(file), + getFlowBoardImageDimensions(file), + ]); + const targetPoint: Point = [ + basePoint[0] + index * 24, + basePoint[1] + index * 24, + ]; + DrawTransforms.insertImage(board, { url, ...dimensions }, targetPoint); + insertedCount += 1; + } catch (error) { + console.error("[FlowBoard] Failed to paste image:", error); + toast.error("图片上传失败"); + } + } + + if (insertedCount > 0) { + toast.success( + insertedCount === 1 + ? "已粘贴图片到白板" + : `已粘贴 ${insertedCount} 张图片到白板` + ); + } + }, []); + + const handleFlowBoardPasteCapture = useCallback((event: ClipboardEvent) => { + const board = boardRef.current; + if (!board || !event.clipboardData) { + return; + } + + const target = event.target instanceof HTMLElement ? event.target : null; + if (target?.closest("[data-flow-board-shell-control='true']")) { + return; + } + + const imageFiles = Array.from(event.clipboardData.items || []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length === 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + void handleFlowBoardPastedImages(board, imageFiles); + }, [handleFlowBoardPastedImages]); + + const handleFlowBoardFileInputChangeCapture = useCallback( + (event: ChangeEvent) => { + const board = boardRef.current; + const target = event.target instanceof HTMLInputElement ? event.target : null; + if ( + !board || + !target || + target.type !== "file" || + target.closest("[data-flow-board-shell-control='true']") + ) { + return; + } + + const files = Array.from(target.files || []); + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length === 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void handleFlowBoardPastedImages(board, imageFiles); + target.value = ""; + }, + [handleFlowBoardPastedImages] ); const handleValueChange = useCallback( (children: PlaitElement[]) => { updateSnapshot({ children }); + refreshMarkerSelectionState(); }, - [updateSnapshot] + [refreshMarkerSelectionState, updateSnapshot] ); const handleViewportChange = useCallback( @@ -202,6 +1029,285 @@ export function DrawnixWorkspace({ [updateSnapshot] ); + const flushPendingSave = useCallback( + async (showToast = false) => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + setIsSavingNow(true); + try { + await persistSnapshot(snapshotRef.current, { toast: showToast }); + } catch (error) { + console.error("[FlowBoard] Manual cloud save failed:", error); + toast.error("流程白板保存到云端失败"); + } finally { + setIsSavingNow(false); + } + }, + [persistSnapshot] + ); + + const handleImportMindMapFile = useCallback( + async (file: File) => { + if (!isSupportedMindMapImportFile(file)) { + toast.error("请选择 MD、OPML 或 FreeMind .mm 文件"); + return; + } + + setIsImportingMindMap(true); + setIsSavingNow(true); + try { + const hasCurrentContent = + Boolean(currentFileIdRef.current) || + snapshotRef.current.children.length > 0; + if (hasCurrentContent) { + await flushPendingSave(false); + } + + const title = getMindMapImportTitle(file.name); + const mindElement = await parseMindMapFileToDrawnixMind(file, title); + const nextSnapshot: FlowBoardSnapshot = normalizeSnapshot({ + children: [ + { + ...mindElement, + points: mindElement.points?.length + ? mindElement.points + : ([[0, 0]] as Point[]), + }, + ], + updatedAt: new Date().toISOString(), + }); + + const response = await fetch(FLOW_BOARD_FILES_API, { + body: JSON.stringify({ + title, + snapshot: nextSnapshot, + }), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to import mind map flow board: ${response.status}`); + } + + const result = (await response.json()) as { data?: unknown }; + const importedFile = normalizeCloudFile(result.data); + if (!importedFile) { + throw new Error("Flow board mind map import response is invalid"); + } + + hydrateCloudFile(importedFile); + setCloudFiles((current) => + sortCloudFiles([ + { + id: importedFile.id, + title: importedFile.title, + createdAt: importedFile.createdAt, + updatedAt: importedFile.updatedAt, + }, + ...current.filter((item) => item.id !== importedFile.id), + ]) + ); + setIsFilePanelOpen(false); + setIsMarkerPanelOpen(false); + toast.success("已生成思维导图"); + } catch (error) { + console.error("[FlowBoard] Failed to import mind map:", error); + toast.error("思维导图导入失败"); + } finally { + setIsImportingMindMap(false); + setIsSavingNow(false); + if (mindMapFileInputRef.current) { + mindMapFileInputRef.current.value = ""; + } + } + }, + [flushPendingSave, hydrateCloudFile] + ); + + const handleOpenFilePanel = useCallback(async () => { + setIsMarkerPanelOpen(false); + setIsFilePanelOpen(true); + try { + await loadCloudFiles(); + } catch (error) { + console.error("[FlowBoard] Failed to refresh file list:", error); + toast.error("云文件列表加载失败"); + } + }, [loadCloudFiles]); + + const handleOpenCloudFile = useCallback( + async (fileId: string) => { + if (fileId === currentFileIdRef.current) { + setIsFilePanelOpen(false); + return; + } + + const hasCurrentContent = + Boolean(currentFileIdRef.current) || + snapshotRef.current.children.length > 0; + if (hasCurrentContent) { + await flushPendingSave(false); + } + + try { + const response = await fetch(getFlowBoardFileUrl(fileId), { + cache: "no-store", + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Failed to open flow board file: ${response.status}`); + } + const result = (await response.json()) as { data?: unknown }; + const file = normalizeCloudFile(result.data); + if (!file) { + throw new Error("Flow board file response is invalid"); + } + hydrateCloudFile(file); + setIsFilePanelOpen(false); + } catch (error) { + console.error("[FlowBoard] Failed to open cloud file:", error); + toast.error("流程白板云文件打开失败"); + } + }, + [flushPendingSave, hydrateCloudFile] + ); + + const handleCreateCloudFile = useCallback(async () => { + const hasCurrentContent = + Boolean(currentFileIdRef.current) || + snapshotRef.current.children.length > 0; + if (hasCurrentContent) { + await flushPendingSave(false); + } + + setIsSavingNow(true); + try { + const now = new Date(); + const nextSnapshot: FlowBoardSnapshot = { + children: [], + updatedAt: now.toISOString(), + }; + const response = await fetch(FLOW_BOARD_FILES_API, { + body: JSON.stringify({ + title: `流程白板 ${now.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })}`, + snapshot: nextSnapshot, + }), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!response.ok) { + throw new Error(`Failed to create flow board file: ${response.status}`); + } + const result = (await response.json()) as { data?: unknown }; + const file = normalizeCloudFile(result.data); + if (!file) { + throw new Error("Flow board create response is invalid"); + } + hydrateCloudFile(file); + setCloudFiles((current) => + sortCloudFiles([ + { + id: file.id, + title: file.title, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }, + ...current.filter((item) => item.id !== file.id), + ]) + ); + setIsFilePanelOpen(false); + toast.success("已新建云端流程白板"); + } catch (error) { + console.error("[FlowBoard] Failed to create cloud file:", error); + toast.error("新建流程白板云文件失败"); + } finally { + setIsSavingNow(false); + } + }, [flushPendingSave, hydrateCloudFile]); + + const handleDeleteCloudFile = useCallback( + async (fileId: string) => { + if (!window.confirm("确定要删除这个流程白板云文件吗?")) { + return; + } + + try { + const response = await fetch(getFlowBoardFileUrl(fileId), { + credentials: "include", + method: "DELETE", + }); + if (!response.ok) { + throw new Error( + `Failed to delete flow board file: ${response.status}` + ); + } + + const remainingFiles = cloudFiles.filter((file) => file.id !== fileId); + setCloudFiles(remainingFiles); + toast.success("已删除流程白板云文件"); + + if (fileId !== currentFileIdRef.current) { + return; + } + + const nextFile = remainingFiles[0] || null; + if (nextFile) { + const nextResponse = await fetch(getFlowBoardFileUrl(nextFile.id), { + cache: "no-store", + credentials: "include", + }); + if (!nextResponse.ok) { + throw new Error( + `Failed to open next flow board file: ${nextResponse.status}` + ); + } + const result = (await nextResponse.json()) as { data?: unknown }; + const file = normalizeCloudFile(result.data); + if (file) { + hydrateCloudFile(file); + } + return; + } + + const emptySnapshot = { children: [] }; + snapshotRef.current = emptySnapshot; + setSnapshot(emptySnapshot); + setCurrentFileId(null); + setCurrentTitle("流程白板"); + setLastSavedAt(null); + setMarkerSelectionState({ count: 0, names: [] }); + updateBrowserFileParam(null); + } catch (error) { + console.error("[FlowBoard] Failed to delete cloud file:", error); + toast.error("删除流程白板云文件失败"); + } + }, + [cloudFiles, hydrateCloudFile, updateBrowserFileParam] + ); + + const handleTitleCommit = useCallback(async () => { + const normalizedTitle = currentTitle.trim() || "未命名流程白板"; + if (normalizedTitle !== currentTitle) { + setCurrentTitle(normalizedTitle); + currentTitleRef.current = normalizedTitle; + } + await flushPendingSave(false); + }, [currentTitle, flushPendingSave]); + if (!isReady) { return (
@@ -213,48 +1319,368 @@ export function DrawnixWorkspace({ } return ( -
-
- +
+ + { + const file = event.currentTarget.files?.[0]; + if (file) { + void handleImportMindMapFile(file); + } + }} + ref={mindMapFileInputRef} + type="file" + /> +
+
+ + +
+ +
+ {displayLogo ? ( + {siteName} + ) : ( +
+ {siteName.slice(0, 1).toUpperCase()} +
+ )} +
+ setCurrentTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + value={currentTitle} + /> + + {siteName} +
- )} - - 流程白板 - - - {siteName} - - - - - {formatSavedAt(lastSavedAt)} - +
+ +
+ +
+ + + + + + + + + + +
+ + + {formatSavedAt(lastSavedAt)} + +
+
+ {isFilePanelOpen ? ( + + ) : null} + + {isMarkerPanelOpen ? ( + + ) : null} + } + onSelectionChange={handleDrawnixSelectionChange} onThemeChange={handleThemeChange} onValueChange={handleValueChange} onViewportChange={handleViewportChange} diff --git a/components/product-planning/product-planning-page.tsx b/components/product-planning/product-planning-page.tsx index becc108ac..11cb406ce 100644 --- a/components/product-planning/product-planning-page.tsx +++ b/components/product-planning/product-planning-page.tsx @@ -1,15 +1,11 @@ "use client"; import { ChevronDown } from "lucide-react"; -import { Streamdown } from "streamdown"; import { startTransition, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { Streamdown } from "streamdown"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -17,19 +13,16 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { analyzeKeywordInputs } from "@/lib/product-planning/keyword-segmentation"; -import { - parseKeywordInputFile, - parseManualSegmentationFile, - parseQaImportFile, - parseReviewImportFile, -} from "@/lib/product-planning/file-parsers"; import { downloadCompetitorAnalysisWorkbook, downloadCompetitorWorkbook, @@ -39,22 +32,29 @@ import { downloadMarkdownFile, downloadSalesWorkbook, } from "@/lib/product-planning/export"; +import { + parseKeywordInputFile, + parseManualSegmentationFile, + parseQaImportFile, + parseReviewImportFile, +} from "@/lib/product-planning/file-parsers"; +import { analyzeKeywordInputs } from "@/lib/product-planning/keyword-segmentation"; import type { KeywordAnalysisRow, + ProductPlanningAnalyzeResponse, ProductPlanningCompetitorAnalysisRecord, ProductPlanningKeywordInsightGroup, ProductPlanningKeywordInsightResponse, ProductPlanningKeywordInsightSuggestion, - ProductPlanningAnalyzeResponse, + ProductPlanningMonthSaleResponse, + ProductPlanningPlatform, ProductPlanningPropertyRow, ProductPlanningRowsResponse, - ProductPlanningPlatform, + ProductPlanningTaobaoItem, + ProductPlanningTaobaoItemResponse, ProductPlanningTemplate, ProductPlanningTemplateListResponse, ProductPlanningTemplateMutationResponse, - ProductPlanningTaobaoItem, - ProductPlanningTaobaoItemResponse, - ProductPlanningMonthSaleResponse, TaobaoQaRow, TaobaoReviewRow, } from "@/lib/product-planning/types"; @@ -101,7 +101,10 @@ type ProductPlanningSessionState = { advancedTab: string; competitorInput: string; competitorItems: ProductPlanningTaobaoItem[]; - competitorAnalysisRecords: Record; + competitorAnalysisRecords: Record< + string, + ProductPlanningCompetitorAnalysisRecord + >; activeCompetitorAnalysisKey: string | null; insightKeyword: string; insightSuggestions: ProductPlanningKeywordInsightSuggestion[]; @@ -137,7 +140,7 @@ function parseCategoryWords(value: string) { function ErrorText({ message }: { message: string | null }) { if (!message) return null; - return

{message}

; + return

{message}

; } function MarkdownPanel({ @@ -156,11 +159,13 @@ function MarkdownPanel({
{title} - {actions ?
{actions}
: null} + {actions ? ( +
{actions}
+ ) : null}
-
+
{markdown}
@@ -186,13 +191,17 @@ function KeywordResultsTable({ rows }: { rows: KeywordAnalysisRow[] }) { {rows.map((row) => ( - + {row.keyword} - {row.segments.join(" / ")} + + {row.segments.join(" / ")} + {row.category} {row.occurrenceCount} - {typeof row.searchPopularity === "number" ? row.searchPopularity : "-"} + {typeof row.searchPopularity === "number" + ? row.searchPopularity + : "-"} {typeof row.conversionRate === "number" @@ -208,11 +217,12 @@ function KeywordResultsTable({ rows }: { rows: KeywordAnalysisRow[] }) { } function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) { - if (rows.length === 0) return ( -
- 还没有评价数据。 -
- ); + if (rows.length === 0) + return ( +
+ 还没有评价数据。 +
+ ); return (
@@ -230,13 +240,17 @@ function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) { {rows.map((row, index) => ( {row.itemId || "-"} {row.date || "-"} - {row.content || "-"} - {row.appendComment || "-"} + + {row.content || "-"} + + + {row.appendComment || "-"} + {row.sku || "-"} {row.userId || "-"} @@ -248,11 +262,12 @@ function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) { } function QaTable({ rows }: { rows: TaobaoQaRow[] }) { - if (rows.length === 0) return ( -
- 还没有问大家数据。 -
- ); + if (rows.length === 0) + return ( +
+ 还没有问大家数据。 +
+ ); return (
@@ -271,13 +286,19 @@ function QaTable({ rows }: { rows: TaobaoQaRow[] }) { {rows.map((row, index) => ( {row.itemId || "-"} - {row.question || "-"} - {row.questionDetail || "-"} - {row.answer || "-"} + + {row.question || "-"} + + + {row.questionDetail || "-"} + + + {row.answer || "-"} + {row.answerUser || "-"} {row.answerDate || "-"} {row.isOfficial ? "是" : "否"} @@ -316,7 +337,10 @@ function formatPropertyPreview( } function createSnapshotId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { return crypto.randomUUID(); } @@ -401,7 +425,7 @@ function CompetitorTable({ }) { if (rows.length === 0) { return ( -
+
还没有竞品数据。
); @@ -430,8 +454,13 @@ function CompetitorTable({ const skuKey = `${row.platform}:${row.itemId}:sku`; return ( - - {formatPlatform(row.platform, row.isTmall)} + + + {formatPlatform(row.platform, row.isTmall)} + {row.itemId} {row.mainImage ? ( @@ -464,7 +493,11 @@ function CompetitorTable({ onClick={() => onViewAnalysis(mainImageKey)} size="sm" type="button" - variant={activeAnalysisKey === mainImageKey ? "default" : "ghost"} + variant={ + activeAnalysisKey === mainImageKey + ? "default" + : "ghost" + } > 查看结果 @@ -478,7 +511,7 @@ function CompetitorTable({

{row.title}

{row.detailUrl ? ( {formatPrice(row.price)} {formatInteger(row.totalSales)} {formatInteger(row.recentSales)} - {row.material || "-"} + + {row.material || "-"} + {row.skus.length > 0 ? ( -
+
{row.skus.slice(0, 4).map((sku) => (
@@ -519,7 +554,9 @@ function CompetitorTable({ onClick={() => onViewAnalysis(skuKey)} size="sm" type="button" - variant={activeAnalysisKey === skuKey ? "default" : "ghost"} + variant={ + activeAnalysisKey === skuKey ? "default" : "ghost" + } > 查看结果 @@ -540,7 +577,7 @@ function CompetitorTable({ function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) { if (rows.length === 0) { return ( -
+
还没有销量数据。
); @@ -563,15 +600,20 @@ function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) { {rows.map((row) => ( - - {formatPlatform(row.platform, row.isTmall)} + + + {formatPlatform(row.platform, row.isTmall)} + {row.itemId}

{row.title}

{row.detailUrl ? (
- {formatInteger(row.recentSales)} + + {formatInteger(row.recentSales)} + {formatPrice(row.price)} {row.shopName || "-"} {row.location || "-"} @@ -611,7 +655,7 @@ function CompetitorGallery({ }) { if (items.length === 0) { return ( -
+
还没有可展示的竞品图集。
); @@ -621,7 +665,10 @@ function CompetitorGallery({
{items.map((item) => { const imageEntries = getCompetitorImageEntries(item); - const detailCount = Math.max(imageEntries.length - (item.mainImage ? 1 : 0), 0); + const detailCount = Math.max( + imageEntries.length - (item.mainImage ? 1 : 0), + 0 + ); return (
- {formatPlatform(item.platform, item.isTmall)} + + {formatPlatform(item.platform, item.isTmall)} + {item.title}
-

+

商品ID:{item.itemId},图集 {imageEntries.length} 张 {detailCount > 0 ? `,其中详情图 ${detailCount} 张` : ""}

@@ -646,28 +695,31 @@ function CompetitorGallery({
-

品牌 / 店铺

-

+

品牌 / 店铺

+

{item.brand || "未识别品牌"} {item.shopName ? ` / ${item.shopName}` : ""}

-

价格 / 发货地

-

+

+ 价格 / 发货地 +

+

{formatPrice(item.price)} {item.location ? ` / ${item.location}` : ""}

-

图集统计

-

- 主图 {item.itemImages.length} 张 / 详情图 {item.detailImages.length} 张 +

图集统计

+

+ 主图 {item.itemImages.length} 张 / 详情图{" "} + {item.detailImages.length} 张

-

媒体信息

-

+

媒体信息

+

{item.videoUrl ? "含主图视频" : "无主图视频"}

@@ -675,12 +727,12 @@ function CompetitorGallery({
-

商品参数预览

-

+

商品参数预览

+

{formatPropertyPreview(item.properties)}

{item.detailedMaterial ? ( -

+

详细材质:{item.detailedMaterial}

) : null} @@ -688,7 +740,7 @@ function CompetitorGallery({
{imageEntries.length === 0 ? ( -
+
当前商品没有可展示的图片。
) : ( @@ -699,7 +751,7 @@ function CompetitorGallery({ key={entry.key} >
-

{entry.label}

+

{entry.label}

@@ -753,7 +811,7 @@ function KeywordInsightSuggestionTable({ }) { if (rows.length === 0) { return ( -
+
还没有搜索建议数据。
); @@ -771,10 +829,12 @@ function KeywordInsightSuggestionTable({ {rows.map((row) => ( - + {row.rank} {row.keyword} - {row.weight.toLocaleString("zh-CN")} + + {row.weight.toLocaleString("zh-CN")} + ))} @@ -790,7 +850,7 @@ function KeywordInsightGroupTable({ }) { if (rows.length === 0) { return ( -
+
还没有标签分组数据。
); @@ -808,13 +868,16 @@ function KeywordInsightGroupTable({ {rows.map((row) => ( - + {row.index} {row.type}
{row.tags.map((tag) => ( - + {tag.title} ))} @@ -847,9 +910,13 @@ export function ProductPlanningPage({ const [templatesLoading, setTemplatesLoading] = useState(false); const [templateSearch, setTemplateSearch] = useState(""); const [templateUsingId, setTemplateUsingId] = useState(null); - const [templateDownloadingId, setTemplateDownloadingId] = useState(null); + const [templateDownloadingId, setTemplateDownloadingId] = useState< + string | null + >(null); const [templateUploading, setTemplateUploading] = useState(false); - const [templateDeletingId, setTemplateDeletingId] = useState(null); + const [templateDeletingId, setTemplateDeletingId] = useState( + null + ); const [templateCategoryName, setTemplateCategoryName] = useState(""); const [templateDescription, setTemplateDescription] = useState(""); const templateUploadRef = useRef(null); @@ -879,23 +946,36 @@ export function ProductPlanningPage({ const [advancedTab, setAdvancedTab] = useState("competitor"); const [competitorInput, setCompetitorInput] = useState(""); - const [competitorItems, setCompetitorItems] = useState([]); + const [competitorItems, setCompetitorItems] = useState< + ProductPlanningTaobaoItem[] + >([]); const [competitorError, setCompetitorError] = useState(null); const [competitorLoading, setCompetitorLoading] = useState(false); - const [competitorImageError, setCompetitorImageError] = useState(null); - const [competitorImageLoadingId, setCompetitorImageLoadingId] = useState(null); - const [competitorSkuError, setCompetitorSkuError] = useState(null); - const [competitorSkuLoadingKey, setCompetitorSkuLoadingKey] = useState(null); + const [competitorImageError, setCompetitorImageError] = useState< + string | null + >(null); + const [competitorImageLoadingId, setCompetitorImageLoadingId] = useState< + string | null + >(null); + const [competitorSkuError, setCompetitorSkuError] = useState( + null + ); + const [competitorSkuLoadingKey, setCompetitorSkuLoadingKey] = useState< + string | null + >(null); const [competitorAnalysisRecords, setCompetitorAnalysisRecords] = useState< Record >({}); - const [activeCompetitorAnalysisKey, setActiveCompetitorAnalysisKey] = useState( - null - ); + const [activeCompetitorAnalysisKey, setActiveCompetitorAnalysisKey] = + useState(null); const [insightKeyword, setInsightKeyword] = useState(""); - const [insightSuggestions, setInsightSuggestions] = useState([]); - const [insightGroups, setInsightGroups] = useState([]); + const [insightSuggestions, setInsightSuggestions] = useState< + ProductPlanningKeywordInsightSuggestion[] + >([]); + const [insightGroups, setInsightGroups] = useState< + ProductPlanningKeywordInsightGroup[] + >([]); const [insightError, setInsightError] = useState(null); const [insightLoading, setInsightLoading] = useState(false); @@ -903,10 +983,13 @@ export function ProductPlanningPage({ const [salesItems, setSalesItems] = useState([]); const [salesError, setSalesError] = useState(null); const [salesLoading, setSalesLoading] = useState(false); - const [savedSnapshots, setSavedSnapshots] = useState([]); + const [savedSnapshots, setSavedSnapshots] = useState< + ProductPlanningSavedSnapshot[] + >([]); const [activeSnapshotId, setActiveSnapshotId] = useState(null); const [snapshotSearch, setSnapshotSearch] = useState(""); - const [snapshotKindFilter, setSnapshotKindFilter] = useState("all"); + const [snapshotKindFilter, setSnapshotKindFilter] = + useState("all"); async function handleKeywordAnalyze() { const keywordFile = keywordFileRef.current?.files?.[0]; @@ -967,7 +1050,8 @@ export function ProductPlanningPage({ method: "GET", cache: "no-store", }); - const payload = (await response.json()) as ProductPlanningTemplateListResponse; + const payload = + (await response.json()) as ProductPlanningTemplateListResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "模板库加载失败"); } @@ -1000,7 +1084,9 @@ export function ProductPlanningPage({ return; } - const parsed = JSON.parse(raw) as Partial | null; + const parsed = JSON.parse( + raw + ) as Partial | null; if (!parsed || parsed.version !== 1) { sessionHydratedRef.current = true; return; @@ -1010,24 +1096,36 @@ export function ProductPlanningPage({ if (typeof parsed.manualSegmentationMode === "boolean") { setManualSegmentationMode(parsed.manualSegmentationMode); } - if (typeof parsed.categoryWords === "string") setCategoryWords(parsed.categoryWords); - if (Array.isArray(parsed.keywordRows)) setKeywordRows(parsed.keywordRows as KeywordAnalysisRow[]); - if (typeof parsed.productInput === "string") setProductInput(parsed.productInput); + if (typeof parsed.categoryWords === "string") + setCategoryWords(parsed.categoryWords); + if (Array.isArray(parsed.keywordRows)) + setKeywordRows(parsed.keywordRows as KeywordAnalysisRow[]); + if (typeof parsed.productInput === "string") + setProductInput(parsed.productInput); if (typeof parsed.minPage === "string") setMinPage(parsed.minPage); if (typeof parsed.maxPage === "string") setMaxPage(parsed.maxPage); - if (Array.isArray(parsed.reviewRows)) setReviewRows(parsed.reviewRows as TaobaoReviewRow[]); - if (Array.isArray(parsed.qaRows)) setQaRows(parsed.qaRows as TaobaoQaRow[]); + if (Array.isArray(parsed.reviewRows)) + setReviewRows(parsed.reviewRows as TaobaoReviewRow[]); + if (Array.isArray(parsed.qaRows)) + setQaRows(parsed.qaRows as TaobaoQaRow[]); if (typeof parsed.resultTab === "string") setResultTab(parsed.resultTab); - if (typeof parsed.reviewMarkdown === "string" || parsed.reviewMarkdown === null) { + if ( + typeof parsed.reviewMarkdown === "string" || + parsed.reviewMarkdown === null + ) { setReviewMarkdown(parsed.reviewMarkdown ?? null); } if (typeof parsed.qaMarkdown === "string" || parsed.qaMarkdown === null) { setQaMarkdown(parsed.qaMarkdown ?? null); } - if (typeof parsed.advancedTab === "string") setAdvancedTab(parsed.advancedTab); - if (typeof parsed.competitorInput === "string") setCompetitorInput(parsed.competitorInput); + if (typeof parsed.advancedTab === "string") + setAdvancedTab(parsed.advancedTab); + if (typeof parsed.competitorInput === "string") + setCompetitorInput(parsed.competitorInput); if (Array.isArray(parsed.competitorItems)) { - setCompetitorItems(parsed.competitorItems as ProductPlanningTaobaoItem[]); + setCompetitorItems( + parsed.competitorItems as ProductPlanningTaobaoItem[] + ); } if ( parsed.competitorAnalysisRecords && @@ -1035,36 +1133,52 @@ export function ProductPlanningPage({ !Array.isArray(parsed.competitorAnalysisRecords) ) { setCompetitorAnalysisRecords( - parsed.competitorAnalysisRecords as Record + parsed.competitorAnalysisRecords as Record< + string, + ProductPlanningCompetitorAnalysisRecord + > ); } if ( typeof parsed.activeCompetitorAnalysisKey === "string" || parsed.activeCompetitorAnalysisKey === null ) { - setActiveCompetitorAnalysisKey(parsed.activeCompetitorAnalysisKey ?? null); + setActiveCompetitorAnalysisKey( + parsed.activeCompetitorAnalysisKey ?? null + ); } - if (typeof parsed.insightKeyword === "string") setInsightKeyword(parsed.insightKeyword); + if (typeof parsed.insightKeyword === "string") + setInsightKeyword(parsed.insightKeyword); if (Array.isArray(parsed.insightSuggestions)) { setInsightSuggestions( parsed.insightSuggestions as ProductPlanningKeywordInsightSuggestion[] ); } if (Array.isArray(parsed.insightGroups)) { - setInsightGroups(parsed.insightGroups as ProductPlanningKeywordInsightGroup[]); + setInsightGroups( + parsed.insightGroups as ProductPlanningKeywordInsightGroup[] + ); } - if (typeof parsed.salesInput === "string") setSalesInput(parsed.salesInput); + if (typeof parsed.salesInput === "string") + setSalesInput(parsed.salesInput); if (Array.isArray(parsed.salesItems)) { setSalesItems(parsed.salesItems as ProductPlanningTaobaoItem[]); } - if (typeof parsed.templateSearch === "string") setTemplateSearch(parsed.templateSearch); + if (typeof parsed.templateSearch === "string") + setTemplateSearch(parsed.templateSearch); if (Array.isArray(parsed.savedSnapshots)) { - setSavedSnapshots(parsed.savedSnapshots as ProductPlanningSavedSnapshot[]); + setSavedSnapshots( + parsed.savedSnapshots as ProductPlanningSavedSnapshot[] + ); } - if (typeof parsed.activeSnapshotId === "string" || parsed.activeSnapshotId === null) { + if ( + typeof parsed.activeSnapshotId === "string" || + parsed.activeSnapshotId === null + ) { setActiveSnapshotId(parsed.activeSnapshotId ?? null); } - if (typeof parsed.snapshotSearch === "string") setSnapshotSearch(parsed.snapshotSearch); + if (typeof parsed.snapshotSearch === "string") + setSnapshotSearch(parsed.snapshotSearch); if ( parsed.snapshotKindFilter === "all" || parsed.snapshotKindFilter === "review" || @@ -1081,14 +1195,17 @@ export function ProductPlanningPage({ }, []); async function fetchTemplateBlob(template: ProductPlanningTemplate) { - const response = await fetch(`/api/product-planning/templates/${encodeURIComponent(template.id)}`, { - method: "GET", - }); + const response = await fetch( + `/api/product-planning/templates/${encodeURIComponent(template.id)}`, + { + method: "GET", + } + ); if (!response.ok) { - const payload = (await response.json().catch(() => null)) as - | ProductPlanningTemplateMutationResponse - | null; + const payload = (await response + .json() + .catch(() => null)) as ProductPlanningTemplateMutationResponse | null; throw new Error(payload?.error || "模板下载失败"); } @@ -1166,7 +1283,8 @@ export function ProductPlanningPage({ method: "POST", body: formData, }); - const payload = (await response.json()) as ProductPlanningTemplateMutationResponse; + const payload = + (await response.json()) as ProductPlanningTemplateMutationResponse; if (!response.ok || !payload.success || !payload.template) { throw new Error(payload.error || "模板上传失败"); } @@ -1196,10 +1314,14 @@ export function ProductPlanningPage({ setTemplateDeletingId(template.id); try { - const response = await fetch(`/api/product-planning/templates/${encodeURIComponent(template.id)}`, { - method: "DELETE", - }); - const payload = (await response.json()) as ProductPlanningTemplateMutationResponse; + const response = await fetch( + `/api/product-planning/templates/${encodeURIComponent(template.id)}`, + { + method: "DELETE", + } + ); + const payload = + (await response.json()) as ProductPlanningTemplateMutationResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "模板删除失败"); } @@ -1230,9 +1352,12 @@ export function ProductPlanningPage({ async function fetchRowsForItem(itemId: string, page: number) { const [reviewResponse, qaResponse] = await Promise.all([ - fetch(`/api/product-planning/taobao/reviews?id=${encodeURIComponent(itemId)}&page=${page}`, { - method: "GET", - }), + fetch( + `/api/product-planning/taobao/reviews?id=${encodeURIComponent(itemId)}&page=${page}`, + { + method: "GET", + } + ), fetch("/api/product-planning/taobao/qa", { method: "POST", headers: { @@ -1242,8 +1367,10 @@ export function ProductPlanningPage({ }), ]); - const reviewPayload = (await reviewResponse.json()) as ProductPlanningRowsResponse; - const qaPayload = (await qaResponse.json()) as ProductPlanningRowsResponse; + const reviewPayload = + (await reviewResponse.json()) as ProductPlanningRowsResponse; + const qaPayload = + (await qaResponse.json()) as ProductPlanningRowsResponse; return { reviewOk: reviewResponse.ok && reviewPayload.success, @@ -1355,9 +1482,13 @@ export function ProductPlanningPage({ startTransition(() => { setReviewRows( uniqueBy(nextReviewRows, (row) => - [row.itemId, row.date, row.content, row.appendComment || "", row.userId || ""].join( - "::" - ) + [ + row.itemId, + row.date, + row.content, + row.appendComment || "", + row.userId || "", + ].join("::") ) ); setQaRows( @@ -1409,7 +1540,8 @@ export function ProductPlanningPage({ setResultTab("qa"); toast.success(`已导入 ${rows.length} 条问大家数据`); } catch (error) { - const message = error instanceof Error ? error.message : "导入问大家表失败"; + const message = + error instanceof Error ? error.message : "导入问大家表失败"; setQaError(message); toast.error(message); } @@ -1418,7 +1550,11 @@ export function ProductPlanningPage({ async function handleAnalyze(kind: "review" | "qa") { const rows = kind === "review" ? reviewRows : qaRows; if (rows.length === 0) { - toast.error(kind === "review" ? "当前没有可分析的评价数据" : "当前没有可分析的问大家数据"); + toast.error( + kind === "review" + ? "当前没有可分析的评价数据" + : "当前没有可分析的问大家数据" + ); return; } @@ -1471,8 +1607,11 @@ export function ProductPlanningPage({ reference.platform === "jd" ? "/api/product-planning/jd/item" : "/api/product-planning/taobao/item"; - const response = await fetch(`${endpoint}?id=${encodeURIComponent(reference.itemId)}`); - const payload = (await response.json()) as ProductPlanningTaobaoItemResponse; + const response = await fetch( + `${endpoint}?id=${encodeURIComponent(reference.itemId)}` + ); + const payload = + (await response.json()) as ProductPlanningTaobaoItemResponse; if (!response.ok || !payload.success || !payload.item) { throw new Error(payload.error || "获取商品详情失败"); } @@ -1500,7 +1639,8 @@ export function ProductPlanningPage({ async function handleCompetitorFetch() { const itemRefs = parseMarketplaceItemLines(competitorInput); if (itemRefs.length === 0) { - const message = "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; + const message = + "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; setCompetitorError(message); toast.error(message); return; @@ -1515,13 +1655,19 @@ export function ProductPlanningPage({ try { const rows: ProductPlanningTaobaoItem[] = []; - for (const reference of uniqueBy(itemRefs, (item) => `${item.platform}:${item.itemId}`)) { + for (const reference of uniqueBy( + itemRefs, + (item) => `${item.platform}:${item.itemId}` + )) { const item = await fetchMarketplaceItem(reference); if (reference.platform === "taobao") { try { item.recentSales = await fetchMonthSale(reference.itemId); } catch (error) { - console.warn("[ProductPlanning] competitor month sale failed:", error); + console.warn( + "[ProductPlanning] competitor month sale failed:", + error + ); } } rows.push(item); @@ -1529,7 +1675,8 @@ export function ProductPlanningPage({ startTransition(() => setCompetitorItems(rows)); toast.success(`已获取 ${rows.length} 个竞品商品数据`); } catch (error) { - const message = error instanceof Error ? error.message : "获取竞品数据失败"; + const message = + error instanceof Error ? error.message : "获取竞品数据失败"; setCompetitorError(message); toast.error(message); } finally { @@ -1552,7 +1699,9 @@ export function ProductPlanningPage({ } } - async function handleCompetitorImageAnalyze(params: CompetitorImageAnalyzeParams) { + async function handleCompetitorImageAnalyze( + params: CompetitorImageAnalyzeParams + ) { const { item, imageUrl, imageLabel, imageKey } = params; if (!imageUrl) { toast.error("当前图片不存在,无法分析"); @@ -1593,7 +1742,8 @@ export function ProductPlanningPage({ setActiveCompetitorAnalysisKey(imageKey); toast.success(`${imageLabel}分析完成`); } catch (error) { - const message = error instanceof Error ? error.message : "竞品图片分析失败"; + const message = + error instanceof Error ? error.message : "竞品图片分析失败"; setCompetitorImageError(message); toast.error(message); } finally { @@ -1642,7 +1792,8 @@ export function ProductPlanningPage({ setActiveCompetitorAnalysisKey(skuKey); toast.success("SKU 卖点分析完成"); } catch (error) { - const message = error instanceof Error ? error.message : "SKU 卖点分析失败"; + const message = + error instanceof Error ? error.message : "SKU 卖点分析失败"; setCompetitorSkuError(message); toast.error(message); } finally { @@ -1666,7 +1817,8 @@ export function ProductPlanningPage({ const response = await fetch( `/api/product-planning/keyword-insight?q=${encodeURIComponent(keyword)}` ); - const payload = (await response.json()) as ProductPlanningKeywordInsightResponse; + const payload = + (await response.json()) as ProductPlanningKeywordInsightResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "关键词洞察分析失败"); } @@ -1675,7 +1827,8 @@ export function ProductPlanningPage({ setInsightGroups(payload.groups); toast.success(`已获取 ${payload.suggestions.length} 条搜索建议`); } catch (error) { - const message = error instanceof Error ? error.message : "关键词洞察分析失败"; + const message = + error instanceof Error ? error.message : "关键词洞察分析失败"; setInsightError(message); toast.error(message); } finally { @@ -1693,7 +1846,10 @@ export function ProductPlanningPage({ await downloadKeywordInsightSuggestionsWorkbook(insightSuggestions); toast.success("搜索建议已导出"); } catch (error) { - console.error("[ProductPlanning] insight suggestions export failed:", error); + console.error( + "[ProductPlanning] insight suggestions export failed:", + error + ); toast.error("导出搜索建议失败"); } } @@ -1716,7 +1872,8 @@ export function ProductPlanningPage({ async function handleSalesFetch() { const itemRefs = parseMarketplaceItemLines(salesInput); if (itemRefs.length === 0) { - const message = "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; + const message = + "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; setSalesError(message); toast.error(message); return; @@ -1727,7 +1884,10 @@ export function ProductPlanningPage({ try { const rows: ProductPlanningTaobaoItem[] = []; - for (const reference of uniqueBy(itemRefs, (item) => `${item.platform}:${item.itemId}`)) { + for (const reference of uniqueBy( + itemRefs, + (item) => `${item.platform}:${item.itemId}` + )) { const item = await fetchMarketplaceItem(reference); if (reference.platform === "taobao") { item.recentSales = await fetchMonthSale(reference.itemId); @@ -1739,7 +1899,8 @@ export function ProductPlanningPage({ startTransition(() => setSalesItems(rows)); toast.success(`已获取 ${rows.length} 个商品的销量数据`); } catch (error) { - const message = error instanceof Error ? error.message : "获取销量数据失败"; + const message = + error instanceof Error ? error.message : "获取销量数据失败"; setSalesError(message); toast.error(message); } finally { @@ -1762,7 +1923,11 @@ export function ProductPlanningPage({ } } - function handleMarkdownExport(markdown: string | null, fileName: string, errorMessage: string) { + function handleMarkdownExport( + markdown: string | null, + fileName: string, + errorMessage: string + ) { if (!markdown) { toast.error(errorMessage); return; @@ -1787,7 +1952,10 @@ export function ProductPlanningPage({ await downloadCompetitorAnalysisWorkbook(competitorAnalysisRecords); toast.success("竞品分析结果已导出"); } catch (error) { - console.error("[ProductPlanning] competitor analysis export failed:", error); + console.error( + "[ProductPlanning] competitor analysis export failed:", + error + ); toast.error("导出竞品分析结果失败"); } } @@ -1812,7 +1980,9 @@ export function ProductPlanningPage({ return; } - const inputName = window.prompt("请输入快照名称", params.defaultName)?.trim(); + const inputName = window + .prompt("请输入快照名称", params.defaultName) + ?.trim(); if (!inputName) { return; } @@ -1832,13 +2002,17 @@ export function ProductPlanningPage({ } function handleDeleteSnapshot(snapshotId: string) { - setSavedSnapshots((current) => current.filter((snapshot) => snapshot.id !== snapshotId)); + setSavedSnapshots((current) => + current.filter((snapshot) => snapshot.id !== snapshotId) + ); setActiveSnapshotId((current) => (current === snapshotId ? null : current)); toast.success("快照已删除"); } function handleRenameSnapshot(snapshotId: string) { - const target = savedSnapshots.find((snapshot) => snapshot.id === snapshotId); + const target = savedSnapshots.find( + (snapshot) => snapshot.id === snapshotId + ); if (!target) { toast.error("未找到该快照"); return; @@ -1888,14 +2062,18 @@ export function ProductPlanningPage({ const filteredTemplates = templates.filter((template) => { if (!normalizedTemplateSearch) return true; - return [template.categoryName, template.description || "", template.fileName] - .some((value) => value.toLowerCase().includes(normalizedTemplateSearch)); + return [ + template.categoryName, + template.description || "", + template.fileName, + ].some((value) => value.toLowerCase().includes(normalizedTemplateSearch)); }); const activeCompetitorAnalysis = activeCompetitorAnalysisKey ? competitorAnalysisRecords[activeCompetitorAnalysisKey] || null : null; const activeSnapshot = activeSnapshotId - ? savedSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) || null + ? savedSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) || + null : null; const normalizedSnapshotSearch = snapshotSearch.trim().toLowerCase(); const filteredSnapshots = savedSnapshots.filter((snapshot) => { @@ -1905,8 +2083,9 @@ export function ProductPlanningPage({ if (!normalizedSnapshotSearch) return true; - return [snapshot.name, snapshot.title, snapshot.markdown] - .some((value) => value.toLowerCase().includes(normalizedSnapshotSearch)); + return [snapshot.name, snapshot.title, snapshot.markdown].some((value) => + value.toLowerCase().includes(normalizedSnapshotSearch) + ); }); useEffect(() => { @@ -1944,7 +2123,10 @@ export function ProductPlanningPage({ }; try { - sessionStorage.setItem(PRODUCT_PLANNING_SESSION_KEY, JSON.stringify(payload)); + sessionStorage.setItem( + PRODUCT_PLANNING_SESSION_KEY, + JSON.stringify(payload) + ); } catch (error) { console.warn("[ProductPlanning] persist session failed:", error); } @@ -1987,13 +2169,20 @@ export function ProductPlanningPage({ 第一阶段 商品策划工具
-
-

商品策划工具

-

- 在当前项目内完成关键词拆词、评价/问大家抓取、本地表格导入和 AI 分析总结。 +

+ 商品策划工具 +

+

+ 在当前项目内完成关键词拆词、评价/问大家抓取、本地表格导入和 AI + 分析总结。

@@ -2006,7 +2195,9 @@ export function ProductPlanningPage({ 将当前分析结果保存为会话内命名快照,便于回看、导出和对比。
- 已保存 {savedSnapshots.length} 条 + + 已保存 {savedSnapshots.length} 条 +
@@ -2017,18 +2208,22 @@ export function ProductPlanningPage({ value={snapshotSearch} />
- {([ - ["all", "全部"], - ["review", "评价"], - ["qa", "问大家"], - ["competitor", "竞品"], - ] as Array<[SnapshotKindFilter, string]>).map(([value, label]) => ( + {( + [ + ["all", "全部"], + ["review", "评价"], + ["qa", "问大家"], + ["competitor", "竞品"], + ] as Array<[SnapshotKindFilter, string]> + ).map(([value, label]) => ( @@ -2037,26 +2232,23 @@ export function ProductPlanningPage({
{savedSnapshots.length === 0 ? ( -
+
还没有保存任何分析快照。你可以在评价、问大家或竞品分析结果卡片里点击“保存快照”。
) : filteredSnapshots.length === 0 ? ( -
+
没有匹配当前筛选条件的快照。
) : (
{filteredSnapshots.map((snapshot) => ( -
+

{snapshot.name}

-

+

{snapshot.kind === "review" ? "评价分析" : snapshot.kind === "qa" @@ -2064,15 +2256,23 @@ export function ProductPlanningPage({ : "竞品分析"}

- {formatDateTime(snapshot.createdAt)} + + {formatDateTime(snapshot.createdAt)} +
-

{snapshot.title}

+

+ {snapshot.title} +

@@ -2114,7 +2314,11 @@ export function ProductPlanningPage({
)} @@ -2122,13 +2326,28 @@ export function ProductPlanningPage({ - - 关键词拆词 - 评价 / 问大家分析 - 拓展分析 + + + 关键词拆词 + + + 评价 / 问大家分析 + + + 拓展分析 + - + 关键词拆词分析 @@ -2139,9 +2358,10 @@ export function ProductPlanningPage({
-

手动分词模式

-

- 开启后会跳过自动拆词,直接使用你上传表格中的“词语 / 标签分类”等结果。 +

手动分词模式

+

+ 开启后会跳过自动拆词,直接使用你上传表格中的“词语 / + 标签分类”等结果。

-
- +