This commit is contained in:
longdayi 2025-02-27 22:01:27 +08:00
commit c9c301a8a3
57 changed files with 1049 additions and 892 deletions

View File

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

1
apps/web/public/logo.svg Executable file

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}, },
} }
: {}, : {},
}, },
}} }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
/* 去除最后一行的底部边框 */ /* 去除最后一行的底部边框 */
} }

View File

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

View File

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

View File

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

0
web-dist/index.html Executable file → Normal file
View File