From 93c2151af2c40b9a2cdbe45c41d31b7698cfd8ca Mon Sep 17 00:00:00 2001 From: ditiqi Date: Sun, 2 Mar 2025 11:49:46 +0800 Subject: [PATCH] add --- apps/server/src/models/post/post.service.ts | 4 +- apps/server/src/models/post/utils.ts | 36 +- apps/web/package.json | 2 +- apps/web/src/app/main/layout/MainProvider.tsx | 2 +- .../components/MyDutyPathContainer.tsx | 2 +- apps/web/src/app/main/path/editor/page.tsx | 12 +- apps/web/src/app/main/path/page.tsx | 3 + .../components/common/editor/MindEditor.tsx | 57 ++- .../src/components/common/editor/NodeMenu.tsx | 393 ++++++++++-------- .../src/components/common/editor/constant.ts | 20 + .../models/course/detail/CourseDetail.tsx | 7 +- .../course/detail/CourseDetailDescription.tsx | 22 +- .../course/detail/CourseDetailDisplayArea.tsx | 5 +- .../CourseDetailHeader/CourseDetailHeader.tsx | 97 ----- .../course/detail/CourseDetailLayout.tsx | 9 +- .../course/detail/CourseDetailTitle.tsx | 11 +- .../CourseOperationBtns.tsx | 98 +++++ .../JoinButton.tsx} | 44 +- .../detail/CoursePreview/CoursePreview.tsx | 12 +- .../detail/CourseSyllabus/CourseSyllabus.tsx | 2 +- ...etailContext.tsx => PostDetailContext.tsx} | 47 +-- .../models/post/PostSelect/PostSelect.tsx | 111 ++++- .../post/PostSelect/PostSelectOption.tsx | 26 ++ .../models/post/PostSelect/utils.ts | 46 ++ apps/web/src/env.ts | 4 + packages/common/src/models/post.ts | 3 +- packages/utils/src/index.ts | 1 + packages/utils/src/safePrismaQuery.ts | 46 ++ 28 files changed, 713 insertions(+), 409 deletions(-) delete mode 100755 apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx create mode 100644 apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx rename apps/web/src/components/models/course/detail/{JoinLearingButton.tsx => CourseOperationBtns/JoinButton.tsx} (63%) rename apps/web/src/components/models/course/detail/{CourseDetailContext.tsx => PostDetailContext.tsx} (75%) create mode 100644 apps/web/src/components/models/post/PostSelect/PostSelectOption.tsx create mode 100644 apps/web/src/components/models/post/PostSelect/utils.ts create mode 100644 packages/utils/src/safePrismaQuery.ts diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index cbcf7bc..1c6f810 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -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 { 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; diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 57b0fd0..4dea80c 100755 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -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,20 +169,36 @@ export async function setCourseInfo({ data }: { data: Post }) { ) as any as Lecture[]; }); - const students = await db.staff.findMany({ + Object.assign(data, { sections, lectureCount }); + } + if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) { + const ancestry = await db.postAncestry.findFirst({ where: { - learningPosts: { - some: { - id: data.id, - }, + descendantId: data?.id, + ancestor: { + type: PostType.COURSE, }, }, select: { - id: true, + ancestor: { select: { id: true } }, }, }); - - const studentIds = (students || []).map((student) => student?.id); - Object.assign(data, { sections, lectureCount, studentIds }); + const courseId = ancestry.ancestor.id; + Object.assign(data, { courseId }); } + 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, { studentIds }); } diff --git a/apps/web/package.json b/apps/web/package.json index 7f6c264..909aa90 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -90,4 +90,4 @@ "typescript-eslint": "^8.0.1", "vite": "^5.4.1" } -} \ No newline at end of file +} diff --git a/apps/web/src/app/main/layout/MainProvider.tsx b/apps/web/src/app/main/layout/MainProvider.tsx index 73db96a..1d0396c 100755 --- a/apps/web/src/app/main/layout/MainProvider.tsx +++ b/apps/web/src/app/main/layout/MainProvider.tsx @@ -81,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) { ], } : {}; - }, [searchValue, debouncedValue]); + }, [debouncedValue]); return ( - - + const { id } = useParams(); + return ( + + ; + + ); } diff --git a/apps/web/src/app/main/path/page.tsx b/apps/web/src/app/main/path/page.tsx index 65178db..542689b 100755 --- a/apps/web/src/app/main/path/page.tsx +++ b/apps/web/src/app/main/path/page.tsx @@ -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 ( diff --git a/apps/web/src/components/common/editor/MindEditor.tsx b/apps/web/src/components/common/editor/MindEditor.tsx index 00f57e8..45fe115 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -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(null); + const { + post, + isLoading, + // userIsLearning, + // setUserIsLearning, + } = useContext(CourseDetailContext); const [instance, setInstance] = useState(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,17 +209,20 @@ export default function MindEditor({ id }: { id?: string }) { multiple /> + + +
+ {canEdit && ( + + )}
- {canEdit && ( - - )} )} diff --git a/apps/web/src/components/common/editor/NodeMenu.tsx b/apps/web/src/components/common/editor/NodeMenu.tsx index f762967..53c464f 100755 --- a/apps/web/src/components/common/editor/NodeMenu.tsx +++ b/apps/web/src/components/common/editor/NodeMenu.tsx @@ -1,190 +1,249 @@ -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' // 黄色系 -]; + FontSizeOutlined, + BoldOutlined, + LinkOutlined, + 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; + mind: MindElixirInstance; } const NodeMenu: React.FC = ({ mind }) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedFontColor, setSelectedFontColor] = useState(''); - const [selectedBgColor, setSelectedBgColor] = useState(''); - const [selectedSize, setSelectedSize] = useState(''); - const [isBold, setIsBold] = useState(false); - const [url, setUrl] = useState(''); - const containerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [selectedFontColor, setSelectedFontColor] = useState(""); + const [selectedBgColor, setSelectedBgColor] = useState(""); + const [selectedSize, setSelectedSize] = useState(""); + const [isBold, setIsBold] = useState(false); - 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 || ''); - }; - const handleUnselectNode = () => { - setIsOpen(false); - }; - mind.bus.addListener('selectNode', handleSelectNode); - mind.bus.addListener('unselectNode', handleUnselectNode); - }, [mind]); + const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL"); + const [url, setUrl] = useState(""); + const [postId, setPostId] = useState(""); + const containerRef = useRef(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}`); - useEffect(() => { - if (containerRef.current && mind.container) { - mind.container.appendChild(containerRef.current); - } + 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 || ""); + }; + const handleUnselectNode = () => { + setIsOpen(false); + }; + mind.bus.addListener("selectNode", handleSelectNode); + mind.bus.addListener("unselectNode", handleUnselectNode); + }, [mind]); - }, [mind.container]); + useEffect(() => { + if (containerRef.current && mind.container) { + mind.container.appendChild(containerRef.current); + } + }, [mind.container]); - const handleColorChange = (type: "font" | "background", color: string) => { - if (type === 'font') { - setSelectedFontColor(color); - } else { - setSelectedBgColor(color); - } - const patch = { style: {} as any }; - if (type === 'font') { - patch.style.color = color; - } else { - patch.style.background = color; - } - mind.reshapeNode(mind.currentNode, patch); - }; + const handleColorChange = (type: "font" | "background", color: string) => { + if (type === "font") { + setSelectedFontColor(color); + } else { + setSelectedBgColor(color); + } + const patch = { style: {} as any }; + if (type === "font") { + patch.style.color = color; + } else { + patch.style.background = color; + } + mind.reshapeNode(mind.currentNode, patch); + }; - const handleSizeChange = (size: string) => { - setSelectedSize(size); - mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); - }; + const handleSizeChange = (size: string) => { + setSelectedSize(size); + mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); + }; - const handleBoldToggle = () => { - const fontWeight = isBold ? '' : 'bold'; - setIsBold(!isBold); - mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); - }; + const handleBoldToggle = () => { + const fontWeight = isBold ? "" : "bold"; + setIsBold(!isBold); + mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); + }; - const handleUrlChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setUrl(value); - mind.reshapeNode(mind.currentNode, { hyperLink: value }); - }; + const handleUrlChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setUrl(value); + mind.reshapeNode(mind.currentNode, { + hyperLink: value, + }); + }; - return ( -
-
- {/* Font Size Selector */} -
-

文字样式

-
- } + 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" }, + ]} + /> + +
+
- 加粗 - -
-
+ {/* Color Picker */} +
+

+ 颜色设置 +

- {/* Color Picker */} -
-

颜色设置

+ {/* Font Color Picker */} +
+

+ 文字颜色 +

+
+ {xmindColorPresets.map((color) => ( +
{ + handleColorChange("font", color); + }} + /> + ))} +
+
- {/* Font Color Picker */} -
-

文字颜色

-
- {xmindColorPresets.map((color) => ( -
{ + {/* Background Color Picker */} +
+

+ 背景颜色 +

+
+ {xmindColorPresets.map((color) => ( +
{ + handleColorChange("background", color); + }} + /> + ))} +
+
+
- handleColorChange('font', color); - }} - /> - ))} -
-
+
+ {urlMode === "URL" ? "关联链接" : "关联课时"} +
- {/* Background Color Picker */} -
-

背景颜色

-
- {xmindColorPresets.map((color) => ( -
{ - handleColorChange('background', color); - }} - /> - ))} -
-
-
+
+ {urlMode === "POSTURL" ? ( + { + if (typeof value === "string") { + setPostId(value); + } + }} + params={{ + where: { + type: PostType.LECTURE, + }, + }} + /> + ) : ( + } + /> + )} -

关联链接

- {/* URL Input */} -
- } - /> - {url && !/^https?:\/\/\S+$/.test(url) && ( -

请输入有效的URL地址

- )} -
-
-
- ); + {urlMode === "URL" && + url && + !/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && ( +

+ 请输入有效的URL地址 +

+ )} +
+
+
+ ); }; export default NodeMenu; diff --git a/apps/web/src/components/common/editor/constant.ts b/apps/web/src/components/common/editor/constant.ts index 29e6890..eb0766e 100644 --- a/apps/web/src/components/common/editor/constant.ts +++ b/apps/web/src/components/common/editor/constant.ts @@ -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", // 黄色系 +]; diff --git a/apps/web/src/components/models/course/detail/CourseDetail.tsx b/apps/web/src/components/models/course/detail/CourseDetail.tsx index c33c268..a9bc127 100755 --- a/apps/web/src/components/models/course/detail/CourseDetail.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetail.tsx @@ -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 ( <> - + - + ); } diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx index 019dcbc..c5c8f35 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx @@ -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 ( //
- {isLoading || !course ? ( + {isLoading || !post ? ( ) : (
@@ -39,7 +39,7 @@ export const CourseDetailDescription: React.FC = () => {
} @@ -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 = () => {
{"课程简介:"}
- {course?.subTitle &&
{course?.subTitle}
} - + {post?.subTitle &&
{post?.subTitle}
} +
{ symbol: "展开", onExpand: () => console.log("展开"), }}> - {course?.content} + {post?.content}
)} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx index 7d89f1a..f95c29d 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx @@ -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, diff --git a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx deleted file mode 100755 index 15eb7e5..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx +++ /dev/null @@ -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 ( -
-
-
-
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"> - 烽火慕课 -
- -
- -
- {isAuthenticated && ( - - )} - {canEdit && ( - - )} - {isAuthenticated ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx index 4ac0d73..40059de 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx @@ -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 */} @@ -36,7 +35,7 @@ export default function CourseDetailLayout() { {/* 课程大纲侧边栏 */} setIsSyllabusOpen(!isSyllabusOpen)} diff --git a/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx b/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx index de577eb..3d23341 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailTitle.tsx @@ -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 (
diff --git a/apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx b/apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx new file mode 100644 index 0000000..aa7124c --- /dev/null +++ b/apps/web/src/components/models/course/detail/CourseOperationBtns/CourseOperationBtns.tsx @@ -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 && ( +
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 ? ( + + ) : ( + + ) + ) : ( + + )} + + {userIsLearning + ? isHovered + ? "退出学习" + : "正在学习" + : "加入学习"} + +
+ )} */} + + {canEdit && ( +
{ + const url = post?.id + ? `/course/${post?.id}/editor` + : "/course/editor"; + navigate(url); + }}> + + {"编辑课程"} +
+ )} + + ); +} diff --git a/apps/web/src/components/models/course/detail/JoinLearingButton.tsx b/apps/web/src/components/models/course/detail/CourseOperationBtns/JoinButton.tsx similarity index 63% rename from apps/web/src/components/models/course/detail/JoinLearingButton.tsx rename to apps/web/src/components/models/course/detail/CourseOperationBtns/JoinButton.tsx index 942a7de..d351156 100644 --- a/apps/web/src/components/models/course/detail/JoinLearingButton.tsx +++ b/apps/web/src/components/models/course/detail/CourseOperationBtns/JoinButton.tsx @@ -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() {
)} - {canEdit && ( -
{ - const url = course?.id - ? `/course/${course?.id}/editor` - : "/course/editor"; - navigate(url); - }}> - - {"编辑课程"} -
- )} ); } diff --git a/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx b/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx index 7d6d4f4..64baa66 100755 --- a/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx +++ b/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx @@ -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 (
example - {course.title} + {post.title} - {course.subTitle} + {post.subTitle} - {course.content} + {post.content} )} diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx index 84463cf..2dcd706 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx @@ -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 { diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/PostDetailContext.tsx similarity index 75% rename from apps/web/src/components/models/course/detail/CourseDetailContext.tsx rename to apps/web/src/components/models/course/detail/PostDetailContext.tsx index c833dc5..8ecf8b2 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/PostDetailContext.tsx @@ -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>; @@ -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(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( - { - where: { id: editId }, - select: courseDetailSelect, - }, - { enabled: Boolean(editId) } - ); - - // const userIsLearning = useMemo(() => { - // return (course?.studentIds || []).includes(user?.id); - // }, [user, course, isLoading]); + const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = ( + api.post as any + ).findFirst.useQuery( + { + where: { id: editId }, + select: courseDetailSelect, + }, + { enabled: Boolean(editId) } + ); 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({ {children} diff --git a/apps/web/src/components/models/post/PostSelect/PostSelect.tsx b/apps/web/src/components/models/post/PostSelect/PostSelect.tsx index 2af8ec2..f3db4dd 100644 --- a/apps/web/src/components/models/post/PostSelect/PostSelect.tsx +++ b/apps/web/src/components/models/post/PostSelect/PostSelect.tsx @@ -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; + }; + 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: , + tag: lecture?.title, + }; + }); + }, [lectures, isLoading]); + const tagRender = (props) => { + // 根据 value 找到对应的 option + const option = options.find((opt) => opt.value === props.value); + // 使用自定义的展示内容(这里假设你的 option 中有 customDisplay 字段) + return {option?.tag}; + }; return ( - <> +
- +
); } diff --git a/apps/web/src/components/models/post/PostSelect/PostSelectOption.tsx b/apps/web/src/components/models/post/PostSelect/PostSelectOption.tsx new file mode 100644 index 0000000..bf10fd2 --- /dev/null +++ b/apps/web/src/components/models/post/PostSelect/PostSelectOption.tsx @@ -0,0 +1,26 @@ +import { Lecture, LessonTypeLabel } from "@nice/common"; + +// 修改 PostSelectOption 组件 +export default function PostSelectOption({ post }: { post: Lecture }) { + return ( +
+ {" "} + {/* 添加 min-w-0 */} + 课程封面 +
+ {" "} + {/* 修改这里 */} + {post?.meta?.type && ( + + {LessonTypeLabel[post?.meta?.type]} + + )} + {post?.title} +
+
+ ); +} diff --git a/apps/web/src/components/models/post/PostSelect/utils.ts b/apps/web/src/components/models/post/PostSelect/utils.ts new file mode 100644 index 0000000..b724f6d --- /dev/null +++ b/apps/web/src/components/models/post/PostSelect/utils.ts @@ -0,0 +1,46 @@ +type PrismaCondition = Record; + 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 }; +}; diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 956a74b..a7904db 100755 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -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, diff --git a/packages/common/src/models/post.ts b/packages/common/src/models/post.ts index d672bc8..fa76dfb 100755 --- a/packages/common/src/models/post.ts +++ b/packages/common/src/models/post.ts @@ -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 & { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b2ba304..5664c72 100755 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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"; diff --git a/packages/utils/src/safePrismaQuery.ts b/packages/utils/src/safePrismaQuery.ts new file mode 100644 index 0000000..ac6f47c --- /dev/null +++ b/packages/utils/src/safePrismaQuery.ts @@ -0,0 +1,46 @@ +type PrismaCondition = Record; +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 }; +};