This commit is contained in:
Rao 2025-02-27 12:28:54 +08:00
commit 78fc40a719
68 changed files with 1383 additions and 602 deletions

View File

0
apps/server/src/queue/models/post/utils.ts Normal file → Executable file
View File

View File

0
apps/web/src/app/main/course/preview/page.tsx Normal file → Executable file
View File

0
apps/web/src/app/main/course/preview/type.ts Normal file → Executable file
View File

View File

@ -87,11 +87,11 @@ export default function CourseCard({ course, edit = false }: CourseCardProps) {
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 flex items-center">
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<EyeOutlined />
{`观看次数 ${course?.meta?.views || 0}`}
</span>
<span className="text-xs font-medium text-gray-500 flex items-center">
<span className="gap-1 text-xs font-medium text-gray-500 flex items-center">
<BookOutlined />
{`学习人数 ${course?.studentIds?.length || 0}`}
</span>
@ -102,7 +102,7 @@ export default function CourseCard({ course, edit = false }: CourseCardProps) {
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 ? "进行编辑" : "立即学习"}
{edit ? "编辑" : "立即学习"}
</Button>
</div>
</div>

View File

@ -6,16 +6,7 @@ import CourseCard from "./CourseCard";
import PostCard from "@web/src/components/models/course/card/PostCard";
export function CoursesContainer() {
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, // 使用类型断言
};
const {searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
@ -24,27 +15,8 @@ export function CoursesContainer() {
pageSize: 12,
where: {
type: PostType.COURSE,
AND: termFilters.map((termFilter) => ({
terms: {
some: {
id: {
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
},
},
},
})),
OR: [
{ title: searchCondition },
{ subTitle: searchCondition },
{ content: searchCondition },
{
terms: {
some: {
name: searchCondition,
},
},
},
],
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>

View File

@ -1,68 +0,0 @@
import { Checkbox, Divider, Radio, Space, Spin } from "antd";
import { TaxonomySlug, TermDto } from "@nice/common";
import { useEffect, useMemo, useState } from "react";
import { api } from "@nice/client";
import { useSearchParams } from "react-router-dom";
import TermSelect from "@web/src/components/models/term/term-select";
import { useMainContext } from "../../layout/MainProvider";
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
export default function FilterSection() {
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 z-0 p-6 rounded-lg mt-4 shadow-sm w-1/6 space-y-6 h-[820px] fixed overscroll-contain overflow-x-hidden">
{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-[400px] overscroll-contain overflow-x-hidden"
onChange={(selected) =>
handleTermChange(
tax?.slug,
selected as string[]
)
}
taxonomyId={tax?.id}
></TermParentSelector>
{/* <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>
);
}

View File

@ -1,19 +0,0 @@
import FilterSection from "../components/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;

View File

@ -1,8 +1,18 @@
import AllCoursesLayout from "./layout/AllCoursesLayout";
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 (
<>
<AllCoursesLayout></AllCoursesLayout>
<BasePostLayout>
<CoursesContainer></CoursesContainer>
</BasePostLayout>
</>
);
}

View File

@ -19,11 +19,11 @@ const CategorySection = () => {
taxonomy: {
slug: TaxonomySlug.CATEGORY,
},
parentId : null
parentId: null,
},
take: 8,
});
const navigate = useNavigate()
const navigate = useNavigate();
const handleMouseEnter = useCallback((index: number) => {
setHoveredIndex(index);
@ -33,13 +33,13 @@ const CategorySection = () => {
setHoveredIndex(null);
}, []);
const handleMouseClick = useCallback((categoryId:string) => {
const handleMouseClick = useCallback((categoryId: string) => {
setSelectedTerms({
[TaxonomySlug.CATEGORY] : [categoryId]
})
navigate('/courses')
window.scrollTo({top: 0,behavior: "smooth",})
},[]);
[TaxonomySlug.CATEGORY]: [categoryId],
});
navigate("/courses");
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
return (
<section className="py-8 relative overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative">
@ -57,7 +57,7 @@ const CategorySection = () => {
{isLoading ? (
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
) : (
courseCategoriesData.map((category, index) => {
courseCategoriesData?.map((category, index) => {
const categoryColor = stringToColor(category.name);
const isHovered = hoveredIndex === index;

View File

View File

0
apps/web/src/app/main/home/components/LookForMore.tsx Normal file → Executable file
View File

View File

@ -0,0 +1,29 @@
import { ReactNode, useEffect } from "react";
import FilterSection from "./FilterSection";
import { useMainContext } from "../MainProvider";
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">
<div className=" flex">
<div className="w-1/6">
<FilterSection></FilterSection>
</div>
<div className="w-5/6 p-4">{children}</div>
</div>
</div>
</>
);
}
export default BasePostLayout;

View File

@ -1,11 +1,12 @@
import { Divider } from "antd";
import { api } from "@nice/client";
import { useMainContext } from "../../layout/MainProvider";
import { useMainContext } from "../MainProvider";
import TermParentSelector from "@web/src/components/models/term/term-parent-selector";
export default function PathFilter() {
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,
@ -13,7 +14,8 @@ export default function PathFilter() {
});
};
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">
{showSearchMode && <SearchModeRadio></SearchModeRadio>}
{taxonomies?.map((tax, index) => {
const items = Object.entries(selectedTerms).find(
([key, items]) => key === tax.slug
@ -25,17 +27,16 @@ export default function PathFilter() {
</h3>
<TermParentSelector
value={items}
slug = {tax?.slug}
className="w-70 max-h-[500px] overscroll-contain overflow-x-hidden"
slug={tax?.slug}
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
onChange={(selected) =>
handleTermChange(
tax?.slug,
selected as string[]
)
}
taxonomyId={tax?.id}
></TermParentSelector>
taxonomyId={tax?.id}></TermParentSelector>
<Divider></Divider>
</div>
);
})}

View File

@ -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>
);
}

View File

@ -1,68 +1,76 @@
import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
import {
CloudOutlined,
FileSearchOutlined,
HomeOutlined,
MailOutlined,
PhoneOutlined,
} from "@ant-design/icons";
export function MainFooter() {
return (
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 z-20 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<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>
<p className="text-gray-400 text-xs italic">
</p>
</div>
return (
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 relative z-10 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<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>
<p className="text-gray-400 text-xs italic">
</p>
</div>
{/* 联系方式 */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">628118</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
</div>
</div>
{/* 联系方式 */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">
628118
</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<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="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
<CloudOutlined className="text-lg" />
</a>
{/* 系统链接 */}
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站">
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云">
<CloudOutlined className="text-lg" />
</a>
<a
href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询"
>
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
<a
href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询">
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
{/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-gray-400 text-xs">
© {new Date().getFullYear()} . All rights reserved.
</p>
</div>
</div>
</footer>
);
{/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-gray-400 text-xs">
© {new Date().getFullYear()} . All rights
reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@ -1,10 +1,16 @@
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
import { EditFilled, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import {
EditFilled,
PlusOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
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();
@ -12,76 +18,83 @@ export function MainHeader() {
const navigate = useNavigate();
const { searchValue, setSearchValue } = useMainContext();
return (
<div className="select-none flex items-center justify-between p-4 bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
<div className="flex items-center gap-4">
<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">
<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
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">
</div>
<NavigationMenu />
</div>
<NavigationMenu />
</div>
<Input
size="large"
prefix={
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
}
placeholder="搜索课程"
className="w-96 rounded-full"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e) => {
if (
!window.location.pathname.startsWith(
"/courses/"
)
) {
navigate(`/courses/`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
<div className=" flex justify-end gap-4 mr-2">
<Input
size="large"
prefix={
<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />
}
}}
/>
<div className="flex items-center gap-4">
{isAuthenticated && (
<>
placeholder="搜索课程"
className="w-96 rounded-full"
value={searchValue}
onClick={(e) => {
if (!window.location.pathname.startsWith("/search")) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={(e) => {
if (!window.location.pathname.startsWith("/search")) {
navigate(`/search`);
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
/>
<div className="flex items-center gap-4">
{isAuthenticated && (
<>
<Button
onClick={() => {
const url = id
? `/course/${id}/editor`
: "/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 />}>
{id ? "编辑课程" : "创建课程"}
</Button>
</>
)}
{isAuthenticated && (
<Button
onClick={() => {
const url = id
? `/course/${id}/editor`
: "/course/editor";
navigate(url);
window.location.href = "/path/editor";
}}
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 />}>
{id ? "编辑课程" : "创建课程"}
icon={<PlusOutlined></PlusOutlined>}>
</Button>
</>
)}
{
isAuthenticated && <Button
onClick={() => {
window.location.href = "/path/editor";
}}
icon={<PlusOutlined></PlusOutlined>} ></Button>
}
{isAuthenticated ? (
<UserMenu />
) : (
<Button
onClick={() => navigate("/login")}
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
icon={<UserOutlined />}>
</Button>
)}
)}
{isAuthenticated ? (
<UserMenu />
) : (
<Button
onClick={() => navigate("/login")}
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
icon={<UserOutlined />}>
</Button>
)}
</div>
</div>
</div>
);

View File

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

70
apps/web/src/app/main/layout/MainProvider.tsx Normal file → Executable file
View File

@ -1,4 +1,11 @@
import React, { createContext, ReactNode, useContext, useState } from "react";
import { PostType, Prisma } from "packages/common/dist";
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
} from "react";
interface SelectedTerms {
[key: string]: string[]; // 每个 slug 对应一个 string 数组
}
@ -8,6 +15,14 @@ interface MainContextType {
selectedTerms?: SelectedTerms;
setSearchValue?: React.Dispatch<React.SetStateAction<string>>;
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);
@ -16,8 +31,55 @@ 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(() => {
return Object.entries(selectedTerms)
.filter(([, terms]) => terms.length > 0)
?.map(([, terms]) => terms);
}, [selectedTerms]);
const termsCondition: Prisma.PostWhereInput = useMemo(() => {
return termFilters && termFilters?.length > 0
? {
AND: termFilters.map((termFilter) => ({
terms: {
some: {
id: {
in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中
},
},
},
})),
}
: {};
}, [termFilters]);
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 (
<MainContext.Provider
value={{
@ -25,6 +87,12 @@ export function MainProvider({ children }: MainProviderProps) {
setSearchValue,
selectedTerms,
setSelectedTerms,
searchCondition,
termsCondition,
searchMode,
setSearchMode,
showSearchMode,
setShowSearchMode,
}}>
{children}
</MainContext.Provider>

View File

@ -14,13 +14,15 @@ export const NavigationMenu = () => {
{ 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: "我学习的" },
{ key: "my-duty", path: "/my-duty", label: "我的授课" },
{ key: "my-learning", path: "/my-learning", label: "我的课程" },
{ key: "my-path", path: "/my-path", label: "我的路径" },
];
}
}, [isAuthenticated]);
@ -31,6 +33,7 @@ export const NavigationMenu = () => {
<Menu
mode="horizontal"
className="border-none font-medium"
disabledOverflow={true}
selectedKeys={[selectedKey]}
onClick={({ key }) => {
const selectedItem = menuItems.find((item) => item.key === key);

View File

12
apps/web/src/app/main/layout/UserMenu/UserForm.tsx Normal file → Executable file
View File

@ -12,15 +12,7 @@ import toast from "react-hot-toast";
export default function StaffForm() {
const { user } = useAuth();
const { create, update } = useStaff(); // Ensure you have these methods in your hooks
const {
formLoading,
modalOpen,
setModalOpen,
domainId,
setDomainId,
form,
setFormLoading,
} = useContext(UserEditorContext);
const {formLoading,modalOpen,setModalOpen,domainId,setDomainId,form,setFormLoading,} = useContext(UserEditorContext);
const {
data,
isLoading,
@ -76,6 +68,8 @@ export default function StaffForm() {
}
useEffect(() => {
form.resetFields();
console.log('cc',data);
if (data) {
form.setFieldValue("username", data.username);
form.setFieldValue("showname", data.showname);

View File

@ -86,20 +86,7 @@ export function UserMenu() {
setModalOpen(true);
},
},
{
icon: <UserOutlined className="text-lg" />,
label: "我创建的课程",
action: () => {
navigate("/my/duty");
},
},
{
icon: <UserOutlined className="text-lg" />,
label: "我学习的课程",
action: () => {
navigate("/my/learning");
},
},
canManageAnyStaff && {
icon: <SettingOutlined className="text-lg" />,
label: "设置",

0
apps/web/src/app/main/layout/UserMenu/types.ts Normal file → Executable file
View File

View File

@ -0,0 +1,29 @@
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";
export default function MyDutyListContainer() {
const { user } = useAuth();
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => (
<CourseCard edit course={post}></CourseCard>
)}
params={{
pageSize: 12,
where: {
type: PostType.COURSE,
authorId: user.id,
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}

32
apps/web/src/app/main/my-duty/page.tsx Normal file → Executable file
View File

@ -1,24 +1,16 @@
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 PostCard from "@web/src/components/models/course/card/PostCard";
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 { user } = useAuth();
const { setSearchMode } = useMainContext();
useEffect(() => {
setSearchMode(PostType.COURSE);
}, [setSearchMode]);
return (
<>
<div className="p-4">
<PostList
renderItem={post=><PostCard course={post}></PostCard>}
params={{
pageSize: 12,
where: {
authorId: user.id,
},
}}
cols={4}></PostList>
</div>
</>
<BasePostLayout>
<MyDutyListContainer></MyDutyListContainer>
</BasePostLayout>
);
}

View File

@ -0,0 +1,32 @@
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";
export default function MyLearningListContainer() {
const { user } = useAuth();
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => (
<CourseCard edit={false} course={post}></CourseCard>
)}
params={{
pageSize: 12,
where: {
type: PostType.COURSE,
students: {
some: {
id: user?.id,
},
},
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}

33
apps/web/src/app/main/my-learning/page.tsx Normal file → Executable file
View File

@ -1,26 +1,17 @@
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 { 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 { user } = useAuth();
const { setSearchMode } = useMainContext();
useEffect(() => {
setSearchMode(PostType.COURSE);
}, [setSearchMode]);
return (
<>
<div className="p-4">
<PostList
renderItem={post => <CourseCard edit={false} course={post}></CourseCard>}
params={{
pageSize: 12,
where: {
students: {
some: {
id: user?.id,
},
},
},
}}
cols={4}></PostList>
</div>
</>
<BasePostLayout>
<MyLearningListContainer></MyLearningListContainer>
</BasePostLayout>
);
}

View File

@ -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 PathCard from "../../path/components/PathCard";
export default function MyPathListContainer() {
const { user } = useAuth();
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
renderItem={(post) => <PathCard path={post}></PathCard>}
params={{
pageSize: 12,
where: {
type: PostType.PATH,
authorId: user.id,
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
import BasePostLayout from "../layout/BasePost/BasePostLayout";
import { useMainContext } from "../layout/MainProvider";
import 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>
);
}

View File

@ -0,0 +1,22 @@
import { TeamOutlined } from "@ant-design/icons";
import { Typography } from "antd";
const { Title, Text } = Typography;
const DeptInfo = ({ path }) => {
return (
<div className="gap-1 flex items-center flex-grow">
<TeamOutlined className="text-blue-500 text-lg transform group-hover:scale-110 transition-transform duration-300" />
{path?.depts && path?.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}
</Text>
) : (
<Text className="font-medium text-blue-500 hover:text-blue-600 transition-colors duration-300 truncate max-w-[120px]">
</Text>
)}
</div>
);
};
export default DeptInfo;

View File

@ -1,10 +1,11 @@
import { Card, Rate, Tag, Typography, Button } from "antd";
import { Card, Tag, Typography, Button } from "antd";
import {
PlayCircleOutlined,
TeamOutlined,
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;
}
@ -13,15 +14,14 @@ export default function PathCard({ path }: pathCardProps) {
const navigate = useNavigate();
const handleClick = (path: PostDto) => {
navigate(`/path/editor/${path.id}`);
window.scrollTo({ top: 0, behavior: "smooth", })
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"
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
@ -34,28 +34,7 @@ export default function PathCard({ path }: pathCardProps) {
</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>
<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">
@ -63,20 +42,12 @@ export default function PathCard({ path }: pathCardProps) {
</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">
<DeptInfo path={path} />
<span className="flex text-xs font-medium text-gray-500">
<EyeOutlined className="mr-2"></EyeOutlined>
{path?.meta?.views
? `观看次数 ${path?.meta?.views}`
: null}
: 0}
</span>
</div>
<div className="pt-4 border-t border-gray-100 text-center">

View File

@ -1,21 +1,11 @@
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";
import PostCard from "@web/src/components/models/course/card/PostCard";
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, // 使用类型断言
};
const { searchCondition, termsCondition } = useMainContext();
return (
<>
<PostList
@ -24,32 +14,12 @@ export function PathListContainer() {
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,
},
},
},
],
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}
export default PathListContainer;

View File

@ -0,0 +1,41 @@
import { Tag } from "antd";
import { TaxonomySlug } from "@nice/common";
const TermInfo = ({ path }) => {
console.log('xx',path?.terms);
return <>
{path?.terms && path?.terms?.length > 0 ? (
<div className="flex gap-2 mb-4">
{path?.terms?.map((term:any) => {
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 border-0">
{term.name}
</Tag>
);
})}
</div>
) : (
<div className="flex gap-2 mb-4">
<Tag
color={"orange"}
className="px-3 py-1 rounded-full border-0">
{"未设置分类"}
</Tag>
</div>
)}
</>
};
export default TermInfo;

10
apps/web/src/app/main/path/editor/page.tsx Normal file → Executable file
View File

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

View File

@ -1,20 +0,0 @@
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;

View File

@ -1,5 +1,17 @@
import PathListLayout from "./layout/PathListLayout";
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() {
return <PathListLayout />
const { setSearchMode } = useMainContext();
useEffect(() => {
setSearchMode(PostType.PATH);
}, [setSearchMode]);
return (
<BasePostLayout>
<PathListContainer></PathListContainer>
</BasePostLayout>
);
}

View File

@ -0,0 +1,23 @@
import PostList from "@web/src/components/models/course/list/PostList";
import { useMainContext } from "../../layout/MainProvider";
import PathCard from "../../path/components/PathCard";
import { useEffect } from "react";
export default function SearchListContainer() {
const { searchCondition, termsCondition, searchMode } = useMainContext();
return (
<>
<PostList
renderItem={(post) => <PathCard path={post}></PathCard>}
params={{
pageSize: 12,
where: {
type: searchMode === "both" ? undefined : searchMode,
...termsCondition,
...searchCondition,
},
}}
cols={4}></PostList>
</>
);
}

View File

@ -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>
);
}

View File

View File

@ -1,15 +1,22 @@
import { Button, Card, Empty, Form, Space, Spin, message, theme } from 'antd';
import NodeMenu from './NodeMenu';
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';
import { Button, Card, Empty, Form, Space, Spin, message, theme } from "antd";
import NodeMenu from "./NodeMenu";
import { 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,
@ -17,53 +24,54 @@ const MIND_OPTIONS = {
toolBar: true,
nodeMenu: true,
keypress: true,
locale: 'zh_CN' as const,
locale: "zh_CN" as const,
theme: {
name: 'Latte',
name: "Latte",
palette: [
'#dd7878',
'#ea76cb',
'#8839ef',
'#e64553',
'#fe640b',
'#df8e1d',
'#40a02b',
'#209fb5',
'#1e66f5',
'#7287fd',
"#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',
"--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);
const { data: post, isLoading }: { data: PostDto, isLoading: boolean } = api.post.findFirst.useQuery({
where: {
id
},
select: postDetailSelect
})
const navigate = useNavigate()
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } =
api.post.findFirst.useQuery({
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()
const { handleFileUpload } = useTusUpload();
const [form] = Form.useForm();
useEffect(() => {
if (post && form && instance && id) {
console.log(post)
instance.refresh((post as any).meta)
console.log(post);
instance.refresh((post as any).meta);
const deptIds = (post?.depts || [])?.map((dept) => dept.id);
const formData = {
title: post.title,
@ -82,116 +90,137 @@ export default function MindEditor({ id }: { id?: string }) {
...MIND_OPTIONS,
el: containerRef.current,
});
mind.init(MindElixir.new('新学习路径'));
mind.init(MindElixir.new("新学习路径"));
containerRef.current.hidden = true;
setInstance(mind);
}, []);
useEffect(() => {
if ((!id || post) && instance) {
containerRef.current.hidden = false
instance.toCenter()
instance.refresh((post as any)?.meta)
containerRef.current.hidden = false;
instance.toCenter();
instance.refresh((post as any)?.meta);
}
}, [id, post, instance])
}, [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: {
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: {
set: termIds.map((id) => ({ id }))
connect: termIds.map((id) => ({ id })),
},
depts: {
set: deptIds.map((id) => ({ id })),
connect: deptIds.map((id) => ({ id })),
},
updatedAt: new Date()
}
updatedAt: new Date(),
};
const res = await create.mutateAsync({ data: params });
navigate(`/path/editor/${res.id}`, { replace: true });
toast.success("创建成功");
}
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;
}
} catch (error) {
toast.error('保存失败');
throw error;
}
console.log(result)
}, (error) => { }, `mind-thumb-${new Date().toString()}`)
console.log(result);
},
(error) => {},
`mind-thumb-${new Date().toString()}`
);
};
return (
<div className=' flex flex-col border rounded-lg overflow-hidden'>
<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='flex items-center justify-between gap-4'>
<div className='flex items-center gap-4'>
<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
>
// rules={[{ required: true }]}
noStyle>
<TermSelect
className=' w-48'
className=" w-48"
placeholder={`请选择${tax.name}`}
taxonomyId={tax.id}
/>
</Form.Item>
))}
<Form.Item name="deptIds" noStyle>
<DepartmentSelect className='w-96' placeholder='请选择制作单位' multiple />
<Form.Item
// rules={[{ required: true }]}
name="deptIds"
noStyle>
<DepartmentSelect
className="w-96"
placeholder="请选择制作单位"
multiple
/>
</Form.Item>
</div>
<Button ghost type='primary' onClick={handleSave} >{id ? '更新' : '保存'}</Button>
<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 ref={containerRef} className="mind-editor min-h-screen" />
{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>
);
}

0
apps/web/src/components/common/editor/NodeMenu.tsx Normal file → Executable file
View File

0
apps/web/src/components/common/editor/i18n.ts Normal file → Executable file
View File

0
apps/web/src/components/common/editor/types.ts Normal file → Executable file
View File

0
apps/web/src/components/common/input/InputList.tsx Normal file → Executable file
View File

View File

View File

0
apps/web/src/components/common/uploader/utils.tsx Normal file → Executable file
View File

View File

@ -36,12 +36,8 @@ 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();
@ -64,6 +60,7 @@ export function CourseDetailProvider({
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
return isAuthor || isRoot;
}, [user, course]);
const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined
>(lectureId || undefined);

View File

@ -15,14 +15,13 @@ import dayjs from "dayjs";
import { useNavigate, useParams } from "react-router-dom";
export const CourseDetailDescription: React.FC = () => {
const { course, isLoading, selectedLectureId, setSelectedLectureId } =
const { course,canEdit, isLoading, selectedLectureId, setSelectedLectureId } =
useContext(CourseDetailContext);
const { Paragraph, Title } = Typography;
const firstLectureId = useMemo(() => {
return course?.sections?.[0]?.lectures?.[0]?.id;
}, [course]);
const navigate = useNavigate();
const { canEdit } = useContext(CourseDetailContext);
const { id } = useParams();
return (
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">

View File

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

View File

@ -1,8 +1,15 @@
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 {
BookOutlined,
CalendarOutlined,
EditTwoTone,
EyeOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import CourseOperationBtns from "./JoinLearingButton";
export default function CourseDetailTitle() {
const {
@ -19,14 +26,14 @@ export default function CourseDetailTitle() {
<div className="flex justify-start w-full text-2xl font-bold">
{course?.title}
</div>
<div className="text-gray-600 flex w-full justify-start gap-5">
<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></ReloadOutlined>
<ReloadOutlined spin></ReloadOutlined>
{"更新于:"}
{dayjs(course?.updatedAt).format("YYYY年M月D日")}
</div>
@ -38,19 +45,7 @@ export default function CourseDetailTitle() {
<BookOutlined />
<div>{`学习人数${course?.studentIds?.length || 0}`}</div>
</div>
{canEdit && (
<div
className="flex gap-1 text-primary hover:cursor-pointer"
onClick={() => {
const url = course?.id
? `/course/${course?.id}/editor`
: "/course/editor";
navigate(url);
}}>
<EditTwoTone></EditTwoTone>
{"点击编辑课程"}
</div>
)}
<CourseOperationBtns />
</div>
</div>
);

View File

@ -58,7 +58,7 @@ export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
style={{
width: isOpen ? "25%" : "0",
right: 0,
top: isHeaderVisible ? "64px" : "0",
top: isHeaderVisible ? "56px" : "0",
}}
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
{isOpen && (

View File

@ -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>
)}
</>
);
}

View File

@ -2,7 +2,7 @@ import { Pagination, Empty, Skeleton } from "antd";
import { courseDetailSelect, CourseDto, Prisma } from "@nice/common";
import { api } from "@nice/client";
import { DefaultArgs } from "@prisma/client/runtime/library";
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
interface PostListProps {
params?: {
page?: number;
@ -12,8 +12,7 @@ interface PostListProps {
};
cols?: number;
showPagination?: boolean;
renderItem: (post: any) => React.ReactNode
renderItem: (post: any) => React.ReactNode;
}
interface PostPagnationProps {
data: {
@ -21,13 +20,12 @@ interface PostPagnationProps {
totalPages: number;
};
isLoading: boolean;
}
export default function PostList({
params,
cols = 3,
showPagination = true,
renderItem
renderItem,
}: PostListProps) {
const [currentPage, setCurrentPage] = useState<number>(params?.page || 1);
const { data, isLoading }: PostPagnationProps =
@ -72,9 +70,9 @@ export default function PostList({
{isLoading ? (
<Skeleton paragraph={{ rows: 5 }}></Skeleton>
) : (
posts.map((post) => <div key={post.id}>
{renderItem(post)}
</div>)
posts.map((post) => (
<div key={post.id}>{renderItem(post)}</div>
))
)}
</div>
{showPagination && (
@ -91,7 +89,6 @@ export default function PostList({
</>
) : (
<div className="py-64">
<Empty description="暂无数据" />
</div>
)}

View File

View File

@ -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,14 @@ export const routes: CustomRouteObject[] = [
</WithAuth>
),
},
{
path: "search",
element: <SearchPage></SearchPage>,
},
{
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />,
},
],
},
@ -125,10 +143,6 @@ export const routes: CustomRouteObject[] = [
},
],
},
{
path: ":id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />,
},
],
},
adminRoute,

View File

@ -42,12 +42,12 @@ export type PostDto = Post & {
};
watchableDepts: Department[];
watchableStaffs: Staff[];
terms: TermDto[]
depts: DepartmentDto[]
terms: TermDto[];
depts: DepartmentDto[];
meta?: {
thumbnail?: string
views?: number
}
thumbnail?: string;
views?: number;
};
};
export type LectureMeta = {
@ -81,7 +81,7 @@ export type CourseMeta = {
likes?: number;
hates?: number;
};
export type Course = Post & {
export type Course = PostDto & {
meta?: CourseMeta;
};
export type CourseDto = Course & {

0
packages/common/src/models/resource.ts Normal file → Executable file
View File

503
scripts/git_stats.py Executable file
View File

@ -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)

0
web-dist/index.html Normal file → Executable file
View File