This commit is contained in:
ditiqi 2025-03-02 11:49:46 +08:00
parent c6a40da7c2
commit 93c2151af2
28 changed files with 713 additions and 409 deletions

View File

@ -15,7 +15,7 @@ import {
import { MessageService } from '../message/message.service';
import { BaseService } from '../base/base.service';
import { DepartmentService } from '../department/department.service';
import { setCourseInfo, setPostRelation } from './utils';
import { setPostInfo, setPostRelation } from './utils';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { BaseTreeService } from '../base/base.tree.service';
import { z } from 'zod';
@ -155,7 +155,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
if (result) {
await setPostRelation({ data: result, staff });
await this.setPerms(result, staff);
await setCourseInfo({ data: result });
await setPostInfo({ data: result });
}
// console.log(result);
return result;

View File

@ -126,7 +126,7 @@ export async function updateCourseEnrollmentStats(courseId: string) {
});
}
export async function setCourseInfo({ data }: { data: Post }) {
export async function setPostInfo({ data }: { data: Post }) {
// await db.term
if (data?.type === PostType.COURSE) {
const ancestries = await db.postAncestry.findMany({
@ -169,6 +169,23 @@ export async function setCourseInfo({ data }: { data: Post }) {
) as any as Lecture[];
});
Object.assign(data, { sections, lectureCount });
}
if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) {
const ancestry = await db.postAncestry.findFirst({
where: {
descendantId: data?.id,
ancestor: {
type: PostType.COURSE,
},
},
select: {
ancestor: { select: { id: true } },
},
});
const courseId = ancestry.ancestor.id;
Object.assign(data, { courseId });
}
const students = await db.staff.findMany({
where: {
learningPosts: {
@ -183,6 +200,5 @@ export async function setCourseInfo({ data }: { data: Post }) {
});
const studentIds = (students || []).map((student) => student?.id);
Object.assign(data, { sections, lectureCount, studentIds });
}
Object.assign(data, { studentIds });
}

View File

@ -81,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) {
],
}
: {};
}, [searchValue, debouncedValue]);
}, [debouncedValue]);
return (
<MainContext.Provider
value={{

View File

@ -4,7 +4,7 @@ import { useMainContext } from "../../layout/MainProvider";
import { PostType } from "@nice/common";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
export default function MyLearningListContainer() {
export default function MyDutyPathContainer() {
const { user } = useAuth();
const { searchCondition, termsCondition } = useMainContext();
return (

View File

@ -1,10 +1,12 @@
import MindEditor from "@web/src/components/common/editor/MindEditor";
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
import { useParams } from "react-router-dom";
export default function PathEditorPage() {
const { id } = useParams();
return <div className="">
<MindEditor id={id}></MindEditor>
</div>
return (
<PostDetailProvider editId={id}>
<MindEditor id={id}></MindEditor>;
</PostDetailProvider>
);
}

View File

@ -3,12 +3,15 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout";
import { useMainContext } from "../layout/MainProvider";
import PathListContainer from "./components/PathListContainer";
import { PostType } from "@nice/common";
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
import { useParams } from "react-router-dom";
export default function PathPage() {
const { setSearchMode } = useMainContext();
useEffect(() => {
setSearchMode(PostType.PATH);
}, [setSearchMode]);
const { id } = useParams();
return (
<BasePostLayout>
<PathListContainer></PathListContainer>

View File

@ -12,7 +12,7 @@ import {
} 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 { useContext, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { MindElixirInstance } from "mind-elixir";
import MindElixir from "mind-elixir";
@ -21,21 +21,29 @@ import { useNavigate } from "react-router-dom";
import { useAuth } from "@web/src/providers/auth-provider";
import { MIND_OPTIONS } from "./constant";
import { SaveOutlined } from "@ant-design/icons";
import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButton";
import { CourseDetailContext } from "../../models/course/detail/PostDetailContext";
export default function MindEditor({ id }: { id?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const {
post,
isLoading,
// userIsLearning,
// setUserIsLearning,
} = useContext(CourseDetailContext);
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
const { isAuthenticated, user, hasSomePermissions } = useAuth();
const { read } = useVisitor();
const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
api.post.findFirst.useQuery(
{
where: {
id,
},
select: postDetailSelect,
},
{ enabled: Boolean(id) }
);
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
// api.post.findFirst.useQuery(
// {
// where: {
// id,
// },
// select: postDetailSelect,
// },
// { enabled: Boolean(id) }
// );
const canEdit: boolean = useMemo(() => {
const isAuth = isAuthenticated && user?.id === post?.author?.id;
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
@ -97,8 +105,8 @@ export default function MindEditor({ id }: { id?: string }) {
if ((!id || post) && instance) {
containerRef.current.hidden = false;
instance.toCenter();
if (post?.meta?.nodeData) {
instance.refresh(post?.meta);
if ((post as any as PathDto)?.meta?.nodeData) {
instance.refresh((post as any as PathDto)?.meta);
}
}
}, [id, post, instance]);
@ -201,7 +209,9 @@ export default function MindEditor({ id }: { id?: string }) {
multiple
/>
</Form.Item>
<JoinButton></JoinButton>
</div>
<div>
{canEdit && (
<Button
ghost
@ -213,6 +223,7 @@ export default function MindEditor({ id }: { id?: string }) {
</Button>
)}
</div>
</div>
</Form>
)}
<div

View File

@ -1,23 +1,18 @@
import React, { useState, useEffect, useRef } from 'react';
import { Input, Button, ColorPicker, Select } from 'antd';
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Input, Button, ColorPicker, Select } from "antd";
import {
FontSizeOutlined,
BoldOutlined,
LinkOutlined,
} from '@ant-design/icons';
import type { MindElixirInstance, NodeObj } from 'mind-elixir';
const xmindColorPresets = [
// 经典16色
'#FFFFFF', '#F5F5F5', // 白色系
'#2196F3', '#1976D2', // 蓝色系
'#4CAF50', '#388E3C', // 绿色系
'#FF9800', '#F57C00', // 橙色系
'#F44336', '#D32F2F', // 红色系
'#9C27B0', '#7B1FA2', // 紫色系
'#424242', '#757575', // 灰色系
'#FFEB3B', '#FBC02D' // 黄色系
];
GlobalOutlined,
SwapOutlined,
} from "@ant-design/icons";
import type { MindElixirInstance, NodeObj } from "mind-elixir";
import PostSelect from "../../models/post/PostSelect/PostSelect";
import { Lecture, PostType } from "@nice/common";
import { xmindColorPresets } from "./constant";
import { api } from "@nice/client";
import { env } from "@web/src/env";
interface NodeMenuProps {
mind: MindElixirInstance;
@ -25,45 +20,61 @@ interface NodeMenuProps {
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedFontColor, setSelectedFontColor] = useState<string>('');
const [selectedBgColor, setSelectedBgColor] = useState<string>('');
const [selectedSize, setSelectedSize] = useState<string>('');
const [selectedFontColor, setSelectedFontColor] = useState<string>("");
const [selectedBgColor, setSelectedBgColor] = useState<string>("");
const [selectedSize, setSelectedSize] = useState<string>("");
const [isBold, setIsBold] = useState(false);
const [url, setUrl] = useState<string>('');
const containerRef = useRef<HTMLDivElement | null>(null);
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
const [url, setUrl] = useState<string>("");
const [postId, setPostId] = useState<string>("");
const containerRef = useRef<HTMLDivElement | null>(null);
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
api.post.findFirst.useQuery(
{
where: { id: postId },
},
{ enabled: !!postId }
);
useEffect(() => {
if (urlMode === "POSTURL")
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
mind.reshapeNode(mind.currentNode, {
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
});
}, [postId, lecture, isLoading, urlMode]);
useEffect(() => {
const handleSelectNode = (nodeObj: NodeObj) => {
setIsOpen(true);
const style = nodeObj.style || {};
setSelectedFontColor(style.color || '');
setSelectedBgColor(style.background || '');
setSelectedSize(style.fontSize || '24');
setIsBold(style.fontWeight === 'bold');
setUrl(nodeObj.hyperLink || '');
setSelectedFontColor(style.color || "");
setSelectedBgColor(style.background || "");
setSelectedSize(style.fontSize || "24");
setIsBold(style.fontWeight === "bold");
setUrl(nodeObj.hyperLink || "");
};
const handleUnselectNode = () => {
setIsOpen(false);
};
mind.bus.addListener('selectNode', handleSelectNode);
mind.bus.addListener('unselectNode', handleUnselectNode);
mind.bus.addListener("selectNode", handleSelectNode);
mind.bus.addListener("unselectNode", handleUnselectNode);
}, [mind]);
useEffect(() => {
if (containerRef.current && mind.container) {
mind.container.appendChild(containerRef.current);
}
}, [mind.container]);
const handleColorChange = (type: "font" | "background", color: string) => {
if (type === 'font') {
if (type === "font") {
setSelectedFontColor(color);
} else {
setSelectedBgColor(color);
}
const patch = { style: {} as any };
if (type === 'font') {
if (type === "font") {
patch.style.color = color;
} else {
patch.style.background = color;
@ -77,7 +88,7 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
};
const handleBoldToggle = () => {
const fontWeight = isBold ? '' : 'bold';
const fontWeight = isBold ? "" : "bold";
setIsBold(!isBold);
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
};
@ -85,43 +96,47 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setUrl(value);
mind.reshapeNode(mind.currentNode, { hyperLink: value });
mind.reshapeNode(mind.currentNode, {
hyperLink: value,
});
};
return (
<div
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${
isOpen
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 pointer-events-none"
}`}
ref={containerRef}
>
ref={containerRef}>
<div className="p-5 space-y-6">
{/* Font Size Selector */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-600"></h3>
<h3 className="text-sm font-medium text-gray-600">
</h3>
<div className="flex gap-3 items-center justify-between">
<Select
value={selectedSize}
onChange={handleSizeChange}
prefix={<FontSizeOutlined className='mr-2' />}
prefix={<FontSizeOutlined className="mr-2" />}
className="w-1/2"
options={[
{ value: '12', label: '12' },
{ value: '14', label: '14' },
{ value: '16', label: '16' },
{ value: '18', label: '18' },
{ value: '20', label: '20' },
{ value: '24', label: '24' },
{ value: '28', label: '28' },
{ value: '32', label: '32' }
{ value: "12", label: "12" },
{ value: "14", label: "14" },
{ value: "16", label: "16" },
{ value: "18", label: "18" },
{ value: "20", label: "20" },
{ value: "24", label: "24" },
{ value: "28", label: "28" },
{ value: "32", label: "32" },
]}
/>
<Button
type={isBold ? "primary" : "default"}
onClick={handleBoldToggle}
className='w-1/2'
icon={<BoldOutlined />}
>
className="w-1/2"
icon={<BoldOutlined />}>
</Button>
</div>
@ -129,21 +144,27 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
{/* Color Picker */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-600"></h3>
<h3 className="text-sm font-medium text-gray-600">
</h3>
{/* Font Color Picker */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-500"></h4>
<h4 className="text-xs font-medium text-gray-500">
</h4>
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
{xmindColorPresets.map((color) => (
<div
key={`font-${color}`}
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedFontColor === color ? 'outline-blue-500' : 'outline-transparent'
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
selectedFontColor === color
? "outline-blue-500"
: "outline-transparent"
}`}
style={{ backgroundColor: color }}
onClick={() => {
handleColorChange('font', color);
handleColorChange("font", color);
}}
/>
))}
@ -152,16 +173,21 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
{/* Background Color Picker */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-500"></h4>
<h4 className="text-xs font-medium text-gray-500">
</h4>
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
{xmindColorPresets.map((color) => (
<div
key={`bg-${color}`}
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedBgColor === color ? 'outline-blue-500' : 'outline-transparent'
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
selectedBgColor === color
? "outline-blue-500"
: "outline-transparent"
}`}
style={{ backgroundColor: color }}
onClick={() => {
handleColorChange('background', color);
handleColorChange("background", color);
}}
/>
))}
@ -169,17 +195,50 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
</div>
</div>
<h3 className="text-sm font-medium text-gray-600"></h3>
{/* URL Input */}
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
{urlMode === "URL" ? "关联链接" : "关联课时"}
<Button
type="text"
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
size="small"
icon={<SwapOutlined />}
onClick={() =>
setUrlMode((prev) =>
prev === "POSTURL" ? "URL" : "POSTURL"
)
}
/>
</div>
<div className="space-y-1">
{urlMode === "POSTURL" ? (
<PostSelect
onChange={(value) => {
if (typeof value === "string") {
setPostId(value);
}
}}
params={{
where: {
type: PostType.LECTURE,
},
}}
/>
) : (
<Input
placeholder="例如https://example.com"
value={url}
onChange={handleUrlChange}
addonBefore={<LinkOutlined />}
/>
{url && !/^https?:\/\/\S+$/.test(url) && (
<p className="text-xs text-red-500">URL地址</p>
)}
{urlMode === "URL" &&
url &&
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
<p className="text-xs text-red-500">
URL地址
</p>
)}
</div>
</div>

View File

@ -32,3 +32,23 @@ export const MIND_OPTIONS = {
},
},
};
export const xmindColorPresets = [
// 经典16色
"#FFFFFF",
"#F5F5F5", // 白色系
"#2196F3",
"#1976D2", // 蓝色系
"#4CAF50",
"#388E3C", // 绿色系
"#FF9800",
"#F57C00", // 橙色系
"#F44336",
"#D32F2F", // 红色系
"#9C27B0",
"#7B1FA2", // 紫色系
"#424242",
"#757575", // 灰色系
"#FFEB3B",
"#FBC02D", // 黄色系
];

View File

@ -1,4 +1,4 @@
import { CourseDetailProvider } from "./CourseDetailContext";
import { PostDetailProvider } from "./PostDetailContext";
import CourseDetailLayout from "./CourseDetailLayout";
export default function CourseDetail({
@ -8,12 +8,11 @@ export default function CourseDetail({
id?: string;
lectureId?: string;
}) {
return (
<>
<CourseDetailProvider editId={id}>
<PostDetailProvider editId={id}>
<CourseDetailLayout></CourseDetailLayout>
</CourseDetailProvider>
</PostDetailProvider>
</>
);
}

View File

@ -1,7 +1,7 @@
import { Course, TaxonomySlug } from "@nice/common";
import { Course, CourseDto, TaxonomySlug } from "@nice/common";
import React, { useContext, useEffect, useMemo } from "react";
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
import { CourseDetailContext } from "./CourseDetailContext";
import { CourseDetailContext } from "./PostDetailContext";
import { useNavigate, useParams } from "react-router-dom";
import { useStaff } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider";
@ -10,7 +10,7 @@ import { PictureOutlined } from "@ant-design/icons";
export const CourseDetailDescription: React.FC = () => {
const {
course,
post,
canEdit,
isLoading,
selectedLectureId,
@ -22,14 +22,14 @@ export const CourseDetailDescription: React.FC = () => {
const { user } = useAuth();
const { update } = useStaff();
const firstLectureId = useMemo(() => {
return course?.sections?.[0]?.lectures?.[0]?.id;
}, [course]);
return (post as CourseDto)?.sections?.[0]?.lectures?.[0]?.id;
}, [post]);
const navigate = useNavigate();
const { id } = useParams();
return (
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
<div className="w-full px-5 my-2">
{isLoading || !course ? (
{isLoading || !post ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : (
<div className="space-y-2">
@ -39,7 +39,7 @@ export const CourseDetailDescription: React.FC = () => {
<div
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
style={{
backgroundImage: `url(${course?.meta?.thumbnail || "/placeholder.webp"})`,
backgroundImage: `url(${post?.meta?.thumbnail || "/placeholder.webp"})`,
}}
/>
}
@ -52,7 +52,7 @@ export const CourseDetailDescription: React.FC = () => {
data: {
learningPosts: {
connect: {
id: course.id,
id: post.id,
},
},
},
@ -69,8 +69,8 @@ export const CourseDetailDescription: React.FC = () => {
<div className="text-lg font-bold">{"课程简介:"}</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2 flex-wrap items-center float-start">
{course?.subTitle && <div>{course?.subTitle}</div>}
<TermInfo terms={course.terms}></TermInfo>
{post?.subTitle && <div>{post?.subTitle}</div>}
<TermInfo terms={post.terms}></TermInfo>
</div>
</div>
<Paragraph
@ -81,7 +81,7 @@ export const CourseDetailDescription: React.FC = () => {
symbol: "展开",
onExpand: () => console.log("展开"),
}}>
{course?.content}
{post?.content}
</Paragraph>
</div>
)}

View File

@ -4,18 +4,17 @@ import React, { useContext, useRef, useState } from "react";
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
import { CourseDetailDescription } from "./CourseDetailDescription";
import { Course, LectureType, PostType } from "@nice/common";
import { CourseDetailContext } from "./CourseDetailContext";
import { CourseDetailContext } from "./PostDetailContext";
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
import { Skeleton } from "antd";
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
import { useNavigate } from "react-router-dom";
import CourseDetailTitle from "./CourseDetailTitle";
export const CourseDetailDisplayArea: React.FC = () => {
// 创建滚动动画效果
const {
course,
isLoading,
canEdit,
lecture,

View File

@ -1,97 +0,0 @@
import { useContext, useState } from "react";
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import {
EditFilled,
HomeOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate, useParams } from "react-router-dom";
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
import { CourseDetailContext } from "../CourseDetailContext";
import { usePost, useStaff } from "@nice/client";
import toast from "react-hot-toast";
import { NavigationMenu } from "@web/src/app/main/layout/NavigationMenu";
const { Header } = Layout;
export function CourseDetailHeader() {
const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth();
const navigate = useNavigate();
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
const { update } = useStaff();
return (
<Header 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 flex items-center justify-between h-full">
<div className="flex items-center space-x-8">
<div
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">
</div>
<NavigationMenu />
</div>
<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 && (
<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 />}>
{"编辑课程"}
</Button>
)}
{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>
</Header>
);
}

View File

@ -1,14 +1,14 @@
import { motion } from "framer-motion";
import { useContext, useState } from "react";
import { CourseDetailContext } from "./CourseDetailContext";
import { CourseDetailContext } from "./PostDetailContext";
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
import { CourseDto } from "packages/common/dist";
export default function CourseDetailLayout() {
const {
course,
post,
setSelectedLectureId,
} = useContext(CourseDetailContext);
@ -19,7 +19,6 @@ export default function CourseDetailLayout() {
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
return (
<div className="relative">
<div className="pt-12 px-32">
{" "}
{/* 添加这个包装 div */}
@ -36,7 +35,7 @@ export default function CourseDetailLayout() {
</motion.div>
{/* 课程大纲侧边栏 */}
<CourseSyllabus
sections={course?.sections || []}
sections={(post as CourseDto)?.sections || []}
onLectureClick={handleLectureClick}
isOpen={isSyllabusOpen}
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}

View File

@ -1,5 +1,5 @@
import { useContext } from "react";
import { CourseDetailContext } from "./CourseDetailContext";
import { CourseDetailContext } from "./PostDetailContext";
import { useNavigate } from "react-router-dom";
import {
BookOutlined,
@ -9,11 +9,14 @@ import {
ReloadOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import CourseOperationBtns from "./JoinLearingButton";
import CourseOperationBtns from "./CourseOperationBtns/CourseOperationBtns";
export default function CourseDetailTitle() {
const { course, lecture, selectedLectureId } =
useContext(CourseDetailContext);
const {
post: course,
lecture,
selectedLectureId,
} = useContext(CourseDetailContext);
return (
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
<div className="flex justify-start w-full text-2xl font-bold">

View File

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

View File

@ -1,54 +1,47 @@
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 { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CourseDetailContext } from "../PostDetailContext";
import toast from "react-hot-toast";
import {
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleFilled,
CloseCircleOutlined,
EditFilled,
EditTwoTone,
LoginOutlined,
} from "@ant-design/icons";
import toast from "react-hot-toast";
export default function CourseOperationBtns() {
const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth();
export default function JoinButton() {
const { isAuthenticated, user } = useAuth();
const navigate = useNavigate();
const { course, canEdit, userIsLearning, setUserIsLearning} = useContext(CourseDetailContext);
const { post, canEdit, userIsLearning, setUserIsLearning } =
useContext(CourseDetailContext);
const { update } = useStaff();
const [isHovered, setIsHovered] = useState(false);
const toggleLearning = async () => {
if (!userIsLearning) {
await update.mutateAsync({
where: { id: user?.id },
data: {
learningPosts: {
connect: { id: course.id },
connect: { id: post.id },
},
},
});
setUserIsLearning(true)
setUserIsLearning(true);
toast.success("加入学习成功");
} else {
await update.mutateAsync({
where: { id: user?.id },
data: {
learningPosts: {
disconnect: {
id: course.id,
id: post.id,
},
},
},
});
toast.success("退出学习成功");
setUserIsLearning(false)
setUserIsLearning(false);
}
};
return (
@ -83,19 +76,6 @@ export default function CourseOperationBtns() {
</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

@ -3,16 +3,16 @@ import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts";
import { Button, Tabs, Image, Skeleton } from "antd";
import type { TabsProps } from "antd";
import { PlayCircleOutlined } from "@ant-design/icons";
import { CourseDetailContext } from "../CourseDetailContext";
import { CourseDetailContext } from "../PostDetailContext";
export function CoursePreview() {
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
const { post, isLoading, lecture, lectureIsLoading, selectedLectureId } =
useContext(CourseDetailContext);
return (
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
<Image
src={isLoading ? "error" : course?.meta?.thumbnail}
src={isLoading ? "error" : post?.meta?.thumbnail}
alt="example"
preview={false}
className="w-full h-full object-cover z-0"
@ -28,13 +28,13 @@ export function CoursePreview() {
) : (
<>
<span className="text-3xl font-bold my-3 ">
{course.title}
{post.title}
</span>
<span className="text-xl font-semibold my-3 text-gray-700">
{course.subTitle}
{post.subTitle}
</span>
<span className="text-lg font-light my-3 text-gray-500 text-clip">
{course.content}
{post.content}
</span>
</>
)}

View File

@ -10,7 +10,7 @@ import { SectionDto, TaxonomySlug } from "@nice/common";
import { SyllabusHeader } from "./SyllabusHeader";
import { SectionItem } from "./SectionItem";
import { CollapsedButton } from "./CollapsedButton";
import { CourseDetailContext } from "../CourseDetailContext";
import { CourseDetailContext } from "../PostDetailContext";
import { api } from "@nice/client";
interface CourseSyllabusProps {

View File

@ -4,6 +4,7 @@ import {
CourseDto,
Lecture,
lectureDetailSelect,
PostDto,
RolePerms,
VisitType,
} from "@nice/common";
@ -19,7 +20,7 @@ import { useNavigate, useParams } from "react-router-dom";
interface CourseDetailContextType {
editId?: string; // 添加 editId
course?: CourseDto;
post?: PostDto;
lecture?: Lecture;
selectedLectureId?: string | undefined;
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
@ -29,7 +30,7 @@ interface CourseDetailContextType {
setIsHeaderVisible: (visible: boolean) => void; // 新增
canEdit?: boolean;
userIsLearning?: boolean;
setUserIsLearning:(learning: boolean) => void;
setUserIsLearning: (learning: boolean) => void;
}
interface CourseFormProviderProps {
@ -39,7 +40,7 @@ interface CourseFormProviderProps {
export const CourseDetailContext =
createContext<CourseDetailContextType | null>(null);
export function CourseDetailProvider({
export function PostDetailProvider({
children,
editId,
}: CourseFormProviderProps) {
@ -48,28 +49,24 @@ export function CourseDetailProvider({
const { user, hasSomePermissions, isAuthenticated } = useAuth();
const { lectureId } = useParams();
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
(api.post as any).findFirst.useQuery(
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
api.post as any
).findFirst.useQuery(
{
where: { id: editId },
select: courseDetailSelect,
},
{ enabled: Boolean(editId) }
);
// const userIsLearning = useMemo(() => {
// return (course?.studentIds || []).includes(user?.id);
// }, [user, course, isLoading]);
const [userIsLearning, setUserIsLearning] = useState(false);
useEffect(()=>{
console.log(course?.studentIds,user?.id)
setUserIsLearning((course?.studentIds || []).includes(user?.id));
},[user, course, isLoading])
useEffect(() => {
setUserIsLearning((post?.studentIds || []).includes(user?.id));
}, [user, post, isLoading]);
const canEdit = useMemo(() => {
const isAuthor = isAuthenticated && user?.id === course?.authorId;
const isAuthor = isAuthenticated && user?.id === post?.authorId;
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
return isAuthor || isRoot;
}, [user, course]);
}, [user, post]);
const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined
@ -86,8 +83,6 @@ export function CourseDetailProvider({
useEffect(() => {
if (lectureId) {
console.log(123);
console.log(lectureId);
read.mutateAsync({
data: {
visitorId: user?.id || null,
@ -96,8 +91,6 @@ export function CourseDetailProvider({
},
});
} else {
console.log(321);
console.log(editId);
read.mutateAsync({
data: {
visitorId: user?.id || null,
@ -117,7 +110,7 @@ export function CourseDetailProvider({
<CourseDetailContext.Provider
value={{
editId,
course,
post,
lecture,
selectedLectureId,
setSelectedLectureId,
@ -127,7 +120,7 @@ export function CourseDetailProvider({
setIsHeaderVisible,
canEdit,
userIsLearning,
setUserIsLearning
setUserIsLearning,
}}>
{children}
</CourseDetailContext.Provider>

View File

@ -1,15 +1,110 @@
import { api } from "@nice/client";
import { Select } from "antd";
import { useState } from "react";
import { Button, Select } from "antd";
import {
Lecture,
lectureDetailSelect,
postDetailSelect,
postUnDetailSelect,
Prisma,
} from "@nice/common";
import { useMemo, useState } from "react";
import PostSelectOption from "./PostSelectOption";
import { DefaultArgs } from "@prisma/client/runtime/library";
import { safeOR } from "@nice/utils";
export default function PostSelect() {
api.post.findMany.useQuery({});
const [search, setSearch] = useState("");
export default function PostSelect({
value,
onChange,
placeholder = "请选择课时",
params = { where: {}, select: {} },
className,
}: {
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
params?: {
where?: Prisma.PostWhereInput;
select?: Prisma.PostSelect<DefaultArgs>;
};
className?: string;
}) {
const [searchValue, setSearch] = useState("");
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
const containTextCondition: Prisma.StringNullableFilter = {
contains: searchValue,
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
};
return searchValue
? {
OR: [
{ title: containTextCondition },
{ content: containTextCondition },
],
}
: {};
}, [searchValue]);
// 核心条件生成逻辑
const idCondition: Prisma.PostWhereInput = useMemo(() => {
if (value === undefined) return {}; // 无值时返回空对象
// 字符串类型增强判断
if (typeof value === "string") {
// 如果明确需要支持逗号分隔字符串
return { id: value };
}
if (Array.isArray(value)) {
return value.length > 0 ? { id: { in: value } } : {}; // 空数组不注入条件
}
return {};
}, [value]);
const {
data: lectures,
isLoading,
}: { data: Lecture[]; isLoading: boolean } = api.post.findMany.useQuery({
where: safeOR([
{ ...idCondition },
{ ...searchCondition, ...(params?.where || {}) },
]),
select: { ...postDetailSelect, ...(params?.select || {}) },
take: 15,
});
const options = useMemo(() => {
return (lectures || []).map((lecture, index) => {
return {
value: lecture.id,
label: <PostSelectOption post={lecture}></PostSelectOption>,
tag: lecture?.title,
};
});
}, [lectures, isLoading]);
const tagRender = (props) => {
// 根据 value 找到对应的 option
const option = options.find((opt) => opt.value === props.value);
// 使用自定义的展示内容(这里假设你的 option 中有 customDisplay 字段)
return <span style={{ marginRight: 3 }}>{option?.tag}</span>;
};
return (
<>
<div
style={{
width: 200,
}}>
<Select
options={[{ value: "id1", label: <></> }]}
showSearch
value={value}
dropdownStyle={{
minWidth: 200, // 设置合适的最小宽度
}}
placeholder={placeholder}
onChange={onChange}
filterOption={false}
loading={isLoading}
className={`flex-1 w-full ${className}`}
options={options}
tagRender={tagRender}
optionLabelProp="tag" // 新增这个属性 ✅
onSearch={(inputValue) => setSearch(inputValue)}></Select>
</>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { Lecture, LessonTypeLabel } from "@nice/common";
// 修改 PostSelectOption 组件
export default function PostSelectOption({ post }: { post: Lecture }) {
return (
<div className="flex items-center gap-2 min-w-0">
{" "}
{/* 添加 min-w-0 */}
<img
src={post?.meta?.thumbnail || "/placeholder.webp"}
className="w-8 h-8 object-cover rounded flex-shrink-0" // 添加 flex-shrink-0
alt="课程封面"
/>
<div className="flex flex-col min-w-0 flex-1">
{" "}
{/* 修改这里 */}
{post?.meta?.type && (
<span className="text-sm text-gray-500 truncate">
{LessonTypeLabel[post?.meta?.type]}
</span>
)}
<span className="font-medium truncate">{post?.title}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
type PrismaCondition = Record<string, any>;
type SafeOROptions = {
/**
*
* @default 'return-undefined' undefined ()
* 'throw-error'
* 'return-empty'
*/
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
};
/**
* OR
* @param conditions
* @param options
* @returns Prisma WHERE
*/
const safeOR = (
conditions: PrismaCondition[],
options?: SafeOROptions
): PrismaCondition | undefined => {
const { emptyBehavior = "return-undefined" } = options || {};
// 过滤空条件和无效值
const validConditions = conditions.filter(
(cond) => cond && Object.keys(cond).length > 0
);
// 处理全空情况
if (validConditions.length === 0) {
switch (emptyBehavior) {
case "throw-error":
throw new Error("No valid conditions provided to OR query");
case "return-empty":
return {};
case "return-undefined":
default:
return undefined;
}
}
// 优化单条件查询
return validConditions.length === 1
? validConditions[0]
: { OR: validConditions };
};

View File

@ -4,6 +4,7 @@ export const env: {
VERSION: string;
FILE_PORT: string;
SERVER_PORT: string;
WEB_PORT: string;
} = {
APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME
@ -14,6 +15,9 @@ export const env: {
FILE_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_FILE_PORT
: import.meta.env.VITE_APP_FILE_PORT,
WEB_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_WEB_PORT
: import.meta.env.VITE_APP_WEB_PORT,
SERVER_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_PORT
: import.meta.env.VITE_APP_SERVER_PORT,

View File

@ -45,7 +45,6 @@ export type PostDto = Post & {
watchableStaffs: Staff[];
terms: TermDto[];
depts: DepartmentDto[];
studentIds?: string[];
};
export type PostMeta = {
@ -64,6 +63,7 @@ export type LectureMeta = PostMeta & {
};
export type Lecture = Post & {
courseId?: string;
resources?: ResourceDto[];
meta?: LectureMeta;
};
@ -72,6 +72,7 @@ export type SectionMeta = PostMeta & {
objectives?: string[];
};
export type Section = Post & {
courseId?: string;
meta?: SectionMeta;
};
export type SectionDto = Section & {

View File

@ -39,3 +39,4 @@ export const getCompressedImageUrl = (originalUrl: string): string => {
return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`;
};
export * from "./type-utils";
export * from "./safePrismaQuery";

View File

@ -0,0 +1,46 @@
type PrismaCondition = Record<string, any>;
type SafeOROptions = {
/**
*
* @default 'return-undefined' undefined ()
* 'throw-error'
* 'return-empty'
*/
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
};
/**
* OR
* @param conditions
* @param options
* @returns Prisma WHERE
*/
export const safeOR = (
conditions: PrismaCondition[],
options?: SafeOROptions
): PrismaCondition | undefined => {
const { emptyBehavior = "return-undefined" } = options || {};
// 过滤空条件和无效值
const validConditions = conditions.filter(
(cond) => cond && Object.keys(cond).length > 0
);
// 处理全空情况
if (validConditions.length === 0) {
switch (emptyBehavior) {
case "throw-error":
throw new Error("No valid conditions provided to OR query");
case "return-empty":
return {};
case "return-undefined":
default:
return undefined;
}
}
// 优化单条件查询
return validConditions.length === 1
? validConditions[0]
: { OR: validConditions };
};