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

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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