This commit is contained in:
Li1304553726 2025-02-26 10:26:32 +08:00
commit 3acb56da6e
25 changed files with 423 additions and 476 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -56,7 +56,7 @@ export default function BaseSettingPage() {
meta: {
...baseSetting,
appConfig: {
...baseSetting.appConfig,
...(baseSetting?.appConfig || {}),
...appConfig,
},
},

View File

@ -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">

View File

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

View File

@ -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;

View File

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

View File

@ -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>
</>
)}

View File

@ -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`
: ""
}`}>
{/* 内容区域 */}

View File

@ -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;

View File

@ -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(

View File

@ -61,7 +61,6 @@ export const CourseDetailDescription: React.FC = () => {
expandable: true,
symbol: "展开",
onExpand: () => console.log("展开"),
// collapseText: "收起",
}}>
{course?.content}
</Paragraph>

View File

@ -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>

View File

@ -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>
</>
)}

View File

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

View File

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

View File

@ -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),

View File

@ -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

View File

@ -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} />
))}

View File

@ -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}>

View File

@ -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 ? (

View File

@ -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>
)
}

View File

@ -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;

View File

@ -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>
)}

View File

@ -68,11 +68,11 @@ export const routes: CustomRouteObject[] = [
path: "profiles",
},
// 课程预览页面
{
path: "coursePreview/:id?",
element: <CoursePreview></CoursePreview>,
},
// // 课程预览页面
// {
// path: "coursePreview/:id?",
// element: <CoursePreview></CoursePreview>,
// },
],
},
{