This commit is contained in:
ditiqi 2025-02-21 13:14:47 +08:00
parent 7fd4bc59bc
commit 0597b4f5b3
14 changed files with 127 additions and 93 deletions

View File

@ -124,7 +124,10 @@ export async function updateCourseEnrollmentStats(courseId: string) {
}, },
}); });
} }
export async function setCourseInfo({ data }: { data: Post }) { export async function setCourseInfo({ data }: { data: Post }) {
// await db.term
if (data?.type === PostType.COURSE) { if (data?.type === PostType.COURSE) {
const ancestries = await db.postAncestry.findMany({ const ancestries = await db.postAncestry.findMany({
where: { where: {

View File

@ -2,7 +2,7 @@ import CourseDetail from "@web/src/components/models/course/detail/CourseDetail"
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
export function CourseDetailPage() { export function CourseDetailPage() {
const { id } = useParams(); const { id, lectureId } = useParams();
console.log("Course ID:", id); console.log("Course ID:", id);
return <CourseDetail id={id}></CourseDetail>; return <CourseDetail id={id} lectureId={lectureId}></CourseDetail>;
} }

View File

@ -1,6 +1,7 @@
import { Checkbox, Divider, Radio, Space } from "antd"; import { Checkbox, Divider, Radio, Space } from "antd";
import { categories, levels } from "../mockData"; import { categories, levels } from "../mockData";
import { api } from "@nice/client"; import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common";
interface FilterSectionProps { interface FilterSectionProps {
selectedCategory: string; selectedCategory: string;
@ -15,6 +16,13 @@ export default function FilterSection({
onCategoryChange, onCategoryChange,
onLevelChange, onLevelChange,
}: FilterSectionProps) { }: FilterSectionProps) {
const { data: levels, isLoading } = api.term.findMany.useQuery({
where: {
taxonomy: {
slug: TaxonomySlug.LEVEL,
},
},
});
// const { data } = api.term; // const { data } = api.term;
return ( return (
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6"> <div className="bg-white p-6 rounded-lg shadow-sm space-y-6">
@ -42,9 +50,9 @@ export default function FilterSection({
onChange={(e) => onLevelChange(e.target.value)} onChange={(e) => onLevelChange(e.target.value)}
className="flex flex-col space-y-3"> className="flex flex-col space-y-3">
<Radio value=""></Radio> <Radio value=""></Radio>
{levels.map((level) => ( {levels?.map((level) => (
<Radio key={level} value={level}> <Radio key={level.id} value={level.id}>
{level} {level.name}
</Radio> </Radio>
))} ))}
</Radio.Group> </Radio.Group>

View File

@ -1,65 +1,75 @@
import { useState } from 'react'; import { useState } from "react";
import { Input, Layout, Avatar, Button, Dropdown } from 'antd'; import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import { SearchOutlined, UserOutlined } from '@ant-design/icons'; import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { useAuth } from '@web/src/providers/auth-provider'; import { useAuth } from "@web/src/providers/auth-provider";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { UserMenu } from './UserMenu'; import { UserMenu } from "./UserMenu";
import { NavigationMenu } from './NavigationMenu'; import { NavigationMenu } from "./NavigationMenu";
const { Header } = Layout; const { Header } = Layout;
export function MainHeader() { export function MainHeader() {
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState("");
const { isAuthenticated, user } = useAuth(); const { isAuthenticated, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30"> <Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full"> <div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
<div <div
onClick={() => navigate('/')} 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" 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>
</div> <NavigationMenu />
<NavigationMenu /> </div>
</div> <div className="flex items-center space-x-6">
<div className="flex items-center space-x-6"> <div className="group relative">
<div className="group relative"> <Input
<Input size="large"
size="large" prefix={
prefix={<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />} <SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
placeholder="搜索课程" }
className="w-72 rounded-full" placeholder="搜索课程"
value={searchValue} className="w-72 rounded-full"
onChange={(e) => setSearchValue(e.target.value)} value={searchValue}
/> onChange={(e) => setSearchValue(e.target.value)}
</div> />
{isAuthenticated ? ( </div>
<Dropdown {isAuthenticated && (
overlay={<UserMenu />} <>
trigger={['click']} <Button
placement="bottomRight" onClick={() => navigate("/course/editor")}
> 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"
<Avatar icon={<EditFilled />}>
size="large"
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold" </Button>
> </>
{(user?.showname || user?.username || '')[0]?.toUpperCase()} )}
</Avatar> {isAuthenticated ? (
</Dropdown> <Dropdown
) : ( overlay={<UserMenu />}
<Button trigger={["click"]}
onClick={() => navigate('/login')} placement="bottomRight">
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" <Avatar
icon={<UserOutlined />} size="large"
> className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
{(user?.showname ||
</Button> user?.username ||
)} "")[0]?.toUpperCase()}
</div> </Avatar>
</div> </Dropdown>
</Header> ) : (
); <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,7 +1,13 @@
import { CourseDetailProvider } from "./CourseDetailContext"; import { CourseDetailProvider } from "./CourseDetailContext";
import CourseDetailLayout from "./CourseDetailLayout"; import CourseDetailLayout from "./CourseDetailLayout";
export default function CourseDetail({ id }: { id?: string }) { export default function CourseDetail({
id,
lectureId,
}: {
id?: string;
lectureId?: string;
}) {
const iframeStyle = { const iframeStyle = {
width: "50%", width: "50%",
height: "100vh", height: "100vh",

View File

@ -21,12 +21,13 @@ export function CourseDetailProvider({
children, children,
editId, editId,
}: CourseFormProviderProps) { }: CourseFormProviderProps) {
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
(api.post as any).findFirst.useQuery( (api.post as any).findFirst.useQuery(
{ {
where: { id: editId }, where: { id: editId },
include: { include: {
sections: { include: { lectures: true } }, // sections: { include: { lectures: true } },
enrollments: true, enrollments: true,
}, },
}, },

View File

@ -7,11 +7,12 @@ import {
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import React, { useState, useRef, useContext } from "react"; import React, { useState, useRef, useContext } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { SectionDto } from "@nice/common"; import { SectionDto, TaxonomySlug } from "@nice/common";
import { SyllabusHeader } from "./SyllabusHeader"; import { SyllabusHeader } from "./SyllabusHeader";
import { SectionItem } from "./SectionItem"; import { SectionItem } from "./SectionItem";
import { CollapsedButton } from "./CollapsedButton"; import { CollapsedButton } from "./CollapsedButton";
import { CourseDetailContext } from "../CourseDetailContext"; import { CourseDetailContext } from "../CourseDetailContext";
import { api } from "@nice/client";
interface CourseSyllabusProps { interface CourseSyllabusProps {
sections: SectionDto[]; sections: SectionDto[];
@ -29,7 +30,11 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
const { isHeaderVisible } = useContext(CourseDetailContext); const { isHeaderVisible } = useContext(CourseDetailContext);
const [expandedSections, setExpandedSections] = useState<string[]>([]); const [expandedSections, setExpandedSections] = useState<string[]>([]);
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
// api.term.findMany.useQuery({
// where: {
// taxonomy: { slug: TaxonomySlug.CATEGORY },
// },
// });
const toggleSection = (sectionId: string) => { const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => setExpandedSections((prev) =>
prev.includes(sectionId) prev.includes(sectionId)

View File

@ -1,9 +1,8 @@
// components/CourseSyllabus/LectureItem.tsx // components/CourseSyllabus/LectureItem.tsx
import { Lecture } from "@nice/common"; import { Lecture, LectureType } from "@nice/common";
import React from "react"; import React from "react";
import { motion } from "framer-motion"; import { ClockCircleOutlined, FileTextOutlined, PlayCircleOutlined } from "@ant-design/icons"; // 使用 Ant Design 图标
import { ClockIcon, PlayCircleIcon } from "@heroicons/react/24/outline";
interface LectureItemProps { interface LectureItemProps {
lecture: Lecture; lecture: Lecture;
@ -14,23 +13,24 @@ export const LectureItem: React.FC<LectureItemProps> = ({
lecture, lecture,
onClick, onClick,
}) => ( }) => (
<motion.button <div
initial={{ opacity: 0, x: -20 }} className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors cursor-pointer"
animate={{ opacity: 1, x: 0 }}
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
onClick={() => onClick(lecture.id)}> onClick={() => onClick(lecture.id)}>
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" /> {lecture.type === LectureType.VIDEO && (
<PlayCircleOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" />
)}
{lecture.type === LectureType.ARTICLE && (
<FileTextOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" /> // 为文章类型添加图标
)}
<div className="flex-grow"> <div className="flex-grow">
<h4 className="font-medium text-gray-800">{lecture.title}</h4> <h4 className="font-medium text-gray-800">{lecture.title}</h4>
{lecture.description && ( {lecture.subTitle && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">{lecture.subTitle}</p>
{lecture.description}
</p>
)} )}
</div> </div>
<div className="flex items-center gap-1 text-sm text-gray-500"> <div className="flex items-center gap-1 text-sm text-gray-500">
<ClockIcon className="w-4 h-4" /> <ClockCircleOutlined className="w-4 h-4" />
<span>{lecture.duration}</span> <span>{lecture.duration}</span>
</div> </div>
</motion.button> </div>
); );

View File

@ -33,8 +33,8 @@ export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
{section.title} {section.title}
</h3> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{section.totalLectures} ·{" "} {section?.lectures?.length} ·{" "}
{Math.floor(section.totalDuration / 60)} {/* {Math.floor(section?.totalDuration / 60)}分钟 */}
</p> </p>
</div> </div>
</div> </div>

View File

@ -96,7 +96,7 @@ export function CourseFormProvider({
}; };
// 删除原始的 taxonomy 字段 // 删除原始的 taxonomy 字段
taxonomies.forEach((tax) => { taxonomies.forEach((tax) => {
delete formattedValues[tax.name]; delete formattedValues[tax.id];
}); });
delete formattedValues.requirements; delete formattedValues.requirements;
delete formattedValues.objectives; delete formattedValues.objectives;

View File

@ -53,7 +53,9 @@ export function CourseBasicForm() {
label={tax.name} label={tax.name}
name={tax.id} name={tax.id}
key={index}> key={index}>
<TermSelect taxonomyId={tax.id}></TermSelect> <TermSelect
placeholder={`请选择${tax.name}`}
taxonomyId={tax.id}></TermSelect>
</Form.Item> </Form.Item>
))} ))}
{/* <Form.Item name="level" label=""> {/* <Form.Item name="level" label="">

View File

@ -56,11 +56,10 @@ export const routes: CustomRouteObject[] = [
{ {
index: true, index: true,
element: <HomePage />, element: <HomePage />,
}, },
{ {
path: "paths", path: "paths",
element: <PathsPage></PathsPage> element: <PathsPage></PathsPage>,
}, },
{ {
path: "courses", path: "courses",
@ -125,7 +124,7 @@ export const routes: CustomRouteObject[] = [
], ],
}, },
{ {
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的 path: ":id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />, element: <CourseDetailPage />,
}, },
], ],

View File

@ -198,7 +198,8 @@ model Post {
terms Term[] @relation("post_term") terms Term[] @relation("post_term")
order Float? @default(0) @map("order") order Float? @default(0) @map("order")
duration Int? duration Int?
rating Int? @default(0)
// 索引
// 日期时间类型字段 // 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
publishedAt DateTime? @map("published_at") // 发布时间 publishedAt DateTime? @map("published_at") // 发布时间
@ -242,7 +243,6 @@ model PostAncestry {
relDepth Int @map("rel_depth") relDepth Int @map("rel_depth")
ancestor Post? @relation("AncestorPosts", fields: [ancestorId], references: [id]) ancestor Post? @relation("AncestorPosts", fields: [ancestorId], references: [id])
descendant Post @relation("DescendantPosts", fields: [descendantId], references: [id]) descendant Post @relation("DescendantPosts", fields: [descendantId], references: [id])
// 复合索引优化 // 复合索引优化
// 索引建议 // 索引建议
@@index([ancestorId]) // 针对祖先的查询 @@index([ancestorId]) // 针对祖先的查询

View File

@ -6,8 +6,8 @@ export enum PostType {
POST_COMMENT = "post_comment", POST_COMMENT = "post_comment",
COURSE_REVIEW = "course_review", COURSE_REVIEW = "course_review",
COURSE = "couse", COURSE = "couse",
LECTURE = "lecture",
SECTION = "section", SECTION = "section",
LECTURE = "lecture",
} }
export enum LectureType { export enum LectureType {
VIDEO = "video", VIDEO = "video",