Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
1f2e6989fd
|
@ -15,7 +15,7 @@ async function bootstrap() {
|
||||||
const trpc = app.get(TrpcRouter);
|
const trpc = app.get(TrpcRouter);
|
||||||
trpc.applyMiddleware(app);
|
trpc.applyMiddleware(app);
|
||||||
|
|
||||||
const port = process.env.SERVER_PORT || 3001;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { setCourseInfo, setPostRelation } from './utils';
|
||||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
import { BaseTreeService } from '../base/base.tree.service';
|
import { BaseTreeService } from '../base/base.tree.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { DefaultArgs } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
|
@ -48,7 +49,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
meta: {
|
meta: {
|
||||||
type: type,
|
type: type,
|
||||||
},
|
},
|
||||||
},
|
} as any,
|
||||||
},
|
},
|
||||||
{ tx },
|
{ tx },
|
||||||
);
|
);
|
||||||
|
@ -70,7 +71,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
parentId: courseId,
|
parentId: courseId,
|
||||||
title: title,
|
title: title,
|
||||||
authorId: staff?.id,
|
authorId: staff?.id,
|
||||||
},
|
} as any,
|
||||||
},
|
},
|
||||||
{ tx },
|
{ tx },
|
||||||
);
|
);
|
||||||
|
@ -215,7 +216,38 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
return { ...result, items };
|
return { ...result, items };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async findManyWithPagination(args: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
where?: Prisma.PostWhereInput;
|
||||||
|
select?: Prisma.PostSelect<DefaultArgs>;
|
||||||
|
}): Promise<{
|
||||||
|
items: {
|
||||||
|
id: string;
|
||||||
|
type: string | null;
|
||||||
|
level: string | null;
|
||||||
|
state: string | null;
|
||||||
|
title: string | null;
|
||||||
|
subTitle: string | null;
|
||||||
|
content: string | null;
|
||||||
|
important: boolean | null;
|
||||||
|
domainId: string | null;
|
||||||
|
order: number | null;
|
||||||
|
duration: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
publishedAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
authorId: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
hasChildren: boolean | null;
|
||||||
|
meta: Prisma.JsonValue | null;
|
||||||
|
}[];
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
return super.findManyWithPagination(args);
|
||||||
|
}
|
||||||
protected async setPerms(data: Post, staff?: UserProfile) {
|
protected async setPerms(data: Post, staff?: UserProfile) {
|
||||||
if (!staff) return;
|
if (!staff) return;
|
||||||
const perms: ResPerm = {
|
const perms: ResPerm = {
|
||||||
|
|
|
@ -137,6 +137,11 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
||||||
id: true,
|
id: true,
|
||||||
descendant: true,
|
descendant: true,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
descendant: {
|
||||||
|
order: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const descendants = ancestries.map((ancestry) => ancestry.descendant);
|
const descendants = ancestries.map((ancestry) => ancestry.descendant);
|
||||||
const sections: SectionDto[] = descendants
|
const sections: SectionDto[] = descendants
|
||||||
|
|
|
@ -37,4 +37,5 @@ export class PostQueueService implements OnModuleInit {
|
||||||
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${data.id}` },
|
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${data.id}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { db, VisitType } from '@nice/common';
|
import { db, VisitType } from '@nice/common';
|
||||||
export async function updatePostViewCount(id: string, type: VisitType) {
|
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({
|
const totalViews = await db.visit.aggregate({
|
||||||
_sum: {
|
_sum: {
|
||||||
views: true,
|
views: true,
|
||||||
|
@ -16,10 +20,12 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
meta: {
|
meta: {
|
||||||
|
...((post?.meta as any) || {}),
|
||||||
views: totalViews._sum.views || 0,
|
views: totalViews._sum.views || 0,
|
||||||
}, // Use 0 if no visits exist
|
}, // Use 0 if no visits exist
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('readed');
|
||||||
} else if (type === VisitType.LIKE) {
|
} else if (type === VisitType.LIKE) {
|
||||||
await db.post.update({
|
await db.post.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -27,6 +33,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
meta: {
|
meta: {
|
||||||
|
...((post?.meta as any) || {}),
|
||||||
likes: totalViews._sum.views || 0, // Use 0 if no visits exist
|
likes: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -38,6 +45,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
meta: {
|
meta: {
|
||||||
|
...((post?.meta as any) || {}),
|
||||||
hates: totalViews._sum.views || 0, // Use 0 if no visits exist
|
hates: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
updateCourseReviewStats,
|
updateCourseReviewStats,
|
||||||
updateParentLectureStats,
|
updateParentLectureStats,
|
||||||
} from '@server/models/post/utils';
|
} from '@server/models/post/utils';
|
||||||
|
import { updatePostViewCount } from '../models/post/utils';
|
||||||
const logger = new Logger('QueueWorker');
|
const logger = new Logger('QueueWorker');
|
||||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
try {
|
try {
|
||||||
|
@ -44,6 +45,12 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
`Updated course stats for courseId: ${courseId}, type: ${type}`,
|
`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) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error processing stats update job: ${error.message}`,
|
`Error processing stats update job: ${error.message}`,
|
||||||
|
|
|
@ -1,51 +1,73 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { CoursePreviewMsg } from '@web/src/app/main/course/preview/type.ts';
|
import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts";
|
||||||
import { Button , Tabs , Image, Skeleton } from 'antd';
|
import { Button, Tabs, Image, Skeleton } from "antd";
|
||||||
import type { TabsProps } from 'antd';
|
import type { TabsProps } from "antd";
|
||||||
import { PlayCircleOutlined } from "@ant-design/icons";
|
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||||
export function CoursePreviewAllmsg({previewMsg,items,isLoading}: {previewMsg?:CoursePreviewMsg,items:TabsProps['items'],isLoading:Boolean}){
|
export function CoursePreviewAllmsg({
|
||||||
useEffect(() => {
|
previewMsg,
|
||||||
console.log(previewMsg)
|
items,
|
||||||
})
|
isLoading,
|
||||||
const TapOnChange = (key: string) => {
|
}: {
|
||||||
console.log(key);
|
previewMsg?: CoursePreviewMsg;
|
||||||
};
|
items: TabsProps["items"];
|
||||||
return (
|
isLoading: boolean;
|
||||||
<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">
|
useEffect(() => {
|
||||||
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
console.log(previewMsg);
|
||||||
<Image
|
});
|
||||||
src={previewMsg.isLoading ? 'error' : previewMsg.videoPreview}
|
const TapOnChange = (key: string) => {
|
||||||
alt="example"
|
console.log(key);
|
||||||
preview = {false}
|
};
|
||||||
className="w-full h-full object-cover z-0"
|
return (
|
||||||
fallback="data:image/png;base64,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"
|
<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='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'>
|
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||||
<PlayCircleOutlined
|
<Image
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10"
|
src={
|
||||||
/>
|
previewMsg.isLoading
|
||||||
</div>
|
? "error"
|
||||||
|
: previewMsg.videoPreview
|
||||||
</div>
|
}
|
||||||
<div className="flex flex-col justify-between w-2/5 content-start h-[340px] my-4 overflow-hidden">
|
alt="example"
|
||||||
{
|
preview={false}
|
||||||
isLoading ? <Skeleton className='my-5' active />
|
className="w-full h-full object-cover z-0"
|
||||||
:(
|
fallback="data:image/png;base64,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"
|
||||||
<>
|
/>
|
||||||
<span className="text-3xl font-bold my-3 ">{previewMsg.Title}</span>
|
<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">
|
||||||
<span className="text-xl font-semibold my-3 text-gray-700">{previewMsg.SubTitle}</span>
|
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
||||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">{previewMsg.Description}</span>
|
</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 />
|
||||||
<Button block type="primary" size='large'> 查看课程 </Button>
|
) : (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<span className="text-3xl font-bold my-3 ">
|
||||||
<div className="overflow-auto w-11/12 mx-auto my-8">
|
{previewMsg.Title}
|
||||||
<Tabs defaultActiveKey="1" tabBarGutter={100} items={items} onChange={TapOnChange} />
|
</span>
|
||||||
</div>
|
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||||
</div>
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto w-11/12 mx-auto my-8">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="1"
|
||||||
|
tabBarGutter={100}
|
||||||
|
items={items}
|
||||||
|
onChange={TapOnChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
export interface CoursePreviewMsg{
|
export interface CoursePreviewMsg {
|
||||||
videoPreview: string;
|
videoPreview: string;
|
||||||
Title: string;
|
Title: string;
|
||||||
SubTitle:string;
|
SubTitle: string;
|
||||||
Description:string;
|
Description: string;
|
||||||
ToCourseUrl:string;
|
ToCourseUrl: string;
|
||||||
isLoading:Boolean
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { Checkbox, Divider, Radio, Space , Spin} from 'antd';
|
||||||
import { categories, levels } from '../mockData';
|
import { categories, levels } from '../mockData';
|
||||||
import { TaxonomySlug, TermDto } from '@nice/common';
|
import { TaxonomySlug, TermDto } from '@nice/common';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { api } from '@nice/client';
|
import { api } from '@nice/client';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
interface FilterSectionProps {
|
interface FilterSectionProps {
|
||||||
selectedCategory: string;
|
selectedCategory: string;
|
||||||
|
@ -53,6 +54,12 @@ export default function FilterSection({
|
||||||
const levels : GetTaxonomyProps = useGetTaxonomy({
|
const levels : GetTaxonomyProps = useGetTaxonomy({
|
||||||
type: TaxonomySlug.LEVEL,
|
type: TaxonomySlug.LEVEL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [searchParams,setSearchParams] = useSearchParams()
|
||||||
|
useEffect(() => {
|
||||||
|
if(searchParams.get('category')) onCategoryChange(searchParams.get('category'))
|
||||||
|
},[searchParams.get('category')])
|
||||||
|
|
||||||
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">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -66,6 +66,7 @@ export default function CoursesPage() {
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
selectedLevel={selectedLevel}
|
selectedLevel={selectedLevel}
|
||||||
onCategoryChange={(category) => {
|
onCategoryChange={(category) => {
|
||||||
|
console.log(category);
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Typography, Button } from 'antd';
|
import { Typography, Button, Spin } from 'antd';
|
||||||
import { stringToColor, TaxonomySlug, TermDto } from '@nice/common';
|
import { stringToColor, TaxonomySlug, TermDto } from '@nice/common';
|
||||||
|
import { api,} from '@nice/client';
|
||||||
|
import { ControlOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
@ -10,45 +13,45 @@ interface CourseCategory {
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseCategories: CourseCategory[] = [
|
// const courseCategories: CourseCategory[] = [
|
||||||
{
|
// {
|
||||||
name: '计算机基础',
|
// name: '计算机基础',
|
||||||
count: 120,
|
// count: 120,
|
||||||
description: '计算机组成原理、操作系统、网络等基础知识'
|
// description: '计算机组成原理、操作系统、网络等基础知识'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: '编程语言',
|
// name: '编程语言',
|
||||||
count: 85,
|
// count: 85,
|
||||||
description: 'Python、Java、JavaScript等主流编程语言'
|
// description: 'Python、Java、JavaScript等主流编程语言'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: '人工智能',
|
// name: '人工智能',
|
||||||
count: 65,
|
// count: 65,
|
||||||
description: '机器学习、深度学习、自然语言处理等前沿技术'
|
// description: '机器学习、深度学习、自然语言处理等前沿技术'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: '数据科学',
|
// name: '数据科学',
|
||||||
count: 45,
|
// count: 45,
|
||||||
description: '数据分析、数据可视化、商业智能等'
|
// description: '数据分析、数据可视化、商业智能等'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: '云计算',
|
// name: '云计算',
|
||||||
count: 38,
|
// count: 38,
|
||||||
description: '云服务、容器化、微服务架构等'
|
// description: '云服务、容器化、微服务架构等'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: '网络安全',
|
// name: '网络安全',
|
||||||
count: 42,
|
// count: 42,
|
||||||
description: '网络安全基础、渗透测试、安全防护等'
|
// description: '网络安全基础、渗透测试、安全防护等'
|
||||||
}
|
// }
|
||||||
];
|
// ];
|
||||||
|
|
||||||
|
|
||||||
const CategorySection = () => {
|
const CategorySection = () => {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
/**
|
//获得分类
|
||||||
* const {data,isLoading} :{data:TermDto[],isLoading:boolean}= api.term.findMany.useQuery({
|
const {data:courseCategoriesData,isLoading} :{data:TermDto[],isLoading:boolean}= api.term.findMany.useQuery({
|
||||||
where:{
|
where:{
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
slug:TaxonomySlug.CATEGORY
|
slug:TaxonomySlug.CATEGORY
|
||||||
|
@ -56,16 +59,32 @@ const CategorySection = () => {
|
||||||
},
|
},
|
||||||
include:{
|
include:{
|
||||||
children :true
|
children :true
|
||||||
}
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc', // 按创建时间降序排列
|
||||||
|
},
|
||||||
|
take:10
|
||||||
})
|
})
|
||||||
const courseCategories: CourseCategory[] = useMemo(() => {
|
// 分类展示
|
||||||
return data?.map((term) => ({
|
const [displayedCategories,setDisplayedCategories] = useState<TermDto[]>([])
|
||||||
name: term.name,
|
useEffect(() => {
|
||||||
count: term.hasChildren ? term.children.length : 0,
|
console.log(courseCategoriesData);
|
||||||
description: term.description
|
if(!isLoading){
|
||||||
})) || [];
|
if(showAll){
|
||||||
},[data])
|
setDisplayedCategories(courseCategoriesData)
|
||||||
*/
|
}else{
|
||||||
|
setDisplayedCategories(courseCategoriesData.slice(0,8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [courseCategoriesData,showAll]);
|
||||||
|
// const courseCategories: CourseCategory[] = useMemo(() => {
|
||||||
|
// return data?.map((term) => ({
|
||||||
|
// name: term.name,
|
||||||
|
// count: term.hasChildren ? term.children.length : 0,
|
||||||
|
// description: term.description
|
||||||
|
// })) || [];
|
||||||
|
// },[data])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback((index: number) => {
|
const handleMouseEnter = useCallback((index: number) => {
|
||||||
|
@ -76,9 +95,7 @@ const CategorySection = () => {
|
||||||
setHoveredIndex(null);
|
setHoveredIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const displayedCategories = showAll
|
const navigate = useNavigate()
|
||||||
? courseCategories
|
|
||||||
: courseCategories.slice(0, 8);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-32 relative overflow-hidden">
|
<section className="py-32 relative overflow-hidden">
|
||||||
|
@ -92,78 +109,86 @@ const CategorySection = () => {
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{displayedCategories.map((category, index) => {
|
{
|
||||||
const categoryColor = stringToColor(category.name);
|
isLoading ? <Spin></Spin> :
|
||||||
const isHovered = hoveredIndex === index;
|
(displayedCategories.map((category, index) => {
|
||||||
|
const categoryColor = stringToColor(category.name);
|
||||||
return (
|
const isHovered = hoveredIndex === index;
|
||||||
<div
|
|
||||||
key={index}
|
return (
|
||||||
className="group relative rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
|
|
||||||
onMouseEnter={() => handleMouseEnter(index)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`查看${category.name}课程类别`}
|
|
||||||
>
|
|
||||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
|
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${
|
key={index}
|
||||||
isHovered ? 'scale-[1.02] bg-opacity-95' : 'scale-100 bg-opacity-90'
|
className="group relative min-h-[130px] rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
|
||||||
}`}
|
onMouseEnter={() => handleMouseEnter(index)}
|
||||||
/>
|
onMouseLeave={handleMouseLeave}
|
||||||
<div
|
role="button"
|
||||||
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${
|
tabIndex={0}
|
||||||
isHovered ? 'shadow-[0_8px_30px_rgb(0,0,0,0.12)]' : 'shadow-none opacity-0'
|
aria-label={`查看${category.name}课程类别`}
|
||||||
}`}
|
onClick={()=>{
|
||||||
/>
|
console.log(category.name)
|
||||||
<div
|
navigate(`/courses?category=${category.name}`)
|
||||||
className={`absolute top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out ${
|
}}
|
||||||
isHovered ? 'w-36 opacity-90' : 'w-24 opacity-60'
|
>
|
||||||
}`}
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
|
||||||
style={{ backgroundColor: categoryColor }}
|
|
||||||
/>
|
|
||||||
<div className="relative p-6">
|
|
||||||
<div className="flex flex-col space-y-4 mb-4">
|
|
||||||
<Text strong className="text-xl font-semibold tracking-tight">
|
|
||||||
{category.name}
|
|
||||||
</Text>
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-sm w-fit font-medium transition-all duration-500 ease-out ${
|
|
||||||
isHovered ? 'shadow-md scale-105' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${categoryColor}15`,
|
|
||||||
color: categoryColor
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{category.count} 门课程
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Text type="secondary" className="block text-sm leading-relaxed opacity-90">
|
|
||||||
{category.description}
|
|
||||||
</Text>
|
|
||||||
<div
|
<div
|
||||||
className={`mt-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out ${
|
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${
|
||||||
isHovered ? 'translate-x-2' : ''
|
isHovered ? 'scale-[1.02] bg-opacity-95' : 'scale-100 bg-opacity-90'
|
||||||
}`}
|
}`}
|
||||||
style={{ color: categoryColor }}
|
/>
|
||||||
>
|
<div
|
||||||
<span>了解更多</span>
|
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${
|
||||||
<span
|
isHovered ? 'shadow-[0_8px_30px_rgb(0,0,0,0.12)]' : 'shadow-none opacity-0'
|
||||||
className={`transform transition-all duration-500 ease-out ${
|
}`}
|
||||||
isHovered ? 'translate-x-2' : ''
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out ${
|
||||||
|
false ? 'w-36 opacity-90' : 'w-24 opacity-60'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: categoryColor }}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full h-full p-6">
|
||||||
|
<div className="flex w-2/3 absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 flex-col space-y-4 mb-4">
|
||||||
|
<Text strong className="text-xl font-medium tracking-wide">
|
||||||
|
{category.name}
|
||||||
|
</Text>
|
||||||
|
{/* <span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm w-fit font-medium transition-all duration-500 ease-out ${
|
||||||
|
isHovered ? 'shadow-md scale-105' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${categoryColor}15`,
|
||||||
|
color: categoryColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.children.length} 门课程
|
||||||
|
</span> */}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" className="block text-sm leading-relaxed opacity-90">
|
||||||
|
{category.description}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
className={` mt-6 absolute bottom-4 right-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out ${
|
||||||
|
false ? 'translate-x-2' : ''
|
||||||
}`}
|
}`}
|
||||||
|
style={{ color: categoryColor }}
|
||||||
>
|
>
|
||||||
→
|
<span>了解更多</span>
|
||||||
</span>
|
<span
|
||||||
|
className={`transform transition-all duration-500 ease-out ${
|
||||||
|
false ? 'translate-x-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}))
|
||||||
})}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{courseCategories.length > 8 && (
|
{!isLoading && courseCategoriesData.length > 8 && (
|
||||||
<div className="flex justify-center mt-12">
|
<div className="flex justify-center mt-12">
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
|
|
|
@ -6,7 +6,8 @@ import {
|
||||||
StarOutlined,
|
StarOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RightOutlined
|
RightOutlined,
|
||||||
|
EyeOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
|
|
||||||
|
@ -46,8 +47,8 @@ const carouselItems: CarouselItem[] = [
|
||||||
const platformStats: PlatformStat[] = [
|
const platformStats: PlatformStat[] = [
|
||||||
{ icon: <TeamOutlined />, value: '50,000+', label: '注册学员' },
|
{ icon: <TeamOutlined />, value: '50,000+', label: '注册学员' },
|
||||||
{ icon: <BookOutlined />, value: '1,000+', label: '精品课程' },
|
{ icon: <BookOutlined />, value: '1,000+', label: '精品课程' },
|
||||||
{ icon: <StarOutlined />, value: '98%', label: '好评度' },
|
// { icon: <StarOutlined />, value: '98%', label: '好评度' },
|
||||||
{ icon: <ClockCircleOutlined />, value: '100万+', label: '学习时长' }
|
{ icon: <EyeOutlined />, value: '100万+', label: '观看次数' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const HeroSection = () => {
|
const HeroSection = () => {
|
||||||
|
@ -132,8 +133,8 @@ const HeroSection = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Container */}
|
{/* Stats Container */}
|
||||||
<div className="absolute -bottom-24 left-1/2 -translate-x-1/2 w-full max-w-6xl px-4">
|
<div className="absolute -bottom-24 left-1/2 -translate-x-1/2 w-1/2 max-w-6xl px-4">
|
||||||
<div className="rounded-2xl grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]">
|
<div className="rounded-2xl grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]">
|
||||||
{platformStats.map((stat, index) => (
|
{platformStats.map((stat, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
@ -145,7 +146,9 @@ const HeroSection = () => {
|
||||||
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
<div className="text-gray-600 font-medium">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,8 +2,8 @@ import HeroSection from './components/HeroSection';
|
||||||
import CategorySection from './components/CategorySection';
|
import CategorySection from './components/CategorySection';
|
||||||
import CoursesSection from './components/CoursesSection';
|
import CoursesSection from './components/CoursesSection';
|
||||||
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
|
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
|
||||||
|
import { api } from '@nice/client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { api } from '@nice/client'
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const mockCourses = [
|
const mockCourses = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -44,7 +44,9 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
||||||
// 在组件中定义 key 状态
|
// 在组件中定义 key 状态
|
||||||
const [avatarKey, setAvatarKey] = useState(0);
|
const [avatarKey, setAvatarKey] = useState(0);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviewUrl(value || "");
|
||||||
|
}, [value]);
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = event.target.files?.[0];
|
const selectedFile = event.target.files?.[0];
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
|
|
@ -185,13 +185,13 @@ export function UserMenu() {
|
||||||
id="user-menu"
|
id="user-menu"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-labelledby="user-menu-button"
|
aria-labelledby="user-menu-button"
|
||||||
style={{ zIndex: 100 }}
|
style={{ zIndex: 1000 }}
|
||||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||||
bg-white rounded-xl overflow-hidden shadow-lg
|
bg-white rounded-xl overflow-hidden shadow-lg
|
||||||
border border-[#E5EDF5]">
|
border border-[#E5EDF5]">
|
||||||
{/* User Profile Section */}
|
{/* User Profile Section */}
|
||||||
<div
|
<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] ">
|
border-b border-[#E5EDF5] ">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
|
@ -1,25 +1,61 @@
|
||||||
import { Course } from "@nice/common";
|
import { Course } from "@nice/common";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext, useMemo } from "react";
|
||||||
import { Typography, Skeleton } from "antd"; // 引入 antd 组件
|
import { Image, Typography, Skeleton } from "antd"; // 引入 antd 组件
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
interface CourseDetailProps {
|
export const CourseDetailDescription: React.FC = () => {
|
||||||
course: Course;
|
const { course, isLoading, selectedLectureId, setSelectedLectureId } =
|
||||||
isLoading: boolean;
|
useContext(CourseDetailContext);
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseDetailDescription: React.FC<CourseDetailProps> = () => {
|
|
||||||
const { course, isLoading } = useContext(CourseDetailContext);
|
|
||||||
const { Paragraph, Title } = Typography;
|
const { Paragraph, Title } = Typography;
|
||||||
|
const firstLectureId = useMemo(() => {
|
||||||
|
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||||
|
}, [course]);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6">
|
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6">
|
||||||
{isLoading || !course ? (
|
{isLoading || !course ? (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-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
|
<Paragraph
|
||||||
|
className="text-gray-600"
|
||||||
ellipsis={{
|
ellipsis={{
|
||||||
rows: 3,
|
rows: 3,
|
||||||
expandable: true,
|
expandable: true,
|
||||||
|
@ -27,7 +63,7 @@ export const CourseDetailDescription: React.FC<CourseDetailProps> = () => {
|
||||||
onExpand: () => console.log("展开"),
|
onExpand: () => console.log("展开"),
|
||||||
// collapseText: "收起",
|
// collapseText: "收起",
|
||||||
}}>
|
}}>
|
||||||
{course.content}
|
{course?.content}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Course, LectureType, PostType } from "@nice/common";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||||
import { Skeleton } from "antd";
|
import { Skeleton } from "antd";
|
||||||
|
import { CoursePreview } from "./CoursePreview/CoursePreview";
|
||||||
|
|
||||||
// interface CourseDetailDisplayAreaProps {
|
// interface CourseDetailDisplayAreaProps {
|
||||||
// // course: Course;
|
// // course: Course;
|
||||||
|
@ -17,7 +18,7 @@ import { Skeleton } from "antd";
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC = () => {
|
export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const { course, isLoading, lecture, lectureIsLoading } =
|
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||||
useContext(CourseDetailContext);
|
useContext(CourseDetailContext);
|
||||||
const { scrollY } = useScroll();
|
const { scrollY } = useScroll();
|
||||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||||
|
@ -27,20 +28,24 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
{lectureIsLoading && (
|
{lectureIsLoading && (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
||||||
)}
|
)}
|
||||||
{!lectureIsLoading && lecture?.meta?.type === LectureType.VIDEO && (
|
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full mt-2 px-4">
|
{selectedLectureId &&
|
||||||
<motion.div
|
!lectureIsLoading &&
|
||||||
style={{
|
lecture?.meta?.type === LectureType.VIDEO && (
|
||||||
opacity: videoOpacity,
|
<div className="flex justify-center flex-col items-center gap-2 w-full mt-2 px-4">
|
||||||
}}
|
<motion.div
|
||||||
className="w-full bg-black rounded-lg ">
|
style={{
|
||||||
<div className=" w-full ">
|
opacity: videoOpacity,
|
||||||
<VideoPlayer src={lecture?.meta?.videoUrl} />
|
}}
|
||||||
</div>
|
className="w-full bg-black rounded-lg ">
|
||||||
</motion.div>{" "}
|
<div className=" w-full ">
|
||||||
</div>
|
<VideoPlayer src={lecture?.meta?.videoUrl} />
|
||||||
)}
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!lectureIsLoading &&
|
{!lectureIsLoading &&
|
||||||
|
selectedLectureId &&
|
||||||
lecture?.meta?.type === LectureType.ARTICLE && (
|
lecture?.meta?.type === LectureType.ARTICLE && (
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
<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 ">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||||
<CourseDetailDescription
|
<CourseDetailDescription />
|
||||||
course={course}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 课程内容区域 */}
|
{/* 课程内容区域 */}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@ant-design/icons";
|
} 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 "@web/src/components/layout/element/usermenu/usermenu";
|
import { UserMenu } from "@web/src/app/main/layout/UserMenu";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
@ -18,7 +18,7 @@ export function CourseDetailHeader() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { course } = useContext(CourseDetailContext);
|
const { course } = useContext(CourseDetailContext);
|
||||||
|
|
||||||
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">
|
||||||
|
|
|
@ -18,7 +18,7 @@ export default function CourseDetailLayout() {
|
||||||
const handleLectureClick = (lectureId: string) => {
|
const handleLectureClick = (lectureId: string) => {
|
||||||
setSelectedLectureId(lectureId);
|
setSelectedLectureId(lectureId);
|
||||||
};
|
};
|
||||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CourseDetailHeader />
|
<CourseDetailHeader />
|
||||||
|
@ -30,6 +30,9 @@ export default function CourseDetailLayout() {
|
||||||
{" "}
|
{" "}
|
||||||
{/* 添加这个包装 div */}
|
{/* 添加这个包装 div */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
width: "75%",
|
||||||
|
}}
|
||||||
animate={{
|
animate={{
|
||||||
width: isSyllabusOpen ? "75%" : "100%",
|
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";
|
} from "@heroicons/react/24/outline";
|
||||||
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 { SectionDto, TaxonomySlug } 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";
|
||||||
|
@ -28,13 +27,11 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
onToggle,
|
onToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const { isHeaderVisible } = useContext(CourseDetailContext);
|
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 }>({});
|
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)
|
||||||
|
@ -42,70 +39,56 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
: [...prev, sectionId]
|
: [...prev, sectionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
// 直接滚动,无需延迟
|
||||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
block: "start",
|
block: "start",
|
||||||
});
|
});
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
{/* 收起按钮直接显示 */}
|
||||||
{/* 收起时的悬浮按钮 */}
|
{!isOpen && (
|
||||||
{!isOpen && (
|
<div className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||||
<motion.div
|
<CollapsedButton onToggle={onToggle} />
|
||||||
initial={{ opacity: 0 }}
|
</div>
|
||||||
animate={{ opacity: 1 }}
|
)}
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
<div
|
||||||
<CollapsedButton onToggle={onToggle} />
|
style={{
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{
|
|
||||||
width: isOpen ? "25%" : "0",
|
width: isOpen ? "25%" : "0",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: isHeaderVisible ? "64px" : "0",
|
top: isHeaderVisible ? "64px" : "0",
|
||||||
}}
|
}}
|
||||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||||
<AnimatePresence>
|
{isOpen && (
|
||||||
{isOpen && (
|
<div className="h-full flex flex-col">
|
||||||
<motion.div
|
<SyllabusHeader onToggle={onToggle} />
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className="h-full flex flex-col">
|
|
||||||
<SyllabusHeader onToggle={onToggle} />
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sections.map((section, index) => (
|
{sections.map((section, index) => (
|
||||||
<SectionItem
|
<SectionItem
|
||||||
key={section.id}
|
key={section.id}
|
||||||
ref={(el) =>
|
ref={(el) =>
|
||||||
(sectionRefs.current[
|
(sectionRefs.current[section.id] =
|
||||||
section.id
|
el)
|
||||||
] = el)
|
}
|
||||||
}
|
index={index + 1}
|
||||||
index={index + 1}
|
section={section}
|
||||||
section={section}
|
isExpanded={expandedSections.includes(
|
||||||
isExpanded={expandedSections.includes(
|
section.id
|
||||||
section.id
|
)}
|
||||||
)}
|
onToggle={toggleSection}
|
||||||
onToggle={toggleSection}
|
onLectureClick={onLectureClick}
|
||||||
onLectureClick={onLectureClick}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</AnimatePresence>
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
interface LectureItemProps {
|
interface LectureItemProps {
|
||||||
lecture: Lecture;
|
lecture: Lecture;
|
||||||
|
@ -16,25 +17,30 @@ interface LectureItemProps {
|
||||||
export const LectureItem: React.FC<LectureItemProps> = ({
|
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||||
lecture,
|
lecture,
|
||||||
onClick,
|
onClick,
|
||||||
}) => (
|
}) => {
|
||||||
<div
|
const { lectureId } = useParams();
|
||||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors cursor-pointer"
|
return (
|
||||||
onClick={() => onClick(lecture.id)}>
|
<div
|
||||||
{lecture.type === LectureType.VIDEO && (
|
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors cursor-pointer"
|
||||||
<PlayCircleOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
onClick={() => onClick(lecture.id)}>
|
||||||
)}
|
{lecture.type === LectureType.VIDEO && (
|
||||||
{lecture.type === LectureType.ARTICLE && (
|
<PlayCircleOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||||
<FileTextOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" /> // 为文章类型添加图标
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{lecture.type === LectureType.ARTICLE && (
|
||||||
{/* <div className="flex items-center gap-1 text-sm text-gray-500">
|
<FileTextOutlined className="w-5 h-5 text-blue-500 flex-shrink-0" /> // 为文章类型添加图标
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* <div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
<ClockCircleOutlined className="w-4 h-4" />
|
<ClockCircleOutlined className="w-4 h-4" />
|
||||||
<span>{lecture.duration}分钟</span>
|
<span>{lecture.duration}分钟</span>
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -16,11 +16,11 @@ interface SectionItemProps {
|
||||||
|
|
||||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||||
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => (
|
({ section, index, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||||
<motion.div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
// initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
// animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
// transition={{ duration: 0.3 }}
|
||||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||||
import { Form, FormInstance, message } from "antd";
|
import { Form, FormInstance, message } from "antd";
|
||||||
import {
|
import {
|
||||||
CourseDto,
|
CourseDto,
|
||||||
|
CourseMeta,
|
||||||
CourseStatus,
|
CourseStatus,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
PostType,
|
PostType,
|
||||||
|
@ -10,6 +11,7 @@ import {
|
||||||
import { api, usePost } from "@nice/client";
|
import { api, usePost } from "@nice/client";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
|
||||||
export type CourseFormData = {
|
export type CourseFormData = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -42,6 +44,7 @@ export function CourseFormProvider({
|
||||||
}: CourseFormProviderProps) {
|
}: CourseFormProviderProps) {
|
||||||
const [form] = Form.useForm<CourseFormData>();
|
const [form] = Form.useForm<CourseFormData>();
|
||||||
const { create, update, createCourse } = usePost();
|
const { create, update, createCourse } = usePost();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
|
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
|
||||||
{
|
{
|
||||||
where: { id: editId },
|
where: { id: editId },
|
||||||
|
@ -77,7 +80,7 @@ export function CourseFormProvider({
|
||||||
}
|
}
|
||||||
}, [course, form]);
|
}, [course, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: CourseFormData) => {
|
const onSubmit = async (values: any) => {
|
||||||
console.log(values);
|
console.log(values);
|
||||||
const sections = values?.sections || [];
|
const sections = values?.sections || [];
|
||||||
const termIds = taxonomies
|
const termIds = taxonomies
|
||||||
|
@ -87,7 +90,7 @@ export function CourseFormProvider({
|
||||||
const formattedValues = {
|
const formattedValues = {
|
||||||
...values,
|
...values,
|
||||||
meta: {
|
meta: {
|
||||||
thumbnail: values.thumbnail,
|
thumbnail: values?.meta?.thumbnail,
|
||||||
},
|
},
|
||||||
terms: {
|
terms: {
|
||||||
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||||
|
@ -98,6 +101,12 @@ export function CourseFormProvider({
|
||||||
delete formattedValues[tax.id];
|
delete formattedValues[tax.id];
|
||||||
});
|
});
|
||||||
delete formattedValues.sections;
|
delete formattedValues.sections;
|
||||||
|
if (course) {
|
||||||
|
formattedValues.meta = {
|
||||||
|
...(course?.meta as CourseMeta),
|
||||||
|
thumbnail: values?.meta?.thumbnail,
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
|
@ -110,6 +119,7 @@ export function CourseFormProvider({
|
||||||
courseDetail: {
|
courseDetail: {
|
||||||
data: {
|
data: {
|
||||||
title: formattedValues.title || "12345",
|
title: formattedValues.title || "12345",
|
||||||
|
|
||||||
// state: CourseStatus.DRAFT,
|
// state: CourseStatus.DRAFT,
|
||||||
type: PostType.COURSE,
|
type: PostType.COURSE,
|
||||||
...formattedValues,
|
...formattedValues,
|
||||||
|
|
|
@ -41,6 +41,9 @@ const CourseContentForm: React.FC = () => {
|
||||||
type: PostType.SECTION,
|
type: PostType.SECTION,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: "asc",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!editId,
|
enabled: !!editId,
|
||||||
|
|
|
@ -58,6 +58,9 @@ export const LectureList: React.FC<LectureListProps> = ({
|
||||||
type: PostType.LECTURE,
|
type: PostType.LECTURE,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: "asc",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!sectionId,
|
enabled: !!sectionId,
|
||||||
|
|
|
@ -1,25 +1,16 @@
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
export function useLocalSettings() {
|
export function useLocalSettings() {
|
||||||
const getBaseUrl = useCallback((protocol: string, port: number) => {
|
const getBaseUrl = useCallback((protocol: string, port: number) => {
|
||||||
return `${protocol}://${env.SERVER_IP}:${port}`;
|
return `${protocol}://${env.SERVER_IP}:${port}`;
|
||||||
}, []);
|
}, []);
|
||||||
const tusUrl = useMemo(() => getBaseUrl("http", 8080), [getBaseUrl]);
|
const tusUrl = useMemo(() => getBaseUrl('http', 8080), [getBaseUrl]);
|
||||||
const apiUrl = useMemo(
|
const apiUrl = useMemo(() => getBaseUrl('http', parseInt(env.SERVER_PORT)), [getBaseUrl]);
|
||||||
() => getBaseUrl("http", parseInt(env.SERVER_PORT)),
|
const websocketUrl = useMemo(() => getBaseUrl('ws', parseInt(env.SERVER_PORT)), [getBaseUrl]);
|
||||||
[getBaseUrl]
|
const checkIsTusUrl = useCallback((url: string) => {
|
||||||
);
|
return url.startsWith(tusUrl)
|
||||||
const websocketUrl = useMemo(() => parseInt(env.SERVER_PORT), [getBaseUrl]);
|
}, [tusUrl])
|
||||||
const checkIsTusUrl = useCallback(
|
return {
|
||||||
(url: string) => {
|
apiUrl, websocketUrl, checkIsTusUrl, tusUrl
|
||||||
return url.startsWith(tusUrl);
|
}
|
||||||
},
|
|
||||||
[tusUrl]
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
apiUrl,
|
|
||||||
websocketUrl,
|
|
||||||
checkIsTusUrl,
|
|
||||||
tusUrl,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
const BASE_URL = `http://${env.SERVER_IP}:${env.SERVER_PORT}`
|
const BASE_URL = `http://${env.SERVER_IP}:${env?.SERVER_PORT}`
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
// withCredentials: true,
|
// withCredentials: true,
|
||||||
});
|
});
|
||||||
// Add a request interceptor to attach the access token
|
// Add a request interceptor to attach the access token
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const accessToken = localStorage.getItem('access_token');
|
const accessToken = localStorage.getItem("access_token");
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
config.headers["Authorization"] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
);
|
);
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|
|
@ -204,7 +204,7 @@ model Post {
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
publishedAt DateTime? @map("published_at") // 发布时间
|
publishedAt DateTime? @map("published_at") // 发布时间
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||||
instructors PostInstructor[]
|
instructors PostInstructor[]
|
||||||
// 关系类型字段
|
// 关系类型字段
|
||||||
|
|
|
@ -58,6 +58,7 @@ export const InitTaxonomies: {
|
||||||
{
|
{
|
||||||
name: "分类",
|
name: "分类",
|
||||||
slug: TaxonomySlug.CATEGORY,
|
slug: TaxonomySlug.CATEGORY,
|
||||||
|
objectType: [ObjectType.COURSE],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "难度等级",
|
name: "难度等级",
|
||||||
|
|
|
@ -67,6 +67,9 @@ export type CourseMeta = {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
|
views?: number;
|
||||||
|
likes?: number;
|
||||||
|
hates?: number;
|
||||||
};
|
};
|
||||||
export type Course = Post & {
|
export type Course = Post & {
|
||||||
meta?: CourseMeta;
|
meta?: CourseMeta;
|
||||||
|
|
Loading…
Reference in New Issue