first commit
This commit is contained in:
30
frontend/app/globals.css
Normal file
30
frontend/app/globals.css
Normal 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
19
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
frontend/app/mindmap/[id]/page.tsx
Normal file
220
frontend/app/mindmap/[id]/page.tsx
Normal 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
20
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user