Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
2f39ecea3c
|
@ -101,7 +101,6 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
|||
},
|
||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
|
||||
const { courseDetail } = args;
|
||||
// If no transaction is provided, create a new one
|
||||
if (!params.tx) {
|
||||
|
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -1,111 +0,0 @@
|
|||
import { Card, Tag, Typography, Button } from "antd";
|
||||
import {
|
||||
BookOutlined,
|
||||
EyeOutlined,
|
||||
PlayCircleOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { CourseDto, TaxonomySlug } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface CourseCardProps {
|
||||
course: CourseDto;
|
||||
edit?: boolean;
|
||||
}
|
||||
const { Title, Text } = Typography;
|
||||
export default function CourseCard({ course, edit = false }: CourseCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = (course: CourseDto) => {
|
||||
if (!edit) {
|
||||
navigate(`/course/${course.id}/detail`);
|
||||
} else {
|
||||
navigate(`/course/${course.id}/editor`);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
onClick={() => handleClick(course)}
|
||||
key={course.id}
|
||||
hoverable
|
||||
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||
cover={
|
||||
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${course?.meta?.thumbnail})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-300" />
|
||||
</div>
|
||||
}>
|
||||
<div className="px-4 ">
|
||||
<div className="overflow-hidden hover:overflow-auto">
|
||||
<div className="flex gap-2 h-7 mb-4 whiteSpace-nowrap">
|
||||
{course?.terms?.map((term) => {
|
||||
return (
|
||||
<>
|
||||
<Tag
|
||||
key={term.id}
|
||||
color={
|
||||
term?.taxonomy?.slug ===
|
||||
TaxonomySlug.CATEGORY
|
||||
? "blue"
|
||||
: term?.taxonomy?.slug ===
|
||||
TaxonomySlug.LEVEL
|
||||
? "green"
|
||||
: "orange"
|
||||
}
|
||||
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0 ">
|
||||
{term.name}
|
||||
</Tag>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 mt-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
||||
<button> {course.title}</button>
|
||||
</Title>
|
||||
|
||||
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||
<div className="ml-2 flex items-center flex-grow">
|
||||
<Text className="font-medium text-blue-500 transition-colors duration-300 truncate max-w-[120px]">
|
||||
{course?.depts?.length > 1
|
||||
? `${course.depts[0].name}等`
|
||||
: course?.depts?.[0]?.name}
|
||||
{/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */}
|
||||
{/* {course?.depts?.map((dept)=>{return dept.name})} */}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<EyeOutlined />
|
||||
{`观看次数 ${course?.meta?.views || 0}`}
|
||||
</span>
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<BookOutlined />
|
||||
{`学习人数 ${course?.studentIds?.length || 0}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||
{edit ? "编辑" : "立即学习"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,17 +1,14 @@
|
|||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { PostType, Prisma } from "@nice/common";
|
||||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useMemo } from "react";
|
||||
import CourseCard from "./CourseCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
export function CoursesContainer() {
|
||||
const {searchCondition, termsCondition } = useMainContext();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => (
|
||||
<CourseCard course={post} edit={false}></CourseCard>
|
||||
)}
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import FilterSection from "../../layout/BasePost/FilterSection";
|
||||
import CoursesContainer from "../components/CoursesContainer";
|
||||
export function AllCoursesLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className=" flex">
|
||||
<div className="w-1/6">
|
||||
<FilterSection></FilterSection>
|
||||
</div>
|
||||
<div className="w-5/6 p-4">
|
||||
<CoursesContainer></CoursesContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default AllCoursesLayout;
|
|
@ -1,6 +1,13 @@
|
|||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import CoursesContainer from "./components/CoursesContainer";
|
||||
import { useEffect } from "react";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
export default function CoursesPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.COURSE);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<>
|
||||
<BasePostLayout>
|
||||
|
|
|
@ -5,7 +5,8 @@ import { api } from "@nice/client";
|
|||
import { CoursesSectionTag } from "./CoursesSectionTag";
|
||||
import LookForMore from "./LookForMore";
|
||||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import CourseCard from "../../courses/components/CourseCard";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
interface GetTaxonomyProps {
|
||||
categories: string[];
|
||||
isLoading: boolean;
|
||||
|
@ -17,7 +18,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps {
|
|||
taxonomy: {
|
||||
slug: type,
|
||||
},
|
||||
parentId: null
|
||||
parentId: null,
|
||||
},
|
||||
take: 11, // 只取前10个
|
||||
});
|
||||
|
@ -82,7 +83,7 @@ const CoursesSection: React.FC<CoursesSectionProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<PostList
|
||||
renderItem={(post) => <CourseCard course={post} edit={false}></CourseCard>}
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
page: 1,
|
||||
pageSize: initialVisibleCoursesCount,
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import FilterSection from "./FilterSection";
|
||||
import { useMainContext } from "../MainProvider";
|
||||
|
||||
export function BasePostLayout({ children }: { children: ReactNode }) {
|
||||
export function BasePostLayout({
|
||||
children,
|
||||
showSearchMode = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
showSearchMode?: boolean;
|
||||
}) {
|
||||
const { setShowSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setShowSearchMode(showSearchMode);
|
||||
}, [showSearchMode]);
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
@ -9,7 +20,7 @@ export function BasePostLayout({ children }: { children: ReactNode }) {
|
|||
<div className="w-1/6">
|
||||
<FilterSection></FilterSection>
|
||||
</div>
|
||||
<div className="w-5/6 p-4">{children}</div>
|
||||
<div className="w-5/6 p-4 py-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -1,17 +1,12 @@
|
|||
import { Checkbox, Divider, Radio, Space, Spin } from "antd";
|
||||
|
||||
import { TaxonomySlug, TermDto } from "@nice/common";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Divider } from "antd";
|
||||
import { api } from "@nice/client";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import TermSelect from "@web/src/components/models/term/term-select";
|
||||
import { useMainContext } from "../MainProvider";
|
||||
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
|
||||
|
||||
import SearchModeRadio from "./SearchModeRadio";
|
||||
export default function FilterSection() {
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({});
|
||||
const { selectedTerms, setSelectedTerms } = useMainContext();
|
||||
const { selectedTerms, setSelectedTerms, showSearchMode } =
|
||||
useMainContext();
|
||||
const handleTermChange = (slug: string, selected: string[]) => {
|
||||
setSelectedTerms({
|
||||
...selectedTerms,
|
||||
|
@ -20,6 +15,7 @@ export default function FilterSection() {
|
|||
};
|
||||
return (
|
||||
<div className=" flex z-0 p-6 flex-col rounded-lg mt-4 space-y-6 h-[820px] overscroll-contain overflow-x-hidden">
|
||||
{showSearchMode && <SearchModeRadio></SearchModeRadio>}
|
||||
{taxonomies?.map((tax, index) => {
|
||||
const items = Object.entries(selectedTerms).find(
|
||||
([key, items]) => key === tax.slug
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { useMainContext } from "../MainProvider";
|
||||
import { Radio, Space, Typography } from "antd";
|
||||
import { PostType } from "@nice/common"; // Assuming PostType is defined in this path
|
||||
|
||||
export default function SearchModeRadio() {
|
||||
const { searchMode, setSearchMode } = useMainContext();
|
||||
|
||||
const handleModeChange = (e) => {
|
||||
setSearchMode(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" align="start" className="mb-2">
|
||||
<h3 className="text-lg font-medium mb-4">搜索模式</h3>
|
||||
<Radio.Group
|
||||
value={searchMode}
|
||||
onChange={handleModeChange}
|
||||
buttonStyle="solid">
|
||||
<Radio.Button value={PostType.COURSE}>课程</Radio.Button>
|
||||
<Radio.Button value={PostType.PATH}>路径</Radio.Button>
|
||||
<Radio.Button value="both">全部</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
||||
import {
|
||||
EditFilled,
|
||||
|
@ -10,8 +11,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|||
import { UserMenu } from "./UserMenu/UserMenu";
|
||||
import { NavigationMenu } from "./NavigationMenu";
|
||||
import { useMainContext } from "./MainProvider";
|
||||
import { Header } from "antd/es/layout/layout";
|
||||
|
||||
export function MainHeader() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { id } = useParams();
|
||||
|
@ -19,34 +18,41 @@ export function MainHeader() {
|
|||
const { searchValue, setSearchValue } = useMainContext();
|
||||
|
||||
return (
|
||||
<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-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="select-none w-full flex items-center justify-between bg-white shadow-md border-b border-gray-100 fixed z-30 py-2 px-4 md:px-6">
|
||||
{/* 左侧区域 - 设置为不收缩 */}
|
||||
<div className="flex items-center justify-start space-x-4 flex-shrink-0">
|
||||
<img src="/logo.svg" className="h-12 w-12" />
|
||||
<div
|
||||
onClick={() => navigate("/")}
|
||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer">
|
||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
||||
烽火慕课
|
||||
</div>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
</div>
|
||||
<div className=" flex justify-end gap-4 mr-2">
|
||||
|
||||
{/* 中间搜索区域 - 允许适当收缩但保持可用性 */}
|
||||
<div className="mx-4 flex-shrink md:flex-shrink-0 md:w-auto w-auto">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={
|
||||
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
}
|
||||
placeholder="搜索课程"
|
||||
className="w-96 rounded-full"
|
||||
className="w-full md:w-96 rounded-full"
|
||||
value={searchValue}
|
||||
onClick={(e) => {
|
||||
if (!window.location.pathname.startsWith("/search")) {
|
||||
navigate(`/search`);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (
|
||||
!window.location.pathname.startsWith("/courses/") &&
|
||||
!window.location.pathname.startsWith("/my") &&
|
||||
!window.location.pathname.startsWith("/path")
|
||||
) {
|
||||
navigate(`/courses/`);
|
||||
if (!window.location.pathname.startsWith("/search")) {
|
||||
navigate(`/search`);
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
|
@ -54,7 +60,11 @@ export function MainHeader() {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
</div>
|
||||
|
||||
{/* 右侧区域 - 可以灵活收缩 */}
|
||||
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Button
|
||||
|
@ -64,8 +74,8 @@ export function MainHeader() {
|
|||
: "/course/editor";
|
||||
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"
|
||||
icon={<EditFilled />}>
|
||||
type="primary"
|
||||
>
|
||||
{id ? "编辑课程" : "创建课程"}
|
||||
</Button>
|
||||
</>
|
||||
|
@ -94,3 +104,4 @@ export function MainHeader() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ export function MainLayout() {
|
|||
<MainProvider>
|
||||
<div className=" min-h-screen bg-gray-100">
|
||||
<MainHeader />
|
||||
<Content className="min-h-screen flex-grow pt-12 bg-gray-50 ">
|
||||
<Content className="min-h-screen flex-grow pt-14 bg-gray-50 ">
|
||||
<Outlet />
|
||||
</Content>
|
||||
<MainFooter />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Prisma } from "packages/common/dist";
|
||||
import { PostType, Prisma } from "@nice/common";
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
@ -17,6 +17,12 @@ interface MainContextType {
|
|||
setSelectedTerms?: React.Dispatch<React.SetStateAction<SelectedTerms>>;
|
||||
searchCondition?: Prisma.PostWhereInput;
|
||||
termsCondition?: Prisma.PostWhereInput;
|
||||
searchMode?: PostType.COURSE | PostType.PATH | "both";
|
||||
setSearchMode?: React.Dispatch<
|
||||
React.SetStateAction<PostType.COURSE | PostType.PATH | "both">
|
||||
>;
|
||||
showSearchMode?: boolean;
|
||||
setShowSearchMode?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const MainContext = createContext<MainContextType | null>(null);
|
||||
|
@ -25,6 +31,10 @@ interface MainProviderProps {
|
|||
}
|
||||
|
||||
export function MainProvider({ children }: MainProviderProps) {
|
||||
const [searchMode, setSearchMode] = useState<
|
||||
PostType.COURSE | PostType.PATH | "both"
|
||||
>("both");
|
||||
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||
const termFilters = useMemo(() => {
|
||||
|
@ -79,6 +89,10 @@ export function MainProvider({ children }: MainProviderProps) {
|
|||
setSelectedTerms,
|
||||
searchCondition,
|
||||
termsCondition,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
showSearchMode,
|
||||
setShowSearchMode,
|
||||
}}>
|
||||
{children}
|
||||
</MainContext.Provider>
|
||||
|
|
|
@ -11,16 +11,17 @@ export const NavigationMenu = () => {
|
|||
const menuItems = useMemo(() => {
|
||||
const baseItems = [
|
||||
{ key: "home", path: "/", label: "首页" },
|
||||
{ key: "courses", path: "/courses", label: "全部课程" },
|
||||
{ key: "path", path: "/path", label: "学习路径" },
|
||||
{ key: "courses", path: "/courses", label: "全部课程" },
|
||||
];
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return baseItems;
|
||||
} else {
|
||||
return [
|
||||
...baseItems,
|
||||
{ key: "my-duty", path: "/my-duty", label: "我创建的" },
|
||||
{ key: "my-learning", path: "/my-learning", label: "我的课表" },
|
||||
{ key: "my-duty", path: "/my-duty", label: "我的授课" },
|
||||
{ key: "my-learning", path: "/my-learning", label: "我的课程" },
|
||||
{ key: "my-path", path: "/my-path", label: "我的路径" },
|
||||
];
|
||||
}
|
||||
|
|
|
@ -159,13 +159,6 @@ export function UserMenu() {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
||||
<span className="text-base text-primary flex items-center gap-1.5">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
import CourseCard from "../../courses/components/CourseCard";
|
||||
import { PostType } from "@nice/common";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
export default function MyDutyListContainer() {
|
||||
const { user } = useAuth();
|
||||
|
@ -11,9 +11,7 @@ export default function MyDutyListContainer() {
|
|||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => (
|
||||
<CourseCard edit course={post}></CourseCard>
|
||||
)}
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import MyDutyListContainer from "./components/MyDutyListContainer";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import { PostType } from "@nice/common";
|
||||
export default function MyDutyPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.COURSE);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyDutyListContainer></MyDutyListContainer>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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";
|
||||
import { PostType } from "@nice/common";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
|
||||
export default function MyLearningListContainer() {
|
||||
const { user } = useAuth();
|
||||
|
@ -10,9 +11,7 @@ export default function MyLearningListContainer() {
|
|||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => (
|
||||
<CourseCard edit={false} course={post}></CourseCard>
|
||||
)}
|
||||
renderItem={(post) => <CourseCard post={post}></CourseCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import MyLearningListContainer from "./components/MyLearningListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
|
||||
export default function MyLearningPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.COURSE);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyLearningListContainer></MyLearningListContainer>
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
import { PostType } from "@nice/common";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export default function MyPathListContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: PostType.PATH,
|
||||
authorId: user.id,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import MyPathListContainer from "./components/MyPathListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
|
||||
export default function MyPathPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<MyPathListContainer></MyPathListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -1,14 +1,18 @@
|
|||
import { TeamOutlined } from "@ant-design/icons";
|
||||
import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { Typography } from "antd";
|
||||
import { PostDto } from "@nice/common";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const DeptInfo = ({ path }) => {
|
||||
const DeptInfo = ({ post }: { post: PostDto }) => {
|
||||
return (
|
||||
<div className="gap-1 flex items-center flex-grow">
|
||||
<div className="gap-1 flex items-center justify-between flex-grow">
|
||||
<div className=" flex justify-start gap-1 items-center">
|
||||
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
|
||||
{path?.depts && path?.depts?.length > 0 ? (
|
||||
{post?.depts && post?.depts?.length > 0 ? (
|
||||
<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}
|
||||
{post?.depts?.length > 1
|
||||
? `${post.depts[0].name}等`
|
||||
: post?.depts?.[0]?.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
|
||||
|
@ -16,6 +20,21 @@ const DeptInfo = ({ path }) => {
|
|||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{post && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<EyeOutlined />
|
||||
{`${post?.meta?.views || 0}`}
|
||||
</span>
|
||||
{post?.studentIds && post?.studentIds?.length > 0 && (
|
||||
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
|
||||
<BookOutlined />
|
||||
{`${post?.studentIds?.length || 0}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import { Card, Tag, Typography, Button } from "antd";
|
||||
import {
|
||||
EyeOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { PostDto, TaxonomySlug } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DeptInfo from "./DeptInfo";
|
||||
import TermInfo from "./TermInfo";
|
||||
interface pathCardProps {
|
||||
path: PostDto;
|
||||
}
|
||||
const { Title, Text } = Typography;
|
||||
export default function PathCard({ path }: pathCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = (path: PostDto) => {
|
||||
navigate(`/path/editor/${path.id}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
onClick={() => handleClick(path)}
|
||||
key={path.id}
|
||||
hoverable
|
||||
className=" group overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||
cover={
|
||||
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${path?.meta?.thumbnail})`,
|
||||
}}
|
||||
/>
|
||||
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" /> */}
|
||||
</div>
|
||||
}>
|
||||
<div className="px-4">
|
||||
<TermInfo path={path} />
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
||||
<button> {path.title}</button>
|
||||
</Title>
|
||||
|
||||
<div className="flex items-center mb-4 p-2 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||
<DeptInfo path={path} />
|
||||
<span className="flex text-xs font-medium text-gray-500">
|
||||
<EyeOutlined className="mr-2"></EyeOutlined>
|
||||
{path?.meta?.views
|
||||
? `观看次数 ${path?.meta?.views}`
|
||||
: 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||
立即学习
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import { PostType, Prisma } from "@nice/common";
|
||||
import PathCard from "./PathCard";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export function PathListContainer() {
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => <PathCard path={post}></PathCard>}
|
||||
renderItem={(post) => <PathCard post={post}></PathCard>}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
import { Tag } from "antd";
|
||||
import { TaxonomySlug } from "@nice/common";
|
||||
import { PostDto, TaxonomySlug } from "@nice/common";
|
||||
|
||||
const TermInfo = ({ path }) => {
|
||||
console.log('xx',path?.terms);
|
||||
|
||||
return <>
|
||||
{path?.terms && path?.terms?.length > 0 ? (
|
||||
const TermInfo = ({ post }: { post: PostDto }) => {
|
||||
return (
|
||||
<div>
|
||||
{post?.terms && post?.terms?.length > 0 ? (
|
||||
<div className="flex gap-2 mb-4">
|
||||
{path?.terms?.map((term:any) => {
|
||||
{post?.terms?.map((term: any) => {
|
||||
return (
|
||||
<Tag
|
||||
key={term.id}
|
||||
color={
|
||||
term?.taxonomy?.slug ===
|
||||
TaxonomySlug.CATEGORY
|
||||
? "blue"
|
||||
? "green"
|
||||
: term?.taxonomy?.slug ===
|
||||
TaxonomySlug.LEVEL
|
||||
? "green"
|
||||
? "blue"
|
||||
: "orange"
|
||||
}
|
||||
className="px-3 py-1 rounded-full border-0">
|
||||
|
@ -35,7 +34,8 @@ const TermInfo = ({ path }) => {
|
|||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermInfo;
|
|
@ -1,7 +1,14 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
import PathListContainer from "./components/PathListContainer";
|
||||
import { PostType } from "@nice/common";
|
||||
|
||||
export default function PathPage() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<PathListContainer></PathListContainer>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import PostList from "@web/src/components/models/course/list/PostList";
|
||||
import { useMainContext } from "../../layout/MainProvider";
|
||||
import PostCard from "@web/src/components/models/post/PostCard";
|
||||
import { PostType } from "@nice/common";
|
||||
import CourseCard from "@web/src/components/models/post/SubPost/CourseCard";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
const POST_TYPE_COMPONENTS = {
|
||||
[PostType.COURSE]: CourseCard,
|
||||
[PostType.PATH]: PathCard,
|
||||
};
|
||||
export default function SearchListContainer() {
|
||||
const { searchCondition, termsCondition, searchMode } = useMainContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostList
|
||||
renderItem={(post) => {
|
||||
const Component =
|
||||
POST_TYPE_COMPONENTS[post.type] || PostCard;
|
||||
return <Component post={post} />;
|
||||
}}
|
||||
params={{
|
||||
pageSize: 12,
|
||||
where: {
|
||||
type: searchMode === "both" ? undefined : searchMode,
|
||||
...termsCondition,
|
||||
...searchCondition,
|
||||
},
|
||||
}}
|
||||
cols={4}></PostList>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect } from "react";
|
||||
import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||
import SearchListContainer from "./components/SearchContainer";
|
||||
import { useMainContext } from "../layout/MainProvider";
|
||||
|
||||
export default function SearchPage() {
|
||||
const { setShowSearchMode, setSearchValue } = useMainContext();
|
||||
useEffect(() => {
|
||||
setShowSearchMode(true);
|
||||
return () => {
|
||||
setShowSearchMode(false);
|
||||
setSearchValue("");
|
||||
};
|
||||
}, [setShowSearchMode]);
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<SearchListContainer></SearchListContainer>
|
||||
</BasePostLayout>
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ const CollapsibleContent: React.FC<CollapsibleContentProps> = ({ content }) => {
|
|||
const contentWrapperRef = useRef(null);
|
||||
return (
|
||||
<div className=" text-base ">
|
||||
<div className=" flex flex-col gap-4 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||
<div className=" flex flex-col gap-4 transition-all duration-300 ease-in-out rounded-xl p-6 ">
|
||||
{/* 包装整个内容区域的容器 */}
|
||||
<div ref={contentWrapperRef}>
|
||||
{/* 内容区域 */}
|
||||
|
|
|
@ -3,64 +3,38 @@ import NodeMenu from "./NodeMenu";
|
|||
import { api, usePost } from "@nice/client";
|
||||
import {
|
||||
ObjectType,
|
||||
PathDto,
|
||||
postDetailSelect,
|
||||
PostDto,
|
||||
PostType,
|
||||
Prisma,
|
||||
Taxonomy,
|
||||
RolePerms,
|
||||
} from "@nice/common";
|
||||
import TermSelect from "../../models/term/term-select";
|
||||
import DepartmentSelect from "../../models/department/department-select";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { MIND_OPTIONS } from "./constant";
|
||||
export default function MindEditor({ id }: { id?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||
|
||||
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } =
|
||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||
const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||
api.post.findFirst.useQuery({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: postDetailSelect,
|
||||
});
|
||||
const canEdit: boolean = useMemo(() => {
|
||||
//登录了且是作者、超管、无id新建模式
|
||||
const isAuth = isAuthenticated && user?.id === post?.author?.id;
|
||||
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||
}, [user]);
|
||||
const navigate = useNavigate();
|
||||
const { create, update } = usePost();
|
||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||
|
@ -83,22 +57,33 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
form.setFieldsValue(formData);
|
||||
}
|
||||
}, [post, form, instance, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const mind = new MindElixir({
|
||||
...MIND_OPTIONS,
|
||||
el: containerRef.current,
|
||||
before: {
|
||||
beginEdit() {
|
||||
return canEdit;
|
||||
},
|
||||
},
|
||||
draggable: canEdit, // 禁用拖拽
|
||||
contextMenu: canEdit, // 禁用右键菜单
|
||||
toolBar: canEdit, // 禁用工具栏
|
||||
nodeMenu: canEdit, // 禁用节点右键菜单
|
||||
keypress: canEdit, // 禁用键盘快捷键
|
||||
});
|
||||
mind.init(MindElixir.new("新学习路径"));
|
||||
containerRef.current.hidden = true;
|
||||
setInstance(mind);
|
||||
}, []);
|
||||
}, [canEdit]);
|
||||
useEffect(() => {
|
||||
if ((!id || post) && instance) {
|
||||
containerRef.current.hidden = false;
|
||||
instance.toCenter();
|
||||
instance.refresh((post as any)?.meta);
|
||||
if (post?.meta?.nodeData) {
|
||||
instance.refresh(post?.meta);
|
||||
}
|
||||
}
|
||||
}, [id, post, instance]);
|
||||
const handleSave = async () => {
|
||||
|
@ -164,15 +149,14 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
`mind-thumb-${new Date().toString()}`
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
containerRef.current.style.height = `${Math.floor(window.innerHeight / 1.25)}px`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className=" flex flex-col border rounded-lg overflow-hidden">
|
||||
{taxonomies && (
|
||||
<Form
|
||||
onFinish={(values) => {
|
||||
console.log(values);
|
||||
}}
|
||||
form={form}
|
||||
className=" bg-white p-2 ">
|
||||
<div className="grid grid-cols-1 flex-col w-[90vw] my-5 h-[80vh] border rounded-lg mx-auto">
|
||||
{canEdit && taxonomies && (
|
||||
<Form form={form} className=" bg-white p-4 ">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{taxonomies.map((tax, index) => (
|
||||
|
@ -199,14 +183,22 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button ghost type="primary" onClick={handleSave}>
|
||||
<Button
|
||||
ghost
|
||||
type="primary"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onClick={handleSave}>
|
||||
{id ? "更新" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<div ref={containerRef} className="mind-editor min-h-screen" />
|
||||
{instance && <NodeMenu mind={instance} />}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="py-64 justify-center flex"
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import MindElixir from "mind-elixir";
|
||||
export const MIND_OPTIONS = {
|
||||
direction: MindElixir.SIDE,
|
||||
draggable: true,
|
||||
contextMenu: true,
|
||||
toolBar: true,
|
||||
nodeMenu: true,
|
||||
keypress: true,
|
||||
locale: "zh_CN" as const,
|
||||
theme: {
|
||||
name: "Latte",
|
||||
palette: [
|
||||
"#dd7878",
|
||||
"#ea76cb",
|
||||
"#8839ef",
|
||||
"#e64553",
|
||||
"#fe640b",
|
||||
"#df8e1d",
|
||||
"#40a02b",
|
||||
"#209fb5",
|
||||
"#1e66f5",
|
||||
"#7287fd",
|
||||
],
|
||||
cssVar: {
|
||||
"--main-color": "#444446",
|
||||
"--main-bgcolor": "#ffffff",
|
||||
"--color": "#777777",
|
||||
"--bgcolor": "#f6f6f6",
|
||||
"--panel-color": "#444446",
|
||||
"--panel-bgcolor": "#ffffff",
|
||||
"--panel-border-color": "#eaeaea",
|
||||
},
|
||||
},
|
||||
};
|
|
@ -35,7 +35,7 @@ export default function ResourcesShower({
|
|||
const imageResources = dealedResources.filter((res) => res.isImage);
|
||||
const fileResources = dealedResources.filter((res) => !res.isImage);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{imageResources.length > 0 && (
|
||||
<Row gutter={[16, 16]} className="mb-6">
|
||||
<Image.PreviewGroup>
|
||||
|
@ -82,6 +82,7 @@ export default function ResourcesShower({
|
|||
</Image.PreviewGroup>
|
||||
</Row>
|
||||
)}
|
||||
<div className=" text-sm px-2">附件:</div>
|
||||
{fileResources.length > 0 && (
|
||||
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
||||
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { CourseDto } from "@nice/common";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
import { CourseHeader } from "./CourseHeader";
|
||||
import { CourseStats } from "./CourseStats";
|
||||
import { Popover } from "@web/src/components/presentation/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CourseCardProps {
|
||||
course: CourseDto;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
|
||||
return (
|
||||
<Card onClick={onClick} className="w-full max-w-sm">
|
||||
<CourseHeader
|
||||
title={course.title}
|
||||
subTitle={course.subTitle}
|
||||
thumbnail={course.thumbnail}
|
||||
level={course.level}
|
||||
numberOfStudents={course.numberOfStudents}
|
||||
publishedAt={course.publishedAt}
|
||||
/>
|
||||
<CourseStats
|
||||
averageRating={course.averageRating}
|
||||
numberOfReviews={course.numberOfReviews}
|
||||
completionRate={course.completionRate}
|
||||
totalDuration={course.totalDuration}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
|
@ -36,8 +36,12 @@ interface CourseFormProviderProps {
|
|||
editId?: string; // 添加 editId 参数
|
||||
}
|
||||
|
||||
export const CourseDetailContext =createContext<CourseDetailContextType | null>(null);
|
||||
export function CourseDetailProvider({children,editId}: CourseFormProviderProps) {
|
||||
export const CourseDetailContext =
|
||||
createContext<CourseDetailContextType | null>(null);
|
||||
export function CourseDetailProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { read } = useVisitor();
|
||||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||
|
@ -86,7 +90,9 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps)
|
|||
}
|
||||
}, [course]);
|
||||
useEffect(() => {
|
||||
if (lectureId !== selectedLectureId) {
|
||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||
}
|
||||
}, [selectedLectureId, editId]);
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||
return (
|
||||
|
|
|
@ -2,22 +2,25 @@ import { Course, TaxonomySlug } from "@nice/common";
|
|||
import React, { useContext, useMemo } from "react";
|
||||
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import {
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
EditTwoTone,
|
||||
EyeOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useStaff } from "@nice/client";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||
import { PictureOutlined } from "@ant-design/icons";
|
||||
|
||||
export const CourseDetailDescription: React.FC = () => {
|
||||
const { course,canEdit, isLoading, selectedLectureId, setSelectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
const { Paragraph, Title } = Typography;
|
||||
const {
|
||||
course,
|
||||
canEdit,
|
||||
isLoading,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
userIsLearning,
|
||||
lecture = null,
|
||||
} = useContext(CourseDetailContext);
|
||||
const { Paragraph } = Typography;
|
||||
const { user } = useAuth();
|
||||
const { update } = useStaff();
|
||||
const firstLectureId = useMemo(() => {
|
||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||
}, [course]);
|
||||
|
@ -30,49 +33,44 @@ export const CourseDetailDescription: React.FC = () => {
|
|||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!selectedLectureId && course?.meta?.thumbnail && (
|
||||
<>
|
||||
{!selectedLectureId && (
|
||||
<div className="relative mb-4 overflow-hidden flex justify-center items-center">
|
||||
{
|
||||
<Image
|
||||
src={course?.meta?.thumbnail}
|
||||
src={course.meta.thumbnail}
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
fallback="/placeholder.webp"
|
||||
/>
|
||||
}
|
||||
<div
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setSelectedLectureId(firstLectureId);
|
||||
if (!userIsLearning) {
|
||||
await update.mutateAsync({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
learningPosts: {
|
||||
connect: {
|
||||
id: course.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full h-full absolute top-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10">
|
||||
className="w-full h-full absolute top-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-4xl z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
点击进入学习
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||
{course?.subTitle && <div>{course?.subTitle}</div>}
|
||||
{course.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>
|
||||
);
|
||||
})}
|
||||
<TermInfo post={course}></TermInfo>
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph
|
||||
|
|
|
@ -65,8 +65,8 @@ export const CourseDetailDisplayArea: React.FC = () => {
|
|||
{!lectureIsLoading &&
|
||||
selectedLectureId &&
|
||||
lecture?.meta?.type === LectureType.ARTICLE && (
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-4">
|
||||
<div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-6 ">
|
||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
|
||||
<div className="w-full rounded-lg ">
|
||||
<CollapsibleContent
|
||||
content={lecture?.content || ""}
|
||||
maxHeight={500} // Optional, defaults to 150
|
||||
|
|
|
@ -12,29 +12,34 @@ import dayjs from "dayjs";
|
|||
import CourseOperationBtns from "./JoinLearingButton";
|
||||
|
||||
export default function CourseDetailTitle() {
|
||||
const {
|
||||
course,
|
||||
isLoading,
|
||||
canEdit,
|
||||
lecture,
|
||||
lectureIsLoading,
|
||||
selectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
const navigate = useNavigate();
|
||||
const { course } = useContext(CourseDetailContext);
|
||||
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">
|
||||
{course?.author?.showname && (
|
||||
<div>
|
||||
发布者:
|
||||
{course?.author?.showname}
|
||||
</div>
|
||||
)}
|
||||
{course?.depts && course?.depts?.length > 0 && (
|
||||
<div>
|
||||
发布单位:
|
||||
{course?.depts?.map((dept) => dept.name)}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
|
|
|
@ -98,12 +98,18 @@ export function CourseFormProvider({
|
|||
thumbnail: values?.meta?.thumbnail,
|
||||
}),
|
||||
},
|
||||
terms: {
|
||||
terms:
|
||||
termIds?.length > 0
|
||||
? {
|
||||
set: termIds.map((id) => ({ id })), // 转换成 connect 格式
|
||||
},
|
||||
depts: {
|
||||
}
|
||||
: undefined,
|
||||
depts:
|
||||
deptIds?.length > 0
|
||||
? {
|
||||
set: deptIds.map((id) => ({ id })),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// 删除原始的 taxonomy 字段
|
||||
taxonomies.forEach((tax) => {
|
||||
|
|
|
@ -26,6 +26,7 @@ import CollapsibleContent from "@web/src/components/common/container/Collapsible
|
|||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader";
|
||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||
import { set } from "idb-keyval";
|
||||
|
||||
interface SortableLectureProps {
|
||||
field: Lecture;
|
||||
|
@ -82,13 +83,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
|||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||
: undefined,
|
||||
},
|
||||
resources: {
|
||||
resources:
|
||||
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||
? {
|
||||
connect: [videoUrlId, ...fileIds]
|
||||
.filter(Boolean)
|
||||
.map((fileId) => ({
|
||||
fileId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
|
@ -108,13 +112,16 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
|||
? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8`
|
||||
: undefined,
|
||||
},
|
||||
resources: {
|
||||
resources:
|
||||
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0
|
||||
? {
|
||||
connect: [videoUrlId, ...fileIds]
|
||||
.filter(Boolean)
|
||||
.map((fileId) => ({
|
||||
fileId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
|
@ -199,13 +206,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
|||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["meta", "fileIds"]}
|
||||
className="mb-0 flex-1"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请传入文件",
|
||||
},
|
||||
]}>
|
||||
className="mb-0 flex-1">
|
||||
<TusUploader multiple={true} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
|
|
@ -135,7 +135,7 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
|
|||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<Space className=" flex">
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
@ -143,17 +143,26 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
|
|||
/>
|
||||
<span>{field.title || "未命名章节"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
extra={
|
||||
!editing && (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
key={field.id || "new"}>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Pagination, Empty, Skeleton } from "antd";
|
||||
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common";
|
||||
import { courseDetailSelect, CourseDto, PostDto, Prisma } from "@nice/common";
|
||||
import { api } from "@nice/client";
|
||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
@ -12,7 +12,7 @@ interface PostListProps {
|
|||
};
|
||||
cols?: number;
|
||||
showPagination?: boolean;
|
||||
renderItem: (post: any) => React.ReactNode;
|
||||
renderItem: (post: PostDto) => React.ReactNode;
|
||||
}
|
||||
interface PostPagnationProps {
|
||||
data: {
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { Card, Typography, Button, Empty } from "antd";
|
||||
|
||||
import { PostDto } from "@nice/common";
|
||||
import DeptInfo from "@web/src/app/main/path/components/DeptInfo";
|
||||
import TermInfo from "@web/src/app/main/path/components/TermInfo";
|
||||
import { PictureOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PostCardProps {
|
||||
post?: PostDto;
|
||||
onClick?: (post?: PostDto) => void;
|
||||
}
|
||||
const { Title } = Typography;
|
||||
export default function PostCard({ post, onClick }: PostCardProps) {
|
||||
const handleClick = (post: PostDto) => {
|
||||
onClick?.(post);
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
onClick={() => handleClick(post)}
|
||||
key={post?.id}
|
||||
hoverable
|
||||
className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||
cover={
|
||||
<div className="relative h-56 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden group">
|
||||
{post?.meta?.thumbnail ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-700 ease-out group-hover:scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${post?.meta?.thumbnail})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary-500 to-primary-700">
|
||||
<PictureOutlined className="text-white text-6xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
<div className="px-4 ">
|
||||
<div className="overflow-hidden hover:overflow-auto">
|
||||
<div className="flex gap-2 h-7 whiteSpace-nowrap">
|
||||
<TermInfo post={post}></TermInfo>
|
||||
</div>
|
||||
</div>
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 mt-4 line-clamp-2 font-bold leading-snug text-gray-800 hover:text-blue-600 transition-colors duration-300 group-hover:scale-[1.02] transform origin-left">
|
||||
<button> {post?.title}</button>
|
||||
</Title>
|
||||
<div className="flex items-center mb-4 rounded-lg transition-all duration-300 hover:bg-blue-50 group">
|
||||
<DeptInfo post={post}></DeptInfo>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out">
|
||||
{"立即学习"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { CourseDto, PostDto } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PostCard from "../PostCard";
|
||||
export default function CourseCard({ post }: { post: PostDto }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => {
|
||||
navigate(`/course/${post?.id}/detail`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}></PostCard>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { PostDto } from "@nice/common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PostCard from "../PostCard";
|
||||
|
||||
export default function PathCard({ post }: { post: PostDto }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => {
|
||||
navigate(`/path/editor/${post?.id}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}></PostCard>
|
||||
);
|
||||
}
|
|
@ -160,8 +160,3 @@
|
|||
border-bottom: none;
|
||||
/* 去除最后一行的底部边框 */
|
||||
}
|
||||
|
||||
.mind-editor {
|
||||
height: calc(100vh - 285px);
|
||||
width: 100%;
|
||||
}
|
|
@ -22,6 +22,8 @@ import PathEditorPage from "../app/main/path/editor/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";
|
||||
import MyPathPage from "../app/main/my-path/page";
|
||||
import SearchPage from "../app/main/search/page";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -68,14 +70,22 @@ export const routes: CustomRouteObject[] = [
|
|||
},
|
||||
{
|
||||
path: "editor/:id?",
|
||||
element: <PathEditorPage></PathEditorPage>
|
||||
}
|
||||
]
|
||||
element: <PathEditorPage></PathEditorPage>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
element: <CoursesPage></CoursesPage>,
|
||||
},
|
||||
{
|
||||
path: "my-path",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<MyPathPage></MyPathPage>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "my-duty",
|
||||
element: (
|
||||
|
@ -92,6 +102,10 @@ export const routes: CustomRouteObject[] = [
|
|||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "search",
|
||||
element: <SearchPage></SearchPage>,
|
||||
},
|
||||
{
|
||||
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
||||
element: <CourseDetailPage />,
|
||||
|
|
|
@ -226,8 +226,6 @@ model Post {
|
|||
ancestors PostAncestry[] @relation("DescendantPosts")
|
||||
descendants PostAncestry[] @relation("AncestorPosts")
|
||||
resources Resource[] // 附件列表
|
||||
// watchableStaffs Staff[] @relation("post_watch_staff")
|
||||
// watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型
|
||||
meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int
|
||||
|
||||
// 索引
|
||||
|
|
|
@ -40,19 +40,23 @@ export type PostDto = Post & {
|
|||
delete: boolean;
|
||||
// edit: boolean;
|
||||
};
|
||||
meta?: PostMeta;
|
||||
watchableDepts: Department[];
|
||||
watchableStaffs: Staff[];
|
||||
terms: TermDto[]
|
||||
depts: DepartmentDto[]
|
||||
meta?: {
|
||||
thumbnail?: string
|
||||
views?: number
|
||||
}
|
||||
};
|
||||
terms: TermDto[];
|
||||
depts: DepartmentDto[];
|
||||
|
||||
export type LectureMeta = {
|
||||
type?: string;
|
||||
studentIds?: string[];
|
||||
};
|
||||
export type PostMeta = {
|
||||
thumbnail?: string;
|
||||
views?: number;
|
||||
likes?: number;
|
||||
hates?: number;
|
||||
};
|
||||
export type LectureMeta = PostMeta & {
|
||||
type?: string;
|
||||
|
||||
videoUrl?: string;
|
||||
videoThumbnail?: string;
|
||||
videoIds?: string[];
|
||||
|
@ -64,7 +68,7 @@ export type Lecture = Post & {
|
|||
meta?: LectureMeta;
|
||||
};
|
||||
|
||||
export type SectionMeta = {
|
||||
export type SectionMeta = PostMeta & {
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Section = Post & {
|
||||
|
@ -73,15 +77,10 @@ export type Section = Post & {
|
|||
export type SectionDto = Section & {
|
||||
lectures: Lecture[];
|
||||
};
|
||||
export type CourseMeta = {
|
||||
thumbnail?: string;
|
||||
|
||||
export type CourseMeta = PostMeta & {
|
||||
objectives?: string[];
|
||||
views?: number;
|
||||
likes?: number;
|
||||
hates?: number;
|
||||
};
|
||||
export type Course = Post & {
|
||||
export type Course = PostDto & {
|
||||
meta?: CourseMeta;
|
||||
};
|
||||
export type CourseDto = Course & {
|
||||
|
@ -92,3 +91,45 @@ export type CourseDto = Course & {
|
|||
depts: Department[];
|
||||
studentIds: string[];
|
||||
};
|
||||
|
||||
export type Summary = {
|
||||
id: string;
|
||||
text: string;
|
||||
parent: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
export type NodeObj = {
|
||||
topic: string;
|
||||
id: string;
|
||||
style?: {
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
fontWeight?: string;
|
||||
};
|
||||
children?: NodeObj[];
|
||||
};
|
||||
export type Arrow = {
|
||||
id: string;
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
delta1: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
delta2: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
export type PathMeta = PostMeta & {
|
||||
nodeData: NodeObj;
|
||||
arrows?: Arrow[];
|
||||
summaries?: Summary[];
|
||||
direction?: number;
|
||||
};
|
||||
export type PathDto = PostDto & {
|
||||
meta: PathMeta;
|
||||
};
|
||||
|
|
|
@ -6,6 +6,8 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
title: true,
|
||||
content: true,
|
||||
resources: true,
|
||||
parent: true,
|
||||
parentId: true,
|
||||
// watchableDepts: true,
|
||||
// watchableStaffs: true,
|
||||
updatedAt: true,
|
||||
|
@ -18,9 +20,9 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
depts: true,
|
||||
author: {
|
||||
|
@ -42,12 +44,14 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
},
|
||||
},
|
||||
},
|
||||
meta: true
|
||||
meta: true,
|
||||
};
|
||||
export const postUnDetailSelect: Prisma.PostSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
title: true,
|
||||
parent: true,
|
||||
parentId: true,
|
||||
content: true,
|
||||
resources: true,
|
||||
updatedAt: true,
|
||||
|
@ -85,6 +89,8 @@ export const courseDetailSelect: Prisma.PostSelect = {
|
|||
title: true,
|
||||
subTitle: true,
|
||||
type: true,
|
||||
author: true,
|
||||
authorId: true,
|
||||
content: true,
|
||||
depts: true,
|
||||
// isFeatured: true,
|
||||
|
|
|
@ -1,503 +0,0 @@
|
|||
import statistics
|
||||
from git import Repo
|
||||
from collections import defaultdict, Counter
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
|
||||
|
||||
def get_contributor_stats(repo_path, start_date=None, end_date=None, branch='HEAD'):
|
||||
"""
|
||||
获取仓库贡献者的详细统计信息
|
||||
|
||||
Args:
|
||||
repo_path: Git仓库路径
|
||||
start_date: 开始日期(可选)
|
||||
end_date: 结束日期(可选)
|
||||
branch: 要分析的分支(默认为HEAD)
|
||||
|
||||
Returns:
|
||||
包含每个贡献者详细统计信息的字典
|
||||
"""
|
||||
# 初始化仓库对象
|
||||
repo = Repo(repo_path)
|
||||
|
||||
# 存储统计结果
|
||||
stats = defaultdict(lambda: {
|
||||
'additions': 0, # 添加的行数
|
||||
'deletions': 0, # 删除的行数
|
||||
'commits': 0, # 提交次数
|
||||
'files_modified': set(), # 修改过的文件集合
|
||||
'file_types': defaultdict(int),# 各类型文件的修改次数
|
||||
'commit_dates': set(), # 提交日期集合
|
||||
'commit_hours': defaultdict(int), # 提交小时分布
|
||||
'commit_weekdays': defaultdict(int), # 提交工作日分布
|
||||
'largest_commit': 0, # 最大单次提交修改量
|
||||
'first_commit': None, # 首次提交时间
|
||||
'last_commit': None, # 最近提交时间
|
||||
'commit_sizes': [], # 每次提交的大小,用于计算平均值和中位数
|
||||
'commit_messages': [], # 提交消息列表
|
||||
'commit_message_lengths': [], # 提交消息长度列表
|
||||
'directories_modified': set(), # 修改过的目录集合
|
||||
'co_authors': set(), # 合作者集合
|
||||
'impact_score': 0, # 影响力得分
|
||||
'complexity_score': 0, # 复杂度得分
|
||||
'commit_by_month': defaultdict(int), # 按月份统计的提交次数
|
||||
'commit_by_quarter': defaultdict(int), # 按季度统计的提交次数
|
||||
'commit_by_year': defaultdict(int), # 按年份统计的提交次数
|
||||
'commit_by_week': defaultdict(int), # 按周统计的提交次数
|
||||
'file_operations': { # 文件操作统计
|
||||
'created': set(), # 创建的文件
|
||||
'deleted': set(), # 删除的文件
|
||||
'modified': set(), # 修改的文件
|
||||
},
|
||||
'review_comments': 0, # 代码审查评论数(如果可用)
|
||||
'merge_commits': 0, # 合并提交数
|
||||
'commit_streak': 0, # 最长连续提交天数
|
||||
'current_streak': 0, # 当前连续提交天数
|
||||
'contribution_days': [], # 所有贡献的日期列表(用于热图)
|
||||
'code_churn': 0, # 代码周转率(添加后又删除的代码)
|
||||
'file_ownership': {}, # 文件所有权百分比
|
||||
'key_files_modified': set(), # 修改过的关键文件
|
||||
'refactoring_commits': 0, # 重构提交数(基于提交消息分析)
|
||||
'bug_fix_commits': 0, # 修复bug的提交数
|
||||
'feature_commits': 0, # 新功能提交数
|
||||
'documentation_commits': 0, # 文档相关提交数
|
||||
'commit_size_distribution': defaultdict(int), # 提交大小分布
|
||||
'collaboration_score': 0, # 协作得分
|
||||
'consistency_score': 0, # 一致性得分
|
||||
'expertise_areas': defaultdict(float), # 专业领域(目录/语言)
|
||||
})
|
||||
|
||||
# 存储所有文件的修改者,用于计算协作指标
|
||||
file_authors = defaultdict(set)
|
||||
|
||||
# 存储项目文件的重要性权重 (基于修改频率)
|
||||
file_importance = Counter()
|
||||
|
||||
# 存储用于检测关键词的正则表达式
|
||||
import re
|
||||
refactor_pattern = re.compile(r'refactor|重构', re.IGNORECASE)
|
||||
bugfix_pattern = re.compile(r'fix|修复|bug|问题|issue|错误', re.IGNORECASE)
|
||||
feature_pattern = re.compile(r'feature|功能|新增|add|实现', re.IGNORECASE)
|
||||
docs_pattern = re.compile(r'doc|文档|注释|comment', re.IGNORECASE)
|
||||
|
||||
# 记录每位贡献者的提交日期,用于计算连续贡献天数
|
||||
author_commit_days = defaultdict(set)
|
||||
|
||||
# 定义关键文件路径模式 (可以根据项目自定义)
|
||||
key_file_patterns = [
|
||||
re.compile(r'package\.json$'),
|
||||
re.compile(r'docker-compose\.yml$'),
|
||||
re.compile(r'Dockerfile$'),
|
||||
re.compile(r'tsconfig\..*\.json$'),
|
||||
re.compile(r'/src/index\.[jt]s$'),
|
||||
re.compile(r'README\.md$'),
|
||||
re.compile(r'\.env'),
|
||||
re.compile(r'/main\.[jt]s$'),
|
||||
re.compile(r'/app\.[jt]s$'),
|
||||
]
|
||||
|
||||
# 遍历所有提交
|
||||
for commit in repo.iter_commits(branch):
|
||||
# 过滤日期
|
||||
commit_date = datetime.fromtimestamp(commit.committed_date)
|
||||
if start_date and commit_date < start_date:
|
||||
continue
|
||||
if end_date and commit_date > end_date:
|
||||
continue
|
||||
|
||||
author = commit.author.name
|
||||
stats[author]['commits'] += 1
|
||||
|
||||
# 记录提交日期和时间
|
||||
commit_day = commit_date.date()
|
||||
stats[author]['commit_dates'].add(commit_day)
|
||||
stats[author]['contribution_days'].append(commit_day) # 用于热图
|
||||
stats[author]['commit_hours'][commit_date.hour] += 1
|
||||
stats[author]['commit_weekdays'][commit_date.weekday()] += 1
|
||||
|
||||
# 添加到作者的提交日集合
|
||||
author_commit_days[author].add(commit_day)
|
||||
|
||||
# 按时间段统计
|
||||
year = commit_date.year
|
||||
month = commit_date.month
|
||||
quarter = (month - 1) // 3 + 1
|
||||
week_num = commit_date.isocalendar()[1]
|
||||
stats[author]['commit_by_year'][year] += 1
|
||||
stats[author]['commit_by_month'][f"{year}-{month:02d}"] += 1
|
||||
stats[author]['commit_by_quarter'][f"{year}-Q{quarter}"] += 1
|
||||
stats[author]['commit_by_week'][f"{year}-W{week_num:02d}"] += 1
|
||||
|
||||
# 分析提交消息,对提交进行分类
|
||||
commit_message = commit.message.strip()
|
||||
if refactor_pattern.search(commit_message):
|
||||
stats[author]['refactoring_commits'] += 1
|
||||
if bugfix_pattern.search(commit_message):
|
||||
stats[author]['bug_fix_commits'] += 1
|
||||
if feature_pattern.search(commit_message):
|
||||
stats[author]['feature_commits'] += 1
|
||||
if docs_pattern.search(commit_message):
|
||||
stats[author]['documentation_commits'] += 1
|
||||
|
||||
# 记录首次和最近提交
|
||||
if stats[author]['first_commit'] is None or commit_date < stats[author]['first_commit']:
|
||||
stats[author]['first_commit'] = commit_date
|
||||
if stats[author]['last_commit'] is None or commit_date > stats[author]['last_commit']:
|
||||
stats[author]['last_commit'] = commit_date
|
||||
|
||||
# 记录提交消息
|
||||
commit_message = commit.message.strip()
|
||||
stats[author]['commit_messages'].append(commit_message)
|
||||
stats[author]['commit_message_lengths'].append(len(commit_message))
|
||||
|
||||
# 检测是否为合并提交
|
||||
if len(commit.parents) > 1:
|
||||
stats[author]['merge_commits'] += 1
|
||||
|
||||
# 统计添加和删除的行数
|
||||
total_changes = 0
|
||||
modified_files = set()
|
||||
created_files = set()
|
||||
deleted_files = set()
|
||||
directories = set()
|
||||
|
||||
# 尝试获取提交前后的差异,以确定文件操作类型
|
||||
try:
|
||||
if commit.parents:
|
||||
parent = commit.parents[0]
|
||||
diffs = parent.diff(commit)
|
||||
for diff_item in diffs:
|
||||
if diff_item.new_file:
|
||||
if diff_item.b_path:
|
||||
created_files.add(diff_item.b_path)
|
||||
elif diff_item.deleted_file:
|
||||
if diff_item.a_path:
|
||||
deleted_files.add(diff_item.a_path)
|
||||
else:
|
||||
if diff_item.a_path:
|
||||
modified_files.add(diff_item.a_path)
|
||||
else:
|
||||
# 对于首次提交,所有文件都是新创建的
|
||||
for file_path in commit.stats.files:
|
||||
created_files.add(file_path)
|
||||
except Exception as e:
|
||||
# 如果获取差异失败,退回到简单的文件修改统计
|
||||
modified_files = set(commit.stats.files.keys())
|
||||
|
||||
for file_path, item in commit.stats.files.items():
|
||||
# 统计文件类型
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext: # 确保扩展名不为空
|
||||
stats[author]['file_types'][ext] += 1
|
||||
else:
|
||||
stats[author]['file_types']['no_extension'] += 1
|
||||
|
||||
# 记录目录
|
||||
directory = os.path.dirname(file_path)
|
||||
if directory:
|
||||
directories.add(directory)
|
||||
|
||||
# 记录修改的文件
|
||||
modified_files.add(file_path)
|
||||
|
||||
# 记录文件的修改者,用于计算协作指标
|
||||
file_authors[file_path].add(author)
|
||||
|
||||
# 统计添加和删除的行数
|
||||
stats[author]['additions'] += item['insertions']
|
||||
stats[author]['deletions'] += item['deletions']
|
||||
total_changes += item['insertions'] + item['deletions']
|
||||
|
||||
# 更新修改过的文件和目录集合
|
||||
stats[author]['files_modified'].update(modified_files)
|
||||
stats[author]['directories_modified'].update(directories)
|
||||
stats[author]['file_operations']['created'].update(created_files)
|
||||
stats[author]['file_operations']['deleted'].update(deleted_files)
|
||||
stats[author]['file_operations']['modified'].update(modified_files - created_files - deleted_files)
|
||||
|
||||
# 记录本次提交的修改量
|
||||
stats[author]['commit_sizes'].append(total_changes)
|
||||
|
||||
# 记录提交大小分布
|
||||
commit_size_category = "小型(1-10行)" if total_changes <= 10 else \
|
||||
"中型(11-100行)" if total_changes <= 100 else \
|
||||
"大型(101-500行)" if total_changes <= 500 else \
|
||||
"超大型(500+行)"
|
||||
stats[author]['commit_size_distribution'][commit_size_category] += 1
|
||||
|
||||
# 更新最大单次提交修改量
|
||||
if total_changes > stats[author]['largest_commit']:
|
||||
stats[author]['largest_commit'] = total_changes
|
||||
|
||||
# 检查修改的文件是否为关键文件
|
||||
for file_path in modified_files:
|
||||
for pattern in key_file_patterns:
|
||||
if pattern.search(file_path):
|
||||
stats[author]['key_files_modified'].add(file_path)
|
||||
break
|
||||
|
||||
# 更新文件重要性权重
|
||||
for file_path in modified_files:
|
||||
file_importance[file_path] += 1
|
||||
|
||||
# 计算影响力得分 (基于修改的文件数和总修改行数)
|
||||
impact = total_changes * len(modified_files) / 100 if modified_files else 0
|
||||
stats[author]['impact_score'] += impact
|
||||
|
||||
# 计算文件协作度和文件所有权
|
||||
for file_path, authors in file_authors.items():
|
||||
# 如果只有一个作者修改了文件,则该作者100%拥有此文件
|
||||
if len(authors) == 1:
|
||||
author = next(iter(authors))
|
||||
if 'file_ownership' not in stats[author]:
|
||||
stats[author]['file_ownership'] = {}
|
||||
stats[author]['file_ownership'][file_path] = 100.0
|
||||
else:
|
||||
# 如果多个作者修改了文件,则按照每个作者的修改比例计算所有权
|
||||
for author in authors:
|
||||
# 简化处理:平均分配所有权
|
||||
ownership_percent = 100.0 / len(authors)
|
||||
if 'file_ownership' not in stats[author]:
|
||||
stats[author]['file_ownership'] = {}
|
||||
stats[author]['file_ownership'][file_path] = ownership_percent
|
||||
|
||||
# 计算每个作者的连续提交天数
|
||||
for author, commit_days in author_commit_days.items():
|
||||
if not commit_days:
|
||||
continue
|
||||
|
||||
# 按日期排序
|
||||
sorted_days = sorted(commit_days)
|
||||
|
||||
# 计算最长提交连续天数
|
||||
current_streak = 1
|
||||
max_streak = 1
|
||||
|
||||
for i in range(1, len(sorted_days)):
|
||||
# 如果当前日期与前一天相差正好一天,则增加连续计数
|
||||
if (sorted_days[i] - sorted_days[i-1]).days == 1:
|
||||
current_streak += 1
|
||||
else:
|
||||
# 重置当前连续计数
|
||||
current_streak = 1
|
||||
|
||||
max_streak = max(max_streak, current_streak)
|
||||
|
||||
# 记录最长连续提交天数
|
||||
stats[author]['commit_streak'] = max_streak
|
||||
|
||||
# 计算当前连续提交天数 (到最后一个日期)
|
||||
if sorted_days:
|
||||
today = datetime.now().date()
|
||||
days_since_last = (today - sorted_days[-1]).days
|
||||
|
||||
if days_since_last <= 1: # 如果最后提交是今天或昨天
|
||||
current_streak = 1
|
||||
for i in range(len(sorted_days) - 1, 0, -1):
|
||||
if (sorted_days[i] - sorted_days[i-1]).days == 1:
|
||||
current_streak += 1
|
||||
else:
|
||||
break
|
||||
stats[author]['current_streak'] = current_streak
|
||||
|
||||
# 后处理:计算派生指标并转换集合为计数
|
||||
for author, data in stats.items():
|
||||
# 将文件集合转换为数量
|
||||
data['files_count'] = len(data['files_modified'])
|
||||
data['active_days'] = len(data['commit_dates'])
|
||||
data['key_files_count'] = len(data['key_files_modified'])
|
||||
|
||||
# 计算平均每次提交的修改量
|
||||
if data['commits'] > 0:
|
||||
data['avg_commit_size'] = sum(data['commit_sizes']) / data['commits']
|
||||
data['median_commit_size'] = statistics.median(data['commit_sizes']) if data['commit_sizes'] else 0
|
||||
|
||||
# 计算代码复杂度得分 (基于修改量、文件数和一致性)
|
||||
variability = statistics.stdev(data['commit_sizes']) if len(data['commit_sizes']) > 1 else 0
|
||||
data['complexity_score'] = (data['avg_commit_size'] * data['files_count'] * (1 + variability / 1000)) / 100
|
||||
|
||||
# 计算一致性得分 (提交大小和频率的一致性)
|
||||
if variability > 0:
|
||||
data['consistency_score'] = 100 * (1 - min(1, variability / data['avg_commit_size']))
|
||||
else:
|
||||
data['consistency_score'] = 100
|
||||
else:
|
||||
data['avg_commit_size'] = 0
|
||||
data['median_commit_size'] = 0
|
||||
data['complexity_score'] = 0
|
||||
data['consistency_score'] = 0
|
||||
|
||||
# 计算总修改量
|
||||
data['total_changes'] = data['additions'] + data['deletions']
|
||||
|
||||
# 计算代码周转率 (code churn) - 估算值
|
||||
if data['additions'] > 0 and data['deletions'] > 0:
|
||||
data['code_churn'] = min(data['additions'], data['deletions']) / max(data['additions'], data['deletions']) * 100
|
||||
|
||||
# 计算活跃时长(天)
|
||||
if data['first_commit'] and data['last_commit']:
|
||||
delta = data['last_commit'] - data['first_commit']
|
||||
data['active_period_days'] = delta.days + 1
|
||||
|
||||
# 计算活跃密度 (提交数/活跃天数)
|
||||
if delta.days > 0:
|
||||
data['activity_density'] = data['commits'] / delta.days
|
||||
else:
|
||||
data['activity_density'] = data['commits']
|
||||
else:
|
||||
data['active_period_days'] = 0
|
||||
data['activity_density'] = 0
|
||||
|
||||
# 计算协作得分 (基于参与修改的共享文件比例)
|
||||
total_files = len(data['files_modified'])
|
||||
shared_files = sum(1 for f in data['files_modified'] if len(file_authors[f]) > 1)
|
||||
if total_files > 0:
|
||||
data['collaboration_score'] = (shared_files / total_files) * 100
|
||||
|
||||
# 计算专业领域 (基于文件类型和目录)
|
||||
if data['file_types']:
|
||||
primary_type = max(data['file_types'].items(), key=lambda x: x[1])[0]
|
||||
data['primary_file_type'] = primary_type
|
||||
data['primary_file_type_percent'] = (data['file_types'][primary_type] / sum(data['file_types'].values())) * 100
|
||||
|
||||
# 统计目录专业度
|
||||
if data['directories_modified']:
|
||||
dir_counts = Counter()
|
||||
for directory in data['directories_modified']:
|
||||
dir_counts[directory] += 1
|
||||
|
||||
# 检查父目录
|
||||
parent = os.path.dirname(directory)
|
||||
while parent:
|
||||
dir_counts[parent] += 0.5 # 对父目录给予较低的权重
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# 找出专业领域(最常修改的目录)
|
||||
if dir_counts:
|
||||
primary_dir = max(dir_counts.items(), key=lambda x: x[1])[0]
|
||||
data['primary_directory'] = primary_dir
|
||||
data['expertise_areas'][primary_dir] = dir_counts[primary_dir] / sum(dir_counts.values())
|
||||
|
||||
return stats
|
||||
|
||||
def print_stats(stats):
|
||||
"""打印贡献者统计信息的详细报告"""
|
||||
# 基本信息表头
|
||||
print("\n===== 贡献者基本统计 =====")
|
||||
print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format(
|
||||
"作者", "提交数", "添加行数", "删除行数", "总修改行数", "修改文件数", "活跃天数"))
|
||||
print("-" * 90)
|
||||
|
||||
# 按总修改量排序
|
||||
for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True):
|
||||
print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format(
|
||||
author,
|
||||
data['commits'],
|
||||
data['additions'],
|
||||
data['deletions'],
|
||||
data['total_changes'],
|
||||
data['files_count'],
|
||||
data['active_days']
|
||||
))
|
||||
|
||||
# 为每个贡献者打印详细信息
|
||||
for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True):
|
||||
print(f"\n\n===== {author} 的详细贡献统计 =====")
|
||||
|
||||
# 活跃时间信息
|
||||
if data['first_commit'] and data['last_commit']:
|
||||
print(f"首次提交时间: {data['first_commit'].strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"最近提交时间: {data['last_commit'].strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"活跃时长: {data['active_period_days']} 天")
|
||||
|
||||
# 提交规模信息
|
||||
print(f"平均每次提交修改: {data['avg_commit_size']:.2f} 行")
|
||||
print(f"最大单次提交修改: {data['largest_commit']} 行")
|
||||
|
||||
# 文件类型分布
|
||||
if data['file_types']:
|
||||
print("\n文件类型分布:")
|
||||
for ext, count in sorted(data['file_types'].items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {ext}: {count} 次修改")
|
||||
|
||||
# 提交时间分布
|
||||
if data['commit_hours']:
|
||||
print("\n提交时间分布:")
|
||||
for hour in range(24):
|
||||
count = data['commit_hours'].get(hour, 0)
|
||||
if count > 0:
|
||||
print(f" {hour:02d}:00-{hour+1:02d}:00: {count} 次提交")
|
||||
|
||||
# 工作日分布
|
||||
if data['commit_weekdays']:
|
||||
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
print("\n工作日分布:")
|
||||
for day in range(7):
|
||||
count = data['commit_weekdays'].get(day, 0)
|
||||
if count > 0:
|
||||
print(f" {weekday_names[day]}: {count} 次提交")
|
||||
|
||||
def get_team_summary(stats):
|
||||
"""生成团队整体统计摘要"""
|
||||
summary = {
|
||||
'total_commits': 0,
|
||||
'total_additions': 0,
|
||||
'total_deletions': 0,
|
||||
'total_files': set(),
|
||||
'contributors': len(stats),
|
||||
'first_commit': None,
|
||||
'last_commit': None,
|
||||
}
|
||||
|
||||
for author, data in stats.items():
|
||||
summary['total_commits'] += data['commits']
|
||||
summary['total_additions'] += data['additions']
|
||||
summary['total_deletions'] += data['deletions']
|
||||
summary['total_files'].update(data['files_modified'])
|
||||
|
||||
# 更新首次和最近提交
|
||||
if data['first_commit']:
|
||||
if summary['first_commit'] is None or data['first_commit'] < summary['first_commit']:
|
||||
summary['first_commit'] = data['first_commit']
|
||||
|
||||
if data['last_commit']:
|
||||
if summary['last_commit'] is None or data['last_commit'] > summary['last_commit']:
|
||||
summary['last_commit'] = data['last_commit']
|
||||
|
||||
return summary
|
||||
|
||||
def print_team_summary(summary):
|
||||
"""打印团队整体统计摘要"""
|
||||
print("\n===== 团队整体统计 =====")
|
||||
print(f"贡献者数量: {summary['contributors']}")
|
||||
print(f"总提交次数: {summary['total_commits']}")
|
||||
print(f"总添加行数: {summary['total_additions']}")
|
||||
print(f"总删除行数: {summary['total_deletions']}")
|
||||
print(f"总修改行数: {summary['total_additions'] + summary['total_deletions']}")
|
||||
print(f"修改的文件数: {len(summary['total_files'])}")
|
||||
|
||||
if summary['first_commit'] and summary['last_commit']:
|
||||
print(f"项目起始时间: {summary['first_commit'].strftime('%Y-%m-%d')}")
|
||||
print(f"最近活动时间: {summary['last_commit'].strftime('%Y-%m-%d')}")
|
||||
delta = summary['last_commit'] - summary['first_commit']
|
||||
print(f"项目活跃时长: {delta.days + 1} 天")
|
||||
if __name__ == "__main__":
|
||||
# 设置仓库路径(当前目录)
|
||||
repo_path = '.'
|
||||
|
||||
# 设置日期范围(示例)
|
||||
# 注意:这里使用的是2025年的日期,可能需要根据实际情况调整
|
||||
start_date = datetime(2025, 1, 1) # 修改为更合理的日期范围
|
||||
end_date = datetime(2025, 12, 31)
|
||||
|
||||
print(f"分析Git仓库: {os.path.abspath(repo_path)}")
|
||||
print(f"时间范围: {start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}")
|
||||
|
||||
# 获取统计信息
|
||||
stats = get_contributor_stats(repo_path, start_date, end_date)
|
||||
|
||||
# 打印团队摘要
|
||||
team_summary = get_team_summary(stats)
|
||||
print_team_summary(team_summary)
|
||||
print(stats)
|
Loading…
Reference in New Issue