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

30
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
height: 100%;
}
body {
min-height: 100%;
margin: 0;
color: #111827;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 30%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
}
* {
box-sizing: border-box;
}
.react-flow__attribution {
display: none;
}
.react-flow__node:focus,
.react-flow__node:focus-visible {
outline: none;
}

19
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "交互式思维导图",
description: "基于 Next.js、FastAPI 和 SQLite 的思维导图 MVP",
};
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

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>
);
}

20
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
export default function HomePage() {
return (
<main className="mx-auto flex min-h-screen max-w-6xl flex-col justify-center px-6 py-16">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-blue-700">
Interactive Mindmap
</p>
<h1 className="mt-4 text-4xl font-extrabold leading-tight text-slate-950 md:text-6xl">
</h1>
<p className="mt-6 mx-auto max-w-2xl text-lg leading-8 text-slate-600">
AI
</p>
<p className="mt-4 text-sm text-slate-500">
</p>
</div>
</main>
);
}