This commit is contained in:
longdayi 2025-02-27 09:27:56 +08:00
commit b9bbf5c55b
11 changed files with 239 additions and 116 deletions

View File

@ -5,16 +5,13 @@ import { useMemo } from "react";
import CourseCard from "./CourseCard"; import CourseCard from "./CourseCard";
export function CoursesContainer() { export function CoursesContainer() {
const { searchValue, selectedTerms } = useMainContext(); const { selectedTerms, searchCondition } = useMainContext();
const termFilters = useMemo(() => { const termFilters = useMemo(() => {
return Object.entries(selectedTerms) return Object.entries(selectedTerms)
.filter(([, terms]) => terms.length > 0) .filter(([, terms]) => terms.length > 0)
.map(([, terms]) => terms); .map(([, terms]) => terms);
}, [selectedTerms]); }, [selectedTerms]);
const searchCondition: Prisma.StringNullableFilter = {
contains: searchValue,
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
};
return ( return (
<> <>
<PostList <PostList
@ -32,18 +29,7 @@ export function CoursesContainer() {
}, },
}, },
})), })),
OR: [ ...searchCondition,
{ title: searchCondition },
{ subTitle: searchCondition },
{ content: searchCondition },
{
terms: {
some: {
name: searchCondition,
},
},
},
],
}, },
}} }}
cols={4}></PostList> cols={4}></PostList>

View File

@ -19,11 +19,11 @@ const CategorySection = () => {
taxonomy: { taxonomy: {
slug: TaxonomySlug.CATEGORY, slug: TaxonomySlug.CATEGORY,
}, },
parentId : null parentId: null,
}, },
take: 8, take: 8,
}); });
const navigate = useNavigate() const navigate = useNavigate();
const handleMouseEnter = useCallback((index: number) => { const handleMouseEnter = useCallback((index: number) => {
setHoveredIndex(index); setHoveredIndex(index);
@ -33,13 +33,13 @@ const CategorySection = () => {
setHoveredIndex(null); setHoveredIndex(null);
}, []); }, []);
const handleMouseClick = useCallback((categoryId:string) => { const handleMouseClick = useCallback((categoryId: string) => {
setSelectedTerms({ setSelectedTerms({
[TaxonomySlug.CATEGORY] : [categoryId] [TaxonomySlug.CATEGORY]: [categoryId],
}) });
navigate('/courses') navigate("/courses");
window.scrollTo({top: 0,behavior: "smooth",}) window.scrollTo({ top: 0, behavior: "smooth" });
},[]); }, []);
return ( return (
<section className="py-8 relative overflow-hidden"> <section className="py-8 relative overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative"> <div className="max-w-screen-2xl mx-auto px-4 relative">
@ -57,7 +57,7 @@ const CategorySection = () => {
{isLoading ? ( {isLoading ? (
<Skeleton paragraph={{ rows: 4 }}></Skeleton> <Skeleton paragraph={{ rows: 4 }}></Skeleton>
) : ( ) : (
courseCategoriesData.map((category, index) => { courseCategoriesData?.map((category, index) => {
const categoryColor = stringToColor(category.name); const categoryColor = stringToColor(category.name);
const isHovered = hoveredIndex === index; const isHovered = hoveredIndex === index;

View File

@ -1,10 +1,16 @@
import { Input, Layout, Avatar, Button, Dropdown } from "antd"; import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import { EditFilled, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons"; import {
EditFilled,
PlusOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; 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();
@ -12,11 +18,10 @@ export function MainHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const { searchValue, setSearchValue } = useMainContext(); const { searchValue, setSearchValue } = useMainContext();
return ( return (
<div className="select-none flex items-center justify-between p-4 bg-white shadow-md border-b border-gray-100 fixed w-full z-30"> <div className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
<div className="w-full max-w-screen-3xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
<div className="flex items-center gap-4"> <div className="flex items-center space-x-8">
<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">
@ -24,6 +29,8 @@ export function MainHeader() {
</div> </div>
<NavigationMenu /> <NavigationMenu />
</div> </div>
</div>
<div className=" flex justify-end gap-4 mr-2">
<Input <Input
size="large" size="large"
prefix={ prefix={
@ -35,9 +42,8 @@ export function MainHeader() {
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e) => { onPressEnter={(e) => {
if ( if (
!window.location.pathname.startsWith( !window.location.pathname.startsWith("/courses/") &&
"/courses/" !window.location.pathname.startsWith("my")
)
) { ) {
navigate(`/courses/`); navigate(`/courses/`);
window.scrollTo({ window.scrollTo({
@ -63,15 +69,15 @@ export function MainHeader() {
</Button> </Button>
</> </>
)} )}
{ {isAuthenticated && (
isAuthenticated && <Button <Button
onClick={() => { onClick={() => {
window.location.href = "/path/editor"; window.location.href = "/path/editor";
}} }}
icon={<PlusOutlined></PlusOutlined>}>
icon={<PlusOutlined></PlusOutlined>} ></Button>
} </Button>
)}
{isAuthenticated ? ( {isAuthenticated ? (
<UserMenu /> <UserMenu />
) : ( ) : (
@ -84,5 +90,6 @@ export function MainHeader() {
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -1,4 +1,11 @@
import React, { createContext, ReactNode, useContext, useState } from "react"; import { Prisma } from "packages/common/dist";
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
} from "react";
interface SelectedTerms { interface SelectedTerms {
[key: string]: string[]; // 每个 slug 对应一个 string 数组 [key: string]: string[]; // 每个 slug 对应一个 string 数组
} }
@ -8,6 +15,7 @@ interface MainContextType {
selectedTerms?: SelectedTerms; selectedTerms?: SelectedTerms;
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;
} }
const MainContext = createContext<MainContextType | null>(null); const MainContext = createContext<MainContextType | null>(null);
@ -18,6 +26,29 @@ interface MainProviderProps {
export function MainProvider({ children }: MainProviderProps) { export function MainProvider({ children }: MainProviderProps) {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态 const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
const containTextCondition: Prisma.StringNullableFilter = {
contains: searchValue,
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
};
return searchValue
? {
OR: [
{ title: containTextCondition },
{ subTitle: containTextCondition },
{ content: containTextCondition },
{
terms: {
some: {
name: containTextCondition,
},
},
},
],
}
: {};
}, [searchValue]);
return ( return (
<MainContext.Provider <MainContext.Provider
value={{ value={{
@ -25,6 +56,7 @@ export function MainProvider({ children }: MainProviderProps) {
setSearchValue, setSearchValue,
selectedTerms, selectedTerms,
setSelectedTerms, setSelectedTerms,
searchCondition,
}}> }}>
{children} {children}
</MainContext.Provider> </MainContext.Provider>

View File

@ -90,14 +90,14 @@ export function UserMenu() {
icon: <UserOutlined className="text-lg" />, icon: <UserOutlined className="text-lg" />,
label: "我创建的课程", label: "我创建的课程",
action: () => { action: () => {
navigate("/my/duty"); navigate("/my-duty");
}, },
}, },
{ {
icon: <UserOutlined className="text-lg" />, icon: <UserOutlined className="text-lg" />,
label: "我学习的课程", label: "我学习的课程",
action: () => { action: () => {
navigate("/my/learning"); navigate("/my-learning");
}, },
}, },
canManageAnyStaff && { canManageAnyStaff && {

View File

@ -1,19 +1,23 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useMainContext } from "../layout/MainProvider";
import CourseCard from "../courses/components/CourseCard"; import CourseCard from "../courses/components/CourseCard";
export default function MyDutyPage() { export default function MyDutyPage() {
const { user } = useAuth(); const { user } = useAuth();
const { searchCondition } = useMainContext();
return ( return (
<> <>
<div className="p-4"> <div className="p-4">
<PostList <PostList
renderItem={post=><CourseCard edit course={post}></CourseCard>} renderItem={(post) => (
<CourseCard edit course={post}></CourseCard>
)}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {
authorId: user.id, authorId: user.id,
...searchCondition,
}, },
}} }}
cols={4}></PostList> cols={4}></PostList>

View File

@ -1,14 +1,18 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useMainContext } from "../layout/MainProvider";
import CourseCard from "../courses/components/CourseCard"; import CourseCard from "../courses/components/CourseCard";
export default function MyLearningPage() { export default function MyLearningPage() {
const { user } = useAuth(); const { user } = useAuth();
const { searchCondition } = useMainContext();
return ( return (
<> <>
<div className="p-4"> <div className="p-4">
<PostList <PostList
renderItem={post => <CourseCard edit={false} course={post}></CourseCard>} renderItem={(post) => (
<CourseCard edit={false} course={post}></CourseCard>
)}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {
@ -17,6 +21,7 @@ export default function MyLearningPage() {
id: user?.id, id: user?.id,
}, },
}, },
...searchCondition,
}, },
}} }}
cols={4}></PostList> cols={4}></PostList>

View File

@ -19,7 +19,7 @@ export default function CourseDetailLayout() {
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true); const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
return ( return (
<div className="relative"> <div className="relative">
<CourseDetailHeader /> {/* <CourseDetailHeader /> */}
{/* 添加 Header 组件 */} {/* 添加 Header 组件 */}
{/* 主内容区域 */} {/* 主内容区域 */}

View File

@ -1,8 +1,15 @@
import { useContext } from "react"; import { useContext } from "react";
import { CourseDetailContext } from "./CourseDetailContext"; import { CourseDetailContext } from "./CourseDetailContext";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { BookOutlined, CalendarOutlined, EditTwoTone, EyeOutlined, ReloadOutlined } from "@ant-design/icons"; import {
BookOutlined,
CalendarOutlined,
EditTwoTone,
EyeOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import CourseOperationBtns from "./JoinLearingButton";
export default function CourseDetailTitle() { export default function CourseDetailTitle() {
const { const {
@ -19,14 +26,14 @@ export default function CourseDetailTitle() {
<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 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></ReloadOutlined> <ReloadOutlined spin></ReloadOutlined>
{"更新于:"} {"更新于:"}
{dayjs(course?.updatedAt).format("YYYY年M月D日")} {dayjs(course?.updatedAt).format("YYYY年M月D日")}
</div> </div>
@ -38,19 +45,7 @@ export default function CourseDetailTitle() {
<BookOutlined /> <BookOutlined />
<div>{`学习人数${course?.studentIds?.length || 0}`}</div> <div>{`学习人数${course?.studentIds?.length || 0}`}</div>
</div> </div>
{canEdit && ( <CourseOperationBtns />
<div
className="flex gap-1 text-primary hover:cursor-pointer"
onClick={() => {
const url = course?.id
? `/course/${course?.id}/editor`
: "/course/editor";
navigate(url);
}}>
<EditTwoTone></EditTwoTone>
{"点击编辑课程"}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,94 @@
import { useAuth } from "@web/src/providers/auth-provider";
import { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { CourseDetailContext } from "./CourseDetailContext";
import { useStaff } from "@nice/client";
import {
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleFilled,
CloseCircleOutlined,
EditFilled,
EditTwoTone,
LoginOutlined,
} from "@ant-design/icons";
export default function CourseOperationBtns() {
const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth();
const navigate = useNavigate();
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
const { update } = useStaff();
const [isHovered, setIsHovered] = useState(false);
const toggleLearning = async () => {
if (!userIsLearning) {
await update.mutateAsync({
where: { id: user?.id },
data: {
learningPosts: {
connect: { id: course.id },
},
},
});
} else {
await update.mutateAsync({
where: { id: user?.id },
data: {
learningPosts: {
disconnect: {
id: course.id,
},
},
},
});
}
};
return (
<>
{isAuthenticated && (
<div
onClick={toggleLearning}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${
userIsLearning
? isHovered
? "text-red-500 border-red-500 rounded-md "
: "text-green-500 "
: "text-primary "
}`}>
{userIsLearning ? (
isHovered ? (
<CloseCircleOutlined />
) : (
<CheckCircleOutlined />
)
) : (
<LoginOutlined />
)}
<span>
{userIsLearning
? isHovered
? "退出学习"
: "正在学习"
: "加入学习"}
</span>
</div>
)}
{canEdit && (
<div
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
onClick={() => {
const url = course?.id
? `/course/${course?.id}/editor`
: "/course/editor";
navigate(url);
}}>
<EditTwoTone></EditTwoTone>
{"编辑课程"}
</div>
)}
</>
);
}

View File

@ -92,6 +92,10 @@ export const routes: CustomRouteObject[] = [
</WithAuth> </WithAuth>
), ),
}, },
{
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />,
},
], ],
}, },
@ -125,10 +129,6 @@ export const routes: CustomRouteObject[] = [
}, },
], ],
}, },
{
path: ":id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />,
},
], ],
}, },
adminRoute, adminRoute,