first commit

This commit is contained in:
2026-03-20 19:40:17 +08:00
commit 8dcebff7a6
41 changed files with 9322 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
"use client";
import { useParams } from "next/navigation";
import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import MindmapCanvas from "@/components/MindmapCanvas";
import ChatPanel from "@/components/ChatPanel";
import { getMindmap } from "@/lib/api";
import type { Mindmap } from "@/types/mindmap";
type TriggerRequest = {
id: number;
content: string;
};
const MIN_CHAT_PANEL_WIDTH = 320;
const DEFAULT_CHAT_PANEL_WIDTH = 420;
const MAX_CHAT_PANEL_WIDTH_RATIO = 0.55;
export default function MindmapDetailPage() {
const params = useParams<{ id: string }>();
const uniqueId = params?.id ?? "";
const containerRef = useRef<HTMLElement | null>(null);
const [mindmap, setMindmap] = useState<Mindmap | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showChat, setShowChat] = useState(false);
const [isResizingChatPanel, setIsResizingChatPanel] = useState(false);
const [chatPanelWidth, setChatPanelWidth] = useState(DEFAULT_CHAT_PANEL_WIDTH);
const [triggerRequest, setTriggerRequest] = useState<TriggerRequest | undefined>();
const clampChatPanelWidth = useCallback((nextWidth: number) => {
const containerWidth =
containerRef.current?.clientWidth ??
(typeof window === "undefined" ? DEFAULT_CHAT_PANEL_WIDTH : window.innerWidth);
const maxWidth = Math.max(
MIN_CHAT_PANEL_WIDTH,
Math.floor(containerWidth * MAX_CHAT_PANEL_WIDTH_RATIO),
);
return Math.min(Math.max(nextWidth, MIN_CHAT_PANEL_WIDTH), maxWidth);
}, []);
useEffect(() => {
async function loadPageData() {
if (!uniqueId) {
setLoading(false);
setError("缺少脑图 ID");
return;
}
try {
setLoading(true);
setError("");
const mindmapData = await getMindmap(uniqueId);
setMindmap(mindmapData);
} catch (pageError) {
const message =
pageError instanceof Error ? pageError.message : "加载脑图失败";
setError(message);
} finally {
setLoading(false);
}
}
void loadPageData();
}, [uniqueId]);
const handleNodeChat = useCallback(
(nodeLabel: string) => {
setShowChat(true);
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
setTriggerRequest({
id: Date.now() + Math.floor(Math.random() * 1000),
content: `帮我解释一下 ${nodeLabel}`,
});
},
[clampChatPanelWidth],
);
const handleCloseChat = useCallback(() => {
setIsResizingChatPanel(false);
setShowChat(false);
setTriggerRequest(undefined);
}, []);
const handleResizeStart = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
setIsResizingChatPanel(true);
},
[clampChatPanelWidth],
);
useEffect(() => {
if (!showChat) {
setIsResizingChatPanel(false);
return;
}
const handleWindowResize = () => {
setChatPanelWidth((currentWidth) => clampChatPanelWidth(currentWidth));
};
handleWindowResize();
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, [clampChatPanelWidth, showChat]);
useEffect(() => {
if (!showChat || !isResizingChatPanel) {
return;
}
const handleMouseMove = (event: MouseEvent) => {
if (!containerRef.current) {
return;
}
const bounds = containerRef.current.getBoundingClientRect();
const nextWidth = bounds.right - event.clientX;
setChatPanelWidth(clampChatPanelWidth(nextWidth));
};
const stopResizing = () => {
setIsResizingChatPanel(false);
};
const { style } = document.body;
const previousCursor = style.cursor;
const previousUserSelect = style.userSelect;
style.cursor = "col-resize";
style.userSelect = "none";
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopResizing);
window.addEventListener("blur", stopResizing);
return () => {
style.cursor = previousCursor;
style.userSelect = previousUserSelect;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopResizing);
window.removeEventListener("blur", stopResizing);
};
}, [clampChatPanelWidth, isResizingChatPanel, showChat]);
if (loading) {
return (
<main className="flex h-screen w-screen items-center justify-center bg-white">
<div className="rounded-3xl bg-white px-8 py-6 shadow-panel">
...
</div>
</main>
);
}
if (error || !mindmap) {
return (
<main className="flex h-screen w-screen flex-col items-center justify-center bg-white px-6 text-center">
<div className="rounded-3xl bg-white px-8 py-8 shadow-panel">
<p className="text-lg font-semibold text-rose-600">
{error || "未找到对应脑图"}
</p>
</div>
</main>
);
}
return (
<main ref={containerRef} className="flex h-screen w-screen overflow-hidden bg-white">
<section className="min-w-0 flex-1">
<MindmapCanvas tree={mindmap.tree} onNodeChat={handleNodeChat} />
</section>
{showChat && (
<>
<div
role="separator"
aria-label="调整聊天区域宽度"
aria-orientation="vertical"
onMouseDown={handleResizeStart}
className="group relative w-3 shrink-0 cursor-col-resize bg-transparent"
>
<div
className={`absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors ${
isResizingChatPanel
? "bg-blue-500"
: "bg-slate-200 group-hover:bg-blue-300"
}`}
/>
<div
className={`absolute left-1/2 top-1/2 h-16 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors ${
isResizingChatPanel
? "bg-blue-500"
: "bg-slate-300 group-hover:bg-blue-400"
}`}
/>
</div>
<aside
style={{ width: `${chatPanelWidth}px` }}
className="min-w-0 shrink-0 overflow-hidden bg-slate-50 shadow-[-18px_0_36px_rgba(15,23,42,0.06)]"
>
<ChatPanel
sessionId={mindmap.session_id}
triggerRequest={triggerRequest}
onClose={handleCloseChat}
/>
</aside>
</>
)}
</main>
);
}