Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
c9c301a8a3
|
@ -101,7 +101,6 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
},
|
},
|
||||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const { courseDetail } = args;
|
const { courseDetail } = args;
|
||||||
// If no transaction is provided, create a new one
|
// If no transaction is provided, create a new one
|
||||||
if (!params.tx) {
|
if (!params.tx) {
|
||||||
|
@ -124,7 +123,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
) {
|
) {
|
||||||
args.data.authorId = params?.staff?.id;
|
args.data.authorId = params?.staff?.id;
|
||||||
args.data.updatedAt = dayjs().toDate();
|
args.data.updatedAt = dayjs().toDate();
|
||||||
|
|
||||||
const result = await super.create(args);
|
const result = await super.create(args);
|
||||||
EventBus.emit('dataChanged', {
|
EventBus.emit('dataChanged', {
|
||||||
type: ObjectType.POST,
|
type: ObjectType.POST,
|
||||||
|
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -1,111 +0,0 @@
|
||||||
import { Card, Tag, Typography, Button } from "antd";
|
|
||||||
import {
|
|
||||||
BookOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { CourseDto, TaxonomySlug } from "@nice/common";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
interface CourseCardProps {
|
|
||||||
course: CourseDto;
|
|
||||||
edit?: boolean;
|
|
||||||
}
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
export default function CourseCard({ course, edit = false }: CourseCardProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const handleClick = (course: CourseDto) => {
|
|
||||||
if (!edit) {
|
|
||||||
navigate(`/course/${course.id}/detail`);
|
|
||||||
} else {
|
|
||||||
navigate(`/course/${course.id}/editor`);
|
|
||||||
}
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
onClick={() => handleClick(course)}
|
|
||||||
key={course.id}
|
|
||||||
hoverable
|
|
||||||
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
|
||||||
cover={
|
|
||||||
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${course?.meta?.thumbnail})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
|
|
||||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-300" />
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div className="px-4 ">
|
|
||||||
<div className="overflow-hidden hover:overflow-auto">
|
|
||||||
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
|
|
||||||
{course?.terms?.map((term) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tag
|
|
||||||
key={term.id}
|
|
||||||
color={
|
|
||||||
term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.CATEGORY
|
|
||||||
? "blue"
|
|
||||||
: term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.LEVEL
|
|
||||||
? "green"
|
|
||||||
: "orange"
|
|
||||||
}
|
|
||||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0 ">
|
|
||||||
{term.name}
|
|
||||||
</Tag>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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> {course.title}</button>
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
|
||||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
|
||||||
<div className="ml-2 flex items-center flex-grow">
|
|
||||||
<Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
|
|
||||||
{course?.depts?.length > 1
|
|
||||||
? `${course.depts[0].name}等`
|
|
||||||
: course?.depts?.[0]?.name}
|
|
||||||
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
|
|
||||||
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
|
||||||
<EyeOutlined />
|
|
||||||
{`观看次数 ${course?.meta?.views || 0}`}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
|
||||||
<BookOutlined />
|
|
||||||
{`学习人数 ${course?.studentIds?.length || 0}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 border-t border-gray-100 text-center">
|
|
||||||
<Button
|
|
||||||
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">
|
|
||||||
{edit ? "进行编辑" : "立即学习"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,34 +1,19 @@
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
import { useMainContext } from "../../layout/MainProvider";
|
||||||
import { PostType, Prisma } from "@nice/common";
|
import { PostType, Prisma } from "@nice/common";
|
||||||
import PostList from "@web/src/components/models/course/list/PostList";
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
import { useMemo } from "react";
|
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||||
import CourseCard from "./CourseCard";
|
|
||||||
|
|
||||||
export function CoursesContainer() {
|
export function CoursesContainer() {
|
||||||
const { selectedTerms, searchCondition } = useMainContext();
|
const { searchCondition, termsCondition } = useMainContext();
|
||||||
const termFilters = useMemo(() => {
|
|
||||||
return Object.entries(selectedTerms)
|
|
||||||
.filter(([, terms]) => terms.length > 0)
|
|
||||||
.map(([, terms]) => terms);
|
|
||||||
}, [selectedTerms]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PostList
|
<PostList
|
||||||
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>}
|
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||||
params={{
|
params={{
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
where: {
|
where: {
|
||||||
type: PostType.COURSE,
|
type: PostType.COURSE,
|
||||||
AND: termFilters.map((termFilter) => ({
|
...termsCondition,
|
||||||
terms: {
|
|
||||||
some: {
|
|
||||||
id: {
|
|
||||||
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...searchCondition,
|
...searchCondition,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,68 +0,0 @@
|
||||||
import { Checkbox, Divider, Radio, Space, Spin } from "antd";
|
|
||||||
|
|
||||||
import { TaxonomySlug, TermDto } from "@nice/common";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { api } from "@nice/client";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import TermSelect from "@web/src/components/models/term/term-select";
|
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
|
||||||
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
|
||||||
|
|
||||||
export default function FilterSection() {
|
|
||||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
|
||||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
|
||||||
const handleTermChange = (slug: string, selected: string[]) => {
|
|
||||||
setSelectedTerms({
|
|
||||||
...selectedTerms,
|
|
||||||
[slug]: selected, // 更新对应 slug 的选择
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="bg-white z-0 p-6 rounded-lg mt-4 shadow-sm w-1/6 space-y-6 h-[820px] fixed overscroll-contain overflow-x-hidden">
|
|
||||||
{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}
|
|
||||||
</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>
|
|
||||||
{/* <TermSelect
|
|
||||||
// open
|
|
||||||
className="w-72"
|
|
||||||
value={items}
|
|
||||||
dropdownRender={(menu) => (
|
|
||||||
<div style={{ padding: "8px" }}>{menu}</div>
|
|
||||||
)}
|
|
||||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
|
||||||
multiple
|
|
||||||
taxonomyId={tax?.id}
|
|
||||||
onChange={(selected) =>
|
|
||||||
handleTermChange(
|
|
||||||
tax?.slug,
|
|
||||||
selected as string[]
|
|
||||||
)
|
|
||||||
}></TermSelect>
|
|
||||||
{index < taxonomies.length - 1 && (
|
|
||||||
<Divider className="my-6" />
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import FilterSection from "../components/FilterSection";
|
|
||||||
import CoursesContainer from "../components/CoursesContainer";
|
|
||||||
export function AllCoursesLayout() {
|
|
||||||
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">
|
|
||||||
<CoursesContainer></CoursesContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default AllCoursesLayout;
|
|
|
@ -1,8 +1,18 @@
|
||||||
import AllCoursesLayout from "./layout/AllCoursesLayout";
|
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||||
|
import CoursesContainer from "./components/CoursesContainer";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
|
import { PostType } from "@nice/common";
|
||||||
export default function CoursesPage() {
|
export default function CoursesPage() {
|
||||||
|
const { setSearchMode } = useMainContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchMode(PostType.COURSE);
|
||||||
|
}, [setSearchMode]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AllCoursesLayout></AllCoursesLayout>
|
<BasePostLayout>
|
||||||
|
<CoursesContainer></CoursesContainer>
|
||||||
|
</BasePostLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import { api } from "@nice/client";
|
||||||
import { CoursesSectionTag } from "./CoursesSectionTag";
|
import { CoursesSectionTag } from "./CoursesSectionTag";
|
||||||
import LookForMore from "./LookForMore";
|
import LookForMore from "./LookForMore";
|
||||||
import PostList from "@web/src/components/models/course/list/PostList";
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
import CourseCard from "../../courses/components/CourseCard";
|
import PostCard from "@web/src/components/models/post/PostCard";
|
||||||
|
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||||
interface GetTaxonomyProps {
|
interface GetTaxonomyProps {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
@ -17,7 +18,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps {
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
slug: type,
|
slug: type,
|
||||||
},
|
},
|
||||||
parentId: null
|
parentId: null,
|
||||||
},
|
},
|
||||||
take: 11, // 只取前10个
|
take: 11, // 只取前10个
|
||||||
});
|
});
|
||||||
|
@ -82,17 +83,17 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PostList
|
<PostList
|
||||||
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>}
|
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||||
params={{
|
params={{
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: initialVisibleCoursesCount,
|
pageSize: initialVisibleCoursesCount,
|
||||||
where: {
|
where: {
|
||||||
terms: !(selectedCategory === "全部")
|
terms: !(selectedCategory === "全部")
|
||||||
? {
|
? {
|
||||||
some: {
|
some: {
|
||||||
name: selectedCategory,
|
name: selectedCategory,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ReactNode, useEffect } from "react";
|
||||||
|
import FilterSection from "./FilterSection";
|
||||||
|
import { useMainContext } from "../MainProvider";
|
||||||
|
|
||||||
|
export function BasePostLayout({
|
||||||
|
children,
|
||||||
|
showSearchMode = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
showSearchMode?: boolean;
|
||||||
|
}) {
|
||||||
|
const { setShowSearchMode } = useMainContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setShowSearchMode(showSearchMode);
|
||||||
|
}, [showSearchMode]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className=" flex">
|
||||||
|
<div className="w-1/6">
|
||||||
|
<FilterSection></FilterSection>
|
||||||
|
</div>
|
||||||
|
<div className="w-5/6 p-4 py-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default BasePostLayout;
|
|
@ -1,11 +1,12 @@
|
||||||
|
import { Divider } from "antd";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
import { useMainContext } from "../MainProvider";
|
||||||
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
||||||
|
import SearchModeRadio from "./SearchModeRadio";
|
||||||
export default function PathFilter() {
|
export default function FilterSection() {
|
||||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
||||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
const { selectedTerms, setSelectedTerms, showSearchMode } =
|
||||||
|
useMainContext();
|
||||||
const handleTermChange = (slug: string, selected: string[]) => {
|
const handleTermChange = (slug: string, selected: string[]) => {
|
||||||
setSelectedTerms({
|
setSelectedTerms({
|
||||||
...selectedTerms,
|
...selectedTerms,
|
||||||
|
@ -13,7 +14,8 @@ export default function PathFilter() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6 h-full">
|
<div className=" flex z-0 p-6 flex-col rounded-lg mt-4 space-y-6 h-[820px] overscroll-contain overflow-x-hidden">
|
||||||
|
{showSearchMode && <SearchModeRadio></SearchModeRadio>}
|
||||||
{taxonomies?.map((tax, index) => {
|
{taxonomies?.map((tax, index) => {
|
||||||
const items = Object.entries(selectedTerms).find(
|
const items = Object.entries(selectedTerms).find(
|
||||||
([key, items]) => key === tax.slug
|
([key, items]) => key === tax.slug
|
||||||
|
@ -25,17 +27,16 @@ export default function PathFilter() {
|
||||||
</h3>
|
</h3>
|
||||||
<TermParentSelector
|
<TermParentSelector
|
||||||
value={items}
|
value={items}
|
||||||
slug = {tax?.slug}
|
slug={tax?.slug}
|
||||||
className="w-70 max-h-[500px] overscroll-contain overflow-x-hidden"
|
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
||||||
onChange={(selected) =>
|
onChange={(selected) =>
|
||||||
handleTermChange(
|
handleTermChange(
|
||||||
tax?.slug,
|
tax?.slug,
|
||||||
selected as string[]
|
selected as string[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
taxonomyId={tax?.id}
|
taxonomyId={tax?.id}></TermParentSelector>
|
||||||
></TermParentSelector>
|
<Divider></Divider>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useMainContext } from "../MainProvider";
|
||||||
|
import { Radio, Space, Typography } from "antd";
|
||||||
|
import { PostType } from "@nice/common"; // Assuming PostType is defined in this path
|
||||||
|
|
||||||
|
export default function SearchModeRadio() {
|
||||||
|
const { searchMode, setSearchMode } = useMainContext();
|
||||||
|
|
||||||
|
const handleModeChange = (e) => {
|
||||||
|
setSearchMode(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" align="start" className="mb-2">
|
||||||
|
<h3 className="text-lg font-medium mb-4">搜索模式</h3>
|
||||||
|
<Radio.Group
|
||||||
|
value={searchMode}
|
||||||
|
onChange={handleModeChange}
|
||||||
|
buttonStyle="solid">
|
||||||
|
<Radio.Button value={PostType.COURSE}>课程</Radio.Button>
|
||||||
|
<Radio.Button value={PostType.PATH}>路径</Radio.Button>
|
||||||
|
<Radio.Button value="both">全部</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,68 +1,76 @@
|
||||||
import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
import {
|
||||||
|
CloudOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
export function MainFooter() {
|
export function MainFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 z-20 text-secondary-200">
|
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-10 text-secondary-200">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* 开发组织信息 */}
|
{/* 开发组织信息 */}
|
||||||
<div className="text-center md:text-left space-y-2">
|
<div className="text-center md:text-left space-y-2">
|
||||||
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
||||||
软件与数据小组
|
软件与数据小组
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-400 text-xs italic">
|
<p className="text-gray-400 text-xs italic">
|
||||||
提供技术支持
|
提供技术支持
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联系方式 */}
|
{/* 联系方式 */}
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<PhoneOutlined className="text-gray-400" />
|
<PhoneOutlined className="text-gray-400" />
|
||||||
<span className="text-gray-300 text-xs">628118</span>
|
<span className="text-gray-300 text-xs">
|
||||||
</div>
|
628118
|
||||||
<div className="flex items-center justify-center space-x-2">
|
</span>
|
||||||
<MailOutlined className="text-gray-400" />
|
</div>
|
||||||
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
|
<div className="flex items-center justify-center space-x-2">
|
||||||
</div>
|
<MailOutlined className="text-gray-400" />
|
||||||
</div>
|
<span className="text-gray-300 text-xs">
|
||||||
|
gcsjs6@tx3l.nb.kj
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 系统链接 */}
|
{/* 系统链接 */}
|
||||||
<div className="text-center md:text-right space-y-2">
|
<div className="text-center md:text-right space-y-2">
|
||||||
<div className="flex items-center justify-center md:justify-end space-x-4">
|
<div className="flex items-center justify-center md:justify-end space-x-4">
|
||||||
<a
|
<a
|
||||||
href="https://27.57.72.21"
|
href="https://27.57.72.21"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
title="访问门户网站"
|
title="访问门户网站">
|
||||||
>
|
<HomeOutlined className="text-lg" />
|
||||||
<HomeOutlined className="text-lg" />
|
</a>
|
||||||
</a>
|
<a
|
||||||
<a
|
href="https://27.57.72.14"
|
||||||
href="https://27.57.72.14"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
title="访问烽火青云">
|
||||||
title="访问烽火青云"
|
<CloudOutlined className="text-lg" />
|
||||||
>
|
</a>
|
||||||
<CloudOutlined className="text-lg" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="http://27.57.72.38"
|
href="http://27.57.72.38"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
title="访问烽火律询"
|
title="访问烽火律询">
|
||||||
>
|
<FileSearchOutlined className="text-lg" />
|
||||||
<FileSearchOutlined className="text-lg" />
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 版权信息 */}
|
{/* 版权信息 */}
|
||||||
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
||||||
<p className="text-gray-400 text-xs">
|
<p className="text-gray-400 text-xs">
|
||||||
© {new Date().getFullYear()} 南天烽火. All rights reserved.
|
© {new Date().getFullYear()} 南天烽火. All rights
|
||||||
</p>
|
reserved.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</div>
|
||||||
);
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
||||||
import {
|
import {
|
||||||
EditFilled,
|
EditFilled,
|
||||||
|
@ -10,6 +11,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { UserMenu } from "./UserMenu/UserMenu";
|
import { UserMenu } from "./UserMenu/UserMenu";
|
||||||
import { NavigationMenu } from "./NavigationMenu";
|
import { NavigationMenu } from "./NavigationMenu";
|
||||||
import { useMainContext } from "./MainProvider";
|
import { useMainContext } from "./MainProvider";
|
||||||
|
import { Header } from "antd/es/layout/layout";
|
||||||
|
|
||||||
export function MainHeader() {
|
export function MainHeader() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
|
@ -18,79 +20,90 @@ export function MainHeader() {
|
||||||
const { searchValue, setSearchValue } = useMainContext();
|
const { searchValue, setSearchValue } = useMainContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none flex items-center gap-4 justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30 h-16">
|
<div className="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6">
|
||||||
<div className="w-full max-w-screen-3xl px-4 md:px-6 mx-auto flex items-center justify-between h-full gap-6">
|
{/* 左侧区域 - 设置为不收缩 */}
|
||||||
|
<div className="flex items-center justify-start space-x-4 flex-shrink-0">
|
||||||
|
<img src="/logo.svg" className="h-12 w-12" />
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer">
|
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
||||||
烽火慕课
|
烽火慕课
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 px-6">
|
<NavigationMenu />
|
||||||
<NavigationMenu />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<Input
|
|
||||||
size="large"
|
{/* 中间搜索区域 - 允许适当收缩但保持可用性 */}
|
||||||
prefix={
|
<div className="mx-4 flex-shrink md:flex-shrink-0 md:w-auto w-auto">
|
||||||
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
<Input
|
||||||
}
|
size="large"
|
||||||
placeholder="搜索课程"
|
prefix={
|
||||||
className="w-96 rounded-full"
|
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||||
value={searchValue}
|
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
|
||||||
onPressEnter={(e) => {
|
|
||||||
if (
|
|
||||||
!window.location.pathname.startsWith("/courses/") &&
|
|
||||||
!window.location.pathname.startsWith("my")
|
|
||||||
) {
|
|
||||||
navigate(`/courses/`);
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
placeholder="搜索课程"
|
||||||
/>
|
className="w-full md:w-96 rounded-full"
|
||||||
<div className="flex items-center gap-4">
|
value={searchValue}
|
||||||
{isAuthenticated && (
|
onClick={(e) => {
|
||||||
<>
|
if (!window.location.pathname.startsWith("/search")) {
|
||||||
|
navigate(`/search`);
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!window.location.pathname.startsWith("/search")) {
|
||||||
|
navigate(`/search`);
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧区域 - 可以灵活收缩 */}
|
||||||
|
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||||
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const url = id
|
||||||
|
? `/course/${id}/editor`
|
||||||
|
: "/course/editor";
|
||||||
|
navigate(url);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||||
|
icon={<EditFilled />}>
|
||||||
|
{id ? "编辑课程" : "创建课程"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = id
|
window.location.href = "/path/editor";
|
||||||
? `/course/${id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}
|
}}
|
||||||
type="primary"
|
icon={<PlusOutlined></PlusOutlined>}>
|
||||||
icon={<EditFilled />}>
|
创建学习路径
|
||||||
{id ? "编辑课程" : "创建课程"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
{isAuthenticated ? (
|
||||||
{isAuthenticated && (
|
<UserMenu />
|
||||||
<Button
|
) : (
|
||||||
type="primary"
|
<Button
|
||||||
ghost
|
onClick={() => navigate("/login")}
|
||||||
onClick={() => {
|
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||||
window.location.href = "/path/editor";
|
icon={<UserOutlined />}>
|
||||||
}}
|
登录
|
||||||
icon={<PlusOutlined></PlusOutlined>}>
|
</Button>
|
||||||
创建学习路径
|
)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<UserMenu />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/login")}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<UserOutlined />}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ export function MainLayout() {
|
||||||
<MainProvider>
|
<MainProvider>
|
||||||
<div className=" min-h-screen bg-gray-100">
|
<div className=" min-h-screen bg-gray-100">
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
<Content className=" pt-20 bg-gray-50 ">
|
<Content className="min-h-screen flex-grow pt-14 bg-gray-50 ">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</Content>
|
||||||
<MainFooter />
|
<MainFooter />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Prisma } from "packages/common/dist";
|
import { PostType, Prisma } from "@nice/common";
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
@ -16,6 +16,13 @@ interface MainContextType {
|
||||||
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
|
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
||||||
searchCondition?: Prisma.PostWhereInput;
|
searchCondition?: Prisma.PostWhereInput;
|
||||||
|
termsCondition?: Prisma.PostWhereInput;
|
||||||
|
searchMode?: PostType.COURSE | PostType.PATH | "both";
|
||||||
|
setSearchMode?: React.Dispatch<
|
||||||
|
React.SetStateAction<PostType.COURSE | PostType.PATH | "both">
|
||||||
|
>;
|
||||||
|
showSearchMode?: boolean;
|
||||||
|
setShowSearchMode?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContext = createContext<MainContextType | null>(null);
|
const MainContext = createContext<MainContextType | null>(null);
|
||||||
|
@ -24,9 +31,33 @@ interface MainProviderProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainProvider({ children }: MainProviderProps) {
|
export function MainProvider({ children }: MainProviderProps) {
|
||||||
|
const [searchMode, setSearchMode] = useState<
|
||||||
|
PostType.COURSE | PostType.PATH | "both"
|
||||||
|
>("both");
|
||||||
|
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||||
|
const termFilters = useMemo(() => {
|
||||||
|
return Object.entries(selectedTerms)
|
||||||
|
.filter(([, terms]) => terms.length > 0)
|
||||||
|
?.map(([, terms]) => terms);
|
||||||
|
}, [selectedTerms]);
|
||||||
|
|
||||||
|
const termsCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
return termFilters && termFilters?.length > 0
|
||||||
|
? {
|
||||||
|
AND: termFilters.map((termFilter) => ({
|
||||||
|
terms: {
|
||||||
|
some: {
|
||||||
|
id: {
|
||||||
|
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
}, [termFilters]);
|
||||||
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
const containTextCondition: Prisma.StringNullableFilter = {
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
contains: searchValue,
|
contains: searchValue,
|
||||||
|
@ -57,6 +88,11 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
selectedTerms,
|
selectedTerms,
|
||||||
setSelectedTerms,
|
setSelectedTerms,
|
||||||
searchCondition,
|
searchCondition,
|
||||||
|
termsCondition,
|
||||||
|
searchMode,
|
||||||
|
setSearchMode,
|
||||||
|
showSearchMode,
|
||||||
|
setShowSearchMode,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MainContext.Provider>
|
</MainContext.Provider>
|
||||||
|
|
|
@ -11,16 +11,18 @@ export const NavigationMenu = () => {
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const baseItems = [
|
const baseItems = [
|
||||||
{ key: "home", path: "/", label: "首页" },
|
{ key: "home", path: "/", label: "首页" },
|
||||||
{ key: "courses", path: "/courses", label: "全部课程" },
|
|
||||||
{ key: "path", path: "/path", label: "学习路径" },
|
{ key: "path", path: "/path", label: "学习路径" },
|
||||||
|
{ key: "courses", path: "/courses", label: "全部课程" },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return baseItems;
|
return baseItems;
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
...baseItems,
|
...baseItems,
|
||||||
{ key: "my-duty", path: "/my-duty", label: "我创建的" },
|
{ key: "my-duty", path: "/my-duty", label: "我的授课" },
|
||||||
{ key: "my-learning", path: "/my-learning", label: "我学习的" },
|
{ key: "my-learning", path: "/my-learning", label: "我的课程" },
|
||||||
|
{ key: "my-path", path: "/my-path", label: "我的路径" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
@ -31,6 +33,7 @@ export const NavigationMenu = () => {
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
className="border-none font-medium"
|
className="border-none font-medium"
|
||||||
|
disabledOverflow={true}
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
const selectedItem = menuItems.find((item) => item.key === key);
|
const selectedItem = menuItems.find((item) => item.key === key);
|
||||||
|
|
|
@ -12,15 +12,7 @@ import toast from "react-hot-toast";
|
||||||
export default function StaffForm() {
|
export default function StaffForm() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { create, update } = useStaff(); // Ensure you have these methods in your hooks
|
const { create, update } = useStaff(); // Ensure you have these methods in your hooks
|
||||||
const {
|
const {formLoading,modalOpen,setModalOpen,domainId,setDomainId,form,setFormLoading,} = useContext(UserEditorContext);
|
||||||
formLoading,
|
|
||||||
modalOpen,
|
|
||||||
setModalOpen,
|
|
||||||
domainId,
|
|
||||||
setDomainId,
|
|
||||||
form,
|
|
||||||
setFormLoading,
|
|
||||||
} = useContext(UserEditorContext);
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
@ -76,6 +68,8 @@ export default function StaffForm() {
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
console.log('cc',data);
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
form.setFieldValue("username", data.username);
|
form.setFieldValue("username", data.username);
|
||||||
form.setFieldValue("showname", data.showname);
|
form.setFieldValue("showname", data.showname);
|
||||||
|
|
|
@ -86,20 +86,7 @@ export function UserMenu() {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: <UserOutlined className="text-lg" />,
|
|
||||||
label: "我创建的课程",
|
|
||||||
action: () => {
|
|
||||||
navigate("/my-duty");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <UserOutlined className="text-lg" />,
|
|
||||||
label: "我学习的课程",
|
|
||||||
action: () => {
|
|
||||||
navigate("/my-learning");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
canManageAnyStaff && {
|
canManageAnyStaff && {
|
||||||
icon: <SettingOutlined className="text-lg" />,
|
icon: <SettingOutlined className="text-lg" />,
|
||||||
label: "设置",
|
label: "设置",
|
||||||
|
@ -172,13 +159,6 @@ export function UserMenu() {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
|
||||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
|
||||||
<span className="text-base text-primary flex items-center gap-1.5">
|
|
||||||
{user?.showname || user?.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
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: {
|
||||||
|
type: PostType.COURSE,
|
||||||
|
authorId: user.id,
|
||||||
|
...termsCondition,
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,27 +1,16 @@
|
||||||
import PostList from "@web/src/components/models/course/list/PostList";
|
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import MyDutyListContainer from "./components/MyDutyListContainer";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useMainContext } from "../layout/MainProvider";
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
import CourseCard from "../courses/components/CourseCard";
|
import { PostType } from "@nice/common";
|
||||||
|
|
||||||
export default function MyDutyPage() {
|
export default function MyDutyPage() {
|
||||||
const { user } = useAuth();
|
const { setSearchMode } = useMainContext();
|
||||||
const { searchCondition } = useMainContext();
|
useEffect(() => {
|
||||||
|
setSearchMode(PostType.COURSE);
|
||||||
|
}, [setSearchMode]);
|
||||||
return (
|
return (
|
||||||
<>
|
<BasePostLayout>
|
||||||
<div className="p-4">
|
<MyDutyListContainer></MyDutyListContainer>
|
||||||
<PostList
|
</BasePostLayout>
|
||||||
renderItem={(post) => (
|
|
||||||
<CourseCard edit course={post}></CourseCard>
|
|
||||||
)}
|
|
||||||
params={{
|
|
||||||
pageSize: 12,
|
|
||||||
where: {
|
|
||||||
authorId: user.id,
|
|
||||||
...searchCondition,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
cols={4}></PostList>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
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: {
|
||||||
|
type: PostType.COURSE,
|
||||||
|
students: {
|
||||||
|
some: {
|
||||||
|
id: user?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...termsCondition,
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,31 +1,17 @@
|
||||||
import PostList from "@web/src/components/models/course/list/PostList";
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||||
import { useMainContext } from "../layout/MainProvider";
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
import CourseCard from "../courses/components/CourseCard";
|
import MyLearningListContainer from "./components/MyLearningListContainer";
|
||||||
|
import { PostType } from "@nice/common";
|
||||||
|
|
||||||
export default function MyLearningPage() {
|
export default function MyLearningPage() {
|
||||||
const { user } = useAuth();
|
const { setSearchMode } = useMainContext();
|
||||||
const { searchCondition } = useMainContext();
|
useEffect(() => {
|
||||||
|
setSearchMode(PostType.COURSE);
|
||||||
|
}, [setSearchMode]);
|
||||||
return (
|
return (
|
||||||
<>
|
<BasePostLayout>
|
||||||
<div className="p-4">
|
<MyLearningListContainer></MyLearningListContainer>
|
||||||
<PostList
|
</BasePostLayout>
|
||||||
renderItem={(post) => (
|
|
||||||
<CourseCard edit={false} course={post}></CourseCard>
|
|
||||||
)}
|
|
||||||
params={{
|
|
||||||
pageSize: 12,
|
|
||||||
where: {
|
|
||||||
students: {
|
|
||||||
some: {
|
|
||||||
id: user?.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...searchCondition,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
cols={4}></PostList>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
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,
|
||||||
|
authorId: user.id,
|
||||||
|
...termsCondition,
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
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?.meta?.views || 0}`}
|
||||||
|
</span>
|
||||||
|
{post?.studentIds && post?.studentIds?.length > 0 && (
|
||||||
|
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||||
|
<BookOutlined />
|
||||||
|
{`${post?.studentIds?.length || 0}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeptInfo;
|
|
@ -1,94 +0,0 @@
|
||||||
import { Card, Rate, Tag, Typography, Button } from "antd";
|
|
||||||
import {
|
|
||||||
PlayCircleOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { PostDto, TaxonomySlug } from "@nice/common";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
interface pathCardProps {
|
|
||||||
path: PostDto;
|
|
||||||
}
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
export default function PathCard({ path }: pathCardProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const handleClick = (path: PostDto) => {
|
|
||||||
navigate(`/path/editor/${path.id}`);
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth", })
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
onClick={() => handleClick(path)}
|
|
||||||
key={path.id}
|
|
||||||
hoverable
|
|
||||||
className="group overflow-hidden rounded-xl border border-gray-200 bg-white
|
|
||||||
shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
|
||||||
cover={
|
|
||||||
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${path?.meta?.thumbnail})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" /> */}
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div className="px-4">
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
{path?.terms?.map((term) => {
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
key={term.id}
|
|
||||||
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
|
||||||
color={
|
|
||||||
term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.CATEGORY
|
|
||||||
? "blue"
|
|
||||||
: term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.LEVEL
|
|
||||||
? "green"
|
|
||||||
: "orange"
|
|
||||||
}
|
|
||||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
|
|
||||||
{term.name}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Title
|
|
||||||
level={4}
|
|
||||||
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
|
||||||
<button> {path.title}</button>
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
|
||||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
|
||||||
<div className="ml-2 flex items-center flex-grow">
|
|
||||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
|
||||||
{path?.depts?.length > 1
|
|
||||||
? `${path.depts[0].name}等`
|
|
||||||
: path?.depts?.[0]?.name}
|
|
||||||
{/* {path?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
|
|
||||||
{/* {path?.depts?.map((dept)=>{return dept.name})} */}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-gray-500">
|
|
||||||
{path?.meta?.views
|
|
||||||
? `观看次数 ${path?.meta?.views}`
|
|
||||||
: null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4 border-t border-gray-100 text-center">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
|
||||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
|
||||||
立即学习
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,54 +1,25 @@
|
||||||
import PostList from "@web/src/components/models/course/list/PostList";
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
import { useMainContext } from "../../layout/MainProvider";
|
||||||
import { PostType, Prisma } from "@nice/common";
|
import { PostType, Prisma } from "@nice/common";
|
||||||
import { useMemo } from "react";
|
import PostCard from "@web/src/components/models/post/PostCard";
|
||||||
import PathCard from "./PathCard";
|
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||||
|
|
||||||
export function PathListContainer() {
|
export function PathListContainer() {
|
||||||
const { searchValue, selectedTerms } = useMainContext();
|
const { searchCondition, termsCondition } = useMainContext();
|
||||||
const termFilters = useMemo(() => {
|
|
||||||
return Object.entries(selectedTerms)
|
|
||||||
.filter(([, terms]) => terms.length > 0)
|
|
||||||
.map(([, terms]) => terms);
|
|
||||||
}, [selectedTerms]);
|
|
||||||
const searchCondition: Prisma.StringNullableFilter = {
|
|
||||||
contains: searchValue,
|
|
||||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PostList
|
<PostList
|
||||||
renderItem={(post) => <PathCard path={post}></PathCard>}
|
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||||
params={{
|
params={{
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
where: {
|
where: {
|
||||||
type: PostType.PATH,
|
type: PostType.PATH,
|
||||||
AND: termFilters.map((termFilter) => ({
|
...termsCondition,
|
||||||
terms: {
|
...searchCondition,
|
||||||
some: {
|
|
||||||
id: {
|
|
||||||
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
OR: [
|
|
||||||
{ title: searchCondition },
|
|
||||||
{ subTitle: searchCondition },
|
|
||||||
{ content: searchCondition },
|
|
||||||
{
|
|
||||||
terms: {
|
|
||||||
some: {
|
|
||||||
name: searchCondition,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
cols={4}></PostList>
|
cols={4}></PostList>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PathListContainer;
|
export default PathListContainer;
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Tag } from "antd";
|
||||||
|
import { PostDto, TaxonomySlug } from "@nice/common";
|
||||||
|
|
||||||
|
const TermInfo = ({ post }: { post: PostDto }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{post?.terms && post?.terms?.length > 0 ? (
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{post?.terms?.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;
|
|
@ -2,9 +2,11 @@ import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function PathEditorPage() {
|
export default function PathEditorPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
return <div className="p-2">
|
return (
|
||||||
<MindEditor id={id}></MindEditor>
|
<div className="p-2 min-h-screen">
|
||||||
</div>
|
<MindEditor id={id}></MindEditor>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import PathFilter from "../components/PathFilter";
|
|
||||||
import PathListContainer from "../components/PathListContainer";
|
|
||||||
|
|
||||||
export function PathListLayout() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<div className=" flex">
|
|
||||||
<div className="w-1/6">
|
|
||||||
<PathFilter></PathFilter>
|
|
||||||
</div>
|
|
||||||
<div className="w-5/6 p-4">
|
|
||||||
<PathListContainer></PathListContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default PathListLayout;
|
|
|
@ -1,5 +1,17 @@
|
||||||
import PathListLayout from "./layout/PathListLayout";
|
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";
|
||||||
|
|
||||||
export default function PathPage() {
|
export default function PathPage() {
|
||||||
return <PathListLayout />
|
const { setSearchMode } = useMainContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchMode(PostType.PATH);
|
||||||
|
}, [setSearchMode]);
|
||||||
|
return (
|
||||||
|
<BasePostLayout>
|
||||||
|
<PathListContainer></PathListContainer>
|
||||||
|
</BasePostLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
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" ? undefined : searchMode,
|
||||||
|
...termsCondition,
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ const CollapsibleContent: React.FC<CollapsibleContentProps> = ({ content }) => {
|
||||||
const contentWrapperRef = useRef(null);
|
const contentWrapperRef = useRef(null);
|
||||||
return (
|
return (
|
||||||
<div className=" text-base ">
|
<div className=" text-base ">
|
||||||
<div className=" flex flex-col gap-4 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
<div className=" flex flex-col gap-4 transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||||
{/* 包装整个内容区域的容器 */}
|
{/* 包装整个内容区域的容器 */}
|
||||||
<div ref={contentWrapperRef}>
|
<div ref={contentWrapperRef}>
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { Button, Card, Empty, Form, Space, Spin, message, theme } from 'antd';
|
import { Button, Card, Empty, Form, Space, Spin, message, theme } from "antd";
|
||||||
import NodeMenu from './NodeMenu';
|
import NodeMenu from "./NodeMenu";
|
||||||
import { useEntity, api, usePost } from '@nice/client';
|
import { api, usePost } from "@nice/client";
|
||||||
import { ObjectType, postDetailSelect, PostDto, PostType, Prisma, Taxonomy } from '@nice/common';
|
import {
|
||||||
import TermSelect from '../../models/term/term-select';
|
ObjectType,
|
||||||
import DepartmentSelect from '../../models/department/department-select';
|
postDetailSelect,
|
||||||
import { useEffect, useRef, useState } from 'react';
|
PostDto,
|
||||||
import toast from 'react-hot-toast';
|
PostType,
|
||||||
import { MindElixirInstance } from 'mind-elixir';
|
Prisma,
|
||||||
import MindElixir from 'mind-elixir';
|
RolePerms,
|
||||||
import { useTusUpload } from '@web/src/hooks/useTusUpload';
|
Taxonomy,
|
||||||
import { useNavigate } from 'react-router-dom';
|
} from "@nice/common";
|
||||||
|
import TermSelect from "../../models/term/term-select";
|
||||||
|
import DepartmentSelect from "../../models/department/department-select";
|
||||||
|
import { 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";
|
||||||
const MIND_OPTIONS = {
|
const MIND_OPTIONS = {
|
||||||
direction: MindElixir.SIDE,
|
direction: MindElixir.SIDE,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
@ -17,53 +26,60 @@ const MIND_OPTIONS = {
|
||||||
toolBar: true,
|
toolBar: true,
|
||||||
nodeMenu: true,
|
nodeMenu: true,
|
||||||
keypress: true,
|
keypress: true,
|
||||||
locale: 'zh_CN' as const,
|
locale: "zh_CN" as const,
|
||||||
theme: {
|
theme: {
|
||||||
name: 'Latte',
|
name: "Latte",
|
||||||
palette: [
|
palette: [
|
||||||
'#dd7878',
|
"#dd7878",
|
||||||
'#ea76cb',
|
"#ea76cb",
|
||||||
'#8839ef',
|
"#8839ef",
|
||||||
'#e64553',
|
"#e64553",
|
||||||
'#fe640b',
|
"#fe640b",
|
||||||
'#df8e1d',
|
"#df8e1d",
|
||||||
'#40a02b',
|
"#40a02b",
|
||||||
'#209fb5',
|
"#209fb5",
|
||||||
'#1e66f5',
|
"#1e66f5",
|
||||||
'#7287fd',
|
"#7287fd",
|
||||||
],
|
],
|
||||||
cssVar: {
|
cssVar: {
|
||||||
'--main-color': '#444446',
|
"--main-color": "#444446",
|
||||||
'--main-bgcolor': '#ffffff',
|
"--main-bgcolor": "#ffffff",
|
||||||
'--color': '#777777',
|
"--color": "#777777",
|
||||||
'--bgcolor': '#f6f6f6',
|
"--bgcolor": "#f6f6f6",
|
||||||
'--panel-color': '#444446',
|
"--panel-color": "#444446",
|
||||||
'--panel-bgcolor': '#ffffff',
|
"--panel-bgcolor": "#ffffff",
|
||||||
'--panel-border-color': '#eaeaea',
|
"--panel-border-color": "#eaeaea",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
export default function MindEditor({ id }: { id?: string }) {
|
export default function MindEditor({ id }: { id?: string }) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||||
|
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||||
|
|
||||||
const { data: post, isLoading }: { data: PostDto, isLoading: boolean } = api.post.findFirst.useQuery({
|
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } =
|
||||||
where: {
|
api.post.findFirst.useQuery({
|
||||||
id
|
where: {
|
||||||
},
|
id,
|
||||||
select: postDetailSelect
|
},
|
||||||
})
|
select: postDetailSelect,
|
||||||
const navigate = useNavigate()
|
});
|
||||||
|
const canEdit: boolean = useMemo(() => {
|
||||||
|
//登录了且是作者、超管、无id新建模式
|
||||||
|
const isAuth = isAuthenticated && user?.id == post?.author.id
|
||||||
|
return !Boolean(id) || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||||
|
}, [user])
|
||||||
|
const navigate = useNavigate();
|
||||||
const { create, update } = usePost();
|
const { create, update } = usePost();
|
||||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||||
type: ObjectType.COURSE,
|
type: ObjectType.COURSE,
|
||||||
});
|
});
|
||||||
const { handleFileUpload } = useTusUpload()
|
const { handleFileUpload } = useTusUpload();
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (post && form && instance && id) {
|
if (post && form && instance && id) {
|
||||||
console.log(post)
|
console.log(post);
|
||||||
instance.refresh((post as any).meta)
|
instance.refresh((post as any).meta);
|
||||||
const deptIds = (post?.depts || [])?.map((dept) => dept.id);
|
const deptIds = (post?.depts || [])?.map((dept) => dept.id);
|
||||||
const formData = {
|
const formData = {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
|
@ -81,117 +97,145 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
const mind = new MindElixir({
|
const mind = new MindElixir({
|
||||||
...MIND_OPTIONS,
|
...MIND_OPTIONS,
|
||||||
el: containerRef.current,
|
el: containerRef.current,
|
||||||
|
before:{
|
||||||
|
beginEdit(){
|
||||||
|
return canEdit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
draggable: canEdit, // 禁用拖拽
|
||||||
|
contextMenu: canEdit, // 禁用右键菜单
|
||||||
|
toolBar: canEdit, // 禁用工具栏
|
||||||
|
nodeMenu: canEdit, // 禁用节点右键菜单
|
||||||
|
keypress: canEdit, // 禁用键盘快捷键
|
||||||
});
|
});
|
||||||
mind.init(MindElixir.new('新学习路径'));
|
mind.init(MindElixir.new("新学习路径"));
|
||||||
containerRef.current.hidden = true;
|
containerRef.current.hidden = true;
|
||||||
setInstance(mind);
|
setInstance(mind);
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((!id || post) && instance) {
|
if ((!id || post) && instance) {
|
||||||
containerRef.current.hidden = false
|
containerRef.current.hidden = false;
|
||||||
instance.toCenter()
|
instance.toCenter();
|
||||||
instance.refresh((post as any)?.meta)
|
instance.refresh((post as any)?.meta);
|
||||||
}
|
}
|
||||||
}, [id, post, instance])
|
}, [id, post, instance]);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
const values = form.getFieldsValue()
|
const values = form.getFieldsValue();
|
||||||
const imgBlob = await instance?.exportPng()
|
const imgBlob = await instance?.exportPng();
|
||||||
handleFileUpload(imgBlob, async (result) => {
|
handleFileUpload(
|
||||||
const termIds = taxonomies.map((tax) => values[tax.id]).filter((id) => id);
|
imgBlob,
|
||||||
const deptIds = (values?.deptIds || []) as string[];
|
async (result) => {
|
||||||
const { theme, ...data } = instance.getData();
|
const termIds = taxonomies
|
||||||
try {
|
.map((tax) => values[tax.id])
|
||||||
if (post && id) {
|
.filter((id) => id);
|
||||||
const params: Prisma.PostUpdateArgs = {
|
const deptIds = (values?.deptIds || []) as string[];
|
||||||
where: {
|
const { theme, ...data } = instance.getData();
|
||||||
id
|
try {
|
||||||
},
|
if (post && id) {
|
||||||
data: {
|
const params: Prisma.PostUpdateArgs = {
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
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,
|
title: data.nodeData.topic,
|
||||||
meta: { ...data, thumbnail: result.compressedUrl },
|
meta: { ...data, thumbnail: result.compressedUrl },
|
||||||
terms: {
|
terms: {
|
||||||
set: termIds.map((id) => ({ id }))
|
connect: termIds.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
depts: {
|
depts: {
|
||||||
set: deptIds.map((id) => ({ id })),
|
connect: deptIds.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const res = await create.mutateAsync({ data: params });
|
||||||
|
navigate(`/path/editor/${res.id}`, { replace: true });
|
||||||
|
toast.success("创建成功");
|
||||||
}
|
}
|
||||||
await update.mutateAsync(params);
|
} catch (error) {
|
||||||
toast.success('更新成功');
|
toast.error("保存失败");
|
||||||
} else {
|
throw error;
|
||||||
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) {
|
console.log(result);
|
||||||
toast.error('保存失败');
|
},
|
||||||
throw error;
|
(error) => { },
|
||||||
}
|
`mind-thumb-${new Date().toString()}`
|
||||||
console.log(result)
|
);
|
||||||
}, (error) => { }, `mind-thumb-${new Date().toString()}`)
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className=' flex flex-col border rounded-lg overflow-hidden'>
|
<div className="grid grid-cols-1 flex-col w-[90vw] my-5 h-[90vh] border rounded-lg mx-auto">
|
||||||
{taxonomies && (
|
{canEdit && taxonomies && (
|
||||||
<Form onFinish={(values) => {
|
<Form
|
||||||
console.log(values)
|
form={form}
|
||||||
}} form={form} className=' bg-white p-2 '>
|
className=" bg-white p-4 ">
|
||||||
<div className='flex items-center justify-between gap-4'>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className='flex items-center gap-4'>
|
<div className="flex items-center gap-4">
|
||||||
{taxonomies.map((tax, index) => (
|
{taxonomies.map((tax, index) => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key={tax.id}
|
key={tax.id}
|
||||||
name={tax.id}
|
name={tax.id}
|
||||||
rules={[{ required: false }]}
|
// rules={[{ required: true }]}
|
||||||
noStyle
|
noStyle>
|
||||||
>
|
|
||||||
<TermSelect
|
<TermSelect
|
||||||
className=' w-48'
|
className=" w-48"
|
||||||
placeholder={`请选择${tax.name}`}
|
placeholder={`请选择${tax.name}`}
|
||||||
taxonomyId={tax.id}
|
taxonomyId={tax.id}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
<Form.Item name="deptIds" noStyle>
|
<Form.Item
|
||||||
<DepartmentSelect className='w-96' placeholder='请选择制作单位' multiple />
|
// rules={[{ required: true }]}
|
||||||
|
name="deptIds"
|
||||||
|
noStyle>
|
||||||
|
<DepartmentSelect
|
||||||
|
className="w-96"
|
||||||
|
placeholder="请选择制作单位"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<Button ghost type='primary' onClick={handleSave} >{id ? '更新' : '保存'}</Button>
|
<Button ghost type="primary" onSubmit={(e) => e.preventDefault()} onClick={handleSave}>
|
||||||
|
{id ? "更新" : "保存"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
|
||||||
}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className='mind-editor'
|
|
||||||
/>
|
|
||||||
{instance && (<NodeMenu mind={instance} />
|
|
||||||
)}
|
)}
|
||||||
{isLoading && <div className='py-64 justify-center flex' style={{ height: "calc(100vh - 287px)" }}>
|
<div ref={containerRef} className="mind-editor" onContextMenu={(e)=>e.preventDefault()} />
|
||||||
<Spin size='large'></Spin>
|
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||||
</div>}
|
{isLoading && (
|
||||||
{!post && id && !isLoading && <div className='py-64' style={{ height: "calc(100vh - 287px)" }}>
|
<div
|
||||||
<Empty></Empty>
|
className="py-64 justify-center flex"
|
||||||
</div>}
|
style={{ height: "calc(100vh - 287px)" }}>
|
||||||
|
<Spin size="large"></Spin>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!post && id && !isLoading && (
|
||||||
|
<div
|
||||||
|
className="py-64"
|
||||||
|
style={{ height: "calc(100vh - 287px)" }}>
|
||||||
|
<Empty></Empty>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default function ResourcesShower({
|
||||||
const imageResources = dealedResources.filter((res) => res.isImage);
|
const imageResources = dealedResources.filter((res) => res.isImage);
|
||||||
const fileResources = dealedResources.filter((res) => !res.isImage);
|
const fileResources = dealedResources.filter((res) => !res.isImage);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
{imageResources.length > 0 && (
|
{imageResources.length > 0 && (
|
||||||
<Row gutter={[16, 16]} className="mb-6">
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
<Image.PreviewGroup>
|
<Image.PreviewGroup>
|
||||||
|
@ -82,6 +82,7 @@ export default function ResourcesShower({
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
<div className=" text-sm px-2">附件:</div>
|
||||||
{fileResources.length > 0 && (
|
{fileResources.length > 0 && (
|
||||||
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
||||||
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { CourseDto } from "@nice/common";
|
|
||||||
import { Card } from "@web/src/components/common/container/Card";
|
|
||||||
import { CourseHeader } from "./CourseHeader";
|
|
||||||
import { CourseStats } from "./CourseStats";
|
|
||||||
import { Popover } from "@web/src/components/presentation/popover";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface CourseCardProps {
|
|
||||||
course: CourseDto;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
|
|
||||||
return (
|
|
||||||
<Card onClick={onClick} className="w-full max-w-sm">
|
|
||||||
<CourseHeader
|
|
||||||
title={course.title}
|
|
||||||
subTitle={course.subTitle}
|
|
||||||
thumbnail={course.thumbnail}
|
|
||||||
level={course.level}
|
|
||||||
numberOfStudents={course.numberOfStudents}
|
|
||||||
publishedAt={course.publishedAt}
|
|
||||||
/>
|
|
||||||
<CourseStats
|
|
||||||
averageRating={course.averageRating}
|
|
||||||
numberOfReviews={course.numberOfReviews}
|
|
||||||
completionRate={course.completionRate}
|
|
||||||
totalDuration={course.totalDuration}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -64,6 +64,7 @@ export function CourseDetailProvider({
|
||||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||||
return isAuthor || isRoot;
|
return isAuthor || isRoot;
|
||||||
}, [user, course]);
|
}, [user, course]);
|
||||||
|
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(lectureId || undefined);
|
>(lectureId || undefined);
|
||||||
|
@ -89,7 +90,9 @@ export function CourseDetailProvider({
|
||||||
}
|
}
|
||||||
}, [course]);
|
}, [course]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
if (lectureId !== selectedLectureId) {
|
||||||
|
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||||
|
}
|
||||||
}, [selectedLectureId, editId]);
|
}, [selectedLectureId, editId]);
|
||||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -2,27 +2,29 @@ import { Course, TaxonomySlug } from "@nice/common";
|
||||||
import React, { useContext, useMemo } from "react";
|
import React, { useContext, useMemo } from "react";
|
||||||
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
import {
|
|
||||||
BookOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
EditTwoTone,
|
|
||||||
EyeOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 = () => {
|
export const CourseDetailDescription: React.FC = () => {
|
||||||
const { course, isLoading, selectedLectureId, setSelectedLectureId } =
|
const {
|
||||||
useContext(CourseDetailContext);
|
course,
|
||||||
const { Paragraph, Title } = Typography;
|
canEdit,
|
||||||
|
isLoading,
|
||||||
|
selectedLectureId,
|
||||||
|
setSelectedLectureId,
|
||||||
|
userIsLearning,
|
||||||
|
lecture = null,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { update } = useStaff();
|
||||||
const firstLectureId = useMemo(() => {
|
const firstLectureId = useMemo(() => {
|
||||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||||
}, [course]);
|
}, [course]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { canEdit } = useContext(CourseDetailContext);
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
||||||
|
@ -31,49 +33,44 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!selectedLectureId && course?.meta?.thumbnail && (
|
{!selectedLectureId && (
|
||||||
<>
|
<div className="relative mb-4 overflow-hidden flex justify-center items-center">
|
||||||
<div className="relative mb-4 overflow-hidden flex justify-center items-center">
|
{
|
||||||
<Image
|
<Image
|
||||||
src={course?.meta?.thumbnail}
|
src={course.meta.thumbnail}
|
||||||
preview={false}
|
preview={false}
|
||||||
className="w-full h-full object-cover z-0"
|
className="w-full h-full object-cover z-0"
|
||||||
|
fallback="/placeholder.webp"
|
||||||
/>
|
/>
|
||||||
<div
|
}
|
||||||
onClick={() => {
|
<div
|
||||||
setSelectedLectureId(firstLectureId);
|
onClick={async () => {
|
||||||
}}
|
setSelectedLectureId(firstLectureId);
|
||||||
className="w-full h-full absolute top-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">
|
if (!userIsLearning) {
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10">
|
await update.mutateAsync({
|
||||||
点击进入学习
|
where: { id: user?.id },
|
||||||
</div>
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
connect: {
|
||||||
|
id: course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full h-full absolute top-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>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||||
{course?.subTitle && <div>{course?.subTitle}</div>}
|
{course?.subTitle && <div>{course?.subTitle}</div>}
|
||||||
{course.terms.map((term) => {
|
<TermInfo post={course}></TermInfo>
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
key={term.id}
|
|
||||||
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
|
||||||
color={
|
|
||||||
term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.CATEGORY
|
|
||||||
? "blue"
|
|
||||||
: term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.LEVEL
|
|
||||||
? "green"
|
|
||||||
: "orange"
|
|
||||||
}
|
|
||||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
|
|
||||||
{term.name}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
|
|
|
@ -65,8 +65,8 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
{!lectureIsLoading &&
|
{!lectureIsLoading &&
|
||||||
selectedLectureId &&
|
selectedLectureId &&
|
||||||
lecture?.meta?.type === LectureType.ARTICLE && (
|
lecture?.meta?.type === LectureType.ARTICLE && (
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
|
||||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
<div className="w-full rounded-lg ">
|
||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
content={lecture?.content || ""}
|
content={lecture?.content || ""}
|
||||||
maxHeight={500} // Optional, defaults to 150
|
maxHeight={500} // Optional, defaults to 150
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default function CourseDetailLayout() {
|
||||||
{/* 添加 Header 组件 */}
|
{/* 添加 Header 组件 */}
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
||||||
<div className="pt-16 px-32">
|
<div className="pt-12 px-32">
|
||||||
{" "}
|
{" "}
|
||||||
{/* 添加这个包装 div */}
|
{/* 添加这个包装 div */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
@ -12,29 +12,34 @@ import dayjs from "dayjs";
|
||||||
import CourseOperationBtns from "./JoinLearingButton";
|
import CourseOperationBtns from "./JoinLearingButton";
|
||||||
|
|
||||||
export default function CourseDetailTitle() {
|
export default function CourseDetailTitle() {
|
||||||
const {
|
const { course } = useContext(CourseDetailContext);
|
||||||
course,
|
|
||||||
isLoading,
|
|
||||||
canEdit,
|
|
||||||
lecture,
|
|
||||||
lectureIsLoading,
|
|
||||||
selectedLectureId,
|
|
||||||
} = useContext(CourseDetailContext);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
<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">
|
<div className="flex justify-start w-full text-2xl font-bold">
|
||||||
{course?.title}
|
{course?.title}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||||
|
{course?.author?.showname && (
|
||||||
|
<div>
|
||||||
|
发布者:
|
||||||
|
{course?.author?.showname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CalendarOutlined></CalendarOutlined>
|
<CalendarOutlined></CalendarOutlined>
|
||||||
{"创建于:"}
|
{"发布于:"}
|
||||||
{dayjs(course?.createdAt).format("YYYY年M月D日")}
|
{dayjs(course?.createdAt).format("YYYY年M月D日")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<ReloadOutlined spin></ReloadOutlined>
|
{"最后更新:"}
|
||||||
{"更新于:"}
|
|
||||||
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
|
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
|
@ -58,7 +58,7 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: isOpen ? "25%" : "0",
|
width: isOpen ? "25%" : "0",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: isHeaderVisible ? "64px" : "0",
|
top: isHeaderVisible ? "56px" : "0",
|
||||||
}}
|
}}
|
||||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|
|
@ -98,12 +98,18 @@ export function CourseFormProvider({
|
||||||
thumbnail: values?.meta?.thumbnail,
|
thumbnail: values?.meta?.thumbnail,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
terms: {
|
terms:
|
||||||
set: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
termIds?.length > 0
|
||||||
},
|
? {
|
||||||
depts: {
|
set: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||||
set: deptIds.map((id) => ({ id })),
|
}
|
||||||
},
|
: undefined,
|
||||||
|
depts:
|
||||||
|
deptIds?.length > 0
|
||||||
|
? {
|
||||||
|
set: deptIds.map((id) => ({ id })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
// 删除原始的 taxonomy 字段
|
// 删除原始的 taxonomy 字段
|
||||||
taxonomies.forEach((tax) => {
|
taxonomies.forEach((tax) => {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import CollapsibleContent from "@web/src/components/common/container/Collapsible
|
||||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
|
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
|
||||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||||
|
import { set } from "idb-keyval";
|
||||||
|
|
||||||
interface SortableLectureProps {
|
interface SortableLectureProps {
|
||||||
field: Lecture;
|
field: Lecture;
|
||||||
|
@ -82,13 +83,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
resources: {
|
resources:
|
||||||
connect: [videoUrlId, ...fileIds]
|
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||||
.filter(Boolean)
|
? {
|
||||||
.map((fileId) => ({
|
connect: [videoUrlId, ...fileIds]
|
||||||
fileId,
|
.filter(Boolean)
|
||||||
})),
|
.map((fileId) => ({
|
||||||
},
|
fileId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
content: values?.content,
|
content: values?.content,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -108,13 +112,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
resources: {
|
resources:
|
||||||
connect: [videoUrlId, ...fileIds]
|
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||||
.filter(Boolean)
|
? {
|
||||||
.map((fileId) => ({
|
connect: [videoUrlId, ...fileIds]
|
||||||
fileId,
|
.filter(Boolean)
|
||||||
})),
|
.map((fileId) => ({
|
||||||
},
|
fileId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
content: values?.content,
|
content: values?.content,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -199,13 +206,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={["meta", "fileIds"]}
|
name={["meta", "fileIds"]}
|
||||||
className="mb-0 flex-1"
|
className="mb-0 flex-1">
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: "请传入文件",
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<TusUploader multiple={true} />
|
<TusUploader multiple={true} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -135,7 +135,7 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Space>
|
<Space className=" flex">
|
||||||
<DragOutlined
|
<DragOutlined
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
|
@ -143,19 +143,28 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
|
||||||
/>
|
/>
|
||||||
<span>{field.title || "未命名章节"}</span>
|
<span>{field.title || "未命名章节"}</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
onClick={() => setEditing(true)}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button type="link" danger onClick={remove}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
</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"}>
|
key={field.id || "new"}>
|
||||||
{children}
|
{children}
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Pagination, Empty, Skeleton } from "antd";
|
import { Pagination, Empty, Skeleton } from "antd";
|
||||||
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common";
|
import { courseDetailSelect, CourseDto, PostDto, Prisma } from "@nice/common";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
interface PostListProps {
|
interface PostListProps {
|
||||||
params?: {
|
params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
@ -12,8 +12,7 @@ interface PostListProps {
|
||||||
};
|
};
|
||||||
cols?: number;
|
cols?: number;
|
||||||
showPagination?: boolean;
|
showPagination?: boolean;
|
||||||
renderItem: (post: any) => React.ReactNode
|
renderItem: (post: PostDto) => React.ReactNode;
|
||||||
|
|
||||||
}
|
}
|
||||||
interface PostPagnationProps {
|
interface PostPagnationProps {
|
||||||
data: {
|
data: {
|
||||||
|
@ -21,13 +20,12 @@ interface PostPagnationProps {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
export default function PostList({
|
export default function PostList({
|
||||||
params,
|
params,
|
||||||
cols = 3,
|
cols = 3,
|
||||||
showPagination = true,
|
showPagination = true,
|
||||||
renderItem
|
renderItem,
|
||||||
}: PostListProps) {
|
}: PostListProps) {
|
||||||
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
|
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
|
||||||
const { data, isLoading }: PostPagnationProps =
|
const { data, isLoading }: PostPagnationProps =
|
||||||
|
@ -72,9 +70,9 @@ export default function PostList({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
|
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
|
||||||
) : (
|
) : (
|
||||||
posts.map((post) => <div key={post.id}>
|
posts.map((post) => (
|
||||||
{renderItem(post)}
|
<div key={post.id}>{renderItem(post)}</div>
|
||||||
</div>)
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showPagination && (
|
{showPagination && (
|
||||||
|
@ -91,7 +89,6 @@ export default function PostList({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-64">
|
<div className="py-64">
|
||||||
|
|
||||||
<Empty description="暂无数据" />
|
<Empty description="暂无数据" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Card, Typography, Button, Empty } 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);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={() => handleClick(post)}
|
||||||
|
key={post?.id}
|
||||||
|
hoverable
|
||||||
|
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||||
|
cover={
|
||||||
|
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 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 post={post}></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
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -159,5 +159,4 @@
|
||||||
.custom-table .ant-table-tbody>tr:last-child>td {
|
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,8 @@ import PathEditorPage from "../app/main/path/editor/page";
|
||||||
import { CoursePreview } from "../app/main/course/preview/page";
|
import { CoursePreview } from "../app/main/course/preview/page";
|
||||||
import MyLearningPage from "../app/main/my-learning/page";
|
import MyLearningPage from "../app/main/my-learning/page";
|
||||||
import MyDutyPage from "../app/main/my-duty/page";
|
import MyDutyPage from "../app/main/my-duty/page";
|
||||||
|
import MyPathPage from "../app/main/my-path/page";
|
||||||
|
import SearchPage from "../app/main/search/page";
|
||||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
|
@ -68,14 +70,22 @@ export const routes: CustomRouteObject[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "editor/:id?",
|
path: "editor/:id?",
|
||||||
element: <PathEditorPage></PathEditorPage>
|
element: <PathEditorPage></PathEditorPage>,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "courses",
|
path: "courses",
|
||||||
element: <CoursesPage></CoursesPage>,
|
element: <CoursesPage></CoursesPage>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "my-path",
|
||||||
|
element: (
|
||||||
|
<WithAuth>
|
||||||
|
<MyPathPage></MyPathPage>
|
||||||
|
</WithAuth>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "my-duty",
|
path: "my-duty",
|
||||||
element: (
|
element: (
|
||||||
|
@ -92,6 +102,10 @@ export const routes: CustomRouteObject[] = [
|
||||||
</WithAuth>
|
</WithAuth>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "search",
|
||||||
|
element: <SearchPage></SearchPage>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
||||||
element: <CourseDetailPage />,
|
element: <CourseDetailPage />,
|
||||||
|
|
|
@ -40,19 +40,23 @@ export type PostDto = Post & {
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
// edit: boolean;
|
// edit: boolean;
|
||||||
};
|
};
|
||||||
|
meta?: PostMeta;
|
||||||
watchableDepts: Department[];
|
watchableDepts: Department[];
|
||||||
watchableStaffs: Staff[];
|
watchableStaffs: Staff[];
|
||||||
terms: TermDto[]
|
terms: TermDto[];
|
||||||
depts: DepartmentDto[]
|
depts: DepartmentDto[];
|
||||||
meta?: {
|
|
||||||
thumbnail?: string
|
|
||||||
views?: number
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LectureMeta = {
|
studentIds?: string[];
|
||||||
type?: string;
|
};
|
||||||
|
export type PostMeta = {
|
||||||
|
thumbnail?: string;
|
||||||
views?: number;
|
views?: number;
|
||||||
|
likes?: number;
|
||||||
|
hates?: number;
|
||||||
|
};
|
||||||
|
export type LectureMeta = PostMeta & {
|
||||||
|
type?: string;
|
||||||
|
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
videoThumbnail?: string;
|
videoThumbnail?: string;
|
||||||
videoIds?: string[];
|
videoIds?: string[];
|
||||||
|
@ -64,7 +68,7 @@ export type Lecture = Post & {
|
||||||
meta?: LectureMeta;
|
meta?: LectureMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionMeta = {
|
export type SectionMeta = PostMeta & {
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
};
|
};
|
||||||
export type Section = Post & {
|
export type Section = Post & {
|
||||||
|
@ -73,15 +77,10 @@ export type Section = Post & {
|
||||||
export type SectionDto = Section & {
|
export type SectionDto = Section & {
|
||||||
lectures: Lecture[];
|
lectures: Lecture[];
|
||||||
};
|
};
|
||||||
export type CourseMeta = {
|
export type CourseMeta = PostMeta & {
|
||||||
thumbnail?: string;
|
|
||||||
|
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
views?: number;
|
|
||||||
likes?: number;
|
|
||||||
hates?: number;
|
|
||||||
};
|
};
|
||||||
export type Course = Post & {
|
export type Course = PostDto & {
|
||||||
meta?: CourseMeta;
|
meta?: CourseMeta;
|
||||||
};
|
};
|
||||||
export type CourseDto = Course & {
|
export type CourseDto = Course & {
|
||||||
|
@ -92,3 +91,45 @@ export type CourseDto = Course & {
|
||||||
depts: Department[];
|
depts: Department[];
|
||||||
studentIds: string[];
|
studentIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Summary = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
parent: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
export type NodeObj = {
|
||||||
|
topic: string;
|
||||||
|
id: string;
|
||||||
|
style?: {
|
||||||
|
fontSize?: string;
|
||||||
|
color?: string;
|
||||||
|
background?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
};
|
||||||
|
children?: NodeObj[];
|
||||||
|
};
|
||||||
|
export type Arrow = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
delta1: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
delta2: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type PathMeta = PostMeta & {
|
||||||
|
nodeData: NodeObj;
|
||||||
|
arrows?: Arrow[];
|
||||||
|
summaries?: Summary[];
|
||||||
|
direction?: number;
|
||||||
|
};
|
||||||
|
export type PathDto = PostDto & {
|
||||||
|
meta: PathMeta;
|
||||||
|
};
|
||||||
|
|
|
@ -6,6 +6,8 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
resources: true,
|
resources: true,
|
||||||
|
parent: true,
|
||||||
|
parentId: true,
|
||||||
// watchableDepts: true,
|
// watchableDepts: true,
|
||||||
// watchableStaffs: true,
|
// watchableStaffs: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
@ -18,9 +20,9 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
depts: true,
|
depts: true,
|
||||||
author: {
|
author: {
|
||||||
|
@ -42,12 +44,14 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: true
|
meta: true,
|
||||||
};
|
};
|
||||||
export const postUnDetailSelect: Prisma.PostSelect = {
|
export const postUnDetailSelect: Prisma.PostSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
parent: true,
|
||||||
|
parentId: true,
|
||||||
content: true,
|
content: true,
|
||||||
resources: true,
|
resources: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
@ -85,6 +89,8 @@ export const courseDetailSelect: Prisma.PostSelect = {
|
||||||
title: true,
|
title: true,
|
||||||
subTitle: true,
|
subTitle: true,
|
||||||
type: true,
|
type: true,
|
||||||
|
author: true,
|
||||||
|
authorId: true,
|
||||||
content: true,
|
content: true,
|
||||||
depts: true,
|
depts: true,
|
||||||
// isFeatured: true,
|
// isFeatured: true,
|
||||||
|
|
Loading…
Reference in New Issue