Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
c1fd27061e
|
@ -1,5 +1,9 @@
|
|||
import { db, VisitType } from '@nice/common';
|
||||
export async function updatePostViewCount(id: string, type: VisitType) {
|
||||
const post = await db.post.findFirst({
|
||||
where: { id },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
const totalViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
|
@ -16,10 +20,12 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
|||
},
|
||||
data: {
|
||||
meta: {
|
||||
...((post?.meta as any) || {}),
|
||||
views: totalViews._sum.views || 0,
|
||||
}, // Use 0 if no visits exist
|
||||
},
|
||||
});
|
||||
console.log('readed');
|
||||
} else if (type === VisitType.LIKE) {
|
||||
await db.post.update({
|
||||
where: {
|
||||
|
@ -27,6 +33,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
|||
},
|
||||
data: {
|
||||
meta: {
|
||||
...((post?.meta as any) || {}),
|
||||
likes: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||
},
|
||||
},
|
||||
|
@ -38,6 +45,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
|||
},
|
||||
data: {
|
||||
meta: {
|
||||
...((post?.meta as any) || {}),
|
||||
hates: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||
},
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
updateCourseReviewStats,
|
||||
updateParentLectureStats,
|
||||
} from '@server/models/post/utils';
|
||||
import { updatePostViewCount } from '../models/post/utils';
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
|
@ -44,6 +45,12 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
`Updated course stats for courseId: ${courseId}, type: ${type}`,
|
||||
);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_POST_STATE) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
import { useEffect } from 'react';
|
||||
import { CoursePreviewMsg } from '@web/src/app/main/course/preview/type.ts';
|
||||
import { Button , Tabs , Image, Skeleton } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { useEffect } from "react";
|
||||
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";
|
||||
export function CoursePreviewAllmsg({previewMsg,items,isLoading}: {previewMsg?:CoursePreviewMsg,items:TabsProps['items'],isLoading:Boolean}){
|
||||
export function CoursePreviewAllmsg({
|
||||
previewMsg,
|
||||
items,
|
||||
isLoading,
|
||||
}: {
|
||||
previewMsg?: CoursePreviewMsg;
|
||||
items: TabsProps["items"];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.log(previewMsg)
|
||||
})
|
||||
console.log(previewMsg);
|
||||
});
|
||||
const TapOnChange = (key: string) => {
|
||||
console.log(key);
|
||||
};
|
||||
|
@ -15,37 +23,51 @@ export function CoursePreviewAllmsg({previewMsg,items,isLoading}: {previewMsg?:C
|
|||
<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={previewMsg.isLoading ? 'error' : previewMsg.videoPreview}
|
||||
src={
|
||||
previewMsg.isLoading
|
||||
? "error"
|
||||
: previewMsg.videoPreview
|
||||
}
|
||||
alt="example"
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
fallback="data:image/png;base64,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"
|
||||
/>
|
||||
<div className='w-[600px] h-[360px] absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer'>
|
||||
<PlayCircleOutlined
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10"
|
||||
/>
|
||||
<div className="w-[600px] h-[360px] absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-col justify-between w-2/5 content-start h-[340px] my-4 overflow-hidden">
|
||||
{
|
||||
isLoading ? <Skeleton className='my-5' active />
|
||||
:(
|
||||
{isLoading ? (
|
||||
<Skeleton className="my-5" active />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold my-3 ">{previewMsg.Title}</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">{previewMsg.SubTitle}</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">{previewMsg.Description}</span>
|
||||
<span className="text-3xl font-bold my-3 ">
|
||||
{previewMsg.Title}
|
||||
</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||
{previewMsg.SubTitle}
|
||||
</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||
{previewMsg.Description}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
<Button block type="primary" size='large'> 查看课程 </Button>
|
||||
<Button block type="primary" size="large">
|
||||
{" "}
|
||||
查看课程{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto w-11/12 mx-auto my-8">
|
||||
<Tabs defaultActiveKey="1" tabBarGutter={100} items={items} onChange={TapOnChange} />
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
tabBarGutter={100}
|
||||
items={items}
|
||||
onChange={TapOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
|
@ -4,5 +4,5 @@ export interface CoursePreviewMsg{
|
|||
SubTitle: string;
|
||||
Description: string;
|
||||
ToCourseUrl: string;
|
||||
isLoading:Boolean
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
|
|
@ -44,7 +44,9 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
|||
// 在组件中定义 key 状态
|
||||
const [avatarKey, setAvatarKey] = useState(0);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl(value || "");
|
||||
}, [value]);
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
|
|
@ -185,13 +185,13 @@ export function UserMenu() {
|
|||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
style={{ zIndex: 1000 }}
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
bg-white rounded-xl overflow-hidden shadow-lg
|
||||
border border-[#E5EDF5]">
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
className="z-50 px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
|
|
|
@ -1,25 +1,61 @@
|
|||
import { Course } from "@nice/common";
|
||||
import React, { useContext } from "react";
|
||||
import { Typography, Skeleton } from "antd"; // 引入 antd 组件
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Image, Typography, Skeleton } from "antd"; // 引入 antd 组件
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import {
|
||||
CalendarOutlined,
|
||||
EyeOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface CourseDetailProps {
|
||||
course: Course;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailDescription: React.FC<CourseDetailProps> = () => {
|
||||
const { course, isLoading } = useContext(CourseDetailContext);
|
||||
export const CourseDetailDescription: React.FC = () => {
|
||||
const { course, isLoading, selectedLectureId, setSelectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
const { Paragraph, Title } = Typography;
|
||||
|
||||
const firstLectureId = useMemo(() => {
|
||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||
}, [course]);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6">
|
||||
{isLoading || !course ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-bold">{"课程简介"}</div>
|
||||
{!selectedLectureId && (
|
||||
<>
|
||||
<div className="relative my-4 overflow-hidden flex justify-center items-center">
|
||||
<Image
|
||||
src={course?.meta?.thumbnail}
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
/>
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedLectureId(firstLectureId);
|
||||
}}
|
||||
className="w-full h-full absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||
<div className="text-gray-600 flex justify-start gap-4">
|
||||
<div>{course?.subTitle}</div>
|
||||
<div className="flex gap-1">
|
||||
<EyeOutlined></EyeOutlined>
|
||||
<div>{course?.meta?.views}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<CalendarOutlined></CalendarOutlined>
|
||||
{dayjs(course?.createdAt).format("YYYY年M月D日")}
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-gray-600"
|
||||
ellipsis={{
|
||||
rows: 3,
|
||||
expandable: true,
|
||||
|
@ -27,7 +63,7 @@ export const CourseDetailDescription: React.FC<CourseDetailProps> = () => {
|
|||
onExpand: () => console.log("展开"),
|
||||
// collapseText: "收起",
|
||||
}}>
|
||||
{course.content}
|
||||
{course?.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Course, LectureType, PostType } from "@nice/common";
|
|||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||
import { Skeleton } from "antd";
|
||||
import { CoursePreview } from "./CoursePreview/CoursePreview";
|
||||
|
||||
// interface CourseDetailDisplayAreaProps {
|
||||
// // course: Course;
|
||||
|
@ -17,7 +18,7 @@ import { Skeleton } from "antd";
|
|||
|
||||
export const CourseDetailDisplayArea: React.FC = () => {
|
||||
// 创建滚动动画效果
|
||||
const { course, isLoading, lecture, lectureIsLoading } =
|
||||
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
const { scrollY } = useScroll();
|
||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
|
@ -27,7 +28,10 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
|||
{lectureIsLoading && (
|
||||
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
||||
)}
|
||||
{!lectureIsLoading && lecture?.meta?.type === LectureType.VIDEO && (
|
||||
|
||||
{selectedLectureId &&
|
||||
!lectureIsLoading &&
|
||||
lecture?.meta?.type === LectureType.VIDEO && (
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full mt-2 px-4">
|
||||
<motion.div
|
||||
style={{
|
||||
|
@ -37,10 +41,11 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
|||
<div className=" w-full ">
|
||||
<VideoPlayer src={lecture?.meta?.videoUrl} />
|
||||
</div>
|
||||
</motion.div>{" "}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{!lectureIsLoading &&
|
||||
selectedLectureId &&
|
||||
lecture?.meta?.type === LectureType.ARTICLE && (
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
||||
|
@ -52,10 +57,7 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||
<CourseDetailDescription
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CourseDetailDescription />
|
||||
</div>
|
||||
{/* 课程内容区域 */}
|
||||
</div>
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserMenu } from "@web/src/components/layout/element/usermenu/usermenu";
|
||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function CourseDetailLayout() {
|
|||
const handleLectureClick = (lectureId: string) => {
|
||||
setSelectedLectureId(lectureId);
|
||||
};
|
||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||
return (
|
||||
<div className="relative">
|
||||
<CourseDetailHeader />
|
||||
|
@ -30,6 +30,9 @@ export default function CourseDetailLayout() {
|
|||
{" "}
|
||||
{/* 添加这个包装 div */}
|
||||
<motion.div
|
||||
initial={{
|
||||
width: "75%",
|
||||
}}
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "75%" : "100%",
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { useContext, useEffect } from "react";
|
||||
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";
|
||||
export function CoursePreview() {
|
||||
const { course, 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}
|
||||
alt="example"
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
fallback="data:image/png;base64,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"
|
||||
/>
|
||||
<div className="w-[600px] h-[360px] absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between w-2/5 content-start h-[340px] my-4 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<Skeleton className="my-5" active />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold my-3 ">
|
||||
{course.title}
|
||||
</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||
{course.subTitle}
|
||||
</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||
{course.content}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button block type="primary" size="large">
|
||||
{" "}
|
||||
查看课程{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { Checkbox, List } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export function CoursePreviewTabmsg({data}){
|
||||
|
||||
|
||||
const renderItem = (item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
return(
|
||||
<div className='my-2'>
|
||||
<List
|
||||
dataSource={data}
|
||||
split={false}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import type { MenuProps } from 'antd';
|
||||
import { Menu } from 'antd';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
export function CourseCatalog(){
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -6,7 +6,6 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { SectionDto, TaxonomySlug } from "@nice/common";
|
||||
import { SyllabusHeader } from "./SyllabusHeader";
|
||||
import { SectionItem } from "./SectionItem";
|
||||
|
@ -28,13 +27,11 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
|||
onToggle,
|
||||
}) => {
|
||||
const { isHeaderVisible } = useContext(CourseDetailContext);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(
|
||||
sections.map((section) => section.id) // 默认展开所有章节
|
||||
);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
// api.term.findMany.useQuery({
|
||||
// where: {
|
||||
// taxonomy: { slug: TaxonomySlug.CATEGORY },
|
||||
// },
|
||||
// });
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
|
@ -42,43 +39,31 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
|||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
// 直接滚动,无需延迟
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{/* 收起时的悬浮按钮 */}
|
||||
{/* 收起按钮直接显示 */}
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||
<div className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||
<CollapsedButton onToggle={onToggle} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: isOpen ? "25%" : "0",
|
||||
right: 0,
|
||||
top: isHeaderVisible ? "64px" : "0",
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="h-full flex flex-col">
|
||||
<div className="h-full flex flex-col">
|
||||
<SyllabusHeader onToggle={onToggle} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
@ -87,9 +72,8 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
|||
<SectionItem
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[
|
||||
section.id
|
||||
] = el)
|
||||
(sectionRefs.current[section.id] =
|
||||
el)
|
||||
}
|
||||
index={index + 1}
|
||||
section={section}
|
||||
|
@ -102,10 +86,9 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
FileTextOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface LectureItemProps {
|
||||
lecture: Lecture;
|
||||
|
@ -16,7 +17,9 @@ interface LectureItemProps {
|
|||
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||
lecture,
|
||||
onClick,
|
||||
}) => (
|
||||
}) => {
|
||||
const { lectureId } = useParams();
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors cursor-pointer"
|
||||
onClick={() => onClick(lecture.id)}>
|
||||
|
@ -29,7 +32,9 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
|||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||
{lecture.subTitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">{lecture.subTitle}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{lecture.subTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
|
@ -38,3 +43,4 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
|||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,11 +16,11 @@ interface SectionItemProps {
|
|||
|
||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||
<motion.div
|
||||
<div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
// initial={{ opacity: 0, y: 20 }}
|
||||
// animate={{ opacity: 1, y: 0 }}
|
||||
// transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
|
@ -64,6 +64,6 @@ export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createContext, useContext, ReactNode, useEffect } from "react";
|
|||
import { Form, FormInstance, message } from "antd";
|
||||
import {
|
||||
CourseDto,
|
||||
CourseMeta,
|
||||
CourseStatus,
|
||||
ObjectType,
|
||||
PostType,
|
||||
|
@ -10,6 +11,7 @@ import {
|
|||
import { api, usePost } from "@nice/client";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
export type CourseFormData = {
|
||||
title: string;
|
||||
|
@ -42,6 +44,7 @@ export function CourseFormProvider({
|
|||
}: CourseFormProviderProps) {
|
||||
const [form] = Form.useForm<CourseFormData>();
|
||||
const { create, update, createCourse } = usePost();
|
||||
const { user } = useAuth();
|
||||
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
|
@ -77,7 +80,7 @@ export function CourseFormProvider({
|
|||
}
|
||||
}, [course, form]);
|
||||
|
||||
const onSubmit = async (values: CourseFormData) => {
|
||||
const onSubmit = async (values: any) => {
|
||||
console.log(values);
|
||||
const sections = values?.sections || [];
|
||||
const termIds = taxonomies
|
||||
|
@ -87,7 +90,7 @@ export function CourseFormProvider({
|
|||
const formattedValues = {
|
||||
...values,
|
||||
meta: {
|
||||
thumbnail: values.thumbnail,
|
||||
thumbnail: values?.meta?.thumbnail,
|
||||
},
|
||||
terms: {
|
||||
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||
|
@ -98,6 +101,12 @@ export function CourseFormProvider({
|
|||
delete formattedValues[tax.id];
|
||||
});
|
||||
delete formattedValues.sections;
|
||||
if (course) {
|
||||
formattedValues.meta = {
|
||||
...(course?.meta as CourseMeta),
|
||||
thumbnail: values?.meta?.thumbnail,
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (editId) {
|
||||
await update.mutateAsync({
|
||||
|
@ -110,6 +119,7 @@ export function CourseFormProvider({
|
|||
courseDetail: {
|
||||
data: {
|
||||
title: formattedValues.title || "12345",
|
||||
|
||||
// state: CourseStatus.DRAFT,
|
||||
type: PostType.COURSE,
|
||||
...formattedValues,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { env } from "../env";
|
||||
export function useLocalSettings() {
|
||||
|
|
|
@ -8,9 +8,9 @@ const apiClient = axios.create({
|
|||
// Add a request interceptor to attach the access token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
if (accessToken) {
|
||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
config.headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
|
|
@ -101,6 +101,7 @@ server {
|
|||
internal;
|
||||
# 代理到认证服务
|
||||
proxy_pass http://${SERVER_IP}:${SERVER_PORT}/auth/file;
|
||||
|
||||
# 请求优化:不传递请求体
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
|
|
|
@ -58,6 +58,7 @@ export const InitTaxonomies: {
|
|||
{
|
||||
name: "分类",
|
||||
slug: TaxonomySlug.CATEGORY,
|
||||
objectType: [ObjectType.COURSE],
|
||||
},
|
||||
{
|
||||
name: "难度等级",
|
||||
|
|
|
@ -67,6 +67,9 @@ export type CourseMeta = {
|
|||
thumbnail?: string;
|
||||
|
||||
objectives?: string[];
|
||||
views?: number;
|
||||
likes?: number;
|
||||
hates?: number;
|
||||
};
|
||||
export type Course = Post & {
|
||||
meta?: CourseMeta;
|
||||
|
|
Loading…
Reference in New Issue