删除冗余文件,进行build
This commit is contained in:
parent
33185aa340
commit
1cfa22e286
|
@ -1,86 +1,86 @@
|
|||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Typography, Skeleton } from "antd";
|
||||
import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
|
||||
import { api } from "@nice/client";
|
||||
import LookForMore from "./LookForMore";
|
||||
import CategorySectionCard from "./CategorySectionCard";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
// import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
// import { Typography, Skeleton } from "antd";
|
||||
// import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
|
||||
// import { api } from "@nice/client";
|
||||
// import LookForMore from "./LookForMore";
|
||||
// import CategorySectionCard from "./CategorySectionCard";
|
||||
// import { useNavigate } from "react-router-dom";
|
||||
// import { useMainContext } from "../../layout/MainProvider";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const CategorySection = () => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||
const {
|
||||
data: courseCategoriesData,
|
||||
isLoading,
|
||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: {
|
||||
slug: TaxonomySlug.CATEGORY,
|
||||
},
|
||||
parentId: null,
|
||||
},
|
||||
take: 8,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
// const { Title, Text } = Typography;
|
||||
// const CategorySection = () => {
|
||||
// const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
// const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||
// const {
|
||||
// data: courseCategoriesData,
|
||||
// isLoading,
|
||||
// }: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
||||
// where: {
|
||||
// taxonomy: {
|
||||
// slug: TaxonomySlug.CATEGORY,
|
||||
// },
|
||||
// parentId: null,
|
||||
// },
|
||||
// take: 8,
|
||||
// });
|
||||
// const navigate = useNavigate();
|
||||
|
||||
const handleMouseEnter = useCallback((index: number) => {
|
||||
setHoveredIndex(index);
|
||||
}, []);
|
||||
// const handleMouseEnter = useCallback((index: number) => {
|
||||
// setHoveredIndex(index);
|
||||
// }, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredIndex(null);
|
||||
}, []);
|
||||
// const handleMouseLeave = useCallback(() => {
|
||||
// setHoveredIndex(null);
|
||||
// }, []);
|
||||
|
||||
const handleMouseClick = useCallback((categoryId: string) => {
|
||||
setSelectedTerms({
|
||||
...selectedTerms,
|
||||
[TaxonomySlug.CATEGORY]: [categoryId],
|
||||
});
|
||||
navigate("/courses");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
return (
|
||||
<section className="py-8 relative overflow-hidden">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="text-center mb-12">
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
|
||||
探索课程分类
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xl font-light">
|
||||
选择你感兴趣的方向,开启学习之旅
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||
) : (
|
||||
courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
|
||||
const categoryColor = stringToColor(category.name);
|
||||
const isHovered = hoveredIndex === index;
|
||||
// const handleMouseClick = useCallback((categoryId: string) => {
|
||||
// setSelectedTerms({
|
||||
// ...selectedTerms,
|
||||
// [TaxonomySlug.CATEGORY]: [categoryId],
|
||||
// });
|
||||
// navigate("/courses");
|
||||
// window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
// }, []);
|
||||
// return (
|
||||
// <section className="py-8 relative overflow-hidden">
|
||||
// <div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
// <div className="text-center mb-12">
|
||||
// <Title
|
||||
// level={2}
|
||||
// className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
|
||||
// 探索课程分类
|
||||
// </Title>
|
||||
// <Text type="secondary" className="text-xl font-light">
|
||||
// 选择你感兴趣的方向,开启学习之旅
|
||||
// </Text>
|
||||
// </div>
|
||||
// <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
// {isLoading ? (
|
||||
// <Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||
// ) : (
|
||||
// courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
|
||||
// const categoryColor = stringToColor(category.name);
|
||||
// const isHovered = hoveredIndex === index;
|
||||
|
||||
return (
|
||||
<CategorySectionCard
|
||||
key={index}
|
||||
index={index}
|
||||
category={category}
|
||||
categoryColor={categoryColor}
|
||||
isHovered={isHovered}
|
||||
handleMouseEnter={handleMouseEnter}
|
||||
handleMouseLeave={handleMouseLeave}
|
||||
handleMouseClick={handleMouseClick}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<LookForMore to={"/courses"}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
// return (
|
||||
// <CategorySectionCard
|
||||
// key={index}
|
||||
// index={index}
|
||||
// category={category}
|
||||
// categoryColor={categoryColor}
|
||||
// isHovered={isHovered}
|
||||
// handleMouseEnter={handleMouseEnter}
|
||||
// handleMouseLeave={handleMouseLeave}
|
||||
// handleMouseClick={handleMouseClick}
|
||||
// />
|
||||
// );
|
||||
// })
|
||||
// )}
|
||||
// </div>
|
||||
// <LookForMore to={"/courses"}></LookForMore>
|
||||
// </div>
|
||||
// </section>
|
||||
// );
|
||||
// };
|
||||
|
||||
export default CategorySection;
|
||||
// export default CategorySection;
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
import React, { useState, useMemo, ReactNode } from "react";
|
||||
import { Typography, Skeleton } from "antd";
|
||||
import { TaxonomySlug, TermDto } from "@nice/common";
|
||||
import { api } from "@nice/client";
|
||||
import { CoursesSectionTag } from "./CoursesSectionTag";
|
||||
import LookForMore from "./LookForMore";
|
||||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
interface GetTaxonomyProps {
|
||||
categories: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
function useGetTaxonomy({ type }): GetTaxonomyProps {
|
||||
const { data, isLoading }: { data: TermDto[]; isLoading: boolean } =
|
||||
api.term.findMany.useQuery({
|
||||
where: {
|
||||
taxonomy: {
|
||||
slug: type,
|
||||
},
|
||||
parentId: null,
|
||||
},
|
||||
take: 11, // 只取前10个
|
||||
});
|
||||
const categories = useMemo(() => {
|
||||
const allCategories = isLoading
|
||||
? []
|
||||
: data?.filter(c=>!c.deletedAt)?.map((course) => course.name);
|
||||
return [...Array.from(new Set(allCategories))];
|
||||
}, [data]);
|
||||
return { categories, isLoading };
|
||||
}
|
||||
const { Title, Text } = Typography;
|
||||
interface CoursesSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
initialVisibleCoursesCount?: number;
|
||||
postType:string;
|
||||
render?:(post)=>ReactNode;
|
||||
to:string
|
||||
}
|
||||
const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
initialVisibleCoursesCount = 8,
|
||||
postType,
|
||||
render,
|
||||
to
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("全部");
|
||||
const gateGory: GetTaxonomyProps = useGetTaxonomy({
|
||||
type: TaxonomySlug.CATEGORY,
|
||||
});
|
||||
return (
|
||||
<section className="relative py-16 overflow-hidden ">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="flex justify-between items-end mb-12 ">
|
||||
<div>
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-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>
|
||||
<PostList
|
||||
renderItem={(post) => render(post)}
|
||||
params={{
|
||||
page: 1,
|
||||
pageSize: initialVisibleCoursesCount,
|
||||
where: {
|
||||
deletedAt:null,
|
||||
terms: !(selectedCategory === "全部")
|
||||
? {
|
||||
some: {
|
||||
name: selectedCategory,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
type: postType
|
||||
},
|
||||
}}
|
||||
showPagination={false}
|
||||
cols={4}></PostList>
|
||||
<LookForMore to={to}></LookForMore>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
export default CoursesSection;
|
|
@ -1,34 +1,34 @@
|
|||
import HeroSection from "./components/HeroSection";
|
||||
import CategorySection from "./components/CategorySection";
|
||||
import CoursesSection from "./components/CoursesSection";
|
||||
import { PostType } from "@nice/common";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
// import HeroSection from "./components/HeroSection";
|
||||
// import CategorySection from "./components/CategorySection";
|
||||
// import CoursesSection from "./components/CoursesSection";
|
||||
// import { PostType } from "@nice/common";
|
||||
// import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
// import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
|
||||
const HomePage = () => {
|
||||
// const HomePage = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<HeroSection />
|
||||
<CoursesSection
|
||||
title="最受欢迎的思维导图"
|
||||
description="深受追捧的思维导图,点亮你的智慧人生"
|
||||
postType={PostType.PATH}
|
||||
render={(post)=><PathCard post={post}></PathCard>}
|
||||
to={"path"}
|
||||
/>
|
||||
<CoursesSection
|
||||
title="推荐课程"
|
||||
description="最受欢迎的精品课程,助你快速成长"
|
||||
postType={PostType.COURSE}
|
||||
render={(post)=> <CourseCard post={post}></CourseCard>}
|
||||
to={"/courses"}
|
||||
/>
|
||||
// return (
|
||||
// <div className="min-h-screen">
|
||||
// <HeroSection />
|
||||
// <CoursesSection
|
||||
// title="最受欢迎的思维导图"
|
||||
// description="深受追捧的思维导图,点亮你的智慧人生"
|
||||
// postType={PostType.PATH}
|
||||
// render={(post)=><PathCard post={post}></PathCard>}
|
||||
// to={"path"}
|
||||
// />
|
||||
// <CoursesSection
|
||||
// title="推荐课程"
|
||||
// description="最受欢迎的精品课程,助你快速成长"
|
||||
// postType={PostType.COURSE}
|
||||
// render={(post)=> <CourseCard post={post}></CourseCard>}
|
||||
// to={"/courses"}
|
||||
// />
|
||||
|
||||
<CategorySection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// <CategorySection />
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
export default HomePage;
|
||||
// export default HomePage;
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { ReactNode, useEffect } from "react";
|
||||
import FilterSection from "./FilterSection";
|
||||
import { useMainContext } from "../MainProvider";
|
||||
|
||||
export function BasePostLayout({
|
||||
children,
|
||||
showSearchMode = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
showSearchMode?: boolean;
|
||||
}) {
|
||||
const { setShowSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setShowSearchMode(showSearchMode);
|
||||
}, [showSearchMode]);
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className=" flex">
|
||||
<div className="w-1/6">
|
||||
<FilterSection></FilterSection>
|
||||
</div>
|
||||
<div className="w-5/6 p-4 py-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default BasePostLayout;
|
|
@ -1,46 +0,0 @@
|
|||
import { Divider } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import { useMainContext } from "../MainProvider";
|
||||
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
||||
import SearchModeRadio from "./SearchModeRadio";
|
||||
export default function FilterSection() {
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
||||
const { selectedTerms, setSelectedTerms, showSearchMode } =
|
||||
useMainContext();
|
||||
const handleTermChange = (slug: string, selected: string[]) => {
|
||||
setSelectedTerms({
|
||||
...selectedTerms,
|
||||
[slug]: selected, // 更新对应 slug 的选择
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className=" flex z-0 p-6 flex-col mt-4 space-y-6 overscroll-contain overflow-x-hidden">
|
||||
{showSearchMode && <SearchModeRadio></SearchModeRadio>}
|
||||
{taxonomies?.map((tax, index) => {
|
||||
const items = Object.entries(selectedTerms).find(
|
||||
([key, items]) => key === tax.slug
|
||||
)?.[1];
|
||||
return (
|
||||
<div key={index}>
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{tax?.name}
|
||||
{/* {JSON.stringify(items)} */}
|
||||
</h3>
|
||||
<TermParentSelector
|
||||
value={items}
|
||||
// slug={tax?.slug}
|
||||
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
||||
onChange={(selected) =>
|
||||
handleTermChange(
|
||||
tax?.slug,
|
||||
selected as string[]
|
||||
)
|
||||
}
|
||||
taxonomyId={tax?.id}></TermParentSelector>
|
||||
<Divider></Divider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { useMainContext } from "../MainProvider";
|
||||
import { Radio, Space, Typography } from "antd";
|
||||
import { PostType } from "@nice/common"; // Assuming PostType is defined in this path
|
||||
|
||||
export default function SearchModeRadio() {
|
||||
const { searchMode, setSearchMode } = useMainContext();
|
||||
|
||||
const handleModeChange = (e) => {
|
||||
setSearchMode(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" align="start" className="mb-2">
|
||||
<h3 className="text-lg font-medium mb-4">只搜索</h3>
|
||||
<Radio.Group
|
||||
value={searchMode}
|
||||
onChange={handleModeChange}
|
||||
buttonStyle="solid">
|
||||
<Radio.Button value={PostType.COURSE}>视频课程</Radio.Button>
|
||||
<Radio.Button value={PostType.PATH}>思维导图</Radio.Button>
|
||||
<Radio.Button value="both">所有资源</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
);
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export default function MyDutyPathContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
deletedAt: null,
|
||||
type: PostType.PATH,
|
||||
authorId: user?.id,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
import MyDutyPathContainer from "./components/MyDutyPathContainer";
|
||||
|
||||
export default function MyDutyPathPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyDutyPathContainer></MyDutyPathContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { PostType } from "@nice/common";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
export default function MyDutyListContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
deletedAt:null,
|
||||
type: PostType.COURSE,
|
||||
authorId: user.id,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import MyDutyListContainer from "./components/MyDutyListContainer";
|
||||
import { useEffect } from "react";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
export default function MyDutyPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.COURSE);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyDutyListContainer></MyDutyListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
export default function MyLearningListContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
deletedAt: null,
|
||||
type: PostType.COURSE,
|
||||
students: {
|
||||
some: {
|
||||
id: user?.id,
|
||||
},
|
||||
},
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import MyLearningListContainer from "./components/MyLearningListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
|
||||
export default function MyLearningPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.COURSE);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyLearningListContainer></MyLearningListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
import { PostType } from "@nice/common";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export default function MyPathListContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
students: {
|
||||
some: {
|
||||
id: user?.id,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import MyPathListContainer from "./components/MyPathListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
|
||||
export default function MyPathPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyPathListContainer></MyPathListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { Typography } from "antd";
|
||||
import { PostDto } from "@nice/common";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const DeptInfo = ({ post }: { post: PostDto }) => {
|
||||
return (
|
||||
<div className="gap-1 flex items-center justify-between flex-grow">
|
||||
<div className=" flex justify-start gap-1 items-center">
|
||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||
{post?.depts && post?.depts?.length > 0 ? (
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
{post?.depts?.length > 1
|
||||
? `${post.depts[0].name}等`
|
||||
: post?.depts?.[0]?.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
未设置单位
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{post && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
浏览量
|
||||
<EyeOutlined />
|
||||
{`${post?.views || 0}`}
|
||||
</span>
|
||||
{post?.studentIds && post?.studentIds?.length > 0 && (
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<BookOutlined />
|
||||
{`${post?.studentIds?.length || 0}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeptInfo;
|
|
@ -1,27 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { PostType, Prisma } from "@nice/common";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export function PathListContainer() {
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
deletedAt: null,
|
||||
},
|
||||
}}
|
||||
cols={4}
|
||||
></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default PathListContainer;
|
|
@ -1,47 +0,0 @@
|
|||
import { Tag } from "antd";
|
||||
import { PostDto, TaxonomySlug, TermDto } from "@nice/common";
|
||||
|
||||
const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => {
|
||||
return (
|
||||
<div>
|
||||
{terms && terms?.length > 0 ? (
|
||||
<div className="flex gap-2 mb-4">
|
||||
{terms
|
||||
?.sort((a, b) =>
|
||||
String(a?.taxonomy?.id || "").localeCompare(
|
||||
String(b?.taxonomy?.id || "")
|
||||
)
|
||||
)
|
||||
?.map((term: any) => {
|
||||
return (
|
||||
<Tag
|
||||
key={term.id}
|
||||
color={
|
||||
term?.taxonomy?.slug ===
|
||||
TaxonomySlug.CATEGORY
|
||||
? "green"
|
||||
: term?.taxonomy?.slug ===
|
||||
TaxonomySlug.LEVEL
|
||||
? "blue"
|
||||
: "orange"
|
||||
}
|
||||
className="px-3 py-1 rounded-full border-0">
|
||||
{term.name}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Tag
|
||||
color={"orange"}
|
||||
className="px-3 py-1 rounded-full border-0">
|
||||
{"未设置分类"}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermInfo;
|
|
@ -1,12 +0,0 @@
|
|||
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export default function PathEditorPage() {
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<PostDetailProvider editId={id}>
|
||||
<MindEditor id={id}></MindEditor>
|
||||
</PostDetailProvider>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import PathListContainer from "./components/PathListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export default function PathPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<PathListContainer></PathListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import { PostType } from "@nice/common";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
const POST_TYPE_COMPONENTS = {
|
||||
[PostType.COURSE]: CourseCard,
|
||||
[PostType.PATH]: PathCard,
|
||||
};
|
||||
export default function SearchListContainer() {
|
||||
const { searchCondition, termsCondition, searchMode } = useMainContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => {
|
||||
const Component =
|
||||
POST_TYPE_COMPONENTS[post.type] || PostCard;
|
||||
return <Component post={post} />;
|
||||
}}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: searchMode === "both" ? { in: [PostType.COURSE, PostType.PATH] } : searchMode,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
deletedAt: null,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import SearchListContainer from "./components/SearchContainer";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
|
||||
export default function SearchPage() {
|
||||
const { setShowSearchMode, setSearchValue } = useMainContext();
|
||||
useEffect(() => {
|
||||
setShowSearchMode(true);
|
||||
return () => {
|
||||
setShowSearchMode(false);
|
||||
setSearchValue("");
|
||||
};
|
||||
}, [setShowSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<SearchListContainer></SearchListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export default function MyCoursePage() {
|
||||
return (
|
||||
<div>
|
||||
My Course Page
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function ProfilesPage() {
|
||||
return <>Profiles</>
|
||||
}
|
|
@ -1,349 +0,0 @@
|
|||
import { Button, Empty, Form, Modal, Spin } from "antd";
|
||||
import NodeMenu from "./NodeMenu";
|
||||
import { api, usePost, useVisitor } from "@nice/client";
|
||||
import {
|
||||
ObjectType,
|
||||
PathDto,
|
||||
postDetailSelect,
|
||||
PostType,
|
||||
Prisma,
|
||||
RolePerms,
|
||||
VisitType,
|
||||
} from "@nice/common";
|
||||
import TermSelect from "../../models/term/term-select";
|
||||
import DepartmentSelect from "../../models/department/department-select";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MindElixirInstance } from "mind-elixir";
|
||||
import MindElixir from "mind-elixir";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { MIND_OPTIONS } from "./constant";
|
||||
import { ExclamationCircleFilled, LinkOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButton";
|
||||
import { CourseDetailContext } from "../../models/course/detail/PostDetailContext";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
export default function MindEditor({ id }: { id?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { confirm } = Modal;
|
||||
const {
|
||||
post,
|
||||
isLoading,
|
||||
// userIsLearning,
|
||||
// setUserIsLearning,
|
||||
} = useContext(CourseDetailContext);
|
||||
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||
const { read } = useVisitor();
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
console.log("post", post)
|
||||
console.log("user", user)
|
||||
console.log(canEdit)
|
||||
})
|
||||
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||
// api.post.findFirst.useQuery(
|
||||
// {
|
||||
// where: {
|
||||
// id,
|
||||
// },
|
||||
// select: postDetailSelect,
|
||||
// },
|
||||
// { enabled: Boolean(id) }
|
||||
// );
|
||||
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
|
||||
}
|
||||
})
|
||||
const canEdit: boolean = useMemo(() => {
|
||||
const isAuth = isAuthenticated && user?.id === post?.author?.id;
|
||||
|
||||
return (
|
||||
isAuthenticated &&
|
||||
(!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST))
|
||||
);
|
||||
}, [user, post]);
|
||||
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { create, update } = usePost();
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||
type: ObjectType.COURSE,
|
||||
});
|
||||
const { handleFileUpload } = useTusUpload();
|
||||
const [form] = Form.useForm();
|
||||
const handleIcon = () => {
|
||||
const hyperLinkElement = document.querySelectorAll(".hyper-link");
|
||||
console.log("hyperLinkElement", hyperLinkElement);
|
||||
hyperLinkElement.forEach((item) => {
|
||||
const hyperLinkDom = createRoot(item);
|
||||
hyperLinkDom.render(<LinkOutlined />);
|
||||
});
|
||||
}
|
||||
const CustomLinkIconPlugin = (mind) => {
|
||||
mind.bus.addListener("operation", handleIcon)
|
||||
};
|
||||
useEffect(() => {
|
||||
if (post?.id && id) {
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post?.id,
|
||||
type: VisitType.READED,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [post]);
|
||||
useEffect(() => {
|
||||
if (post && form && instance && id) {
|
||||
instance.refresh((post as any).meta);
|
||||
const deptIds = (post?.depts || [])?.map((dept) => dept.id);
|
||||
const formData = {
|
||||
title: post.title,
|
||||
deptIds: deptIds,
|
||||
};
|
||||
// post.terms?.forEach((term) => {
|
||||
// formData[term.taxonomyId] = term.id; // 假设 taxonomyName是您在 Form.Item 中使用的name
|
||||
// });
|
||||
|
||||
// 按 taxonomyId 分组所有 terms
|
||||
const termsByTaxonomy = {};
|
||||
post.terms?.forEach((term) => {
|
||||
if (!termsByTaxonomy[term.taxonomyId]) {
|
||||
termsByTaxonomy[term.taxonomyId] = [];
|
||||
}
|
||||
termsByTaxonomy[term.taxonomyId].push(term.id);
|
||||
});
|
||||
|
||||
// 将分组后的 terms 设置到 formData
|
||||
Object.entries(termsByTaxonomy).forEach(([taxonomyId, termIds]) => {
|
||||
formData[taxonomyId] = termIds;
|
||||
});
|
||||
form.setFieldsValue(formData);
|
||||
}
|
||||
}, [post, form, instance, id]);
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const mind = new MindElixir({
|
||||
...MIND_OPTIONS,
|
||||
el: containerRef.current,
|
||||
before: {
|
||||
beginEdit() {
|
||||
return canEdit;
|
||||
},
|
||||
},
|
||||
draggable: canEdit, // 禁用拖拽
|
||||
contextMenu: canEdit, // 禁用右键菜单
|
||||
toolBar: canEdit, // 禁用工具栏
|
||||
nodeMenu: canEdit, // 禁用节点右键菜单
|
||||
keypress: canEdit, // 禁用键盘快捷键
|
||||
});
|
||||
mind.install(CustomLinkIconPlugin);
|
||||
mind.init(MindElixir.new("新思维导图"));
|
||||
containerRef.current.hidden = true;
|
||||
//挂载实例
|
||||
setInstance(mind);
|
||||
|
||||
}, [canEdit, post]);
|
||||
useEffect(() => {
|
||||
handleIcon()
|
||||
});
|
||||
useEffect(() => {
|
||||
if ((!id || post) && instance) {
|
||||
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
|
||||
containerRef.current.style.width = `100%`;
|
||||
containerRef.current.hidden = false;
|
||||
instance.toCenter();
|
||||
if ((post as any as PathDto)?.meta?.nodeData) {
|
||||
instance.refresh((post as any as PathDto)?.meta);
|
||||
}
|
||||
}
|
||||
}, [id, post, instance]);
|
||||
|
||||
//保存 按钮 函数
|
||||
const handleSave = async () => {
|
||||
if (!instance) return;
|
||||
const values = form.getFieldsValue();
|
||||
//以图片格式导出思维导图以作为思维导图封面
|
||||
const imgBlob = await instance?.exportPng();
|
||||
handleFileUpload(
|
||||
imgBlob,
|
||||
async (result) => {
|
||||
const termIds = taxonomies
|
||||
.flatMap((tax) => values[tax.id] || []) // 获取每个 taxonomy 对应的选中值并展平
|
||||
.filter((id) => id); // 过滤掉空值
|
||||
const deptIds = (values?.deptIds || []) as string[];
|
||||
const { theme, ...data } = instance.getData();
|
||||
try {
|
||||
if (post && id) {
|
||||
const params: Prisma.PostUpdateArgs = {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
//authorId: post.authorId,
|
||||
title: data.nodeData.topic,
|
||||
meta: {
|
||||
...data,
|
||||
thumbnail: result.compressedUrl,
|
||||
},
|
||||
terms: {
|
||||
set: termIds.map((id) => ({ id })),
|
||||
},
|
||||
depts: {
|
||||
set: deptIds.map((id) => ({ id })),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
await update.mutateAsync(params);
|
||||
toast.success("更新成功");
|
||||
} else {
|
||||
const params: Prisma.PostCreateInput = {
|
||||
type: PostType.PATH,
|
||||
title: data.nodeData.topic,
|
||||
meta: { ...data, thumbnail: result.compressedUrl },
|
||||
terms: {
|
||||
connect: termIds.map((id) => ({ id })),
|
||||
},
|
||||
depts: {
|
||||
connect: deptIds.map((id) => ({ id })),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const res = await create.mutateAsync({ data: params });
|
||||
navigate(`/path/editor/${res.id}`, { replace: true });
|
||||
toast.success("创建成功");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("保存失败");
|
||||
throw error;
|
||||
}
|
||||
console.log(result);
|
||||
},
|
||||
(error) => { },
|
||||
`mind-thumb-${new Date().toString()}`
|
||||
);
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
await softDeletePostDescendant.mutateAsync({
|
||||
ancestorId: id,
|
||||
});
|
||||
navigate("/path");
|
||||
}
|
||||
|
||||
|
||||
const showDeleteConfirm = () => {
|
||||
confirm({
|
||||
title: '确定删除该思维导图吗',
|
||||
icon: <ExclamationCircleFilled />,
|
||||
content: '',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
console.log('OK');
|
||||
await handleDelete()
|
||||
toast.success('思维导图已删除')
|
||||
},
|
||||
onCancel() {
|
||||
console.log('Cancel');
|
||||
},
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
|
||||
}, []);
|
||||
return (
|
||||
<div className={` flex-col flex `}>
|
||||
{taxonomies && (
|
||||
<Form form={form} className=" bg-white p-4 border-b">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{taxonomies.map((tax, index) => (
|
||||
<Form.Item
|
||||
key={tax.id}
|
||||
name={tax.id}
|
||||
// rules={[{ required: true }]}
|
||||
noStyle>
|
||||
<TermSelect
|
||||
maxTagCount={1}
|
||||
multiple
|
||||
disabled={!canEdit}
|
||||
className=" w-56"
|
||||
placeholder={`请选择${tax.name}`}
|
||||
taxonomyId={tax.id}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item
|
||||
// rules={[{ required: true }]}
|
||||
name="deptIds"
|
||||
noStyle>
|
||||
<DepartmentSelect
|
||||
disabled={!canEdit}
|
||||
className="w-96"
|
||||
placeholder="请选择制作单位"
|
||||
multiple
|
||||
/>
|
||||
</Form.Item>
|
||||
{post && id ? <JoinButton></JoinButton> : <></>}
|
||||
</div>
|
||||
<div>
|
||||
{canEdit && (
|
||||
<>
|
||||
{
|
||||
id && (
|
||||
<Button
|
||||
danger
|
||||
icon={<SaveOutlined></SaveOutlined>}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onClick={showDeleteConfirm}>
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
className="ml-4"
|
||||
ghost
|
||||
type="primary"
|
||||
icon={<SaveOutlined></SaveOutlined>}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onClick={handleSave}>
|
||||
{id ? "更新" : "保存"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="py-64 justify-center flex"
|
||||
style={{ height: "calc(100vh - 271px)" }}>
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
)}
|
||||
{!post && id && !isLoading && (
|
||||
<div
|
||||
className="py-64"
|
||||
style={{ height: "calc(100vh - 271px)" }}>
|
||||
<Empty></Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,276 +0,0 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Input, Button, ColorPicker, Select } from "antd";
|
||||
import {
|
||||
FontSizeOutlined,
|
||||
BoldOutlined,
|
||||
LinkOutlined,
|
||||
GlobalOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MindElixirInstance, NodeObj } from "mind-elixir";
|
||||
import PostSelect from "../../models/post/PostSelect/PostSelect";
|
||||
import { Lecture, PostType } from "@nice/common";
|
||||
import { xmindColorPresets } from "./constant";
|
||||
import { api } from "@nice/client";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
interface NodeMenuProps {
|
||||
mind: MindElixirInstance;
|
||||
}
|
||||
|
||||
//管理节点样式状态
|
||||
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedFontColor, setSelectedFontColor] = useState<string>("");
|
||||
const [selectedBgColor, setSelectedBgColor] = useState<string>("");
|
||||
const [selectedSize, setSelectedSize] = useState<string>("");
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const { user} = useAuth();
|
||||
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [postId, setPostId] = useState<string>("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
|
||||
api.post.findFirst.useQuery(
|
||||
{
|
||||
where: { id: postId },
|
||||
},
|
||||
{ enabled: !!postId }
|
||||
);
|
||||
useEffect(() => {
|
||||
{
|
||||
if(lecture?.courseId && lecture?.id){
|
||||
if (urlMode === "POSTURL"){
|
||||
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
|
||||
}
|
||||
mind.reshapeNode(mind.currentNode, {
|
||||
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [postId, lecture, isLoading, urlMode]);
|
||||
|
||||
//监听思维导图节点选择事件,更新节点菜单状态
|
||||
useEffect(() => {
|
||||
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||
setIsOpen(true);
|
||||
const style = nodeObj.style || {};
|
||||
setSelectedFontColor(style.color || "");
|
||||
setSelectedBgColor(style.background || "");
|
||||
setSelectedSize(style.fontSize || "24");
|
||||
setIsBold(style.fontWeight === "bold");
|
||||
setUrl(nodeObj.hyperLink || "");
|
||||
};
|
||||
const handleUnselectNode = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
mind.bus.addListener("selectNode", handleSelectNode);
|
||||
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||
}, [mind]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||
setIsOpen(true);
|
||||
const style = nodeObj.style || {};
|
||||
setSelectedFontColor(style.color || "");
|
||||
setSelectedBgColor(style.background || "");
|
||||
setSelectedSize(style.fontSize || "24");
|
||||
setIsBold(style.fontWeight === "bold");
|
||||
setUrl(nodeObj.hyperLink || "");
|
||||
};
|
||||
const handleUnselectNode = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
mind.bus.addListener("selectNode", handleSelectNode);
|
||||
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||
}, [mind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && mind.container) {
|
||||
mind.container.appendChild(containerRef.current);
|
||||
}
|
||||
}, [mind.container]);
|
||||
|
||||
const handleColorChange = (type: "font" | "background", color: string) => {
|
||||
if (type === "font") {
|
||||
setSelectedFontColor(color);
|
||||
} else {
|
||||
setSelectedBgColor(color);
|
||||
}
|
||||
const patch = { style: {} as any };
|
||||
if (type === "font") {
|
||||
patch.style.color = color;
|
||||
} else {
|
||||
patch.style.background = color;
|
||||
}
|
||||
mind.reshapeNode(mind.currentNode, patch);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: string) => {
|
||||
setSelectedSize(size);
|
||||
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
||||
};
|
||||
|
||||
const handleBoldToggle = () => {
|
||||
const fontWeight = isBold ? "" : "bold";
|
||||
setIsBold(!isBold);
|
||||
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||||
};
|
||||
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setUrl(value);
|
||||
mind.reshapeNode(mind.currentNode, {
|
||||
hyperLink: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${
|
||||
isOpen
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 pointer-events-none"
|
||||
}`}
|
||||
ref={containerRef}>
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Font Size Selector */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
文字样式
|
||||
</h3>
|
||||
<div className="flex gap-3 items-center justify-between">
|
||||
<Select
|
||||
value={selectedSize}
|
||||
onChange={handleSizeChange}
|
||||
prefix={<FontSizeOutlined className="mr-2" />}
|
||||
className="w-1/2"
|
||||
options={[
|
||||
{ value: "12", label: "12" },
|
||||
{ value: "14", label: "14" },
|
||||
{ value: "16", label: "16" },
|
||||
{ value: "18", label: "18" },
|
||||
{ value: "20", label: "20" },
|
||||
{ value: "24", label: "24" },
|
||||
{ value: "28", label: "28" },
|
||||
{ value: "32", label: "32" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type={isBold ? "primary" : "default"}
|
||||
onClick={handleBoldToggle}
|
||||
className="w-1/2"
|
||||
icon={<BoldOutlined />}>
|
||||
加粗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
颜色设置
|
||||
</h3>
|
||||
|
||||
{/* Font Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">
|
||||
文字颜色
|
||||
</h4>
|
||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
{xmindColorPresets.map((color) => (
|
||||
<div
|
||||
key={`font-${color}`}
|
||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||
selectedFontColor === color
|
||||
? "outline-blue-500"
|
||||
: "outline-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
handleColorChange("font", color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">
|
||||
背景颜色
|
||||
</h4>
|
||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
{xmindColorPresets.map((color) => (
|
||||
<div
|
||||
key={`bg-${color}`}
|
||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||
selectedBgColor === color
|
||||
? "outline-blue-500"
|
||||
: "outline-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
handleColorChange("background", color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
{urlMode === "URL" ? "关联链接" : "关联课时"}
|
||||
<Button
|
||||
type="text"
|
||||
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() =>
|
||||
setUrlMode((prev) =>
|
||||
prev === "POSTURL" ? "URL" : "POSTURL"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{urlMode === "POSTURL" ? (
|
||||
<PostSelect
|
||||
onChange={(value) => {
|
||||
if (typeof value === "string" ) {
|
||||
setPostId(value);
|
||||
}
|
||||
}}
|
||||
params={{
|
||||
where: {
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
authorId: user?.id,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="例如:https://example.com"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
addonBefore={<LinkOutlined />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{urlMode === "URL" &&
|
||||
url &&
|
||||
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
|
||||
<p className="text-xs text-red-500">
|
||||
请输入有效的URL地址
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeMenu;
|
|
@ -1,54 +0,0 @@
|
|||
import MindElixir from "mind-elixir";
|
||||
export const MIND_OPTIONS = {
|
||||
direction: MindElixir.SIDE,
|
||||
draggable: true,
|
||||
contextMenu: true,
|
||||
toolBar: true,
|
||||
nodeMenu: true,
|
||||
keypress: true,
|
||||
locale: "zh_CN" as const,
|
||||
theme: {
|
||||
name: "Latte",
|
||||
palette: [
|
||||
"#dd7878",
|
||||
"#ea76cb",
|
||||
"#8839ef",
|
||||
"#e64553",
|
||||
"#fe640b",
|
||||
"#df8e1d",
|
||||
"#40a02b",
|
||||
"#209fb5",
|
||||
"#1e66f5",
|
||||
"#7287fd",
|
||||
],
|
||||
cssVar: {
|
||||
"--main-color": "#444446",
|
||||
"--main-bgcolor": "#ffffff",
|
||||
"--color": "#777777",
|
||||
"--bgcolor": "#f6f6f6",
|
||||
"--panel-color": "#444446",
|
||||
"--panel-bgcolor": "#ffffff",
|
||||
"--panel-border-color": "#eaeaea",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const xmindColorPresets = [
|
||||
// 经典16色
|
||||
"#FFFFFF",
|
||||
"#F5F5F5", // 白色系
|
||||
"#2196F3",
|
||||
"#1976D2", // 蓝色系
|
||||
"#4CAF50",
|
||||
"#388E3C", // 绿色系
|
||||
"#FF9800",
|
||||
"#F57C00", // 橙色系
|
||||
"#F44336",
|
||||
"#D32F2F", // 红色系
|
||||
"#9C27B0",
|
||||
"#7B1FA2", // 紫色系
|
||||
"#424242",
|
||||
"#757575", // 灰色系
|
||||
"#FFEB3B",
|
||||
"#FBC02D", // 黄色系
|
||||
];
|
|
@ -1,42 +0,0 @@
|
|||
interface QuillCharCounterProps {
|
||||
currentCount: number;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
||||
currentCount,
|
||||
maxLength,
|
||||
minLength = 0
|
||||
}) => {
|
||||
const getStatusColor = () => {
|
||||
if (currentCount > (maxLength || Infinity)) return 'text-red-500';
|
||||
if (currentCount < minLength) return 'text-amber-500';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
flex items-center justify-end gap-1
|
||||
px-3 py-1.5 text-sm
|
||||
${getStatusColor()}
|
||||
transition-colors duration-200
|
||||
`}>
|
||||
<span className="font-medium tabular-nums">{currentCount}</span>
|
||||
{maxLength && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="tabular-nums">{maxLength}</span>
|
||||
</>
|
||||
)}
|
||||
<span>字符</span>
|
||||
{minLength > 0 && currentCount < minLength && (
|
||||
<span className="ml-2 text-amber-500">
|
||||
至少输入 {minLength} 字符
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuillCharCounter
|
|
@ -1,198 +0,0 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css"; // 引入默认样式
|
||||
import QuillCharCounter from "./QuillCharCounter";
|
||||
import { defaultModules } from "./constants";
|
||||
|
||||
interface QuillEditorProps {
|
||||
value?: string;
|
||||
onChange?: (content: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
theme?: "snow" | "bubble";
|
||||
modules?: any;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent) => void;
|
||||
onKeyUp?: (event: KeyboardEvent) => void;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}
|
||||
const QuillEditor: React.FC<QuillEditorProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "请输入内容...",
|
||||
readOnly = false,
|
||||
theme = "snow",
|
||||
modules = defaultModules,
|
||||
className = "",
|
||||
style = {},
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
maxLength,
|
||||
minLength = 0,
|
||||
minRows = 1,
|
||||
maxRows,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const quillRef = useRef<Quill | null>(null);
|
||||
const isMounted = useRef(false);
|
||||
const [charCount, setCharCount] = useState(0); // 添加字符计数状态
|
||||
const handleTextChange = () => {
|
||||
if (!quillRef.current) return;
|
||||
const editor = quillRef.current;
|
||||
// 获取文本并处理换行符
|
||||
const text = editor.getText().replace(/\n$/, "");
|
||||
const textLength = text.length;
|
||||
|
||||
// 处理最大长度限制
|
||||
if (maxLength && textLength > maxLength) {
|
||||
// 暂时移除事件监听器
|
||||
editor.off("text-change", handleTextChange);
|
||||
|
||||
// 获取当前选区
|
||||
const selection = editor.getSelection();
|
||||
const delta = editor.getContents();
|
||||
let length = 0;
|
||||
const newDelta = delta.ops?.reduce((acc: any, op: any) => {
|
||||
if (typeof op.insert === "string") {
|
||||
const remainingLength = maxLength - length;
|
||||
if (length < maxLength) {
|
||||
const truncatedText = op.insert.slice(
|
||||
0,
|
||||
remainingLength
|
||||
);
|
||||
length += truncatedText.length;
|
||||
acc.push({ ...op, insert: truncatedText });
|
||||
}
|
||||
} else {
|
||||
acc.push(op);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
// 更新内容
|
||||
editor.setContents({ ops: newDelta } as any);
|
||||
// 恢复光标位置
|
||||
if (selection) {
|
||||
editor.setSelection(Math.min(selection.index, maxLength));
|
||||
}
|
||||
// 重新计算截断后的实际长度
|
||||
const finalText = editor.getText().replace(/\n$/, "");
|
||||
setCharCount(finalText.length);
|
||||
|
||||
// 重新绑定事件监听器
|
||||
editor.on("text-change", handleTextChange);
|
||||
} else {
|
||||
// 如果没有超出最大长度,直接更新字符计数
|
||||
setCharCount(textLength);
|
||||
}
|
||||
|
||||
onChange?.(quillRef.current.root.innerHTML);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
if (!isMounted.current) {
|
||||
// 初始化 Quill 编辑器
|
||||
quillRef.current = new Quill(editorRef.current, {
|
||||
theme,
|
||||
modules,
|
||||
placeholder,
|
||||
readOnly,
|
||||
});
|
||||
// 设置初始内容
|
||||
quillRef.current.root.innerHTML = value;
|
||||
if (onFocus) {
|
||||
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
|
||||
if (range) {
|
||||
onFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (onBlur) {
|
||||
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
|
||||
if (!range) {
|
||||
onBlur();
|
||||
}
|
||||
});
|
||||
}
|
||||
quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange);
|
||||
if (onKeyDown) {
|
||||
quillRef.current.root.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
if (onKeyUp) {
|
||||
quillRef.current.root.addEventListener("keyup", onKeyUp);
|
||||
}
|
||||
isMounted.current = true;
|
||||
}
|
||||
}, [
|
||||
theme,
|
||||
modules,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
maxLength,
|
||||
minLength,
|
||||
]); // 添加所有相关的依赖
|
||||
useEffect(() => {
|
||||
if (quillRef.current) {
|
||||
const editor = editorRef.current?.querySelector(
|
||||
".ql-editor"
|
||||
) as HTMLElement;
|
||||
if (editor) {
|
||||
const lineHeight = parseInt(
|
||||
window.getComputedStyle(editor).lineHeight,
|
||||
10
|
||||
);
|
||||
const paddingTop = parseInt(
|
||||
window.getComputedStyle(editor).paddingTop,
|
||||
10
|
||||
);
|
||||
const paddingBottom = parseInt(
|
||||
window.getComputedStyle(editor).paddingBottom,
|
||||
10
|
||||
);
|
||||
const minHeight =
|
||||
lineHeight * minRows + paddingTop + paddingBottom;
|
||||
editor.style.minHeight = `${minHeight}px`;
|
||||
if (maxRows) {
|
||||
const maxHeight =
|
||||
lineHeight * maxRows + paddingTop + paddingBottom;
|
||||
editor.style.maxHeight = `${maxHeight}px`;
|
||||
editor.style.overflowY = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [minRows, maxRows, quillRef.current]);
|
||||
|
||||
// 监听 value 属性变化
|
||||
useEffect(() => {
|
||||
if (quillRef.current && value !== quillRef.current.root.innerHTML) {
|
||||
quillRef.current.root.innerHTML = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={`quill-editor-container ${className}`} style={style}>
|
||||
<div ref={editorRef} />
|
||||
{(maxLength || minLength > 0) && (
|
||||
<QuillCharCounter
|
||||
currentCount={charCount}
|
||||
maxLength={maxLength}
|
||||
minLength={minLength}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuillEditor;
|
|
@ -1,11 +0,0 @@
|
|||
export const defaultModules = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ align: [] }],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
import { MindElixirInstance, MindElixirData } from 'mind-elixir';
|
||||
import { PostType, ObjectType } from '@nice/common';
|
||||
|
||||
export interface MindEditorProps {
|
||||
initialData?: MindElixirData;
|
||||
onSave?: (data: MindElixirData) => Promise<void>;
|
||||
taxonomyType?: ObjectType;
|
||||
}
|
||||
|
||||
export interface MindEditorState {
|
||||
instance: MindElixirInstance | null;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
|
@ -3,6 +3,7 @@ import { useRef, useState } from 'react';
|
|||
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import FormError from './FormError';
|
||||
import { Button } from '../element/Button';
|
||||
import React from 'react';
|
||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
||||
name: string;
|
||||
label?: string;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import FormError from './FormError';
|
||||
import { useState } from 'react';
|
||||
import QuillEditor from '../editor/quill/QuillEditor';
|
||||
// import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
||||
|
||||
export interface FormQuillInputProps {
|
||||
name: string;
|
||||
|
@ -54,7 +54,7 @@ export function FormQuillInput({
|
|||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||
<div className={containerClasses}>
|
||||
<Controller
|
||||
{/* <Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
|
@ -69,9 +69,9 @@ export function FormQuillInput({
|
|||
readOnly={readOnly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
{/* )} */}
|
||||
{/* /> */}
|
||||
</div>
|
||||
<FormError error={error} />
|
||||
</div>
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
import {
|
||||
CalendarIcon,
|
||||
UserGroupIcon,
|
||||
AcademicCapIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CourseLevelLabel } from "@nice/common";
|
||||
|
||||
interface CourseHeaderProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
thumbnail?: string;
|
||||
level?: string;
|
||||
numberOfStudents?: number;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
export const CourseHeader = ({
|
||||
title,
|
||||
subTitle,
|
||||
thumbnail,
|
||||
level,
|
||||
numberOfStudents,
|
||||
publishedAt,
|
||||
}: CourseHeaderProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{thumbnail && (
|
||||
<div className="relative h-48 w-full">
|
||||
<img src={thumbnail} alt={title} className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
|
||||
{subTitle && <p className="mt-2 text-gray-600 ">{subTitle}</p>}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
|
||||
{level && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AcademicCapIcon className="h-4 w-4" />
|
||||
<span>{CourseLevelLabel[level]}</span>
|
||||
</div>
|
||||
)}
|
||||
{numberOfStudents !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserGroupIcon className="h-4 w-4" />
|
||||
<span>{numberOfStudents} 人学习中</span>
|
||||
</div>
|
||||
)}
|
||||
{publishedAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>{publishedAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import { StarIcon, ChartBarIcon, ClockIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
interface CourseStatsProps {
|
||||
averageRating?: number;
|
||||
numberOfReviews?: number;
|
||||
completionRate?: number;
|
||||
totalDuration?: number;
|
||||
}
|
||||
export const CourseStats = ({
|
||||
averageRating,
|
||||
numberOfReviews,
|
||||
completionRate,
|
||||
totalDuration,
|
||||
}: CourseStatsProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 ">
|
||||
{averageRating !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StarIcon className="h-5 w-5 text-yellow-400" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
{numberOfReviews} 观看量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{completionRate !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChartBarIcon className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
完成率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDuration !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 ">
|
||||
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
总时长
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,18 +0,0 @@
|
|||
import { PostDetailProvider } from "./PostDetailContext";
|
||||
import CourseDetailLayout from "./CourseDetailLayout";
|
||||
|
||||
export default function CourseDetail({
|
||||
id,
|
||||
lectureId,
|
||||
}: {
|
||||
id?: string;
|
||||
lectureId?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PostDetailProvider editId={id}>
|
||||
<CourseDetailLayout></CourseDetailLayout>
|
||||
</PostDetailProvider>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
import { Course, CourseDto, TaxonomySlug } from "@nice/common";
|
||||
import React, { useContext, useEffect, useMemo } from "react";
|
||||
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useStaff } from "@nice/client";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||
import { PictureOutlined } from "@ant-design/icons";
|
||||
|
||||
export const CourseDetailDescription: React.FC = () => {
|
||||
const {
|
||||
post,
|
||||
canEdit,
|
||||
isLoading,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
userIsLearning,
|
||||
lecture = null,
|
||||
} = useContext(CourseDetailContext);
|
||||
const { Paragraph } = Typography;
|
||||
const { user } = useAuth();
|
||||
const { update } = useStaff();
|
||||
const firstLectureId = useMemo(() => {
|
||||
return (post as CourseDto)?.sections?.[0]?.lectures?.[0]?.id;
|
||||
}, [post]);
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
return (
|
||||
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
||||
<div className="w-full px-5 my-2">
|
||||
{isLoading || !post ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!selectedLectureId && (
|
||||
<div className="relative mb-4 overflow-hidden flex justify-center items-center">
|
||||
{
|
||||
<div
|
||||
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
||||
style={{
|
||||
backgroundImage: `url(${post?.meta?.thumbnail || "/placeholder.webp"})`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
onClick={async () => {
|
||||
setSelectedLectureId(firstLectureId);
|
||||
if (!userIsLearning) {
|
||||
await update.mutateAsync({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
learningPosts: {
|
||||
connect: {
|
||||
id: post.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="absolute rounded-xl top-0 left-0 right-0 bottom-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
点击进入学习
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||
<TermInfo terms={post.terms}></TermInfo>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap items-center float-start pl-2">
|
||||
{post?.subTitle && <div>{post?.subTitle}</div>}
|
||||
{/* <TermInfo terms={post.terms}></TermInfo> */}
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-gray-600 pl-2"
|
||||
ellipsis={{
|
||||
rows: 3,
|
||||
expandable: true,
|
||||
symbol: "展开",
|
||||
onExpand: () => console.log("展开"),
|
||||
}}>
|
||||
{post?.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,88 +0,0 @@
|
|||
// components/CourseDetailDisplayArea.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import { CourseDetailDescription } from "./CourseDetailDescription";
|
||||
import { Course, LectureType, PostType } from "@nice/common";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||
import { Skeleton } from "antd";
|
||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import CourseDetailTitle from "./CourseDetailTitle";
|
||||
import ReactPlayer from "react-player";
|
||||
export const CourseDetailDisplayArea: React.FC = () => {
|
||||
// 创建滚动动画效果
|
||||
const {
|
||||
|
||||
isLoading,
|
||||
canEdit,
|
||||
lecture,
|
||||
lectureIsLoading,
|
||||
selectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
const navigate = useNavigate();
|
||||
const { scrollY } = useScroll();
|
||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 固定的视频区域 */}
|
||||
{lectureIsLoading && (
|
||||
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
||||
)}
|
||||
<CourseDetailTitle></CourseDetailTitle>
|
||||
{selectedLectureId &&
|
||||
!lectureIsLoading &&
|
||||
lecture?.meta?.type === LectureType.VIDEO && (
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full mt-2 px-4">
|
||||
<motion.div
|
||||
style={{
|
||||
opacity: videoOpacity,
|
||||
}}
|
||||
className="w-full bg-black rounded-lg ">
|
||||
<div className=" w-full cursor-pointer">
|
||||
{/* <ReactPlayer
|
||||
url={lecture?.meta?.videoUrl}
|
||||
controls={true}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={(error) => {
|
||||
console.log(error);
|
||||
}}
|
||||
/> */}
|
||||
<VideoPlayer src={lecture?.meta?.videoUrl} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{!lectureIsLoading &&
|
||||
selectedLectureId &&
|
||||
(
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
|
||||
<div className="w-full rounded-lg ">
|
||||
{lecture?.meta?.type === LectureType.ARTICLE && (
|
||||
<CollapsibleContent
|
||||
content={lecture?.content || ""}
|
||||
maxHeight={500} // Optional, defaults to 150
|
||||
/>
|
||||
)}
|
||||
<div className="px-6">
|
||||
<ResourcesShower
|
||||
resources={
|
||||
lecture?.resources
|
||||
}
|
||||
isShowImage = {lecture?.meta?.type === LectureType.ARTICLE}
|
||||
></ResourcesShower>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
|
||||
<CourseDetailDescription />
|
||||
</div>
|
||||
{/* 课程内容区域 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseDetailDisplayArea;
|
|
@ -1,46 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useContext, useState } from "react";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
|
||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||
import { CourseDto } from "packages/common/dist";
|
||||
|
||||
export default function CourseDetailLayout() {
|
||||
const {
|
||||
post,
|
||||
|
||||
setSelectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
|
||||
const handleLectureClick = (lectureId: string) => {
|
||||
setSelectedLectureId(lectureId);
|
||||
};
|
||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pt-12 px-32">
|
||||
{" "}
|
||||
{/* 添加这个包装 div */}
|
||||
<motion.div
|
||||
initial={{
|
||||
width: "75%",
|
||||
}}
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "75%" : "100%",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative">
|
||||
<CourseDetailDisplayArea />
|
||||
</motion.div>
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={(post as CourseDto)?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import {
|
||||
SkeletonItem,
|
||||
SkeletonSection,
|
||||
} from "@web/src/components/presentation/Skeleton";
|
||||
import { api } from "packages/client/dist";
|
||||
|
||||
export const CourseDetailSkeleton = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 标题骨架屏 */}
|
||||
<div className="space-y-4">
|
||||
<SkeletonItem className="h-9 w-3/4" />
|
||||
<SkeletonItem className="h-6 w-1/2" delay={0.2} />
|
||||
</div>
|
||||
|
||||
{/* 描述骨架屏 */}
|
||||
<SkeletonSection items={2} />
|
||||
|
||||
{/* 学习目标骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 适合人群骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 技能骨架屏 */}
|
||||
<div>
|
||||
<SkeletonItem className="h-6 w-32 mb-4" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonItem
|
||||
key={i}
|
||||
className="h-8 w-20 rounded-full"
|
||||
delay={i * 0.2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseDetailSkeleton;
|
|
@ -1,68 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
EditTwoTone,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import CourseOperationBtns from "./CourseOperationBtns/CourseOperationBtns";
|
||||
|
||||
export default function CourseDetailTitle() {
|
||||
const {
|
||||
post: course,
|
||||
lecture,
|
||||
selectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
||||
<div className="flex justify-start w-full text-2xl font-bold ">
|
||||
{!selectedLectureId ? course?.title : `${course?.title} : ${lecture?.title}`}
|
||||
</div>
|
||||
<div className="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||
{course?.depts && course?.depts?.length > 0 && (
|
||||
<div>
|
||||
发布单位:
|
||||
{course?.depts?.map((dept) => dept.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||
<div className="flex gap-1">
|
||||
<CalendarOutlined></CalendarOutlined>
|
||||
{"发布于:"}
|
||||
{dayjs(
|
||||
!selectedLectureId
|
||||
? course?.createdAt
|
||||
: lecture?.createdAt
|
||||
).format("YYYY年M月D日")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{"最后更新:"}
|
||||
{dayjs(
|
||||
!selectedLectureId
|
||||
? course?.updatedAt
|
||||
: lecture?.updatedAt
|
||||
).format("YYYY年M月D日")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<EyeOutlined></EyeOutlined>
|
||||
<div>{`观看次数${
|
||||
!selectedLectureId
|
||||
? course?.views || 0
|
||||
: lecture?.views || 0
|
||||
}`}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<BookOutlined />
|
||||
<div>{`学习人数${course?.studentIds?.length || 0}`}</div>
|
||||
</div>
|
||||
<CourseOperationBtns />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
import { api } from "@nice/client";
|
||||
import {
|
||||
DeleteTwoTone,
|
||||
EditTwoTone,
|
||||
ExclamationCircleFilled,
|
||||
} from "@ant-design/icons";
|
||||
import toast from "react-hot-toast";
|
||||
import JoinButton from "./JoinButton";
|
||||
import { Modal } from "antd";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
export default function CourseOperationBtns() {
|
||||
const navigate = useNavigate();
|
||||
const { post, canEdit } = useContext(CourseDetailContext);
|
||||
return (
|
||||
<>
|
||||
<JoinButton></JoinButton>
|
||||
{canEdit && (
|
||||
<>
|
||||
<div
|
||||
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
const url = post?.id
|
||||
? `/course/${post?.id}/editor`
|
||||
: "/course/editor";
|
||||
navigate(url);
|
||||
}}>
|
||||
<EditTwoTone></EditTwoTone>
|
||||
{"编辑课程"}
|
||||
</div>
|
||||
</>
|
||||
|
||||
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useStaff } from "@nice/client";
|
||||
import { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoginOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export default function JoinButton() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { post, canEdit, userIsLearning, setUserIsLearning } =
|
||||
useContext(CourseDetailContext);
|
||||
const { update } = useStaff();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleLearning = async () => {
|
||||
if (!userIsLearning) {
|
||||
await update.mutateAsync({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
learningPosts: {
|
||||
connect: { id: post.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
setUserIsLearning(true);
|
||||
toast.success("加入学习成功");
|
||||
} else {
|
||||
await update.mutateAsync({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
learningPosts: {
|
||||
disconnect: {
|
||||
id: post.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success("退出学习成功");
|
||||
setUserIsLearning(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<div
|
||||
onClick={toggleLearning}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${
|
||||
userIsLearning
|
||||
? isHovered
|
||||
? "text-red-500 border-red-500 rounded-md "
|
||||
: "text-green-500 "
|
||||
: "text-primary "
|
||||
}`}>
|
||||
{userIsLearning ? (
|
||||
isHovered ? (
|
||||
<CloseCircleOutlined />
|
||||
) : (
|
||||
<CheckCircleOutlined />
|
||||
)
|
||||
) : (
|
||||
<LoginOutlined />
|
||||
)}
|
||||
<span>
|
||||
{userIsLearning
|
||||
? isHovered
|
||||
? "退出学习"
|
||||
: "正在学习"
|
||||
: "加入学习"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import { useContext, useEffect } from "react";
|
||||
import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts";
|
||||
import { Button, Tabs, Image, Skeleton } from "antd";
|
||||
import type { TabsProps } from "antd";
|
||||
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
export function CoursePreview() {
|
||||
const { post, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
return (
|
||||
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
||||
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||
<Image
|
||||
src={isLoading ? "error" : post?.meta?.thumbnail}
|
||||
alt="example"
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg=="
|
||||
/>
|
||||
<div className="w-[600px] h-[360px] absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between w-2/5 content-start h-[340px] my-4 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<Skeleton className="my-5" active />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold my-3 ">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||
{post.subTitle}
|
||||
</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||
{post.content}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button block type="primary" size="large">
|
||||
{" "}
|
||||
查看课程{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { Checkbox, List } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export function CoursePreviewTabmsg({data}){
|
||||
|
||||
|
||||
const renderItem = (item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
return(
|
||||
<div className='my-2'>
|
||||
<List
|
||||
dataSource={data}
|
||||
split={false}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import type { MenuProps } from 'antd';
|
||||
import { Menu } from 'antd';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
export function CourseCatalog(){
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import { BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
interface CollapsedButtonProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CollapsedButton: React.FC<CollapsedButtonProps> = ({
|
||||
onToggle,
|
||||
}) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onToggle}
|
||||
className="p-2 bg-white rounded-l-lg shadow-lg hover:bg-gray-100">
|
||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
);
|
|
@ -1,93 +0,0 @@
|
|||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
PlayCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { SectionDto, TaxonomySlug } from "@nice/common";
|
||||
import { SyllabusHeader } from "./SyllabusHeader";
|
||||
import { SectionItem } from "./SectionItem";
|
||||
import { CollapsedButton } from "./CollapsedButton";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[];
|
||||
onLectureClick?: (lectureId: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const { isHeaderVisible } = useContext(CourseDetailContext);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(
|
||||
sections.map((section) => section.id) // 默认展开所有章节
|
||||
);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter((id) => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
// 直接滚动,无需延迟
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* 收起按钮直接显示 */}
|
||||
{!isOpen && (
|
||||
<div className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||
<CollapsedButton onToggle={onToggle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: isOpen ? "25%" : "0",
|
||||
right: 0,
|
||||
top: isHeaderVisible ? "56px" : "0",
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
{isOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<SyllabusHeader onToggle={onToggle} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, index) => (
|
||||
<SectionItem
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[section.id] =
|
||||
el)
|
||||
}
|
||||
index={index + 1}
|
||||
section={section}
|
||||
isExpanded={expandedSections.includes(
|
||||
section.id
|
||||
)}
|
||||
onToggle={toggleSection}
|
||||
onLectureClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,65 +0,0 @@
|
|||
// components/CourseSyllabus/LectureItem.tsx
|
||||
|
||||
import { Lecture, LectureType, LessonTypeLabel } from "@nice/common";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
EyeOutlined,
|
||||
FileTextOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface LectureItemProps {
|
||||
lecture: Lecture;
|
||||
onClick: (lectureId: string) => void;
|
||||
}
|
||||
|
||||
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||
lecture,
|
||||
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 text-left transition-colors cursor-pointer
|
||||
${
|
||||
isReading
|
||||
? "bg-blue-50 border-l-4 border-blue-500 hover:bg-blue-50"
|
||||
: "hover:bg-gray-200"
|
||||
}`}
|
||||
onClick={() => onClick(lecture.id)}>
|
||||
{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?.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 flex justify-between items-center w-2/3 realative">
|
||||
<h4 className="font-medium text-gray-800 w-4/5">
|
||||
{lecture.title}
|
||||
</h4>
|
||||
{lecture.subTitle && (
|
||||
<span className="text-sm text-gray-500 mt-1 w-4/5">
|
||||
{lecture.subTitle}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-gray-500 whitespace-normal">
|
||||
<EyeOutlined></EyeOutlined>
|
||||
<span className="ml-2">
|
||||
{lecture?.views ? lecture?.views : 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,79 +0,0 @@
|
|||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useMemo } from "react";
|
||||
import { LectureItem } from "./LectureItem";
|
||||
import { useParams } from "react-router-dom";
|
||||
interface SectionItemProps {
|
||||
section: SectionDto;
|
||||
index?: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: (sectionId: string) => void;
|
||||
onLectureClick: (lectureId: string) => void;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||
({ 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -1,18 +0,0 @@
|
|||
// components/CourseSyllabus/SyllabusHeader.tsx
|
||||
import React from "react";
|
||||
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
interface SyllabusHeaderProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const SyllabusHeader: React.FC<SyllabusHeaderProps> = ({ onToggle }) => (
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
export * from "./CourseSyllabus";
|
|
@ -1,131 +0,0 @@
|
|||
import { api, useVisitor } from "@nice/client";
|
||||
import {
|
||||
courseDetailSelect,
|
||||
CourseDto,
|
||||
Lecture,
|
||||
lectureDetailSelect,
|
||||
PostDto,
|
||||
RolePerms,
|
||||
VisitType,
|
||||
} from "@nice/common";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
post?: PostDto;
|
||||
lecture?: Lecture;
|
||||
selectedLectureId?: string | undefined;
|
||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isLoading?: boolean;
|
||||
lectureIsLoading?: boolean;
|
||||
isHeaderVisible: boolean; // 新增
|
||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||
canEdit?: boolean;
|
||||
userIsLearning?: boolean;
|
||||
setUserIsLearning: (learning: boolean) => void;
|
||||
}
|
||||
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
}
|
||||
|
||||
export const CourseDetailContext =
|
||||
createContext<CourseDetailContextType | null>(null);
|
||||
export function PostDetailProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { read } = useVisitor();
|
||||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||
const { lectureId } = useParams();
|
||||
|
||||
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
|
||||
api.post as any
|
||||
).findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
select: courseDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
const [userIsLearning, setUserIsLearning] = useState(false);
|
||||
useEffect(() => {
|
||||
setUserIsLearning((post?.studentIds || []).includes(user?.id));
|
||||
}, [user, post, isLoading]);
|
||||
const canEdit = useMemo(() => {
|
||||
const isAuthor = isAuthenticated && user?.id === post?.authorId;
|
||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||
return isAuthor || isRoot;
|
||||
}, [user, post]);
|
||||
|
||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
>(lectureId || undefined);
|
||||
const { data: lecture, isLoading: lectureIsLoading } = (
|
||||
api.post as any
|
||||
).findFirst.useQuery(
|
||||
{
|
||||
where: { id: selectedLectureId },
|
||||
select: lectureDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (lectureId) {
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: lectureId,
|
||||
type: VisitType.READED,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: editId,
|
||||
type: VisitType.READED,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [editId, lectureId]);
|
||||
useEffect(() => {
|
||||
if (lectureId !== selectedLectureId) {
|
||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||
}
|
||||
}, [selectedLectureId, editId]);
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||
useEffect(() => {
|
||||
console.log("post", post);
|
||||
}, [post]);
|
||||
return (
|
||||
<CourseDetailContext.Provider
|
||||
value={{
|
||||
editId,
|
||||
post,
|
||||
lecture,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
isLoading,
|
||||
lectureIsLoading,
|
||||
isHeaderVisible,
|
||||
setIsHeaderVisible,
|
||||
canEdit,
|
||||
userIsLearning,
|
||||
setUserIsLearning,
|
||||
}}>
|
||||
{children}
|
||||
</CourseDetailContext.Provider>
|
||||
);
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||
import { Form, FormInstance, message } from "antd";
|
||||
import {
|
||||
courseDetailSelect,
|
||||
CourseDto,
|
||||
CourseMeta,
|
||||
CourseStatus,
|
||||
ObjectType,
|
||||
PostType,
|
||||
Taxonomy,
|
||||
} from "@nice/common";
|
||||
import { api, usePost } from "@nice/client";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export type CourseFormData = {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
content?: string;
|
||||
thumbnail?: string;
|
||||
requirements?: string[];
|
||||
objectives?: string[];
|
||||
sections: any;
|
||||
};
|
||||
|
||||
interface CourseEditorContextType {
|
||||
onSubmit: (values: CourseFormData) => Promise<void>;
|
||||
handleDeleteCourse: () => Promise<void>;
|
||||
editId?: string;
|
||||
course?: CourseDto;
|
||||
taxonomies?: Taxonomy[]; // 根据实际类型调整
|
||||
form: FormInstance<CourseFormData>; // 添加 form 到上下文
|
||||
}
|
||||
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string;
|
||||
}
|
||||
|
||||
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
||||
|
||||
export function CourseFormProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const [form] = Form.useForm<CourseFormData>();
|
||||
const { create, update, createCourse } = usePost();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
select: courseDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
const {
|
||||
data: taxonomies,
|
||||
}: {
|
||||
data: Taxonomy[];
|
||||
} = api.taxonomy.getAll.useQuery({
|
||||
type: ObjectType.COURSE,
|
||||
});
|
||||
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
|
||||
onSuccess:()=>{
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
|
||||
}
|
||||
})
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (course) {
|
||||
const deptIds = (course?.depts || [])?.map((dept) => dept.id);
|
||||
const formData = {
|
||||
title: course.title,
|
||||
subTitle: course.subTitle,
|
||||
content: course.content,
|
||||
deptIds: deptIds,
|
||||
meta: {
|
||||
thumbnail: course?.meta?.thumbnail,
|
||||
},
|
||||
};
|
||||
|
||||
// 按 taxonomyId 分组所有 terms
|
||||
const termsByTaxonomy = {};
|
||||
course.terms?.forEach((term) => {
|
||||
if (!termsByTaxonomy[term.taxonomyId]) {
|
||||
termsByTaxonomy[term.taxonomyId] = [];
|
||||
}
|
||||
termsByTaxonomy[term.taxonomyId].push(term.id);
|
||||
});
|
||||
|
||||
// 将分组后的 terms 设置到 formData
|
||||
Object.entries(termsByTaxonomy).forEach(([taxonomyId, termIds]) => {
|
||||
formData[taxonomyId] = termIds;
|
||||
});
|
||||
|
||||
form.setFieldsValue(formData);
|
||||
}
|
||||
}, [course, form]);
|
||||
const handleDeleteCourse = async () => {
|
||||
if(editId){
|
||||
await softDeletePostDescendant.mutateAsync({
|
||||
ancestorId: editId,
|
||||
});
|
||||
|
||||
navigate("/courses");
|
||||
}
|
||||
}
|
||||
const onSubmit = async (values: any) => {
|
||||
const sections = values?.sections || [];
|
||||
const deptIds = values?.deptIds || [];
|
||||
const termIds = taxonomies
|
||||
.flatMap((tax) => values[tax.id] || []) // 获取每个 taxonomy 对应的选中值并展平
|
||||
.filter((id) => id); // 过滤掉空值
|
||||
|
||||
const formattedValues = {
|
||||
...values,
|
||||
type: PostType.COURSE,
|
||||
meta: {
|
||||
...((course?.meta as CourseMeta) || {}),
|
||||
...(values?.meta?.thumbnail !== undefined && {
|
||||
thumbnail: values?.meta?.thumbnail,
|
||||
}),
|
||||
},
|
||||
terms:
|
||||
termIds?.length > 0
|
||||
? {
|
||||
[editId ? "set" : "connect"]: termIds.map((id) => ({
|
||||
id,
|
||||
})), // 转换成 connect 格式
|
||||
}
|
||||
: undefined,
|
||||
depts:
|
||||
deptIds?.length > 0
|
||||
? {
|
||||
[editId ? "set" : "connect"]: deptIds.map((id) => ({
|
||||
id,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// 删除原始的 taxonomy 字段
|
||||
taxonomies.forEach((tax) => {
|
||||
delete formattedValues[tax.id];
|
||||
});
|
||||
delete formattedValues.sections;
|
||||
delete formattedValues.deptIds;
|
||||
|
||||
try {
|
||||
if (editId) {
|
||||
const result = await update.mutateAsync({
|
||||
where: { id: editId },
|
||||
data: formattedValues,
|
||||
});
|
||||
message.success("课程更新成功!");
|
||||
navigate(`/course/${result.id}/editor/content`);
|
||||
} else {
|
||||
const result = await createCourse.mutateAsync({
|
||||
courseDetail: {
|
||||
data: {
|
||||
title: formattedValues.title,
|
||||
|
||||
// state: CourseStatus.DRAFT,
|
||||
type: PostType.COURSE,
|
||||
...formattedValues,
|
||||
},
|
||||
},
|
||||
sections,
|
||||
});
|
||||
message.success("课程创建成功!");
|
||||
navigate(`/course/${result.id}/editor/content`);
|
||||
}
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
message.error("操作失败,请重试!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CourseEditorContext.Provider
|
||||
value={{
|
||||
onSubmit,
|
||||
editId,
|
||||
course,
|
||||
taxonomies,
|
||||
form,
|
||||
handleDeleteCourse
|
||||
}}>
|
||||
<Form
|
||||
// requiredMark="optional"
|
||||
variant="filled"
|
||||
form={form}
|
||||
onFinish={onSubmit}
|
||||
initialValues={{
|
||||
requirements: [],
|
||||
objectives: [],
|
||||
}}>
|
||||
{children}
|
||||
</Form>
|
||||
</CourseEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useCourseEditor = () => {
|
||||
const context = useContext(CourseEditorContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCourseEditor must be used within CourseFormProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,76 +0,0 @@
|
|||
import { Form, Input, Select } from "antd";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
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;
|
||||
|
||||
export function CourseBasicForm() {
|
||||
// 将 CourseLevelLabel 使用 Object.entries 将 CourseLevelLabel 对象转换为键值对数组。
|
||||
const levelOptions = Object.entries(CourseLevelLabel).map(
|
||||
([key, value]) => ({
|
||||
label: value,
|
||||
value: key as CourseLevel,
|
||||
})
|
||||
);
|
||||
|
||||
const { form, taxonomies } = useCourseEditor();
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="课程标题"
|
||||
rules={[
|
||||
{ required: true, message: "请输入课程标题" },
|
||||
{ max: 20, message: "标题最多20个字符" },
|
||||
]}>
|
||||
<Input placeholder="请输入课程标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
rules={[{ max: 20, message: "副标题最多20个字符" }]}>
|
||||
<Input placeholder="请输入课程副标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name={["meta", "thumbnail"]} label="课程封面">
|
||||
<AvatarUploader
|
||||
style={{
|
||||
width: "192px",
|
||||
height: "108px",
|
||||
margin: " 0 10px",
|
||||
}}></AvatarUploader>
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="课程描述">
|
||||
<TextArea
|
||||
placeholder="请输入课程描述"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="deptIds" label="参与单位">
|
||||
<DepartmentSelect multiple />
|
||||
</Form.Item>
|
||||
{taxonomies &&
|
||||
taxonomies.map((tax, index) => (
|
||||
<Form.Item
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
message: "",
|
||||
},
|
||||
]}
|
||||
label={tax.name}
|
||||
name={tax.id}
|
||||
key={index}>
|
||||
<TermSelect
|
||||
maxTagCount={4}
|
||||
multiple
|
||||
placeholder={`请选择${tax.name}`}
|
||||
taxonomyId={tax.id}></TermSelect>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,137 +0,0 @@
|
|||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { PostType } from "@nice/common";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import { CourseContentFormHeader } from "./CourseContentFormHeader";
|
||||
import { CourseSectionEmpty } from "./CourseSectionEmpty";
|
||||
import { SortableSection } from "./SortableSection";
|
||||
import { LectureList } from "./LectureList";
|
||||
import toast from "react-hot-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
const CourseContentForm: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { editId } = useCourseEditor();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const { softDeleteByIds, updateOrderByIds } = usePost();
|
||||
const { data: sections = [], isLoading } = api.post.findMany.useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: editId,
|
||||
type: PostType.SECTION,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
order: "asc",
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!editId,
|
||||
}
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any[]>(sections);
|
||||
useEffect(() => {
|
||||
if (sections && !isLoading) {
|
||||
setItems(sections);
|
||||
}
|
||||
}, [sections]);
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
let newItems = [];
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
newItems = arrayMove(items, oldIndex, newIndex);
|
||||
return newItems;
|
||||
});
|
||||
updateOrderByIds.mutateAsync({
|
||||
ids: newItems.map((item) => item.id),
|
||||
});
|
||||
};
|
||||
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
|
||||
onSuccess:()=>{
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<CourseContentFormHeader />
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CourseSectionEmpty />
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items?.map((section, index) => (
|
||||
<SortableSection
|
||||
courseId={editId}
|
||||
key={section.id}
|
||||
field={section}
|
||||
remove={async () => {
|
||||
if (section?.id) {
|
||||
await softDeletePostDescendant.mutateAsync({
|
||||
ancestorId: section.id,
|
||||
});
|
||||
}
|
||||
setItems(sections);
|
||||
}}>
|
||||
<LectureList
|
||||
field={section}
|
||||
sectionId={section.id}
|
||||
/>
|
||||
</SortableSection>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
if (items.some((item) => item.id === null)) {
|
||||
toast.error("请先保存当前编辑的章节");
|
||||
} else {
|
||||
setItems([...items, { id: null, title: "" }]);
|
||||
}
|
||||
}}>
|
||||
添加章节
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseContentForm;
|
|
@ -1,35 +0,0 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
|
||||
export const CourseContentFormHeader = () => (
|
||||
<Alert
|
||||
type="info"
|
||||
message="创建您的课程大纲"
|
||||
description={
|
||||
<>
|
||||
<p>通过组织清晰的章节和课时,帮助学员更好地学习。建议:</p>
|
||||
<ul className="mt-2 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
className="mb-8"
|
||||
/>
|
||||
);
|
|
@ -1,11 +0,0 @@
|
|||
import { PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
export const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-gray-500">
|
||||
<PlusOutlined className="text-4xl mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -1,164 +0,0 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api, emitDataChange } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
Lecture,
|
||||
lectureDetailSelect,
|
||||
LectureType,
|
||||
PostType,
|
||||
} from "@nice/common";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import { LectureData, SectionData } from "./interface";
|
||||
import { SortableLecture } from "./SortableLecture";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface LectureListProps {
|
||||
field: SectionData;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
export const LectureList: React.FC<LectureListProps> = ({
|
||||
field,
|
||||
sectionId,
|
||||
}) => {
|
||||
const { softDeleteByIds, updateOrderByIds } = usePost();
|
||||
const { data: lectures = [], isLoading } = (
|
||||
api.post.findMany as any
|
||||
).useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: sectionId,
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
order: "asc",
|
||||
},
|
||||
select: lectureDetailSelect,
|
||||
},
|
||||
{
|
||||
enabled: !!sectionId,
|
||||
}
|
||||
);
|
||||
|
||||
// 用 lectures 初始化 items 状态
|
||||
const [items, setItems] = useState<Lecture[]>(lectures);
|
||||
|
||||
// 当 lectures 变化时更新 items
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setItems(lectures);
|
||||
}
|
||||
}, [lectures, isLoading]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
// updateOrder.mutateAsync({
|
||||
// id: active.id,
|
||||
// overId: over.id,
|
||||
// });
|
||||
let newItems = [];
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
newItems = arrayMove(items, oldIndex, newIndex);
|
||||
return newItems;
|
||||
});
|
||||
updateOrderByIds.mutateAsync({
|
||||
ids: newItems.map((item) => item.id),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items.map((lecture) => (
|
||||
<SortableLecture
|
||||
key={lecture.id}
|
||||
field={lecture}
|
||||
remove={async () => {
|
||||
if (lecture?.id) {
|
||||
await softDeleteByIds.mutateAsync({
|
||||
ids: [lecture.id],
|
||||
});
|
||||
}
|
||||
setItems(lectures);
|
||||
}}
|
||||
sectionFieldKey={sectionId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
if (items.some((item) => item.id === null)) {
|
||||
toast.error("请先保存当前编辑中的课时!");
|
||||
} else {
|
||||
setItems((prevItems) => [
|
||||
...prevItems.filter((item) => !!item.id),
|
||||
{
|
||||
id: null,
|
||||
title: "",
|
||||
meta: {
|
||||
type: LectureType.ARTICLE,
|
||||
},
|
||||
} as Lecture,
|
||||
]);
|
||||
}
|
||||
}}>
|
||||
添加课时
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,305 +0,0 @@
|
|||
import {
|
||||
DragOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
CaretDownOutlined,
|
||||
PaperClipOutlined,
|
||||
PlaySquareOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Form, Button, Input, Select, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import {
|
||||
Lecture,
|
||||
LectureType,
|
||||
LessonTypeLabel,
|
||||
PostType,
|
||||
ResourceStatus,
|
||||
videoMimeTypes,
|
||||
} from "@nice/common";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { env } from "@web/src/env";
|
||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
|
||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||
import { set } from "idb-keyval";
|
||||
|
||||
interface SortableLectureProps {
|
||||
field: Lecture;
|
||||
remove: () => void;
|
||||
sectionFieldKey: string;
|
||||
}
|
||||
export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||
field,
|
||||
remove,
|
||||
sectionFieldKey,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
const { create, update } = usePost();
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field?.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isContentVisible, setIsContentVisible] = useState(false); // State to manage content visibility
|
||||
|
||||
const handleToggleContent = () => {
|
||||
setIsContentVisible(!isContentVisible); // Toggle content visibility
|
||||
};
|
||||
const lectureType =
|
||||
Form.useWatch(["meta", "type"], form) || LectureType.ARTICLE;
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
const fileIds = values?.meta?.fileIds || [];
|
||||
const videoUrlId = Array.isArray(values?.meta?.videoIds)
|
||||
? values?.meta?.videoIds[0]
|
||||
: typeof values?.meta?.videoIds === "string"
|
||||
? values?.meta?.videoIds
|
||||
: undefined;
|
||||
console.log(sectionFieldKey);
|
||||
if (!field.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
parentId: sectionFieldKey,
|
||||
type: PostType.LECTURE,
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fileIds: fileIds,
|
||||
videoIds: videoUrlId ? [videoUrlId] : [],
|
||||
videoUrl: videoUrlId
|
||||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||
: undefined,
|
||||
},
|
||||
resources:
|
||||
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||
? {
|
||||
connect: [videoUrlId, ...fileIds]
|
||||
.filter(Boolean)
|
||||
.map((fileId) => ({
|
||||
fileId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
where: {
|
||||
id: field?.id,
|
||||
},
|
||||
data: {
|
||||
parentId: sectionFieldKey,
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fileIds: fileIds,
|
||||
videoIds: videoUrlId ? [videoUrlId] : [],
|
||||
videoUrl: videoUrlId
|
||||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||
: undefined,
|
||||
},
|
||||
resources:
|
||||
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||
? {
|
||||
set: [videoUrlId, ...fileIds]
|
||||
.filter(Boolean)
|
||||
.map((fileId) => ({
|
||||
fileId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
setIsContentVisible(false);
|
||||
toast.success("课时已更新");
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.success("更新失败");
|
||||
console.log(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="p-4">
|
||||
{editing ? (
|
||||
<Form form={form} initialValues={field}>
|
||||
<div className="flex gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
initialValue={field?.title}
|
||||
className="mb-0 flex-1"
|
||||
rules={[
|
||||
{ required: true, message: "请输入课时标题" },
|
||||
]}>
|
||||
<Input placeholder="课时标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["meta", "type"]}
|
||||
className="mb-0 w-32"
|
||||
rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择类型"
|
||||
options={[
|
||||
{ label: "视频", value: LectureType.VIDEO },
|
||||
{
|
||||
label: "文章",
|
||||
value: LectureType.ARTICLE,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-2 flex-row gap-x-5 ">
|
||||
{lectureType === LectureType.VIDEO ? (
|
||||
<>
|
||||
<div className="mb-0 flex-1">
|
||||
{/* <span className="inline-block w-full h-7 my-1 rounded-lg bg-slate-100 text-center leading-7">添加视频</span> */}
|
||||
<Form.Item
|
||||
name={["meta", "videoIds"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请传入视频",
|
||||
},
|
||||
]}>
|
||||
<TusUploader
|
||||
allowTypes={videoMimeTypes}
|
||||
multiple={false}
|
||||
style={"h-64"}
|
||||
icon={<PlaySquareOutlined />}
|
||||
description="点击或拖拽视频到此区域进行上传"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mb-0 flex-1">
|
||||
{/* <span className="inline-block w-full h-7 my-1 rounded-lg bg-slate-100 text-center leading-7">添加附件</span> */}
|
||||
<Form.Item
|
||||
name={["meta", "fileIds"]}>
|
||||
<TusUploader
|
||||
style={"h-64"}
|
||||
multiple={true}
|
||||
icon={<PaperClipOutlined />}
|
||||
description="点击或拖拽附件到此区域进行上传"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
|
||||
) : (
|
||||
<div>
|
||||
<Form.Item
|
||||
name="content"
|
||||
className="mb-0 flex-1"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入内容",
|
||||
},
|
||||
]}>
|
||||
<QuillEditor
|
||||
style={{
|
||||
width: "700px",
|
||||
}}
|
||||
></QuillEditor>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["meta", "fileIds"]}
|
||||
className="mb-0 flex-1">
|
||||
<TusUploader multiple={true} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
{isContentVisible ? (
|
||||
<CaretDownOutlined onClick={handleToggleContent} />
|
||||
) : (
|
||||
<CaretRightOutlined onClick={handleToggleContent} />
|
||||
)}
|
||||
<span>{LessonTypeLabel[field?.meta?.type]}</span>
|
||||
<span>{field?.title || "未命名课时"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible &&
|
||||
!editing && // Conditionally render content based on type
|
||||
(field?.meta?.type === LectureType.ARTICLE ? (
|
||||
<div>
|
||||
<CollapsibleContent
|
||||
maxHeight={200}
|
||||
content={field?.content}
|
||||
/>
|
||||
<div className="px-6 ">
|
||||
<ResourcesShower
|
||||
resources={field?.resources}></ResourcesShower>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VideoPlayer src={field?.meta?.videoUrl} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,174 +0,0 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Lecture, LectureType, PostType } from "@nice/common";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { LectureData, SectionData } from "./interface";
|
||||
interface SortableSectionProps {
|
||||
courseId?: string;
|
||||
field: SectionData;
|
||||
remove: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SortableSection: React.FC<SortableSectionProps> = ({
|
||||
field,
|
||||
remove,
|
||||
courseId,
|
||||
children,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { create, update } = usePost();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!courseId) {
|
||||
toast.error("课程未创建,请先填写课程基本信息完成创建");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
try {
|
||||
if (!field?.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
title: values?.title,
|
||||
type: PostType.SECTION,
|
||||
parentId: courseId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
where: {
|
||||
id: field?.id,
|
||||
},
|
||||
data: {
|
||||
title: values?.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
message.success("保存成功");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="mb-4">
|
||||
<Collapse>
|
||||
<Collapse.Panel
|
||||
collapsible={!field.id ? "disabled" : undefined} // 添加此行以禁用未创建章节的展开
|
||||
header={
|
||||
editing ? (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex items-center gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
className="mb-0 flex-1"
|
||||
initialValue={field?.title}>
|
||||
<Input placeholder="章节标题" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
type="primary">
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space className=" flex">
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
<span>{field.title || "未命名章节"}</span>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
extra={
|
||||
!editing && (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
key={field.id || "new"}>
|
||||
{children}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,19 +0,0 @@
|
|||
import { LectureType } from "@nice/common";
|
||||
|
||||
export interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
courseId?: string;
|
||||
}
|
||||
|
||||
export interface LectureData {
|
||||
id: string;
|
||||
title: string;
|
||||
meta?: {
|
||||
type?: LectureType;
|
||||
fieldIds?: [];
|
||||
};
|
||||
content?: string;
|
||||
sectionId?: string;
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
import { ArrowLeftOutlined, ClockCircleOutlined, ExclamationCircleFilled } from "@ant-design/icons";
|
||||
import { Button, Modal, Tag, Typography } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CourseStatus, CourseStatusLabel } from "@nice/common";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const courseStatusVariant: Record<CourseStatus, string> = {
|
||||
[CourseStatus.DRAFT]: "default",
|
||||
[CourseStatus.UNDER_REVIEW]: "warning",
|
||||
[CourseStatus.PUBLISHED]: "success",
|
||||
[CourseStatus.ARCHIVED]: "error",
|
||||
};
|
||||
|
||||
export default function CourseEditorHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { user, hasSomePermissions } = useAuth();
|
||||
const { confirm } = Modal;
|
||||
const { onSubmit, course, form, handleDeleteCourse, editId } = useCourseEditor();
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
form.validateFields().then((values) => {
|
||||
onSubmit(values);
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
const showDeleteConfirm = () => {
|
||||
confirm({
|
||||
title: '确定删除该课程吗',
|
||||
icon: <ExclamationCircleFilled />,
|
||||
content: '',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
console.log('OK');
|
||||
console.log(editId)
|
||||
await handleDeleteCourse()
|
||||
toast.success('课程已删除')
|
||||
navigate("/courses");
|
||||
},
|
||||
onCancel() {
|
||||
console.log('Cancel');
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
|
||||
<div className="h-full flex items-center justify-between px-3 md:px-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => {
|
||||
if (course?.id) {
|
||||
navigate(`/course/${course?.id}/detail`);
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{course?.title || "新建课程"}
|
||||
</Title>
|
||||
{/* <Tag
|
||||
color={
|
||||
courseStatusVariant[
|
||||
course?.state || CourseStatus.DRAFT
|
||||
]
|
||||
}>
|
||||
{course?.state
|
||||
? CourseStatusLabel[course.state]
|
||||
: CourseStatusLabel[CourseStatus.DRAFT]}
|
||||
</Tag> */}
|
||||
{/* {course?.duration && (
|
||||
<span className="hidden md:flex items-center text-gray-500 text-sm">
|
||||
<ClockCircleOutlined
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
总时长 {course.duration}
|
||||
</span>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{editId &&
|
||||
<Button
|
||||
danger
|
||||
onClick={showDeleteConfirm}
|
||||
>
|
||||
删除课程
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
className="ml-4"
|
||||
type="primary"
|
||||
// size="small"
|
||||
onClick={handleSave}
|
||||
// disabled={form
|
||||
// .getFieldsError()
|
||||
// .some(({ errors }) => errors.length)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import CourseEditorHeader from "./CourseEditorHeader";
|
||||
import { motion } from "framer-motion";
|
||||
import { NavItem } from "@nice/client";
|
||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
||||
import { CourseFormProvider } from "../context/CourseEditorContext";
|
||||
import { getNavItems } from "../navItems";
|
||||
|
||||
export default function CourseEditorLayout() {
|
||||
const { id } = useParams();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
||||
const [navItems, setNavItems] = useState<NavItem[]>(getNavItems(id));
|
||||
useEffect(() => {
|
||||
setNavItems(getNavItems(id));
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
const index = navItems.findIndex((item) => item.path === currentPath);
|
||||
if (index !== -1) {
|
||||
setSelectedSection(index);
|
||||
}
|
||||
}, [location.pathname, navItems]);
|
||||
const navigate = useNavigate();
|
||||
const handleNavigation = (item: NavItem, index: number) => {
|
||||
setSelectedSection(index);
|
||||
navigate(item.path);
|
||||
};
|
||||
return (
|
||||
<CourseFormProvider editId={id}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<CourseEditorHeader />
|
||||
<div className="flex pt-16">
|
||||
<CourseEditorSidebar
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
navItems={navItems}
|
||||
selectedSection={selectedSection}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
<motion.main
|
||||
animate={{ marginLeft: "16rem" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 25,
|
||||
mass: 1,
|
||||
}}
|
||||
className="flex-1 p-8">
|
||||
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
||||
<header className="p-6 border-b border-gray-100">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{navItems[selectedSection]?.label}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<Outlet></Outlet>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
</div>
|
||||
</div>
|
||||
</CourseFormProvider>
|
||||
);
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { NavItem } from "@nice/client";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
import toast from "react-hot-toast";
|
||||
interface CourseSidebarProps {
|
||||
id?: string | undefined;
|
||||
isHovered: boolean;
|
||||
setIsHovered: (value: boolean) => void;
|
||||
navItems: (NavItem & { isInitialized?: boolean; isCompleted?: boolean })[];
|
||||
selectedSection: number;
|
||||
onNavigate: (
|
||||
item: NavItem & { isInitialized?: boolean; isCompleted?: boolean },
|
||||
index: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function CourseEditorSidebar({
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
navItems,
|
||||
selectedSection,
|
||||
onNavigate,
|
||||
}: CourseSidebarProps) {
|
||||
const { editId } = useCourseEditor();
|
||||
return (
|
||||
<motion.nav
|
||||
animate={{ width: "16rem" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 40 }}
|
||||
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden">
|
||||
<div className="p-4">
|
||||
{navItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (!editId && !item.isInitialized) {
|
||||
e.preventDefault();
|
||||
toast.error("请先完成课程概述填写并保存"); // 提示信息
|
||||
} else {
|
||||
e.preventDefault();
|
||||
onNavigate(item, index);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex ${!isHovered ? "justify-center" : "items-center"} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${
|
||||
selectedSection === index
|
||||
? "bg-blue-50 text-blue-600 shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{
|
||||
<>
|
||||
<span className="ml-3 font-medium flex-1 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.nav>
|
||||
);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export enum CoursePart {
|
||||
OVERVIEW = "overview",
|
||||
TARGET = "target",
|
||||
CONTENT = "content",
|
||||
SETTING = "settings",
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import {
|
||||
AcademicCapIcon,
|
||||
BookOpenIcon,
|
||||
Cog6ToothIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { NavItem } from "@nice/client";
|
||||
|
||||
export const getNavItems = (
|
||||
courseId?: string
|
||||
): (NavItem & { isInitialized?: boolean; isCompleted?: boolean })[] => [
|
||||
{
|
||||
label: "课程概述",
|
||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor`,
|
||||
isInitialized: true,
|
||||
},
|
||||
// {
|
||||
// label: "目标学员",
|
||||
// icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||
// path: `/course/${courseId ? `${courseId}/` : ""}editor/goal`,
|
||||
// isInitialized: false,
|
||||
// },
|
||||
{
|
||||
label: "课程内容",
|
||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/content`,
|
||||
isInitialized: false,
|
||||
},
|
||||
// {
|
||||
// label: "课程设置",
|
||||
// icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
// path: `/course/${courseId ? `${courseId}/` : ""}editor/setting`,
|
||||
// isInitialized: false,
|
||||
// },
|
||||
];
|
|
@ -1,99 +0,0 @@
|
|||
import { Pagination, Empty, Skeleton } from "antd";
|
||||
import { courseDetailSelect, CourseDto, PostDto, Prisma } from "@nice/common";
|
||||
import { api } from "@nice/client";
|
||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
interface PostListProps {
|
||||
params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: Prisma.PostWhereInput;
|
||||
select?: Prisma.PostSelect<DefaultArgs>;
|
||||
};
|
||||
cols?: number;
|
||||
showPagination?: boolean;
|
||||
renderItem: (post: PostDto) => React.ReactNode;
|
||||
}
|
||||
interface PostPagnationProps {
|
||||
data: {
|
||||
items: CourseDto[];
|
||||
totalPages: number;
|
||||
};
|
||||
isLoading: boolean;
|
||||
}
|
||||
export default function PostList({
|
||||
params,
|
||||
cols = 3,
|
||||
showPagination = true,
|
||||
renderItem,
|
||||
}: PostListProps) {
|
||||
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
|
||||
|
||||
const { data, isLoading }: PostPagnationProps =
|
||||
api.post.findManyWithPagination.useQuery({
|
||||
select: courseDetailSelect,
|
||||
...params,
|
||||
page: currentPage,
|
||||
});
|
||||
const totalPages = useMemo(() => {
|
||||
if (data && !isLoading) {
|
||||
return data?.totalPages;
|
||||
}
|
||||
return 1;
|
||||
}, [data, isLoading]);
|
||||
|
||||
const posts = useMemo(() => {
|
||||
if (data && !isLoading) {
|
||||
console.log(data?.items)
|
||||
return data?.items.filter(item=>item.deletedAt === null);
|
||||
}
|
||||
return [];
|
||||
}, [data, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(params?.page || 1);
|
||||
}, [params?.page, params]);
|
||||
function onPageChange(page: number, pageSize: number) {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton paragraph={{ rows: 10 }}></Skeleton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{posts.length > 0 ? (
|
||||
<>
|
||||
<div className={`grid lg:grid-cols-${cols} gap-6`}>
|
||||
{isLoading ? (
|
||||
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<div key={post.id}>{renderItem(post)}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={totalPages * params.pageSize}
|
||||
pageSize={params?.pageSize}
|
||||
onChange={onPageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-64">
|
||||
<Empty description="暂无数据" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { Typography, Button, Empty, Card } from "antd";
|
||||
|
||||
import { PostDto } from "@nice/common";
|
||||
import DeptInfo from "@web/src/app/main/path/components/DeptInfo";
|
||||
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||
import { PictureOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PostCardProps {
|
||||
post?: PostDto;
|
||||
onClick?: (post?: PostDto) => void;
|
||||
}
|
||||
const { Title } = Typography;
|
||||
export default function PostCard({ post, onClick }: PostCardProps) {
|
||||
const handleClick = (post: PostDto) => {
|
||||
onClick?.(post);
|
||||
// 添加平滑滚动到顶部
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth", // 关键参数,启用平滑滚动
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
onClick={() => handleClick(post)}
|
||||
key={post?.id}
|
||||
hoverable
|
||||
className="group overflow-hidden rounded-2xl border border-gray-200 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||
cover={
|
||||
<div className="relative h-56 overflow-hidden group">
|
||||
{post?.meta?.thumbnail ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${post?.meta?.thumbnail})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary-500 to-primary-700">
|
||||
<PictureOutlined className="text-white text-6xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
<div className="px-4 ">
|
||||
<div className="overflow-hidden hover:overflow-auto">
|
||||
<div className="flex gap-2 h-7 whiteSpace-nowrap">
|
||||
<TermInfo terms={post.terms}></TermInfo>
|
||||
</div>
|
||||
</div>
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 mt-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">
|
||||
<button> {post?.title}</button>
|
||||
</Title>
|
||||
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||
<DeptInfo post={post}></DeptInfo>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
shape="round"
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||
{"立即学习"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import { api } from "@nice/client";
|
||||
|
||||
export default function PostSelect() {
|
||||
const { data } = api.post.findMany.useQuery({
|
||||
where: {
|
||||
title: {
|
||||
contains: ""
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
import { api } from "@nice/client";
|
||||
import { Input, Select } from "antd";
|
||||
import { Lecture, postDetailSelect, Prisma } from "@nice/common";
|
||||
import { useMemo, useState } from "react";
|
||||
import PostSelectOption from "./PostSelectOption";
|
||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||
import { safeOR } from "@nice/utils";
|
||||
import { LinkOutlined } from "@ant-design/icons";
|
||||
|
||||
export default function PostSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "请选择课时",
|
||||
params = { where: {}, select: {} },
|
||||
className,
|
||||
createdById,
|
||||
}: {
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
placeholder?: string;
|
||||
params?: {
|
||||
where?: Prisma.PostWhereInput;
|
||||
select?: Prisma.PostSelect<DefaultArgs>;
|
||||
};
|
||||
className?: string;
|
||||
createdById?: string;
|
||||
}) {
|
||||
const [searchValue, setSearch] = useState("");
|
||||
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||
const containTextCondition: Prisma.StringNullableFilter = {
|
||||
contains: searchValue,
|
||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||
};
|
||||
return searchValue
|
||||
? {
|
||||
OR: [
|
||||
{ title: containTextCondition },
|
||||
|
||||
{ content: containTextCondition },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
}, [searchValue]);
|
||||
// 核心条件生成逻辑
|
||||
const idCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||
if (value === undefined) return {}; // 无值时返回空对象
|
||||
// 字符串类型增强判断
|
||||
if (typeof value === "string") {
|
||||
// 如果明确需要支持逗号分隔字符串
|
||||
|
||||
return { id: value };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? { id: { in: value } } : {}; // 空数组不注入条件
|
||||
}
|
||||
return {};
|
||||
}, [value]);
|
||||
const {
|
||||
data: lectures,
|
||||
isLoading,
|
||||
}: { data: Lecture[]; isLoading: boolean } = api.post.findMany.useQuery({
|
||||
where: safeOR([
|
||||
{ ...idCondition },
|
||||
|
||||
{...(params?.where || {}), ...searchCondition },
|
||||
]),
|
||||
select: { ...postDetailSelect, ...(params?.select || {}) },
|
||||
take: 15,
|
||||
});
|
||||
const options = useMemo(() => {
|
||||
return (lectures || []).map((lecture, index) => {
|
||||
return {
|
||||
value: lecture.id,
|
||||
label: <PostSelectOption post={lecture}></PostSelectOption>,
|
||||
tag: lecture?.title,
|
||||
};
|
||||
});
|
||||
}, [lectures, isLoading]);
|
||||
const tagRender = (props) => {
|
||||
// 根据 value 找到对应的 option
|
||||
const option = options.find((opt) => opt.value === props.value);
|
||||
// 使用自定义的展示内容(这里假设你的 option 中有 customDisplay 字段)
|
||||
return <span style={{ marginRight: 3 }}>{option?.tag}</span>;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
}}>
|
||||
<Select
|
||||
showSearch
|
||||
value={value}
|
||||
dropdownStyle={{
|
||||
minWidth: 200, // 设置合适的最小宽度
|
||||
}}
|
||||
autoClearSearchValue
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
filterOption={false}
|
||||
loading={isLoading}
|
||||
className={`flex-1 w-full ${className}`}
|
||||
options={options}
|
||||
tagRender={tagRender}
|
||||
optionLabelProp="tag" // 新增这个属性 ✅
|
||||
onSearch={(inputValue) => setSearch(inputValue)}></Select>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import { Lecture, LessonTypeLabel } from "@nice/common";
|
||||
|
||||
// 修改 PostSelectOption 组件
|
||||
export default function PostSelectOption({ post }: { post: Lecture }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{" "}
|
||||
{/* 添加 min-w-0 */}
|
||||
<img
|
||||
src={post?.meta?.thumbnail || "/placeholder.webp"}
|
||||
className="w-8 h-8 object-cover rounded flex-shrink-0" // 添加 flex-shrink-0
|
||||
alt="课程封面"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{" "}
|
||||
{/* 修改这里 */}
|
||||
{post?.meta?.type && (
|
||||
<span className="text-sm text-gray-500 truncate">
|
||||
{LessonTypeLabel[post?.meta?.type]}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium truncate">{post?.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
type PrismaCondition = Record<string, any>;
|
||||
type SafeOROptions = {
|
||||
/**
|
||||
* 当所有条件为空时的处理方式
|
||||
* @default 'return-undefined' 返回 undefined (等效查询所有)
|
||||
* 'throw-error' 抛出错误
|
||||
* 'return-empty' 返回空对象
|
||||
*/
|
||||
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全合并多个查询条件为 OR 关系
|
||||
* @param conditions 多个查询条件
|
||||
* @param options 配置选项
|
||||
* @returns 安全的 Prisma WHERE 条件
|
||||
*/
|
||||
const safeOR = (
|
||||
conditions: PrismaCondition[],
|
||||
options?: SafeOROptions
|
||||
): PrismaCondition | undefined => {
|
||||
const { emptyBehavior = "return-undefined" } = options || {};
|
||||
|
||||
// 过滤空条件和无效值
|
||||
const validConditions = conditions.filter(
|
||||
(cond) => cond && Object.keys(cond).length > 0
|
||||
);
|
||||
|
||||
// 处理全空情况
|
||||
if (validConditions.length === 0) {
|
||||
switch (emptyBehavior) {
|
||||
case "throw-error":
|
||||
throw new Error("No valid conditions provided to OR query");
|
||||
case "return-empty":
|
||||
return {};
|
||||
case "return-undefined":
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 优化单条件查询
|
||||
return validConditions.length === 1
|
||||
? validConditions[0]
|
||||
: { OR: validConditions };
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
import { CourseDto, PostDto } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PostCard from "../PostCard";
|
||||
export default function CourseCard({ post }: { post: PostDto }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => {
|
||||
navigate(`/course/${post?.id}/detail`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}></PostCard>
|
||||
);
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { PostDto } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PostCard from "../PostCard";
|
||||
|
||||
export default function PathCard({ post }: { post: PostDto }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => {
|
||||
navigate(`/path/editor/${post?.id}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}></PostCard>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue