02061632
This commit is contained in:
parent
43ca9881d8
commit
4fb0fa49e7
|
@ -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:^",
|
||||||
|
|
|
@ -24,7 +24,7 @@ function App() {
|
||||||
theme={{
|
theme={{
|
||||||
algorithm: theme.defaultAlgorithm,
|
algorithm: theme.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: "#2e75b6",
|
colorPrimary: "#0088E8",
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
}}>
|
}}>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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)}小时`
|
||||||
|
}));
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,25 +1,125 @@
|
||||||
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';
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
const HomePage = () => {
|
||||||
import * as tus from "tus-js-client";
|
const mockCourses = [
|
||||||
interface TusUploadProps {
|
{
|
||||||
onSuccess?: (response: any) => void;
|
id: 1,
|
||||||
onError?: (error: Error) => void;
|
title: 'Python 零基础入门',
|
||||||
}
|
instructor: '张教授',
|
||||||
const HomePage: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
|
students: 12000,
|
||||||
return (
|
rating: 4.8,
|
||||||
<div>
|
level: '入门',
|
||||||
<FileUploader></FileUploader>
|
duration: '36小时',
|
||||||
<div className="w-full" style={{ height: 800 }}>
|
category: '编程语言',
|
||||||
<GraphEditor></GraphEditor>
|
progress: 0,
|
||||||
</div>
|
thumbnail: '/images/course1.jpg',
|
||||||
{/* <div className=' h-screen'>
|
},
|
||||||
<MindMap></MindMap>
|
{
|
||||||
</div> */}
|
id: 2,
|
||||||
{/* <MindMapEditor></MindMapEditor> */}
|
title: '数据结构与算法',
|
||||||
</div>
|
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 (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<HeroSection />
|
||||||
|
<CoursesSection
|
||||||
|
title="推荐课程"
|
||||||
|
description="最受欢迎的精品课程,助你快速成长"
|
||||||
|
courses={mockCourses}
|
||||||
|
/>
|
||||||
|
<CoursesSection
|
||||||
|
title="热门课程"
|
||||||
|
description="最受欢迎的精品课程,助你快速成长"
|
||||||
|
courses={mockCourses}
|
||||||
|
/>
|
||||||
|
<CategorySection />
|
||||||
|
<FeaturedTeachersSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomePage;
|
export default HomePage;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function PathsPage() {
|
||||||
|
return <>paths</>
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function MyCoursePage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
My Course Page
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function ProfilesPage() {
|
||||||
|
return <>Profiles</>
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
];
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 鼠标悬停在滚动块上 */
|
/* 鼠标悬停在滚动块上 */
|
||||||
|
@ -123,4 +123,4 @@
|
||||||
.custom-table .ant-table-tbody>tr:last-child>td {
|
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
|
@ -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: [
|
||||||
|
|
|
@ -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: [],
|
|
||||||
}
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { NiceTailwindConfig } from "@nice/config"
|
||||||
|
export default NiceTailwindConfig
|
|
@ -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 {
|
|
||||||
let hash = 0;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串转换为优雅的颜色值
|
||||||
|
* @param str - 输入字符串
|
||||||
|
* @param saturation - 饱和度(0-100),默认为75
|
||||||
|
* @param lightness - 亮度(0-100),默认为65
|
||||||
|
* @returns 返回HSL格式的颜色字符串
|
||||||
|
*/
|
||||||
|
export function stringToColor(str: string, saturation: number = 75, lightness: number = 65): string {
|
||||||
|
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;
|
|
||||||
|
// 使用HSL颜色空间生成颜色
|
||||||
// Adjusting the value to avoid dark, gray or too light colors
|
// 固定饱和度和亮度以确保颜色的优雅性
|
||||||
if (value < 100) {
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
value += 100; // Avoids too dark colors
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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)
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./context"
|
||||||
|
export * from "./types"
|
||||||
|
export * from "./utils"
|
||||||
|
export * from "./styles"
|
||||||
|
export * from "./constants"
|
||||||
|
export * from "./tailwind"
|
|
@ -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
|
||||||
|
}
|
|
@ -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: [],
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
}, {})
|
||||||
|
}
|
|
@ -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__"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
})
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue