diff --git a/.continue/prompts/coder.prompt b/.continue/prompts/coder.prompt new file mode 100644 index 0000000..dce8ee7 --- /dev/null +++ b/.continue/prompts/coder.prompt @@ -0,0 +1,19 @@ +temperature: 0.5 +maxTokens: 8192 +--- + +请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。 +输入要求: +1. 用户将提供目标文件名或需要实现的功能描述。 +2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。 +任务描述: +1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。 +2. 如果已有文件,检查并基于现有实现完善功能或修复问题。 +3. 遵循约定的开发框架、语言标准和最佳实践。 +4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。 +输出要求: +1. 仅返回生成的代码或文件内容。 +2. 全程使用中文注释 +3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。 +4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。 + \ No newline at end of file diff --git a/.continue/prompts/comment.prompt b/.continue/prompts/comment.prompt index 68cb798..37fbee4 100755 --- a/.continue/prompts/comment.prompt +++ b/.continue/prompts/comment.prompt @@ -4,11 +4,9 @@ maxTokens: 8192 角色定位: - 高级软件开发工程师 -- 代码文档化与知识传播专家 注释目标: 1. 顶部注释 - 模块/文件整体功能描述 - - 使用场景 2. 类注释 - 核心功能概述 - 设计模式解析 @@ -22,12 +20,9 @@ maxTokens: 8192 - 逐行解释代码意图 - 关键语句原理阐述 - 高级语言特性解读 - - 潜在的设计考量 注释风格要求: - 全程使用中文 - 专业、清晰、通俗易懂 -- 面向初学者的知识传递 -- 保持技术严谨性 输出约束: - 仅返回添加注释后的代码 - 注释与代码完美融合 diff --git a/apps/web/package.json b/apps/web/package.json index 3370827..f10e338 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,10 +32,11 @@ "@hookform/resolvers": "^3.9.1", "@nice/client": "workspace:^", "@nice/common": "workspace:^", + "@nice/config": "workspace:^", "@nice/iconer": "workspace:^", - "@nice/mindmap": "workspace:^", - "@nice/ui": "workspace:^", "@nice/utils": "workspace:^", + "mind-elixir": "workspace:^", + "@nice/ui": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-persist-client": "^5.51.9", @@ -61,9 +62,7 @@ "mitt": "^3.0.1", "quill": "2.0.3", "react": "18.2.0", - "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", - "react-dropzone": "^14.3.5", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.4.1", "react-resizable": "^3.0.5", @@ -90,4 +89,4 @@ "typescript-eslint": "^8.0.1", "vite": "^5.4.1" } -} +} \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 54a93a3..7f00070 100755 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,7 +24,7 @@ function App() { theme={{ algorithm: theme.defaultAlgorithm, token: { - colorPrimary: "#2e75b6", + colorPrimary: "#0088E8", }, components: {}, }}> diff --git a/apps/web/src/app/main/courses/components/CourseCard.tsx b/apps/web/src/app/main/courses/components/CourseCard.tsx new file mode 100644 index 0000000..d549a1e --- /dev/null +++ b/apps/web/src/app/main/courses/components/CourseCard.tsx @@ -0,0 +1,48 @@ +import { Card, Rate, Tag } from 'antd'; +import { Course } from '../mockData'; +import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons'; + +interface CourseCardProps { + course: Course; +} + +export default function CourseCard({ course }: CourseCardProps) { + return ( + + } + > +
+

+ {course.title} +

+

{course.instructor}

+
+ + {course.rating} +
+
+
+ + {course.enrollments} 人在学 +
+
+ + {course.duration} +
+
+
+ {course.category} + {course.level} +
+
+
+ ); +} diff --git a/apps/web/src/app/main/courses/components/CourseList.tsx b/apps/web/src/app/main/courses/components/CourseList.tsx new file mode 100644 index 0000000..3d67b1c --- /dev/null +++ b/apps/web/src/app/main/courses/components/CourseList.tsx @@ -0,0 +1,44 @@ +import { Pagination, Empty } from 'antd'; +import { Course } from '../mockData'; +import CourseCard from './CourseCard'; + +interface CourseListProps { + courses: Course[]; + total: number; + pageSize: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export default function CourseList({ + courses, + total, + pageSize, + currentPage, + onPageChange, +}: CourseListProps) { + return ( +
+ {courses.length > 0 ? ( + <> +
+ {courses.map(course => ( + + ))} +
+
+ +
+ + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/courses/components/FilterSection.tsx b/apps/web/src/app/main/courses/components/FilterSection.tsx new file mode 100644 index 0000000..93e0e6f --- /dev/null +++ b/apps/web/src/app/main/courses/components/FilterSection.tsx @@ -0,0 +1,54 @@ +import { Checkbox, Divider, Radio, Space } from 'antd'; +import { categories, levels } from '../mockData'; + +interface FilterSectionProps { + selectedCategory: string; + selectedLevel: string; + onCategoryChange: (category: string) => void; + onLevelChange: (level: string) => void; +} + +export default function FilterSection({ + selectedCategory, + selectedLevel, + onCategoryChange, + onLevelChange, +}: FilterSectionProps) { + return ( +
+
+

课程分类

+ onCategoryChange(e.target.value)} + className="flex flex-col space-y-3" + > + 全部课程 + {categories.map(category => ( + + {category} + + ))} + +
+ + + +
+

难度等级

+ onLevelChange(e.target.value)} + className="flex flex-col space-y-3" + > + 全部难度 + {levels.map(level => ( + + {level} + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/courses/mockData.ts b/apps/web/src/app/main/courses/mockData.ts new file mode 100644 index 0000000..096d174 --- /dev/null +++ b/apps/web/src/app/main/courses/mockData.ts @@ -0,0 +1,44 @@ +export interface Course { + id: string; + title: string; + description: string; + instructor: string; + price: number; + originalPrice: number; + category: string; + level: string; + thumbnail: string; + rating: number; + enrollments: number; + duration: string; +} + +export const categories = [ + "计算机科学", + "数据科学", + "商业管理", + "人工智能", + "软件开发", + "网络安全", + "云计算", + "前端开发", + "后端开发", + "移动开发" +]; + +export const levels = ["入门", "初级", "中级", "高级"]; + +export const mockCourses: Course[] = Array.from({ length: 50 }, (_, i) => ({ + id: `course-${i + 1}`, + title: `${categories[i % categories.length]}课程 ${i + 1}`, + description: "本课程将带你深入了解该领域的核心概念和实践应用,通过实战项目提升你的专业技能。", + instructor: `讲师 ${i + 1}`, + price: Math.floor(Math.random() * 500 + 99), + originalPrice: Math.floor(Math.random() * 1000 + 299), + category: categories[i % categories.length], + level: levels[i % levels.length], + thumbnail: `/api/placeholder/280/160`, + rating: Number((Math.random() * 2 + 3).toFixed(1)), + enrollments: Math.floor(Math.random() * 10000), + duration: `${Math.floor(Math.random() * 20 + 10)}小时` +})); \ No newline at end of file diff --git a/apps/web/src/app/main/courses/page.tsx b/apps/web/src/app/main/courses/page.tsx new file mode 100644 index 0000000..f852e00 --- /dev/null +++ b/apps/web/src/app/main/courses/page.tsx @@ -0,0 +1,73 @@ +import { useState, useMemo } from 'react'; +import { mockCourses } from './mockData'; +import FilterSection from './components/FilterSection'; +import CourseList from './components/CourseList'; + +export default function CoursesPage() { + const [currentPage, setCurrentPage] = useState(1); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedLevel, setSelectedLevel] = useState(''); + const pageSize = 12; + + const filteredCourses = useMemo(() => { + return mockCourses.filter(course => { + const matchCategory = !selectedCategory || course.category === selectedCategory; + const matchLevel = !selectedLevel || course.level === selectedLevel; + return matchCategory && matchLevel; + }); + }, [selectedCategory, selectedLevel]); + + const paginatedCourses = useMemo(() => { + const startIndex = (currentPage - 1) * pageSize; + return filteredCourses.slice(startIndex, startIndex + pageSize); + }, [filteredCourses, currentPage]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+
+ {/* 左侧筛选区域 */} +
+
+ { + setSelectedCategory(category); + setCurrentPage(1); + }} + onLevelChange={level => { + setSelectedLevel(level); + setCurrentPage(1); + }} + /> +
+
+ + {/* 右侧课程列表区域 */} +
+
+
+ + 共找到 {filteredCourses.length} 门课程 + +
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/home/components/CategorySection.tsx b/apps/web/src/app/main/home/components/CategorySection.tsx new file mode 100644 index 0000000..638e429 --- /dev/null +++ b/apps/web/src/app/main/home/components/CategorySection.tsx @@ -0,0 +1,162 @@ +import React, { useState, useCallback } from 'react'; +import { Typography, Button } from 'antd'; +import { stringToColor } from '@nice/common'; + +const { Title, Text } = Typography; + +interface CourseCategory { + name: string; + count: number; + description: string; +} + +const courseCategories: CourseCategory[] = [ + { + name: '计算机基础', + count: 120, + description: '计算机组成原理、操作系统、网络等基础知识' + }, + { + name: '编程语言', + count: 85, + description: 'Python、Java、JavaScript等主流编程语言' + }, + { + name: '人工智能', + count: 65, + description: '机器学习、深度学习、自然语言处理等前沿技术' + }, + { + name: '数据科学', + count: 45, + description: '数据分析、数据可视化、商业智能等' + }, + { + name: '云计算', + count: 38, + description: '云服务、容器化、微服务架构等' + }, + { + name: '网络安全', + count: 42, + description: '网络安全基础、渗透测试、安全防护等' + } +]; + +const CategorySection = () => { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [showAll, setShowAll] = useState(false); + + const handleMouseEnter = useCallback((index: number) => { + setHoveredIndex(index); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredIndex(null); + }, []); + + const displayedCategories = showAll + ? courseCategories + : courseCategories.slice(0, 8); + + return ( +
+
+
+ + 探索课程分类 + + + 选择你感兴趣的方向,开启学习之旅 + +
+
+ {displayedCategories.map((category, index) => { + const categoryColor = stringToColor(category.name); + const isHovered = hoveredIndex === index; + + return ( +
handleMouseEnter(index)} + onMouseLeave={handleMouseLeave} + role="button" + tabIndex={0} + aria-label={`查看${category.name}课程类别`} + > +
+
+
+
+
+
+ + {category.name} + + + {category.count} 门课程 + +
+ + {category.description} + +
+ 了解更多 + + → + +
+
+
+ ); + })} +
+ {courseCategories.length > 8 && ( +
+ +
+ )} +
+
+ ); +}; + +export default CategorySection; \ No newline at end of file diff --git a/apps/web/src/app/main/home/components/CoursesSection.tsx b/apps/web/src/app/main/home/components/CoursesSection.tsx new file mode 100644 index 0000000..4818e00 --- /dev/null +++ b/apps/web/src/app/main/home/components/CoursesSection.tsx @@ -0,0 +1,206 @@ +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Card, Typography, Tag, Progress } from 'antd'; +import { + PlayCircleOutlined, + UserOutlined, + ClockCircleOutlined, + TeamOutlined, + StarOutlined, + ArrowRightOutlined, +} from '@ant-design/icons'; + +const { Title, Text } = Typography; + +interface Course { + id: number; + title: string; + instructor: string; + students: number; + rating: number; + level: string; + duration: string; + category: string; + progress: number; + thumbnail: string; +} + +interface CoursesSectionProps { + title: string; + description: string; + courses: Course[]; + initialVisibleCoursesCount?: number; +} + +const CoursesSection: React.FC = ({ + title, + description, + courses, + initialVisibleCoursesCount = 8, +}) => { + const navigate = useNavigate(); + const [selectedCategory, setSelectedCategory] = useState('全部'); + const [visibleCourses, setVisibleCourses] = useState(initialVisibleCoursesCount); + + const categories = useMemo(() => { + const allCategories = courses.map((course) => course.category); + return ['全部', ...Array.from(new Set(allCategories))]; + }, [courses]); + + const filteredCourses = useMemo(() => { + return selectedCategory === '全部' + ? courses + : courses.filter((course) => course.category === selectedCategory); + }, [selectedCategory, courses]); + + const displayedCourses = filteredCourses.slice(0, visibleCourses); + + return ( +
+
+
+
+ + {title} + + + {description} + +
+
+ +
+ {categories.map((category) => ( + setSelectedCategory(category)} + className={`px-4 py-2 text-base cursor-pointer hover:scale-105 transform transition-all duration-300 ${selectedCategory === category + ? 'shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)]' + : 'hover:shadow-md' + }`} + > + {category} + + ))} +
+ +
+ {displayedCourses.map((course) => ( + +
+
+ + {course.progress > 0 && ( +
+ +
+ )} +
+ } + > +
+
+ + {course.category} + + + {course.level} + +
+ + {course.title} + +
+ + + {course.instructor} + +
+
+ + + {course.duration} + + + + {course.students.toLocaleString()} + + + + {course.rating} + +
+
+ +
+
+ + ))} +
+ + {filteredCourses.length >= visibleCourses && ( +
+
+
+
navigate('/courses')} + className="cursor-pointer tracking-widest text-gray-500 hover:text-primary font-medium flex items-center gap-2 transition-all duration-300 ease-in-out" + > + 查看更多 + +
+ +
+ +
+ )} +
+
+ ); +}; + +export default CoursesSection; diff --git a/apps/web/src/app/main/home/components/FeaturedTeachersSection.tsx b/apps/web/src/app/main/home/components/FeaturedTeachersSection.tsx new file mode 100644 index 0000000..c880b07 --- /dev/null +++ b/apps/web/src/app/main/home/components/FeaturedTeachersSection.tsx @@ -0,0 +1,222 @@ +import React, { useRef } from 'react'; +import { Typography, Tag, Carousel } from 'antd'; +import { StarFilled, UserOutlined, ReadOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; + +const { Title, Text } = Typography; + +interface Teacher { + name: string; + title: string; + avatar: string; + courses: number; + students: number; + rating: number; + description: string; +} + +const featuredTeachers: Teacher[] = [ + { + name: '张教授', + title: '资深前端开发专家', + avatar: '/images/teacher1.jpg', + courses: 12, + students: 25000, + rating: 4.9, + description: '前 BAT 高级工程师,10年+开发经验' + }, + { + name: '李教授', + title: '算法与数据结构专家', + avatar: '/images/teacher2.jpg', + courses: 8, + students: 18000, + rating: 4.8, + description: '计算机博士,专注算法教育8年' + }, + { + name: '王博士', + title: '人工智能研究员', + avatar: '/images/teacher3.jpg', + courses: 15, + students: 30000, + rating: 4.95, + description: '人工智能领域专家,曾主导多个大型AI项目' + }, + { + name: '陈教授', + title: '云计算架构师', + avatar: '/images/teacher4.jpg', + courses: 10, + students: 22000, + rating: 4.85, + description: '知名云服务提供商技术总监,丰富的实战经验' + }, + { + name: '郑老师', + title: '移动开发专家', + avatar: '/images/teacher5.jpg', + courses: 14, + students: 28000, + rating: 4.88, + description: '资深移动端开发者,著名互联网公司技术专家' + } +]; + +const generateGradientColors = (name: string) => { + // 优化的哈希函数 + const hash = name.split('').reduce((acc, char, index) => { + return char.charCodeAt(0) + ((acc << 5) - acc) + index; + }, 0); + + // 定义蓝色色相范围(210-240) + const blueHueStart = 210; + const blueHueRange = 30; + + // 基础蓝色色相 - 将哈希值映射到蓝色范围内 + const baseHue = blueHueStart + Math.abs(hash % blueHueRange); + + // 生成第二个蓝色色相,保持在蓝色范围内 + let secondHue = baseHue + 15; // 在基础色相的基础上略微偏移 + if (secondHue > blueHueStart + blueHueRange) { + secondHue -= blueHueRange; + } + + // 基于输入字符串的特征调整饱和度和亮度 + const nameLength = name.length; + const saturation = Math.max(65, Math.min(85, 75 + (nameLength % 10))); // 65-85%范围 + const lightness = Math.max(45, Math.min(65, 55 + (hash % 10))); // 45-65%范围 + + // 为第二个颜色稍微调整饱和度和亮度,创造层次感 + const saturation2 = Math.max(60, saturation - 5); + const lightness2 = Math.min(70, lightness + 5); + + return { + from: `hsl(${Math.round(baseHue)}, ${Math.round(saturation)}%, ${Math.round(lightness)}%)`, + to: `hsl(${Math.round(secondHue)}, ${Math.round(saturation2)}%, ${Math.round(lightness2)}%)` + }; +}; + +const FeaturedTeachersSection: React.FC = () => { + const carouselRef = useRef(null); + + const settings = { + dots: true, + infinite: true, + speed: 500, + slidesToShow: 4, + slidesToScroll: 1, + autoplay: true, + autoplaySpeed: 5000, + responsive: [ + { + breakpoint: 1024, + settings: { + slidesToShow: 2, + slidesToScroll: 1 + } + }, + { + breakpoint: 640, + settings: { + slidesToShow: 1, + slidesToScroll: 1 + } + } + ] + }; + + const TeacherCard = ({ teacher }: { teacher: Teacher }) => { + const gradientColors = generateGradientColors(teacher.name); + return ( +
+
+
+ {teacher.name} +
+
+
+ + {teacher.name} + + + {teacher.title} + +
+ + + {teacher.description} + + +
+
+
+ + {teacher.courses} +
+ 课程 +
+
+
+ + {(teacher.students / 1000).toFixed(1)}k +
+ 学员 +
+
+
+ + {teacher.rating} +
+ 评分 +
+
+
+
+
+ ); + }; + + return ( +
+
+
+ + 优秀讲师 + + + 业界专家实战分享,传授独家经验 + +
+ +
+ + {featuredTeachers.map((teacher, index) => ( + + ))} + + + + +
+
+
+ ); +}; + +export default FeaturedTeachersSection; diff --git a/apps/web/src/app/main/home/components/HeroSection.tsx b/apps/web/src/app/main/home/components/HeroSection.tsx new file mode 100644 index 0000000..b577173 --- /dev/null +++ b/apps/web/src/app/main/home/components/HeroSection.tsx @@ -0,0 +1,157 @@ +import React, { useRef, useCallback } from 'react'; +import { Button, Carousel, Typography } from 'antd'; +import { + TeamOutlined, + BookOutlined, + StarOutlined, + ClockCircleOutlined, + LeftOutlined, + RightOutlined +} from '@ant-design/icons'; +import type { CarouselRef } from 'antd/es/carousel'; + +const { Title, Text } = Typography; + +interface CarouselItem { + title: string; + desc: string; + image: string; + action: string; + color: string; +} + +interface PlatformStat { + icon: React.ReactNode; + value: string; + label: string; +} + +const carouselItems: CarouselItem[] = [ + { + title: '探索编程世界', + desc: '从零开始学习编程,开启你的技术之旅', + image: '/images/banner1.jpg', + action: '立即开始', + color: 'from-blue-600/90' + }, + { + title: '人工智能课程', + desc: '掌握AI技术,引领未来发展', + image: '/images/banner2.jpg', + action: '了解更多', + color: 'from-purple-600/90' + } +]; + +const platformStats: PlatformStat[] = [ + { icon: , value: '50,000+', label: '注册学员' }, + { icon: , value: '1,000+', label: '精品课程' }, + { icon: , value: '98%', label: '好评度' }, + { icon: , value: '100万+', label: '学习时长' } +]; + +const HeroSection = () => { + const carouselRef = useRef(null); + + const handlePrev = useCallback(() => { + carouselRef.current?.prev(); + }, []); + + const handleNext = useCallback(() => { + carouselRef.current?.next(); + }, []); + + return ( +
+
+ + {carouselItems.map((item, index) => ( +
+
+
+
+ + {/* Content Container */} +
+
+ + {item.title} + + + {item.desc} + + +
+
+
+ ))} + + + {/* Navigation Buttons */} + + +
+ + {/* Stats Container */} +
+
+ {platformStats.map((stat, index) => ( +
+
+ {stat.icon} +
+
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+
+
+ ); +}; + +export default HeroSection; \ No newline at end of file diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx index 7952dd5..2d645a0 100644 --- a/apps/web/src/app/main/home/page.tsx +++ b/apps/web/src/app/main/home/page.tsx @@ -1,25 +1,125 @@ -import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor"; -// import FileUploader from "@web/src/components/common/uploader/FileUploader"; +import HeroSection from './components/HeroSection'; +import CategorySection from './components/CategorySection'; +import CoursesSection from './components/CoursesSection'; +import FeaturedTeachersSection from './components/FeaturedTeachersSection'; -import React, { useState, useCallback } from "react"; -import * as tus from "tus-js-client"; -interface TusUploadProps { - onSuccess?: (response: any) => void; - onError?: (error: Error) => void; -} -const HomePage: React.FC = ({ onSuccess, onError }) => { - return ( -
- {/* */} -
- -
- {/*
- -
*/} - {/* */} -
- ); +const HomePage = () => { + const mockCourses = [ + { + id: 1, + title: 'Python 零基础入门', + instructor: '张教授', + students: 12000, + rating: 4.8, + level: '入门', + duration: '36小时', + category: '编程语言', + progress: 0, + thumbnail: '/images/course1.jpg', + }, + { + id: 2, + title: '数据结构与算法', + instructor: '李教授', + students: 8500, + rating: 4.9, + level: '进阶', + duration: '48小时', + category: '计算机基础', + progress: 35, + thumbnail: '/images/course2.jpg', + }, + { + id: 3, + title: '前端开发实战', + instructor: '王教授', + students: 10000, + rating: 4.7, + level: '中级', + duration: '42小时', + category: '前端开发', + progress: 68, + thumbnail: '/images/course3.jpg', + }, + { + id: 4, + title: 'Java企业级开发', + instructor: '刘教授', + students: 9500, + rating: 4.6, + level: '高级', + duration: '56小时', + category: '编程语言', + progress: 0, + thumbnail: '/images/course4.jpg', + }, + { + id: 5, + title: '人工智能基础', + instructor: '陈教授', + students: 11000, + rating: 4.9, + level: '中级', + duration: '45小时', + category: '人工智能', + progress: 20, + thumbnail: '/images/course5.jpg', + }, + { + id: 6, + title: '大数据分析', + instructor: '赵教授', + students: 8000, + rating: 4.8, + level: '进阶', + duration: '50小时', + category: '数据科学', + progress: 45, + thumbnail: '/images/course6.jpg', + }, + { + id: 7, + title: '云计算实践', + instructor: '孙教授', + students: 7500, + rating: 4.7, + level: '高级', + duration: '48小时', + category: '云计算', + progress: 15, + thumbnail: '/images/course7.jpg', + }, + { + id: 8, + title: '移动应用开发', + instructor: '周教授', + students: 9000, + rating: 4.8, + level: '中级', + duration: '40小时', + category: '移动开发', + progress: 0, + thumbnail: '/images/course8.jpg', + }, + ]; + + return ( +
+ + + + + +
+ ); }; -export default HomePage; +export default HomePage; \ No newline at end of file diff --git a/apps/web/src/app/main/layout/MainFooter.tsx b/apps/web/src/app/main/layout/MainFooter.tsx new file mode 100644 index 0000000..1356335 --- /dev/null +++ b/apps/web/src/app/main/layout/MainFooter.tsx @@ -0,0 +1,68 @@ +import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons'; +import { Layout, Typography } from 'antd'; +export function MainFooter() { + return ( +
+
+
+ {/* 开发组织信息 */} +
+

+ 创新高地 软件小组 +

+

+ 提供技术支持 +

+
+ + {/* 联系方式 */} +
+
+ + 628118 +
+
+ + gcsjs6@tx3l.nb.kj +
+
+ + {/* 系统链接 */} +
+ +
+
+ + {/* 版权信息 */} +
+

+ © {new Date().getFullYear()} 南天烽火. All rights reserved. +

+
+
+
+ ); +} diff --git a/apps/web/src/app/main/layout/MainHeader.tsx b/apps/web/src/app/main/layout/MainHeader.tsx new file mode 100644 index 0000000..d7d089b --- /dev/null +++ b/apps/web/src/app/main/layout/MainHeader.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { Input, Layout, Avatar, Button, Dropdown } from 'antd'; +import { SearchOutlined, UserOutlined } from '@ant-design/icons'; +import { useAuth } from '@web/src/providers/auth-provider'; +import { useNavigate } from 'react-router-dom'; +import { UserMenu } from './UserMenu'; +import { NavigationMenu } from './NavigationMenu'; + +const { Header } = Layout; + +export function MainHeader() { + const [searchValue, setSearchValue] = useState(''); + const { isAuthenticated, user } = useAuth(); + const navigate = useNavigate(); + + return ( +
+
+
+
navigate('/')} + className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer" + > + 烽火慕课 +
+ +
+
+
+ } + placeholder="搜索课程" + className="w-72 rounded-full" + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + /> +
+ {isAuthenticated ? ( + } + trigger={['click']} + placement="bottomRight" + > + + {(user?.showname || user?.username || '')[0]?.toUpperCase()} + + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/layout/MainLayout.tsx b/apps/web/src/app/main/layout/MainLayout.tsx new file mode 100644 index 0000000..8ec4204 --- /dev/null +++ b/apps/web/src/app/main/layout/MainLayout.tsx @@ -0,0 +1,18 @@ +import { Layout } from 'antd'; +import { Outlet } from 'react-router-dom'; +import { MainHeader } from './MainHeader'; +import { MainFooter } from './MainFooter'; + +const { Content } = Layout; + +export function MainLayout() { + return ( + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/web/src/app/main/layout/NavigationMenu.tsx b/apps/web/src/app/main/layout/NavigationMenu.tsx new file mode 100644 index 0000000..b1a4fb6 --- /dev/null +++ b/apps/web/src/app/main/layout/NavigationMenu.tsx @@ -0,0 +1,31 @@ +import { Menu } from 'antd'; +import { useNavigate, useLocation } from 'react-router-dom'; + +const menuItems = [ + { key: 'home', path: '/', label: '首页' }, + { key: 'courses', path: '/courses', label: '全部课程' }, + { key: 'paths', path: '/paths', label: '学习路径' } +]; + +export const NavigationMenu = () => { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const selectedKey = menuItems.find(item => item.path === pathname)?.key || ''; + return ( + { + const selectedItem = menuItems.find(item => item.key === key); + if (selectedItem) navigate(selectedItem.path); + }} + > + {menuItems.map(({ key, label }) => ( + + {label} + + ))} + + ); +}; \ No newline at end of file diff --git a/apps/web/src/app/main/layout/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu.tsx new file mode 100644 index 0000000..97396fd --- /dev/null +++ b/apps/web/src/app/main/layout/UserMenu.tsx @@ -0,0 +1,49 @@ +import { Avatar, Menu, Dropdown } from 'antd'; +import { LogoutOutlined, SettingOutlined } from '@ant-design/icons'; +import { useAuth } from '@web/src/providers/auth-provider'; +import { useNavigate } from 'react-router-dom'; + +export const UserMenu = () => { + const { isAuthenticated, logout, user } = useAuth(); + const navigate = useNavigate(); + + return ( + + {isAuthenticated ? ( + <> + +
+ + {(user?.showname || user?.username || '')[0]?.toUpperCase()} + +
+ {user?.showname || user?.username} + {user?.department?.name || user?.officerId} +
+
+
+ + } className="px-4"> + 个人设置 + + } + onClick={async () => await logout()} + className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50" + > + 退出登录 + + + ) : ( + navigate("/login")} + className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50" + > + 登录/注册 + + )} +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/app/main/paths/page.tsx b/apps/web/src/app/main/paths/page.tsx new file mode 100644 index 0000000..0b4edfd --- /dev/null +++ b/apps/web/src/app/main/paths/page.tsx @@ -0,0 +1,3 @@ +export default function PathsPage() { + return <>paths +} \ No newline at end of file diff --git a/apps/web/src/app/main/self/courses/page.tsx b/apps/web/src/app/main/self/courses/page.tsx new file mode 100644 index 0000000..36b358f --- /dev/null +++ b/apps/web/src/app/main/self/courses/page.tsx @@ -0,0 +1,7 @@ +export default function MyCoursePage() { + return ( +
+ My Course Page +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/main/self/profiles/page.tsx b/apps/web/src/app/main/self/profiles/page.tsx new file mode 100644 index 0000000..70ac75b --- /dev/null +++ b/apps/web/src/app/main/self/profiles/page.tsx @@ -0,0 +1,3 @@ +export default function ProfilesPage() { + return <>Profiles +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx b/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx deleted file mode 100644 index 7224da2..0000000 --- a/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { EdgeProps, getBezierPath, useInternalNode } from '@xyflow/react'; -import { getEdgeParams } from '../utils'; - -/** - * FloatingEdge 组件用于渲染图中的浮动边。 - * 该组件通过计算源节点和目标节点的位置,生成贝塞尔曲线路径,并渲染为SVG路径元素。 - * 适用于需要自定义边样式的图结构可视化场景。 - */ -function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { - // 使用 useInternalNode 钩子获取源节点和目标节点的内部节点信息 - const sourceNode = useInternalNode(source); - const targetNode = useInternalNode(target); - - // 如果源节点或目标节点不存在,则不渲染任何内容 - if (!sourceNode || !targetNode) { - return null; - } - - // 获取边的参数,包括源节点和目标节点的坐标及位置信息 - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( - sourceNode, - targetNode, - ); - - // 使用 getBezierPath 函数生成贝塞尔曲线路径 - const [edgePath] = getBezierPath({ - sourceX: sx, - sourceY: sy, - sourcePosition: sourcePos, - targetPosition: targetPos, - targetX: tx, - targetY: ty, - }); - - // 返回 SVG 路径元素,表示图中的边 - return ( - - ); -} - -export default FloatingEdge; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx index f7da3d2..15e2224 100644 --- a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx +++ b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx @@ -1,17 +1,58 @@ -import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, useReactFlow } from '@xyflow/react'; +import { BaseEdge, Node, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, Position, useReactFlow, useInternalNode, InternalNode } from '@xyflow/react'; export type GraphEdge = Edge<{ text: string }, 'graph-edge'>; -export const GraphEdge = ({ id, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps) => { - const { setEdges } = useReactFlow(); - // 使用贝塞尔曲线代替直线,让连线更流畅 - const [edgePath, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - targetX, - targetY, - }); +function getEdgeParams(sourceNode: InternalNode, targetNode: InternalNode) { + console.log(sourceNode) + const sourceCenter = { + x: sourceNode.position.x + sourceNode.width / 2, + y: sourceNode.position.y + sourceNode.height / 2, + }; + const targetCenter = { + x: targetNode.position.x + targetNode.width / 2, + y: targetNode.position.y + targetNode.height / 2, + }; + const dx = targetCenter.x - sourceCenter.x; + + // 简化连接逻辑:只基于x轴方向判断 + let sourcePos: Position; + let targetPos: Position; + + // 如果目标在源节点右边,源节点用右侧连接点,目标节点用左侧连接点 + if (dx > 0) { + sourcePos = Position.Right; + targetPos = Position.Left; + } else { + // 如果目标在源节点左边,源节点用左侧连接点,目标节点用右侧连接点 + sourcePos = Position.Left; + targetPos = Position.Right; + } + + // 使用节点中心的y坐标 + return { + sourcePos, + targetPos, + sx: sourceCenter.x + sourceNode.measured.width / 2, + sy: sourceCenter.y, + tx: targetCenter.x - targetNode.measured.width / 2, + ty: targetCenter.y, + }; +} +export const GraphEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + const { sx, sy, tx, ty, targetPos, sourcePos } = getEdgeParams(sourceNode, targetNode) + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX: sx, + sourceY: sy, + targetX: tx, + targetY: ty, + sourcePosition: sourcePos, + targetPosition: targetPos, + curvature: 0.3, + + }); return ( <> - {/* 添加边的标签渲染器 */} {data?.text && (
{ + const nodeMap = new Map(); + nodes.forEach(node => { + nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 }); + }); + return nodeMap; + } + + protected buildTreeStructure(nodeMap: Map, edges: Edge[]): NodeWithLayout | undefined { + edges.forEach(edge => { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (source && target) { + source.children?.push(target); + target.parent = source; + } + }); + return Array.from(nodeMap.values()).find(node => !node.parent); + } + + abstract layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] }; +} diff --git a/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts new file mode 100644 index 0000000..42779ca --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts @@ -0,0 +1,87 @@ +import { Edge,Node } from "@xyflow/react"; +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, NodeWithLayout } from "./types"; + +// 思维导图布局实现 +export class MindMapLayout extends BaseLayout { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { + nodes, + edges, + levelSeparation = 200, + nodeSeparation = 60 + } = options; + + const nodeMap = this.buildNodeMap(nodes); + const rootNode = this.buildTreeStructure(nodeMap, edges); + if (!rootNode) return { nodes, edges }; + + this.assignSides(rootNode); + this.calculateSubtreeHeight(rootNode, nodeSeparation); + this.calculateLayout(rootNode, 0, 0, levelSeparation, nodeSeparation); + + const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ + ...node, + position: node.position, + })); + + return { nodes: layoutedNodes, edges }; + } + + private assignSides(node: NodeWithLayout, isRight: boolean = true): void { + if (!node.children?.length) return; + + const len = node.children.length; + const midIndex = Math.floor(len / 2); + + if (!node.parent) { + for (let i = 0; i < len; i++) { + const child = node.children[i]; + this.assignSides(child, i < midIndex); + child.isRight = i < midIndex; + } + } else { + node.children.forEach(child => { + this.assignSides(child, isRight); + child.isRight = isRight; + }); + } + } + + private calculateSubtreeHeight(node: NodeWithLayout, nodeSeparation: number): number { + if (!node.children?.length) { + node.subtreeHeight = node.height || 40; + return node.subtreeHeight; + } + + const childrenHeight = node.children.reduce((sum, child) => { + return sum + this.calculateSubtreeHeight(child, nodeSeparation); + }, 0); + + const totalGaps = (node.children.length - 1) * nodeSeparation; + node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps); + return node.subtreeHeight; + } + + private calculateLayout( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number, + nodeSeparation: number + ): void { + node.position = { x, y }; + if (!node.children?.length) return; + + let currentY = y - (node.subtreeHeight || 0) / 2; + + node.children.forEach(child => { + const direction = child.isRight ? 1 : -1; + const childX = x + (levelSeparation * direction); + const childY = currentY + (child.subtreeHeight || 0) / 2; + + this.calculateLayout(child, childX, childY, levelSeparation, nodeSeparation); + currentY += (child.subtreeHeight || 0) + nodeSeparation; + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts new file mode 100644 index 0000000..2d7d68c --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts @@ -0,0 +1,127 @@ +import { Edge, Node } from "@xyflow/react"; +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, NodeWithLayout } from "./types"; + +/** + * SingleMapLayout 类继承自 BaseLayout,用于实现单图布局。 + * 该类主要负责将节点和边按照一定的规则进行布局,使得节点在视觉上呈现出层次分明、结构清晰的效果。 + */ +export class SingleMapLayout extends BaseLayout { + /** + * 布局方法,根据提供的选项对节点和边进行布局。 + * @param options 布局选项,包含节点、边、层级间距和节点间距等信息。 + * @returns 返回布局后的节点和边。 + */ + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { nodes, edges, levelSeparation = 100, nodeSeparation = 30 } = options; + const nodeMap = this.buildNodeMap(nodes); + const root = this.buildTreeStructure(nodeMap, edges); + + if (!root) { + return { nodes: [], edges: [] }; + } + + // 计算子树的尺寸 + this.calculateSubtreeDimensions(root); + + // 第一遍:分配垂直位置 + this.assignInitialVerticalPositions(root, 0); + + // 第二遍:使用平衡布局定位节点 + this.positionNodes(root, 0, 0, levelSeparation, nodeSeparation, 'right'); + + return { + nodes: Array.from(nodeMap.values()), + edges + }; + } + + /** + * 计算子树的尺寸,包括高度和宽度。 + * @param node 当前节点。 + */ + private calculateSubtreeDimensions(node: NodeWithLayout): void { + + node.subtreeHeight = node.height || 40; + node.subtreeWidth = node.width || 150; + + if (node.children && node.children.length > 0) { + // 首先计算所有子节点的尺寸 + node.children.forEach(child => this.calculateSubtreeDimensions(child)); + + // 计算子节点所需的总高度,包括间距 + const totalChildrenHeight = this.calculateTotalChildrenHeight(node.children, 30); + + // 更新节点的子树尺寸 + node.subtreeHeight = Math.max(node.subtreeHeight, totalChildrenHeight); + node.subtreeWidth += Math.max(...node.children.map(child => child.subtreeWidth || 0)); + } + } + + /** + * 计算子节点的总高度。 + * @param children 子节点数组。 + * @param spacing 子节点之间的间距。 + * @returns 返回子节点的总高度。 + */ + private calculateTotalChildrenHeight(children: NodeWithLayout[], spacing: number): number { + if (!children.length) return 0; + const totalHeight = children.reduce((sum, child) => sum + (child.subtreeHeight || 0), 0); + return totalHeight + (spacing * (children.length - 1)); + } + + /** + * 分配初始垂直位置。 + * @param node 当前节点。 + * @param level 当前层级。 + */ + private assignInitialVerticalPositions(node: NodeWithLayout, level: number): void { + if (!node.children?.length) return; + const totalHeight = this.calculateTotalChildrenHeight(node.children, 30); + let currentY = -(totalHeight / 2); + node.children.forEach(child => { + const childHeight = child.subtreeHeight || 0; + child.verticalLevel = level + 1; + child.relativeY = currentY + (childHeight / 2); + this.assignInitialVerticalPositions(child, level + 1); + currentY += childHeight + 30; // 30 是垂直间距 + }); + } + + /** + * 定位节点。 + * @param node 当前节点。 + * @param x 当前节点的水平位置。 + * @param y 当前节点的垂直位置。 + * @param levelSeparation 层级间距。 + * @param nodeSeparation 节点间距。 + * @param direction 布局方向,'left' 或 'right'。 + */ + private positionNodes( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number, + nodeSeparation: number, + direction: 'left' | 'right' + ): void { + node.position = { x, y }; + if (!node.children?.length) return; + // 计算子节点的水平位置 + const nextX = direction === 'right' + ? x + (node.width || 0) + levelSeparation + : x - (node.width || 0) - levelSeparation; + // 定位每个子节点 + node.children.forEach(child => { + const childY = y + (child.relativeY || 0); + this.positionNodes( + child, + nextX, + childY, + levelSeparation, + nodeSeparation, + direction + ); + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts b/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts new file mode 100644 index 0000000..31fe186 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts @@ -0,0 +1,68 @@ +import { BaseLayout } from "./BaseLayout"; +import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types"; +import { Edge, Node } from "@xyflow/react"; +export class TreeLayout extends BaseLayout { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } { + const { + nodes, + edges, + levelSeparation = 100, // 层级间垂直距离 + nodeSeparation = 50 // 节点间水平距离 + } = options; + + const nodeMap = this.buildNodeMap(nodes); + const rootNode = this.buildTreeStructure(nodeMap, edges); + if (!rootNode) return { nodes, edges }; + // 计算每个节点的子树宽度 + this.calculateSubtreeWidth(rootNode, nodeSeparation); + // 计算布局位置 + this.calculateTreeLayout(rootNode, 0, 0, levelSeparation); + const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ + ...node, + position: node.position, + })); + return { nodes: layoutedNodes, edges }; + } + + private calculateSubtreeWidth(node: NodeWithLayout, nodeSeparation: number): number { + if (!node.children?.length) { + node.subtreeWidth = node.width || 150; + return node.subtreeWidth; + } + + const childrenWidth = node.children.reduce((sum, child) => { + return sum + this.calculateSubtreeWidth(child, nodeSeparation); + }, 0); + + const totalGaps = (node.children.length - 1) * nodeSeparation; + node.subtreeWidth = Math.max(node.width || 150, childrenWidth + totalGaps); + return node.subtreeWidth; + } + + private calculateTreeLayout( + node: NodeWithLayout, + x: number, + y: number, + levelSeparation: number + ): void { + node.position = { x, y }; + + if (!node.children?.length) return; + + const totalChildrenWidth = node.children.reduce((sum, child) => + sum + (child.subtreeWidth || 0), 0); + const totalGaps = (node.children.length - 1) * (node.width || 150); + + // 计算最左侧子节点的起始x坐标 + let startX = x - (totalChildrenWidth + totalGaps) / 2; + + node.children.forEach(child => { + const childX = startX + (child.subtreeWidth || 0) / 2; + const childY = y + levelSeparation; + + this.calculateTreeLayout(child, childX, childY, levelSeparation); + + startX += (child.subtreeWidth || 0) + (node.width || 150); + }); + } +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/index.ts b/apps/web/src/components/common/editor/graph/layout/index.ts index 631dcda..45022e5 100644 --- a/apps/web/src/components/common/editor/graph/layout/index.ts +++ b/apps/web/src/components/common/editor/graph/layout/index.ts @@ -1,124 +1,34 @@ import { Node, Edge } from "@xyflow/react"; +import { LayoutOptions, LayoutStrategy } from "./types"; +import { TreeLayout } from "./TreeLayout"; +import { MindMapLayout } from "./MindMapLayout"; +import { SingleMapLayout } from "./SingleMapLayout"; -interface LayoutOptions { - nodes: Node[]; - edges: Edge[]; - levelSeparation?: number; - nodeSeparation?: number; +// 布局工厂类 +class LayoutFactory { + static createLayout(type: 'mindmap' | 'tree' | 'force' | 'single'): LayoutStrategy { + switch (type) { + case 'mindmap': + return new MindMapLayout(); + case 'tree': + return new TreeLayout(); + case 'single': + return new SingleMapLayout() + case 'force': + // return new ForceLayout(); // 待实现 + default: + return new MindMapLayout(); + } + } } -interface NodeWithLayout extends Node { - width?: number; - height?: number; - children?: NodeWithLayout[]; - parent?: NodeWithLayout; - subtreeHeight?: number; - isRight?: boolean; +// 导出布局函数 +export function getLayout(type: 'mindmap' | 'tree' | 'force' | 'single', options: LayoutOptions) { + const layoutStrategy = LayoutFactory.createLayout(type); + return layoutStrategy.layout(options); } +// 为了保持向后兼容,保留原有的导出 export function getMindMapLayout(options: LayoutOptions) { - const { - nodes, - edges, - levelSeparation = 200, - nodeSeparation = 60 - } = options; - - // 构建树形结构 - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 }); - }); - - let rootNode: NodeWithLayout | undefined; - edges.forEach(edge => { - const source = nodeMap.get(edge.source); - const target = nodeMap.get(edge.target); - if (source && target) { - source.children?.push(target); - target.parent = source; - } - }); - - // 找到根节点 - rootNode = Array.from(nodeMap.values()).find(node => !node.parent); - if (!rootNode) return { nodes, edges }; - - // 分配节点到左右两侧 - function assignSides(node: NodeWithLayout, isRight: boolean = true) { - if (!node.children?.length) return; - - const len = node.children.length; - const midIndex = Math.floor(len / 2); - - // 如果是根节点,将子节点分为左右两部分 - if (!node.parent) { - for (let i = 0; i < len; i++) { - const child = node.children[i]; - assignSides(child, i < midIndex); - child.isRight = i < midIndex; - } - } - // 如果不是根节点,所有子节点继承父节点的方向 - else { - node.children.forEach(child => { - assignSides(child, isRight); - child.isRight = isRight; - }); - } - } - - // 计算子树高度 - function calculateSubtreeHeight(node: NodeWithLayout): number { - if (!node.children?.length) { - node.subtreeHeight = node.height || 40; - return node.subtreeHeight; - } - - const childrenHeight = node.children.reduce((sum, child) => { - return sum + calculateSubtreeHeight(child); - }, 0); - - const totalGaps = (node.children.length - 1) * nodeSeparation; - node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps); - return node.subtreeHeight; - } - - // 布局计算 - function calculateLayout(node: NodeWithLayout, x: number, y: number) { - node.position = { x, y }; - if (!node.children?.length) return; - - let currentY = y - (node.subtreeHeight || 0) / 2; - - node.children.forEach(child => { - const direction = child.isRight ? 1 : -1; - const childX = x + (levelSeparation * direction); - const childY = currentY + (child.subtreeHeight || 0) / 2; - - calculateLayout(child, childX, childY); - currentY += (child.subtreeHeight || 0) + nodeSeparation; - }); - } - - // 执行布局流程 - if (rootNode) { - // 1. 分配节点到左右两侧 - assignSides(rootNode); - // 2. 计算子树高度 - calculateSubtreeHeight(rootNode); - // 3. 执行布局计算 - calculateLayout(rootNode, 0, 0); - } - - // 转换回原始格式 - const layoutedNodes = Array.from(nodeMap.values()).map(node => ({ - ...node, - position: node.position, - })); - - return { - nodes: layoutedNodes, - edges, - }; + return getLayout("single", options); } \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/types.ts b/apps/web/src/components/common/editor/graph/layout/types.ts new file mode 100644 index 0000000..b46b4df --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/types.ts @@ -0,0 +1,23 @@ +import { Node, Edge } from "@xyflow/react"; +// 基础接口和类型定义 +export interface LayoutOptions { + nodes: Node[]; + edges: Edge[]; + levelSeparation?: number; + nodeSeparation?: number; +} + +export interface NodeWithLayout extends Node { + children?: NodeWithLayout[]; + parent?: NodeWithLayout; + subtreeHeight?: number; + subtreeWidth?: number; + isRight?: boolean; + relativeY?: number + verticalLevel?: number +} + +// 布局策略接口 +export interface LayoutStrategy { + layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] }; +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx index b5b8636..d4aec7b 100644 --- a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx +++ b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx @@ -1,8 +1,10 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { Handle, Position, NodeProps, Node } from '@xyflow/react'; +import { Handle, Position, NodeProps, Node, useUpdateNodeInternals } from '@xyflow/react'; import useGraphStore from '../store'; import { shallow } from 'zustand/shallow'; import { GraphState } from '../types'; +import { cn } from '@web/src/utils/classname'; +import { LEVEL_STYLES, NODE_BASE_STYLES, TEXTAREA_BASE_STYLES } from './style'; export type GraphNode = Node<{ label: string; @@ -10,157 +12,166 @@ export type GraphNode = Node<{ level?: number; }, 'graph-node'>; -const getLevelStyles = (level: number = 0) => { + + +interface TextMeasurerProps { + element: HTMLTextAreaElement; + minWidth?: number; + maxWidth?: number; + padding?: number; +} + +const measureTextWidth = ({ + element, + minWidth = 60, + maxWidth = 400, + padding = 16, +}: TextMeasurerProps): number => { + const span = document.createElement('span'); const styles = { - 0: { - container: 'bg-[#2B4B6F] text-white', - handle: 'bg-[#2B4B6F]', - fontSize: 'text-lg' - }, - 1: { - container: 'bg-blue-300 text-white', - handle: 'bg-[#3A5F84]', - fontSize: 'text-base' - }, - 2: { - container: 'bg-gray-100', - handle: 'bg-[#496F96]', - fontSize: 'text-base' - } - }; - return styles[level as keyof typeof styles] + visibility: 'hidden', + position: 'absolute', + whiteSpace: 'pre', + fontSize: window.getComputedStyle(element).fontSize, + } as const; + + Object.assign(span.style, styles); + span.textContent = element.value || element.placeholder; + document.body.appendChild(span); + + const contentWidth = Math.min(Math.max(span.offsetWidth + padding, minWidth), maxWidth); + document.body.removeChild(span); + + return contentWidth; }; - -const baseTextStyles = ` -text-center -break-words -whitespace-pre-wrap -`; -const handleStyles = ` - w-2.5 h-2.5 - border-2 border-white/80 - rounded-full - transition-colors - duration-200 - opacity-80 - hover:opacity-100 -`; const selector = (store: GraphState) => ({ updateNode: store.updateNode, }); -export const GraphNode = memo(({ id, selected, data, isConnectable }: NodeProps) => { +export const GraphNode = memo(({ id, selected, width, height, data, isConnectable }: NodeProps) => { const { updateNode } = useGraphStore(selector, shallow); const [isEditing, setIsEditing] = useState(false); - const levelStyles = getLevelStyles(data.level); const [inputValue, setInputValue] = useState(data.label); const [isComposing, setIsComposing] = useState(false); - const updateTextareaHeight = useCallback((element: HTMLTextAreaElement) => { + const containerRef = useRef(null); + const textareaRef = useRef(null); + const updateNodeInternals = useUpdateNodeInternals(); + // const [nodeWidth, setNodeWidth] = useState(width) + // const [nodeHeight, setNodeHeight] = useState(height) + const updateTextareaSize = useCallback((element: HTMLTextAreaElement) => { + const contentWidth = measureTextWidth({ element }); + element.style.whiteSpace = contentWidth >= 400 ? 'pre-wrap' : 'pre'; + element.style.width = `${contentWidth}px`; element.style.height = 'auto'; element.style.height = `${element.scrollHeight}px`; + }, []); + const handleChange = useCallback((evt: React.ChangeEvent) => { const newValue = evt.target.value; setInputValue(newValue); updateNode(id, { label: newValue }); - updateTextareaHeight(evt.target); - }, [updateNode, id, updateTextareaHeight]); + updateTextareaSize(evt.target); + }, [updateNode, id, updateTextareaSize]); + + useEffect(() => { + if (textareaRef.current) { + updateTextareaSize(textareaRef.current); + } + }, [isEditing, inputValue, updateTextareaSize]); + const handleKeyDown = useCallback((evt: React.KeyboardEvent) => { - if (!isEditing) { - if (/^[a-zA-Z0-9]$/.test(evt.key)) { - setIsEditing(true); - setInputValue(evt.key); // 将第一个字符添加到现有内容后 - updateNode(id, { label: evt.key }); - } - if (evt.key === ' ') { - setIsEditing(true); - setInputValue(data.label); // 将第一个字符添加到现有内容后 - updateNode(id, { label: data.label }); - } - evt.preventDefault(); // 阻止默认行为 - evt.stopPropagation(); // 阻止事件冒泡 - } else if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) { - setIsEditing(false); + const isAlphanumeric = /^[a-zA-Z0-9]$/.test(evt.key); + const isSpaceKey = evt.key === ' '; + + if (!isEditing && (isAlphanumeric || isSpaceKey)) { evt.preventDefault(); + evt.stopPropagation(); + + const newValue = isAlphanumeric ? evt.key : data.label; + setIsEditing(true); + setInputValue(newValue); + updateNode(id, { label: newValue }); + return; + } + + if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) { + evt.preventDefault(); + setIsEditing(false); } }, [isEditing, isComposing, data.label, id, updateNode]); + const handleDoubleClick = useCallback(() => { setIsEditing(true); }, []); - const handleBlur = useCallback(() => setIsEditing(false), []); - // 添加 ref 来获取父元素 - const containerRef = useRef(null); - const textareaRef = useRef(null); + const handleBlur = useCallback(() => { + setIsEditing(false); + }, []); useEffect(() => { - if (isEditing && textareaRef.current) { - updateTextareaHeight(textareaRef.current); - // 聚焦并将光标移到末尾 - textareaRef.current.focus(); - const length = textareaRef.current.value.length; - textareaRef.current.setSelectionRange(length, length); - } - }, [isEditing, updateTextareaHeight]); + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + updateNodeInternals(id); + } + }); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, []); return (
-