This commit is contained in:
wfc 2025-02-28 15:10:01 +08:00
commit 821bf1ec3e
45 changed files with 402 additions and 410 deletions

View File

@ -8,6 +8,7 @@ import {
DelegateFuncs, DelegateFuncs,
UpdateOrderArgs, UpdateOrderArgs,
TransactionType, TransactionType,
OrderByArgs,
SelectArgs, SelectArgs,
} from './base.type'; } from './base.type';
import { import {
@ -450,9 +451,10 @@ export class BaseService<
page?: number; page?: number;
pageSize?: number; pageSize?: number;
where?: WhereArgs<A['findMany']>; where?: WhereArgs<A['findMany']>;
orderBy?: OrderByArgs<A['findMany']>;
select?: SelectArgs<A['findMany']>; select?: SelectArgs<A['findMany']>;
}): Promise<{ items: R['findMany']; totalPages: number }> { }): Promise<{ items: R['findMany']; totalPages: number }> {
const { page = 1, pageSize = 10, where, select } = args; const { page = 1, pageSize = 10, where, select, orderBy } = args;
try { try {
// 获取总记录数 // 获取总记录数
@ -461,6 +463,7 @@ export class BaseService<
const items = (await this.getModel().findMany({ const items = (await this.getModel().findMany({
where, where,
select, select,
orderBy,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
} as any)) as R['findMany']; } as any)) as R['findMany'];

View File

@ -21,6 +21,7 @@ import { BaseTreeService } from '../base/base.tree.service';
import { z } from 'zod'; import { z } from 'zod';
import { DefaultArgs } from '@prisma/client/runtime/library'; import { DefaultArgs } from '@prisma/client/runtime/library';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { OrderByArgs } from '../base/base.type';
@Injectable() @Injectable()
export class PostService extends BaseTreeService<Prisma.PostDelegate> { export class PostService extends BaseTreeService<Prisma.PostDelegate> {
@ -181,6 +182,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
where?: Prisma.PostWhereInput; where?: Prisma.PostWhereInput;
orderBy?: OrderByArgs<(typeof db.post)['findMany']>;
select?: Prisma.PostSelect<DefaultArgs>; select?: Prisma.PostSelect<DefaultArgs>;
}): Promise<{ }): Promise<{
items: { items: {
@ -197,6 +199,9 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
duration: number | null; duration: number | null;
rating: number | null; rating: number | null;
createdAt: Date; createdAt: Date;
views: number;
hates: number;
likes: number;
publishedAt: Date | null; publishedAt: Date | null;
updatedAt: Date; updatedAt: Date;
deletedAt: Date | null; deletedAt: Date | null;

View File

@ -15,13 +15,13 @@ export class VisitRouter {
private readonly visitService: VisitService, private readonly visitService: VisitService,
) {} ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.procedure
.input(VisitCreateArgsSchema) .input(VisitCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.visitService.create(input, staff); return await this.visitService.create(input, staff);
}), }),
createMany: this.trpc.protectProcedure createMany: this.trpc.procedure
.input(z.array(VisitCreateManyInputSchema)) .input(z.array(VisitCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;

View File

@ -9,17 +9,22 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
} }
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
const { postId, lectureId, messageId } = args.data; const { postId, lectureId, messageId } = args.data;
const visitorId = args.data.visitorId || staff?.id; const visitorId = args.data?.visitorId || staff?.id;
let result; let result;
console.log(args.data.type);
console.log(visitorId);
console.log(postId);
const existingVisit = await db.visit.findFirst({ const existingVisit = await db.visit.findFirst({
where: { where: {
type: args.data.type, type: args.data.type,
visitorId, // visitorId: visitorId ? visitorId : null,
OR: [{ postId }, { lectureId }, { messageId }], OR: [{ postId }, { messageId }],
}, },
}); });
console.log('result', existingVisit);
if (!existingVisit) { if (!existingVisit) {
result = await super.create(args); result = await super.create(args);
console.log('createdResult', result);
} else if (args.data.type === VisitType.READED) { } else if (args.data.type === VisitType.READED) {
result = await super.update({ result = await super.update({
where: { id: existingVisit.id }, where: { id: existingVisit.id },

View File

@ -8,7 +8,7 @@ import {
export async function updateTotalCourseViewCount(type: VisitType) { export async function updateTotalCourseViewCount(type: VisitType) {
const posts = await db.post.findMany({ const posts = await db.post.findMany({
where: { where: {
type: { in: [PostType.COURSE, PostType.LECTURE] }, // type: { in: [PostType.COURSE, PostType.LECTURE,] },
deletedAt: null, deletedAt: null,
}, },
select: { id: true, type: true }, select: { id: true, type: true },
@ -66,27 +66,34 @@ export async function updatePostViewCount(id: string, type: VisitType) {
where: { id }, where: { id },
select: { id: true, meta: true, type: true }, select: { id: true, meta: true, type: true },
}); });
console.log(post?.type);
console.log('updatePostViewCount');
const metaFieldMap = { const metaFieldMap = {
[VisitType.READED]: 'views', [VisitType.READED]: 'views',
[VisitType.LIKE]: 'likes', [VisitType.LIKE]: 'likes',
[VisitType.HATE]: 'hates', [VisitType.HATE]: 'hates',
}; };
if (post?.type === PostType.LECTURE) { if (post?.type === PostType.LECTURE) {
const course = await db.postAncestry.findFirst({ const courseAncestry = await db.postAncestry.findFirst({
where: { where: {
descendantId: post?.id, descendantId: post?.id,
ancestor: { ancestor: {
type: PostType.COURSE, type: PostType.COURSE,
}, },
}, },
select: { id: true }, select: { id: true, ancestorId: true },
}); });
const lectures = await db.postAncestry.findMany({ const course = { id: courseAncestry.ancestorId };
const lecturesAncestry = await db.postAncestry.findMany({
where: { ancestorId: course.id, descendant: { type: PostType.LECTURE } }, where: { ancestorId: course.id, descendant: { type: PostType.LECTURE } },
select: { select: {
id: true, id: true,
descendantId: true,
}, },
}); });
const lectures = lecturesAncestry.map((ancestry) => ({
id: ancestry.descendantId,
}));
const courseViews = await db.visit.aggregate({ const courseViews = await db.visit.aggregate({
_sum: { _sum: {
views: true, views: true,
@ -98,9 +105,11 @@ export async function updatePostViewCount(id: string, type: VisitType) {
type: type, type: type,
}, },
}); });
console.log(courseViews);
await db.post.update({ await db.post.update({
where: { id: course.id }, where: { id: course.id },
data: { data: {
[metaFieldMap[type]]: courseViews._sum.views || 0,
meta: { meta: {
...((post?.meta as any) || {}), ...((post?.meta as any) || {}),
[metaFieldMap[type]]: courseViews._sum.views || 0, [metaFieldMap[type]]: courseViews._sum.views || 0,
@ -117,9 +126,11 @@ export async function updatePostViewCount(id: string, type: VisitType) {
type: type, type: type,
}, },
}); });
console.log('totalViews', totalViews);
await db.post.update({ await db.post.update({
where: { id }, where: { id },
data: { data: {
[metaFieldMap[type]]: totalViews._sum.views || 0,
meta: { meta: {
...((post?.meta as any) || {}), ...((post?.meta as any) || {}),
[metaFieldMap[type]]: totalViews._sum.views || 0, [metaFieldMap[type]]: totalViews._sum.views || 0,

View File

@ -31,6 +31,7 @@ export class GenDevService {
domainDepts: Record<string, Department[]> = {}; domainDepts: Record<string, Department[]> = {};
staffs: Staff[] = []; staffs: Staff[] = [];
deptGeneratedCount = 0; deptGeneratedCount = 0;
courseGeneratedCount = 1;
constructor( constructor(
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
@ -194,8 +195,9 @@ export class GenDevService {
cate.id, cate.id,
randomLevelId, randomLevelId,
); );
this.courseGeneratedCount++;
this.logger.log( this.logger.log(
`Generated ${this.deptGeneratedCount}/${total} departments`, `Generated ${this.courseGeneratedCount}/${total} course`,
); );
} }
} }

View File

@ -3,6 +3,5 @@ import { useParams } from "react-router-dom";
export function CourseDetailPage() { export function CourseDetailPage() {
const { id, lectureId } = useParams(); const { id, lectureId } = useParams();
console.log("Course ID:", id);
return <CourseDetail id={id} lectureId={lectureId}></CourseDetail>; return <CourseDetail id={id} lectureId={lectureId}></CourseDetail>;
} }

View File

@ -35,6 +35,7 @@ const CategorySection = () => {
const handleMouseClick = useCallback((categoryId: string) => { const handleMouseClick = useCallback((categoryId: string) => {
setSelectedTerms({ setSelectedTerms({
...selectedTerms,
[TaxonomySlug.CATEGORY]: [categoryId], [TaxonomySlug.CATEGORY]: [categoryId],
}); });
navigate("/courses"); navigate("/courses");

View File

@ -1,12 +1,10 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo, ReactNode } from "react";
import { Typography, Skeleton } from "antd"; import { Typography, Skeleton } from "antd";
import { TaxonomySlug, TermDto } from "@nice/common"; import { TaxonomySlug, TermDto } from "@nice/common";
import { api } from "@nice/client"; 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 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;
@ -35,11 +33,17 @@ interface CoursesSectionProps {
title: string; title: string;
description: string; description: string;
initialVisibleCoursesCount?: number; initialVisibleCoursesCount?: number;
postType:string;
render?:(post)=>ReactNode;
to:string
} }
const CoursesSection: React.FC<CoursesSectionProps> = ({ const CoursesSection: React.FC<CoursesSectionProps> = ({
title, title,
description, description,
initialVisibleCoursesCount = 8, initialVisibleCoursesCount = 8,
postType,
render,
to
}) => { }) => {
const [selectedCategory, setSelectedCategory] = useState<string>("全部"); const [selectedCategory, setSelectedCategory] = useState<string>("全部");
const gateGory: GetTaxonomyProps = useGetTaxonomy({ const gateGory: GetTaxonomyProps = useGetTaxonomy({
@ -83,7 +87,7 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
)} )}
</div> </div>
<PostList <PostList
renderItem={(post) => <CourseCard post={post}></CourseCard>} renderItem={(post) => render(post)}
params={{ params={{
page: 1, page: 1,
pageSize: initialVisibleCoursesCount, pageSize: initialVisibleCoursesCount,
@ -95,11 +99,12 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
}, },
} }
: {}, : {},
type: postType
}, },
}} }}
showPagination={false} showPagination={false}
cols={4}></PostList> cols={4}></PostList>
<LookForMore to={"/courses"}></LookForMore> <LookForMore to={to}></LookForMore>
</div> </div>
</section> </section>
); );

View File

@ -57,7 +57,7 @@ const HeroSection = () => {
{ {
icon: <EyeOutlined />, icon: <EyeOutlined />,
value: statistics.reads, value: statistics.reads,
label: "观看次数", label: "播放次数",
}, },
]; ];
}, [statistics]); }, [statistics]);

View File

@ -11,7 +11,10 @@ export default function LookForMore({to}:{to:string}) {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="link" type="link"
onClick={() => navigate(to)} onClick={() => {
navigate(to)
window.scrollTo({top: 0,behavior: "smooth"});
}}
className="flex items-center gap-2 text-gray-600 hover:text-blue-600 font-medium transition-colors duration-300"> className="flex items-center gap-2 text-gray-600 hover:text-blue-600 font-medium transition-colors duration-300">
<ArrowRightOutlined /> <ArrowRightOutlined />

View File

@ -1,6 +1,9 @@
import HeroSection from "./components/HeroSection"; 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 { PostType } from "@nice/common";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
const HomePage = () => { const HomePage = () => {
@ -8,10 +11,21 @@ const HomePage = () => {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<HeroSection /> <HeroSection />
<CoursesSection
title="最受欢迎的思维导图"
description="深受追捧的思维导图,点亮你的智慧人生"
postType={PostType.PATH}
render={(post)=><PathCard post={post}></PathCard>}
to={"path"}
/>
<CoursesSection <CoursesSection
title="推荐课程" title="推荐课程"
description="最受欢迎的精品课程,助你快速成长" description="最受欢迎的精品课程,助你快速成长"
postType={PostType.COURSE}
render={(post)=> <CourseCard post={post}></CourseCard>}
to={"/courses"}
/> />
<CategorySection /> <CategorySection />
</div> </div>
); );

View File

@ -14,7 +14,7 @@ export default function FilterSection() {
}); });
}; };
return ( return (
<div className=" flex z-0 p-6 flex-col rounded-lg mt-4 space-y-6 h-[820px] overscroll-contain overflow-x-hidden"> <div className=" flex z-0 p-6 flex-col mt-4 space-y-6 overscroll-contain overflow-x-hidden">
{showSearchMode && <SearchModeRadio></SearchModeRadio>} {showSearchMode && <SearchModeRadio></SearchModeRadio>}
{taxonomies?.map((tax, index) => { {taxonomies?.map((tax, index) => {
const items = Object.entries(selectedTerms).find( const items = Object.entries(selectedTerms).find(
@ -24,10 +24,11 @@ export default function FilterSection() {
<div key={index}> <div key={index}>
<h3 className="text-lg font-medium mb-4"> <h3 className="text-lg font-medium mb-4">
{tax?.name} {tax?.name}
{/* {JSON.stringify(items)} */}
</h3> </h3>
<TermParentSelector <TermParentSelector
value={items} value={items}
slug={tax?.slug} // slug={tax?.slug}
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden" className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
onChange={(selected) => onChange={(selected) =>
handleTermChange( handleTermChange(

View File

@ -11,14 +11,14 @@ export default function SearchModeRadio() {
return ( return (
<Space direction="vertical" align="start" className="mb-2"> <Space direction="vertical" align="start" className="mb-2">
<h3 className="text-lg font-medium mb-4"></h3> <h3 className="text-lg font-medium mb-4"></h3>
<Radio.Group <Radio.Group
value={searchMode} value={searchMode}
onChange={handleModeChange} onChange={handleModeChange}
buttonStyle="solid"> buttonStyle="solid">
<Radio.Button value={PostType.COURSE}></Radio.Button> <Radio.Button value={PostType.COURSE}></Radio.Button>
<Radio.Button value={PostType.PATH}></Radio.Button> <Radio.Button value={PostType.PATH}></Radio.Button>
<Radio.Button value="both"></Radio.Button> <Radio.Button value="both"></Radio.Button>
</Radio.Group> </Radio.Group>
</Space> </Space>
); );

View File

@ -1,4 +1,3 @@
import { Input, Layout, Avatar, Button, Dropdown } from "antd"; import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import { import {
EditFilled, EditFilled,
@ -30,71 +29,79 @@ export function MainHeader() {
<NavigationMenu /> <NavigationMenu />
</div> </div>
{/* 中间搜索区域 - 允许适当收缩但保持可用性 */}
<div className="mx-4 flex-shrink md:flex-shrink-0 md:w-auto w-auto">
<Input
size="large"
prefix={
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
}
placeholder="搜索课程"
className="w-full md:w-96 rounded-full"
value={searchValue}
onClick={(e) => {
if (!window.location.pathname.startsWith("/search")) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e) => {
if (!window.location.pathname.startsWith("/search")) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
/>
</div>
{/* 右侧区域 - 可以灵活收缩 */} {/* 右侧区域 - 可以灵活收缩 */}
<div className="flex justify-end gap-2 md:gap-4 flex-shrink"> <div className="flex justify-end gap-2 md:gap-4 flex-shrink">
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
<Input
size="large"
prefix={
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
}
placeholder="搜索课程"
className="w-full md:w-96 rounded-full"
value={searchValue}
onClick={(e) => {
if (
!window.location.pathname.startsWith("/search")
) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e) => {
if (
!window.location.pathname.startsWith("/search")
) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
/>
{isAuthenticated && ( {isAuthenticated && (
<> <>
<Button <Button
size="large"
shape="round"
icon={<PlusOutlined></PlusOutlined>}
onClick={() => { onClick={() => {
const url = id const url = id
? `/course/${id}/editor` ? `/course/${id}/editor`
: "/course/editor"; : "/course/editor";
navigate(url); navigate(url);
}} }}
type="primary" type="primary">
>
{id ? "编辑课程" : "创建课程"} {id ? "编辑课程" : "创建课程"}
</Button> </Button>
</> </>
)} )}
{isAuthenticated && ( {isAuthenticated && (
<Button <Button
size="large"
shape="round"
onClick={() => { onClick={() => {
window.location.href = "/path/editor"; window.location.href = "/path/editor";
}} }}
ghost
type="primary"
icon={<PlusOutlined></PlusOutlined>}> icon={<PlusOutlined></PlusOutlined>}>
</Button> </Button>
)} )}
{isAuthenticated ? ( {isAuthenticated ? (
<UserMenu /> <UserMenu />
) : ( ) : (
<Button <Button
type="primary"
size="large"
shape="round"
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
icon={<UserOutlined />}> icon={<UserOutlined />}>
</Button> </Button>
@ -104,4 +111,3 @@ export function MainHeader() {
</div> </div>
); );
} }

View File

@ -11,7 +11,7 @@ export function MainLayout() {
<MainProvider> <MainProvider>
<div className=" min-h-screen bg-gray-100"> <div className=" min-h-screen bg-gray-100">
<MainHeader /> <MainHeader />
<Content className="min-h-screen flex-grow pt-14 bg-gray-50 "> <Content className=" flex-grow pt-16 bg-gray-50 ">
<Outlet /> <Outlet />
</Content> </Content>
<MainFooter /> <MainFooter />

View File

@ -11,8 +11,8 @@ export const NavigationMenu = () => {
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
const baseItems = [ const baseItems = [
{ key: "home", path: "/", label: "首页" }, { key: "home", path: "/", label: "首页" },
{ key: "path", path: "/path", label: "学习路径" }, { key: "path", path: "/path", label: "全部思维导图" },
{ key: "courses", path: "/courses", label: "全部课程" }, { key: "courses", path: "/courses", label: "所有课程" },
]; ];
if (!isAuthenticated) { if (!isAuthenticated) {
@ -20,9 +20,10 @@ export const NavigationMenu = () => {
} else { } else {
return [ return [
...baseItems, ...baseItems,
{ key: "my-duty", path: "/my-duty", label: "我的授课" }, { key: "my-duty", path: "/my-duty", label: "我创建的课程" },
{ key: "my-learning", path: "/my-learning", label: "我的课程" }, { key: "my-learning", path: "/my-learning", label: "我学习的课程" },
{ key: "my-path", path: "/my-path", label: "我的路径" }, { key: "my-duty-path", path: "/my-duty-path", label: "我创建的思维导图" },
{ key: "my-path", path: "/my-path", label: "我学习的思维导图" },
]; ];
} }
}, [isAuthenticated]); }, [isAuthenticated]);

View File

@ -12,7 +12,7 @@ import toast from "react-hot-toast";
export default function StaffForm() { export default function StaffForm() {
const { user } = useAuth(); const { user } = useAuth();
const { create, update } = useStaff(); // Ensure you have these methods in your hooks const { create, update } = useStaff(); // Ensure you have these methods in your hooks
const {formLoading,modalOpen,setModalOpen,domainId,setDomainId,form,setFormLoading,} = useContext(UserEditorContext); const { formLoading, modalOpen, setModalOpen, domainId, setDomainId, form, setFormLoading, } = useContext(UserEditorContext);
const { const {
data, data,
isLoading, isLoading,
@ -68,7 +68,7 @@ export default function StaffForm() {
} }
useEffect(() => { useEffect(() => {
form.resetFields(); form.resetFields();
console.log('cc',data); console.log('cc', data);
if (data) { if (data) {
form.setFieldValue("username", data.username); form.setFieldValue("username", data.username);
@ -121,7 +121,7 @@ export default function StaffForm() {
name={"showname"} name={"showname"}
label="名称"> label="名称">
<Input <Input
placeholder="请输入" placeholder="请输入名"
allowClear allowClear
autoComplete="new-name" // 使用非标准的自动完成值 autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}

View File

@ -0,0 +1,30 @@
import PostList from "@web/src/components/models/course/list/PostList";
import { useAuth } from "@web/src/providers/auth-provider";
import { useMainContext } from "../../layout/MainProvider";
import { PostType } from "@nice/common";
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
export default function MyLearningListContainer() {
const { user } = useAuth();
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => <PathCard post={post}></PathCard>}
params={{
pageSize: 12,
where: {
type: PostType.PATH,
students: {
some: {
id: user?.id,
},
},
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
import BasePostLayout from "../layout/BasePost/BasePostLayout";
import { useMainContext } from "../layout/MainProvider";
import { PostType } from "@nice/common";
import MyDutyPathContainer from "./components/MyDutyPathContainer";
export default function MyDutyPathPage() {
const { setSearchMode } = useMainContext();
useEffect(() => {
setSearchMode(PostType.PATH);
}, [setSearchMode]);
return (
<BasePostLayout>
<MyDutyPathContainer></MyDutyPathContainer>
</BasePostLayout>
);
}

View File

@ -23,8 +23,9 @@ const DeptInfo = ({ post }: { post: PostDto }) => {
{post && ( {post && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center"> <span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined /> <EyeOutlined />
{`${post?.meta?.views || 0}`} {`${post?.views || 0}`}
</span> </span>
{post?.studentIds && post?.studentIds?.length > 0 && ( {post?.studentIds && post?.studentIds?.length > 0 && (
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center"> <span className="gap-1 text-xs font-medium text-gray-500 flex items-center">

View File

@ -1,21 +1,21 @@
import { Tag } from "antd"; import { Tag } from "antd";
import { PostDto, TaxonomySlug } from "@nice/common"; import { PostDto, TaxonomySlug, TermDto } from "@nice/common";
const TermInfo = ({ post }: { post: PostDto }) => { const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => {
return ( return (
<div> <div>
{post?.terms && post?.terms?.length > 0 ? ( {terms && terms?.length > 0 ? (
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
{post?.terms?.map((term: any) => { {terms?.map((term: any) => {
return ( return (
<Tag <Tag
key={term.id} key={term.id}
color={ color={
term?.taxonomy?.slug === term?.taxonomy?.slug ===
TaxonomySlug.CATEGORY TaxonomySlug.CATEGORY
? "green" ? "green"
: term?.taxonomy?.slug === : term?.taxonomy?.slug ===
TaxonomySlug.LEVEL TaxonomySlug.LEVEL
? "blue" ? "blue"
: "orange" : "orange"
} }

View File

@ -2,11 +2,9 @@ import MindEditor from "@web/src/components/common/editor/MindEditor";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
export default function PathEditorPage() { export default function PathEditorPage() {
const { id } = useParams(); const { id } = useParams();
return ( return <div className="">
<div className="p-2 min-h-screen"> <MindEditor id={id}></MindEditor>
<MindEditor id={id}></MindEditor> </div>
</div>
);
} }

View File

@ -1,6 +1,6 @@
import { Button, Card, Empty, Form, Space, Spin, message, theme } from "antd"; import { Button, Empty, Form, Spin } from "antd";
import NodeMenu from "./NodeMenu"; import NodeMenu from "./NodeMenu";
import { api, usePost } from "@nice/client"; import { api, usePost, useVisitor } from "@nice/client";
import { import {
ObjectType, ObjectType,
PathDto, PathDto,
@ -8,6 +8,7 @@ import {
PostType, PostType,
Prisma, Prisma,
RolePerms, RolePerms,
VisitType,
} from "@nice/common"; } from "@nice/common";
import TermSelect from "../../models/term/term-select"; import TermSelect from "../../models/term/term-select";
import DepartmentSelect from "../../models/department/department-select"; import DepartmentSelect from "../../models/department/department-select";
@ -19,19 +20,20 @@ import { useTusUpload } from "@web/src/hooks/useTusUpload";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { MIND_OPTIONS } from "./constant"; import { MIND_OPTIONS } from "./constant";
import { SaveOutlined } from "@ant-design/icons";
export default function MindEditor({ id }: { id?: string }) { export default function MindEditor({ id }: { id?: string }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [instance, setInstance] = useState<MindElixirInstance | null>(null); const [instance, setInstance] = useState<MindElixirInstance | null>(null);
const { isAuthenticated, user, hasSomePermissions } = useAuth(); const { isAuthenticated, user, hasSomePermissions } = useAuth();
const { read } = useVisitor()
const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
api.post.findFirst.useQuery({ api.post.findFirst.useQuery({
where: { where: {
id, id,
}, },
select: postDetailSelect, select: postDetailSelect,
}); }, { enabled: Boolean(id) });
const canEdit: boolean = useMemo(() => { const canEdit: boolean = useMemo(() => {
//登录了且是作者、超管、无id新建模式
const isAuth = isAuthenticated && user?.id === post?.author?.id; const isAuth = isAuthenticated && user?.id === post?.author?.id;
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
}, [user]); }, [user]);
@ -42,9 +44,19 @@ export default function MindEditor({ id }: { id?: string }) {
}); });
const { handleFileUpload } = useTusUpload(); const { handleFileUpload } = useTusUpload();
const [form] = Form.useForm(); const [form] = Form.useForm();
useEffect(() => {
if (post?.id && id) {
read.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post?.id,
type: VisitType.READED,
},
});
}
}, [post]);
useEffect(() => { useEffect(() => {
if (post && form && instance && id) { if (post && form && instance && id) {
console.log(post);
instance.refresh((post as any).meta); instance.refresh((post as any).meta);
const deptIds = (post?.depts || [])?.map((dept) => dept.id); const deptIds = (post?.depts || [])?.map((dept) => dept.id);
const formData = { const formData = {
@ -52,8 +64,8 @@ export default function MindEditor({ id }: { id?: string }) {
deptIds: deptIds, deptIds: deptIds,
}; };
post.terms?.forEach((term) => { post.terms?.forEach((term) => {
formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name formData[term.taxonomyId] = term.id // 假设 taxonomyName是您在 Form.Item 中使用的name
}); })
form.setFieldsValue(formData); form.setFieldsValue(formData);
} }
}, [post, form, instance, id]); }, [post, form, instance, id]);
@ -73,8 +85,9 @@ export default function MindEditor({ id }: { id?: string }) {
nodeMenu: canEdit, // 禁用节点右键菜单 nodeMenu: canEdit, // 禁用节点右键菜单
keypress: canEdit, // 禁用键盘快捷键 keypress: canEdit, // 禁用键盘快捷键
}); });
mind.init(MindElixir.new("新学习路径")); mind.init(MindElixir.new("新思维导图"));
containerRef.current.hidden = true; containerRef.current.hidden = true;
//挂载实例
setInstance(mind); setInstance(mind);
}, [canEdit]); }, [canEdit]);
useEffect(() => { useEffect(() => {
@ -86,6 +99,7 @@ export default function MindEditor({ id }: { id?: string }) {
} }
} }
}, [id, post, instance]); }, [id, post, instance]);
//保存 按钮 函数
const handleSave = async () => { const handleSave = async () => {
if (!instance) return; if (!instance) return;
const values = form.getFieldsValue(); const values = form.getFieldsValue();
@ -145,19 +159,23 @@ export default function MindEditor({ id }: { id?: string }) {
} }
console.log(result); console.log(result);
}, },
(error) => {}, (error) => { },
`mind-thumb-${new Date().toString()}` `mind-thumb-${new Date().toString()}`
); );
}; };
useEffect(() => { useEffect(() => {
containerRef.current.style.height = `${Math.floor(window.innerHeight / 1.25)}px`;
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
}, []); }, []);
return ( return (
<div className="grid grid-cols-1 flex-col w-[90vw] my-5 h-[80vh] border rounded-lg mx-auto">
<div className={` flex-col flex `}>
{canEdit && taxonomies && ( {canEdit && taxonomies && (
<Form form={form} className=" bg-white p-4 "> <Form
<div className="flex items-center justify-between gap-4"> form={form}
className=" bg-white p-4 border-b">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{taxonomies.map((tax, index) => ( {taxonomies.map((tax, index) => (
<Form.Item <Form.Item
@ -166,6 +184,7 @@ export default function MindEditor({ id }: { id?: string }) {
// rules={[{ required: true }]} // rules={[{ required: true }]}
noStyle> noStyle>
<TermSelect <TermSelect
disabled={!canEdit}
className=" w-48" className=" w-48"
placeholder={`请选择${tax.name}`} placeholder={`请选择${tax.name}`}
taxonomyId={tax.id} taxonomyId={tax.id}
@ -177,19 +196,21 @@ export default function MindEditor({ id }: { id?: string }) {
name="deptIds" name="deptIds"
noStyle> noStyle>
<DepartmentSelect <DepartmentSelect
disabled={!canEdit}
className="w-96" className="w-96"
placeholder="请选择制作单位" placeholder="请选择制作单位"
multiple multiple
/> />
</Form.Item> </Form.Item>
</div> </div>
<Button {canEdit && <Button
ghost ghost
type="primary" type="primary"
icon={<SaveOutlined></SaveOutlined>}
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
onClick={handleSave}> onClick={handleSave}>
{id ? "更新" : "保存"} {id ? "更新" : "保存"}
</Button> </Button>}
</div> </div>
</Form> </Form>
)} )}
@ -199,20 +220,24 @@ export default function MindEditor({ id }: { id?: string }) {
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
/> />
{canEdit && instance && <NodeMenu mind={instance} />} {canEdit && instance && <NodeMenu mind={instance} />}
{isLoading && ( {
<div isLoading && (
className="py-64 justify-center flex" <div
style={{ height: "calc(100vh - 287px)" }}> className="py-64 justify-center flex"
<Spin size="large"></Spin> style={{ height: "calc(100vh - 271px)" }}>
</div> <Spin size="large"></Spin>
)} </div>
{!post && id && !isLoading && ( )
<div }
className="py-64" {
style={{ height: "calc(100vh - 287px)" }}> !post && id && !isLoading && (
<Empty></Empty> <div
</div> className="py-64"
)} style={{ height: "calc(100vh - 271px)" }}>
</div> <Empty></Empty>
</div>
)
}
</div >
); );
} }

View File

@ -35,23 +35,18 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
useEffect(() => { useEffect(() => {
const handleSelectNode = (nodeObj: NodeObj) => { const handleSelectNode = (nodeObj: NodeObj) => {
setIsOpen(true); setIsOpen(true);
const style = nodeObj.style || {}; const style = nodeObj.style || {};
setSelectedFontColor(style.color || ''); setSelectedFontColor(style.color || '');
setSelectedBgColor(style.background || ''); setSelectedBgColor(style.background || '');
setSelectedSize(style.fontSize || '24'); setSelectedSize(style.fontSize || '24');
setIsBold(style.fontWeight === 'bold'); setIsBold(style.fontWeight === 'bold');
setUrl(nodeObj.hyperLink || ''); setUrl(nodeObj.hyperLink || '');
}; };
const handleUnselectNode = () => { const handleUnselectNode = () => {
setIsOpen(false); setIsOpen(false);
}; };
mind.bus.addListener('selectNode', handleSelectNode); mind.bus.addListener('selectNode', handleSelectNode);
mind.bus.addListener('unselectNode', handleUnselectNode); mind.bus.addListener('unselectNode', handleUnselectNode);
}, [mind]); }, [mind]);
useEffect(() => { useEffect(() => {

View File

@ -1,152 +0,0 @@
interface I18n {
addChild: string
addParent: string
addSibling: string
removeNode: string
focus: string
cancelFocus: string
moveUp: string
moveDown: string
link: string
clickTips: string
font: string
background: string
tag: string
icon: string
tagsSeparate: string
iconsSeparate: string
url: string
memo?: string
}
const cn: I18n = {
addChild: '插入子节点',
addParent: '插入父节点',
addSibling: '插入同级节点',
removeNode: '删除节点',
focus: '专注',
cancelFocus: '取消专注',
moveUp: '上移',
moveDown: '下移',
link: '连接',
clickTips: '请点击目标节点',
font: '文字',
background: '背景',
tag: '标签',
icon: '图标',
tagsSeparate: '多个标签半角逗号分隔',
iconsSeparate: '多个图标半角逗号分隔',
url: 'URL',
}
interface I18nCollection {
cn: I18n
zh_CN: I18n
zh_TW: I18n
en: I18n
ru: I18n
ja: I18n
pt: I18n
}
const i18n: I18nCollection = {
cn,
zh_CN: cn,
zh_TW: {
addChild: '插入子節點',
addParent: '插入父節點',
addSibling: '插入同級節點',
removeNode: '刪除節點',
focus: '專注',
cancelFocus: '取消專注',
moveUp: '上移',
moveDown: '下移',
link: '連接',
clickTips: '請點擊目標節點',
font: '文字',
background: '背景',
tag: '標簽',
icon: '圖標',
tagsSeparate: '多個標簽半角逗號分隔',
iconsSeparate: '多個圖標半角逗號分隔',
url: 'URL',
},
en: {
addChild: 'Add child',
addParent: 'Add parent',
addSibling: 'Add sibling',
removeNode: 'Remove node',
focus: 'Focus Mode',
cancelFocus: 'Cancel Focus Mode',
moveUp: 'Move up',
moveDown: 'Move down',
link: 'Link',
clickTips: 'Please click the target node',
font: 'Font',
background: 'Background',
tag: 'Tag',
icon: 'Icon',
tagsSeparate: 'Separate tags by comma',
iconsSeparate: 'Separate icons by comma',
url: 'URL',
},
ru: {
addChild: 'Добавить дочерний элемент',
addParent: 'Добавить родительский элемент',
addSibling: 'Добавить на этом уровне',
removeNode: 'Удалить узел',
focus: 'Режим фокусировки',
cancelFocus: 'Отменить режим фокусировки',
moveUp: 'Поднять выше',
moveDown: 'Опустить ниже',
link: 'Ссылка',
clickTips: 'Пожалуйста, нажмите на целевой узел',
font: 'Цвет шрифта',
background: 'Цвет фона',
tag: 'Тег',
icon: 'Иконка',
tagsSeparate: 'Разделяйте теги запятой',
iconsSeparate: 'Разделяйте иконки запятой',
url: 'URL',
},
ja: {
addChild: '子ノードを追加する',
addParent: '親ノードを追加します',
addSibling: '兄弟ノードを追加する',
removeNode: 'ノードを削除',
focus: '集中',
cancelFocus: '集中解除',
moveUp: '上へ移動',
moveDown: '下へ移動',
link: 'コネクト',
clickTips: 'ターゲットノードをクリックしてください',
font: 'フォント',
background: 'バックグラウンド',
tag: 'タグ',
icon: 'アイコン',
tagsSeparate: '複数タグはカンマ区切り',
iconsSeparate: '複数アイコンはカンマ区切り',
url: 'URL',
},
pt: {
addChild: 'Adicionar item filho',
addParent: 'Adicionar item pai',
addSibling: 'Adicionar item irmao',
removeNode: 'Remover item',
focus: 'Modo Foco',
cancelFocus: 'Cancelar Modo Foco',
moveUp: 'Mover para cima',
moveDown: 'Mover para baixo',
link: 'Link',
clickTips: 'Favor clicar no item alvo',
font: 'Fonte',
background: 'Cor de fundo',
tag: 'Tag',
icon: 'Icone',
tagsSeparate: 'Separe tags por virgula',
iconsSeparate: 'Separe icones por virgula',
url: 'URL',
},
}
export default i18n

View File

@ -108,15 +108,15 @@ export default function ResourcesShower({
{resource.title?.slice(0, 12) || {resource.title?.slice(0, 12) ||
"未命名"} "未命名"}
</p> </p>
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center justify-between text-xs text-gray-500 ">
<span className="bg-gray-100 px-0.5 rounded-sm"> <span className="bg-gray-100 px-0.5 rounded-sm mr-2 whitespace-pre-wrap">
{resource.url {resource.url
.split(".") .split(".")
.pop() .pop()
?.slice(0, 4) ?.slice(0, 4)
.toUpperCase()} .toUpperCase()}
</span> </span>
<span> <span className="flex bg-gray-100 px-0.5 rounded-sm justify-items-center whitespace-pre-wrap">
{resource.meta.size && {resource.meta.size &&
formatFileSize( formatFileSize(
resource.meta.size resource.meta.size

View File

@ -8,11 +8,7 @@ export default function CourseDetail({
id?: string; id?: string;
lectureId?: string; lectureId?: string;
}) { }) {
const iframeStyle = {
width: "50%",
height: "100vh",
border: "none",
};
return ( return (
<> <>
<CourseDetailProvider editId={id}> <CourseDetailProvider editId={id}>

View File

@ -29,6 +29,7 @@ interface CourseDetailContextType {
setIsHeaderVisible: (visible: boolean) => void; // 新增 setIsHeaderVisible: (visible: boolean) => void; // 新增
canEdit?: boolean; canEdit?: boolean;
userIsLearning?: boolean; userIsLearning?: boolean;
setUserIsLearning:(learning: boolean) => void;
} }
interface CourseFormProviderProps { interface CourseFormProviderProps {
@ -56,9 +57,14 @@ export function CourseDetailProvider({
{ enabled: Boolean(editId) } { enabled: Boolean(editId) }
); );
const userIsLearning = useMemo(() => { // const userIsLearning = useMemo(() => {
return (course?.studentIds || []).includes(user?.id); // return (course?.studentIds || []).includes(user?.id);
}, [user, course, isLoading]); // }, [user, course, isLoading]);
const [userIsLearning, setUserIsLearning] = useState(false);
useEffect(()=>{
console.log(course?.studentIds,user?.id)
setUserIsLearning((course?.studentIds || []).includes(user?.id));
},[user, course, isLoading])
const canEdit = useMemo(() => { const canEdit = useMemo(() => {
const isAuthor = isAuthenticated && user?.id === course?.authorId; const isAuthor = isAuthenticated && user?.id === course?.authorId;
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
@ -79,16 +85,28 @@ export function CourseDetailProvider({
); );
useEffect(() => { useEffect(() => {
if (lecture?.id) { if (lectureId) {
console.log(123);
console.log(lectureId);
read.mutateAsync({ read.mutateAsync({
data: { data: {
visitorId: user?.id || null, visitorId: user?.id || null,
postId: lecture?.id, postId: lectureId,
type: VisitType.READED,
},
});
} else {
console.log(321);
console.log(editId);
read.mutateAsync({
data: {
visitorId: user?.id || null,
postId: editId,
type: VisitType.READED, type: VisitType.READED,
}, },
}); });
} }
}, [course]); }, [editId, lectureId]);
useEffect(() => { useEffect(() => {
if (lectureId !== selectedLectureId) { if (lectureId !== selectedLectureId) {
navigate(`/course/${editId}/detail/${selectedLectureId}`); navigate(`/course/${editId}/detail/${selectedLectureId}`);
@ -109,6 +127,7 @@ export function CourseDetailProvider({
setIsHeaderVisible, setIsHeaderVisible,
canEdit, canEdit,
userIsLearning, userIsLearning,
setUserIsLearning
}}> }}>
{children} {children}
</CourseDetailContext.Provider> </CourseDetailContext.Provider>

View File

@ -1,5 +1,5 @@
import { Course, TaxonomySlug } from "@nice/common"; import { Course, TaxonomySlug } from "@nice/common";
import React, { useContext, useMemo } from "react"; import React, { useContext, useEffect, useMemo } from "react";
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件 import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
import { CourseDetailContext } from "./CourseDetailContext"; import { CourseDetailContext } from "./CourseDetailContext";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@ -36,11 +36,11 @@ export const CourseDetailDescription: React.FC = () => {
{!selectedLectureId && ( {!selectedLectureId && (
<div className="relative mb-4 overflow-hidden flex justify-center items-center"> <div className="relative mb-4 overflow-hidden flex justify-center items-center">
{ {
<Image <div
src={course.meta.thumbnail} className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
preview={false} style={{
className="w-full h-full object-cover z-0" backgroundImage: `url(${course?.meta?.thumbnail || "/placeholder.webp"})`,
fallback="/placeholder.webp" }}
/> />
} }
<div <div
@ -59,7 +59,7 @@ export const CourseDetailDescription: React.FC = () => {
}); });
} }
}} }}
className="w-full h-full absolute top-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer group"> className="absolute rounded-xl top-0 left-0 right-0 bottom-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer group">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div> </div>
@ -70,7 +70,7 @@ export const CourseDetailDescription: React.FC = () => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex gap-2 flex-wrap items-center float-start"> <div className="flex gap-2 flex-wrap items-center float-start">
{course?.subTitle && <div>{course?.subTitle}</div>} {course?.subTitle && <div>{course?.subTitle}</div>}
<TermInfo post={course}></TermInfo> <TermInfo terms={course.terms}></TermInfo>
</div> </div>
</div> </div>
<Paragraph <Paragraph

View File

@ -7,25 +7,10 @@ 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";
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
import {
BookOutlined,
CalendarOutlined,
EditTwoTone,
EyeOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import CourseDetailTitle from "./CourseDetailTitle"; import CourseDetailTitle from "./CourseDetailTitle";
// interface CourseDetailDisplayAreaProps {
// // course: Course;
// // videoSrc?: string;
// // videoPoster?: string;
// // isLoading?: boolean;
// }
export const CourseDetailDisplayArea: React.FC = () => { export const CourseDetailDisplayArea: React.FC = () => {
// 创建滚动动画效果 // 创建滚动动画效果

View File

@ -19,11 +19,7 @@ export default function CourseDetailLayout() {
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true); const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
return ( return (
<div className="relative"> <div className="relative">
{/* <CourseDetailHeader /> */}
{/* 添加 Header 组件 */}
{/* 主内容区域 */}
{/* 为了防止 Header 覆盖内容,添加上边距 */}
<div className="pt-12 px-32"> <div className="pt-12 px-32">
{" "} {" "}
{/* 添加这个包装 div */} {/* 添加这个包装 div */}

View File

@ -12,11 +12,12 @@ import dayjs from "dayjs";
import CourseOperationBtns from "./JoinLearingButton"; import CourseOperationBtns from "./JoinLearingButton";
export default function CourseDetailTitle() { export default function CourseDetailTitle() {
const { course } = useContext(CourseDetailContext); const { course, lecture, selectedLectureId } =
useContext(CourseDetailContext);
return ( return (
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6"> <div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
<div className="flex justify-start w-full text-2xl font-bold"> <div className="flex justify-start w-full text-2xl font-bold">
{course?.title} {!selectedLectureId ? course?.title : lecture?.title}
</div> </div>
<div className="text-gray-600 flex w-full justify-start items-center gap-5"> <div className="text-gray-600 flex w-full justify-start items-center gap-5">
{course?.author?.showname && ( {course?.author?.showname && (
@ -36,15 +37,27 @@ export default function CourseDetailTitle() {
<div className="flex gap-1"> <div className="flex gap-1">
<CalendarOutlined></CalendarOutlined> <CalendarOutlined></CalendarOutlined>
{"发布于:"} {"发布于:"}
{dayjs(course?.createdAt).format("YYYY年M月D日")} {dayjs(
!selectedLectureId
? course?.createdAt
: lecture?.createdAt
).format("YYYY年M月D日")}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{"最后更新:"} {"最后更新:"}
{dayjs(course?.updatedAt).format("YYYY年M月D日")} {dayjs(
!selectedLectureId
? course?.updatedAt
: lecture?.updatedAt
).format("YYYY年M月D日")}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<EyeOutlined></EyeOutlined> <EyeOutlined></EyeOutlined>
<div>{`观看次数${course?.meta?.views || 0}`}</div> <div>{`观看次数${
!selectedLectureId
? course?.views || 0
: lecture?.views || 0
}`}</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<BookOutlined /> <BookOutlined />

View File

@ -45,7 +45,9 @@ export const LectureItem: React.FC<LectureItemProps> = ({
</div> </div>
)} )}
<div className="flex-grow flex justify-between items-center w-2/3 realative"> <div className="flex-grow flex justify-between items-center w-2/3 realative">
<h4 className="font-medium text-gray-800 w-4/5">{lecture.title}</h4> <h4 className="font-medium text-gray-800 w-4/5">
{lecture.title}
</h4>
{lecture.subTitle && ( {lecture.subTitle && (
<span className="text-sm text-gray-500 mt-1 w-4/5"> <span className="text-sm text-gray-500 mt-1 w-4/5">
{lecture.subTitle} {lecture.subTitle}
@ -53,7 +55,9 @@ export const LectureItem: React.FC<LectureItemProps> = ({
)} )}
<div className="text-gray-500 whitespace-normal"> <div className="text-gray-500 whitespace-normal">
<EyeOutlined></EyeOutlined> <EyeOutlined></EyeOutlined>
<span className="ml-2">{lecture?.meta?.views ? lecture?.meta?.views : 0}</span> <span className="ml-2">
{lecture?.views ? lecture?.views : 0}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,15 +12,17 @@ import {
EditTwoTone, EditTwoTone,
LoginOutlined, LoginOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import toast from "react-hot-toast";
export default function CourseOperationBtns() { export default function CourseOperationBtns() {
const { id } = useParams(); const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth(); useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext); const { course, canEdit, userIsLearning, setUserIsLearning} = useContext(CourseDetailContext);
const { update } = useStaff(); const { update } = useStaff();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const toggleLearning = async () => { const toggleLearning = async () => {
if (!userIsLearning) { if (!userIsLearning) {
await update.mutateAsync({ await update.mutateAsync({
@ -31,7 +33,10 @@ export default function CourseOperationBtns() {
}, },
}, },
}); });
setUserIsLearning(true)
toast.success("加入学习成功");
} else { } else {
await update.mutateAsync({ await update.mutateAsync({
where: { id: user?.id }, where: { id: user?.id },
data: { data: {
@ -42,6 +47,8 @@ export default function CourseOperationBtns() {
}, },
}, },
}); });
toast.success("退出学习成功");
setUserIsLearning(false)
} }
}; };
return ( return (

View File

@ -1,29 +0,0 @@
import { CheckOutlined } from '@ant-design/icons';
import React from 'react';
interface CourseObjectivesProps {
objectives: string[];
title?: string;
}
const CourseObjectives: React.FC<CourseObjectivesProps> = ({
objectives,
title = "您将会学到"
}) => {
return (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-bold mb-4">{title}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{objectives.map((objective, index) => (
<div
key={index}
className="flex items-start space-x-3"
>
<CheckOutlined></CheckOutlined>
<span className="text-gray-700">{objective}</span>
</div>
))}
</div>
</div>
);
};
export default CourseObjectives;

View File

@ -82,7 +82,7 @@ export function CourseFormProvider({
}, [course, form]); }, [course, form]);
const onSubmit = async (values: any) => { const onSubmit = async (values: any) => {
console.log(values);
const sections = values?.sections || []; const sections = values?.sections || [];
const deptIds = values?.deptIds || []; const deptIds = values?.deptIds || [];
const termIds = taxonomies const termIds = taxonomies
@ -101,13 +101,17 @@ export function CourseFormProvider({
terms: terms:
termIds?.length > 0 termIds?.length > 0
? { ? {
set: termIds.map((id) => ({ id })), // 转换成 connect 格式 [editId ? "set" : "connect"]: termIds.map((id) => ({
id,
})), // 转换成 connect 格式
} }
: undefined, : undefined,
depts: depts:
deptIds?.length > 0 deptIds?.length > 0
? { ? {
set: deptIds.map((id) => ({ id })), [editId ? "set" : "connect"]: deptIds.map((id) => ({
id,
})),
} }
: undefined, : undefined,
}; };
@ -149,6 +153,7 @@ export function CourseFormProvider({
} }
}; };
return ( return (
<CourseEditorContext.Provider <CourseEditorContext.Provider
value={{ value={{

View File

@ -9,7 +9,7 @@ import DepartmentSelect from "../../../department/department-select";
const { TextArea } = Input; const { TextArea } = Input;
export function CourseBasicForm() { export function CourseBasicForm() {
// 将 CourseLevelLabel 转换为 Ant Design Select 需要的选项格式 // 将 CourseLevelLabel 使用 Object.entries 将 CourseLevelLabel 对象转换为键值对数组。
const levelOptions = Object.entries(CourseLevelLabel).map( const levelOptions = Object.entries(CourseLevelLabel).map(
([key, value]) => ({ ([key, value]) => ({
label: value, label: value,

View File

@ -115,7 +115,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
resources: resources:
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0 [videoUrlId, ...fileIds].filter(Boolean)?.length > 0
? { ? {
connect: [videoUrlId, ...fileIds] set: [videoUrlId, ...fileIds]
.filter(Boolean) .filter(Boolean)
.map((fileId) => ({ .map((fileId) => ({
fileId, fileId,

View File

@ -1,4 +1,4 @@
import { Card, Typography, Button, Empty } from "antd"; import { Typography, Button, Empty, Card } from "antd";
import { PostDto } from "@nice/common"; import { PostDto } from "@nice/common";
import DeptInfo from "@web/src/app/main/path/components/DeptInfo"; import DeptInfo from "@web/src/app/main/path/components/DeptInfo";
@ -19,9 +19,9 @@ export default function PostCard({ post, onClick }: PostCardProps) {
onClick={() => handleClick(post)} onClick={() => handleClick(post)}
key={post?.id} key={post?.id}
hoverable 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" className="group overflow-hidden rounded-2xl border border-gray-200 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
cover={ cover={
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden group"> <div className="relative h-56 overflow-hidden group">
{post?.meta?.thumbnail ? ( {post?.meta?.thumbnail ? (
<div <div
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110" className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
@ -39,7 +39,7 @@ export default function PostCard({ post, onClick }: PostCardProps) {
<div className="px-4 "> <div className="px-4 ">
<div className="overflow-hidden hover:overflow-auto"> <div className="overflow-hidden hover:overflow-auto">
<div className="flex gap-2 h-7 whiteSpace-nowrap"> <div className="flex gap-2 h-7 whiteSpace-nowrap">
<TermInfo post={post}></TermInfo> <TermInfo terms={post.terms}></TermInfo>
</div> </div>
</div> </div>
<Title <Title
@ -53,6 +53,7 @@ export default function PostCard({ post, onClick }: PostCardProps) {
<div className="pt-4 border-t border-gray-100 text-center"> <div className="pt-4 border-t border-gray-100 text-center">
<Button <Button
shape="round"
type="primary" type="primary"
size="large" 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)] 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)]

View File

@ -31,7 +31,7 @@ export default function TaxonomyForm() {
setTaxonomyModalOpen(false) setTaxonomyModalOpen(false)
}}> }}>
<Form.Item <Form.Item
rules={[{ required: true, message: "请输入" }]} rules={[{ required: true, message: "请输入名" }]}
name={"name"} name={"name"}
label="名称"> label="名称">
<Input></Input> <Input></Input>

View File

@ -1,50 +1,56 @@
import { api } from "@nice/client/"; import { api } from "@nice/client/";
import { Checkbox, Form } from "antd"; import { Checkbox, Skeleton } from "antd";
import { TermDto } from "@nice/common"; import { TermDto } from "@nice/common";
import { useCallback, useEffect, useState } from "react"; import React from "react";
export default function TermParentSelector({ export default function TermParentSelector({
value, value,
onChange, onChange,
className, className,
placeholder = "选择分类", taxonomyId,
multiple = true, domainId = undefined,
taxonomyId, style,
domainId, }: {
style, value?: string[];
}: any) { onChange?: (value: string[]) => void;
const [selectedValues, setSelectedValues] = useState<string[]>([]); // 用于存储选中的值 className?: string;
const { taxonomyId: string;
data, domainId?: string;
isLoading, style?: React.CSSProperties;
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({ }) {
where: { const { data, isLoading }: { data: TermDto[]; isLoading: boolean } =
taxonomy: { api.term.findMany.useQuery({
id: taxonomyId, where: {
}, taxonomyId: taxonomyId,
parentId: null parentId: null,
}, domainId,
}); },
const handleCheckboxChange = (checkedValues: string[]) => { });
setSelectedValues(checkedValues); // 更新选中的值 const handleCheckboxChange = (checkedValues: string[]) => {
if (onChange) { // setSelectedValues(checkedValues); // 更新选中的值
onChange(checkedValues); // 调用外部传入的 onChange 回调 if (onChange) {
} onChange(checkedValues); // 调用外部传入的 onChange 回调
}; }
return ( };
<div className={className} style={style}> return (
<Form onFinish={null}> <div className={className} style={style}>
<Form.Item name="categories"> {isLoading ? (
<Checkbox.Group onChange={handleCheckboxChange}> <Skeleton
{data?.map((category) => ( paragraph={{
<div className="w-full h-9 p-2 my-1"> rows: 4,
<Checkbox className="text-base text-slate-700" key={category.id} value={category.id}> }}></Skeleton>
{category.name} ) : (
</Checkbox> <Checkbox.Group value={value} onChange={handleCheckboxChange}>
</div> {data?.map((category) => (
))} <div className="w-full h-9 p-2 my-1" key={category.id}>
</Checkbox.Group> <Checkbox
</Form.Item> className="text-base text-slate-700"
</Form> value={category.id}>
</div> {category.name}
) </Checkbox>
} </div>
))}
</Checkbox.Group>
)}
</div>
);
}

View File

@ -70,7 +70,9 @@ export const routes: CustomRouteObject[] = [
}, },
{ {
path: "editor/:id?", path: "editor/:id?",
element: <PathEditorPage></PathEditorPage>, element: <WithAuth>
<PathEditorPage></PathEditorPage>
</WithAuth>,
}, },
], ],
}, },
@ -86,6 +88,14 @@ export const routes: CustomRouteObject[] = [
</WithAuth> </WithAuth>
), ),
}, },
{
path: "my-duty-path",
element: (
<WithAuth>
<MyPathPage></MyPathPage>
</WithAuth>
),
},
{ {
path: "my-duty", path: "my-duty",
element: ( element: (

View File

@ -206,6 +206,9 @@ model Post {
rating Int? @default(0) rating Int? @default(0)
students Staff[] @relation("post_student") students Staff[] @relation("post_student")
depts Department[] @relation("post_dept") depts Department[] @relation("post_dept")
views Int @default(0) @map("views")
hates Int @default(0) @map("hates")
likes Int @default(0) @map("likes")
// 索引 // 索引
// 日期时间类型字段 // 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -238,6 +241,7 @@ model Post {
@@index([type, publishedAt]) @@index([type, publishedAt])
@@index([state]) @@index([state])
@@index([level]) @@index([level])
@@index([views])
@@index([important]) @@index([important])
@@map("post") @@map("post")
} }
@ -282,8 +286,8 @@ model Visit {
views Int @default(1) @map("views") views Int @default(1) @map("views")
// sourceIP String? @map("source_ip") // sourceIP String? @map("source_ip")
// 关联关系 // 关联关系
visitorId String @map("visitor_id") visitorId String? @map("visitor_id")
visitor Staff @relation(fields: [visitorId], references: [id]) visitor Staff? @relation(fields: [visitorId], references: [id])
postId String? @map("post_id") postId String? @map("post_id")
post Post? @relation(fields: [postId], references: [id]) post Post? @relation(fields: [postId], references: [id])
message Message? @relation(fields: [messageId], references: [id]) message Message? @relation(fields: [messageId], references: [id])

View File

@ -45,11 +45,13 @@ export const postDetailSelect: Prisma.PostSelect = {
}, },
}, },
meta: true, meta: true,
views: true,
}; };
export const postUnDetailSelect: Prisma.PostSelect = { export const postUnDetailSelect: Prisma.PostSelect = {
id: true, id: true,
type: true, type: true,
title: true, title: true,
views: true,
parent: true, parent: true,
parentId: true, parentId: true,
content: true, content: true,
@ -79,6 +81,7 @@ export const messageDetailSelect: Prisma.MessageSelect = {
id: true, id: true,
sender: true, sender: true,
content: true, content: true,
title: true, title: true,
url: true, url: true,
option: true, option: true,
@ -88,6 +91,7 @@ export const courseDetailSelect: Prisma.PostSelect = {
id: true, id: true,
title: true, title: true,
subTitle: true, subTitle: true,
views: true,
type: true, type: true,
author: true, author: true,
authorId: true, authorId: true,
@ -124,6 +128,7 @@ export const lectureDetailSelect: Prisma.PostSelect = {
subTitle: true, subTitle: true,
content: true, content: true,
resources: true, resources: true,
views: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
// 关联表选择 // 关联表选择