diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 6ca6139..811a75f 100755 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -1,6 +1,7 @@ import { db, EnrollmentStatus, + Lecture, Post, PostType, SectionDto, @@ -144,28 +145,29 @@ export async function setCourseInfo({ data }: { data: Post }) { }, }); const descendants = ancestries.map((ancestry) => ancestry.descendant); - const sections: SectionDto[] = descendants - .filter((descendant) => { + const sections: SectionDto[] = ( + descendants.filter((descendant) => { return ( descendant.type === PostType.SECTION && descendant.parentId === data.id ); - }) - .map((section) => ({ - ...section, - lectures: [], - })); + }) as any + ).map((section) => ({ + ...section, + lectures: [], + })); const lectures = descendants.filter((descendant) => { return ( descendant.type === PostType.LECTURE && sections.map((section) => section.id).includes(descendant.parentId) ); }); + const lectureCount = lectures?.length || 0; sections.forEach((section) => { section.lectures = lectures.filter( (lecture) => lecture.parentId === section.id, - ); + ) as any as Lecture[]; }); Object.assign(data, { sections, lectureCount }); } diff --git a/apps/web/src/components/common/container/CollapsibleContent.tsx b/apps/web/src/components/common/container/CollapsibleContent.tsx index 9352183..80b9756 100644 --- a/apps/web/src/components/common/container/CollapsibleContent.tsx +++ b/apps/web/src/components/common/container/CollapsibleContent.tsx @@ -6,39 +6,13 @@ interface CollapsibleContentProps { maxHeight?: number; } -const CollapsibleContent: React.FC = ({ - content, - maxHeight = 150, -}) => { +const CollapsibleContent: React.FC = ({ content }) => { const contentWrapperRef = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - const [shouldCollapse, setShouldCollapse] = useState(false); - - useEffect(() => { - if (contentWrapperRef.current) { - const shouldCollapse = - contentWrapperRef.current.scrollHeight > maxHeight; - setShouldCollapse(shouldCollapse); - } - }, [content]); - return (
{/* 包装整个内容区域的容器 */} -
+
{/* 内容区域 */}
= ({ __html: content || "", }} /> - - {/* 渐变遮罩 */} - {shouldCollapse && !isExpanded && ( -
- )}
- - {/* 展开/收起按钮 */} - {shouldCollapse && ( - - )} - {/* PostResources 组件 */} - {/* */}
); diff --git a/apps/web/src/components/common/editor/quill/constants.ts b/apps/web/src/components/common/editor/quill/constants.ts index d47f27b..fd92bd1 100755 --- a/apps/web/src/components/common/editor/quill/constants.ts +++ b/apps/web/src/components/common/editor/quill/constants.ts @@ -1,11 +1,11 @@ export const defaultModules = { - toolbar: [ - [{ 'header': [1, 2, 3, 4, 5, 6, false] }], - ['bold', 'italic', 'underline', 'strike'], - [{ 'list': 'ordered' }, { 'list': 'bullet' }], - [{ 'color': [] }, { 'background': [] }], - [{ 'align': [] }], - ['link', 'image'], - ['clean'] - ] -}; \ No newline at end of file + toolbar: [ + [{ header: [1, 2, 3, 4, 5, 6, false] }], + ["bold", "italic", "underline", "strike"], + [{ list: "ordered" }, { list: "bullet" }], + [{ color: [] }, { background: [] }], + [{ align: [] }], + ["link"], + ["clean"], + ], +}; diff --git a/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx b/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx index 16f389d..bf324ea 100644 --- a/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx +++ b/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -// import UncoverAvatarUploader from "../uploader/UncoverAvatarUploader "; +import React, { useEffect, useState } from "react"; import { Upload, Progress, Button, Image, Form } from "antd"; import { DeleteOutlined } from "@ant-design/icons"; import AvatarUploader from "./AvatarUploader"; @@ -8,11 +7,17 @@ import { isEqual } from "lodash"; interface MultiAvatarUploaderProps { value?: string[]; onChange?: (value: string[]) => void; + className?: string; + placeholder?: string; + style?: React.CSSProperties; } export function MultiAvatarUploader({ value, onChange, + className, + style, + placeholder = "点击上传", }: MultiAvatarUploaderProps) { const [imageList, setImageList] = useState(value || []); const [previewImage, setPreviewImage] = useState(""); @@ -30,12 +35,21 @@ export function MultiAvatarUploader({ {(imageList || [])?.map((image, index) => { return (
+ style={{ + width: "100px", + height: "100px", + ...style, + }}>
{ console.log(value); diff --git a/apps/web/src/components/common/uploader/ResourceShower.tsx b/apps/web/src/components/common/uploader/ResourceShower.tsx new file mode 100644 index 0000000..98d14d4 --- /dev/null +++ b/apps/web/src/components/common/uploader/ResourceShower.tsx @@ -0,0 +1,135 @@ +import React, { useMemo } from "react"; +import { Image, Button, Row, Col, Tooltip } from "antd"; +import { ResourceDto } from "@nice/common"; +import { env } from "@web/src/env"; +import { getFileIcon } from "./utils"; +import { formatFileSize, getCompressedImageUrl } from "@nice/utils"; + +export default function ResourcesShower({ + resources = [], +}: { + resources: ResourceDto[]; +}) { + const { resources: dealedResources } = useMemo(() => { + if (!resources) return { resources: [] }; + + const isImage = (url: string) => + /\.(png|jpg|jpeg|gif|webp)$/i.test(url); + + const sortedResources = resources + .map((resource) => { + const original = `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${resource.url}`; + const isImg = isImage(resource.url); + return { + ...resource, + url: isImg ? getCompressedImageUrl(original) : original, + originalUrl: original, + isImage: isImg, + }; + }) + .sort((a, b) => (a.isImage === b.isImage ? 0 : a.isImage ? -1 : 1)); + + return { resources: sortedResources }; + }, [resources]); + + const imageResources = dealedResources.filter((res) => res.isImage); + const fileResources = dealedResources.filter((res) => !res.isImage); + return ( +
+ {imageResources.length > 0 && ( + + + {imageResources.map((resource) => ( + +
+
+ {resource.title} + 点击预览 +
+ ), + }} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + }} + rootClassName="w-full h-full" + /> +
+ {resource.title && ( +
+ {resource.title} +
+ )} +
+ + ))} + + + )} + {fileResources.length > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/common/uploader/utils.tsx b/apps/web/src/components/common/uploader/utils.tsx new file mode 100644 index 0000000..8437211 --- /dev/null +++ b/apps/web/src/components/common/uploader/utils.tsx @@ -0,0 +1,48 @@ +import { + FilePdfOutlined, + FileWordOutlined, + FileExcelOutlined, + FilePptOutlined, + FileTextOutlined, + FileZipOutlined, + FileImageOutlined, + FileUnknownOutlined, +} from "@ant-design/icons"; + +export const isContentEmpty = (html: string) => { + // 创建一个临时 div 来解析 HTML 内容 + const temp = document.createElement("div"); + temp.innerHTML = html; + // 获取纯文本内容并检查是否为空 + return !temp.textContent?.trim(); +}; +export const getFileIcon = (filename: string) => { + const extension = filename.split(".").pop()?.toLowerCase(); + switch (extension) { + case "pdf": + return ; + case "doc": + case "docx": + return ; + case "xls": + case "xlsx": + return ; + case "ppt": + case "pptx": + return ; + case "txt": + return ; + case "zip": + case "rar": + case "7z": + return ; + case "png": + case "jpg": + case "jpeg": + case "gif": + case "webp": + return ; + default: + return ; + } +}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index 2f702e7..9ba1283 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -3,6 +3,7 @@ import { courseDetailSelect, CourseDto, Lecture, + lectureDetailSelect, RolePerms, VisitType, } from "@nice/common"; @@ -72,16 +73,17 @@ export function CourseDetailProvider({ ).findFirst.useQuery( { where: { id: selectedLectureId }, + select: lectureDetailSelect, }, { enabled: Boolean(editId) } ); useEffect(() => { - if (course) { + if (lecture?.id) { read.mutateAsync({ data: { visitorId: user?.id || null, - postId: course.id, + postId: lecture?.id, type: VisitType.READED, }, }); diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx deleted file mode 100755 index 984f266..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/Overview.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// import { useContext } from "react"; -// import { CourseDetailContext } from "../../CourseDetailContext"; -// import { CheckCircleIcon } from "@heroicons/react/24/solid"; - -// export function Overview() { -// const { course } = useContext(CourseDetailContext); -// return ( -// <> -//
-// {/* 课程描述 */} -//
-//

{course?.description}

-//
- -// {/* 学习目标 */} -//
-//

学习目标

-//
-// {course?.objectives.map((objective, index) => ( -//
-// -// {objective} -//
-// ))} -//
-//
- -// {/* 适合人群 */} -//
-//

适合人群

-//
-// {course?.audiences.map((audience, index) => ( -//
-// -// {audience} -//
-// ))} -//
-//
- -// {/* 课程要求 */} -//
-//

课程要求

-//
    -// {course?.requirements.map((requirement, index) => ( -//
  • {requirement}
  • -// ))} -//
-//
- -// {/* 可获得技能 */} -//
-//

可获得技能

-//
-// {course?.skills.map((skill, index) => ( -// -// {skill} -// -// ))} -//
-//
-//
-// -// ); -// } diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts b/apps/web/src/components/models/course/detail/CourseDetailDescription/Description/index.ts deleted file mode 100755 index e69de29..0000000 diff --git a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx index 447ecda..bcef62a 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDisplayArea.tsx @@ -8,6 +8,7 @@ import { CourseDetailContext } from "./CourseDetailContext"; import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent"; import { Skeleton } from "antd"; import { CoursePreview } from "./CoursePreview/CoursePreview"; +import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; // interface CourseDetailDisplayAreaProps { // // course: Course; @@ -53,6 +54,12 @@ export const CourseDetailDisplayArea: React.FC = () => { content={lecture?.content || ""} maxHeight={500} // Optional, defaults to 150 /> +
+ +
)} diff --git a/apps/web/src/components/models/course/editor/form/CourseContentForm/LectureList.tsx b/apps/web/src/components/models/course/editor/form/CourseContentForm/LectureList.tsx index b495edd..138fc0f 100755 --- a/apps/web/src/components/models/course/editor/form/CourseContentForm/LectureList.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseContentForm/LectureList.tsx @@ -33,7 +33,12 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { Lecture, LectureType, PostType } from "@nice/common"; +import { + Lecture, + lectureDetailSelect, + LectureType, + PostType, +} from "@nice/common"; import { useCourseEditor } from "../../context/CourseEditorContext"; import { usePost } from "@nice/client"; import { LectureData, SectionData } from "./interface"; @@ -62,6 +67,7 @@ export const LectureList: React.FC = ({ orderBy: { order: "asc", }, + select: lectureDetailSelect, }, { enabled: !!sectionId, diff --git a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx index a3ed362..26282d2 100755 --- a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx @@ -15,6 +15,7 @@ import { LectureType, LessonTypeLabel, PostType, + ResourceStatus, videoMimeTypes, } from "@nice/common"; import { usePost } from "@nice/client"; @@ -23,6 +24,8 @@ import toast from "react-hot-toast"; import { env } from "@web/src/env"; import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent"; import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer"; +import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader"; +import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; interface SortableLectureProps { field: Lecture; @@ -58,6 +61,7 @@ export const SortableLecture: React.FC = ({ setLoading(true); const values = await form.validateFields(); let result; + const fileIds = values?.meta?.fileIds || []; const videoUrlId = Array.isArray(values?.meta?.videoIds) ? values?.meta?.videoIds[0] : typeof values?.meta?.videoIds === "string" @@ -72,13 +76,14 @@ export const SortableLecture: React.FC = ({ title: values?.title, meta: { type: values?.meta?.type, + fileIds: fileIds, videoIds: videoUrlId ? [videoUrlId] : [], videoUrl: videoUrlId ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` : undefined, }, resources: { - connect: [videoUrlId] + connect: [videoUrlId, ...fileIds] .filter(Boolean) .map((fileId) => ({ fileId, @@ -97,13 +102,14 @@ export const SortableLecture: React.FC = ({ title: values?.title, meta: { type: values?.meta?.type, + fileIds: fileIds, videoIds: videoUrlId ? [videoUrlId] : [], videoUrl: videoUrlId ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` : undefined, }, resources: { - connect: [videoUrlId] + connect: [videoUrlId, ...fileIds] .filter(Boolean) .map((fileId) => ({ fileId, @@ -113,6 +119,7 @@ export const SortableLecture: React.FC = ({ }, }); } + setIsContentVisible(false); toast.success("课时已更新"); field.id = result.id; setEditing(false); @@ -178,14 +185,30 @@ export const SortableLecture: React.FC = ({ /> ) : ( - - - +
+ + + + + + +
)}
@@ -237,10 +260,16 @@ export const SortableLecture: React.FC = ({ {isContentVisible && !editing && // Conditionally render content based on type (field?.meta?.type === LectureType.ARTICLE ? ( - +
+ +
+ +
+
) : ( ))} diff --git a/apps/web/src/components/models/term/term-select.tsx b/apps/web/src/components/models/term/term-select.tsx index 95990dd..3896d8e 100755 --- a/apps/web/src/components/models/term/term-select.tsx +++ b/apps/web/src/components/models/term/term-select.tsx @@ -48,7 +48,7 @@ export default function TermSelect({ const [listTreeData, setListTreeData] = useState< Omit[] >([]); - + const fetchParentTerms = useCallback( async (termIds: string | string[], taxonomyId?: string) => { const idsArray = Array.isArray(termIds) diff --git a/apps/web/src/components/presentation/video-player/VideoControls.tsx b/apps/web/src/components/presentation/video-player/VideoControls.tsx index 93fc540..4200476 100755 --- a/apps/web/src/components/presentation/video-player/VideoControls.tsx +++ b/apps/web/src/components/presentation/video-player/VideoControls.tsx @@ -96,7 +96,6 @@ export const Controls = () => {
{/* 播放/暂停按钮 */} - {/* 时间显示 */} {duration && ( @@ -114,7 +113,7 @@ export const Controls = () => { {/* 倍速控制 */} {/* 设置按钮 */} - + {/* */} {/* 全屏按钮 */} diff --git a/apps/web/src/components/presentation/video-player/VideoDisplay.tsx b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx index b786739..5b48209 100755 --- a/apps/web/src/components/presentation/video-player/VideoDisplay.tsx +++ b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx @@ -14,7 +14,6 @@ export const VideoDisplay: React.FC = ({ onError, videoRef, setIsReady, - isPlaying, setIsPlaying, setError, setBufferingState, @@ -26,8 +25,6 @@ export const VideoDisplay: React.FC = ({ isDragging, setIsDragging, progressRef, - resolution, - setResolutions, } = useContext(VideoPlayerContext); // 处理进度条拖拽 @@ -40,7 +37,6 @@ export const VideoDisplay: React.FC = ({ ); videoRef.current.currentTime = percent * videoRef.current.duration; }; - // 添加拖拽事件监听 useEffect(() => { const handleMouseUp = () => setIsDragging(false); @@ -66,15 +62,12 @@ export const VideoDisplay: React.FC = ({ setError(null); setLoadingProgress(0); setBufferingState(false); - // Check for native HLS support (Safari) if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { videoRef.current.src = src; setIsReady(true); - // 设置视频时长 setDuration(videoRef.current.duration); - if (autoPlay) { try { await videoRef.current.play(); @@ -85,14 +78,12 @@ export const VideoDisplay: React.FC = ({ } return; } - if (!Hls.isSupported()) { const errorMessage = "您的浏览器不支持 HLS 视频播放"; setError(errorMessage); onError?.(errorMessage); return; } - hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 600, @@ -102,13 +93,10 @@ export const VideoDisplay: React.FC = ({ hls.loadSource(src); hls.attachMedia(videoRef.current); - hls.on(Hls.Events.MANIFEST_PARSED, async () => { setIsReady(true); - // 设置视频时长 setDuration(videoRef.current?.duration || 0); - if (autoPlay && videoRef.current) { try { await videoRef.current.play(); @@ -118,7 +106,6 @@ export const VideoDisplay: React.FC = ({ } } }); - hls.on(Hls.Events.BUFFER_APPENDING, () => { setBufferingState(true); }); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 9a60fd1..e1ae965 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -71,7 +71,7 @@ export const routes: CustomRouteObject[] = [ { path: ":id?/editor", element: ( - + ), diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index 2716f11..6273433 100755 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -1,7 +1,8 @@ -export * from "./department" -export * from "./message" -export * from "./staff" -export * from "./term" -export * from "./post" -export * from "./rbac" -export * from "./select" \ No newline at end of file +export * from "./department"; +export * from "./message"; +export * from "./staff"; +export * from "./term"; +export * from "./post"; +export * from "./rbac"; +export * from "./select"; +export * from "./resource"; diff --git a/packages/common/src/models/post.ts b/packages/common/src/models/post.ts index 0d2bf07..254f8eb 100755 --- a/packages/common/src/models/post.ts +++ b/packages/common/src/models/post.ts @@ -8,6 +8,7 @@ import { } from "@prisma/client"; import { StaffDto } from "./staff"; import { TermDto } from "./term"; +import { ResourceDto } from "./resource"; export type PostComment = { id: string; @@ -51,6 +52,7 @@ export type LectureMeta = { }; export type Lecture = Post & { + resources?: ResourceDto[]; meta?: LectureMeta; }; @@ -79,5 +81,5 @@ export type CourseDto = Course & { sections?: SectionDto[]; terms: TermDto[]; lectureCount?: number; - depts:Department[] + depts: Department[]; }; diff --git a/packages/common/src/models/resource.ts b/packages/common/src/models/resource.ts new file mode 100644 index 0000000..e732d88 --- /dev/null +++ b/packages/common/src/models/resource.ts @@ -0,0 +1,52 @@ +import { Resource } from "@prisma/client"; + +export interface BaseMetadata { + size: number; + filetype: string; + filename: string; + extension: string; + modifiedAt: Date; +} +/** + * 图片特有元数据接口 + */ +export interface ImageMetadata { + width: number; // 图片宽度(px) + height: number; // 图片高度(px) + compressedUrl?: string; + orientation?: number; // EXIF方向信息 + space?: string; // 色彩空间 (如: RGB, CMYK) + hasAlpha?: boolean; // 是否包含透明通道 +} + +/** + * 视频特有元数据接口 + */ +export interface VideoMetadata { + width?: number; + height?: number; + duration?: number; + videoCodec?: string; + audioCodec?: string; + coverUrl?: string; +} + +/** + * 音频特有元数据接口 + */ +export interface AudioMetadata { + duration: number; // 音频时长(秒) + bitrate?: number; // 比特率(bps) + sampleRate?: number; // 采样率(Hz) + channels?: number; // 声道数 + codec?: string; // 音频编码格式 +} + +export type FileMetadata = ImageMetadata & + VideoMetadata & + AudioMetadata & + BaseMetadata; + +export type ResourceDto = Resource & { + meta: FileMetadata; +}; diff --git a/packages/common/src/models/section.ts b/packages/common/src/models/section.ts deleted file mode 100755 index 0824441..0000000 --- a/packages/common/src/models/section.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Lecture, Section } from "@prisma/client"; - -export type SectionDto = Section & { - lectures: Lecture[]; -}; \ No newline at end of file diff --git a/packages/common/src/models/select.ts b/packages/common/src/models/select.ts index a591134..f7fdaf5 100755 --- a/packages/common/src/models/select.ts +++ b/packages/common/src/models/select.ts @@ -79,6 +79,7 @@ export const courseDetailSelect: Prisma.PostSelect = { select: { id: true, name: true, + taxonomyId: true, taxonomy: { select: { id: true, @@ -95,3 +96,15 @@ export const courseDetailSelect: Prisma.PostSelect = { meta: true, rating: true, }; +export const lectureDetailSelect: Prisma.PostSelect = { + id: true, + title: true, + subTitle: true, + content: true, + resources: true, + createdAt: true, + updatedAt: true, + // 关联表选择 + + meta: true, +};