删除冗余文件,进行build
This commit is contained in:
parent
33185aa340
commit
1cfa22e286
|
@ -1,86 +1,86 @@
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
// import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Typography, Skeleton } from "antd";
|
// import { Typography, Skeleton } from "antd";
|
||||||
import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
|
// import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
|
||||||
import { api } from "@nice/client";
|
// import { api } from "@nice/client";
|
||||||
import LookForMore from "./LookForMore";
|
// import LookForMore from "./LookForMore";
|
||||||
import CategorySectionCard from "./CategorySectionCard";
|
// import CategorySectionCard from "./CategorySectionCard";
|
||||||
import { useNavigate } from "react-router-dom";
|
// import { useNavigate } from "react-router-dom";
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
// import { useMainContext } from "../../layout/MainProvider";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
// const { Title, Text } = Typography;
|
||||||
const CategorySection = () => {
|
// const CategorySection = () => {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
// const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
// const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||||
const {
|
// const {
|
||||||
data: courseCategoriesData,
|
// data: courseCategoriesData,
|
||||||
isLoading,
|
// isLoading,
|
||||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
// }: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
||||||
where: {
|
// where: {
|
||||||
taxonomy: {
|
// taxonomy: {
|
||||||
slug: TaxonomySlug.CATEGORY,
|
// slug: TaxonomySlug.CATEGORY,
|
||||||
},
|
// },
|
||||||
parentId: null,
|
// parentId: null,
|
||||||
},
|
// },
|
||||||
take: 8,
|
// take: 8,
|
||||||
});
|
// });
|
||||||
const navigate = useNavigate();
|
// const navigate = useNavigate();
|
||||||
|
|
||||||
const handleMouseEnter = useCallback((index: number) => {
|
// const handleMouseEnter = useCallback((index: number) => {
|
||||||
setHoveredIndex(index);
|
// setHoveredIndex(index);
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
// const handleMouseLeave = useCallback(() => {
|
||||||
setHoveredIndex(null);
|
// setHoveredIndex(null);
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
const handleMouseClick = useCallback((categoryId: string) => {
|
// const handleMouseClick = useCallback((categoryId: string) => {
|
||||||
setSelectedTerms({
|
// setSelectedTerms({
|
||||||
...selectedTerms,
|
// ...selectedTerms,
|
||||||
[TaxonomySlug.CATEGORY]: [categoryId],
|
// [TaxonomySlug.CATEGORY]: [categoryId],
|
||||||
});
|
// });
|
||||||
navigate("/courses");
|
// navigate("/courses");
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
// window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, []);
|
// }, []);
|
||||||
return (
|
// return (
|
||||||
<section className="py-8 relative overflow-hidden">
|
// <section className="py-8 relative overflow-hidden">
|
||||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
// <div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||||
<div className="text-center mb-12">
|
// <div className="text-center mb-12">
|
||||||
<Title
|
// <Title
|
||||||
level={2}
|
// 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">
|
// 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>
|
// </Title>
|
||||||
<Text type="secondary" className="text-xl font-light">
|
// <Text type="secondary" className="text-xl font-light">
|
||||||
选择你感兴趣的方向,开启学习之旅
|
// 选择你感兴趣的方向,开启学习之旅
|
||||||
</Text>
|
// </Text>
|
||||||
</div>
|
// </div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
// <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{isLoading ? (
|
// {isLoading ? (
|
||||||
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
// <Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||||
) : (
|
// ) : (
|
||||||
courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
|
// courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
|
||||||
const categoryColor = stringToColor(category.name);
|
// const categoryColor = stringToColor(category.name);
|
||||||
const isHovered = hoveredIndex === index;
|
// const isHovered = hoveredIndex === index;
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<CategorySectionCard
|
// <CategorySectionCard
|
||||||
key={index}
|
// key={index}
|
||||||
index={index}
|
// index={index}
|
||||||
category={category}
|
// category={category}
|
||||||
categoryColor={categoryColor}
|
// categoryColor={categoryColor}
|
||||||
isHovered={isHovered}
|
// isHovered={isHovered}
|
||||||
handleMouseEnter={handleMouseEnter}
|
// handleMouseEnter={handleMouseEnter}
|
||||||
handleMouseLeave={handleMouseLeave}
|
// handleMouseLeave={handleMouseLeave}
|
||||||
handleMouseClick={handleMouseClick}
|
// handleMouseClick={handleMouseClick}
|
||||||
/>
|
// />
|
||||||
);
|
// );
|
||||||
})
|
// })
|
||||||
)}
|
// )}
|
||||||
</div>
|
// </div>
|
||||||
<LookForMore to={"/courses"}></LookForMore>
|
// <LookForMore to={"/courses"}></LookForMore>
|
||||||
</div>
|
// </div>
|
||||||
</section>
|
// </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 HeroSection from "./components/HeroSection";
|
||||||
import CategorySection from "./components/CategorySection";
|
// import CategorySection from "./components/CategorySection";
|
||||||
import CoursesSection from "./components/CoursesSection";
|
// import CoursesSection from "./components/CoursesSection";
|
||||||
import { PostType } from "@nice/common";
|
// import { PostType } from "@nice/common";
|
||||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
// import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
// import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||||
|
|
||||||
|
|
||||||
const HomePage = () => {
|
// const HomePage = () => {
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<div className="min-h-screen">
|
// <div className="min-h-screen">
|
||||||
<HeroSection />
|
// <HeroSection />
|
||||||
<CoursesSection
|
// <CoursesSection
|
||||||
title="最受欢迎的思维导图"
|
// title="最受欢迎的思维导图"
|
||||||
description="深受追捧的思维导图,点亮你的智慧人生"
|
// description="深受追捧的思维导图,点亮你的智慧人生"
|
||||||
postType={PostType.PATH}
|
// postType={PostType.PATH}
|
||||||
render={(post)=><PathCard post={post}></PathCard>}
|
// render={(post)=><PathCard post={post}></PathCard>}
|
||||||
to={"path"}
|
// to={"path"}
|
||||||
/>
|
// />
|
||||||
<CoursesSection
|
// <CoursesSection
|
||||||
title="推荐课程"
|
// title="推荐课程"
|
||||||
description="最受欢迎的精品课程,助你快速成长"
|
// description="最受欢迎的精品课程,助你快速成长"
|
||||||
postType={PostType.COURSE}
|
// postType={PostType.COURSE}
|
||||||
render={(post)=> <CourseCard post={post}></CourseCard>}
|
// render={(post)=> <CourseCard post={post}></CourseCard>}
|
||||||
to={"/courses"}
|
// to={"/courses"}
|
||||||
/>
|
// />
|
||||||
|
|
||||||
<CategorySection />
|
// <CategorySection />
|
||||||
</div>
|
// </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 { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import FormError from './FormError';
|
import FormError from './FormError';
|
||||||
import { Button } from '../element/Button';
|
import { Button } from '../element/Button';
|
||||||
|
import React from 'react';
|
||||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useFormContext, Controller } from 'react-hook-form';
|
import { useFormContext, Controller } from 'react-hook-form';
|
||||||
import FormError from './FormError';
|
import FormError from './FormError';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import QuillEditor from '../editor/quill/QuillEditor';
|
// import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
||||||
|
|
||||||
export interface FormQuillInputProps {
|
export interface FormQuillInputProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -54,7 +54,7 @@ export function FormQuillInput({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
<Controller
|
{/* <Controller
|
||||||
name={name}
|
name={name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
|
@ -69,9 +69,9 @@ export function FormQuillInput({
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
/>
|
/> */}
|
||||||
)}
|
{/* )} */}
|
||||||
/>
|
{/* /> */}
|
||||||
</div>
|
</div>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
</div>
|
</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