Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
f3d4d2f1e9
|
@ -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'];
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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 },
|
||||||
|
@ -72,21 +72,26 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
[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,
|
||||||
|
@ -101,6 +106,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
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,
|
||||||
|
@ -120,6 +126,7 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
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,
|
||||||
|
|
|
@ -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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,8 @@
|
||||||
"@nice/common": "workspace:^",
|
"@nice/common": "workspace:^",
|
||||||
"@nice/config": "workspace:^",
|
"@nice/config": "workspace:^",
|
||||||
"@nice/iconer": "workspace:^",
|
"@nice/iconer": "workspace:^",
|
||||||
"@nice/utils": "workspace:^",
|
|
||||||
"mind-elixir": "workspace:^",
|
|
||||||
"@nice/ui": "workspace:^",
|
"@nice/ui": "workspace:^",
|
||||||
|
"@nice/utils": "workspace:^",
|
||||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
"@tanstack/react-query-persist-client": "^5.51.9",
|
"@tanstack/react-query-persist-client": "^5.51.9",
|
||||||
|
@ -59,6 +58,7 @@
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"hls.js": "^1.5.18",
|
"hls.js": "^1.5.18",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
|
"mind-elixir": "workspace:^",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"quill": "2.0.3",
|
"quill": "2.0.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -69,6 +69,7 @@
|
||||||
"react-router-dom": "^6.24.1",
|
"react-router-dom": "^6.24.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"yjs": "^13.6.20",
|
"yjs": "^13.6.20",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -57,7 +57,7 @@ const HeroSection = () => {
|
||||||
{
|
{
|
||||||
icon: <EyeOutlined />,
|
icon: <EyeOutlined />,
|
||||||
value: statistics.reads,
|
value: statistics.reads,
|
||||||
label: "观看次数",
|
label: "播放次数",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [statistics]);
|
}, [statistics]);
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function BasePostLayout({
|
||||||
<div className="w-1/6">
|
<div className="w-1/6">
|
||||||
<FilterSection></FilterSection>
|
<FilterSection></FilterSection>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-5/6 p-4">{children}</div>
|
<div className="w-5/6 p-4 py-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,17 +1,10 @@
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
import { Input, Button } from "antd";
|
||||||
import {
|
import { PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
EditFilled,
|
|
||||||
PlusOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { UserMenu } from "./UserMenu/UserMenu";
|
import { UserMenu } from "./UserMenu/UserMenu";
|
||||||
import { NavigationMenu } from "./NavigationMenu";
|
import { NavigationMenu } from "./NavigationMenu";
|
||||||
import { useMainContext } from "./MainProvider";
|
import { useMainContext } from "./MainProvider";
|
||||||
import { Header } from "antd/es/layout/layout";
|
|
||||||
|
|
||||||
export function MainHeader() {
|
export function MainHeader() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
@ -19,28 +12,33 @@ export function MainHeader() {
|
||||||
const { searchValue, setSearchValue } = useMainContext();
|
const { searchValue, setSearchValue } = useMainContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30 py-2">
|
<div className="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6">
|
||||||
<div className="w-full max-w-screen-3xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
|
{/* 左侧区域 - 设置为不收缩 */}
|
||||||
<div className="flex items-center space-x-8">
|
<div className="flex items-center justify-start space-x-4 flex-shrink-0">
|
||||||
|
<img src="/logo.svg" className="h-12 w-12" />
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer">
|
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
||||||
烽火慕课
|
烽火慕课
|
||||||
</div>
|
</div>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className=" flex justify-end gap-4 mr-2">
|
{/* 右侧区域 - 可以灵活收缩 */}
|
||||||
|
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||||
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
prefix={
|
prefix={
|
||||||
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||||
}
|
}
|
||||||
placeholder="搜索课程"
|
placeholder="搜索课程"
|
||||||
className="w-96 rounded-full"
|
className="w-full md:w-96 rounded-full"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!window.location.pathname.startsWith("/search")) {
|
if (
|
||||||
|
!window.location.pathname.startsWith("/search")
|
||||||
|
) {
|
||||||
navigate(`/search`);
|
navigate(`/search`);
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -50,7 +48,9 @@ export function MainHeader() {
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
onPressEnter={(e) => {
|
onPressEnter={(e) => {
|
||||||
if (!window.location.pathname.startsWith("/search")) {
|
if (
|
||||||
|
!window.location.pathname.startsWith("/search")
|
||||||
|
) {
|
||||||
navigate(`/search`);
|
navigate(`/search`);
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -59,37 +59,44 @@ export function MainHeader() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{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);
|
||||||
}}
|
}}
|
||||||
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"
|
type="primary">
|
||||||
icon={<EditFilled />}>
|
|
||||||
{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>
|
||||||
|
|
|
@ -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-12 bg-gray-50 ">
|
<Content className=" flex-grow pt-16 bg-gray-50 ">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</Content>
|
||||||
<MainFooter />
|
<MainFooter />
|
||||||
|
|
|
@ -6,6 +6,7 @@ import React, {
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
interface SelectedTerms {
|
interface SelectedTerms {
|
||||||
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
||||||
}
|
}
|
||||||
|
@ -35,7 +36,8 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
PostType.COURSE | PostType.PATH | "both"
|
PostType.COURSE | PostType.PATH | "both"
|
||||||
>("both");
|
>("both");
|
||||||
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState<string>("");
|
||||||
|
const [debouncedValue] = useDebounce<string>(searchValue, 500);
|
||||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||||
const termFilters = useMemo(() => {
|
const termFilters = useMemo(() => {
|
||||||
return Object.entries(selectedTerms)
|
return Object.entries(selectedTerms)
|
||||||
|
@ -60,10 +62,10 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
}, [termFilters]);
|
}, [termFilters]);
|
||||||
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
const containTextCondition: Prisma.StringNullableFilter = {
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
contains: searchValue,
|
contains: debouncedValue,
|
||||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
};
|
};
|
||||||
return searchValue
|
return debouncedValue
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ title: containTextCondition },
|
{ title: containTextCondition },
|
||||||
|
@ -79,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
}, [searchValue]);
|
}, [searchValue, debouncedValue]);
|
||||||
return (
|
return (
|
||||||
<MainContext.Provider
|
<MainContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
|
@ -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: "courses", path: "/courses", label: "全部课程" },
|
{ key: "path", path: "/path", label: "全部思维导图" },
|
||||||
{ key: "path", path: "/path", label: "学习路径" },
|
{ key: "courses", path: "/courses", label: "所有课程" },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
@ -20,15 +20,19 @@ 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]);
|
||||||
|
|
||||||
const selectedKey =
|
const selectedKey = useMemo(() => {
|
||||||
menuItems.find((item) => item.path === pathname)?.key || "";
|
const normalizePath = (path: string): string => path.replace(/\/$/, "");
|
||||||
|
return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || "";
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -159,13 +159,6 @@ export function UserMenu() {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
|
||||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
|
||||||
<span className="text-base text-primary flex items-center gap-1.5">
|
|
||||||
{user?.showname || user?.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -1,24 +1,22 @@
|
||||||
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 }) => {
|
|
||||||
console.log("xx", post?.terms);
|
|
||||||
|
|
||||||
|
const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<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
|
||||||
? "blue"
|
? "green"
|
||||||
: term?.taxonomy?.slug ===
|
: term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.LEVEL
|
TaxonomySlug.LEVEL
|
||||||
? "green"
|
? "blue"
|
||||||
: "orange"
|
: "orange"
|
||||||
}
|
}
|
||||||
className="px-3 py-1 rounded-full border-0">
|
className="px-3 py-1 rounded-full border-0">
|
||||||
|
@ -36,7 +34,7 @@ const TermInfo = ({ post }: { post: PostDto }) => {
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,7 @@ 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>
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ const CollapsibleContent: React.FC<CollapsibleContentProps> = ({ content }) => {
|
||||||
const contentWrapperRef = useRef(null);
|
const contentWrapperRef = useRef(null);
|
||||||
return (
|
return (
|
||||||
<div className=" text-base ">
|
<div className=" text-base ">
|
||||||
<div className=" flex flex-col gap-4 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
<div className=" flex flex-col gap-4 transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||||
{/* 包装整个内容区域的容器 */}
|
{/* 包装整个内容区域的容器 */}
|
||||||
<div ref={contentWrapperRef}>
|
<div ref={contentWrapperRef}>
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
|
|
|
@ -1,67 +1,45 @@
|
||||||
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,
|
||||||
postDetailSelect,
|
postDetailSelect,
|
||||||
PostDto,
|
|
||||||
PostType,
|
PostType,
|
||||||
Prisma,
|
Prisma,
|
||||||
Taxonomy,
|
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";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { MindElixirInstance } from "mind-elixir";
|
import { MindElixirInstance } from "mind-elixir";
|
||||||
import MindElixir from "mind-elixir";
|
import MindElixir from "mind-elixir";
|
||||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
const MIND_OPTIONS = {
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
direction: MindElixir.SIDE,
|
import { MIND_OPTIONS } from "./constant";
|
||||||
draggable: true,
|
import { SaveOutlined } from "@ant-design/icons";
|
||||||
contextMenu: true,
|
|
||||||
toolBar: true,
|
|
||||||
nodeMenu: true,
|
|
||||||
keypress: true,
|
|
||||||
locale: "zh_CN" as const,
|
|
||||||
theme: {
|
|
||||||
name: "Latte",
|
|
||||||
palette: [
|
|
||||||
"#dd7878",
|
|
||||||
"#ea76cb",
|
|
||||||
"#8839ef",
|
|
||||||
"#e64553",
|
|
||||||
"#fe640b",
|
|
||||||
"#df8e1d",
|
|
||||||
"#40a02b",
|
|
||||||
"#209fb5",
|
|
||||||
"#1e66f5",
|
|
||||||
"#7287fd",
|
|
||||||
],
|
|
||||||
cssVar: {
|
|
||||||
"--main-color": "#444446",
|
|
||||||
"--main-bgcolor": "#ffffff",
|
|
||||||
"--color": "#777777",
|
|
||||||
"--bgcolor": "#f6f6f6",
|
|
||||||
"--panel-color": "#444446",
|
|
||||||
"--panel-bgcolor": "#ffffff",
|
|
||||||
"--panel-border-color": "#eaeaea",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
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();
|
||||||
//根据id 查询post,以获取相关信息。第一条信息?
|
const { read } = useVisitor();
|
||||||
const { data: post, isLoading }: { data: PostDto; 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 isAuth = isAuthenticated && user?.id === post?.author?.id;
|
||||||
|
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||||
|
}, [user]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { create, update } = usePost();
|
const { create, update } = usePost();
|
||||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||||
|
@ -69,9 +47,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 = {
|
||||||
|
@ -79,30 +67,42 @@ 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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const mind = new MindElixir({
|
const mind = new MindElixir({
|
||||||
...MIND_OPTIONS,
|
...MIND_OPTIONS,
|
||||||
el: containerRef.current,
|
el: containerRef.current,
|
||||||
|
before: {
|
||||||
|
beginEdit() {
|
||||||
|
return canEdit;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
draggable: canEdit, // 禁用拖拽
|
||||||
|
contextMenu: canEdit, // 禁用右键菜单
|
||||||
|
toolBar: canEdit, // 禁用工具栏
|
||||||
|
nodeMenu: canEdit, // 禁用节点右键菜单
|
||||||
|
keypress: canEdit, // 禁用键盘快捷键
|
||||||
});
|
});
|
||||||
mind.init(MindElixir.new("新学习路径"));
|
mind.init(MindElixir.new("新思维导图"));
|
||||||
containerRef.current.hidden = true;
|
containerRef.current.hidden = true;
|
||||||
|
//挂载实例
|
||||||
setInstance(mind);
|
setInstance(mind);
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((!id || post) && instance) {
|
if ((!id || post) && instance) {
|
||||||
containerRef.current.hidden = false;
|
containerRef.current.hidden = false;
|
||||||
instance.toCenter();
|
instance.toCenter();
|
||||||
instance.refresh((post as any)?.meta);
|
if (post?.meta?.nodeData) {
|
||||||
|
instance.refresh(post?.meta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [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();
|
||||||
|
@ -167,15 +167,14 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
`mind-thumb-${new Date().toString()}`
|
`mind-thumb-${new Date().toString()}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" flex flex-col border rounded-lg overflow-hidden">
|
<div className={` flex-col flex `}>
|
||||||
{taxonomies && (
|
{canEdit && taxonomies && (
|
||||||
<Form
|
<Form form={form} className=" bg-white p-4 border-b">
|
||||||
onFinish={(values) => {
|
|
||||||
console.log(values);
|
|
||||||
}}
|
|
||||||
form={form}
|
|
||||||
className=" bg-white p-2 ">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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) => (
|
||||||
|
@ -185,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}
|
||||||
|
@ -196,31 +196,43 @@ 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 ghost type="primary" onClick={handleSave}>
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined></SaveOutlined>}
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
onClick={handleSave}>
|
||||||
{id ? "更新" : "保存"}
|
{id ? "更新" : "保存"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
<div ref={containerRef} className="mind-editor min-h-screen" />
|
<div
|
||||||
{instance && <NodeMenu mind={instance} />}
|
ref={containerRef}
|
||||||
|
className="w-full"
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div
|
<div
|
||||||
className="py-64 justify-center flex"
|
className="py-64 justify-center flex"
|
||||||
style={{ height: "calc(100vh - 287px)" }}>
|
style={{ height: "calc(100vh - 271px)" }}>
|
||||||
<Spin size="large"></Spin>
|
<Spin size="large"></Spin>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!post && id && !isLoading && (
|
{!post && id && !isLoading && (
|
||||||
<div
|
<div
|
||||||
className="py-64"
|
className="py-64"
|
||||||
style={{ height: "calc(100vh - 287px)" }}>
|
style={{ height: "calc(100vh - 271px)" }}>
|
||||||
<Empty></Empty>
|
<Empty></Empty>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -38,23 +38,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(() => {
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import MindElixir from "mind-elixir";
|
||||||
|
export const MIND_OPTIONS = {
|
||||||
|
direction: MindElixir.SIDE,
|
||||||
|
draggable: true,
|
||||||
|
contextMenu: true,
|
||||||
|
toolBar: true,
|
||||||
|
nodeMenu: true,
|
||||||
|
keypress: true,
|
||||||
|
locale: "zh_CN" as const,
|
||||||
|
theme: {
|
||||||
|
name: "Latte",
|
||||||
|
palette: [
|
||||||
|
"#dd7878",
|
||||||
|
"#ea76cb",
|
||||||
|
"#8839ef",
|
||||||
|
"#e64553",
|
||||||
|
"#fe640b",
|
||||||
|
"#df8e1d",
|
||||||
|
"#40a02b",
|
||||||
|
"#209fb5",
|
||||||
|
"#1e66f5",
|
||||||
|
"#7287fd",
|
||||||
|
],
|
||||||
|
cssVar: {
|
||||||
|
"--main-color": "#444446",
|
||||||
|
"--main-bgcolor": "#ffffff",
|
||||||
|
"--color": "#777777",
|
||||||
|
"--bgcolor": "#f6f6f6",
|
||||||
|
"--panel-color": "#444446",
|
||||||
|
"--panel-bgcolor": "#ffffff",
|
||||||
|
"--panel-border-color": "#eaeaea",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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
|
|
|
@ -35,7 +35,7 @@ export default function ResourcesShower({
|
||||||
const imageResources = dealedResources.filter((res) => res.isImage);
|
const imageResources = dealedResources.filter((res) => res.isImage);
|
||||||
const fileResources = dealedResources.filter((res) => !res.isImage);
|
const fileResources = dealedResources.filter((res) => !res.isImage);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
{imageResources.length > 0 && (
|
{imageResources.length > 0 && (
|
||||||
<Row gutter={[16, 16]} className="mb-6">
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
<Image.PreviewGroup>
|
<Image.PreviewGroup>
|
||||||
|
@ -82,6 +82,7 @@ export default function ResourcesShower({
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
<div className=" text-sm px-2">附件:</div>
|
||||||
{fileResources.length > 0 && (
|
{fileResources.length > 0 && (
|
||||||
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
||||||
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
||||||
|
@ -107,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
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -36,8 +37,12 @@ interface CourseFormProviderProps {
|
||||||
editId?: string; // 添加 editId 参数
|
editId?: string; // 添加 editId 参数
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CourseDetailContext =createContext<CourseDetailContextType | null>(null);
|
export const CourseDetailContext =
|
||||||
export function CourseDetailProvider({children,editId}: CourseFormProviderProps) {
|
createContext<CourseDetailContextType | null>(null);
|
||||||
|
export function CourseDetailProvider({
|
||||||
|
children,
|
||||||
|
editId,
|
||||||
|
}: CourseFormProviderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { read } = useVisitor();
|
const { read } = useVisitor();
|
||||||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
|
@ -52,9 +57,14 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps)
|
||||||
{ 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);
|
||||||
|
@ -75,18 +85,32 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps)
|
||||||
);
|
);
|
||||||
|
|
||||||
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) {
|
||||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||||
|
}
|
||||||
}, [selectedLectureId, editId]);
|
}, [selectedLectureId, editId]);
|
||||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||||
return (
|
return (
|
||||||
|
@ -103,6 +127,7 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps)
|
||||||
setIsHeaderVisible,
|
setIsHeaderVisible,
|
||||||
canEdit,
|
canEdit,
|
||||||
userIsLearning,
|
userIsLearning,
|
||||||
|
setUserIsLearning
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</CourseDetailContext.Provider>
|
</CourseDetailContext.Provider>
|
||||||
|
|
|
@ -1,23 +1,26 @@
|
||||||
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 {
|
|
||||||
BookOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
EditTwoTone,
|
|
||||||
EyeOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useStaff } from "@nice/client";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||||
|
import { PictureOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
export const CourseDetailDescription: React.FC = () => {
|
export const CourseDetailDescription: React.FC = () => {
|
||||||
const { course,canEdit, isLoading, selectedLectureId, setSelectedLectureId } =
|
const {
|
||||||
useContext(CourseDetailContext);
|
course,
|
||||||
const { Paragraph, Title } = Typography;
|
canEdit,
|
||||||
|
isLoading,
|
||||||
|
selectedLectureId,
|
||||||
|
setSelectedLectureId,
|
||||||
|
userIsLearning,
|
||||||
|
lecture = null,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { update } = useStaff();
|
||||||
const firstLectureId = useMemo(() => {
|
const firstLectureId = useMemo(() => {
|
||||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||||
}, [course]);
|
}, [course]);
|
||||||
|
@ -30,49 +33,44 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!selectedLectureId && course?.meta?.thumbnail && (
|
{!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
|
{
|
||||||
src={course?.meta?.thumbnail}
|
|
||||||
preview={false}
|
|
||||||
className="w-full h-full object-cover z-0"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
||||||
setSelectedLectureId(firstLectureId);
|
style={{
|
||||||
|
backgroundImage: `url(${course?.meta?.thumbnail || "/placeholder.webp"})`,
|
||||||
}}
|
}}
|
||||||
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">
|
/>
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10">
|
}
|
||||||
|
<div
|
||||||
|
onClick={async () => {
|
||||||
|
setSelectedLectureId(firstLectureId);
|
||||||
|
if (!userIsLearning) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
where: { id: user?.id },
|
||||||
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
connect: {
|
||||||
|
id: course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||||
<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>}
|
||||||
{course.terms.map((term) => {
|
<TermInfo terms={course.terms}></TermInfo>
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
key={term.id}
|
|
||||||
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
|
||||||
color={
|
|
||||||
term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.CATEGORY
|
|
||||||
? "blue"
|
|
||||||
: term?.taxonomy?.slug ===
|
|
||||||
TaxonomySlug.LEVEL
|
|
||||||
? "green"
|
|
||||||
: "orange"
|
|
||||||
}
|
|
||||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
|
|
||||||
{term.name}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
|
|
|
@ -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 = () => {
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
|
@ -65,8 +50,8 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
{!lectureIsLoading &&
|
{!lectureIsLoading &&
|
||||||
selectedLectureId &&
|
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 ">
|
||||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
<div className="w-full rounded-lg ">
|
||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
content={lecture?.content || ""}
|
content={lecture?.content || ""}
|
||||||
maxHeight={500} // Optional, defaults to 150
|
maxHeight={500} // Optional, defaults to 150
|
||||||
|
|
|
@ -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 */}
|
||||||
|
|
|
@ -12,34 +12,52 @@ import dayjs from "dayjs";
|
||||||
import CourseOperationBtns from "./JoinLearingButton";
|
import CourseOperationBtns from "./JoinLearingButton";
|
||||||
|
|
||||||
export default function CourseDetailTitle() {
|
export default function CourseDetailTitle() {
|
||||||
const {
|
const { course, lecture, selectedLectureId } =
|
||||||
course,
|
useContext(CourseDetailContext);
|
||||||
isLoading,
|
|
||||||
canEdit,
|
|
||||||
lecture,
|
|
||||||
lectureIsLoading,
|
|
||||||
selectedLectureId,
|
|
||||||
} = useContext(CourseDetailContext);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
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 className="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||||
|
{course?.author?.showname && (
|
||||||
|
<div>
|
||||||
|
发布者:
|
||||||
|
{course?.author?.showname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course?.depts && course?.depts?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
发布单位:
|
||||||
|
{course?.depts?.map((dept) => dept.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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">
|
||||||
<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">
|
||||||
<ReloadOutlined spin></ReloadOutlined>
|
{"最后更新:"}
|
||||||
{"更新于:"}
|
{dayjs(
|
||||||
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
|
!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 />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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;
|
|
|
@ -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={{
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Card, Typography, Button } 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";
|
||||||
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||||
|
import { PictureOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post?: PostDto;
|
post?: PostDto;
|
||||||
|
@ -18,21 +19,27 @@ 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">
|
<div className="relative h-56 overflow-hidden group">
|
||||||
|
{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"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${post?.meta?.thumbnail})`,
|
backgroundImage: `url(${post?.meta?.thumbnail})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary-500 to-primary-700">
|
||||||
|
<PictureOutlined className="text-white text-6xl" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<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 mb-4 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
|
||||||
|
@ -46,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)]
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { Select } from "antd";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function PostSelect() {
|
||||||
|
api.post.findMany.useQuery({});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
options={[{ value: "id1", label: <></> }]}
|
||||||
|
onSearch={(inputValue) => setSearch(inputValue)}></Select>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ export default function CourseCard({ post }: { post: PostDto }) {
|
||||||
post={post}
|
post={post}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/course/${post?.id}/detail`);
|
navigate(`/course/${post?.id}/detail`);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}}></PostCard>
|
}}></PostCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ export default function PathCard({ post }: { post: PostDto }) {
|
||||||
post={post}
|
post={post}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/path/editor/${post?.id}`);
|
navigate(`/path/editor/${post?.id}`);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}}></PostCard>
|
}}></PostCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 = "选择分类",
|
|
||||||
multiple = true,
|
|
||||||
taxonomyId,
|
taxonomyId,
|
||||||
domainId,
|
domainId = undefined,
|
||||||
style,
|
style,
|
||||||
}: any) {
|
}: {
|
||||||
const [selectedValues, setSelectedValues] = useState<string[]>([]); // 用于存储选中的值
|
value?: string[];
|
||||||
const {
|
onChange?: (value: string[]) => void;
|
||||||
data,
|
className?: string;
|
||||||
isLoading,
|
taxonomyId: string;
|
||||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
domainId?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading }: { data: TermDto[]; isLoading: boolean } =
|
||||||
|
api.term.findMany.useQuery({
|
||||||
where: {
|
where: {
|
||||||
taxonomy: {
|
taxonomyId: taxonomyId,
|
||||||
id: taxonomyId,
|
parentId: null,
|
||||||
},
|
domainId,
|
||||||
parentId: null
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handleCheckboxChange = (checkedValues: string[]) => {
|
const handleCheckboxChange = (checkedValues: string[]) => {
|
||||||
setSelectedValues(checkedValues); // 更新选中的值
|
// setSelectedValues(checkedValues); // 更新选中的值
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(checkedValues); // 调用外部传入的 onChange 回调
|
onChange(checkedValues); // 调用外部传入的 onChange 回调
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
<Form onFinish={null}>
|
{isLoading ? (
|
||||||
<Form.Item name="categories">
|
<Skeleton
|
||||||
<Checkbox.Group onChange={handleCheckboxChange}>
|
paragraph={{
|
||||||
|
rows: 4,
|
||||||
|
}}></Skeleton>
|
||||||
|
) : (
|
||||||
|
<Checkbox.Group value={value} onChange={handleCheckboxChange}>
|
||||||
{data?.map((category) => (
|
{data?.map((category) => (
|
||||||
<div className="w-full h-9 p-2 my-1">
|
<div className="w-full h-9 p-2 my-1" key={category.id}>
|
||||||
<Checkbox className="text-base text-slate-700" key={category.id} value={category.id}>
|
<Checkbox
|
||||||
|
className="text-base text-slate-700"
|
||||||
|
value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
</Form.Item>
|
)}
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
|
@ -160,8 +160,3 @@
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mind-editor {
|
|
||||||
height: calc(100vh - 285px);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
|
@ -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: (
|
||||||
|
|
|
@ -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")
|
||||||
|
@ -226,8 +229,6 @@ model Post {
|
||||||
ancestors PostAncestry[] @relation("DescendantPosts")
|
ancestors PostAncestry[] @relation("DescendantPosts")
|
||||||
descendants PostAncestry[] @relation("AncestorPosts")
|
descendants PostAncestry[] @relation("AncestorPosts")
|
||||||
resources Resource[] // 附件列表
|
resources Resource[] // 附件列表
|
||||||
// watchableStaffs Staff[] @relation("post_watch_staff")
|
|
||||||
// watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型
|
|
||||||
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
||||||
|
|
||||||
// 索引
|
// 索引
|
||||||
|
@ -240,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")
|
||||||
}
|
}
|
||||||
|
@ -284,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])
|
||||||
|
|
|
@ -40,20 +40,23 @@ export type PostDto = Post & {
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
// edit: boolean;
|
// edit: boolean;
|
||||||
};
|
};
|
||||||
|
meta?: PostMeta;
|
||||||
watchableDepts: Department[];
|
watchableDepts: Department[];
|
||||||
watchableStaffs: Staff[];
|
watchableStaffs: Staff[];
|
||||||
terms: TermDto[];
|
terms: TermDto[];
|
||||||
depts: DepartmentDto[];
|
depts: DepartmentDto[];
|
||||||
meta?: {
|
|
||||||
thumbnail?: string;
|
|
||||||
views?: number;
|
|
||||||
};
|
|
||||||
studentIds?: string[];
|
studentIds?: string[];
|
||||||
};
|
};
|
||||||
|
export type PostMeta = {
|
||||||
export type LectureMeta = {
|
thumbnail?: string;
|
||||||
type?: string;
|
|
||||||
views?: number;
|
views?: number;
|
||||||
|
likes?: number;
|
||||||
|
hates?: number;
|
||||||
|
};
|
||||||
|
export type LectureMeta = PostMeta & {
|
||||||
|
type?: string;
|
||||||
|
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
videoThumbnail?: string;
|
videoThumbnail?: string;
|
||||||
videoIds?: string[];
|
videoIds?: string[];
|
||||||
|
@ -65,7 +68,7 @@ export type Lecture = Post & {
|
||||||
meta?: LectureMeta;
|
meta?: LectureMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionMeta = {
|
export type SectionMeta = PostMeta & {
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
};
|
};
|
||||||
export type Section = Post & {
|
export type Section = Post & {
|
||||||
|
@ -74,13 +77,8 @@ export type Section = Post & {
|
||||||
export type SectionDto = Section & {
|
export type SectionDto = Section & {
|
||||||
lectures: Lecture[];
|
lectures: Lecture[];
|
||||||
};
|
};
|
||||||
export type CourseMeta = {
|
export type CourseMeta = PostMeta & {
|
||||||
thumbnail?: string;
|
|
||||||
|
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
views?: number;
|
|
||||||
likes?: number;
|
|
||||||
hates?: number;
|
|
||||||
};
|
};
|
||||||
export type Course = PostDto & {
|
export type Course = PostDto & {
|
||||||
meta?: CourseMeta;
|
meta?: CourseMeta;
|
||||||
|
@ -93,3 +91,45 @@ export type CourseDto = Course & {
|
||||||
depts: Department[];
|
depts: Department[];
|
||||||
studentIds: string[];
|
studentIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Summary = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
parent: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
export type NodeObj = {
|
||||||
|
topic: string;
|
||||||
|
id: string;
|
||||||
|
style?: {
|
||||||
|
fontSize?: string;
|
||||||
|
color?: string;
|
||||||
|
background?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
};
|
||||||
|
children?: NodeObj[];
|
||||||
|
};
|
||||||
|
export type Arrow = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
delta1: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
delta2: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type PathMeta = PostMeta & {
|
||||||
|
nodeData: NodeObj;
|
||||||
|
arrows?: Arrow[];
|
||||||
|
summaries?: Summary[];
|
||||||
|
direction?: number;
|
||||||
|
};
|
||||||
|
export type PathDto = PostDto & {
|
||||||
|
meta: PathMeta;
|
||||||
|
};
|
||||||
|
|
|
@ -6,6 +6,8 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
resources: true,
|
resources: true,
|
||||||
|
parent: true,
|
||||||
|
parentId: true,
|
||||||
// watchableDepts: true,
|
// watchableDepts: true,
|
||||||
// watchableStaffs: true,
|
// watchableStaffs: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
@ -18,9 +20,9 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
depts: true,
|
depts: true,
|
||||||
author: {
|
author: {
|
||||||
|
@ -42,12 +44,16 @@ 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,
|
||||||
|
parentId: true,
|
||||||
content: true,
|
content: true,
|
||||||
resources: true,
|
resources: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
@ -75,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,
|
||||||
|
@ -84,7 +91,10 @@ 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,
|
||||||
|
authorId: true,
|
||||||
content: true,
|
content: true,
|
||||||
depts: true,
|
depts: true,
|
||||||
// isFeatured: true,
|
// isFeatured: true,
|
||||||
|
@ -118,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,
|
||||||
// 关联表选择
|
// 关联表选择
|
||||||
|
|
|
@ -12,7 +12,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/bullmq':
|
'@nestjs/bullmq':
|
||||||
specifier: ^10.2.0
|
specifier: ^10.2.0
|
||||||
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)
|
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^10.3.10
|
specifier: ^10.3.10
|
||||||
version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
|
@ -33,7 +33,7 @@ importers:
|
||||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.15)(rxjs@7.8.1)
|
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.15)(rxjs@7.8.1)
|
||||||
'@nestjs/schedule':
|
'@nestjs/schedule':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
|
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
|
||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
specifier: ^10.3.10
|
specifier: ^10.3.10
|
||||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-socket.io@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-socket.io@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
|
@ -148,7 +148,7 @@ importers:
|
||||||
version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
|
version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)
|
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))
|
||||||
'@types/exceljs':
|
'@types/exceljs':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.2
|
version: 1.3.2
|
||||||
|
@ -401,6 +401,9 @@ importers:
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
use-debounce:
|
||||||
|
specifier: ^10.0.4
|
||||||
|
version: 10.0.4(react@18.2.0)
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
|
@ -7555,6 +7558,12 @@ packages:
|
||||||
url-parse@1.5.10:
|
url-parse@1.5.10:
|
||||||
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||||
|
|
||||||
|
use-debounce@10.0.4:
|
||||||
|
resolution: {integrity: sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
use-sync-external-store@1.4.0:
|
use-sync-external-store@1.4.0:
|
||||||
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -9645,15 +9654,15 @@ snapshots:
|
||||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
|
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)':
|
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
|
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
|
||||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
bullmq: 5.34.8
|
bullmq: 5.34.8
|
||||||
|
@ -9750,7 +9759,7 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
|
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
|
@ -9768,7 +9777,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- chokidar
|
||||||
|
|
||||||
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)':
|
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||||
|
@ -15808,6 +15817,10 @@ snapshots:
|
||||||
querystringify: 2.2.0
|
querystringify: 2.2.0
|
||||||
requires-port: 1.0.0
|
requires-port: 1.0.0
|
||||||
|
|
||||||
|
use-debounce@10.0.4(react@18.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
|
||||||
use-sync-external-store@1.4.0(react@18.2.0):
|
use-sync-external-store@1.4.0(react@18.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
|
|
|
@ -1,503 +0,0 @@
|
||||||
import statistics
|
|
||||||
from git import Repo
|
|
||||||
from collections import defaultdict, Counter
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def get_contributor_stats(repo_path, start_date=None, end_date=None, branch='HEAD'):
|
|
||||||
"""
|
|
||||||
获取仓库贡献者的详细统计信息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_path: Git仓库路径
|
|
||||||
start_date: 开始日期(可选)
|
|
||||||
end_date: 结束日期(可选)
|
|
||||||
branch: 要分析的分支(默认为HEAD)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
包含每个贡献者详细统计信息的字典
|
|
||||||
"""
|
|
||||||
# 初始化仓库对象
|
|
||||||
repo = Repo(repo_path)
|
|
||||||
|
|
||||||
# 存储统计结果
|
|
||||||
stats = defaultdict(lambda: {
|
|
||||||
'additions': 0, # 添加的行数
|
|
||||||
'deletions': 0, # 删除的行数
|
|
||||||
'commits': 0, # 提交次数
|
|
||||||
'files_modified': set(), # 修改过的文件集合
|
|
||||||
'file_types': defaultdict(int),# 各类型文件的修改次数
|
|
||||||
'commit_dates': set(), # 提交日期集合
|
|
||||||
'commit_hours': defaultdict(int), # 提交小时分布
|
|
||||||
'commit_weekdays': defaultdict(int), # 提交工作日分布
|
|
||||||
'largest_commit': 0, # 最大单次提交修改量
|
|
||||||
'first_commit': None, # 首次提交时间
|
|
||||||
'last_commit': None, # 最近提交时间
|
|
||||||
'commit_sizes': [], # 每次提交的大小,用于计算平均值和中位数
|
|
||||||
'commit_messages': [], # 提交消息列表
|
|
||||||
'commit_message_lengths': [], # 提交消息长度列表
|
|
||||||
'directories_modified': set(), # 修改过的目录集合
|
|
||||||
'co_authors': set(), # 合作者集合
|
|
||||||
'impact_score': 0, # 影响力得分
|
|
||||||
'complexity_score': 0, # 复杂度得分
|
|
||||||
'commit_by_month': defaultdict(int), # 按月份统计的提交次数
|
|
||||||
'commit_by_quarter': defaultdict(int), # 按季度统计的提交次数
|
|
||||||
'commit_by_year': defaultdict(int), # 按年份统计的提交次数
|
|
||||||
'commit_by_week': defaultdict(int), # 按周统计的提交次数
|
|
||||||
'file_operations': { # 文件操作统计
|
|
||||||
'created': set(), # 创建的文件
|
|
||||||
'deleted': set(), # 删除的文件
|
|
||||||
'modified': set(), # 修改的文件
|
|
||||||
},
|
|
||||||
'review_comments': 0, # 代码审查评论数(如果可用)
|
|
||||||
'merge_commits': 0, # 合并提交数
|
|
||||||
'commit_streak': 0, # 最长连续提交天数
|
|
||||||
'current_streak': 0, # 当前连续提交天数
|
|
||||||
'contribution_days': [], # 所有贡献的日期列表(用于热图)
|
|
||||||
'code_churn': 0, # 代码周转率(添加后又删除的代码)
|
|
||||||
'file_ownership': {}, # 文件所有权百分比
|
|
||||||
'key_files_modified': set(), # 修改过的关键文件
|
|
||||||
'refactoring_commits': 0, # 重构提交数(基于提交消息分析)
|
|
||||||
'bug_fix_commits': 0, # 修复bug的提交数
|
|
||||||
'feature_commits': 0, # 新功能提交数
|
|
||||||
'documentation_commits': 0, # 文档相关提交数
|
|
||||||
'commit_size_distribution': defaultdict(int), # 提交大小分布
|
|
||||||
'collaboration_score': 0, # 协作得分
|
|
||||||
'consistency_score': 0, # 一致性得分
|
|
||||||
'expertise_areas': defaultdict(float), # 专业领域(目录/语言)
|
|
||||||
})
|
|
||||||
|
|
||||||
# 存储所有文件的修改者,用于计算协作指标
|
|
||||||
file_authors = defaultdict(set)
|
|
||||||
|
|
||||||
# 存储项目文件的重要性权重 (基于修改频率)
|
|
||||||
file_importance = Counter()
|
|
||||||
|
|
||||||
# 存储用于检测关键词的正则表达式
|
|
||||||
import re
|
|
||||||
refactor_pattern = re.compile(r'refactor|重构', re.IGNORECASE)
|
|
||||||
bugfix_pattern = re.compile(r'fix|修复|bug|问题|issue|错误', re.IGNORECASE)
|
|
||||||
feature_pattern = re.compile(r'feature|功能|新增|add|实现', re.IGNORECASE)
|
|
||||||
docs_pattern = re.compile(r'doc|文档|注释|comment', re.IGNORECASE)
|
|
||||||
|
|
||||||
# 记录每位贡献者的提交日期,用于计算连续贡献天数
|
|
||||||
author_commit_days = defaultdict(set)
|
|
||||||
|
|
||||||
# 定义关键文件路径模式 (可以根据项目自定义)
|
|
||||||
key_file_patterns = [
|
|
||||||
re.compile(r'package\.json$'),
|
|
||||||
re.compile(r'docker-compose\.yml$'),
|
|
||||||
re.compile(r'Dockerfile$'),
|
|
||||||
re.compile(r'tsconfig\..*\.json$'),
|
|
||||||
re.compile(r'/src/index\.[jt]s$'),
|
|
||||||
re.compile(r'README\.md$'),
|
|
||||||
re.compile(r'\.env'),
|
|
||||||
re.compile(r'/main\.[jt]s$'),
|
|
||||||
re.compile(r'/app\.[jt]s$'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 遍历所有提交
|
|
||||||
for commit in repo.iter_commits(branch):
|
|
||||||
# 过滤日期
|
|
||||||
commit_date = datetime.fromtimestamp(commit.committed_date)
|
|
||||||
if start_date and commit_date < start_date:
|
|
||||||
continue
|
|
||||||
if end_date and commit_date > end_date:
|
|
||||||
continue
|
|
||||||
|
|
||||||
author = commit.author.name
|
|
||||||
stats[author]['commits'] += 1
|
|
||||||
|
|
||||||
# 记录提交日期和时间
|
|
||||||
commit_day = commit_date.date()
|
|
||||||
stats[author]['commit_dates'].add(commit_day)
|
|
||||||
stats[author]['contribution_days'].append(commit_day) # 用于热图
|
|
||||||
stats[author]['commit_hours'][commit_date.hour] += 1
|
|
||||||
stats[author]['commit_weekdays'][commit_date.weekday()] += 1
|
|
||||||
|
|
||||||
# 添加到作者的提交日集合
|
|
||||||
author_commit_days[author].add(commit_day)
|
|
||||||
|
|
||||||
# 按时间段统计
|
|
||||||
year = commit_date.year
|
|
||||||
month = commit_date.month
|
|
||||||
quarter = (month - 1) // 3 + 1
|
|
||||||
week_num = commit_date.isocalendar()[1]
|
|
||||||
stats[author]['commit_by_year'][year] += 1
|
|
||||||
stats[author]['commit_by_month'][f"{year}-{month:02d}"] += 1
|
|
||||||
stats[author]['commit_by_quarter'][f"{year}-Q{quarter}"] += 1
|
|
||||||
stats[author]['commit_by_week'][f"{year}-W{week_num:02d}"] += 1
|
|
||||||
|
|
||||||
# 分析提交消息,对提交进行分类
|
|
||||||
commit_message = commit.message.strip()
|
|
||||||
if refactor_pattern.search(commit_message):
|
|
||||||
stats[author]['refactoring_commits'] += 1
|
|
||||||
if bugfix_pattern.search(commit_message):
|
|
||||||
stats[author]['bug_fix_commits'] += 1
|
|
||||||
if feature_pattern.search(commit_message):
|
|
||||||
stats[author]['feature_commits'] += 1
|
|
||||||
if docs_pattern.search(commit_message):
|
|
||||||
stats[author]['documentation_commits'] += 1
|
|
||||||
|
|
||||||
# 记录首次和最近提交
|
|
||||||
if stats[author]['first_commit'] is None or commit_date < stats[author]['first_commit']:
|
|
||||||
stats[author]['first_commit'] = commit_date
|
|
||||||
if stats[author]['last_commit'] is None or commit_date > stats[author]['last_commit']:
|
|
||||||
stats[author]['last_commit'] = commit_date
|
|
||||||
|
|
||||||
# 记录提交消息
|
|
||||||
commit_message = commit.message.strip()
|
|
||||||
stats[author]['commit_messages'].append(commit_message)
|
|
||||||
stats[author]['commit_message_lengths'].append(len(commit_message))
|
|
||||||
|
|
||||||
# 检测是否为合并提交
|
|
||||||
if len(commit.parents) > 1:
|
|
||||||
stats[author]['merge_commits'] += 1
|
|
||||||
|
|
||||||
# 统计添加和删除的行数
|
|
||||||
total_changes = 0
|
|
||||||
modified_files = set()
|
|
||||||
created_files = set()
|
|
||||||
deleted_files = set()
|
|
||||||
directories = set()
|
|
||||||
|
|
||||||
# 尝试获取提交前后的差异,以确定文件操作类型
|
|
||||||
try:
|
|
||||||
if commit.parents:
|
|
||||||
parent = commit.parents[0]
|
|
||||||
diffs = parent.diff(commit)
|
|
||||||
for diff_item in diffs:
|
|
||||||
if diff_item.new_file:
|
|
||||||
if diff_item.b_path:
|
|
||||||
created_files.add(diff_item.b_path)
|
|
||||||
elif diff_item.deleted_file:
|
|
||||||
if diff_item.a_path:
|
|
||||||
deleted_files.add(diff_item.a_path)
|
|
||||||
else:
|
|
||||||
if diff_item.a_path:
|
|
||||||
modified_files.add(diff_item.a_path)
|
|
||||||
else:
|
|
||||||
# 对于首次提交,所有文件都是新创建的
|
|
||||||
for file_path in commit.stats.files:
|
|
||||||
created_files.add(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
# 如果获取差异失败,退回到简单的文件修改统计
|
|
||||||
modified_files = set(commit.stats.files.keys())
|
|
||||||
|
|
||||||
for file_path, item in commit.stats.files.items():
|
|
||||||
# 统计文件类型
|
|
||||||
_, ext = os.path.splitext(file_path)
|
|
||||||
if ext: # 确保扩展名不为空
|
|
||||||
stats[author]['file_types'][ext] += 1
|
|
||||||
else:
|
|
||||||
stats[author]['file_types']['no_extension'] += 1
|
|
||||||
|
|
||||||
# 记录目录
|
|
||||||
directory = os.path.dirname(file_path)
|
|
||||||
if directory:
|
|
||||||
directories.add(directory)
|
|
||||||
|
|
||||||
# 记录修改的文件
|
|
||||||
modified_files.add(file_path)
|
|
||||||
|
|
||||||
# 记录文件的修改者,用于计算协作指标
|
|
||||||
file_authors[file_path].add(author)
|
|
||||||
|
|
||||||
# 统计添加和删除的行数
|
|
||||||
stats[author]['additions'] += item['insertions']
|
|
||||||
stats[author]['deletions'] += item['deletions']
|
|
||||||
total_changes += item['insertions'] + item['deletions']
|
|
||||||
|
|
||||||
# 更新修改过的文件和目录集合
|
|
||||||
stats[author]['files_modified'].update(modified_files)
|
|
||||||
stats[author]['directories_modified'].update(directories)
|
|
||||||
stats[author]['file_operations']['created'].update(created_files)
|
|
||||||
stats[author]['file_operations']['deleted'].update(deleted_files)
|
|
||||||
stats[author]['file_operations']['modified'].update(modified_files - created_files - deleted_files)
|
|
||||||
|
|
||||||
# 记录本次提交的修改量
|
|
||||||
stats[author]['commit_sizes'].append(total_changes)
|
|
||||||
|
|
||||||
# 记录提交大小分布
|
|
||||||
commit_size_category = "小型(1-10行)" if total_changes <= 10 else \
|
|
||||||
"中型(11-100行)" if total_changes <= 100 else \
|
|
||||||
"大型(101-500行)" if total_changes <= 500 else \
|
|
||||||
"超大型(500+行)"
|
|
||||||
stats[author]['commit_size_distribution'][commit_size_category] += 1
|
|
||||||
|
|
||||||
# 更新最大单次提交修改量
|
|
||||||
if total_changes > stats[author]['largest_commit']:
|
|
||||||
stats[author]['largest_commit'] = total_changes
|
|
||||||
|
|
||||||
# 检查修改的文件是否为关键文件
|
|
||||||
for file_path in modified_files:
|
|
||||||
for pattern in key_file_patterns:
|
|
||||||
if pattern.search(file_path):
|
|
||||||
stats[author]['key_files_modified'].add(file_path)
|
|
||||||
break
|
|
||||||
|
|
||||||
# 更新文件重要性权重
|
|
||||||
for file_path in modified_files:
|
|
||||||
file_importance[file_path] += 1
|
|
||||||
|
|
||||||
# 计算影响力得分 (基于修改的文件数和总修改行数)
|
|
||||||
impact = total_changes * len(modified_files) / 100 if modified_files else 0
|
|
||||||
stats[author]['impact_score'] += impact
|
|
||||||
|
|
||||||
# 计算文件协作度和文件所有权
|
|
||||||
for file_path, authors in file_authors.items():
|
|
||||||
# 如果只有一个作者修改了文件,则该作者100%拥有此文件
|
|
||||||
if len(authors) == 1:
|
|
||||||
author = next(iter(authors))
|
|
||||||
if 'file_ownership' not in stats[author]:
|
|
||||||
stats[author]['file_ownership'] = {}
|
|
||||||
stats[author]['file_ownership'][file_path] = 100.0
|
|
||||||
else:
|
|
||||||
# 如果多个作者修改了文件,则按照每个作者的修改比例计算所有权
|
|
||||||
for author in authors:
|
|
||||||
# 简化处理:平均分配所有权
|
|
||||||
ownership_percent = 100.0 / len(authors)
|
|
||||||
if 'file_ownership' not in stats[author]:
|
|
||||||
stats[author]['file_ownership'] = {}
|
|
||||||
stats[author]['file_ownership'][file_path] = ownership_percent
|
|
||||||
|
|
||||||
# 计算每个作者的连续提交天数
|
|
||||||
for author, commit_days in author_commit_days.items():
|
|
||||||
if not commit_days:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 按日期排序
|
|
||||||
sorted_days = sorted(commit_days)
|
|
||||||
|
|
||||||
# 计算最长提交连续天数
|
|
||||||
current_streak = 1
|
|
||||||
max_streak = 1
|
|
||||||
|
|
||||||
for i in range(1, len(sorted_days)):
|
|
||||||
# 如果当前日期与前一天相差正好一天,则增加连续计数
|
|
||||||
if (sorted_days[i] - sorted_days[i-1]).days == 1:
|
|
||||||
current_streak += 1
|
|
||||||
else:
|
|
||||||
# 重置当前连续计数
|
|
||||||
current_streak = 1
|
|
||||||
|
|
||||||
max_streak = max(max_streak, current_streak)
|
|
||||||
|
|
||||||
# 记录最长连续提交天数
|
|
||||||
stats[author]['commit_streak'] = max_streak
|
|
||||||
|
|
||||||
# 计算当前连续提交天数 (到最后一个日期)
|
|
||||||
if sorted_days:
|
|
||||||
today = datetime.now().date()
|
|
||||||
days_since_last = (today - sorted_days[-1]).days
|
|
||||||
|
|
||||||
if days_since_last <= 1: # 如果最后提交是今天或昨天
|
|
||||||
current_streak = 1
|
|
||||||
for i in range(len(sorted_days) - 1, 0, -1):
|
|
||||||
if (sorted_days[i] - sorted_days[i-1]).days == 1:
|
|
||||||
current_streak += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
stats[author]['current_streak'] = current_streak
|
|
||||||
|
|
||||||
# 后处理:计算派生指标并转换集合为计数
|
|
||||||
for author, data in stats.items():
|
|
||||||
# 将文件集合转换为数量
|
|
||||||
data['files_count'] = len(data['files_modified'])
|
|
||||||
data['active_days'] = len(data['commit_dates'])
|
|
||||||
data['key_files_count'] = len(data['key_files_modified'])
|
|
||||||
|
|
||||||
# 计算平均每次提交的修改量
|
|
||||||
if data['commits'] > 0:
|
|
||||||
data['avg_commit_size'] = sum(data['commit_sizes']) / data['commits']
|
|
||||||
data['median_commit_size'] = statistics.median(data['commit_sizes']) if data['commit_sizes'] else 0
|
|
||||||
|
|
||||||
# 计算代码复杂度得分 (基于修改量、文件数和一致性)
|
|
||||||
variability = statistics.stdev(data['commit_sizes']) if len(data['commit_sizes']) > 1 else 0
|
|
||||||
data['complexity_score'] = (data['avg_commit_size'] * data['files_count'] * (1 + variability / 1000)) / 100
|
|
||||||
|
|
||||||
# 计算一致性得分 (提交大小和频率的一致性)
|
|
||||||
if variability > 0:
|
|
||||||
data['consistency_score'] = 100 * (1 - min(1, variability / data['avg_commit_size']))
|
|
||||||
else:
|
|
||||||
data['consistency_score'] = 100
|
|
||||||
else:
|
|
||||||
data['avg_commit_size'] = 0
|
|
||||||
data['median_commit_size'] = 0
|
|
||||||
data['complexity_score'] = 0
|
|
||||||
data['consistency_score'] = 0
|
|
||||||
|
|
||||||
# 计算总修改量
|
|
||||||
data['total_changes'] = data['additions'] + data['deletions']
|
|
||||||
|
|
||||||
# 计算代码周转率 (code churn) - 估算值
|
|
||||||
if data['additions'] > 0 and data['deletions'] > 0:
|
|
||||||
data['code_churn'] = min(data['additions'], data['deletions']) / max(data['additions'], data['deletions']) * 100
|
|
||||||
|
|
||||||
# 计算活跃时长(天)
|
|
||||||
if data['first_commit'] and data['last_commit']:
|
|
||||||
delta = data['last_commit'] - data['first_commit']
|
|
||||||
data['active_period_days'] = delta.days + 1
|
|
||||||
|
|
||||||
# 计算活跃密度 (提交数/活跃天数)
|
|
||||||
if delta.days > 0:
|
|
||||||
data['activity_density'] = data['commits'] / delta.days
|
|
||||||
else:
|
|
||||||
data['activity_density'] = data['commits']
|
|
||||||
else:
|
|
||||||
data['active_period_days'] = 0
|
|
||||||
data['activity_density'] = 0
|
|
||||||
|
|
||||||
# 计算协作得分 (基于参与修改的共享文件比例)
|
|
||||||
total_files = len(data['files_modified'])
|
|
||||||
shared_files = sum(1 for f in data['files_modified'] if len(file_authors[f]) > 1)
|
|
||||||
if total_files > 0:
|
|
||||||
data['collaboration_score'] = (shared_files / total_files) * 100
|
|
||||||
|
|
||||||
# 计算专业领域 (基于文件类型和目录)
|
|
||||||
if data['file_types']:
|
|
||||||
primary_type = max(data['file_types'].items(), key=lambda x: x[1])[0]
|
|
||||||
data['primary_file_type'] = primary_type
|
|
||||||
data['primary_file_type_percent'] = (data['file_types'][primary_type] / sum(data['file_types'].values())) * 100
|
|
||||||
|
|
||||||
# 统计目录专业度
|
|
||||||
if data['directories_modified']:
|
|
||||||
dir_counts = Counter()
|
|
||||||
for directory in data['directories_modified']:
|
|
||||||
dir_counts[directory] += 1
|
|
||||||
|
|
||||||
# 检查父目录
|
|
||||||
parent = os.path.dirname(directory)
|
|
||||||
while parent:
|
|
||||||
dir_counts[parent] += 0.5 # 对父目录给予较低的权重
|
|
||||||
parent = os.path.dirname(parent)
|
|
||||||
|
|
||||||
# 找出专业领域(最常修改的目录)
|
|
||||||
if dir_counts:
|
|
||||||
primary_dir = max(dir_counts.items(), key=lambda x: x[1])[0]
|
|
||||||
data['primary_directory'] = primary_dir
|
|
||||||
data['expertise_areas'][primary_dir] = dir_counts[primary_dir] / sum(dir_counts.values())
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
def print_stats(stats):
|
|
||||||
"""打印贡献者统计信息的详细报告"""
|
|
||||||
# 基本信息表头
|
|
||||||
print("\n===== 贡献者基本统计 =====")
|
|
||||||
print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format(
|
|
||||||
"作者", "提交数", "添加行数", "删除行数", "总修改行数", "修改文件数", "活跃天数"))
|
|
||||||
print("-" * 90)
|
|
||||||
|
|
||||||
# 按总修改量排序
|
|
||||||
for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True):
|
|
||||||
print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format(
|
|
||||||
author,
|
|
||||||
data['commits'],
|
|
||||||
data['additions'],
|
|
||||||
data['deletions'],
|
|
||||||
data['total_changes'],
|
|
||||||
data['files_count'],
|
|
||||||
data['active_days']
|
|
||||||
))
|
|
||||||
|
|
||||||
# 为每个贡献者打印详细信息
|
|
||||||
for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True):
|
|
||||||
print(f"\n\n===== {author} 的详细贡献统计 =====")
|
|
||||||
|
|
||||||
# 活跃时间信息
|
|
||||||
if data['first_commit'] and data['last_commit']:
|
|
||||||
print(f"首次提交时间: {data['first_commit'].strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print(f"最近提交时间: {data['last_commit'].strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print(f"活跃时长: {data['active_period_days']} 天")
|
|
||||||
|
|
||||||
# 提交规模信息
|
|
||||||
print(f"平均每次提交修改: {data['avg_commit_size']:.2f} 行")
|
|
||||||
print(f"最大单次提交修改: {data['largest_commit']} 行")
|
|
||||||
|
|
||||||
# 文件类型分布
|
|
||||||
if data['file_types']:
|
|
||||||
print("\n文件类型分布:")
|
|
||||||
for ext, count in sorted(data['file_types'].items(), key=lambda x: x[1], reverse=True):
|
|
||||||
print(f" {ext}: {count} 次修改")
|
|
||||||
|
|
||||||
# 提交时间分布
|
|
||||||
if data['commit_hours']:
|
|
||||||
print("\n提交时间分布:")
|
|
||||||
for hour in range(24):
|
|
||||||
count = data['commit_hours'].get(hour, 0)
|
|
||||||
if count > 0:
|
|
||||||
print(f" {hour:02d}:00-{hour+1:02d}:00: {count} 次提交")
|
|
||||||
|
|
||||||
# 工作日分布
|
|
||||||
if data['commit_weekdays']:
|
|
||||||
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
|
||||||
print("\n工作日分布:")
|
|
||||||
for day in range(7):
|
|
||||||
count = data['commit_weekdays'].get(day, 0)
|
|
||||||
if count > 0:
|
|
||||||
print(f" {weekday_names[day]}: {count} 次提交")
|
|
||||||
|
|
||||||
def get_team_summary(stats):
|
|
||||||
"""生成团队整体统计摘要"""
|
|
||||||
summary = {
|
|
||||||
'total_commits': 0,
|
|
||||||
'total_additions': 0,
|
|
||||||
'total_deletions': 0,
|
|
||||||
'total_files': set(),
|
|
||||||
'contributors': len(stats),
|
|
||||||
'first_commit': None,
|
|
||||||
'last_commit': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
for author, data in stats.items():
|
|
||||||
summary['total_commits'] += data['commits']
|
|
||||||
summary['total_additions'] += data['additions']
|
|
||||||
summary['total_deletions'] += data['deletions']
|
|
||||||
summary['total_files'].update(data['files_modified'])
|
|
||||||
|
|
||||||
# 更新首次和最近提交
|
|
||||||
if data['first_commit']:
|
|
||||||
if summary['first_commit'] is None or data['first_commit'] < summary['first_commit']:
|
|
||||||
summary['first_commit'] = data['first_commit']
|
|
||||||
|
|
||||||
if data['last_commit']:
|
|
||||||
if summary['last_commit'] is None or data['last_commit'] > summary['last_commit']:
|
|
||||||
summary['last_commit'] = data['last_commit']
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def print_team_summary(summary):
|
|
||||||
"""打印团队整体统计摘要"""
|
|
||||||
print("\n===== 团队整体统计 =====")
|
|
||||||
print(f"贡献者数量: {summary['contributors']}")
|
|
||||||
print(f"总提交次数: {summary['total_commits']}")
|
|
||||||
print(f"总添加行数: {summary['total_additions']}")
|
|
||||||
print(f"总删除行数: {summary['total_deletions']}")
|
|
||||||
print(f"总修改行数: {summary['total_additions'] + summary['total_deletions']}")
|
|
||||||
print(f"修改的文件数: {len(summary['total_files'])}")
|
|
||||||
|
|
||||||
if summary['first_commit'] and summary['last_commit']:
|
|
||||||
print(f"项目起始时间: {summary['first_commit'].strftime('%Y-%m-%d')}")
|
|
||||||
print(f"最近活动时间: {summary['last_commit'].strftime('%Y-%m-%d')}")
|
|
||||||
delta = summary['last_commit'] - summary['first_commit']
|
|
||||||
print(f"项目活跃时长: {delta.days + 1} 天")
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 设置仓库路径(当前目录)
|
|
||||||
repo_path = '.'
|
|
||||||
|
|
||||||
# 设置日期范围(示例)
|
|
||||||
# 注意:这里使用的是2025年的日期,可能需要根据实际情况调整
|
|
||||||
start_date = datetime(2025, 1, 1) # 修改为更合理的日期范围
|
|
||||||
end_date = datetime(2025, 12, 31)
|
|
||||||
|
|
||||||
print(f"分析Git仓库: {os.path.abspath(repo_path)}")
|
|
||||||
print(f"时间范围: {start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}")
|
|
||||||
|
|
||||||
# 获取统计信息
|
|
||||||
stats = get_contributor_stats(repo_path, start_date, end_date)
|
|
||||||
|
|
||||||
# 打印团队摘要
|
|
||||||
team_summary = get_team_summary(stats)
|
|
||||||
print_team_summary(team_summary)
|
|
||||||
print(stats)
|
|
Loading…
Reference in New Issue