删除冗余文件,进行build

This commit is contained in:
Li1304553726 2025-05-29 19:19:16 +08:00
parent 33185aa340
commit 1cfa22e286
74 changed files with 115 additions and 4571 deletions

View File

@ -1,86 +1,86 @@
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Typography, Skeleton } from "antd";
import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
import { api } from "@nice/client";
import LookForMore from "./LookForMore";
import CategorySectionCard from "./CategorySectionCard";
import { useNavigate } from "react-router-dom";
import { useMainContext } from "../../layout/MainProvider";
// import React, { useState, useCallback, useEffect, useMemo } from "react";
// import { Typography, Skeleton } from "antd";
// import { stringToColor, TaxonomySlug, TermDto } from "@nice/common";
// import { api } from "@nice/client";
// import LookForMore from "./LookForMore";
// import CategorySectionCard from "./CategorySectionCard";
// import { useNavigate } from "react-router-dom";
// import { useMainContext } from "../../layout/MainProvider";
const { Title, Text } = Typography;
const CategorySection = () => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const { selectedTerms, setSelectedTerms } = useMainContext();
const {
data: courseCategoriesData,
isLoading,
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
where: {
taxonomy: {
slug: TaxonomySlug.CATEGORY,
},
parentId: null,
},
take: 8,
});
const navigate = useNavigate();
// const { Title, Text } = Typography;
// const CategorySection = () => {
// const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
// const { selectedTerms, setSelectedTerms } = useMainContext();
// const {
// data: courseCategoriesData,
// isLoading,
// }: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
// where: {
// taxonomy: {
// slug: TaxonomySlug.CATEGORY,
// },
// parentId: null,
// },
// take: 8,
// });
// const navigate = useNavigate();
const handleMouseEnter = useCallback((index: number) => {
setHoveredIndex(index);
}, []);
// const handleMouseEnter = useCallback((index: number) => {
// setHoveredIndex(index);
// }, []);
const handleMouseLeave = useCallback(() => {
setHoveredIndex(null);
}, []);
// const handleMouseLeave = useCallback(() => {
// setHoveredIndex(null);
// }, []);
const handleMouseClick = useCallback((categoryId: string) => {
setSelectedTerms({
...selectedTerms,
[TaxonomySlug.CATEGORY]: [categoryId],
});
navigate("/courses");
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
return (
<section className="py-8 relative overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative">
<div className="text-center mb-12">
<Title
level={2}
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
</Title>
<Text type="secondary" className="text-xl font-light">
</Text>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{isLoading ? (
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
) : (
courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
const categoryColor = stringToColor(category.name);
const isHovered = hoveredIndex === index;
// const handleMouseClick = useCallback((categoryId: string) => {
// setSelectedTerms({
// ...selectedTerms,
// [TaxonomySlug.CATEGORY]: [categoryId],
// });
// navigate("/courses");
// window.scrollTo({ top: 0, behavior: "smooth" });
// }, []);
// return (
// <section className="py-8 relative overflow-hidden">
// <div className="max-w-screen-2xl mx-auto px-4 relative">
// <div className="text-center mb-12">
// <Title
// level={2}
// className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
// 探索课程分类
// </Title>
// <Text type="secondary" className="text-xl font-light">
// 选择你感兴趣的方向,开启学习之旅
// </Text>
// </div>
// <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
// {isLoading ? (
// <Skeleton paragraph={{ rows: 4 }}></Skeleton>
// ) : (
// courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
// const categoryColor = stringToColor(category.name);
// const isHovered = hoveredIndex === index;
return (
<CategorySectionCard
key={index}
index={index}
category={category}
categoryColor={categoryColor}
isHovered={isHovered}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
handleMouseClick={handleMouseClick}
/>
);
})
)}
</div>
<LookForMore to={"/courses"}></LookForMore>
</div>
</section>
);
};
// return (
// <CategorySectionCard
// key={index}
// index={index}
// category={category}
// categoryColor={categoryColor}
// isHovered={isHovered}
// handleMouseEnter={handleMouseEnter}
// handleMouseLeave={handleMouseLeave}
// handleMouseClick={handleMouseClick}
// />
// );
// })
// )}
// </div>
// <LookForMore to={"/courses"}></LookForMore>
// </div>
// </section>
// );
// };
export default CategorySection;
// export default CategorySection;

View File

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

View File

@ -1,34 +1,34 @@
import HeroSection from "./components/HeroSection";
import CategorySection from "./components/CategorySection";
import CoursesSection from "./components/CoursesSection";
import { PostType } from "@nice/common";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
// import HeroSection from "./components/HeroSection";
// import CategorySection from "./components/CategorySection";
// import CoursesSection from "./components/CoursesSection";
// import { PostType } from "@nice/common";
// import PathCard from "@web/src/components/models/post/SubPost/PathCard";
// import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
const HomePage = () => {
// const HomePage = () => {
return (
<div className="min-h-screen">
<HeroSection />
<CoursesSection
title="最受欢迎的思维导图"
description="深受追捧的思维导图,点亮你的智慧人生"
postType={PostType.PATH}
render={(post)=><PathCard post={post}></PathCard>}
to={"path"}
/>
<CoursesSection
title="推荐课程"
description="最受欢迎的精品课程,助你快速成长"
postType={PostType.COURSE}
render={(post)=> <CourseCard post={post}></CourseCard>}
to={"/courses"}
/>
// return (
// <div className="min-h-screen">
// <HeroSection />
// <CoursesSection
// title="最受欢迎的思维导图"
// description="深受追捧的思维导图,点亮你的智慧人生"
// postType={PostType.PATH}
// render={(post)=><PathCard post={post}></PathCard>}
// to={"path"}
// />
// <CoursesSection
// title="推荐课程"
// description="最受欢迎的精品课程,助你快速成长"
// postType={PostType.COURSE}
// render={(post)=> <CourseCard post={post}></CourseCard>}
// to={"/courses"}
// />
<CategorySection />
</div>
);
};
// <CategorySection />
// </div>
// );
// };
export default HomePage;
// export default HomePage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
export default function MyCoursePage() {
return (
<div>
My Course Page
</div>
)
}

View File

@ -1,3 +0,0 @@
export default function ProfilesPage() {
return <>Profiles</>
}

View File

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

View File

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

View File

@ -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", // 黄色系
];

View File

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

View File

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

View File

@ -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"],
],
};

View File

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

View File

@ -3,6 +3,7 @@ import { useRef, useState } from 'react';
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
import FormError from './FormError';
import { Button } from '../element/Button';
import React from 'react';
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
name: string;
label?: string;

View File

@ -1,7 +1,7 @@
import { useFormContext, Controller } from 'react-hook-form';
import FormError from './FormError';
import { useState } from 'react';
import QuillEditor from '../editor/quill/QuillEditor';
// import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
export interface FormQuillInputProps {
name: string;
@ -54,7 +54,7 @@ export function FormQuillInput({
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<div className={containerClasses}>
<Controller
{/* <Controller
name={name}
control={control}
render={({ field: { value, onChange } }) => (
@ -69,9 +69,9 @@ export function FormQuillInput({
readOnly={readOnly}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
/>
)}
/>
/> */}
{/* )} */}
{/* /> */}
</div>
<FormError error={error} />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
import type { MenuProps } from 'antd';
import { Menu } from 'antd';
type MenuItem = Required<MenuProps>['items'][number];
export function CourseCatalog(){
return (
<>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
export * from "./CourseSyllabus";

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
export enum CoursePart {
OVERVIEW = "overview",
TARGET = "target",
CONTENT = "content",
SETTING = "settings",
}

View File

@ -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,
// },
];

View File

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

View File

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

View File

@ -1,12 +0,0 @@
import { api } from "@nice/client";
export default function PostSelect() {
const { data } = api.post.findMany.useQuery({
where: {
title: {
contains: ""
}
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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