This commit is contained in:
Rao 2025-02-26 22:39:06 +08:00
commit 6042002280
13 changed files with 173 additions and 190 deletions

View File

@ -166,19 +166,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
); );
return transDto; return transDto;
} }
// async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
// if (!args.where) args.where = {};
// args.where.OR = await this.preFilter(args.where.OR, staff);
// return this.wrapResult(super.findMany(args), async (result) => {
// await Promise.all(
// result.map(async (item) => {
// await setPostRelation({ data: item, staff });
// await this.setPerms(item, staff);
// }),
// );
// return { ...result };
// });
// }
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
if (!args.where) args.where = {}; if (!args.where) args.where = {};
args.where.OR = await this.preFilter(args.where.OR, staff); args.where.OR = await this.preFilter(args.where.OR, staff);
@ -255,6 +243,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
// 批量执行更新 // 批量执行更新
return updates.length > 0 ? await db.$transaction(updates) : []; return updates.length > 0 ? await db.$transaction(updates) : [];
} }
protected async setPerms(data: Post, staff?: UserProfile) { protected async setPerms(data: Post, staff?: UserProfile) {
if (!staff) return; if (!staff) return;
const perms: ResPerm = { const perms: ResPerm = {

View File

@ -168,6 +168,21 @@ export async function setCourseInfo({ data }: { data: Post }) {
(lecture) => lecture.parentId === section.id, (lecture) => lecture.parentId === section.id,
) as any as Lecture[]; ) as any as Lecture[];
}); });
Object.assign(data, { sections, lectureCount });
const students = await db.staff.findMany({
where: {
learningPosts: {
some: {
id: data.id,
},
},
},
select: {
id: true,
},
});
const studentIds = (students || []).map((student) => student?.id);
Object.assign(data, { sections, lectureCount, studentIds });
} }
} }

View File

@ -1,5 +1,6 @@
import { Card, Tag, Typography, Button } from "antd"; import { Card, Tag, Typography, Button } from "antd";
import { import {
BookOutlined,
EyeOutlined, EyeOutlined,
PlayCircleOutlined, PlayCircleOutlined,
TeamOutlined, TeamOutlined,
@ -73,10 +74,10 @@ export default function CourseCard({ course, edit = false }: CourseCardProps) {
<button> {course.title}</button> <button> {course.title}</button>
</Title> </Title>
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group"> <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" /> <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"> <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]"> <Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
{course?.depts?.length > 1 {course?.depts?.length > 1
? `${course.depts[0].name}` ? `${course.depts[0].name}`
: course?.depts?.[0]?.name} : course?.depts?.[0]?.name}
@ -84,10 +85,16 @@ export default function CourseCard({ course, edit = false }: CourseCardProps) {
{/* {course?.depts?.map((dept)=>{return dept.name})} */} {/* {course?.depts?.map((dept)=>{return dept.name})} */}
</Text> </Text>
</div> </div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 flex items-center"> <span className="text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined className="mr-1" /> <EyeOutlined />
{`观看次数 ${course?.meta?.views || 0}`} {`观看次数 ${course?.meta?.views || 0}`}
</span> </span>
<span className="text-xs font-medium text-gray-500 flex items-center">
<BookOutlined />
{`学习人数 ${course?.studentIds?.length || 0}`}
</span>
</div> </div>
<div className="pt-4 border-t border-gray-100 text-center"> <div className="pt-4 border-t border-gray-100 text-center">
<Button <Button

View File

@ -7,11 +7,14 @@ export default function MyLearningPage() {
<> <>
<div className="p-4"> <div className="p-4">
<CourseList <CourseList
edit
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {
authorId: user.id, students: {
some: {
id: user?.id,
},
},
}, },
}} }}
cols={4}></CourseList> cols={4}></CourseList>

View File

@ -28,6 +28,7 @@ interface CourseDetailContextType {
isHeaderVisible: boolean; // 新增 isHeaderVisible: boolean; // 新增
setIsHeaderVisible: (visible: boolean) => void; // 新增 setIsHeaderVisible: (visible: boolean) => void; // 新增
canEdit?: boolean; canEdit?: boolean;
userIsLearning?: boolean;
} }
interface CourseFormProviderProps { interface CourseFormProviderProps {
@ -43,30 +44,25 @@ export function CourseDetailProvider({
}: CourseFormProviderProps) { }: CourseFormProviderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { read } = useVisitor(); const { read } = useVisitor();
const { user, hasSomePermissions } = useAuth(); const { user, hasSomePermissions, isAuthenticated } = useAuth();
const { lectureId } = useParams(); const { lectureId } = useParams();
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
(api.post as any).findFirst.useQuery( (api.post as any).findFirst.useQuery(
{ {
where: { id: editId }, where: { id: editId },
// include: { select: courseDetailSelect,
// // sections: { include: { lectures: true } },
// enrollments: true,
// terms:true
// },
select:courseDetailSelect
}, },
{ enabled: Boolean(editId) } { enabled: Boolean(editId) }
); );
const userIsLearning = useMemo(() => {
return (course?.studentIds || []).includes(user?.id);
}, [user, course, isLoading]);
const canEdit = useMemo(() => { const canEdit = useMemo(() => {
const isAuthor = user?.id === course?.authorId; const isAuthor = isAuthenticated && user?.id === course?.authorId;
const isDept = course?.depts
?.map((dept) => dept.id)
.includes(user?.deptId);
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
return isAuthor || isDept || isRoot; return isAuthor || isRoot;
}, [user, course]); }, [user, course]);
const [selectedLectureId, setSelectedLectureId] = useState< const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined string | undefined
@ -109,6 +105,7 @@ export function CourseDetailProvider({
isHeaderVisible, isHeaderVisible,
setIsHeaderVisible, setIsHeaderVisible,
canEdit, canEdit,
userIsLearning,
}}> }}>
{children} {children}
</CourseDetailContext.Provider> </CourseDetailContext.Provider>

View File

@ -3,11 +3,13 @@ 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 { import {
BookOutlined,
CalendarOutlined, CalendarOutlined,
EditTwoTone, EditTwoTone,
EyeOutlined, EyeOutlined,
PlayCircleOutlined, PlayCircleOutlined,
ReloadOutlined, ReloadOutlined,
TeamOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@ -23,7 +25,8 @@ export const CourseDetailDescription: React.FC = () => {
const { canEdit } = useContext(CourseDetailContext); 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">
<div className="w-full p-5 my-4">
{isLoading || !course ? ( {isLoading || !course ? (
<Skeleton active paragraph={{ rows: 4 }} /> <Skeleton active paragraph={{ rows: 4 }} />
) : ( ) : (
@ -41,53 +44,61 @@ export const CourseDetailDescription: React.FC = () => {
setSelectedLectureId(firstLectureId); setSelectedLectureId(firstLectureId);
}} }}
className="w-full h-full absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer"> className="w-full h-full absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" /> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10">
</div>
</div> </div>
</div> </div>
</> </>
)} )}
<div className="text-lg font-bold">{"课程简介:"}</div> <div className="text-lg font-bold">{"课程简介:"}</div>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex flex-col gap-2">
<div>{course?.subTitle}</div> <div className="flex gap-2 flex-wrap items-center float-start">
{ {course?.subTitle && <div>{course?.subTitle}</div>}
course.terms.map((term) => { {course.terms.map((term) => {
return ( return (
<Tag <Tag
key={term.id} key={term.id}
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"} // color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
color={ color={
term?.taxonomy?.slug === term?.taxonomy?.slug ===
TaxonomySlug.CATEGORY TaxonomySlug.CATEGORY
? "blue" ? "blue"
: term?.taxonomy?.slug === : term?.taxonomy?.slug ===
TaxonomySlug.LEVEL TaxonomySlug.LEVEL
? "green" ? "green"
: "orange" : "orange"
} }
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0"> className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
{term.name} {term.name}
</Tag> </Tag>
) );
}) })}
}
</div>
<div className="text-gray-800 flex justify-start gap-5">
<div className="flex gap-1">
<CalendarOutlined></CalendarOutlined>
{dayjs(course?.createdAt).format("YYYY年M月D日")}
</div> </div>
<div className="flex gap-1"> <div className="text-gray-600 flex justify-start gap-5">
<ReloadOutlined></ReloadOutlined> <div className="flex gap-1">
{dayjs(course?.updatedAt).format("YYYY年M月D日")} <CalendarOutlined></CalendarOutlined>
</div> {"创建于:"}
<div className="flex gap-1"> {dayjs(course?.createdAt).format(
<EyeOutlined></EyeOutlined> "YYYY年M月D日"
<div>{course?.meta?.views || 0}</div> )}
</div> </div>
{ <div className="flex gap-1">
canEdit && ( <ReloadOutlined></ReloadOutlined>
{"更新于:"}
{dayjs(course?.updatedAt).format(
"YYYY年M月D日"
)}
</div>
<div className="flex gap-1">
<EyeOutlined></EyeOutlined>
<div>{`观看次数${course?.meta?.views || 0}`}</div>
</div>
<div className="flex gap-1">
<BookOutlined />
<div>{`学习人数${course?.studentIds?.length || 0}`}</div>
</div>
{canEdit && (
<div <div
className="flex gap-1 text-primary hover:cursor-pointer" className="flex gap-1 text-primary hover:cursor-pointer"
onClick={() => { onClick={() => {
@ -95,13 +106,12 @@ export const CourseDetailDescription: React.FC = () => {
? `/course/${id}/editor` ? `/course/${id}/editor`
: "/course/editor"; : "/course/editor";
navigate(url); navigate(url);
}} }}>
>
<EditTwoTone></EditTwoTone> <EditTwoTone></EditTwoTone>
{"点击编辑课程"} {"点击编辑课程"}
</div> </div>
) )}
} </div>
</div> </div>
<Paragraph <Paragraph
className="text-gray-600" className="text-gray-600"

View File

@ -10,17 +10,18 @@ import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu"; import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
import { CourseDetailContext } from "../CourseDetailContext"; import { CourseDetailContext } from "../CourseDetailContext";
import { usePost, useStaff } from "@nice/client";
import toast from "react-hot-toast";
const { Header } = Layout; const { Header } = Layout;
export function CourseDetailHeader() { export function CourseDetailHeader() {
const [searchValue, setSearchValue] = useState("");
const { id } = useParams(); const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth(); useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { course, canEdit } = useContext(CourseDetailContext); const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
const { update } = useStaff();
return ( return (
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30"> <Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
@ -39,20 +40,48 @@ export function CourseDetailHeader() {
{/* <NavigationMenu /> */} {/* <NavigationMenu /> */}
</div> </div>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
{isAuthenticated && (
<Button
onClick={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,
},
},
},
});
}
}}
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 />}>
{userIsLearning ? "退出学习" : "加入学习"}
</Button>
)}
{canEdit && ( {canEdit && (
<> <Button
<Button onClick={() => {
onClick={() => { const url = id
const url = id ? `/course/${id}/editor`
? `/course/${id}/editor` : "/course/editor";
: "/course/editor"; navigate(url);
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"
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 />}>
icon={<EditFilled />}> {"编辑课程"}
{"编辑课程"} </Button>
</Button>
</>
)} )}
{isAuthenticated ? ( {isAuthenticated ? (
<UserMenu /> <UserMenu />

View File

@ -1,77 +0,0 @@
// // components/Header.tsx
// import { motion, useScroll, useTransform } from "framer-motion";
// import { useContext, useEffect, useState } from "react";
// import { CourseDetailContext } from "../CourseDetailContext";
// import { Avatar, Button, Dropdown } from "antd";
// import { UserOutlined } from "@ant-design/icons";
// import { UserMenu } from "@web/src/app/main/layout/UserMenu";
// import { useAuth } from "@web/src/providers/auth-provider";
// export const CourseDetailHeader = () => {
// const { scrollY } = useScroll();
// const { user, isAuthenticated } = useAuth();
// const [lastScrollY, setLastScrollY] = useState(0);
// const { course, isHeaderVisible, setIsHeaderVisible, lecture } =
// useContext(CourseDetailContext);
// useEffect(() => {
// const updateHeader = () => {
// const current = scrollY.get();
// const direction = current > lastScrollY ? "down" : "up";
// if (direction === "down" && current > 100) {
// setIsHeaderVisible(false);
// } else if (direction === "up") {
// setIsHeaderVisible(true);
// }
// setLastScrollY(current);
// };
// // 使用 requestAnimationFrame 来优化性能
// const unsubscribe = scrollY.on("change", () => {
// requestAnimationFrame(updateHeader);
// });
// return () => {
// unsubscribe();
// };
// }, [lastScrollY, scrollY, setIsHeaderVisible]);
// return (
// <motion.header
// initial={{ y: 0 }}
// animate={{ y: isHeaderVisible ? 0 : -100 }}
// transition={{ type: "spring", stiffness: 300, damping: 30 }}
// className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
// <div className="w-full mx-auto px-4 h-full flex items-center justify-between">
// <div className="flex items-center space-x-4">
// <h1 className="text-white text-xl ">{course?.title}</h1>
// </div>
// {isAuthenticated ? (
// <Dropdown
// overlay={<UserMenu />}
// trigger={["click"]}
// placement="bottomRight">
// <Avatar
// size="large"
// className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
// {(user?.showname ||
// user?.username ||
// "")[0]?.toUpperCase()}
// </Avatar>
// </Dropdown>
// ) : (
// <Button
// onClick={() => navigator("/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>
// </motion.header>
// );
// };
// export default CourseDetailHeader;

View File

@ -99,10 +99,10 @@ export function CourseFormProvider({
}), }),
}, },
terms: { terms: {
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式 set: termIds.map((id) => ({ id })), // 转换成 connect 格式
}, },
depts: { depts: {
connect: deptIds.map((id) => ({ id })), set: deptIds.map((id) => ({ id })),
}, },
}; };
// 删除原始的 taxonomy 字段 // 删除原始的 taxonomy 字段

View File

@ -32,7 +32,7 @@ export function CourseBasicForm() {
<Form.Item <Form.Item
name="subTitle" name="subTitle"
label="课程副标题" label="课程副标题"
rules={[{ max: 10, message: "副标题最多10个字符" }]}> rules={[{ max: 20, message: "副标题最多20个字符" }]}>
<Input placeholder="请输入课程副标题" /> <Input placeholder="请输入课程副标题" />
</Form.Item> </Form.Item>
<Form.Item name={["meta", "thumbnail"]} label="课程封面"> <Form.Item name={["meta", "thumbnail"]} label="课程封面">

View File

@ -5,39 +5,48 @@ import { ObjectType, Staff } from "@nice/common";
import { findQueryData } from "../utils"; import { findQueryData } from "../utils";
import { CrudOperation, emitDataChange } from "../../event"; import { CrudOperation, emitDataChange } from "../../event";
export function useStaff() { export function useStaff() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const queryKey = getQueryKey(api.staff); const queryKey = getQueryKey(api.staff);
const create = api.staff.create.useMutation({ const create = api.staff.create.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
emitDataChange(ObjectType.STAFF, result as any, CrudOperation.CREATED) emitDataChange(
}, ObjectType.STAFF,
}); result as any,
const updateUserDomain = api.staff.updateUserDomain.useMutation({ CrudOperation.CREATED
onSuccess: async (result) => { );
queryClient.invalidateQueries({ queryKey }); },
}, });
}); const updateUserDomain = api.staff.updateUserDomain.useMutation({
const update = api.staff.update.useMutation({ onSuccess: async (result) => {
onSuccess: (result) => { queryClient.invalidateQueries({ queryKey });
queryClient.invalidateQueries({ queryKey }); },
emitDataChange(ObjectType.STAFF, result as any, CrudOperation.UPDATED) });
}, const update = api.staff.update.useMutation({
}); onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey });
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
emitDataChange(
ObjectType.STAFF,
result as any,
CrudOperation.UPDATED
);
},
});
const softDeleteByIds = api.staff.softDeleteByIds.useMutation({ const softDeleteByIds = api.staff.softDeleteByIds.useMutation({
onSuccess: (result, variables) => { onSuccess: (result, variables) => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
}, },
}); });
const getStaff = (key: string) => { const getStaff = (key: string) => {
return findQueryData<Staff>(queryClient, api.staff, key); return findQueryData<Staff>(queryClient, api.staff, key);
}; };
return { return {
create, create,
update, update,
softDeleteByIds, softDeleteByIds,
getStaff, getStaff,
updateUserDomain updateUserDomain,
}; };
} }

View File

@ -93,7 +93,7 @@ model Staff {
posts Post[] posts Post[]
learningPost Post[] @relation("post_student") learningPosts Post[] @relation("post_student")
sentMsgs Message[] @relation("message_sender") sentMsgs Message[] @relation("message_sender")
receivedMsgs Message[] @relation("message_receiver") receivedMsgs Message[] @relation("message_receiver")
registerToken String? registerToken String?

View File

@ -83,4 +83,5 @@ export type CourseDto = Course & {
terms: TermDto[]; terms: TermDto[];
lectureCount?: number; lectureCount?: number;
depts: Department[]; depts: Department[];
studentIds: string[];
}; };