This commit is contained in:
ditiqi 2025-02-27 13:32:33 +08:00
parent 78fc40a719
commit 669525fb61
23 changed files with 256 additions and 462 deletions

View File

@ -101,7 +101,6 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
}, },
params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
) { ) {
const { courseDetail } = args; const { courseDetail } = args;
// If no transaction is provided, create a new one // If no transaction is provided, create a new one
if (!params.tx) { if (!params.tx) {
@ -124,7 +123,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
) { ) {
args.data.authorId = params?.staff?.id; args.data.authorId = params?.staff?.id;
args.data.updatedAt = dayjs().toDate(); args.data.updatedAt = dayjs().toDate();
const result = await super.create(args); const result = await super.create(args);
EventBus.emit('dataChanged', { EventBus.emit('dataChanged', {
type: ObjectType.POST, type: ObjectType.POST,

View File

@ -1,111 +0,0 @@
import { Card, Tag, Typography, Button } from "antd";
import {
BookOutlined,
EyeOutlined,
PlayCircleOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { CourseDto, TaxonomySlug } from "@nice/common";
import { useNavigate } from "react-router-dom";
interface CourseCardProps {
course: CourseDto;
edit?: boolean;
}
const { Title, Text } = Typography;
export default function CourseCard({ course, edit = false }: CourseCardProps) {
const navigate = useNavigate();
const handleClick = (course: CourseDto) => {
if (!edit) {
navigate(`/course/${course.id}/detail`);
} else {
navigate(`/course/${course.id}/editor`);
}
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<Card
onClick={() => handleClick(course)}
key={course.id}
hoverable
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
cover={
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
style={{
backgroundImage: `url(${course?.meta?.thumbnail})`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-300" />
</div>
}>
<div className="px-4 ">
<div className="overflow-hidden hover:overflow-auto">
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
{course?.terms?.map((term) => {
return (
<>
<Tag
key={term.id}
color={
term?.taxonomy?.slug ===
TaxonomySlug.CATEGORY
? "blue"
: term?.taxonomy?.slug ===
TaxonomySlug.LEVEL
? "green"
: "orange"
}
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0 ">
{term.name}
</Tag>
</>
);
})}
</div>
</div>
<Title
level={4}
className="mb-4 mt-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
<button> {course.title}</button>
</Title>
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
<div className="ml-2 flex items-center flex-grow">
<Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
{course?.depts?.length > 1
? `${course.depts[0].name}`
: course?.depts?.[0]?.name}
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
</Text>
</div>
</div>
<div className="flex items-center gap-2">
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined />
{`观看次数 ${course?.meta?.views || 0}`}
</span>
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<BookOutlined />
{`学习人数 ${course?.studentIds?.length || 0}`}
</span>
</div>
<div className="pt-4 border-t border-gray-100 text-center">
<Button
type="primary"
size="large"
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
{edit ? "编辑" : "立即学习"}
</Button>
</div>
</div>
</Card>
);
}

View File

@ -1,16 +1,14 @@
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import { PostType, Prisma } from "@nice/common"; import { PostType, Prisma } from "@nice/common";
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useMemo } from "react"; import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
import CourseCard from "./CourseCard";
import PostCard from "@web/src/components/models/course/card/PostCard";
export function CoursesContainer() { export function CoursesContainer() {
const {searchCondition, termsCondition } = useMainContext(); const { searchCondition, termsCondition } = useMainContext();
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => <PostCard course={post}></PostCard>} renderItem={(post) => <CourseCard post={post}></CourseCard>}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -5,7 +5,8 @@ import { api } from "@nice/client";
import { CoursesSectionTag } from "./CoursesSectionTag"; import { CoursesSectionTag } from "./CoursesSectionTag";
import LookForMore from "./LookForMore"; import LookForMore from "./LookForMore";
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import CourseCard from "../../courses/components/CourseCard"; import PostCard from "@web/src/components/models/post/PostCard";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
interface GetTaxonomyProps { interface GetTaxonomyProps {
categories: string[]; categories: string[];
isLoading: boolean; isLoading: boolean;
@ -17,7 +18,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps {
taxonomy: { taxonomy: {
slug: type, slug: type,
}, },
parentId: null parentId: null,
}, },
take: 11, // 只取前10个 take: 11, // 只取前10个
}); });
@ -82,17 +83,17 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
)} )}
</div> </div>
<PostList <PostList
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>} renderItem={(post) => <CourseCard post={post}></CourseCard>}
params={{ params={{
page: 1, page: 1,
pageSize: initialVisibleCoursesCount, pageSize: initialVisibleCoursesCount,
where: { where: {
terms: !(selectedCategory === "全部") terms: !(selectedCategory === "全部")
? { ? {
some: { some: {
name: selectedCategory, name: selectedCategory,
}, },
} }
: {}, : {},
}, },
}} }}

View File

@ -1,4 +1,4 @@
import { PostType, Prisma } from "packages/common/dist"; import { PostType, Prisma } from "@nice/common";
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,

View File

@ -1,9 +1,9 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import CourseCard from "../../courses/components/CourseCard";
import { PostType } from "@nice/common"; import { PostType } from "@nice/common";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import PostCard from "@web/src/components/models/post/PostCard";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
export default function MyDutyListContainer() { export default function MyDutyListContainer() {
const { user } = useAuth(); const { user } = useAuth();
@ -11,9 +11,7 @@ export default function MyDutyListContainer() {
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => ( renderItem={(post) => <CourseCard post={post}></CourseCard>}
<CourseCard edit course={post}></CourseCard>
)}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -1,8 +1,9 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import CourseCard from "../../courses/components/CourseCard";
import { PostType } from "@nice/common"; import { PostType } from "@nice/common";
import PostCard from "@web/src/components/models/post/PostCard";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
export default function MyLearningListContainer() { export default function MyLearningListContainer() {
const { user } = useAuth(); const { user } = useAuth();
@ -10,9 +11,7 @@ export default function MyLearningListContainer() {
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => ( renderItem={(post) => <CourseCard post={post}></CourseCard>}
<CourseCard edit={false} course={post}></CourseCard>
)}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -1,10 +1,10 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { PostType } from "@nice/common"; import { PostType } from "@nice/common";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import PathCard from "../../path/components/PathCard"; import PostCard from "@web/src/components/models/post/PostCard";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
export default function MyPathListContainer() { export default function MyPathListContainer() {
const { user } = useAuth(); const { user } = useAuth();
@ -12,7 +12,7 @@ export default function MyPathListContainer() {
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => <PathCard path={post}></PathCard>} renderItem={(post) => <PathCard post={post}></PathCard>}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -1,22 +1,41 @@
import { TeamOutlined } from "@ant-design/icons"; import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons";
import { Typography } from "antd"; import { Typography } from "antd";
import { PostDto } from "@nice/common";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const DeptInfo = ({ path }) => { const DeptInfo = ({ post }: { post: PostDto }) => {
return ( return (
<div className="gap-1 flex items-center flex-grow"> <div className="gap-1 flex items-center justify-between flex-grow">
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" /> <div className=" flex justify-start gap-1 items-center">
{path?.depts && path?.depts?.length > 0 ? ( <TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]"> {post?.depts && post?.depts?.length > 0 ? (
{path?.depts?.length > 1 ? `${path.depts[0].name}` : path?.depts?.[0]?.name} <Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
</Text> {post?.depts?.length > 1
) : ( ? `${post.depts[0].name}`
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]"> : post?.depts?.[0]?.name}
</Text>
</Text> ) : (
)} <Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
</div>
); </Text>
)}
</div>
{post && (
<div className="flex items-center gap-2">
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined />
{`${post?.meta?.views || 0}`}
</span>
{post?.studentIds && post?.studentIds?.length > 0 && (
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<BookOutlined />
{`${post?.studentIds?.length || 0}`}
</span>
)}
</div>
)}
</div>
);
}; };
export default DeptInfo; export default DeptInfo;

View File

@ -1,65 +0,0 @@
import { Card, Tag, Typography, Button } from "antd";
import {
EyeOutlined
} from "@ant-design/icons";
import { PostDto, TaxonomySlug } from "@nice/common";
import { useNavigate } from "react-router-dom";
import DeptInfo from "./DeptInfo";
import TermInfo from "./TermInfo";
interface pathCardProps {
path: PostDto;
}
const { Title, Text } = Typography;
export default function PathCard({ path }: pathCardProps) {
const navigate = useNavigate();
const handleClick = (path: PostDto) => {
navigate(`/path/editor/${path.id}`);
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<Card
onClick={() => handleClick(path)}
key={path.id}
hoverable
className=" group overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
cover={
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
style={{
backgroundImage: `url(${path?.meta?.thumbnail})`,
}}
/>
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" /> */}
</div>
}>
<div className="px-4">
<TermInfo path={path} />
<Title
level={4}
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
<button> {path.title}</button>
</Title>
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
<DeptInfo path={path} />
<span className="flex text-xs font-medium text-gray-500">
<EyeOutlined className="mr-2"></EyeOutlined>
{path?.meta?.views
? `观看次数 ${path?.meta?.views}`
: 0}
</span>
</div>
<div className="pt-4 border-t border-gray-100 text-center">
<Button
type="primary"
size="large"
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
</Button>
</div>
</div>
</Card>
);
}

View File

@ -1,15 +1,15 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import { PostType, Prisma } from "@nice/common"; import { PostType, Prisma } from "@nice/common";
import PathCard from "./PathCard"; import PostCard from "@web/src/components/models/post/PostCard";
import PostCard from "@web/src/components/models/course/card/PostCard"; import PathCard from "@web/src/components/models/post/SubPost/PathCard";
export function PathListContainer() { export function PathListContainer() {
const { searchCondition, termsCondition } = useMainContext(); const { searchCondition, termsCondition } = useMainContext();
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => <PostCard path={post}></PostCard>} renderItem={(post) => <PathCard post={post}></PathCard>}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -1,41 +1,43 @@
import { Tag } from "antd"; import { Tag } from "antd";
import { TaxonomySlug } from "@nice/common"; import { PostDto, TaxonomySlug } from "@nice/common";
const TermInfo = ({ path }) => { const TermInfo = ({ post }: { post: PostDto }) => {
console.log('xx',path?.terms); console.log("xx", post?.terms);
return <> return (
{path?.terms && path?.terms?.length > 0 ? ( <>
<div className="flex gap-2 mb-4"> {post?.terms && post?.terms?.length > 0 ? (
{path?.terms?.map((term:any) => { <div className="flex gap-2 mb-4">
return ( {post?.terms?.map((term: any) => {
<Tag return (
key={term.id} <Tag
color={ key={term.id}
term?.taxonomy?.slug === color={
TaxonomySlug.CATEGORY term?.taxonomy?.slug ===
? "blue" TaxonomySlug.CATEGORY
: term?.taxonomy?.slug === ? "blue"
TaxonomySlug.LEVEL : term?.taxonomy?.slug ===
? "green" TaxonomySlug.LEVEL
: "orange" ? "green"
} : "orange"
className="px-3 py-1 rounded-full border-0"> }
{term.name} className="px-3 py-1 rounded-full border-0">
</Tag> {term.name}
); </Tag>
})} );
</div> })}
) : ( </div>
<div className="flex gap-2 mb-4"> ) : (
<Tag <div className="flex gap-2 mb-4">
color={"orange"} <Tag
className="px-3 py-1 rounded-full border-0"> color={"orange"}
{"未设置分类"} className="px-3 py-1 rounded-full border-0">
</Tag> {"未设置分类"}
</div> </Tag>
)} </div>
</> )}
</>
);
}; };
export default TermInfo; export default TermInfo;

View File

@ -1,14 +1,24 @@
import PostList from "@web/src/components/models/course/list/PostList"; import PostList from "@web/src/components/models/course/list/PostList";
import { useMainContext } from "../../layout/MainProvider"; import { useMainContext } from "../../layout/MainProvider";
import PathCard from "../../path/components/PathCard"; import PostCard from "@web/src/components/models/post/PostCard";
import { useEffect } from "react"; import { PostType } from "@nice/common";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
const POST_TYPE_COMPONENTS = {
[PostType.COURSE]: CourseCard,
[PostType.PATH]: PathCard,
};
export default function SearchListContainer() { export default function SearchListContainer() {
const { searchCondition, termsCondition, searchMode } = useMainContext(); const { searchCondition, termsCondition, searchMode } = useMainContext();
return ( return (
<> <>
<PostList <PostList
renderItem={(post) => <PathCard path={post}></PathCard>} renderItem={(post) => {
const Component =
POST_TYPE_COMPONENTS[post.type] || PostCard;
return <Component post={post} />;
}}
params={{ params={{
pageSize: 12, pageSize: 12,
where: { where: {

View File

@ -1,32 +0,0 @@
import { CourseDto } from "@nice/common";
import { Card } from "@web/src/components/common/container/Card";
import { CourseHeader } from "./CourseHeader";
import { CourseStats } from "./CourseStats";
import { Popover } from "@web/src/components/presentation/popover";
import { useState } from "react";
interface CourseCardProps {
course: CourseDto;
onClick?: () => void;
}
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
return (
<Card onClick={onClick} className="w-full max-w-sm">
<CourseHeader
title={course.title}
subTitle={course.subTitle}
thumbnail={course.thumbnail}
level={course.level}
numberOfStudents={course.numberOfStudents}
publishedAt={course.publishedAt}
/>
<CourseStats
averageRating={course.averageRating}
numberOfReviews={course.numberOfReviews}
completionRate={course.completionRate}
totalDuration={course.totalDuration}
/>
</Card>
);
};

View File

@ -1,127 +0,0 @@
import { Card, Tag, Typography, Button } from "antd";
import {
BookOutlined,
EyeOutlined,
PlayCircleOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { CourseDto, PostDto, TaxonomySlug } from "@nice/common";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
interface PostCardProps {
course?: CourseDto;
path?: PostDto
}
const { Title, Text } = Typography;
export default function PostCard({ course = null, path = null }: PostCardProps) {
const navigate = useNavigate();
const handleClick = (course: CourseDto) => {
if (course) {
navigate(`/course/${course.id}/detail`);
} else if (path) {
navigate(`/path/editor/${path.id}`);
}
window.scrollTo({ top: 0, behavior: "smooth", })
};
return (
<Card
onClick={() => handleClick(course)}
key={course?.id}
hoverable
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
cover={
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
style={{
backgroundImage: `url(${course?.meta?.thumbnail})`,
}}
/>
{course && (
<>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-300" />
</>
)}
</div>
}>
<div className="px-4 ">
<div className="overflow-hidden hover:overflow-auto">
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
{course?.terms?.map((term) => {
return (
<>
<Tag
key={term.id}
color={
term?.taxonomy?.slug ===
TaxonomySlug.CATEGORY
? "blue"
: term?.taxonomy?.slug ===
TaxonomySlug.LEVEL
? "green"
: "orange"
}
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0 ">
{term.name}
</Tag>
</>
);
})}
</div>
</div>
<Title
level={4}
className="mb-4 mt-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
<button> {course?.title}</button>
</Title>
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
<div className="ml-2 flex items-center flex-grow">
<Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
{course?.depts?.length > 1
? `${course.depts[0].name}`
: course?.depts?.[0]?.name}
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
</Text>
</div>
{!course && (
<>
<span className="text-xs font-medium text-gray-500">
{path?.meta?.views
? `观看次数 ${path?.meta?.views}`
: null}
</span>
</>
)}
</div>
{course && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined />
{`观看次数 ${course?.meta?.views || 0}`}
</span>
<span className="text-xs font-medium text-gray-500 flex items-center">
<BookOutlined />
{`学习人数 ${course?.studentIds?.length || 0}`}
</span>
</div>
)}
<div className="pt-4 border-t border-gray-100 text-center">
<Button
type="primary"
size="large"
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
{"立即学习"}
</Button>
</div>
</div>
</Card>
);
}

View File

@ -98,12 +98,18 @@ export function CourseFormProvider({
thumbnail: values?.meta?.thumbnail, thumbnail: values?.meta?.thumbnail,
}), }),
}, },
terms: { terms:
set: termIds.map((id) => ({ id })), // 转换成 connect 格式 termIds?.length > 0
}, ? {
depts: { set: termIds.map((id) => ({ id })), // 转换成 connect 格式
set: deptIds.map((id) => ({ id })), }
}, : undefined,
depts:
deptIds?.length > 0
? {
set: deptIds.map((id) => ({ id })),
}
: undefined,
}; };
// 删除原始的 taxonomy 字段 // 删除原始的 taxonomy 字段
taxonomies.forEach((tax) => { taxonomies.forEach((tax) => {

View File

@ -26,6 +26,7 @@ import CollapsibleContent from "@web/src/components/common/container/Collapsible
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer"; import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader"; import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
import { set } from "idb-keyval";
interface SortableLectureProps { interface SortableLectureProps {
field: Lecture; field: Lecture;
@ -82,13 +83,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
: undefined, : undefined,
}, },
resources: { resources:
connect: [videoUrlId, ...fileIds] [videoUrlId, ...fileIds].filter(Boolean)?.length > 0
.filter(Boolean) ? {
.map((fileId) => ({ connect: [videoUrlId, ...fileIds]
fileId, .filter(Boolean)
})), .map((fileId) => ({
}, fileId,
})),
}
: undefined,
content: values?.content, content: values?.content,
}, },
}); });
@ -108,13 +112,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
: undefined, : undefined,
}, },
resources: { resources:
connect: [videoUrlId, ...fileIds] [videoUrlId, ...fileIds].filter(Boolean)?.length > 0
.filter(Boolean) ? {
.map((fileId) => ({ connect: [videoUrlId, ...fileIds]
fileId, .filter(Boolean)
})), .map((fileId) => ({
}, fileId,
})),
}
: undefined,
content: values?.content, content: values?.content,
}, },
}); });
@ -199,13 +206,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name={["meta", "fileIds"]} name={["meta", "fileIds"]}
className="mb-0 flex-1" className="mb-0 flex-1">
rules={[
{
required: true,
message: "请传入文件",
},
]}>
<TusUploader multiple={true} /> <TusUploader multiple={true} />
</Form.Item> </Form.Item>
</div> </div>

View File

@ -135,7 +135,7 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
</Form> </Form>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Space> <Space className=" flex">
<DragOutlined <DragOutlined
{...attributes} {...attributes}
{...listeners} {...listeners}
@ -143,19 +143,28 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
/> />
<span>{field.title || "未命名章节"}</span> <span>{field.title || "未命名章节"}</span>
</Space> </Space>
<Space>
<Button
type="link"
onClick={() => setEditing(true)}>
</Button>
<Button type="link" danger onClick={remove}>
</Button>
</Space>
</div> </div>
) )
} }
extra={
!editing && (
<Space onClick={(e) => e.stopPropagation()}>
<Button
size="small"
type="link"
onClick={() => setEditing(true)}>
</Button>
<Button
size="small"
type="link"
danger
onClick={remove}>
</Button>
</Space>
)
}
key={field.id || "new"}> key={field.id || "new"}>
{children} {children}
</Collapse.Panel> </Collapse.Panel>

View File

@ -1,5 +1,5 @@
import { Pagination, Empty, Skeleton } from "antd"; import { Pagination, Empty, Skeleton } from "antd";
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common"; import { courseDetailSelect, CourseDto, PostDto, Prisma } from "@nice/common";
import { api } from "@nice/client"; import { api } from "@nice/client";
import { DefaultArgs } from "@prisma/client/runtime/library"; import { DefaultArgs } from "@prisma/client/runtime/library";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
@ -12,7 +12,7 @@ interface PostListProps {
}; };
cols?: number; cols?: number;
showPagination?: boolean; showPagination?: boolean;
renderItem: (post: any) => React.ReactNode; renderItem: (post: PostDto) => React.ReactNode;
} }
interface PostPagnationProps { interface PostPagnationProps {
data: { data: {

View File

@ -0,0 +1,59 @@
import { Card, Typography, Button } from "antd";
import { PostDto } from "@nice/common";
import DeptInfo from "@web/src/app/main/path/components/DeptInfo";
import TermInfo from "@web/src/app/main/path/components/TermInfo";
interface PostCardProps {
post?: PostDto;
onClick?: (post?: PostDto) => void;
}
const { Title } = Typography;
export default function PostCard({ post, onClick }: PostCardProps) {
const handleClick = (post: PostDto) => {
onClick?.(post);
};
return (
<Card
onClick={() => handleClick(post)}
key={post?.id}
hoverable
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
cover={
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
style={{
backgroundImage: `url(${post?.meta?.thumbnail})`,
}}
/>
</div>
}>
<div className="px-4 ">
<div className="overflow-hidden hover:overflow-auto">
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
<TermInfo post={post}></TermInfo>
</div>
</div>
<Title
level={4}
className="mb-4 mt-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
<button> {post?.title}</button>
</Title>
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
<DeptInfo post={post}></DeptInfo>
</div>
<div className="pt-4 border-t border-gray-100 text-center">
<Button
type="primary"
size="large"
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
{"立即学习"}
</Button>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,13 @@
import { CourseDto, PostDto } from "@nice/common";
import { useNavigate } from "react-router-dom";
import PostCard from "../PostCard";
export default function CourseCard({ post }: { post: PostDto }) {
const navigate = useNavigate();
return (
<PostCard
post={post}
onClick={() => {
navigate(`/course/${post?.id}/detail`);
}}></PostCard>
);
}

View File

@ -0,0 +1,14 @@
import { PostDto } from "@nice/common";
import { useNavigate } from "react-router-dom";
import PostCard from "../PostCard";
export default function PathCard({ post }: { post: PostDto }) {
const navigate = useNavigate();
return (
<PostCard
post={post}
onClick={() => {
navigate(`/path/editor/${post?.id}`);
}}></PostCard>
);
}

View File

@ -48,6 +48,7 @@ export type PostDto = Post & {
thumbnail?: string; thumbnail?: string;
views?: number; views?: number;
}; };
studentIds?: string[];
}; };
export type LectureMeta = { export type LectureMeta = {