This commit is contained in:
longdayi 2025-02-06 16:32:31 +08:00
parent 43ca9881d8
commit 4fb0fa49e7
47 changed files with 2132 additions and 561 deletions

View File

@ -32,6 +32,7 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@nice/client": "workspace:^", "@nice/client": "workspace:^",
"@nice/common": "workspace:^", "@nice/common": "workspace:^",
"@nice/config": "workspace:^",
"@nice/iconer": "workspace:^", "@nice/iconer": "workspace:^",
"@nice/utils": "workspace:^", "@nice/utils": "workspace:^",
"mind-elixir": "workspace:^", "mind-elixir": "workspace:^",

View File

@ -24,7 +24,7 @@ function App() {
theme={{ theme={{
algorithm: theme.defaultAlgorithm, algorithm: theme.defaultAlgorithm,
token: { token: {
colorPrimary: "#2e75b6", colorPrimary: "#0088E8",
}, },
components: {}, components: {},
}}> }}>

View File

@ -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 (
<Card
hoverable
className="w-full h-full transition-all duration-300 hover:shadow-lg"
cover={
<img
alt={course.title}
src={course.thumbnail}
className="object-cover w-full h-40"
/>
}
>
<div className="space-y-3">
<h3 className="text-lg font-semibold line-clamp-2 hover:text-blue-600 transition-colors">
{course.title}
</h3>
<p className="text-gray-500 text-sm">{course.instructor}</p>
<div className="flex items-center space-x-2">
<Rate disabled defaultValue={course.rating} className="text-sm" />
<span className="text-gray-500 text-sm">{course.rating}</span>
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-1">
<UserOutlined className="text-gray-400" />
<span>{course.enrollments} </span>
</div>
<div className="flex items-center space-x-1">
<ClockCircleOutlined className="text-gray-400" />
<span>{course.duration}</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Tag color="blue" className="rounded-full px-3">{course.category}</Tag>
<Tag color="green" className="rounded-full px-3">{course.level}</Tag>
</div>
</div>
</Card>
);
}

View File

@ -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 (
<div className="space-y-6">
{courses.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<CourseCard key={course.id} course={course} />
))}
</div>
<div className="flex justify-center mt-8">
<Pagination
current={currentPage}
total={total}
pageSize={pageSize}
onChange={onPageChange}
showSizeChanger={false}
/>
</div>
</>
) : (
<Empty description="暂无相关课程" />
)}
</div>
);
}

View File

@ -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 (
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6">
<div>
<h3 className="text-lg font-medium mb-4"></h3>
<Radio.Group
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value)}
className="flex flex-col space-y-3"
>
<Radio value=""></Radio>
{categories.map(category => (
<Radio key={category} value={category}>
{category}
</Radio>
))}
</Radio.Group>
</div>
<Divider className="my-6" />
<div>
<h3 className="text-lg font-medium mb-4"></h3>
<Radio.Group
value={selectedLevel}
onChange={(e) => onLevelChange(e.target.value)}
className="flex flex-col space-y-3"
>
<Radio value=""></Radio>
{levels.map(level => (
<Radio key={level} value={level}>
{level}
</Radio>
))}
</Radio.Group>
</div>
</div>
);
}

View File

@ -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)}小时`
}));

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="flex gap-4">
{/* 左侧筛选区域 */}
<div className="lg:w-1/5">
<div className="sticky top-24">
<FilterSection
selectedCategory={selectedCategory}
selectedLevel={selectedLevel}
onCategoryChange={category => {
setSelectedCategory(category);
setCurrentPage(1);
}}
onLevelChange={level => {
setSelectedLevel(level);
setCurrentPage(1);
}}
/>
</div>
</div>
{/* 右侧课程列表区域 */}
<div className="lg:w-4/5">
<div className="bg-white p-6 rounded-lg shadow-sm">
<div className="flex justify-between items-center mb-6">
<span className="text-gray-600">
{filteredCourses.length}
</span>
</div>
<CourseList
courses={paginatedCourses}
total={filteredCourses.length}
pageSize={pageSize}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<number | null>(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 (
<section className="py-32 relative overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative">
<div className="text-center mb-24">
<Title level={2} className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
</Title>
<Text type="secondary" className="text-xl font-light">
</Text>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{displayedCategories.map((category, index) => {
const categoryColor = stringToColor(category.name);
const isHovered = hoveredIndex === index;
return (
<div
key={index}
className="group relative rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
onMouseEnter={() => handleMouseEnter(index)}
onMouseLeave={handleMouseLeave}
role="button"
tabIndex={0}
aria-label={`查看${category.name}课程类别`}
>
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
<div
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${
isHovered ? 'scale-[1.02] bg-opacity-95' : 'scale-100 bg-opacity-90'
}`}
/>
<div
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${
isHovered ? 'shadow-[0_8px_30px_rgb(0,0,0,0.12)]' : 'shadow-none opacity-0'
}`}
/>
<div
className={`absolute top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out ${
isHovered ? 'w-36 opacity-90' : 'w-24 opacity-60'
}`}
style={{ backgroundColor: categoryColor }}
/>
<div className="relative p-6">
<div className="flex flex-col space-y-4 mb-4">
<Text strong className="text-xl font-semibold tracking-tight">
{category.name}
</Text>
<span
className={`px-3 py-1 rounded-full text-sm w-fit font-medium transition-all duration-500 ease-out ${
isHovered ? 'shadow-md scale-105' : ''
}`}
style={{
backgroundColor: `${categoryColor}15`,
color: categoryColor
}}
>
{category.count}
</span>
</div>
<Text type="secondary" className="block text-sm leading-relaxed opacity-90">
{category.description}
</Text>
<div
className={`mt-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out ${
isHovered ? 'translate-x-2' : ''
}`}
style={{ color: categoryColor }}
>
<span></span>
<span
className={`transform transition-all duration-500 ease-out ${
isHovered ? 'translate-x-2' : ''
}`}
>
</span>
</div>
</div>
</div>
);
})}
</div>
{courseCategories.length > 8 && (
<div className="flex justify-center mt-12">
<Button
type="default"
size="large"
className="px-8 h-12 text-base font-medium hover:shadow-md transition-all duration-300"
onClick={() => setShowAll(!showAll)}
>
{showAll ? '收起' : '查看更多分类'}
</Button>
</div>
)}
</div>
</section>
);
};
export default CategorySection;

View File

@ -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<CoursesSectionProps> = ({
title,
description,
courses,
initialVisibleCoursesCount = 8,
}) => {
const navigate = useNavigate();
const [selectedCategory, setSelectedCategory] = useState<string>('全部');
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 (
<section className="relative py-16 overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative">
<div className="flex justify-between items-end mb-12">
<div>
<Title
level={2}
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-blue-600 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x"
>
{title}
</Title>
<Text type="secondary" className="text-xl font-light">
{description}
</Text>
</div>
</div>
<div className="mb-8 flex flex-wrap gap-3">
{categories.map((category) => (
<Tag
key={category}
color={selectedCategory === category ? 'blue' : 'default'}
onClick={() => 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}
</Tag>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{displayedCourses.map((course) => (
<Card
key={course.id}
hoverable
className="group overflow-hidden rounded-2xl border-0 bg-white/70 backdrop-blur-sm
shadow-[0_10px_40px_-15px_rgba(0,0,0,0.1)] hover:shadow-[0_20px_50px_-15px_rgba(0,0,0,0.15)]
transition-all duration-700 ease-out transform hover:-translate-y-1 will-change-transform"
cover={
<div className="relative h-48 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-1000 ease-out group-hover:scale-110"
style={{ backgroundImage: `url(${course.thumbnail})` }}
/>
<div className="absolute inset-0 bg-gradient-to-tr from-black/60 via-black/40 to-transparent opacity-60 group-hover:opacity-40 transition-opacity duration-700" />
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-500 ease-out transform group-hover:scale-110 drop-shadow-lg" />
{course.progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 backdrop-blur-md bg-black/20">
<Progress
percent={course.progress}
showInfo={false}
strokeColor={{
from: '#3b82f6',
to: '#60a5fa',
}}
className="m-0"
/>
</div>
)}
</div>
}
>
<div className="px-2">
<div className="flex gap-2 mb-4">
<Tag
color="blue"
className="px-3 py-1 rounded-full border-0 shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)] transition-all duration-300 hover:shadow-[0_4px_12px_-4px_rgba(59,130,246,0.6)]"
>
{course.category}
</Tag>
<Tag
color={
course.level === '入门'
? 'green'
: course.level === '中级'
? 'blue'
: 'purple'
}
className="px-3 py-1 rounded-full border-0 shadow-sm transition-all duration-300 hover:shadow-md"
>
{course.level}
</Tag>
</div>
<Title
level={4}
className="mb-4 line-clamp-2 font-bold leading-snug transition-colors duration-300 group-hover:text-blue-600"
>
{course.title}
</Title>
<div className="flex items-center mb-4 transition-all duration-300 group-hover:text-blue-500">
<UserOutlined className="mr-2 text-blue-500" />
<Text className="text-gray-600 font-medium group-hover:text-blue-500">
{course.instructor}
</Text>
</div>
<div className="flex justify-between items-center mb-4 text-gray-500 text-sm">
<span className="flex items-center">
<ClockCircleOutlined className="mr-1.5" />
{course.duration}
</span>
<span className="flex items-center">
<TeamOutlined className="mr-1.5" />
{course.students.toLocaleString()}
</span>
<span className="flex items-center text-yellow-500">
<StarOutlined className="mr-1.5" />
{course.rating}
</span>
</div>
<div className="pt-4 border-t border-gray-100 text-center">
<Button
type="primary"
size="large"
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
transform hover:translate-y-[-2px] transition-all duration-500 ease-out"
>
</Button>
</div>
</div>
</Card>
))}
</div>
{filteredCourses.length >= visibleCourses && (
<div className=' flex items-center gap-4 justify-between mt-6'>
<div className='h-[1px] flex-grow bg-gray-200'></div>
<div className="flex justify-end ">
<div
onClick={() => 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"
>
</div>
</div>
</div>
)}
</div>
</section>
);
};
export default CoursesSection;

View File

@ -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<any>(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 (
<div className="p-8">
<div className="bg-white rounded-2xl shadow-[0_4px_20px_-2px_rgba(0,0,0,0.1)] overflow-hidden transform transition-all duration-300 hover:shadow-[0_8px_30px_-4px_rgba(0,0,0,0.15)] hover:-translate-y-1">
<div className="relative h-48" style={{
background: `linear-gradient(to right, ${gradientColors.from}, ${gradientColors.to})`
}}>
<img
src={teacher.avatar}
alt={teacher.name}
className="absolute left-1/2 bottom-0 transform -translate-x-1/2 translate-y-1/2 w-24 h-24 rounded-full border-4 border-white object-cover shadow-lg"
/>
</div>
<div className="pt-16 p-6 min-h-[280px] flex flex-col">
<div className="text-center mb-4">
<Title level={4} className="mb-1">
{teacher.name}
</Title>
<Tag color="blue" className="text-sm">
{teacher.title}
</Tag>
</div>
<Text type="secondary" className="block text-center mb-6 line-clamp-2 min-h-[3rem]">
{teacher.description}
</Text>
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="text-center">
<div className="flex items-center justify-center gap-1 text-blue-600">
<ReadOutlined className="text-lg" />
<span className="font-bold">{teacher.courses}</span>
</div>
<Text type="secondary" className="text-sm"></Text>
</div>
<div className="text-center border-x">
<div className="flex items-center justify-center gap-1 text-blue-600">
<UserOutlined className="text-lg" />
<span className="font-bold">{(teacher.students / 1000).toFixed(1)}k</span>
</div>
<Text type="secondary" className="text-sm"></Text>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1 text-blue-600">
<StarFilled className="text-lg" />
<span className="font-bold">{teacher.rating}</span>
</div>
<Text type="secondary" className="text-sm"></Text>
</div>
</div>
</div>
</div>
</div>
);
};
return (
<section className="py-20 px-4 bg-gradient-to-b from-gray-50/50 to-transparent">
<div className="max-w-screen-2xl mx-auto">
<div className="relative z-10 text-center mb-16">
<Title level={2} className="font-bold text-4xl mb-4">
</Title>
<Text type="secondary" className="text-lg">
</Text>
</div>
<div className="relative group">
<Carousel ref={carouselRef} {...settings}>
{featuredTeachers.map((teacher, index) => (
<TeacherCard key={index} teacher={teacher} />
))}
</Carousel>
<button
onClick={() => carouselRef.current?.prev()}
className="absolute left-0 top-1/2 -translate-y-1/2 -ml-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors opacity-0 group-hover:opacity-100"
>
<LeftOutlined />
</button>
<button
onClick={() => carouselRef.current?.next()}
className="absolute right-0 top-1/2 -translate-y-1/2 -mr-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors opacity-0 group-hover:opacity-100"
>
<RightOutlined />
</button>
</div>
</div>
</section>
);
};
export default FeaturedTeachersSection;

View File

@ -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: <TeamOutlined />, value: '50,000+', label: '注册学员' },
{ icon: <BookOutlined />, value: '1,000+', label: '精品课程' },
{ icon: <StarOutlined />, value: '98%', label: '好评度' },
{ icon: <ClockCircleOutlined />, value: '100万+', label: '学习时长' }
];
const HeroSection = () => {
const carouselRef = useRef<CarouselRef>(null);
const handlePrev = useCallback(() => {
carouselRef.current?.prev();
}, []);
const handleNext = useCallback(() => {
carouselRef.current?.next();
}, []);
return (
<section className="relative ">
<div className="group">
<Carousel
ref={carouselRef}
autoplay
effect="fade"
className="h-[600px] mb-24"
dots={{
className: 'carousel-dots !bottom-32 !z-20',
}}
>
{carouselItems.map((item, index) => (
<div key={index} className="relative h-[600px]">
<div
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
style={{
backgroundImage: `url(${item.image})`,
backfaceVisibility: 'hidden'
}}
/>
<div
className={`absolute inset-0 bg-gradient-to-r ${item.color} to-transparent opacity-90 mix-blend-overlay transition-opacity duration-500`}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
{/* Content Container */}
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8">
<div className="absolute left-0 top-1/2 -translate-y-1/2 max-w-2xl">
<Title
className="text-white mb-8 text-5xl md:text-6xl xl:text-7xl !leading-tight font-bold tracking-tight"
style={{
transform: 'translateZ(0)'
}}
>
{item.title}
</Title>
<Text className="text-white/95 text-lg md:text-xl block mb-12 font-light leading-relaxed">
{item.desc}
</Text>
<Button
type="primary"
size="large"
className="h-14 px-12 text-lg font-semibold bg-gradient-to-r from-primary to-primary-600 border-0 shadow-lg hover:shadow-xl hover:from-primary-600 hover:to-primary-700 hover:scale-105 transform transition-all duration-300 ease-out"
>
{item.action}
</Button>
</div>
</div>
</div>
))}
</Carousel>
{/* Navigation Buttons */}
<button
onClick={handlePrev}
className="absolute left-4 md:left-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
aria-label="Previous slide"
>
<LeftOutlined className="text-white text-xl" />
</button>
<button
onClick={handleNext}
className="absolute right-4 md:right-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
aria-label="Next slide"
>
<RightOutlined className="text-white text-xl" />
</button>
</div>
{/* Stats Container */}
<div className="absolute -bottom-24 left-1/2 -translate-x-1/2 w-full max-w-6xl px-4">
<div className="rounded-2xl grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]">
{platformStats.map((stat, index) => (
<div
key={index}
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out"
>
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-50 text-primary-600 text-3xl transition-colors duration-300 group-hover:text-primary-700">
{stat.icon}
</div>
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
{stat.value}
</div>
<div className="text-gray-600 font-medium">{stat.label}</div>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroSection;

View File

@ -1,23 +1,123 @@
import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor"; import HeroSection from './components/HeroSection';
import FileUploader from "@web/src/components/common/uploader/FileUploader"; import CategorySection from './components/CategorySection';
import CoursesSection from './components/CoursesSection';
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
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',
},
];
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<TusUploadProps> = ({ onSuccess, onError }) => {
return ( return (
<div> <div className="min-h-screen">
<FileUploader></FileUploader> <HeroSection />
<div className="w-full" style={{ height: 800 }}> <CoursesSection
<GraphEditor></GraphEditor> title="推荐课程"
</div> description="最受欢迎的精品课程,助你快速成长"
{/* <div className=' h-screen'> courses={mockCourses}
<MindMap></MindMap> />
</div> */} <CoursesSection
{/* <MindMapEditor></MindMapEditor> */} title="热门课程"
description="最受欢迎的精品课程,助你快速成长"
courses={mockCourses}
/>
<CategorySection />
<FeaturedTeachersSection />
</div> </div>
); );
}; };

View File

@ -0,0 +1,68 @@
import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
import { Layout, Typography } from 'antd';
export function MainFooter() {
return (
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2">
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
</h3>
<p className="text-gray-400 text-xs italic">
</p>
</div>
{/* 联系方式 */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">628118</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
</div>
</div>
{/* 系统链接 */}
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
<CloudOutlined className="text-lg" />
</a>
<a
href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询"
>
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
{/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-gray-400 text-xs">
© {new Date().getFullYear()} . All rights reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@ -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 (
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
<div className="flex items-center space-x-8">
<div
onClick={() => 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"
>
</div>
<NavigationMenu />
</div>
<div className="flex items-center space-x-6">
<div className="group relative">
<Input
size="large"
prefix={<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />}
placeholder="搜索课程"
className="w-72 rounded-full"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
</div>
{isAuthenticated ? (
<Dropdown
overlay={<UserMenu />}
trigger={['click']}
placement="bottomRight"
>
<Avatar
size="large"
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold"
>
{(user?.showname || user?.username || '')[0]?.toUpperCase()}
</Avatar>
</Dropdown>
) : (
<Button
onClick={() => navigate('/login')}
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
icon={<UserOutlined />}
>
</Button>
)}
</div>
</div>
</Header>
);
}

View File

@ -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 (
<Layout className="min-h-screen">
<MainHeader />
<Content className="mt-16 bg-gray-50">
<Outlet />
</Content>
<MainFooter />
</Layout>
);
}

View File

@ -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 (
<Menu
mode="horizontal"
className="border-none font-medium"
selectedKeys={[selectedKey]}
onClick={({ key }) => {
const selectedItem = menuItems.find(item => item.key === key);
if (selectedItem) navigate(selectedItem.path);
}}
>
{menuItems.map(({ key, label }) => (
<Menu.Item key={key} className="text-gray-600 hover:text-blue-600">
{label}
</Menu.Item>
))}
</Menu>
);
};

View File

@ -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 (
<Menu className="w-48">
{isAuthenticated ? (
<>
<Menu.Item key="profile" className="px-4 py-2">
<div className="flex items-center space-x-3">
<Avatar className="bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
{(user?.showname || user?.username || '')[0]?.toUpperCase()}
</Avatar>
<div className="flex flex-col">
<span className="font-medium">{user?.showname || user?.username}</span>
<span className="text-xs text-gray-500">{user?.department?.name || user?.officerId}</span>
</div>
</div>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="settings" icon={<SettingOutlined />} className="px-4">
</Menu.Item>
<Menu.Item
key="logout"
icon={<LogoutOutlined />}
onClick={async () => await logout()}
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50"
>
退
</Menu.Item>
</>
) : (
<Menu.Item
key="login"
onClick={() => navigate("/login")}
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
>
/
</Menu.Item>
)}
</Menu>
);
};

View File

@ -0,0 +1,3 @@
export default function PathsPage() {
return <>paths</>
}

View File

@ -0,0 +1,7 @@
export default function MyCoursePage() {
return (
<div>
My Course Page
</div>
)
}

View File

@ -0,0 +1,3 @@
export default function ProfilesPage() {
return <>Profiles</>
}

View File

@ -1,40 +0,0 @@
import { useState, useEffect, useRef, ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar';
import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data';
import { Sidebar } from '@web/src/components/layout/main/side-bar';
import { Outlet } from 'react-router-dom';
export function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [notifications, setNotifications] = useState(3);
const [recentSearches] = useState([
'React Fundamentals',
'TypeScript Advanced',
'Tailwind CSS Projects',
]);
return (
<div className="min-h-screen bg-gray-50">
<TopNavBar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
notifications={notifications}
notificationItems={notificationItems}
recentSearches={recentSearches}
/>
<AnimatePresence mode="wait">
{sidebarOpen && <Sidebar navItems={navItems} />}
</AnimatePresence>
<main
className={`pt-16 min-h-screen transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0'
}`}
>
<Outlet></Outlet>
</main>
</div>
);
}

View File

@ -1,43 +0,0 @@
import { NavItem } from '@nice/client';
import {
HomeIcon,
BookOpenIcon,
UserGroupIcon,
Cog6ToothIcon,
BellIcon,
HeartIcon,
AcademicCapIcon,
UsersIcon,
PresentationChartBarIcon
} from '@heroicons/react/24/outline';
export const navItems: NavItem[] = [
{ icon: <HomeIcon className="w-6 h-6" />, label: '探索知识', path: '/' },
{ icon: <AcademicCapIcon className="w-6 h-6" />, label: '我的学习', path: '/courses/student' },
{ icon: <PresentationChartBarIcon className="w-6 h-6" />, label: '我的授课', path: '/courses/instructor' },
{ icon: <UsersIcon className="w-6 h-6" />, label: '学习社区', path: '/community' },
{ icon: <Cog6ToothIcon className="w-6 h-6" />, label: '应用设置', path: '/settings' },
];
export const notificationItems = [
{
icon: <BellIcon className="w-6 h-6 text-blue-500" />,
title: "New Course Available",
description: "Advanced TypeScript Programming is now available",
time: "2 hours ago",
isUnread: true,
},
{
icon: <HeartIcon className="w-6 h-6 text-red-500" />,
title: "Course Recommendation",
description: "Based on your interests: React Native Development",
time: "1 day ago",
isUnread: true,
},
{
icon: <AcademicCapIcon className="w-6 h-6 text-green-500" />,
title: "Certificate Ready",
description: "Your React Fundamentals certificate is ready to download",
time: "2 days ago",
isUnread: true,
},
];

View File

@ -1,40 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { NotificationsPanel } from './notifications-panel';
import { BellIcon } from '@heroicons/react/24/outline';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
interface NotificationsDropdownProps {
notifications: number;
notificationItems: Array<any>;
}
export function NotificationsDropdown({ notifications, notificationItems }: NotificationsDropdownProps) {
const [showNotifications, setShowNotifications] = useState(false);
const notificationRef = useRef<HTMLDivElement>(null);
useClickOutside(notificationRef, () => setShowNotifications(false));
return (
<div ref={notificationRef} className="relative">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setShowNotifications(!showNotifications)}
>
<BellIcon className='w-6 h-6' ></BellIcon>
{notifications > 0 && (
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center">
{notifications}
</span>
)}
</motion.button>
<AnimatePresence>
{showNotifications && (
<NotificationsPanel notificationItems={notificationItems} />
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,64 +0,0 @@
import { ClockIcon } from '@heroicons/react/24/outline';
import { motion } from 'framer-motion';
interface NotificationsPanelProps {
notificationItems: Array<{
icon: React.ReactNode;
title: string;
description: string;
time: string;
isUnread: boolean;
}>;
}
export function NotificationsPanel({ notificationItems }: NotificationsPanelProps) {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
<span className="text-sm text-blue-600 hover:text-blue-700 cursor-pointer">
Mark all as read
</span>
</div>
</div>
<div className="max-h-[400px] overflow-y-auto overflow-x-hidden">
{notificationItems.map((item, index) => (
<motion.div
key={index}
whileHover={{ x: 4 }}
className={`p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors ${item.isUnread ? 'bg-blue-50/50' : ''
}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
{item.icon}
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900">{item.title}</h4>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
<ClockIcon className='h-4 w-4'></ClockIcon>
<span>{item.time}</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
<div className="p-4 border-t border-gray-100 bg-gray-50">
<button className="w-full text-sm text-center text-blue-600 hover:text-blue-700">
View all notifications
</button>
</div>
</motion.div>
);
}

View File

@ -1,55 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SearchDropdown } from './search-dropdown';
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
interface SearchBarProps {
recentSearches: string[];
}
export function SearchBar({ recentSearches }: SearchBarProps) {
const [searchFocused, setSearchFocused] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef<HTMLDivElement>(null);
useClickOutside(searchRef, () => setSearchFocused(false))
return (
<div ref={searchRef} className="relative max-w-xl w-full px-4">
<div className={`
relative flex items-center w-full h-10 rounded-full
transition-all duration-300 ease-in-out
${searchFocused
? 'bg-white shadow-md ring-2 ring-blue-500'
: 'bg-gray-100 hover:bg-gray-200'
}
`}>
<MagnifyingGlassIcon className="h-5 w-5 ml-3 text-gray-500" />
<input
type="text"
placeholder="Search for courses, topics, or instructors..."
className="w-full h-full bg-transparent px-3 outline-none text-sm"
onFocus={() => setSearchFocused(true)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="p-1.5 mr-2 rounded-full hover:bg-gray-200"
onClick={() => setSearchQuery('')}
>
<XMarkIcon className="h-4 w-4 text-gray-500" />
</motion.button>
)}
</div>
<SearchDropdown
searchFocused={searchFocused}
searchQuery={searchQuery}
recentSearches={recentSearches}
setSearchQuery={setSearchQuery}
/>
</div>
);
}

View File

@ -1,58 +0,0 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { motion } from 'framer-motion';
interface SearchDropdownProps {
searchFocused: boolean;
searchQuery: string;
recentSearches: string[];
setSearchQuery: (query: string) => void;
}
export function SearchDropdown({
searchFocused,
searchQuery,
recentSearches,
setSearchQuery
}: SearchDropdownProps) {
if (!searchFocused) return null;
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute top-12 left-4 right-4 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-3">
<h3 className="text-xs font-medium text-gray-500 mb-2">Recent Searches</h3>
<div className="space-y-1">
{recentSearches.map((search, index) => (
<motion.button
key={index}
whileHover={{ x: 4 }}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-left"
onClick={() => setSearchQuery(search)}
>
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-700">{search}</span>
</motion.button>
))}
</div>
</div>
{searchQuery && (
<div className="border-t border-gray-100 p-3">
<motion.button
whileHover={{ x: 4 }}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100"
>
<MagnifyingGlassIcon className="h-4 w-4 text-blue-500" />
<span className="text-sm text-blue-500">
Search for "{searchQuery}"
</span>
</motion.button>
</div>
)}
</motion.div>
);
}

View File

@ -1,44 +0,0 @@
import { motion } from 'framer-motion';
import { useNavigate, useLocation } from 'react-router-dom';
import { NavItem } from '@nice/client';
interface SidebarProps {
navItems: Array<NavItem>;
}
export function Sidebar({ navItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
return (
<motion.aside
initial={{ x: -300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -300, opacity: 0 }}
transition={{ type: "spring", bounce: 0.1, duration: 0.5 }}
className="fixed left-0 top-16 bottom-0 w-64 bg-white border-r border-gray-200 z-40"
>
<div className="p-4 space-y-2">
{navItems.map((item, index) => {
const isActive = location.pathname === item.path;
return (
<motion.button
key={index}
whileHover={{ x: 5 }}
onClick={() => {
navigate(item.path)
}}
className={`flex items-center gap-3 w-full p-3 rounded-lg transition-colors
${isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-blue-50 hover:text-blue-600'
}`}
>
{item.icon}
<span className="font-medium">{item.label}</span>
</motion.button>
);
})}
</div>
</motion.aside>
);
}

View File

@ -1,47 +0,0 @@
import { NotificationsDropdown } from './notifications-dropdown';
import { SearchBar } from './search-bar';
import { UserMenuDropdown } from './usermenu-dropdown';
import { Bars3Icon } from '@heroicons/react/24/outline';
interface TopNavBarProps {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
notifications: number;
notificationItems: Array<any>;
recentSearches: string[];
}
export function TopNavBar({
sidebarOpen,
setSidebarOpen,
notifications,
notificationItems,
recentSearches
}: TopNavBarProps) {
return (
<nav className="fixed top-0 left-0 right-0 h-16 bg-white shadow-sm z-50">
<div className="flex items-center justify-between h-full px-4">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors">
<Bars3Icon className='w-5 h-5' />
</button>
<h1 className="text-xl font-semibold text-slate-800 tracking-wide">
fhmooc
</h1>
</div>
<SearchBar recentSearches={recentSearches} />
<div className="flex items-center gap-4">
<NotificationsDropdown
notifications={notifications}
notificationItems={notificationItems}
/>
<UserMenuDropdown />
</div>
</div>
</nav>
);
}

View File

@ -1,68 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeftStartOnRectangleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { useAuth } from '@web/src/providers/auth-provider';
import { Avatar } from '../../presentation/user/Avatar';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
export function UserMenuDropdown() {
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { user, logout } = useAuth()
useClickOutside(menuRef, () => setShowMenu(false));
const menuItems = [
{ icon: <UserCircleIcon className='w-5 h-5'></UserCircleIcon>, label: '个人信息', action: () => { } },
{ icon: <Cog6ToothIcon className='w-5 h-5'></Cog6ToothIcon>, label: '设置', action: () => { } },
{ icon: <QuestionMarkCircleIcon className='w-5 h-5'></QuestionMarkCircleIcon>, label: '帮助', action: () => { } },
{ icon: <ArrowLeftStartOnRectangleIcon className='w-5 h-5' />, label: '注销', action: () => { logout() } },
];
return (
<div ref={menuRef} className="relative">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowMenu(!showMenu)}
className="w-10 h-10" // 移除了边框相关的类
>
<Avatar
src={user?.avatar}
name={user?.showname || user?.username}
size={40}
className="ring-2 ring-gray-200 hover:ring-blue-500 transition-colors" // 使用 ring 替代 border
/>
</motion.button>
<AnimatePresence>
{showMenu && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-3 border-b border-gray-100">
<h4 className="text-sm font-medium text-gray-900">{user?.showname}</h4>
<p className="text-xs text-gray-500">{user?.username}</p>
</div>
<div className="p-2">
{menuItems.map((item, index) => (
<motion.button
key={index}
whileHover={{ x: 4 }}
onClick={item.action}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-gray-700 text-sm"
>
{item.icon}
<span>{item.label}</span>
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -16,7 +16,7 @@
} }
.ag-custom-dragging-class { .ag-custom-dragging-class {
@apply border-b-2 border-primaryHover; @apply border-b-2 border-blue-200;
} }
.ant-popover-inner { .ant-popover-inner {
@ -71,7 +71,7 @@
border-radius: 10px; border-radius: 10px;
border: 2px solid #f0f0f0; border: 2px solid #f0f0f0;
@apply hover:bg-primaryHover transition-all bg-gray-400 ease-in-out rounded-full; @apply hover:bg-blue-200 transition-all bg-gray-400 ease-in-out rounded-full;
} }
/* 鼠标悬停在滚动块上 */ /* 鼠标悬停在滚动块上 */

View File

@ -13,7 +13,6 @@ import RoleAdminPage from "../app/admin/role/page";
import WithAuth from "../components/utils/with-auth"; import WithAuth from "../components/utils/with-auth";
import LoginPage from "../app/login"; import LoginPage from "../app/login";
import BaseSettingPage from "../app/admin/base-setting/page"; import BaseSettingPage from "../app/admin/base-setting/page";
import { MainLayout } from "../components/layout/main/MainLayout";
import StudentCoursesPage from "../app/main/courses/student/page"; import StudentCoursesPage from "../app/main/courses/student/page";
import InstructorCoursesPage from "../app/main/courses/instructor/page"; import InstructorCoursesPage from "../app/main/courses/instructor/page";
import HomePage from "../app/main/home/page"; import HomePage from "../app/main/home/page";
@ -23,6 +22,8 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm"; import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm"; import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout"; import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
import { MainLayout } from "../app/main/layout/MainLayout";
import CoursesPage from "../app/main/courses/page";
interface CustomIndexRouteObject extends IndexRouteObject { interface CustomIndexRouteObject extends IndexRouteObject {
name?: string; name?: string;
@ -61,6 +62,16 @@ export const routes: CustomRouteObject[] = [
index: true, index: true,
element: <HomePage />, element: <HomePage />,
}, },
{
path: "courses",
element: <CoursesPage></CoursesPage>
},
{
path: "my-courses"
},
{
path: "profiles"
},
{ {
path: "courses", path: "courses",
children: [ children: [

View File

@ -1,53 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: "var(--color-primary)",
primaryActive: "var(--color-primary-active)",
primaryHover: "var(--color-primary-hover)",
error: "var(--color-error)",
warning: "var(--color-warning)",
info: "var(--color-info)",
success: "var(--color-success)",
link: "var(--color-link)",
highlight: "var(--color-highlight)",
},
backgroundColor: {
layout: "var(--color-bg-layout)",
mask: "var(--color-bg-mask)",
container: "var(--color-bg-container)",
textHover: "var(--color-bg-text-hover)",
primary: "var(--color-bg-primary)",
error: "var(--color-error-bg)",
warning: "var(--color-warning-bg)",
info: "var(--color-info-bg)",
success: "var(--color-success-bg)",
},
textColor: {
default: "var(--color-text)",
quaternary: "var(--color-text-quaternary)",
placeholder: "var(--color-text-placeholder)",
description: "var(--color-text-description)",
secondary: "var(--color-text-secondary)",
tertiary: "var(--color-text-tertiary)",
primary: "var(--color-text-primary)",
heading: "var(--color-text-heading)",
label: "var(--color-text-label)",
lightSolid: "var(--color-text-lightsolid)"
},
borderColor: {
default: "var(--color-border)",
},
boxShadow: {
elegant: '0 3px 6px -2px rgba(46, 117, 182, 0.10), 0 2px 4px -1px rgba(46, 117, 182, 0.05)'
}
},
},
plugins: [],
}

2
apps/web/tailwind.config.ts Executable file
View File

@ -0,0 +1,2 @@
import { NiceTailwindConfig } from "@nice/config"
export default NiceTailwindConfig

View File

@ -79,32 +79,28 @@ interface MappingConfig {
hasChildrenField?: string; // Optional, in case the structure has nested items hasChildrenField?: string; // Optional, in case the structure has nested items
childrenField?: string; childrenField?: string;
} }
export function stringToColor(str: string): string {
/**
*
* @param str -
* @param saturation - 0-10075
* @param lightness - 0-10065
* @returns HSL格式的颜色字符串
*/
export function stringToColor(str: string, saturation: number = 75, lightness: number = 65): string {
let hash = 0; let hash = 0;
// 使用字符串生成哈希值
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
let color = '#'; // 将哈希值转换为0-360的色相值
for (let i = 0; i < 3; i++) { const hue = Math.abs(hash % 360);
let value = (hash >> (i * 8)) & 0xFF;
// Adjusting the value to avoid dark, gray or too light colors // 使用HSL颜色空间生成颜色
if (value < 100) { // 固定饱和度和亮度以确保颜色的优雅性
value += 100; // Avoids too dark colors return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
if (value > 200) {
value -= 55; // Avoids too light colors
}
// Ensure the color is not gray by adjusting R, G, B individually
value = Math.floor((value + 255) / 2);
color += ('00' + value.toString(16)).slice(-2);
}
return color;
} }

View File

@ -0,0 +1,35 @@
{
"name": "@nice/config",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"private": true,
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nice/utils": "workspace:^",
"color": "^4.2.3",
"nanoid": "^5.0.9"
},
"peerDependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/color": "^4.2.0",
"@types/dagre": "^0.7.52",
"@types/node": "^20.3.1",
"@types/react": "18.2.38",
"@types/react-dom": "18.2.15",
"concurrently": "^8.0.0",
"rimraf": "^6.0.1",
"ts-node": "^10.9.1",
"tsup": "^8.3.5",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,24 @@
import Color from "color";
import { ColorScale } from "./types";
export function generateColorScale(baseColor: string): ColorScale {
const color = Color(baseColor);
const steps = [-0.4, -0.32, -0.24, -0.16, -0.08, 0, 0.08, 0.16, 0.24, 0.32, 0.4];
const keys = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const scale = Object.fromEntries(
keys.map((key, index) => [
key,
color.lighten(-steps[index]).rgb().string()
])
) as ColorScale;
return {
...scale,
DEFAULT: scale[500]
};
}
export function withAlpha(color: string, alpha: number): string {
return Color(color).alpha(alpha).toString();
}

View File

@ -0,0 +1,19 @@
import { generateTheme} from "./generator";
import { ThemeSeed } from "./types";
// 添加默认的主题配置
export const USAFSeed: ThemeSeed = {
colors: {
primary: '#003087', // 深蓝色
secondary: '#71767C', // 灰色
neutral: '#4A4A4A', // 中性灰色
success: '#287233', // 绿色
warning: '#FF9F1C', // 警告橙色
error: '#AF1E2D', // 红色
info: '#00538E', // 信息蓝色
},
isDark: false
};
export const defaultTheme = generateTheme(USAFSeed)

View File

@ -0,0 +1,50 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import type { ThemeSeed, ThemeToken } from './types';
import { USAFSeed } from './constants';
import { createTailwindTheme, injectThemeVariables } from './styles';
import { generateTheme } from './generator';
interface ThemeContextValue {
token: ThemeToken
setTheme: (options: ThemeSeed) => void;
toggleDarkMode: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({
children,
seed = USAFSeed,
}: {
children: React.ReactNode;
seed?: ThemeSeed;
}) {
const [themeSeed, setThemeSeed] = useState<ThemeSeed>(seed);
const token = useMemo<ThemeToken>(() => {
const result = generateTheme(themeSeed)
console.log(createTailwindTheme(result))
injectThemeVariables(result)
return result.token;
}, [themeSeed]);
const contextValue = useMemo<ThemeContextValue>(
() => ({
token,
setTheme: setThemeSeed,
toggleDarkMode: () =>
setThemeSeed((prev) => ({ ...prev, isDark: !prev.isDark })),
}),
[token]
);
return (
<ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@ -0,0 +1,99 @@
import { Theme, ThemeColors, ThemeSemantics, ThemeSeed, ThemeToken } from './types';
import { withAlpha, generateColorScale } from './colors';
import { darkMode } from './utils';
export function generateThemeColors(seed: ThemeSeed['colors']): ThemeColors {
const defaultColors = {
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6'
};
const colors = { ...defaultColors, ...seed };
return Object.fromEntries(
Object.entries(colors).map(([key, value]) => [
key,
generateColorScale(value)
])
) as ThemeColors;
}
export function generateSemantics(colors: ThemeColors, isDark: boolean): ThemeSemantics {
const neutral = colors.neutral;
const primary = colors.primary;
const statusColors = {
success: colors.success,
warning: colors.warning,
error: colors.error,
info: colors.info
};
return {
colors,
textColor: {
DEFAULT: darkMode(isDark, neutral[100], neutral[900]),
primary: darkMode(isDark, neutral[100], neutral[900]),
secondary: darkMode(isDark, neutral[300], neutral[700]),
tertiary: darkMode(isDark, neutral[400], neutral[600]),
disabled: darkMode(isDark, neutral[500], neutral[400]),
inverse: darkMode(isDark, neutral[900], neutral[100]),
success: statusColors.success[darkMode(isDark, 400, 600)],
warning: statusColors.warning[darkMode(isDark, 400, 600)],
error: statusColors.error[darkMode(isDark, 400, 600)],
info: statusColors.info[darkMode(isDark, 400, 600)],
link: primary[darkMode(isDark, 400, 600)],
linkHover: primary[darkMode(isDark, 300, 700)],
placeholder: darkMode(isDark, neutral[500], neutral[400]),
highlight: primary[darkMode(isDark, 300, 700)]
},
backgroundColor: {
DEFAULT: darkMode(isDark, neutral[900], neutral[50]),
paper: darkMode(isDark, neutral[800], neutral[100]),
subtle: darkMode(isDark, neutral[700], neutral[200]),
inverse: darkMode(isDark, neutral[50], neutral[900]),
success: withAlpha(statusColors.success[darkMode(isDark, 900, 50)], 0.12),
warning: withAlpha(statusColors.warning[darkMode(isDark, 900, 50)], 0.12),
error: withAlpha(statusColors.error[darkMode(isDark, 900, 50)], 0.12),
info: withAlpha(statusColors.info[darkMode(isDark, 900, 50)], 0.12),
primaryHover: withAlpha(primary[darkMode(isDark, 800, 100)], 0.08),
primaryActive: withAlpha(primary[darkMode(isDark, 700, 200)], 0.12),
primaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
secondaryHover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
secondaryActive: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
secondaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
selected: withAlpha(primary[darkMode(isDark, 900, 50)], 0.16),
hover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
focused: withAlpha(primary[darkMode(isDark, 900, 50)], 0.12),
disabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
overlay: withAlpha(neutral[900], 0.5)
},
border: {
DEFAULT: darkMode(isDark, neutral[600], neutral[300]),
subtle: darkMode(isDark, neutral[700], neutral[200]),
strong: darkMode(isDark, neutral[500], neutral[400]),
focus: primary[500],
inverse: darkMode(isDark, neutral[300], neutral[600]),
success: statusColors.success[darkMode(isDark, 500, 500)],
warning: statusColors.warning[darkMode(isDark, 500, 500)],
error: statusColors.error[darkMode(isDark, 500, 500)],
info: statusColors.info[darkMode(isDark, 500, 500)],
disabled: darkMode(isDark, neutral[600], neutral[300])
}
};
}
export function generateTheme(seed: ThemeSeed): Theme {
const isDark = seed.isDark ?? false;
const colors = generateThemeColors(seed.colors);
const semantics = generateSemantics(colors, isDark);
return {
token: {
...colors,
...semantics
},
isDark
};
}

View File

@ -0,0 +1,6 @@
export * from "./context"
export * from "./types"
export * from "./utils"
export * from "./styles"
export * from "./constants"
export * from "./tailwind"

View File

@ -0,0 +1,89 @@
import { Theme, ThemeToken } from './types'
import { flattenObject, toKebabCase } from './utils'
import type { Config } from 'tailwindcss'
const PREFIX = '--nice'
/**
* CSS变量键名
*/
function createCssVariableName(path: string[]): string {
return `${PREFIX}-${path.map(p => toKebabCase(p.toLowerCase())).join('-')}`
}
/**
* CSS变量对象
*/
export function themeToCssVariables(theme: Theme): Record<string, string> {
const flattenedToken = flattenObject(theme.token)
console.log(flattenedToken)
const cssVars: Record<string, string> = {}
for (const [path, value] of Object.entries(flattenedToken)) {
const cssVarName = createCssVariableName(path.split('.'))
cssVars[cssVarName] = value
}
return cssVars
}
export function injectThemeVariables(theme: Theme) {
const cssVars = themeToCssVariables(theme)
const root = document.documentElement
Object.entries(cssVars).forEach(([key, value]) => {
console.log(key, value)
root.style.setProperty(key, value)
})
}
/**
* Tailwind主题配置
*/
function transformToTailwindConfig(flattenedToken: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
// 处理对象路径,将其转换为嵌套结构
for (const [path, _] of Object.entries(flattenedToken)) {
const parts = path.split('.')
let current = result
// 遍历路径的每一部分,构建嵌套结构
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isLast = i === parts.length - 1
// 如果是最后一个部分设置CSS变量引用
if (isLast) {
current[part] = `var(${createCssVariableName(parts)})`
} else {
// 如果不是最后一个部分,确保存在嵌套对象
current[part] = current[part] || {}
current = current[part]
}
}
}
return result
}
/**
* CSS变量的Tailwind主题配置
*/
export function createTailwindTheme(theme: Theme): Partial<Config["theme"]> {
const flattenedToken = flattenObject(theme.token)
const themeConfig = transformToTailwindConfig(flattenedToken)
// 将主题配置映射到Tailwind的结构
const result = {
extend: {
colors: themeConfig.colors,
textColor: themeConfig.textColor,
backgroundColor: themeConfig.backgroundColor,
borderColor: themeConfig.border,
}
}
console.log(result)
return result
}

View File

@ -0,0 +1,132 @@
import type { Config } from "tailwindcss";
export const NiceTailwindConfig: Config = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
// 主色调 - 优雅蓝(清新雅致)
primary: {
50: "#f0f7ff",
100: "#e0f0ff",
150: "#cce7ff",
200: "#b3ddff",
250: "#99d3ff",
300: "#66bfff",
350: "#47b3ff",
400: "#1ca6ff",
500: "#0088E8", // 主色调
600: "#0070cc",
700: "#0058a3",
800: "#004080",
900: "#002847",
DEFAULT: "#0088E8",
},
// 辅助色 - 温和灰(内容展示)
secondary: {
50: "#fafafa",
100: "#f5f5f5",
200: "#eeeeee",
300: "#e0e0e0",
350: "#d4d4d4",
400: "#bdbdbd",
500: "#9e9e9e",
600: "#757575",
700: "#616161",
800: "#424242",
900: "#212121",
DEFAULT: "#757575",
},
// 强调色 - 活力橙(重要操作、交互)
accent: {
50: "#fff4e5",
100: "#ffe8cc",
200: "#ffd699",
300: "#ffc466",
400: "#ffb333",
500: "#ffa200",
600: "#cc8200",
700: "#996100",
800: "#664100",
900: "#332000",
DEFAULT: "#ffa200",
},
// 功能色(协调搭配)
success: "#4caf50",
warning: "#ff9800",
danger: "#f44336",
info: "#03a9f4",
},
fontFamily: {
sans: [
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
"system-ui",
"sans-serif",
],
heading: [
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
"sans-serif",
],
mono: ["Source Code Pro", "monospace"],
},
spacing: {
"72": "18rem",
"84": "21rem",
"96": "24rem",
},
borderRadius: {
xl: "1rem",
"2xl": "2rem",
"3xl": "3rem",
},
boxShadow: {
outline: "0 0 0 3px rgba(0, 136, 232, 0.4)",
solid: "2px 2px 0 0 rgba(0, 0, 0, 0.1)",
glow: "0 0 8px rgba(0, 136, 232, 0.4)",
inset: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)",
"elevation-1": "0 1px 2px rgba(0, 0, 0, 0.05)",
"elevation-2": "0 2px 4px rgba(0, 0, 0, 0.1)",
"elevation-3": "0 4px 8px rgba(0, 0, 0, 0.1)",
"elevation-4": "0 8px 16px rgba(0, 0, 0, 0.1)",
"elevation-5": "0 16px 32px rgba(0, 0, 0, 0.1)",
panel: "0 2px 4px rgba(0, 0, 0, 0.05)",
button: "0 2px 4px rgba(0, 0, 0, 0.1)",
card: "0 2px 8px rgba(0, 0, 0, 0.08)",
modal: "0 4px 16px rgba(0, 0, 0, 0.1)",
"video-thumbnail": "0 2px 4px rgba(0, 0, 0, 0.1)",
"hover-card": "0 4px 8px rgba(0, 0, 0, 0.08)",
"player-controls": "0 -2px 8px rgba(0, 0, 0, 0.1)",
"floating-player": "0 4px 12px rgba(0, 0, 0, 0.1)",
},
animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
"spin-slow": "spin 3s linear infinite",
"fade-in": "fadeIn 0.3s ease-in-out",
"slide-up": "slideUp 0.4s ease-out",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(10px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
transitionDuration: {
"2000": "2000ms",
"3000": "3000ms",
},
screens: {
"3xl": "1920px",
"4xl": "2560px",
},
},
},
plugins: [],
};

View File

@ -0,0 +1,112 @@
/**
* (50)(950)
*
*/
export type ColorScale = {
50: string; // 最浅色调
100: string; // 非常浅色调
200: string; // 浅色调
300: string; // 中浅色调
400: string; // 中等偏浅色调
500: string; // 基准色调
600: string; // 中等偏深色调
700: string; // 深色调
800: string; // 很深色调
900: string; // 非常深色调
950: string; // 最深色调
DEFAULT: string; // Tailwind 默认值,通常对应 500
}
/**
*
* 使
*/
export type ThemeColors = {
primary: ColorScale; // 主要品牌色
secondary: ColorScale; // 次要品牌色
neutral: ColorScale; // 中性色,通常用于文本和背景
success: ColorScale; // 成功状态颜色
warning: ColorScale; // 警告状态颜色
error: ColorScale; // 错误状态颜色
info: ColorScale; // 信息状态颜色
}
/**
*
* UI元素的颜色应用场景
*/
export type ThemeSemantics = {
colors: ThemeColors,
/** 文本颜色相关配置 */
textColor: {
DEFAULT: string; // 默认文本颜色
primary: string; // 主要文本
secondary: string; // 次要文本
tertiary: string; // 第三级文本
disabled: string; // 禁用状态
inverse: string; // 反色文本
success: string; // 成功状态
warning: string; // 警告状态
error: string; // 错误状态
info: string; // 信息提示
link: string; // 链接文本
linkHover: string; // 链接悬浮
placeholder: string; // 占位符文本
highlight: string; // 高亮文本
};
/** 背景颜色相关配置 */
backgroundColor: {
DEFAULT: string; // 默认背景色
paper: string; // 卡片/纸张背景
subtle: string; // 轻微背景
inverse: string; // 反色背景
success: string; // 成功状态背景
warning: string; // 警告状态背景
error: string; // 错误状态背景
info: string; // 信息提示背景
primaryHover: string; // 主要按钮悬浮
primaryActive: string; // 主要按钮激活
primaryDisabled: string; // 主要按钮禁用
secondaryHover: string; // 次要按钮悬浮
secondaryActive: string; // 次要按钮激活
secondaryDisabled: string; // 次要按钮禁用
selected: string; // 选中状态
hover: string; // 通用悬浮态
focused: string; // 聚焦状态
disabled: string; // 禁用状态
overlay: string; // 遮罩层
};
/** 边框颜色配置 */
border: {
DEFAULT: string; // 默认边框
subtle: string; // 轻微边框
strong: string; // 强调边框
focus: string; // 聚焦边框
inverse: string; // 反色边框
success: string; // 成功状态边框
warning: string; // 警告状态边框
error: string; // 错误状态边框
info: string; // 信息提示边框
disabled: string; // 禁用状态边框
};
}
export type ThemeToken = ThemeSemantics
export interface Theme {
token: ThemeToken;
isDark: boolean;
}
export interface ThemeSeed {
colors: {
primary: string;
secondary: string;
neutral: string;
success?: string;
warning?: string;
error?: string;
info?: string;
}
isDark?: boolean;
}

View File

@ -0,0 +1,26 @@
// Helper function to generate conditional values based on dark mode
export function darkMode<T>(isDark: boolean, darkValue: T, lightValue: T): T {
return isDark ? darkValue : lightValue;
}
/**
* kebab-case
*/
export function toKebabCase(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
/**
* 使
*/
export function flattenObject(obj: Record<string, any>, prefix = ''): Record<string, string> {
return Object.keys(obj).reduce((acc: Record<string, string>, k: string) => {
const pre = prefix.length ? prefix + '.' : ''
if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
Object.assign(acc, flattenObject(obj[k], pre + k))
} else {
acc[pre + k] = obj[k].toString()
}
return acc
}, {})
}

View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"allowJs": true,
"lib": [
"DOM",
"es2022",
"DOM.Iterable"
],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"removeComments": true,
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedIndexedAccess": false,
"noImplicitOverride": false,
"noPropertyAccessFromIndexSignature": false,
"outDir": "dist",
"incremental": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__"
]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
minify: true,
external: ['react', 'react-dom'],
bundle: true,
target: "esnext"
})

View File

@ -290,6 +290,9 @@ importers:
'@nice/common': '@nice/common':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/common version: link:../../packages/common
'@nice/config':
specifier: workspace:^
version: link:../../packages/config
'@nice/iconer': '@nice/iconer':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/iconer version: link:../../packages/iconer
@ -543,6 +546,55 @@ importers:
specifier: ^5.5.4 specifier: ^5.5.4
version: 5.7.2 version: 5.7.2
packages/config:
dependencies:
'@nice/utils':
specifier: workspace:^
version: link:../utils
color:
specifier: ^4.2.3
version: 4.2.3
nanoid:
specifier: ^5.0.9
version: 5.0.9
react:
specifier: 18.2.0
version: 18.2.0
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
devDependencies:
'@types/color':
specifier: ^4.2.0
version: 4.2.0
'@types/dagre':
specifier: ^0.7.52
version: 0.7.52
'@types/node':
specifier: ^20.3.1
version: 20.17.12
'@types/react':
specifier: 18.2.38
version: 18.2.38
'@types/react-dom':
specifier: 18.2.15
version: 18.2.15
concurrently:
specifier: ^8.0.0
version: 8.2.2
rimraf:
specifier: ^6.0.1
version: 6.0.1
ts-node:
specifier: ^10.9.1
version: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.17.12)(typescript@5.7.2)
tsup:
specifier: ^8.3.5
version: 8.3.5(@microsoft/api-extractor@7.49.2(@types/node@20.17.12))(@swc/core@1.10.6(@swc/helpers@0.5.15))(jiti@1.21.7)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0)
typescript:
specifier: ^5.5.4
version: 5.7.2
packages/iconer: packages/iconer:
devDependencies: devDependencies:
'@types/react': '@types/react':
@ -3010,6 +3062,15 @@ packages:
'@types/body-parser@1.19.5': '@types/body-parser@1.19.5':
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
'@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
'@types/color-name@1.1.5':
resolution: {integrity: sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg==}
'@types/color@4.2.0':
resolution: {integrity: sha512-6+xrIRImMtGAL2X3qYkd02Mgs+gFGs+WsK0b7VVMaO4mYRISwyTjcqNrO0mNSmYEoq++rSLDB2F5HDNmqfOe+A==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -10539,6 +10600,16 @@ snapshots:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
'@types/node': 20.17.12 '@types/node': 20.17.12
'@types/color-convert@2.0.4':
dependencies:
'@types/color-name': 1.1.5
'@types/color-name@1.1.5': {}
'@types/color@4.2.0':
dependencies:
'@types/color-convert': 2.0.4
'@types/connect@3.4.38': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 20.17.12 '@types/node': 20.17.12