Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
3acb56da6e
|
@ -67,5 +67,6 @@ yarn-error.log*
|
|||
|
||||
# Ignore .idea files in the Expo monorepo
|
||||
**/.idea/
|
||||
|
||||
uploads
|
||||
packages/mind-elixir-core
|
||||
config/nginx/conf.d/web.conf
|
|
@ -56,7 +56,7 @@ export default function BaseSettingPage() {
|
|||
meta: {
|
||||
...baseSetting,
|
||||
appConfig: {
|
||||
...baseSetting.appConfig,
|
||||
...(baseSetting?.appConfig || {}),
|
||||
...appConfig,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -16,6 +16,7 @@ export default function CourseCard({ course }: CourseCardProps) {
|
|||
const navigate = useNavigate();
|
||||
const handleClick = (course: CourseDto) => {
|
||||
navigate(`/course/${course.id}/detail`);
|
||||
window.scrollTo({top: 0,behavior: "smooth",})
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -33,6 +34,7 @@ export default function CourseCard({ course }: CourseCardProps) {
|
|||
backgroundImage: `url(${course?.meta?.thumbnail})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
|
||||
<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-300" />
|
||||
</div>
|
||||
|
@ -46,10 +48,10 @@ export default function CourseCard({ course }: CourseCardProps) {
|
|||
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
||||
color={
|
||||
term?.taxonomy?.slug ===
|
||||
TaxonomySlug.CATEGORY
|
||||
TaxonomySlug.CATEGORY
|
||||
? "blue"
|
||||
: term?.taxonomy?.slug ===
|
||||
TaxonomySlug.LEVEL
|
||||
TaxonomySlug.LEVEL
|
||||
? "green"
|
||||
: "orange"
|
||||
}
|
||||
|
@ -59,6 +61,7 @@ export default function CourseCard({ course }: CourseCardProps) {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
||||
|
@ -69,13 +72,17 @@ export default function CourseCard({ course }: CourseCardProps) {
|
|||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||
<div className="ml-2 flex items-center flex-grow">
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
{
|
||||
course?.depts.length > 1 ?`${course.depts[0].name}等`:course.depts[0].name
|
||||
}
|
||||
{course?.depts?.length > 1
|
||||
? `${course.depts[0].name}等`
|
||||
: course?.depts?.[0]?.name}
|
||||
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
|
||||
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
|
||||
</Text>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{course?.meta?.views ? `观看次数 ${course?.meta?.views}` : null}
|
||||
{course?.meta?.views
|
||||
? `观看次数 ${course?.meta?.views}`
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
|
|
|
@ -7,6 +7,7 @@ import { api } from "@nice/client";
|
|||
import { useSearchParams } from "react-router-dom";
|
||||
import TermSelect from "@web/src/components/models/term/term-select";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
||||
|
||||
export default function FilterSection() {
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
||||
|
@ -28,7 +29,19 @@ export default function FilterSection() {
|
|||
<h3 className="text-lg font-medium mb-4">
|
||||
{tax?.name}
|
||||
</h3>
|
||||
<TermSelect
|
||||
<TermParentSelector
|
||||
value={items}
|
||||
slug = {tax?.slug}
|
||||
className="w-70 max-h-[500px] overscroll-contain overflow-x-hidden"
|
||||
onChange={(selected) =>
|
||||
handleTermChange(
|
||||
tax?.slug,
|
||||
selected as string[]
|
||||
)
|
||||
}
|
||||
taxonomyId={tax?.id}
|
||||
></TermParentSelector>
|
||||
{/* <TermSelect
|
||||
// open
|
||||
className="w-72"
|
||||
value={items}
|
||||
|
@ -46,7 +59,7 @@ export default function FilterSection() {
|
|||
}></TermSelect>
|
||||
{index < taxonomies.length - 1 && (
|
||||
<Divider className="my-6" />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -38,66 +38,67 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
|
|||
description,
|
||||
initialVisibleCoursesCount = 8,
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("全部");
|
||||
const gateGory: GetTaxonomyProps = useGetTaxonomy({
|
||||
type: TaxonomySlug.CATEGORY,
|
||||
});
|
||||
return (
|
||||
<section className="relative py-20 overflow-hidden bg-gradient-to-b from-blue-50 to-gray-50 ">
|
||||
<div className="max-w-screen-2xl mx-auto px-6 relative">
|
||||
<div className="flex justify-between items-end mb-16 bg-blue-100 rounded-lg p-6">
|
||||
<div>
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</Title>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="text-xl font-light text-gray-600">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-wrap gap-4">
|
||||
{gateGory.isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 2 }}></Skeleton>
|
||||
) : (
|
||||
<>
|
||||
{["全部", ...gateGory.categories].map(
|
||||
(category, idx) => (
|
||||
<CoursesSectionTag
|
||||
key={idx}
|
||||
category={category}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={
|
||||
setSelectedCategory
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CourseList
|
||||
params={{
|
||||
page: 1,
|
||||
pageSize: initialVisibleCoursesCount,
|
||||
where: {
|
||||
terms: !(selectedCategory === "全部")
|
||||
? {
|
||||
some: {
|
||||
name: selectedCategory,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}}
|
||||
showPagination={false}
|
||||
cols={4}></CourseList>
|
||||
<LookForMore to={"/courses"}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("全部");
|
||||
const gateGory: GetTaxonomyProps = useGetTaxonomy({
|
||||
type: TaxonomySlug.CATEGORY,
|
||||
});
|
||||
return (
|
||||
<section className="relative py-20 overflow-hidden bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="max-w-screen-2xl mx-auto px-6 relative">
|
||||
<div className="flex justify-between items-end mb-16">
|
||||
<div>
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</Title>
|
||||
|
||||
<Text
|
||||
type="secondary"
|
||||
className="text-xl font-light text-gray-600">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-wrap gap-4">
|
||||
{gateGory.isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 2 }}></Skeleton>
|
||||
) : (
|
||||
<>
|
||||
{["全部", ...gateGory.categories].map(
|
||||
(category, idx) => (
|
||||
<CoursesSectionTag
|
||||
key={idx}
|
||||
category={category}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={
|
||||
setSelectedCategory
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CourseList
|
||||
params={{
|
||||
page: 1,
|
||||
pageSize: initialVisibleCoursesCount,
|
||||
where: {
|
||||
terms: !(selectedCategory === "全部")
|
||||
? {
|
||||
some: {
|
||||
name: selectedCategory,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}}
|
||||
showPagination={false}
|
||||
cols={4}></CourseList>
|
||||
<LookForMore to={"/courses"}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
export default CoursesSection;
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import React, { useRef, useCallback, useEffect } from "react";
|
||||
import { Button, Carousel, Typography } from "antd";
|
||||
import React, { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Carousel, Typography } from "antd";
|
||||
import {
|
||||
TeamOutlined,
|
||||
BookOutlined,
|
||||
StarOutlined,
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
EyeOutlined,
|
||||
|
@ -24,36 +23,22 @@ interface CarouselItem {
|
|||
|
||||
interface PlatformStat {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
value: number;
|
||||
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 HeroSection = () => {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
const { statistics, baseSetting } = useAppConfig();
|
||||
const platformStats: PlatformStat[] = [
|
||||
{ icon: <TeamOutlined />, value: statistics.staffs.toString(), label: "注册学员" },
|
||||
{ icon: <StarOutlined />, value: statistics.courses.toString(), label: "精品课程" },
|
||||
{ icon: <BookOutlined />, value: statistics.lectures.toString(), label: '课程章节' },
|
||||
{ icon: <EyeOutlined />, value: statistics.reads.toString(), label: "观看次数" },
|
||||
];
|
||||
const { statistics, slides } = useAppConfig();
|
||||
const [countStatistics, setCountStatistics] = useState<number>(0)
|
||||
const platformStats: PlatformStat[] = useMemo(() => {
|
||||
return [
|
||||
{ icon: <TeamOutlined />, value: statistics.staffs, label: "注册学员" },
|
||||
{ icon: <StarOutlined />, value: statistics.courses, label: "精品课程" },
|
||||
{ icon: <BookOutlined />, value: statistics.lectures, label: '课程章节' },
|
||||
{ icon: <EyeOutlined />, value: statistics.reads, label: "观看次数" },
|
||||
];
|
||||
}, [statistics]);
|
||||
const handlePrev = useCallback(() => {
|
||||
carouselRef.current?.prev();
|
||||
}, []);
|
||||
|
@ -61,10 +46,16 @@ const HeroSection = () => {
|
|||
const handleNext = useCallback(() => {
|
||||
carouselRef.current?.next();
|
||||
}, []);
|
||||
const { slides } = useAppConfig()
|
||||
|
||||
const countNonZeroValues = (statistics: Record<string, number>): number => {
|
||||
return Object.values(statistics).filter(value => value !== 0).length;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(statistics)
|
||||
}, [statistics])
|
||||
const count = countNonZeroValues(statistics);
|
||||
console.log(count);
|
||||
setCountStatistics(count);
|
||||
}, [statistics]);
|
||||
return (
|
||||
<section className="relative ">
|
||||
<div className="group">
|
||||
|
@ -76,7 +67,7 @@ const HeroSection = () => {
|
|||
dots={{
|
||||
className: "carousel-dots !bottom-32 !z-20",
|
||||
}}>
|
||||
{Array.isArray(slides)?
|
||||
{Array.isArray(slides) ?
|
||||
(slides.map((item, index) => (
|
||||
<div key={index} className="relative h-[600px]">
|
||||
<div
|
||||
|
@ -96,9 +87,9 @@ const HeroSection = () => {
|
|||
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</Carousel>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
|
@ -117,25 +108,31 @@ const HeroSection = () => {
|
|||
</div>
|
||||
|
||||
{/* Stats Container */}
|
||||
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 w-2/3 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>
|
||||
{
|
||||
countStatistics > 1 && (
|
||||
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 w-3/5 max-w-6xl px-4">
|
||||
<div className={`rounded-2xl grid grid-cols-${countStatistics} md:grid-cols-${countStatistics} 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) => {
|
||||
return stat.value
|
||||
? (<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>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useContext, useState } from "react";
|
|||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
||||
import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { UserMenu } from "./UserMenu/UserMenu";
|
||||
import { NavigationMenu } from "./NavigationMenu";
|
||||
import { useMainContext } from "./MainProvider";
|
||||
|
@ -10,6 +10,7 @@ const { Header } = Layout;
|
|||
|
||||
export function MainHeader() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { searchValue, setSearchValue } = useMainContext();
|
||||
return (
|
||||
|
@ -52,10 +53,15 @@ export function MainHeader() {
|
|||
{isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate("/course/editor")}
|
||||
onClick={() => {
|
||||
const url = id
|
||||
? `/course/${id}/editor`
|
||||
: "/course/editor";
|
||||
navigate(url);
|
||||
}}
|
||||
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={<EditFilled />}>
|
||||
创建课程
|
||||
{id ? "编辑课程" : "创建课程"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -28,9 +28,15 @@ const CollapsibleContent: React.FC<CollapsibleContentProps> = ({
|
|||
{/* 包装整个内容区域的容器 */}
|
||||
<div
|
||||
ref={contentWrapperRef}
|
||||
style={{
|
||||
maxHeight:
|
||||
shouldCollapse && !isExpanded
|
||||
? maxHeight
|
||||
: undefined,
|
||||
}}
|
||||
className={`duration-300 ${
|
||||
shouldCollapse && !isExpanded
|
||||
? `max-h-[${maxHeight}px] overflow-hidden relative`
|
||||
? ` overflow-hidden relative`
|
||||
: ""
|
||||
}`}>
|
||||
{/* 内容区域 */}
|
||||
|
|
|
@ -3,66 +3,81 @@ import { useEffect, useState } from "react";
|
|||
import { Upload, Progress, Button, Image, Form } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import AvatarUploader from "./AvatarUploader";
|
||||
import { isEqual } from 'lodash';
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
interface MultiAvatarUploaderProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
}
|
||||
|
||||
export function MultiAvatarUploader({
|
||||
value,
|
||||
onChange,
|
||||
value,
|
||||
onChange,
|
||||
}: MultiAvatarUploaderProps) {
|
||||
const [imageList, setImageList] = useState<string[]>(value || [])
|
||||
const [previewImage, setPreviewImage] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (!isEqual(value, imageList)) {
|
||||
setImageList(value || []);
|
||||
}
|
||||
}, [value]);
|
||||
useEffect(() => {
|
||||
onChange?.(imageList)
|
||||
}, [imageList])
|
||||
return <>
|
||||
<div className="flex gap-2 mb-2" style={{ width: "1200px" }}>
|
||||
{imageList.map((image, index) => {
|
||||
return (
|
||||
<div className="mr-2px relative" key={index} style={{ width: "200px", height: "100px" }} >
|
||||
<Image alt="" style={{ width: "200px", height: "100px" }} src={image}
|
||||
preview={{
|
||||
visible: previewImage === image,
|
||||
onVisibleChange: (visible) =>
|
||||
setPreviewImage(visible ? image || "" : "")
|
||||
}} >
|
||||
</Image>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined className="text-red" />}
|
||||
onClick={() => image && setImageList(imageList.filter((_, i) => i !== index))}
|
||||
style={{
|
||||
position: "absolute", // 绝对定位
|
||||
top: "0", // 顶部对齐
|
||||
right: "0", // 右侧对齐
|
||||
zIndex: 1, // 确保按钮在图片上方
|
||||
padding: "4px", // 调整按钮内边距
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)", // 半透明背景
|
||||
borderRadius: "50%", // 圆形按钮
|
||||
}}
|
||||
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<AvatarUploader showCover={false} successText={'轮播图上传成功'} onChange={(value) => {
|
||||
console.log(value);
|
||||
setImageList([...imageList, value])
|
||||
}}></AvatarUploader>
|
||||
</div>
|
||||
|
||||
</>;
|
||||
const [imageList, setImageList] = useState<string[]>(value || []);
|
||||
const [previewImage, setPreviewImage] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (!isEqual(value, imageList)) {
|
||||
setImageList(value || []);
|
||||
}
|
||||
}, [value]);
|
||||
useEffect(() => {
|
||||
onChange?.(imageList);
|
||||
}, [imageList]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-2" style={{ width: "1200px" }}>
|
||||
{(imageList || [])?.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
className="mr-2px relative"
|
||||
key={index}
|
||||
style={{ width: "200px", height: "100px" }}>
|
||||
<Image
|
||||
alt=""
|
||||
style={{ width: "200px", height: "100px" }}
|
||||
src={image}
|
||||
preview={{
|
||||
visible: previewImage === image,
|
||||
onVisibleChange: (visible) =>
|
||||
setPreviewImage(
|
||||
visible ? image || "" : ""
|
||||
),
|
||||
}}></Image>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined className="text-red" />}
|
||||
onClick={() =>
|
||||
image &&
|
||||
setImageList(
|
||||
imageList.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
style={{
|
||||
position: "absolute", // 绝对定位
|
||||
top: "0", // 顶部对齐
|
||||
right: "0", // 右侧对齐
|
||||
zIndex: 1, // 确保按钮在图片上方
|
||||
padding: "4px", // 调整按钮内边距
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)", // 半透明背景
|
||||
borderRadius: "50%", // 圆形按钮
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<AvatarUploader
|
||||
showCover={false}
|
||||
successText={"轮播图上传成功"}
|
||||
onChange={(value) => {
|
||||
console.log(value);
|
||||
setImageList([...imageList, value]);
|
||||
}}></AvatarUploader>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default MultiAvatarUploader;
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "@nice/common";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import React, { createContext, ReactNode, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
|
@ -33,6 +33,7 @@ export function CourseDetailProvider({
|
|||
const navigate = useNavigate();
|
||||
const { read } = useVisitor();
|
||||
const { user } = useAuth();
|
||||
const { lectureId } = useParams();
|
||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||
(api.post as any).findFirst.useQuery(
|
||||
{
|
||||
|
@ -47,7 +48,7 @@ export function CourseDetailProvider({
|
|||
|
||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
>(lectureId || undefined);
|
||||
const { data: lecture, isLoading: lectureIsLoading } = (
|
||||
api.post as any
|
||||
).findFirst.useQuery(
|
||||
|
|
|
@ -61,7 +61,6 @@ export const CourseDetailDescription: React.FC = () => {
|
|||
expandable: true,
|
||||
symbol: "展开",
|
||||
onExpand: () => console.log("展开"),
|
||||
// collapseText: "收起",
|
||||
}}>
|
||||
{course?.content}
|
||||
</Paragraph>
|
||||
|
|
|
@ -51,7 +51,7 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
|||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
||||
<CollapsibleContent
|
||||
content={lecture?.content || ""}
|
||||
maxHeight={150} // Optional, defaults to 150
|
||||
maxHeight={500} // Optional, defaults to 150
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
|
@ -15,7 +15,8 @@ const { Header } = Layout;
|
|||
|
||||
export function CourseDetailHeader() {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { id } = useParams();
|
||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { course } = useContext(CourseDetailContext);
|
||||
|
||||
|
@ -51,10 +52,15 @@ export function CourseDetailHeader() {
|
|||
{isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate("/course/editor")}
|
||||
onClick={() => {
|
||||
const url = id
|
||||
? `/course/${id}/editor`
|
||||
: "/course/editor";
|
||||
navigate(url);
|
||||
}}
|
||||
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={<EditFilled />}>
|
||||
创建课程
|
||||
{"编辑课程"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// components/CourseSyllabus/LectureItem.tsx
|
||||
|
||||
import { Lecture, LectureType } from "@nice/common";
|
||||
import React from "react";
|
||||
import { Lecture, LectureType, LessonTypeLabel } from "@nice/common";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
|
@ -19,15 +19,24 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
|||
onClick,
|
||||
}) => {
|
||||
const { lectureId } = useParams();
|
||||
const isReading = useMemo(() => {
|
||||
return lecture?.id === lectureId;
|
||||
}, [lectureId, lecture]);
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors cursor-pointer"
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-200 text-left transition-colors cursor-pointer"
|
||||
onClick={() => onClick(lecture.id)}>
|
||||
{lecture.type === LectureType.VIDEO && (
|
||||
<PlayCircleOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
{lecture?.meta?.type === LectureType.VIDEO && (
|
||||
<div className="text-blue-500 flex items-center">
|
||||
<PlayCircleOutlined className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{LessonTypeLabel[lecture?.meta?.type]}</span>
|
||||
</div>
|
||||
)}
|
||||
{lecture.type === LectureType.ARTICLE && (
|
||||
<FileTextOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" /> // 为文章类型添加图标
|
||||
{lecture?.meta?.type === LectureType.ARTICLE && (
|
||||
<div className="text-blue-500 flex items-center">
|
||||
<FileTextOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" />{" "}
|
||||
<span>{LessonTypeLabel[lecture?.meta?.type]}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||
|
@ -37,10 +46,6 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ClockCircleOutlined className="w-4 h-4" />
|
||||
<span>{lecture.duration}分钟</span>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { LectureItem } from "./LectureItem";
|
||||
|
||||
// components/CourseSyllabus/SectionItem.tsx
|
||||
import { useParams } from "react-router-dom";
|
||||
interface SectionItemProps {
|
||||
section: SectionDto;
|
||||
index?: number;
|
||||
|
@ -13,57 +12,68 @@ interface SectionItemProps {
|
|||
onLectureClick: (lectureId: string) => void;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => onToggle(section.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第{index}章
|
||||
</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section?.lectures?.length}节课 ·{" "}
|
||||
{/* {Math.floor(section?.totalDuration / 60)}分钟 */}
|
||||
</p>
|
||||
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => {
|
||||
const { lectureId } = useParams();
|
||||
const isReading = useMemo(() => {
|
||||
return (section?.lectures || [])
|
||||
?.map((lecture) => lecture?.id)
|
||||
.includes(lectureId);
|
||||
}, [lectureId, section]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<div
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => onToggle(section.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第{index}章
|
||||
</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section?.lectures?.length}节课 ·
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" flex justify-end gap-2">
|
||||
{isReading && (
|
||||
<span className="text-primary text-sm">
|
||||
正在学习中
|
||||
</span>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t">
|
||||
{section.lectures.map((lecture) => (
|
||||
<LectureItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
onClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t">
|
||||
{section.lectures.map((lecture) => (
|
||||
<LectureItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
onClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -83,6 +83,7 @@ export function CourseFormProvider({
|
|||
const onSubmit = async (values: any) => {
|
||||
console.log(values);
|
||||
const sections = values?.sections || [];
|
||||
const deptIds = values?.deptIds || [];
|
||||
const termIds = taxonomies
|
||||
.map((tax) => values[tax.id]) // 获取每个 taxonomy 对应的选中值
|
||||
.filter((id) => id); // 过滤掉空值
|
||||
|
@ -95,12 +96,16 @@ export function CourseFormProvider({
|
|||
terms: {
|
||||
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||
},
|
||||
depts: {
|
||||
connect: deptIds.map((id) => ({ id })),
|
||||
},
|
||||
};
|
||||
// 删除原始的 taxonomy 字段
|
||||
taxonomies.forEach((tax) => {
|
||||
delete formattedValues[tax.id];
|
||||
});
|
||||
delete formattedValues.sections;
|
||||
delete formattedValues.deptIds;
|
||||
if (course) {
|
||||
formattedValues.meta = {
|
||||
...(course?.meta as CourseMeta),
|
||||
|
|
|
@ -4,6 +4,7 @@ import { convertToOptions } from "@nice/client";
|
|||
import TermSelect from "../../../term/term-select";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||
import DepartmentSelect from "../../../department/department-select";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
|
@ -48,6 +49,9 @@ export function CourseBasicForm() {
|
|||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="deptIds" label="参与单位">
|
||||
<DepartmentSelect multiple />
|
||||
</Form.Item>
|
||||
{taxonomies &&
|
||||
taxonomies.map((tax, index) => (
|
||||
<Form.Item
|
||||
|
|
|
@ -237,7 +237,10 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
|||
{isContentVisible &&
|
||||
!editing && // Conditionally render content based on type
|
||||
(field?.meta?.type === LectureType.ARTICLE ? (
|
||||
<CollapsibleContent content={field?.content} />
|
||||
<CollapsibleContent
|
||||
maxHeight={200}
|
||||
content={field?.content}
|
||||
/>
|
||||
) : (
|
||||
<VideoPlayer src={field?.meta?.videoUrl} />
|
||||
))}
|
||||
|
|
|
@ -25,7 +25,7 @@ export default function CourseEditorLayout() {
|
|||
const navigate = useNavigate();
|
||||
const handleNavigation = (item: NavItem, index: number) => {
|
||||
setSelectedSection(index);
|
||||
navigate(item.path, { replace: true });
|
||||
navigate(item.path);
|
||||
};
|
||||
return (
|
||||
<CourseFormProvider editId={id}>
|
||||
|
|
|
@ -54,6 +54,9 @@ export default function CourseList({
|
|||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
if (isLoading) {
|
||||
return <Skeleton paragraph={{ rows: 10 }}></Skeleton>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{courses.length > 0 ? (
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import { api } from "@nice/client/";
|
||||
import { Checkbox, Form } from "antd";
|
||||
import { TermDto } from "@nice/common";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function TermParentSelector({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "选择分类",
|
||||
multiple = true,
|
||||
taxonomyId,
|
||||
domainId,
|
||||
style,
|
||||
}: any) {
|
||||
const utils = api.useUtils();
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]); // 用于存储选中的值
|
||||
const [termsData, setTermsData] = useState<any[]>([]);
|
||||
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: {
|
||||
id: taxonomyId,
|
||||
},
|
||||
parentId: null
|
||||
},
|
||||
});
|
||||
const handleCheckboxChange = (checkedValues: string[]) => {
|
||||
setSelectedValues(checkedValues); // 更新选中的值
|
||||
if (onChange) {
|
||||
onChange(checkedValues); // 调用外部传入的 onChange 回调
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<Form onFinish={null}>
|
||||
<Form.Item name="categories">
|
||||
<Checkbox.Group onChange={handleCheckboxChange}>
|
||||
{data?.map((category) => (
|
||||
<div className="w-full h-9 p-2 my-1">
|
||||
<Checkbox className="text-base text-slate-700" key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,199 +0,0 @@
|
|||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Tree } from "antd";
|
||||
import type { DataNode, TreeProps } from "antd/es/tree";
|
||||
import { getUniqueItems } from "@nice/common";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
interface TermData {
|
||||
value?: string;
|
||||
children?: TermData[];
|
||||
key?: string;
|
||||
hasChildren?: boolean;
|
||||
isLeaf?: boolean;
|
||||
pId?: string;
|
||||
title?: React.ReactNode;
|
||||
data?: any;
|
||||
order?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface TermTreeProps {
|
||||
defaultValue?: string | string[];
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
multiple?: boolean;
|
||||
taxonomyId?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
domainId?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const TermTree: React.FC<TermTreeProps> = ({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
multiple = false,
|
||||
taxonomyId,
|
||||
domainId,
|
||||
disabled = false,
|
||||
style,
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const [treeData, setTreeData] = useState<TermData[]>([]);
|
||||
|
||||
const processTermData = (terms: TermData[]): TermData[] => {
|
||||
return terms.map((term) => ({
|
||||
...term,
|
||||
key: term.key || term.id || "",
|
||||
title: term.title || term.value,
|
||||
children: term.children
|
||||
? processTermData(term.children)
|
||||
: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchParentTerms = useCallback(
|
||||
async (termIds: string | string[], taxonomyId?: string) => {
|
||||
const idsArray = Array.isArray(termIds)
|
||||
? termIds
|
||||
: [termIds].filter(Boolean);
|
||||
try {
|
||||
const result = await utils.term.getParentSimpleTree.fetch({
|
||||
termIds: idsArray,
|
||||
taxonomyId,
|
||||
domainId,
|
||||
});
|
||||
return processTermData(result);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error fetching parent terms for termIds",
|
||||
idsArray,
|
||||
":",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[utils, domainId]
|
||||
);
|
||||
|
||||
const fetchTerms = useCallback(async () => {
|
||||
try {
|
||||
const rootTerms = await utils.term.getChildSimpleTree.fetch({
|
||||
taxonomyId,
|
||||
domainId,
|
||||
});
|
||||
let combinedTerms = processTermData(rootTerms);
|
||||
if (defaultValue) {
|
||||
const defaultTerms = await fetchParentTerms(
|
||||
defaultValue,
|
||||
taxonomyId
|
||||
);
|
||||
combinedTerms = getUniqueItems(
|
||||
[...treeData, ...combinedTerms, ...defaultTerms],
|
||||
"key"
|
||||
);
|
||||
}
|
||||
if (value) {
|
||||
const valueTerms = await fetchParentTerms(value, taxonomyId);
|
||||
combinedTerms = getUniqueItems(
|
||||
[...treeData, ...combinedTerms, ...valueTerms],
|
||||
"key"
|
||||
);
|
||||
}
|
||||
|
||||
setTreeData(combinedTerms);
|
||||
} catch (error) {
|
||||
console.error("Error fetching terms:", error);
|
||||
}
|
||||
}, [
|
||||
defaultValue,
|
||||
value,
|
||||
taxonomyId,
|
||||
utils,
|
||||
fetchParentTerms,
|
||||
domainId,
|
||||
treeData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTerms();
|
||||
}, [fetchTerms]);
|
||||
|
||||
const onLoadData = async ({ key }: any) => {
|
||||
try {
|
||||
const result = await utils.term.getChildSimpleTree.fetch({
|
||||
termIds: [key],
|
||||
taxonomyId,
|
||||
domainId,
|
||||
});
|
||||
const processedResult = processTermData(result);
|
||||
const newItems = getUniqueItems(
|
||||
[...treeData, ...processedResult],
|
||||
"key"
|
||||
);
|
||||
setTreeData(newItems);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error loading data for node with key",
|
||||
key,
|
||||
":",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck: TreeProps["onCheck"] = (checkedKeys, info) => {
|
||||
if (onChange) {
|
||||
if (multiple) {
|
||||
onChange(checkedKeys as string[]);
|
||||
} else {
|
||||
onChange((checkedKeys as string[])[0] || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = async (expandedKeys: React.Key[]) => {
|
||||
try {
|
||||
const allKeyIds = expandedKeys
|
||||
.map((key) => key.toString())
|
||||
.filter(Boolean);
|
||||
const expandedNodes = await utils.term.getChildSimpleTree.fetch({
|
||||
termIds: allKeyIds,
|
||||
taxonomyId,
|
||||
domainId,
|
||||
});
|
||||
const processedNodes = processTermData(expandedNodes);
|
||||
const newItems = getUniqueItems(
|
||||
[...treeData, ...processedNodes],
|
||||
"key"
|
||||
);
|
||||
setTreeData(newItems);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error expanding nodes with keys",
|
||||
expandedKeys,
|
||||
":",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tree
|
||||
checkable={multiple}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
style={style}
|
||||
treeData={treeData as DataNode[]}
|
||||
checkedKeys={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onCheck={handleCheck}
|
||||
loadData={onLoadData}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermTree;
|
|
@ -14,7 +14,7 @@ interface CollapsibleSectionProps {
|
|||
interface MenuItem {
|
||||
key: string;
|
||||
link?: string;
|
||||
blank?: boolean
|
||||
blank?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
children?: Array<MenuItem>;
|
||||
|
@ -69,7 +69,10 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
const isChildCollapsed = !expandedSections[item.key];
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex flex-col mb-2 select-none" style={{ color: token.colorTextLightSolid }}>
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex flex-col mb-2 select-none"
|
||||
style={{ color: token.colorTextLightSolid }}>
|
||||
<motion.div
|
||||
className={`flex items-center justify-between px-4 py-2 rounded-full ${hasChildren ? "cursor-pointer" : ""} `}
|
||||
onClick={() => {
|
||||
|
@ -78,7 +81,7 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
}
|
||||
if (item.link) {
|
||||
if (!item.blank) {
|
||||
navigate(item.link, { replace: true });
|
||||
navigate(item.link);
|
||||
} else {
|
||||
window.open(item.link, "_blank");
|
||||
}
|
||||
|
@ -86,12 +89,20 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
}}
|
||||
initial={false}
|
||||
animate={{
|
||||
backgroundColor: isActive ? token.colorPrimaryBorder : token.colorPrimary,
|
||||
backgroundColor: isActive
|
||||
? token.colorPrimaryBorder
|
||||
: token.colorPrimary,
|
||||
}}
|
||||
whileHover={{ backgroundColor: token.colorPrimaryHover }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25, duration: 0.3 }}
|
||||
style={{ marginLeft: `${level * 16}px` }}
|
||||
>
|
||||
whileHover={{
|
||||
backgroundColor: token.colorPrimaryHover,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
duration: 0.3,
|
||||
}}
|
||||
style={{ marginLeft: `${level * 16}px` }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className=" items-center flex gap-2">
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
|
@ -100,8 +111,7 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
{hasChildren && (
|
||||
<Icon
|
||||
name={"caret-right"}
|
||||
className={`ml-1 transition-transform duration-300 ${!isChildCollapsed ? "rotate-90" : ""}`}
|
||||
></Icon>
|
||||
className={`ml-1 transition-transform duration-300 ${!isChildCollapsed ? "rotate-90" : ""}`}></Icon>
|
||||
)}
|
||||
</div>
|
||||
{item.extra && <div className="ml-4">{item.extra}</div>}
|
||||
|
@ -115,11 +125,10 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
// stiffness: 200,
|
||||
// damping: 20,
|
||||
type: "tween",
|
||||
duration: 0.2
|
||||
duration: 0.2,
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
className="mt-1"
|
||||
>
|
||||
className="mt-1">
|
||||
{renderItems(item.children, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
@ -68,11 +68,11 @@ export const routes: CustomRouteObject[] = [
|
|||
path: "profiles",
|
||||
},
|
||||
|
||||
// 课程预览页面
|
||||
{
|
||||
path: "coursePreview/:id?",
|
||||
element: <CoursePreview></CoursePreview>,
|
||||
},
|
||||
// // 课程预览页面
|
||||
// {
|
||||
// path: "coursePreview/:id?",
|
||||
// element: <CoursePreview></CoursePreview>,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue