This commit is contained in:
Rao 2025-02-27 22:08:06 +08:00
commit 1dd03e5b99
21 changed files with 187 additions and 634 deletions

1
apps/web/public/logo.svg Executable file

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

View File

@ -20,7 +20,7 @@ export function BasePostLayout({
<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>
</>

View File

@ -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,25 +18,27 @@ 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 py-2">
<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")) {
@ -59,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
@ -69,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>
</>
@ -99,3 +104,4 @@ export function MainHeader() {
</div>
);
}

View File

@ -11,8 +11,8 @@ 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) {

View File

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

View File

@ -2,10 +2,8 @@ import { Tag } from "antd";
import { PostDto, TaxonomySlug } from "@nice/common";
const TermInfo = ({ post }: { post: PostDto }) => {
console.log("xx", post?.terms);
return (
<>
<div>
{post?.terms && post?.terms?.length > 0 ? (
<div className="flex gap-2 mb-4">
{post?.terms?.map((term: any) => {
@ -15,10 +13,10 @@ const TermInfo = ({ post }: { post: PostDto }) => {
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">
@ -36,7 +34,7 @@ const TermInfo = ({ post }: { post: PostDto }) => {
</Tag>
</div>
)}
</>
</div>
);
};

View File

@ -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}>
{/* 内容区域 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { Card, Typography, Button } from "antd";
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;
@ -20,18 +21,24 @@ export default function PostCard({ post, onClick }: PostCardProps) {
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="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 mb-4 whiteSpace-nowrap">
<div className="flex gap-2 h-7 whiteSpace-nowrap">
<TermInfo post={post}></TermInfo>
</div>
</div>

View File

@ -160,8 +160,3 @@
border-bottom: none;
/* 去除最后一行的底部边框 */
}
.mind-editor {
height: calc(82vh);
width: 100%;
}

View File

@ -100,7 +100,7 @@ server {
# 仅供内部使用
internal;
# 代理到认证服务
proxy_pass http://host.docker.internal:3001/auth/file;
proxy_pass http://host.docker.internal:3000/auth/file;
# 请求优化:不传递请求体
proxy_pass_request_body off;

View File

@ -40,20 +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;
};
studentIds?: string[];
};
export type LectureMeta = {
type?: string;
export type PostMeta = {
thumbnail?: string;
views?: number;
likes?: number;
hates?: number;
};
export type LectureMeta = PostMeta & {
type?: string;
videoUrl?: string;
videoThumbnail?: string;
videoIds?: string[];
@ -65,7 +68,7 @@ export type Lecture = Post & {
meta?: LectureMeta;
};
export type SectionMeta = {
export type SectionMeta = PostMeta & {
objectives?: string[];
};
export type Section = Post & {
@ -74,13 +77,8 @@ 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 = PostDto & {
meta?: CourseMeta;
@ -93,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;
};

View File

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

View File

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

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