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