Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
ff10914de2
|
@ -101,11 +101,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
},
|
},
|
||||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||||
) {
|
) {
|
||||||
// const await db.post.findMany({
|
|
||||||
// where: {
|
|
||||||
// type: PostType.COURSE,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
const { courseDetail } = args;
|
const { courseDetail } = args;
|
||||||
// If no transaction is provided, create a new one
|
// If no transaction is provided, create a new one
|
||||||
if (!params.tx) {
|
if (!params.tx) {
|
||||||
|
@ -128,6 +124,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
) {
|
) {
|
||||||
args.data.authorId = params?.staff?.id;
|
args.data.authorId = params?.staff?.id;
|
||||||
args.data.updatedAt = dayjs().toDate();
|
args.data.updatedAt = dayjs().toDate();
|
||||||
|
|
||||||
const result = await super.create(args);
|
const result = await super.create(args);
|
||||||
EventBus.emit('dataChanged', {
|
EventBus.emit('dataChanged', {
|
||||||
type: ObjectType.POST,
|
type: ObjectType.POST,
|
||||||
|
@ -166,19 +163,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
);
|
);
|
||||||
return transDto;
|
return transDto;
|
||||||
}
|
}
|
||||||
// async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
|
||||||
// if (!args.where) args.where = {};
|
|
||||||
// args.where.OR = await this.preFilter(args.where.OR, staff);
|
|
||||||
// return this.wrapResult(super.findMany(args), async (result) => {
|
|
||||||
// await Promise.all(
|
|
||||||
// result.map(async (item) => {
|
|
||||||
// await setPostRelation({ data: item, staff });
|
|
||||||
// await this.setPerms(item, staff);
|
|
||||||
// }),
|
|
||||||
// );
|
|
||||||
// return { ...result };
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||||
if (!args.where) args.where = {};
|
if (!args.where) args.where = {};
|
||||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||||
|
@ -255,6 +240,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
// 批量执行更新
|
// 批量执行更新
|
||||||
return updates.length > 0 ? await db.$transaction(updates) : [];
|
return updates.length > 0 ? await db.$transaction(updates) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async setPerms(data: Post, staff?: UserProfile) {
|
protected async setPerms(data: Post, staff?: UserProfile) {
|
||||||
if (!staff) return;
|
if (!staff) return;
|
||||||
const perms: ResPerm = {
|
const perms: ResPerm = {
|
||||||
|
@ -306,37 +292,37 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
staff?.id && {
|
staff?.id && {
|
||||||
authorId: staff.id,
|
authorId: staff.id,
|
||||||
},
|
},
|
||||||
staff?.id && {
|
// staff?.id && {
|
||||||
watchableStaffs: {
|
// watchableStaffs: {
|
||||||
some: {
|
// some: {
|
||||||
id: staff.id,
|
// id: staff.id,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
deptId && {
|
// deptId && {
|
||||||
watchableDepts: {
|
// watchableDepts: {
|
||||||
some: {
|
// some: {
|
||||||
id: {
|
// id: {
|
||||||
in: parentDeptIds,
|
// in: parentDeptIds,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
// {
|
||||||
AND: [
|
// AND: [
|
||||||
{
|
// {
|
||||||
watchableStaffs: {
|
// watchableStaffs: {
|
||||||
none: {}, // 匹配 watchableStaffs 为空
|
// none: {}, // 匹配 watchableStaffs 为空
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
watchableDepts: {
|
// watchableDepts: {
|
||||||
none: {}, // 匹配 watchableDepts 为空
|
// none: {}, // 匹配 watchableDepts 为空
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (orCondition?.length > 0) return orCondition;
|
if (orCondition?.length > 0) return orCondition;
|
||||||
|
|
|
@ -168,6 +168,21 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
||||||
(lecture) => lecture.parentId === section.id,
|
(lecture) => lecture.parentId === section.id,
|
||||||
) as any as Lecture[];
|
) as any as Lecture[];
|
||||||
});
|
});
|
||||||
Object.assign(data, { sections, lectureCount });
|
|
||||||
|
const students = await db.staff.findMany({
|
||||||
|
where: {
|
||||||
|
learningPosts: {
|
||||||
|
some: {
|
||||||
|
id: data.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const studentIds = (students || []).map((student) => student?.id);
|
||||||
|
Object.assign(data, { sections, lectureCount, studentIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ export async function updateTotalCourseViewCount(type: VisitType) {
|
||||||
views: true,
|
views: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
postId: { in: lectures.map((lecture) => lecture.id) },
|
postId: { in: posts.map((post) => post.id) },
|
||||||
type: type,
|
type: type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
"@nice/iconer": "workspace:^",
|
"@nice/iconer": "workspace:^",
|
||||||
"@nice/utils": "workspace:^",
|
"@nice/utils": "workspace:^",
|
||||||
"mind-elixir": "workspace:^",
|
"mind-elixir": "workspace:^",
|
||||||
|
"@mind-elixir/node-menu": "workspace:*",
|
||||||
"@nice/ui": "workspace:^",
|
"@nice/ui": "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",
|
||||||
|
|
0
apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx
Normal file → Executable file
0
apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx
Normal file → Executable file
0
apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx
Normal file → Executable file
0
apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx
Normal file → Executable file
|
@ -1,5 +1,6 @@
|
||||||
import { Card, Tag, Typography, Button } from "antd";
|
import { Card, Tag, Typography, Button } from "antd";
|
||||||
import {
|
import {
|
||||||
|
BookOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
@ -9,13 +10,18 @@ import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
interface CourseCardProps {
|
interface CourseCardProps {
|
||||||
course: CourseDto;
|
course: CourseDto;
|
||||||
|
edit?: boolean;
|
||||||
}
|
}
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
export default function CourseCard({ course }: CourseCardProps) {
|
export default function CourseCard({ course, edit = false }: CourseCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handleClick = (course: CourseDto) => {
|
const handleClick = (course: CourseDto) => {
|
||||||
navigate(`/course/${course.id}/detail`);
|
if (!edit) {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth", })
|
navigate(`/course/${course.id}/detail`);
|
||||||
|
} else {
|
||||||
|
navigate(`/course/${course.id}/editor`);
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
@ -38,7 +44,7 @@ export default function CourseCard({ course }: CourseCardProps) {
|
||||||
}>
|
}>
|
||||||
<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 mb-4 whiteSpace-nowrap">
|
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
|
||||||
{course?.terms?.map((term) => {
|
{course?.terms?.map((term) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -46,10 +52,10 @@ export default function CourseCard({ course }: CourseCardProps) {
|
||||||
key={term.id}
|
key={term.id}
|
||||||
color={
|
color={
|
||||||
term?.taxonomy?.slug ===
|
term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.CATEGORY
|
TaxonomySlug.CATEGORY
|
||||||
? "blue"
|
? "blue"
|
||||||
: term?.taxonomy?.slug ===
|
: term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.LEVEL
|
TaxonomySlug.LEVEL
|
||||||
? "green"
|
? "green"
|
||||||
: "orange"
|
: "orange"
|
||||||
}
|
}
|
||||||
|
@ -68,10 +74,10 @@ export default function CourseCard({ course }: CourseCardProps) {
|
||||||
<button> {course.title}</button>
|
<button> {course.title}</button>
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||||
<div className="ml-2 flex items-center flex-grow">
|
<div className="ml-2 flex items-center flex-grow">
|
||||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
<Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
|
||||||
{course?.depts?.length > 1
|
{course?.depts?.length > 1
|
||||||
? `${course.depts[0].name}等`
|
? `${course.depts[0].name}等`
|
||||||
: course?.depts?.[0]?.name}
|
: course?.depts?.[0]?.name}
|
||||||
|
@ -79,10 +85,15 @@ export default function CourseCard({ course }: CourseCardProps) {
|
||||||
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
|
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
</div>
|
||||||
<EyeOutlined className="mr-1" />{course?.meta?.views
|
<div className="flex items-center gap-2">
|
||||||
? `观看次数 ${course?.meta?.views}`
|
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||||
: null}
|
<EyeOutlined />
|
||||||
|
{`观看次数 ${course?.meta?.views || 0}`}
|
||||||
|
</span>
|
||||||
|
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||||
|
<BookOutlined />
|
||||||
|
{`学习人数 ${course?.studentIds?.length || 0}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-gray-100 text-center">
|
<div className="pt-4 border-t border-gray-100 text-center">
|
||||||
|
@ -91,7 +102,7 @@ export default function CourseCard({ course }: CourseCardProps) {
|
||||||
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)]
|
||||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||||
立即学习
|
{edit ? "编辑" : "立即学习"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
import CourseList from "@web/src/components/models/course/list/CourseList";
|
|
||||||
import { useMainContext } from "../../layout/MainProvider";
|
import { useMainContext } from "../../layout/MainProvider";
|
||||||
import { PostType, Prisma } from "@nice/common";
|
import { PostType, Prisma } from "@nice/common";
|
||||||
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import CourseCard from "./CourseCard";
|
||||||
|
|
||||||
export function CoursesContainer() {
|
export function CoursesContainer() {
|
||||||
const { searchValue, selectedTerms } = useMainContext();
|
const { selectedTerms, searchCondition } = useMainContext();
|
||||||
const termFilters = useMemo(() => {
|
const termFilters = useMemo(() => {
|
||||||
return Object.entries(selectedTerms)
|
return Object.entries(selectedTerms)
|
||||||
.filter(([, terms]) => terms.length > 0)
|
.filter(([, terms]) => terms.length > 0)
|
||||||
.map(([, terms]) => terms);
|
.map(([, terms]) => terms);
|
||||||
}, [selectedTerms]);
|
}, [selectedTerms]);
|
||||||
const searchCondition: Prisma.StringNullableFilter = {
|
|
||||||
contains: searchValue,
|
|
||||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CourseList
|
<PostList
|
||||||
|
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>}
|
||||||
params={{
|
params={{
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
where: {
|
where: {
|
||||||
|
@ -30,21 +29,10 @@ export function CoursesContainer() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
OR: [
|
...searchCondition,
|
||||||
{ title: searchCondition },
|
|
||||||
{ subTitle: searchCondition },
|
|
||||||
{ content: searchCondition },
|
|
||||||
{
|
|
||||||
terms: {
|
|
||||||
some: {
|
|
||||||
name: searchCondition,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
cols={4}></CourseList>
|
cols={4}></PostList>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ export default function FilterSection() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6 h-full">
|
<div className=" flex z-0 p-6 flex-col rounded-lg mt-4 space-y-6 h-[820px] overscroll-contain overflow-x-hidden">
|
||||||
{taxonomies?.map((tax, index) => {
|
{taxonomies?.map((tax, index) => {
|
||||||
const items = Object.entries(selectedTerms).find(
|
const items = Object.entries(selectedTerms).find(
|
||||||
([key, items]) => key === tax.slug
|
([key, items]) => key === tax.slug
|
||||||
|
@ -31,35 +31,16 @@ export default function FilterSection() {
|
||||||
</h3>
|
</h3>
|
||||||
<TermParentSelector
|
<TermParentSelector
|
||||||
value={items}
|
value={items}
|
||||||
slug = {tax?.slug}
|
slug={tax?.slug}
|
||||||
className="w-70 max-h-[500px] overscroll-contain overflow-x-hidden"
|
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
||||||
onChange={(selected) =>
|
onChange={(selected) =>
|
||||||
handleTermChange(
|
handleTermChange(
|
||||||
tax?.slug,
|
tax?.slug,
|
||||||
selected as string[]
|
selected as string[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
taxonomyId={tax?.id}
|
taxonomyId={tax?.id}></TermParentSelector>
|
||||||
></TermParentSelector>
|
<Divider></Divider>
|
||||||
{/* <TermSelect
|
|
||||||
// open
|
|
||||||
className="w-72"
|
|
||||||
value={items}
|
|
||||||
dropdownRender={(menu) => (
|
|
||||||
<div style={{ padding: "8px" }}>{menu}</div>
|
|
||||||
)}
|
|
||||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
|
||||||
multiple
|
|
||||||
taxonomyId={tax?.id}
|
|
||||||
onChange={(selected) =>
|
|
||||||
handleTermChange(
|
|
||||||
tax?.slug,
|
|
||||||
selected as string[]
|
|
||||||
)
|
|
||||||
}></TermSelect>
|
|
||||||
{index < taxonomies.length - 1 && (
|
|
||||||
<Divider className="my-6" />
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -19,11 +19,11 @@ const CategorySection = () => {
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
slug: TaxonomySlug.CATEGORY,
|
slug: TaxonomySlug.CATEGORY,
|
||||||
},
|
},
|
||||||
parentId : null
|
parentId: null,
|
||||||
},
|
},
|
||||||
take: 8,
|
take: 8,
|
||||||
});
|
});
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleMouseEnter = useCallback((index: number) => {
|
const handleMouseEnter = useCallback((index: number) => {
|
||||||
setHoveredIndex(index);
|
setHoveredIndex(index);
|
||||||
|
@ -33,13 +33,13 @@ const CategorySection = () => {
|
||||||
setHoveredIndex(null);
|
setHoveredIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseClick = useCallback((categoryId:string) => {
|
const handleMouseClick = useCallback((categoryId: string) => {
|
||||||
setSelectedTerms({
|
setSelectedTerms({
|
||||||
[TaxonomySlug.CATEGORY] : [categoryId]
|
[TaxonomySlug.CATEGORY]: [categoryId],
|
||||||
})
|
});
|
||||||
navigate('/courses')
|
navigate("/courses");
|
||||||
window.scrollTo({top: 0,behavior: "smooth",})
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
},[]);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<section className="py-8 relative overflow-hidden">
|
<section className="py-8 relative overflow-hidden">
|
||||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||||
|
@ -57,7 +57,7 @@ const CategorySection = () => {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||||
) : (
|
) : (
|
||||||
courseCategoriesData.map((category, index) => {
|
courseCategoriesData?.map((category, index) => {
|
||||||
const categoryColor = stringToColor(category.name);
|
const categoryColor = stringToColor(category.name);
|
||||||
const isHovered = hoveredIndex === index;
|
const isHovered = hoveredIndex === index;
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,9 @@ 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 CourseList from "@web/src/components/models/course/list/CourseList";
|
|
||||||
import LookForMore from "./LookForMore";
|
import LookForMore from "./LookForMore";
|
||||||
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
|
import CourseCard from "../../courses/components/CourseCard";
|
||||||
interface GetTaxonomyProps {
|
interface GetTaxonomyProps {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
@ -80,7 +81,8 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CourseList
|
<PostList
|
||||||
|
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>}
|
||||||
params={{
|
params={{
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: initialVisibleCoursesCount,
|
pageSize: initialVisibleCoursesCount,
|
||||||
|
@ -95,7 +97,7 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
cols={4}></CourseList>
|
cols={4}></PostList>
|
||||||
<LookForMore to={"/courses"}></LookForMore>
|
<LookForMore to={"/courses"}></LookForMore>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import React, { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Carousel, Typography } from "antd";
|
import { Carousel, Typography } from "antd";
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
@ -30,13 +36,29 @@ interface PlatformStat {
|
||||||
const HeroSection = () => {
|
const HeroSection = () => {
|
||||||
const carouselRef = useRef<CarouselRef>(null);
|
const carouselRef = useRef<CarouselRef>(null);
|
||||||
const { statistics, slides } = useAppConfig();
|
const { statistics, slides } = useAppConfig();
|
||||||
const [countStatistics, setCountStatistics] = useState<number>(4)
|
const [countStatistics, setCountStatistics] = useState<number>(4);
|
||||||
const platformStats: PlatformStat[] = useMemo(() => {
|
const platformStats: PlatformStat[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ icon: <TeamOutlined />, value: statistics.staffs, label: "注册学员" },
|
{
|
||||||
{ icon: <StarOutlined />, value: statistics.courses, label: "精品课程" },
|
icon: <TeamOutlined />,
|
||||||
{ icon: <BookOutlined />, value: statistics.lectures, label: '课程章节' },
|
value: statistics.staffs,
|
||||||
{ icon: <EyeOutlined />, value: statistics.reads, label: "观看次数" },
|
label: "注册学员",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <StarOutlined />,
|
||||||
|
value: statistics.courses,
|
||||||
|
label: "精品课程",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
value: statistics.lectures,
|
||||||
|
label: "课程章节",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <EyeOutlined />,
|
||||||
|
value: statistics.reads,
|
||||||
|
label: "观看次数",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}, [statistics]);
|
}, [statistics]);
|
||||||
const handlePrev = useCallback(() => {
|
const handlePrev = useCallback(() => {
|
||||||
|
@ -48,7 +70,7 @@ const HeroSection = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const countNonZeroValues = (statistics: Record<string, number>): number => {
|
const countNonZeroValues = (statistics: Record<string, number>): number => {
|
||||||
return Object.values(statistics).filter(value => value !== 0).length;
|
return Object.values(statistics).filter((value) => value !== 0).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -67,8 +89,8 @@ const HeroSection = () => {
|
||||||
dots={{
|
dots={{
|
||||||
className: "carousel-dots !bottom-32 !z-20",
|
className: "carousel-dots !bottom-32 !z-20",
|
||||||
}}>
|
}}>
|
||||||
{Array.isArray(slides) ?
|
{Array.isArray(slides) ? (
|
||||||
(slides.map((item, index) => (
|
slides.map((item, index) => (
|
||||||
<div key={index} className="relative h-[600px]">
|
<div key={index} className="relative h-[600px]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
||||||
|
@ -87,9 +109,9 @@ const HeroSection = () => {
|
||||||
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div>
|
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8"></div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div></div>
|
<div></div>
|
||||||
)}
|
)}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
|
@ -108,31 +130,30 @@ const HeroSection = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Container */}
|
{/* Stats Container */}
|
||||||
{
|
{countStatistics > 1 && (
|
||||||
countStatistics > 1 && (
|
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 w-3/5 max-w-6xl px-4">
|
||||||
<div className="absolute -bottom-20 left-1/2 -translate-x-1/2 w-3/5 max-w-6xl px-4">
|
<div
|
||||||
<div className={`rounded-2xl grid grid-cols-${countStatistics} lg:grid-cols-${countStatistics} md:grid-cols-${countStatistics} gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]`}>
|
className={`rounded-2xl grid grid-cols-${countStatistics} lg:grid-cols-${countStatistics} md:grid-cols-${countStatistics} gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]`}>
|
||||||
{platformStats.map((stat, index) => {
|
{platformStats.map((stat, index) => {
|
||||||
return stat.value
|
return stat.value ? (
|
||||||
? (<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out">
|
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-50 text-primary-600 text-3xl transition-colors duration-300 group-hover:text-primary-700">
|
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-50 text-primary-600 text-3xl transition-colors duration-300 group-hover:text-primary-700">
|
||||||
{stat.icon}
|
{stat.icon}
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-600 font-medium">
|
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null
|
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
||||||
})}
|
{stat.value}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-gray-600 font-medium">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,68 +1,76 @@
|
||||||
import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { Layout, Typography } from 'antd';
|
CloudOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
export function MainFooter() {
|
export function MainFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 text-secondary-200">
|
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-50 text-secondary-200">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* 开发组织信息 */}
|
{/* 开发组织信息 */}
|
||||||
<div className="text-center md:text-left space-y-2">
|
<div className="text-center md:text-left space-y-2">
|
||||||
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
||||||
软件与数据小组
|
软件与数据小组
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-400 text-xs italic">
|
<p className="text-gray-400 text-xs italic">
|
||||||
提供技术支持
|
提供技术支持
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联系方式 */}
|
{/* 联系方式 */}
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<PhoneOutlined className="text-gray-400" />
|
<PhoneOutlined className="text-gray-400" />
|
||||||
<span className="text-gray-300 text-xs">628118</span>
|
<span className="text-gray-300 text-xs">
|
||||||
</div>
|
628118
|
||||||
<div className="flex items-center justify-center space-x-2">
|
</span>
|
||||||
<MailOutlined className="text-gray-400" />
|
</div>
|
||||||
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
|
<div className="flex items-center justify-center space-x-2">
|
||||||
</div>
|
<MailOutlined className="text-gray-400" />
|
||||||
</div>
|
<span className="text-gray-300 text-xs">
|
||||||
|
gcsjs6@tx3l.nb.kj
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 系统链接 */}
|
{/* 系统链接 */}
|
||||||
<div className="text-center md:text-right space-y-2">
|
<div className="text-center md:text-right space-y-2">
|
||||||
<div className="flex items-center justify-center md:justify-end space-x-4">
|
<div className="flex items-center justify-center md:justify-end space-x-4">
|
||||||
<a
|
<a
|
||||||
href="https://27.57.72.21"
|
href="https://27.57.72.21"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
title="访问门户网站"
|
title="访问门户网站">
|
||||||
>
|
<HomeOutlined className="text-lg" />
|
||||||
<HomeOutlined className="text-lg" />
|
</a>
|
||||||
</a>
|
<a
|
||||||
<a
|
href="https://27.57.72.14"
|
||||||
href="https://27.57.72.14"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
title="访问烽火青云">
|
||||||
title="访问烽火青云"
|
<CloudOutlined className="text-lg" />
|
||||||
>
|
</a>
|
||||||
<CloudOutlined className="text-lg" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="http://27.57.72.38"
|
href="http://27.57.72.38"
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
title="访问烽火律询"
|
title="访问烽火律询">
|
||||||
>
|
<FileSearchOutlined className="text-lg" />
|
||||||
<FileSearchOutlined className="text-lg" />
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 版权信息 */}
|
{/* 版权信息 */}
|
||||||
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
||||||
<p className="text-gray-400 text-xs">
|
<p className="text-gray-400 text-xs">
|
||||||
© {new Date().getFullYear()} 南天烽火. All rights reserved.
|
© {new Date().getFullYear()} 南天烽火. All rights
|
||||||
</p>
|
reserved.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</div>
|
||||||
);
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,26 @@
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
||||||
import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
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, useSearchParams } 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";
|
||||||
const { Header } = Layout;
|
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();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { searchValue, setSearchValue } = useMainContext();
|
const { searchValue, setSearchValue } = useMainContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
<div className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
||||||
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
|
<div className="w-full max-w-screen-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 space-x-8">
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
|
@ -24,32 +29,31 @@ export function MainHeader() {
|
||||||
</div>
|
</div>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-6">
|
</div>
|
||||||
<div className="group relative">
|
<div className=" flex justify-end gap-4 mr-2">
|
||||||
<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-72 rounded-full"
|
className="w-96 rounded-full"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
onPressEnter={(e) => {
|
onPressEnter={(e) => {
|
||||||
if (
|
if (
|
||||||
!window.location.pathname.startsWith(
|
!window.location.pathname.startsWith("/courses/") &&
|
||||||
"/courses/"
|
!window.location.pathname.startsWith("my")
|
||||||
)
|
) {
|
||||||
) {
|
navigate(`/courses/`);
|
||||||
navigate(`/courses/`);
|
window.scrollTo({
|
||||||
window.scrollTo({
|
top: 0,
|
||||||
top: 0,
|
behavior: "smooth",
|
||||||
behavior: "smooth",
|
});
|
||||||
});
|
}
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
@ -65,6 +69,15 @@ export function MainHeader() {
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/path/editor";
|
||||||
|
}}
|
||||||
|
icon={<PlusOutlined></PlusOutlined>}>
|
||||||
|
创建学习路径
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
) : (
|
) : (
|
||||||
|
@ -77,6 +90,6 @@ export function MainHeader() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,13 @@ const { Content } = Layout;
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
return (
|
return (
|
||||||
<MainProvider>
|
<MainProvider>
|
||||||
<Layout className="min-h-screen">
|
<div className=" min-h-screen bg-gray-100">
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
<Content className="mt-16 bg-gray-50">
|
<Content className="min-h-screen flex-grow pt-12 bg-gray-50 ">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</Content>
|
||||||
<MainFooter />
|
<MainFooter />
|
||||||
</Layout>
|
</div>
|
||||||
</MainProvider>
|
</MainProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import React, { createContext, ReactNode, useContext, useState } from "react";
|
import { Prisma } from "packages/common/dist";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
interface SelectedTerms {
|
interface SelectedTerms {
|
||||||
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
||||||
}
|
}
|
||||||
|
@ -8,6 +15,7 @@ interface MainContextType {
|
||||||
selectedTerms?: SelectedTerms;
|
selectedTerms?: SelectedTerms;
|
||||||
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
|
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
||||||
|
searchCondition?: Prisma.PostWhereInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MainContext = createContext<MainContextType | null>(null);
|
const MainContext = createContext<MainContextType | null>(null);
|
||||||
|
@ -18,6 +26,29 @@ interface MainProviderProps {
|
||||||
export function MainProvider({ children }: MainProviderProps) {
|
export function MainProvider({ children }: MainProviderProps) {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||||
|
|
||||||
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
|
contains: searchValue,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
|
};
|
||||||
|
return searchValue
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: containTextCondition },
|
||||||
|
{ subTitle: containTextCondition },
|
||||||
|
{ content: containTextCondition },
|
||||||
|
{
|
||||||
|
terms: {
|
||||||
|
some: {
|
||||||
|
name: containTextCondition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
}, [searchValue]);
|
||||||
return (
|
return (
|
||||||
<MainContext.Provider
|
<MainContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -25,6 +56,7 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
setSearchValue,
|
setSearchValue,
|
||||||
selectedTerms,
|
selectedTerms,
|
||||||
setSelectedTerms,
|
setSelectedTerms,
|
||||||
|
searchCondition,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MainContext.Provider>
|
</MainContext.Provider>
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { Menu } from "antd";
|
import { Menu } from "antd";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ key: "home", path: "/", label: "首页" },
|
|
||||||
{ key: "courses", path: "/courses", label: "全部课程" },
|
|
||||||
{ key: "paths", path: "/paths", label: "学习路径" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const NavigationMenu = () => {
|
export const NavigationMenu = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const baseItems = [
|
||||||
|
{ key: "home", path: "/", label: "首页" },
|
||||||
|
{ key: "courses", path: "/courses", label: "全部课程" },
|
||||||
|
{ key: "path", path: "/path", label: "学习路径" },
|
||||||
|
];
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return baseItems;
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
...baseItems,
|
||||||
|
{ key: "my-duty", path: "/my-duty", label: "我创建的" },
|
||||||
|
{ key: "my-learning", path: "/my-learning", label: "我学习的" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const selectedKey =
|
const selectedKey =
|
||||||
menuItems.find((item) => item.path === pathname)?.key || "";
|
menuItems.find((item) => item.path === pathname)?.key || "";
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -86,6 +86,20 @@ export function UserMenu() {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <UserOutlined className="text-lg" />,
|
||||||
|
label: "我创建的课程",
|
||||||
|
action: () => {
|
||||||
|
navigate("/my-duty");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UserOutlined className="text-lg" />,
|
||||||
|
label: "我学习的课程",
|
||||||
|
action: () => {
|
||||||
|
navigate("/my-learning");
|
||||||
|
},
|
||||||
|
},
|
||||||
canManageAnyStaff && {
|
canManageAnyStaff && {
|
||||||
icon: <SettingOutlined className="text-lg" />,
|
icon: <SettingOutlined className="text-lg" />,
|
||||||
label: "设置",
|
label: "设置",
|
||||||
|
@ -222,18 +236,20 @@ export function UserMenu() {
|
||||||
focus:ring-2 focus:ring-[#00538E]/20
|
focus:ring-2 focus:ring-[#00538E]/20
|
||||||
group relative overflow-hidden
|
group relative overflow-hidden
|
||||||
active:scale-[0.99]
|
active:scale-[0.99]
|
||||||
${item.label === "注销"
|
${
|
||||||
|
item.label === "注销"
|
||||||
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
||||||
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
||||||
}`}>
|
}`}>
|
||||||
<span
|
<span
|
||||||
className={`w-5 h-5 flex items-center justify-center
|
className={`w-5 h-5 flex items-center justify-center
|
||||||
transition-all duration-200 ease-in-out
|
transition-all duration-200 ease-in-out
|
||||||
group-hover:scale-110 group-hover:rotate-6
|
group-hover:scale-110 group-hover:rotate-6
|
||||||
group-hover:translate-x-0.5 ${item.label === "注销"
|
group-hover:translate-x-0.5 ${
|
||||||
? "group-hover:text-red-600"
|
item.label === "注销"
|
||||||
: "group-hover:text-[#003F6A]"
|
? "group-hover:text-red-600"
|
||||||
}`}>
|
: "group-hover:text-[#003F6A]"
|
||||||
|
}`}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
|
import CourseCard from "../courses/components/CourseCard";
|
||||||
|
|
||||||
|
export default function MyDutyPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { searchCondition } = useMainContext();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4">
|
||||||
|
<PostList
|
||||||
|
renderItem={(post) => (
|
||||||
|
<CourseCard edit course={post}></CourseCard>
|
||||||
|
)}
|
||||||
|
params={{
|
||||||
|
pageSize: 12,
|
||||||
|
where: {
|
||||||
|
authorId: user.id,
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
|
import CourseCard from "../courses/components/CourseCard";
|
||||||
|
|
||||||
|
export default function MyLearningPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { searchCondition } = useMainContext();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4">
|
||||||
|
<PostList
|
||||||
|
renderItem={(post) => (
|
||||||
|
<CourseCard edit={false} course={post}></CourseCard>
|
||||||
|
)}
|
||||||
|
params={{
|
||||||
|
pageSize: 12,
|
||||||
|
where: {
|
||||||
|
students: {
|
||||||
|
some: {
|
||||||
|
id: user?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...searchCondition,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Card, Rate, Tag, Typography, Button } from "antd";
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { PostDto, TaxonomySlug } from "@nice/common";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
interface pathCardProps {
|
||||||
|
path: PostDto;
|
||||||
|
}
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
export default function PathCard({ path }: pathCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleClick = (path: PostDto) => {
|
||||||
|
navigate(`/path/editor/${path.id}`);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth", })
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={() => handleClick(path)}
|
||||||
|
key={path.id}
|
||||||
|
hoverable
|
||||||
|
className="group overflow-hidden rounded-xl border border-gray-200 bg-white
|
||||||
|
shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||||
|
cover={
|
||||||
|
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${path?.meta?.thumbnail})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" /> */}
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{path?.terms?.map((term) => {
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Title
|
||||||
|
level={4}
|
||||||
|
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
||||||
|
<button> {path.title}</button>
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||||
|
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||||
|
<div className="ml-2 flex items-center flex-grow">
|
||||||
|
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||||
|
{path?.depts?.length > 1
|
||||||
|
? `${path.depts[0].name}等`
|
||||||
|
: path?.depts?.[0]?.name}
|
||||||
|
{/* {path?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
|
||||||
|
{/* {path?.depts?.map((dept)=>{return dept.name})} */}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-500">
|
||||||
|
{path?.meta?.views
|
||||||
|
? `观看次数 ${path?.meta?.views}`
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-gray-100 text-center">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||||
|
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||||
|
立即学习
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { useMainContext } from "../../layout/MainProvider";
|
||||||
|
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
||||||
|
|
||||||
|
export default function PathFilter() {
|
||||||
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
||||||
|
const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||||
|
const handleTermChange = (slug: string, selected: string[]) => {
|
||||||
|
setSelectedTerms({
|
||||||
|
...selectedTerms,
|
||||||
|
[slug]: selected, // 更新对应 slug 的选择
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6 h-full">
|
||||||
|
{taxonomies?.map((tax, index) => {
|
||||||
|
const items = Object.entries(selectedTerms).find(
|
||||||
|
([key, items]) => key === tax.slug
|
||||||
|
)?.[1];
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
<h3 className="text-lg font-medium mb-4">
|
||||||
|
{tax?.name}
|
||||||
|
</h3>
|
||||||
|
<TermParentSelector
|
||||||
|
value={items}
|
||||||
|
slug = {tax?.slug}
|
||||||
|
className="w-70 max-h-[500px] overscroll-contain overflow-x-hidden"
|
||||||
|
onChange={(selected) =>
|
||||||
|
handleTermChange(
|
||||||
|
tax?.slug,
|
||||||
|
selected as string[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
taxonomyId={tax?.id}
|
||||||
|
></TermParentSelector>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import PostList from "@web/src/components/models/course/list/PostList";
|
||||||
|
import { useMainContext } from "../../layout/MainProvider";
|
||||||
|
import { PostType, Prisma } from "@nice/common";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import PathCard from "./PathCard";
|
||||||
|
|
||||||
|
export function PathListContainer() {
|
||||||
|
const { searchValue, selectedTerms } = useMainContext();
|
||||||
|
const termFilters = useMemo(() => {
|
||||||
|
return Object.entries(selectedTerms)
|
||||||
|
.filter(([, terms]) => terms.length > 0)
|
||||||
|
.map(([, terms]) => terms);
|
||||||
|
}, [selectedTerms]);
|
||||||
|
const searchCondition: Prisma.StringNullableFilter = {
|
||||||
|
contains: searchValue,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PostList
|
||||||
|
renderItem={(post) => <PathCard path={post}></PathCard>}
|
||||||
|
params={{
|
||||||
|
pageSize: 12,
|
||||||
|
where: {
|
||||||
|
type: PostType.PATH,
|
||||||
|
AND: termFilters.map((termFilter) => ({
|
||||||
|
terms: {
|
||||||
|
some: {
|
||||||
|
id: {
|
||||||
|
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
OR: [
|
||||||
|
{ title: searchCondition },
|
||||||
|
{ subTitle: searchCondition },
|
||||||
|
{ content: searchCondition },
|
||||||
|
{
|
||||||
|
terms: {
|
||||||
|
some: {
|
||||||
|
name: searchCondition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols={4}></PostList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PathListContainer;
|
|
@ -0,0 +1,10 @@
|
||||||
|
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function PathEditorPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return <div className="p-2">
|
||||||
|
<MindEditor id={id}></MindEditor>
|
||||||
|
</div>
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import PathFilter from "../components/PathFilter";
|
||||||
|
import PathListContainer from "../components/PathListContainer";
|
||||||
|
|
||||||
|
export function PathListLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className=" flex">
|
||||||
|
<div className="w-1/6">
|
||||||
|
<PathFilter></PathFilter>
|
||||||
|
</div>
|
||||||
|
<div className="w-5/6 p-4">
|
||||||
|
<PathListContainer></PathListContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default PathListLayout;
|
|
@ -0,0 +1,5 @@
|
||||||
|
import PathListLayout from "./layout/PathListLayout";
|
||||||
|
|
||||||
|
export default function PathPage() {
|
||||||
|
return <PathListLayout />
|
||||||
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
|
||||||
|
|
||||||
export default function PathsPage() {
|
|
||||||
// return <MindEditor></MindEditor>;
|
|
||||||
return <>123</>
|
|
||||||
}
|
|
|
@ -1,27 +1,197 @@
|
||||||
import { MindElixirInstance } from "mind-elixir";
|
import { Button, Card, Empty, Form, Space, Spin, message, theme } from 'antd';
|
||||||
import { useRef, useEffect } from "react";
|
import NodeMenu from './NodeMenu';
|
||||||
import MindElixir from "mind-elixir";
|
import { useEntity, api, usePost } from '@nice/client';
|
||||||
|
import { ObjectType, postDetailSelect, PostDto, PostType, Prisma, Taxonomy } from '@nice/common';
|
||||||
|
import TermSelect from '../../models/term/term-select';
|
||||||
|
import DepartmentSelect from '../../models/department/department-select';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { MindElixirInstance } from 'mind-elixir';
|
||||||
|
import MindElixir from 'mind-elixir';
|
||||||
|
import { useTusUpload } from '@web/src/hooks/useTusUpload';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export default function MindEditor({ id }: { id?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||||
|
|
||||||
export default function MindEditor() {
|
const { data: post, isLoading }: { data: PostDto, isLoading: boolean } = api.post.findFirst.useQuery({
|
||||||
const me = useRef<MindElixirInstance>();
|
where: {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
select: postDetailSelect
|
||||||
|
})
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { create, update } = usePost();
|
||||||
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||||
|
type: ObjectType.COURSE,
|
||||||
|
});
|
||||||
|
const { handleFileUpload } = useTusUpload()
|
||||||
|
const [form] = Form.useForm()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const instance = new MindElixir({
|
if (post && form && instance && id) {
|
||||||
el: "#map",
|
console.log(post)
|
||||||
direction: MindElixir.SIDE,
|
instance.refresh((post as any).meta)
|
||||||
draggable: true, // default true
|
const deptIds = (post?.depts || [])?.map((dept) => dept.id);
|
||||||
contextMenu: true, // default true
|
const formData = {
|
||||||
toolBar: true, // default true
|
title: post.title,
|
||||||
nodeMenu: true, // default true
|
deptIds: deptIds,
|
||||||
keypress: true, // default true
|
};
|
||||||
locale: "zh_CN",
|
post.terms?.forEach((term) => {
|
||||||
|
formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name
|
||||||
|
});
|
||||||
|
form.setFieldsValue(formData);
|
||||||
|
}
|
||||||
|
}, [post, form, instance, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const mind = new MindElixir({
|
||||||
|
...MIND_OPTIONS,
|
||||||
|
el: containerRef.current,
|
||||||
});
|
});
|
||||||
// instance.install(NodeMenu);
|
mind.init(MindElixir.new('新学习路径'));
|
||||||
instance.init(MindElixir.new("新主题"));
|
containerRef.current.hidden = true;
|
||||||
me.current = instance;
|
setInstance(mind);
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if ((!id || post) && instance) {
|
||||||
|
containerRef.current.hidden = false
|
||||||
|
instance.toCenter()
|
||||||
|
instance.refresh((post as any)?.meta)
|
||||||
|
}
|
||||||
|
}, [id, post, instance])
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!instance) return;
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const imgBlob = await instance?.exportPng()
|
||||||
|
handleFileUpload(imgBlob, async (result) => {
|
||||||
|
const termIds = taxonomies.map((tax) => values[tax.id]).filter((id) => id);
|
||||||
|
const deptIds = (values?.deptIds || []) as string[];
|
||||||
|
const { theme, ...data } = instance.getData();
|
||||||
|
try {
|
||||||
|
if (post && id) {
|
||||||
|
const params: Prisma.PostUpdateArgs = {
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: data.nodeData.topic,
|
||||||
|
meta: { ...data, thumbnail: result.compressedUrl },
|
||||||
|
terms: {
|
||||||
|
set: termIds.map((id) => ({ id }))
|
||||||
|
},
|
||||||
|
depts: {
|
||||||
|
set: deptIds.map((id) => ({ id })),
|
||||||
|
},
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await update.mutateAsync(params);
|
||||||
|
toast.success('更新成功');
|
||||||
|
} else {
|
||||||
|
const params: Prisma.PostCreateInput = {
|
||||||
|
type: PostType.PATH,
|
||||||
|
title: data.nodeData.topic,
|
||||||
|
meta: { ...data, thumbnail: result.compressedUrl },
|
||||||
|
terms: {
|
||||||
|
connect: termIds.map((id) => ({ id }))
|
||||||
|
},
|
||||||
|
depts: {
|
||||||
|
connect: deptIds.map((id) => ({ id })),
|
||||||
|
},
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await create.mutateAsync({ data: params });
|
||||||
|
navigate(`/path/editor/${res.id}`, { replace: true })
|
||||||
|
toast.success('创建成功');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('保存失败');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(result)
|
||||||
|
}, (error) => { }, `mind-thumb-${new Date().toString()}`)
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className=' flex flex-col border rounded-lg overflow-hidden'>
|
||||||
<div id="map" style={{ width: "100%" }} />
|
{taxonomies && (
|
||||||
|
<Form 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 gap-4'>
|
||||||
|
{taxonomies.map((tax, index) => (
|
||||||
|
<Form.Item
|
||||||
|
key={tax.id}
|
||||||
|
name={tax.id}
|
||||||
|
rules={[{ required: false }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<TermSelect
|
||||||
|
className=' w-48'
|
||||||
|
placeholder={`请选择${tax.name}`}
|
||||||
|
taxonomyId={tax.id}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
))}
|
||||||
|
<Form.Item name="deptIds" noStyle>
|
||||||
|
<DepartmentSelect className='w-96' placeholder='请选择制作单位' multiple />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Button ghost type='primary' onClick={handleSave} >{id ? '更新' : '保存'}</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className='mind-editor'
|
||||||
|
/>
|
||||||
|
{instance && (<NodeMenu mind={instance} />
|
||||||
|
)}
|
||||||
|
{isLoading && <div className='py-64 justify-center flex' style={{ height: "calc(100vh - 287px)" }}>
|
||||||
|
<Spin size='large'></Spin>
|
||||||
|
</div>}
|
||||||
|
{!post && id && !isLoading && <div className='py-64' style={{ height: "calc(100vh - 287px)" }}>
|
||||||
|
<Empty></Empty>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,195 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Input, Button, ColorPicker, Select } from 'antd';
|
||||||
|
import {
|
||||||
|
FontSizeOutlined,
|
||||||
|
BoldOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { MindElixirInstance, NodeObj } from 'mind-elixir';
|
||||||
|
|
||||||
|
const xmindColorPresets = [
|
||||||
|
// 经典16色
|
||||||
|
'#FFFFFF', '#F5F5F5', // 白色系
|
||||||
|
'#2196F3', '#1976D2', // 蓝色系
|
||||||
|
'#4CAF50', '#388E3C', // 绿色系
|
||||||
|
'#FF9800', '#F57C00', // 橙色系
|
||||||
|
'#F44336', '#D32F2F', // 红色系
|
||||||
|
'#9C27B0', '#7B1FA2', // 紫色系
|
||||||
|
'#424242', '#757575', // 灰色系
|
||||||
|
'#FFEB3B', '#FBC02D' // 黄色系
|
||||||
|
];
|
||||||
|
|
||||||
|
interface NodeMenuProps {
|
||||||
|
mind: MindElixirInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedFontColor, setSelectedFontColor] = useState<string>('');
|
||||||
|
const [selectedBgColor, setSelectedBgColor] = useState<string>('');
|
||||||
|
const [selectedSize, setSelectedSize] = useState<string>('');
|
||||||
|
const [isBold, setIsBold] = useState(false);
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
const style = nodeObj.style || {};
|
||||||
|
setSelectedFontColor(style.color || '');
|
||||||
|
setSelectedBgColor(style.background || '');
|
||||||
|
|
||||||
|
setSelectedSize(style.fontSize || '24');
|
||||||
|
setIsBold(style.fontWeight === 'bold');
|
||||||
|
setUrl(nodeObj.hyperLink || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnselectNode = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
mind.bus.addListener('selectNode', handleSelectNode);
|
||||||
|
mind.bus.addListener('unselectNode', handleUnselectNode);
|
||||||
|
|
||||||
|
}, [mind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && mind.container) {
|
||||||
|
mind.container.appendChild(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [mind.container]);
|
||||||
|
|
||||||
|
const handleColorChange = (type: "font" | "background", color: string) => {
|
||||||
|
if (type === 'font') {
|
||||||
|
setSelectedFontColor(color);
|
||||||
|
} else {
|
||||||
|
setSelectedBgColor(color);
|
||||||
|
}
|
||||||
|
const patch = { style: {} as any };
|
||||||
|
if (type === 'font') {
|
||||||
|
patch.style.color = color;
|
||||||
|
} else {
|
||||||
|
patch.style.background = color;
|
||||||
|
}
|
||||||
|
mind.reshapeNode(mind.currentNode, patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSizeChange = (size: string) => {
|
||||||
|
setSelectedSize(size);
|
||||||
|
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBoldToggle = () => {
|
||||||
|
const fontWeight = isBold ? '' : 'bold';
|
||||||
|
setIsBold(!isBold);
|
||||||
|
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setUrl(value);
|
||||||
|
mind.reshapeNode(mind.currentNode, { hyperLink: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
{/* Font Size Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">文字样式</h3>
|
||||||
|
<div className="flex gap-3 items-center justify-between">
|
||||||
|
<Select
|
||||||
|
value={selectedSize}
|
||||||
|
onChange={handleSizeChange}
|
||||||
|
prefix={<FontSizeOutlined className='mr-2' />}
|
||||||
|
className="w-1/2"
|
||||||
|
options={[
|
||||||
|
{ value: '12', label: '12' },
|
||||||
|
{ value: '14', label: '14' },
|
||||||
|
{ value: '16', label: '16' },
|
||||||
|
{ value: '18', label: '18' },
|
||||||
|
{ value: '20', label: '20' },
|
||||||
|
{ value: '24', label: '24' },
|
||||||
|
{ value: '28', label: '28' },
|
||||||
|
{ value: '32', label: '32' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type={isBold ? "primary" : "default"}
|
||||||
|
onClick={handleBoldToggle}
|
||||||
|
className='w-1/2'
|
||||||
|
icon={<BoldOutlined />}
|
||||||
|
>
|
||||||
|
|
||||||
|
加粗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">颜色设置</h3>
|
||||||
|
|
||||||
|
{/* Font Color Picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500">文字颜色</h4>
|
||||||
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
{xmindColorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={`font-${color}`}
|
||||||
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedFontColor === color ? 'outline-blue-500' : 'outline-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
|
||||||
|
handleColorChange('font', color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background Color Picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500">背景颜色</h4>
|
||||||
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
{xmindColorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={`bg-${color}`}
|
||||||
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedBgColor === color ? 'outline-blue-500' : 'outline-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
handleColorChange('background', color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">关联链接</h3>
|
||||||
|
{/* URL Input */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
placeholder="例如:https://example.com"
|
||||||
|
value={url}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
addonBefore={<LinkOutlined />}
|
||||||
|
/>
|
||||||
|
{url && !/^https?:\/\/\S+$/.test(url) && (
|
||||||
|
<p className="text-xs text-red-500">请输入有效的URL地址</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NodeMenu;
|
|
@ -0,0 +1,152 @@
|
||||||
|
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
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { MindElixirInstance, MindElixirData } from 'mind-elixir';
|
||||||
|
import { PostType, ObjectType } from '@nice/common';
|
||||||
|
|
||||||
|
export interface MindEditorProps {
|
||||||
|
initialData?: MindElixirData;
|
||||||
|
onSave?: (data: MindElixirData) => Promise<void>;
|
||||||
|
taxonomyType?: ObjectType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MindEditorState {
|
||||||
|
instance: MindElixirInstance | null;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
|
@ -40,7 +40,6 @@ export const TusUploader = ({
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
const [uploadResults, setUploadResults] = useState<string[]>(value || []);
|
const [uploadResults, setUploadResults] = useState<string[]>(value || []);
|
||||||
|
|
||||||
const handleRemoveFile = useCallback(
|
const handleRemoveFile = useCallback(
|
||||||
(fileId: string) => {
|
(fileId: string) => {
|
||||||
setCompletedFiles((prev) =>
|
setCompletedFiles((prev) =>
|
||||||
|
|
|
@ -28,6 +28,7 @@ interface CourseDetailContextType {
|
||||||
isHeaderVisible: boolean; // 新增
|
isHeaderVisible: boolean; // 新增
|
||||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
userIsLearning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseFormProviderProps {
|
interface CourseFormProviderProps {
|
||||||
|
@ -39,31 +40,25 @@ export const CourseDetailContext =createContext<CourseDetailContextType | null>(
|
||||||
export function CourseDetailProvider({children,editId}: CourseFormProviderProps) {
|
export function CourseDetailProvider({children,editId}: CourseFormProviderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { read } = useVisitor();
|
const { read } = useVisitor();
|
||||||
const { user, hasSomePermissions } = useAuth();
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
const { lectureId } = useParams();
|
const { lectureId } = useParams();
|
||||||
|
|
||||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||||
(api.post as any).findFirst.useQuery(
|
(api.post as any).findFirst.useQuery(
|
||||||
{
|
{
|
||||||
where: { id: editId },
|
where: { id: editId },
|
||||||
// include: {
|
select: courseDetailSelect,
|
||||||
// // sections: { include: { lectures: true } },
|
|
||||||
// enrollments: true,
|
|
||||||
// terms:true
|
|
||||||
// },
|
|
||||||
|
|
||||||
select:courseDetailSelect
|
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(editId) }
|
{ enabled: Boolean(editId) }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const userIsLearning = useMemo(() => {
|
||||||
|
return (course?.studentIds || []).includes(user?.id);
|
||||||
|
}, [user, course, isLoading]);
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
//先判断登陆再判断是否是作者,三个条件满足一个就有编辑权限
|
const isAuthor = isAuthenticated && user?.id === course?.authorId;
|
||||||
const isAuthor = user?.id === course?.authorId;
|
|
||||||
const isDept = course?.depts
|
|
||||||
?.map((dept) => dept.id)
|
|
||||||
.includes(user?.deptId);
|
|
||||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||||
return isAuthor || isDept || isRoot;
|
return isAuthor || isRoot;
|
||||||
}, [user, course]);
|
}, [user, course]);
|
||||||
|
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
|
@ -107,6 +102,7 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps)
|
||||||
isHeaderVisible,
|
isHeaderVisible,
|
||||||
setIsHeaderVisible,
|
setIsHeaderVisible,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
userIsLearning,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</CourseDetailContext.Provider>
|
</CourseDetailContext.Provider>
|
||||||
|
|
|
@ -3,11 +3,13 @@ import React, { useContext, 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 {
|
import {
|
||||||
|
BookOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
EditTwoTone,
|
EditTwoTone,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
TeamOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
@ -22,14 +24,15 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
||||||
|
<div className="w-full px-5 my-2">
|
||||||
{isLoading || !course ? (
|
{isLoading || !course ? (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!selectedLectureId && (
|
{!selectedLectureId && course?.meta?.thumbnail && (
|
||||||
<>
|
<>
|
||||||
<div className="relative my-4 overflow-hidden flex justify-center items-center">
|
<div className="relative mb-4 overflow-hidden flex justify-center items-center">
|
||||||
<Image
|
<Image
|
||||||
src={course?.meta?.thumbnail}
|
src={course?.meta?.thumbnail}
|
||||||
preview={false}
|
preview={false}
|
||||||
|
@ -39,68 +42,38 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedLectureId(firstLectureId);
|
setSelectedLectureId(firstLectureId);
|
||||||
}}
|
}}
|
||||||
className="w-full h-full absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer">
|
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">
|
||||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10" />
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10">
|
||||||
|
点击进入学习
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex flex-col gap-2">
|
||||||
<div>{course?.subTitle}</div>
|
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||||
{
|
{course?.subTitle && <div>{course?.subTitle}</div>}
|
||||||
course.terms.map((term) => {
|
{course.terms.map((term) => {
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
key={term.id}
|
key={term.id}
|
||||||
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
// color={term.taxonomy.slug===TaxonomySlug.CATEGORY? "blue" : "green"}
|
||||||
color={
|
color={
|
||||||
term?.taxonomy?.slug ===
|
term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.CATEGORY
|
TaxonomySlug.CATEGORY
|
||||||
? "blue"
|
? "blue"
|
||||||
: term?.taxonomy?.slug ===
|
: term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.LEVEL
|
TaxonomySlug.LEVEL
|
||||||
? "green"
|
? "green"
|
||||||
: "orange"
|
: "orange"
|
||||||
}
|
}
|
||||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
|
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
|
||||||
{term.name}
|
{term.name}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
);
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-800 flex justify-start gap-5">
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<CalendarOutlined></CalendarOutlined>
|
|
||||||
{dayjs(course?.createdAt).format("YYYY年M月D日")}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
|
||||||
<ReloadOutlined></ReloadOutlined>
|
|
||||||
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<EyeOutlined></EyeOutlined>
|
|
||||||
<div>{course?.meta?.views || 0}</div>
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
canEdit && (
|
|
||||||
<div
|
|
||||||
className="flex gap-1 text-primary hover:cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const url = id
|
|
||||||
? `/course/${id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditTwoTone></EditTwoTone>
|
|
||||||
{"点击编辑课程"}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
className="text-gray-600"
|
className="text-gray-600"
|
||||||
|
|
|
@ -9,6 +9,16 @@ import CollapsibleContent from "@web/src/components/common/container/Collapsible
|
||||||
import { Skeleton } from "antd";
|
import { Skeleton } from "antd";
|
||||||
import { CoursePreview } from "./CoursePreview/CoursePreview";
|
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 CourseDetailTitle from "./CourseDetailTitle";
|
||||||
|
|
||||||
// interface CourseDetailDisplayAreaProps {
|
// interface CourseDetailDisplayAreaProps {
|
||||||
// // course: Course;
|
// // course: Course;
|
||||||
|
@ -19,8 +29,15 @@ import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC = () => {
|
export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
const {
|
||||||
useContext(CourseDetailContext);
|
course,
|
||||||
|
isLoading,
|
||||||
|
canEdit,
|
||||||
|
lecture,
|
||||||
|
lectureIsLoading,
|
||||||
|
selectedLectureId,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
const { scrollY } = useScroll();
|
const { scrollY } = useScroll();
|
||||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||||
return (
|
return (
|
||||||
|
@ -29,7 +46,7 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
{lectureIsLoading && (
|
{lectureIsLoading && (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
<Skeleton active paragraph={{ rows: 4 }} title={false} />
|
||||||
)}
|
)}
|
||||||
|
<CourseDetailTitle></CourseDetailTitle>
|
||||||
{selectedLectureId &&
|
{selectedLectureId &&
|
||||||
!lectureIsLoading &&
|
!lectureIsLoading &&
|
||||||
lecture?.meta?.type === LectureType.VIDEO && (
|
lecture?.meta?.type === LectureType.VIDEO && (
|
||||||
|
@ -63,7 +80,7 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
|
||||||
<CourseDetailDescription />
|
<CourseDetailDescription />
|
||||||
</div>
|
</div>
|
||||||
{/* 课程内容区域 */}
|
{/* 课程内容区域 */}
|
||||||
|
|
|
@ -10,49 +10,75 @@ import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
|
import { usePost, useStaff } from "@nice/client";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { NavigationMenu } from "@web/src/app/main/layout/NavigationMenu";
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
export function CourseDetailHeader() {
|
export function CourseDetailHeader() {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
|
||||||
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 } = useContext(CourseDetailContext);
|
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
|
||||||
|
const { update } = useStaff();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
||||||
<div className="w-full flex items-center justify-between h-full">
|
<div className="w-full max-w-screen-3xl px-4 md:px-6 flex items-center justify-between h-full">
|
||||||
<div className="flex items-center space-x-10">
|
<div className="flex items-center space-x-8">
|
||||||
<HomeOutlined
|
<div
|
||||||
onClick={() => {
|
onClick={() => navigate("/")}
|
||||||
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 text-primary-500 hover:scale-105 cursor-pointer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="text-2xl tracking-widest font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent transition-transform ">
|
|
||||||
{course?.title}
|
|
||||||
</div>
|
</div>
|
||||||
{/* <NavigationMenu /> */}
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!userIsLearning) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
where: { id: user?.id },
|
||||||
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
connect: { id: course.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await update.mutateAsync({
|
||||||
|
where: { id: user?.id },
|
||||||
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
disconnect: {
|
||||||
|
id: course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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={<EditFilled />}>
|
||||||
|
{userIsLearning ? "退出学习" : "加入学习"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
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"
|
||||||
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={<EditFilled />}>
|
||||||
icon={<EditFilled />}>
|
{"编辑课程"}
|
||||||
{"编辑课程"}
|
</Button>
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
|
|
|
@ -1,77 +0,0 @@
|
||||||
// // components/Header.tsx
|
|
||||||
// import { motion, useScroll, useTransform } from "framer-motion";
|
|
||||||
// import { useContext, useEffect, useState } from "react";
|
|
||||||
// import { CourseDetailContext } from "../CourseDetailContext";
|
|
||||||
// import { Avatar, Button, Dropdown } from "antd";
|
|
||||||
// import { UserOutlined } from "@ant-design/icons";
|
|
||||||
// import { UserMenu } from "@web/src/app/main/layout/UserMenu";
|
|
||||||
// import { useAuth } from "@web/src/providers/auth-provider";
|
|
||||||
|
|
||||||
// export const CourseDetailHeader = () => {
|
|
||||||
// const { scrollY } = useScroll();
|
|
||||||
// const { user, isAuthenticated } = useAuth();
|
|
||||||
// const [lastScrollY, setLastScrollY] = useState(0);
|
|
||||||
// const { course, isHeaderVisible, setIsHeaderVisible, lecture } =
|
|
||||||
// useContext(CourseDetailContext);
|
|
||||||
// useEffect(() => {
|
|
||||||
// const updateHeader = () => {
|
|
||||||
// const current = scrollY.get();
|
|
||||||
// const direction = current > lastScrollY ? "down" : "up";
|
|
||||||
|
|
||||||
// if (direction === "down" && current > 100) {
|
|
||||||
// setIsHeaderVisible(false);
|
|
||||||
// } else if (direction === "up") {
|
|
||||||
// setIsHeaderVisible(true);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// setLastScrollY(current);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // 使用 requestAnimationFrame 来优化性能
|
|
||||||
// const unsubscribe = scrollY.on("change", () => {
|
|
||||||
// requestAnimationFrame(updateHeader);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return () => {
|
|
||||||
// unsubscribe();
|
|
||||||
// };
|
|
||||||
// }, [lastScrollY, scrollY, setIsHeaderVisible]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <motion.header
|
|
||||||
// initial={{ y: 0 }}
|
|
||||||
// animate={{ y: isHeaderVisible ? 0 : -100 }}
|
|
||||||
// transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
||||||
// className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
|
||||||
// <div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
|
||||||
// <div className="flex items-center space-x-4">
|
|
||||||
// <h1 className="text-white text-xl ">{course?.title}</h1>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {isAuthenticated ? (
|
|
||||||
// <Dropdown
|
|
||||||
// overlay={<UserMenu />}
|
|
||||||
// trigger={["click"]}
|
|
||||||
// placement="bottomRight">
|
|
||||||
// <Avatar
|
|
||||||
// size="large"
|
|
||||||
// className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
|
||||||
// {(user?.showname ||
|
|
||||||
// user?.username ||
|
|
||||||
// "")[0]?.toUpperCase()}
|
|
||||||
// </Avatar>
|
|
||||||
// </Dropdown>
|
|
||||||
// ) : (
|
|
||||||
// <Button
|
|
||||||
// onClick={() => navigator("/login")}
|
|
||||||
// className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
// icon={<UserOutlined />}>
|
|
||||||
// 登录
|
|
||||||
// </Button>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </motion.header>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// export default CourseDetailHeader;
|
|
|
@ -19,12 +19,12 @@ export default function CourseDetailLayout() {
|
||||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CourseDetailHeader />
|
{/* <CourseDetailHeader /> */}
|
||||||
|
|
||||||
{/* 添加 Header 组件 */}
|
{/* 添加 Header 组件 */}
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
||||||
<div className="pt-16">
|
<div className="pt-12 px-32">
|
||||||
{" "}
|
{" "}
|
||||||
{/* 添加这个包装 div */}
|
{/* 添加这个包装 div */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
EditTwoTone,
|
||||||
|
EyeOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import CourseOperationBtns from "./JoinLearingButton";
|
||||||
|
|
||||||
|
export default function CourseDetailTitle() {
|
||||||
|
const {
|
||||||
|
course,
|
||||||
|
isLoading,
|
||||||
|
canEdit,
|
||||||
|
lecture,
|
||||||
|
lectureIsLoading,
|
||||||
|
selectedLectureId,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{course?.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 flex w-full justify-start items-center gap-5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<CalendarOutlined></CalendarOutlined>
|
||||||
|
{"创建于:"}
|
||||||
|
{dayjs(course?.createdAt).format("YYYY年M月D日")}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ReloadOutlined spin></ReloadOutlined>
|
||||||
|
{"更新于:"}
|
||||||
|
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<EyeOutlined></EyeOutlined>
|
||||||
|
<div>{`观看次数${course?.meta?.views || 0}`}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<BookOutlined />
|
||||||
|
<div>{`学习人数${course?.studentIds?.length || 0}`}</div>
|
||||||
|
</div>
|
||||||
|
<CourseOperationBtns />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
0
apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx
Normal file → Executable file
0
apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx
Normal file → Executable file
|
@ -45,7 +45,6 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
block: "start",
|
block: "start",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 收起按钮直接显示 */}
|
{/* 收起按钮直接显示 */}
|
||||||
|
@ -59,9 +58,9 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: isOpen ? "25%" : "0",
|
width: isOpen ? "25%" : "0",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: isHeaderVisible ? "64px" : "0",
|
top: isHeaderVisible ? "56px" : "0",
|
||||||
}}
|
}}
|
||||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
className="fixed top-0 bottom-0 z-10 bg-white shadow-xl">
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<SyllabusHeader onToggle={onToggle} />
|
<SyllabusHeader onToggle={onToggle} />
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Lecture, LectureType, LessonTypeLabel } from "@nice/common";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
} from "@ant-design/icons"; // 使用 Ant Design 图标
|
||||||
|
@ -43,13 +44,17 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
||||||
<span>{LessonTypeLabel[lecture?.meta?.type]}</span>
|
<span>{LessonTypeLabel[lecture?.meta?.type]}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow flex justify-between items-center w-2/3 realative">
|
||||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
<h4 className="font-medium text-gray-800 w-4/5">{lecture.title}</h4>
|
||||||
{lecture.subTitle && (
|
{lecture.subTitle && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<span className="text-sm text-gray-500 mt-1 w-4/5">
|
||||||
{lecture.subTitle}
|
{lecture.subTitle}
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="text-gray-500 whitespace-normal">
|
||||||
|
<EyeOutlined></EyeOutlined>
|
||||||
|
<span className="ml-2">{lecture?.meta?.views ? lecture?.meta?.views : 0}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
|
import { useStaff } from "@nice/client";
|
||||||
|
import {
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleFilled,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EditFilled,
|
||||||
|
EditTwoTone,
|
||||||
|
LoginOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
export default function CourseOperationBtns() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
||||||
|
useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
|
||||||
|
const { update } = useStaff();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const toggleLearning = async () => {
|
||||||
|
if (!userIsLearning) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
where: { id: user?.id },
|
||||||
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
connect: { id: course.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await update.mutateAsync({
|
||||||
|
where: { id: user?.id },
|
||||||
|
data: {
|
||||||
|
learningPosts: {
|
||||||
|
disconnect: {
|
||||||
|
id: course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div
|
||||||
|
onClick={toggleLearning}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${
|
||||||
|
userIsLearning
|
||||||
|
? isHovered
|
||||||
|
? "text-red-500 border-red-500 rounded-md "
|
||||||
|
: "text-green-500 "
|
||||||
|
: "text-primary "
|
||||||
|
}`}>
|
||||||
|
{userIsLearning ? (
|
||||||
|
isHovered ? (
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
) : (
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<LoginOutlined />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{userIsLearning
|
||||||
|
? isHovered
|
||||||
|
? "退出学习"
|
||||||
|
: "正在学习"
|
||||||
|
: "加入学习"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div
|
||||||
|
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const url = course?.id
|
||||||
|
? `/course/${course?.id}/editor`
|
||||||
|
: "/course/editor";
|
||||||
|
navigate(url);
|
||||||
|
}}>
|
||||||
|
<EditTwoTone></EditTwoTone>
|
||||||
|
{"编辑课程"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -99,10 +99,10 @@ export function CourseFormProvider({
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
terms: {
|
terms: {
|
||||||
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
set: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||||
},
|
},
|
||||||
depts: {
|
depts: {
|
||||||
connect: deptIds.map((id) => ({ id })),
|
set: deptIds.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// 删除原始的 taxonomy 字段
|
// 删除原始的 taxonomy 字段
|
||||||
|
|
|
@ -32,7 +32,7 @@ export function CourseBasicForm() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="subTitle"
|
name="subTitle"
|
||||||
label="课程副标题"
|
label="课程副标题"
|
||||||
rules={[{ max: 10, message: "副标题最多10个字符" }]}>
|
rules={[{ max: 20, message: "副标题最多20个字符" }]}>
|
||||||
<Input placeholder="请输入课程副标题" />
|
<Input placeholder="请输入课程副标题" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name={["meta", "thumbnail"]} label="课程封面">
|
<Form.Item name={["meta", "thumbnail"]} label="课程封面">
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { Pagination, Empty, Skeleton } from "antd";
|
import { Pagination, Empty, Skeleton } from "antd";
|
||||||
import CourseCard from "../../../../app/main/courses/components/CourseCard";
|
|
||||||
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common";
|
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
interface CourseListProps {
|
interface PostListProps {
|
||||||
params?: {
|
params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
@ -13,21 +12,25 @@ interface CourseListProps {
|
||||||
};
|
};
|
||||||
cols?: number;
|
cols?: number;
|
||||||
showPagination?: boolean;
|
showPagination?: boolean;
|
||||||
|
renderItem: (post: any) => React.ReactNode
|
||||||
|
|
||||||
}
|
}
|
||||||
interface CoursesPagnationProps {
|
interface PostPagnationProps {
|
||||||
data: {
|
data: {
|
||||||
items: CourseDto[];
|
items: CourseDto[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
export default function CourseList({
|
export default function PostList({
|
||||||
params,
|
params,
|
||||||
cols = 3,
|
cols = 3,
|
||||||
showPagination = true,
|
showPagination = true,
|
||||||
}: CourseListProps) {
|
renderItem
|
||||||
|
}: PostListProps) {
|
||||||
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
|
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
|
||||||
const { data, isLoading }: CoursesPagnationProps =
|
const { data, isLoading }: PostPagnationProps =
|
||||||
api.post.findManyWithPagination.useQuery({
|
api.post.findManyWithPagination.useQuery({
|
||||||
select: courseDetailSelect,
|
select: courseDetailSelect,
|
||||||
...params,
|
...params,
|
||||||
|
@ -40,7 +43,7 @@ export default function CourseList({
|
||||||
return 1;
|
return 1;
|
||||||
}, [data, isLoading]);
|
}, [data, isLoading]);
|
||||||
|
|
||||||
const courses = useMemo(() => {
|
const posts = useMemo(() => {
|
||||||
if (data && !isLoading) {
|
if (data && !isLoading) {
|
||||||
return data?.items;
|
return data?.items;
|
||||||
}
|
}
|
||||||
|
@ -55,19 +58,23 @@ export default function CourseList({
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton paragraph={{ rows: 10 }}></Skeleton>;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton paragraph={{ rows: 10 }}></Skeleton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{courses.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className={`grid lg:grid-cols-${cols} gap-6`}>
|
<div className={`grid lg:grid-cols-${cols} gap-6`}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
|
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
|
||||||
) : (
|
) : (
|
||||||
courses.map((course) => (
|
posts.map((post) => <div key={post.id}>
|
||||||
<CourseCard key={course.id} course={course} />
|
{renderItem(post)}
|
||||||
))
|
</div>)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showPagination && (
|
{showPagination && (
|
||||||
|
@ -83,7 +90,10 @@ export default function CourseList({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="暂无相关课程" />
|
<div className="py-64">
|
||||||
|
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -192,7 +192,7 @@ export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||||
}, [src, onError, autoPlay]);
|
}, [src, onError, autoPlay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full aspect-video">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
|
|
@ -55,7 +55,6 @@ export function VideoPlayer({
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
onError,
|
onError,
|
||||||
|
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
|
|
@ -18,7 +18,7 @@ export default function VideoPlayerLayout() {
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`relative w-full bg-black rounded-lg overflow-hidden`}
|
className={`relative w-full bg-black rounded-lg overflow-hidden`}
|
||||||
style={{ aspectRatio: "21/9" }}
|
style={{ aspectRatio: "16/9" }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setIsHovering(true);
|
setIsHovering(true);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
|
|
|
@ -34,7 +34,7 @@ export function useTusUpload() {
|
||||||
return resUrl;
|
return resUrl;
|
||||||
};
|
};
|
||||||
const handleFileUpload = async (
|
const handleFileUpload = async (
|
||||||
file: File,
|
file: File | Blob,
|
||||||
onSuccess: (result: UploadResult) => void,
|
onSuccess: (result: UploadResult) => void,
|
||||||
onError: (error: Error) => void,
|
onError: (error: Error) => void,
|
||||||
fileKey: string // 添加文件唯一标识
|
fileKey: string // 添加文件唯一标识
|
||||||
|
@ -45,14 +45,24 @@ export function useTusUpload() {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 如果是Blob,需要转换为File
|
||||||
|
let fileName = "uploaded-file";
|
||||||
|
if (file instanceof Blob && !(file instanceof File)) {
|
||||||
|
// 根据MIME类型设置文件扩展名
|
||||||
|
const extension = file.type.split('/')[1];
|
||||||
|
fileName = `uploaded-file.${extension}`;
|
||||||
|
}
|
||||||
|
const uploadFile = file instanceof Blob && !(file instanceof File)
|
||||||
|
? new File([file], fileName, { type: file.type })
|
||||||
|
: file as File;
|
||||||
console.log(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`);
|
console.log(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`);
|
||||||
const upload = new tus.Upload(file, {
|
const upload = new tus.Upload(uploadFile, {
|
||||||
endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`,
|
endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`,
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
retryDelays: [0, 1000, 3000, 5000],
|
||||||
metadata: {
|
metadata: {
|
||||||
filename: file.name,
|
filename: uploadFile.name,
|
||||||
filetype: file.type,
|
filetype: uploadFile.type,
|
||||||
size: file.size as any,
|
size: uploadFile.size as any,
|
||||||
},
|
},
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
const progress = Number(
|
const progress = Number(
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
||||||
content: "标题 1";
|
content: "标题 1";
|
||||||
|
@ -23,6 +24,7 @@
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
||||||
content: "标题 2";
|
content: "标题 2";
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
||||||
content: "标题 3";
|
content: "标题 3";
|
||||||
|
@ -32,6 +34,7 @@
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
||||||
content: "标题 4";
|
content: "标题 4";
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
||||||
content: "标题 5";
|
content: "标题 5";
|
||||||
|
@ -41,11 +44,13 @@
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
||||||
content: "标题 6";
|
content: "标题 6";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对下拉菜单中的选项 */
|
/* 针对下拉菜单中的选项 */
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-item:not([data-value])::before,
|
.ql-snow .ql-picker.ql-header .ql-picker-item:not([data-value])::before,
|
||||||
.ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before {
|
.ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before {
|
||||||
content: "正文" !important;
|
content: "正文" !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ag-custom-dragging-class {
|
.ag-custom-dragging-class {
|
||||||
@apply border-b-2 border-blue-200;
|
@apply border-b-2 border-blue-200;
|
||||||
}
|
}
|
||||||
|
@ -76,11 +81,11 @@
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead > tr > th {
|
.ant-table-thead>tr>th {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr > td {
|
.ant-table-tbody>tr>td {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-bottom-color: transparent !important;
|
border-bottom-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
@ -117,9 +122,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
||||||
.ant-radio-button-wrapper-checked:not(
|
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before {
|
||||||
.ant-radio-button-wrapper-disabled
|
|
||||||
)::before {
|
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,7 +135,7 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-wrap-header .ant-table-thead > tr > th {
|
.no-wrap-header .ant-table-thead>tr>th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,17 +151,17 @@
|
||||||
/* 设置单元格边框 */
|
/* 设置单元格边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody > tr > td {
|
.custom-table .ant-table-tbody>tr>td {
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
/* 设置表格行底部边框 */
|
/* 设置表格行底部边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody > tr:last-child > td {
|
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
#map {
|
.mind-editor {
|
||||||
height: 600px;
|
height: calc(100vh - 285px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
|
@ -15,9 +15,13 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon
|
||||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||||
import CoursesPage from "../app/main/courses/page";
|
import CoursesPage from "../app/main/courses/page";
|
||||||
import PathsPage from "../app/main/paths/page";
|
import PathPage from "../app/main/path/page";
|
||||||
import { adminRoute } from "./admin-route";
|
import { adminRoute } from "./admin-route";
|
||||||
|
import PathEditorPage from "../app/main/path/editor/page";
|
||||||
|
|
||||||
import { CoursePreview } from "../app/main/course/preview/page";
|
import { CoursePreview } from "../app/main/course/preview/page";
|
||||||
|
import MyLearningPage from "../app/main/my-learning/page";
|
||||||
|
import MyDutyPage from "../app/main/my-duty/page";
|
||||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
|
@ -56,22 +60,52 @@ export const routes: CustomRouteObject[] = [
|
||||||
element: <HomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "paths",
|
path: "path",
|
||||||
element: <PathsPage></PathsPage>,
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <PathPage></PathPage>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "editor/:id?",
|
||||||
|
element: <PathEditorPage></PathEditorPage>
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "courses",
|
path: "courses",
|
||||||
element: <CoursesPage></CoursesPage>,
|
element: <CoursesPage></CoursesPage>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "my-duty",
|
||||||
|
element: (
|
||||||
|
<WithAuth>
|
||||||
|
<MyDutyPage></MyDutyPage>
|
||||||
|
</WithAuth>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "my-learning",
|
||||||
|
element: (
|
||||||
|
<WithAuth>
|
||||||
|
<MyLearningPage></MyLearningPage>
|
||||||
|
</WithAuth>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
||||||
|
element: <CourseDetailPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "course",
|
path: "course",
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: ":id?/editor",
|
path: ":id?/editor",
|
||||||
element: (
|
element: (
|
||||||
<WithAuth >
|
<WithAuth>
|
||||||
<CourseEditorLayout></CourseEditorLayout>
|
<CourseEditorLayout></CourseEditorLayout>
|
||||||
</WithAuth>
|
</WithAuth>
|
||||||
),
|
),
|
||||||
|
@ -95,10 +129,6 @@ export const routes: CustomRouteObject[] = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: ":id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
|
||||||
element: <CourseDetailPage />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
adminRoute,
|
adminRoute,
|
||||||
|
|
|
@ -100,7 +100,7 @@ server {
|
||||||
# 仅供内部使用
|
# 仅供内部使用
|
||||||
internal;
|
internal;
|
||||||
# 代理到认证服务
|
# 代理到认证服务
|
||||||
proxy_pass http://host.docker.internal:/auth/file;
|
proxy_pass http://host.docker.internal:3000/auth/file;
|
||||||
|
|
||||||
# 请求优化:不传递请求体
|
# 请求优化:不传递请求体
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"dev": "pnpm run --parallel dev",
|
"dev": "pnpm run --parallel dev",
|
||||||
"db:clear": "pnpm --filter common run db:clear"
|
"db:clear": "pnpm --filter common run db:clear",
|
||||||
|
"studio": "pnpm --filter common run studio"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "insiinc",
|
"author": "insiinc",
|
||||||
|
|
|
@ -9,4 +9,4 @@ export * from "./useTaxonomy"
|
||||||
export * from "./useVisitor"
|
export * from "./useVisitor"
|
||||||
export * from "./useMessage"
|
export * from "./useMessage"
|
||||||
export * from "./usePost"
|
export * from "./usePost"
|
||||||
// export * from "./useCourse"
|
export * from "./useEntity"
|
||||||
|
|
|
@ -1,12 +1,5 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { getQueryKey } from "@trpc/react-query";
|
|
||||||
import { MutationResult, useEntity } from "./useEntity";
|
|
||||||
import { ObjectType } from "@nice/common";
|
|
||||||
import { api } from "../trpc";
|
|
||||||
import { CrudOperation, emitDataChange } from "../../event";
|
|
||||||
export function usePost() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const queryKey = getQueryKey(api.post);
|
|
||||||
|
|
||||||
|
import { MutationResult, useEntity } from "./useEntity";
|
||||||
|
export function usePost() {
|
||||||
return useEntity("post");
|
return useEntity("post");
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,39 +5,48 @@ import { ObjectType, Staff } from "@nice/common";
|
||||||
import { findQueryData } from "../utils";
|
import { findQueryData } from "../utils";
|
||||||
import { CrudOperation, emitDataChange } from "../../event";
|
import { CrudOperation, emitDataChange } from "../../event";
|
||||||
export function useStaff() {
|
export function useStaff() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const queryKey = getQueryKey(api.staff);
|
const queryKey = getQueryKey(api.staff);
|
||||||
|
|
||||||
const create = api.staff.create.useMutation({
|
const create = api.staff.create.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
emitDataChange(ObjectType.STAFF, result as any, CrudOperation.CREATED)
|
emitDataChange(
|
||||||
},
|
ObjectType.STAFF,
|
||||||
});
|
result as any,
|
||||||
const updateUserDomain = api.staff.updateUserDomain.useMutation({
|
CrudOperation.CREATED
|
||||||
onSuccess: async (result) => {
|
);
|
||||||
queryClient.invalidateQueries({ queryKey });
|
},
|
||||||
},
|
});
|
||||||
});
|
const updateUserDomain = api.staff.updateUserDomain.useMutation({
|
||||||
const update = api.staff.update.useMutation({
|
onSuccess: async (result) => {
|
||||||
onSuccess: (result) => {
|
queryClient.invalidateQueries({ queryKey });
|
||||||
queryClient.invalidateQueries({ queryKey });
|
},
|
||||||
emitDataChange(ObjectType.STAFF, result as any, CrudOperation.UPDATED)
|
});
|
||||||
},
|
const update = api.staff.update.useMutation({
|
||||||
});
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
|
||||||
|
emitDataChange(
|
||||||
|
ObjectType.STAFF,
|
||||||
|
result as any,
|
||||||
|
CrudOperation.UPDATED
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
const softDeleteByIds = api.staff.softDeleteByIds.useMutation({
|
const softDeleteByIds = api.staff.softDeleteByIds.useMutation({
|
||||||
onSuccess: (result, variables) => {
|
onSuccess: (result, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const getStaff = (key: string) => {
|
const getStaff = (key: string) => {
|
||||||
return findQueryData<Staff>(queryClient, api.staff, key);
|
return findQueryData<Staff>(queryClient, api.staff, key);
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
softDeleteByIds,
|
softDeleteByIds,
|
||||||
getStaff,
|
getStaff,
|
||||||
updateUserDomain
|
updateUserDomain,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,9 +88,12 @@ model Staff {
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
officerId String? @map("officer_id")
|
officerId String? @map("officer_id")
|
||||||
|
|
||||||
watchedPost Post[] @relation("post_watch_staff")
|
// watchedPost Post[] @relation("post_watch_staff")
|
||||||
visits Visit[]
|
visits Visit[]
|
||||||
posts Post[]
|
posts Post[]
|
||||||
|
|
||||||
|
|
||||||
|
learningPosts Post[] @relation("post_student")
|
||||||
sentMsgs Message[] @relation("message_sender")
|
sentMsgs Message[] @relation("message_sender")
|
||||||
receivedMsgs Message[] @relation("message_receiver")
|
receivedMsgs Message[] @relation("message_receiver")
|
||||||
registerToken String?
|
registerToken String?
|
||||||
|
@ -124,7 +127,7 @@ model Department {
|
||||||
deptStaffs Staff[] @relation("DeptStaff")
|
deptStaffs Staff[] @relation("DeptStaff")
|
||||||
terms Term[] @relation("department_term")
|
terms Term[] @relation("department_term")
|
||||||
|
|
||||||
watchedPost Post[] @relation("post_watch_dept")
|
// watchedPost Post[] @relation("post_watch_dept")
|
||||||
hasChildren Boolean? @default(false) @map("has_children")
|
hasChildren Boolean? @default(false) @map("has_children")
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
|
@ -201,7 +204,7 @@ model Post {
|
||||||
order Float? @default(0) @map("order")
|
order Float? @default(0) @map("order")
|
||||||
duration Int?
|
duration Int?
|
||||||
rating Int? @default(0)
|
rating Int? @default(0)
|
||||||
|
students Staff[] @relation("post_student")
|
||||||
depts Department[] @relation("post_dept")
|
depts Department[] @relation("post_dept")
|
||||||
// 索引
|
// 索引
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
|
@ -223,8 +226,8 @@ 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") // 可观看的员工列表,关联 Staff 模型
|
// watchableStaffs Staff[] @relation("post_watch_staff")
|
||||||
watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型
|
// watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型
|
||||||
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
||||||
|
|
||||||
// 索引
|
// 索引
|
||||||
|
@ -288,14 +291,6 @@ model Visit {
|
||||||
message Message? @relation(fields: [messageId], references: [id])
|
message Message? @relation(fields: [messageId], references: [id])
|
||||||
messageId String? @map("message_id")
|
messageId String? @map("message_id")
|
||||||
lectureId String? @map("lecture_id") // 课时ID
|
lectureId String? @map("lecture_id") // 课时ID
|
||||||
|
|
||||||
// 学习数据
|
|
||||||
// progress Float? @default(0) @map("progress") // 完成进度(0-100%)
|
|
||||||
// isCompleted Boolean? @default(false) @map("is_completed") // 是否完成
|
|
||||||
// lastPosition Int? @default(0) @map("last_position") // 视频播放位置(秒)
|
|
||||||
// totalWatchTime Int? @default(0) @map("total_watch_time") // 总观看时长(秒)
|
|
||||||
// // 时间记录
|
|
||||||
// lastWatchedAt DateTime? @map("last_watched_at") // 最后观看时间
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") // 创建时间
|
createdAt DateTime @default(now()) @map("created_at") // 创建时间
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
|
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
|
||||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { StaffDto } from "./staff";
|
import { StaffDto } from "./staff";
|
||||||
import { TermDto } from "./term";
|
import { TermDto } from "./term";
|
||||||
import { ResourceDto } from "./resource";
|
import { ResourceDto } from "./resource";
|
||||||
|
import { DepartmentDto } from "./department";
|
||||||
|
|
||||||
export type PostComment = {
|
export type PostComment = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -41,6 +42,12 @@ export type PostDto = Post & {
|
||||||
};
|
};
|
||||||
watchableDepts: Department[];
|
watchableDepts: Department[];
|
||||||
watchableStaffs: Staff[];
|
watchableStaffs: Staff[];
|
||||||
|
terms: TermDto[]
|
||||||
|
depts: DepartmentDto[]
|
||||||
|
meta?: {
|
||||||
|
thumbnail?: string
|
||||||
|
views?: number
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LectureMeta = {
|
export type LectureMeta = {
|
||||||
|
@ -83,4 +90,5 @@ export type CourseDto = Course & {
|
||||||
terms: TermDto[];
|
terms: TermDto[];
|
||||||
lectureCount?: number;
|
lectureCount?: number;
|
||||||
depts: Department[];
|
depts: Department[];
|
||||||
|
studentIds: string[];
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,9 +6,23 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
resources: true,
|
resources: true,
|
||||||
watchableDepts: true,
|
// watchableDepts: true,
|
||||||
watchableStaffs: true,
|
// watchableStaffs: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
terms: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
taxonomyId: true,
|
||||||
|
taxonomy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
depts: true,
|
||||||
author: {
|
author: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -28,6 +42,7 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
meta: true
|
||||||
};
|
};
|
||||||
export const postUnDetailSelect: Prisma.PostSelect = {
|
export const postUnDetailSelect: Prisma.PostSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
289
pnpm-lock.yaml
289
pnpm-lock.yaml
|
@ -284,6 +284,9 @@ importers:
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^3.9.1
|
specifier: ^3.9.1
|
||||||
version: 3.10.0(react-hook-form@7.54.2(react@18.2.0))
|
version: 3.10.0(react-hook-form@7.54.2(react@18.2.0))
|
||||||
|
'@mind-elixir/node-menu':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/mind-node-menu
|
||||||
'@nice/client':
|
'@nice/client':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/client
|
version: link:../../packages/client
|
||||||
|
@ -706,6 +709,18 @@ importers:
|
||||||
specifier: ^3.5.1
|
specifier: ^3.5.1
|
||||||
version: 3.5.2(vite@4.5.9(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0))
|
version: 3.5.2(vite@4.5.9(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0))
|
||||||
|
|
||||||
|
packages/mind-node-menu:
|
||||||
|
devDependencies:
|
||||||
|
less:
|
||||||
|
specifier: ^4.1.3
|
||||||
|
version: 4.2.2
|
||||||
|
mind-elixir:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../mind-elixir-core
|
||||||
|
vite:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.2.11(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0)
|
||||||
|
|
||||||
packages/template:
|
packages/template:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
|
@ -1460,6 +1475,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.15.18':
|
||||||
|
resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm@0.18.20':
|
'@esbuild/android-arm@0.18.20':
|
||||||
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -1622,6 +1643,12 @@ packages:
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.15.18':
|
||||||
|
resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.18.20':
|
'@esbuild/linux-loong64@0.18.20':
|
||||||
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -4547,6 +4574,131 @@ packages:
|
||||||
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
|
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
esbuild-android-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
esbuild-android-arm64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
esbuild-darwin-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
esbuild-darwin-arm64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
esbuild-freebsd-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
esbuild-freebsd-arm64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
esbuild-linux-32@0.15.18:
|
||||||
|
resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-arm64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-arm@0.15.18:
|
||||||
|
resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-mips64le@0.15.18:
|
||||||
|
resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-ppc64le@0.15.18:
|
||||||
|
resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-riscv64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-linux-s390x@0.15.18:
|
||||||
|
resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
esbuild-netbsd-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
esbuild-openbsd-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
esbuild-sunos-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
esbuild-windows-32@0.15.18:
|
||||||
|
resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
esbuild-windows-64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
esbuild-windows-arm64@0.15.18:
|
||||||
|
resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
esbuild@0.15.18:
|
||||||
|
resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
esbuild@0.18.20:
|
esbuild@0.18.20:
|
||||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -6889,6 +7041,11 @@ packages:
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rollup@2.79.2:
|
||||||
|
resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
rollup@3.29.5:
|
rollup@3.29.5:
|
||||||
resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
|
resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
|
||||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||||
|
@ -7634,6 +7791,31 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: '>=2.6.0'
|
vite: '>=2.6.0'
|
||||||
|
|
||||||
|
vite@3.2.11:
|
||||||
|
resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==}
|
||||||
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>= 14'
|
||||||
|
less: '*'
|
||||||
|
sass: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite@4.5.9:
|
vite@4.5.9:
|
||||||
resolution: {integrity: sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==}
|
resolution: {integrity: sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
@ -8978,6 +9160,9 @@ snapshots:
|
||||||
'@esbuild/android-arm64@0.24.2':
|
'@esbuild/android-arm64@0.24.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.15.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.18.20':
|
'@esbuild/android-arm@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
@ -9059,6 +9244,9 @@ snapshots:
|
||||||
'@esbuild/linux-ia32@0.24.2':
|
'@esbuild/linux-ia32@0.24.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.15.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.18.20':
|
'@esbuild/linux-loong64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
@ -12330,6 +12518,91 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
esbuild-android-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-android-arm64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-darwin-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-darwin-arm64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-freebsd-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-freebsd-arm64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-32@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-arm64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-arm@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-mips64le@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-ppc64le@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-riscv64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-linux-s390x@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-netbsd-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-openbsd-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-sunos-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-windows-32@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-windows-64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild-windows-arm64@0.15.18:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
esbuild@0.15.18:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/android-arm': 0.15.18
|
||||||
|
'@esbuild/linux-loong64': 0.15.18
|
||||||
|
esbuild-android-64: 0.15.18
|
||||||
|
esbuild-android-arm64: 0.15.18
|
||||||
|
esbuild-darwin-64: 0.15.18
|
||||||
|
esbuild-darwin-arm64: 0.15.18
|
||||||
|
esbuild-freebsd-64: 0.15.18
|
||||||
|
esbuild-freebsd-arm64: 0.15.18
|
||||||
|
esbuild-linux-32: 0.15.18
|
||||||
|
esbuild-linux-64: 0.15.18
|
||||||
|
esbuild-linux-arm: 0.15.18
|
||||||
|
esbuild-linux-arm64: 0.15.18
|
||||||
|
esbuild-linux-mips64le: 0.15.18
|
||||||
|
esbuild-linux-ppc64le: 0.15.18
|
||||||
|
esbuild-linux-riscv64: 0.15.18
|
||||||
|
esbuild-linux-s390x: 0.15.18
|
||||||
|
esbuild-netbsd-64: 0.15.18
|
||||||
|
esbuild-openbsd-64: 0.15.18
|
||||||
|
esbuild-sunos-64: 0.15.18
|
||||||
|
esbuild-windows-32: 0.15.18
|
||||||
|
esbuild-windows-64: 0.15.18
|
||||||
|
esbuild-windows-arm64: 0.15.18
|
||||||
|
|
||||||
esbuild@0.18.20:
|
esbuild@0.18.20:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/android-arm': 0.18.20
|
'@esbuild/android-arm': 0.18.20
|
||||||
|
@ -15038,6 +15311,10 @@ snapshots:
|
||||||
glob: 11.0.0
|
glob: 11.0.0
|
||||||
package-json-from-dist: 1.0.1
|
package-json-from-dist: 1.0.1
|
||||||
|
|
||||||
|
rollup@2.79.2:
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
rollup@3.29.5:
|
rollup@3.29.5:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
@ -15885,6 +16162,18 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
vite@3.2.11(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0):
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.15.18
|
||||||
|
postcss: 8.4.49
|
||||||
|
resolve: 1.22.10
|
||||||
|
rollup: 2.79.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.17.12
|
||||||
|
fsevents: 2.3.3
|
||||||
|
less: 4.2.2
|
||||||
|
terser: 5.37.0
|
||||||
|
|
||||||
vite@4.5.9(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0):
|
vite@4.5.9(@types/node@20.17.12)(less@4.2.2)(terser@5.37.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.18.20
|
esbuild: 0.18.20
|
||||||
|
|
|
@ -0,0 +1,503 @@
|
||||||
|
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