├── .editorconfig ├── .env ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── bin ├── commit-detail.js ├── commit-time.js └── index.js ├── package.json ├── public ├── _source.js ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.jsx ├── App.test.js ├── asset │ └── iconfont-2019-07-04.woff ├── component │ ├── author-file.jsx │ ├── author-wordcloud.jsx │ ├── commit-pane.jsx │ ├── commit-time.js │ ├── echarts.jsx │ └── tree.jsx ├── const │ └── index.js ├── context │ └── tree-context.js ├── index.css ├── index.js ├── logo.svg ├── service │ ├── color.js │ ├── echarts-bar.js │ ├── echarts-commit-time.js │ ├── echarts-pie.js │ └── echarts-wordcloud.js └── serviceWorker.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig编码规范约束 2 | # http://EditorConfig.org 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GENERATE_SOURCEMAP=false 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # misc 12 | .DS_Store 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | .editorconfig 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # lock 25 | yarn.lock 26 | 27 | # source code 28 | /src 29 | /public 30 | 31 | # build 32 | /build/service-worker.js 33 | /build/manifest.json 34 | /build/asset-manifest.json 35 | /build/precache-*.js 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 jingzhiMo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 介绍 2 | 这是一个统计 git 仓库贡献的工具;主要统计作者所贡献的行数;统计每个文件夹和文件的贡献占比,还有项目中文件类型的占比等。具体如下: 3 | 4 | 1. 统计每个文件夹下成员的贡献占比 5 | 2. 统计每个文件夹下不同文件的占比 6 | 3. 统计成员commit的数量与每个commit平均更改行数 7 | 4. 统计成员贡献的文件类型占比 8 | 5. 统计成员commit的提交信息词云分析 9 | 10 | ## 使用方法 11 | 12 | ### 对本地仓库进行分析 13 | * 使用 npx 命令生成(推荐) 14 | 15 | ```bash 16 | $ cd git-repository /* 进入需要统计的 git 仓库文件夹 */ 17 | $ npx visualize-commit 18 | ``` 19 | 20 | * 安装包到对应仓库 21 | 22 | ```bash 23 | $ cd git-repository 24 | $ npm install visualize-commit --save-dev 25 | # or 26 | $ yarn add visualize-commit --dev 27 | ``` 28 | 29 | 在`package.json`加入对应的脚本: 30 | 31 | ```json 32 | { 33 | "scripts": { 34 | "vsz": "vsz-commit" 35 | } 36 | } 37 | ``` 38 | 39 | 执行命令: 40 | 41 | ```bash 42 | $ npm run vsz 43 | # or 44 | $ yarn add vsz 45 | ``` 46 | 47 | ### 对远端的仓库进行分析 48 | 49 | 通过配置 `-g` 命令,设定需要分析的远端仓库,例如: 50 | 51 | ```bash 52 | $ npx visualize-commit -g git@github.com:jingzhiMo/visualize-commit.git 53 | ``` 54 | 55 | **目前只支持 git 协议的克隆方式,不支持 https 的方式** 56 | 57 | ## 依赖环境 58 | 59 | * node > 8 (支持 async function) 建议安装最新稳定版 node 版本 60 | * npx (建议安装,通常 npm 5.2.0 版本之后会自动安装) 61 | * git 62 | 63 | ## 统计截图 64 | 下面的统计截图是对[`create-react-app`仓库](https://github.com/facebook/create-react-app)的`v3.3.0`版本统计的demo 65 | 66 | * 统计每个文件夹下成员的贡献占比 67 | 68 | ![vsz-1.png](https://i.loli.net/2020/04/16/GvIqZgNJBsuAUy9.png) 69 | 70 | * 统计每个文件夹下不同文件的占比 71 | 72 | ![vsz-2.png](https://i.loli.net/2020/04/16/N71aEZRvFm85uA3.png) 73 | 74 | * 统计成员commit的数量与每个commit平均更改行数 75 | 76 | ![vsz-3.png](https://i.loli.net/2020/04/16/l2y6HX8SzwtEJO3.png) 77 | 78 | * 统计成员贡献的文件类型占比 79 | 80 | ![vsz-4.png](https://i.loli.net/2020/04/16/oGiWFhc457CbAdV.png) 81 | 82 | * 统计成员commit的提交信息词云分析 83 | 84 | ![vsz-5.png](https://i.loli.net/2020/04/16/UerDEdBCoI6Qsbk.png) 85 | -------------------------------------------------------------------------------- /bin/commit-detail.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process') 2 | const nodejieba = require('nodejieba') 3 | const { BLACK_LIST } = require('../src/const') 4 | 5 | // 特殊字符 6 | const specialStringPattern = /[~!@#$%^&*()_\-+=`\[\]{}|\\;:'",<.>\/?~!@¥()——「」【】、;:‘“”’《》,。?]+/g 7 | // 英文字符 8 | const enPattern = /\w+/g 9 | // 非英文字符 10 | const notEnPattern = /[^a-zA-Z]+/g 11 | 12 | /** 13 | * @description 把 commit 信息进行拆解 14 | * @param {string} msg 15 | * @returns {Map} 16 | */ 17 | const splitCommitMsg = msg => { 18 | const data = new Map() 19 | msg = msg.replace(specialStringPattern, ' ') 20 | // 对英文单词进行分割 21 | const en = msg.replace(notEnPattern, ' ').trim().split(' ') 22 | const notEn = msg.replace(enPattern, ' ').trim() 23 | const word = nodejieba.cut(notEn) 24 | 25 | en.forEach(item => { 26 | if (!item) return 27 | data.set(item, data.get(item) + 1 || 1) 28 | }) 29 | word.forEach(item => { 30 | if (!item.trim()) return 31 | 32 | data.set(item, data.get(item) + 1 || 1) 33 | }) 34 | 35 | return data 36 | } 37 | 38 | /** 39 | * @description 合并两个关键词的对象 40 | */ 41 | const mergeSplitData = (baseData, addData) => { 42 | baseData = baseData || new Map() 43 | 44 | for (let [key, value] of addData) { 45 | const baseValue = baseData.get(key) || 0 46 | baseData.set(key, baseValue + addData.get(key)) 47 | } 48 | 49 | return baseData 50 | } 51 | 52 | const collectAuthorCommitMsg = async (cwd) => { 53 | const command = spawn( 54 | `git`, 55 | // 是用于唯一标识分行的分隔符 56 | [ 57 | 'log', 58 | `--pretty=%an,%B`, 59 | `--no-merges` 60 | ], 61 | { 62 | cwd: cwd.slice(0, -1) 63 | } 64 | ) 65 | 66 | const authorCommitMsg = {} 67 | 68 | return new Promise((resolve, reject) => { 69 | command.stdout.on('data', data => { 70 | const originData = data.toString() 71 | 72 | const lineData = originData.split('') 73 | const pattern = /([^,]+)(.*)/ 74 | 75 | lineData.forEach(line => { 76 | line = line.replace(/[\n\r\f]+/, '') 77 | if (!line) return 78 | 79 | const match = line.match(pattern) 80 | const author = match[1] 81 | const msg = match[2].slice(1) 82 | 83 | if (!author || BLACK_LIST.includes(author)) return 84 | 85 | authorCommitMsg[author] = mergeSplitData(authorCommitMsg[author], splitCommitMsg(msg)) 86 | authorCommitMsg['all'] = mergeSplitData(authorCommitMsg['all'], splitCommitMsg(msg)) 87 | }) 88 | }) 89 | command.stderr.on('data', error => { 90 | reject(error) 91 | }) 92 | command.on('close', code => { 93 | const authorName = Object.keys(authorCommitMsg) 94 | const result = {} 95 | 96 | // 对用户的关键词获取前100个 97 | authorName.forEach(name => { 98 | const mapData = authorCommitMsg[name] 99 | const sortArray = [] 100 | 101 | for (let [name, value] of mapData) { 102 | sortArray.push({ 103 | name, 104 | value 105 | }) 106 | } 107 | 108 | result[name] = sortArray.sort((a, b) => b.value - a.value).slice(0, 100) 109 | }) 110 | resolve(result) 111 | }) 112 | }) 113 | } 114 | 115 | module.exports = { 116 | collectAuthorCommitMsg 117 | } 118 | -------------------------------------------------------------------------------- /bin/commit-time.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process') 2 | const { BLACK_LIST } = require('../src/const') 3 | 4 | // 统计每个 commit 的时间 5 | const collectTime = (cwd) => { 6 | const command = spawn( 7 | `git`, 8 | // 是用于唯一标识分行的分隔符 9 | [ 10 | 'log', 11 | `--pretty=%an,%at`, 12 | `--no-merges` 13 | ], 14 | { 15 | cwd: cwd.slice(0, -1) 16 | } 17 | ) 18 | 19 | const authorObject = { 20 | 'all': [] 21 | } 22 | 23 | return new Promise((resolve, reject) => { 24 | command.stdout.on('data', data => { 25 | const lineData = [...data.toString().replace(/[\r\f]+/, '').matchAll(/([^,]+),(\d+)\n/g)] 26 | 27 | lineData.forEach(line => { 28 | const [, author, time] = line 29 | 30 | if (!author || BLACK_LIST.includes(author)) return 31 | 32 | authorObject[author] = authorObject[author] || [] 33 | authorObject[author].push(time) 34 | authorObject.all.push(time) 35 | }) 36 | }) 37 | command.stderr.on('data', error => { 38 | reject(error) 39 | }) 40 | command.on('close', code => { 41 | const result = [] 42 | 43 | Object.keys(authorObject).forEach(author => { 44 | result.push({ 45 | author, 46 | time: authorObject[author] 47 | }) 48 | }) 49 | resolve(result) 50 | }) 51 | }) 52 | } 53 | 54 | module.exports = collectTime 55 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path') 3 | const fs = require('fs') 4 | const opn = require('open') 5 | const { program } = require('commander') 6 | const { exec, spawn } = require('child_process') 7 | const copyFolder = require('fs-extra').copySync 8 | const { collectAuthorCommitMsg } = require('./commit-detail') 9 | const collectTime = require('./commit-time') 10 | 11 | // 当前执行的路径 12 | const CURRENT_PATH = process.cwd() 13 | 14 | // 仓库名称 15 | let REPO_NAME = CURRENT_PATH.split('/').reverse()[0] 16 | 17 | // 进入用户文件夹的命令 18 | let CD_COMMAND = CURRENT_PATH === __dirname ? '' : `cd ${CURRENT_PATH} && ` 19 | 20 | // 需要剔除的关键词 21 | const EXCLUDE_PATTERN = [ 22 | 'package-lock.json', 23 | 'yarn.lock', 24 | // 忽略图片与办公软件等 25 | /ico|jpe?g|png|gif|webp|mp4|xlsx?|docx?|pptx?/ 26 | ] 27 | 28 | // 文件后缀映射 29 | const EXT_MAP = { 30 | '.': '_anonymous' 31 | } 32 | 33 | // 文件夹节点id 34 | let _uid = 0 35 | 36 | function isFile(path) { 37 | return fs.lstatSync(path).isFile() 38 | } 39 | 40 | function isFolder(path) { 41 | return fs.lstatSync(path).isDirectory() 42 | } 43 | 44 | function isString(str) { 45 | return Object.prototype.toString.call(str).toLowerCase() === '[object string]' 46 | } 47 | 48 | function isRegExp(p) { 49 | return Object.prototype.toString.call(p).toLowerCase() === '[object regexp]' 50 | } 51 | 52 | /** 53 | * @desc 删除文件夹 54 | * @param {String} path 需要删除文件夹的路径 55 | */ 56 | function rmdir(path) { 57 | if (path[path.length - 1] !== '/') path = path + '/' 58 | 59 | let allFile = fs.readdirSync(path) 60 | 61 | allFile.forEach(file => { 62 | file = path + file 63 | 64 | if (isFile(file)) { 65 | fs.unlinkSync(file) 66 | } else { 67 | rmdir(file + '/') 68 | } 69 | }) 70 | 71 | // 删除当前文件夹 72 | fs.rmdirSync(path) 73 | } 74 | 75 | function count(fileFormat) { 76 | return new Promise((resolve, reject) => { 77 | exec(`cat ${fileFormat} | wc -l`, (err, data) => { 78 | if (err) { 79 | reject(err) 80 | } else { 81 | let lineNum = parseInt(data.trim()) 82 | 83 | if (isNaN(lineNum)) { 84 | reject('load line numer error', data) 85 | } else { 86 | resolve(lineNum) 87 | } 88 | } 89 | }) 90 | }) 91 | } 92 | 93 | // 统计文件的 git 提交行数 94 | function countGitContribution(fileName) { 95 | // command line 96 | // git ls-files | while read f; do git blame --line-porcelain $f | grep '^author '; done | sort -f | uniq -ic | sort -n 97 | return new Promise((resolve, reject) => { 98 | exec(`${CD_COMMAND}git ls-files ${fileName} | while read f; do git blame --line-porcelain $f | grep '^author '; done | sort -f | uniq -ic | sort -n`, function (err, data) { 99 | if (err) { 100 | reject(err) 101 | return 102 | } 103 | 104 | let line = data.split('\n').filter(item => item).map(item => item.trim()) 105 | 106 | line = line.map(item => { 107 | let detail = item.split('author') 108 | let line 109 | let author 110 | 111 | // 部分文件没有对应的 author ,例如 图片,字体图标等 112 | if (detail.length < 2) { 113 | line = 0 114 | author = null 115 | } else { 116 | line = parseInt(detail[0].trim()) 117 | author = detail[1].trim() 118 | } 119 | 120 | return { 121 | line, 122 | author 123 | } 124 | }).sort((a, b) => b.line - a.line) 125 | 126 | resolve(line) 127 | }) 128 | }) 129 | } 130 | 131 | // 统计当前仓库的不同用户的commit数量 132 | function countGitCommit() { 133 | const tty = process.platform === 'win32' ? 'CON' : '/dev/tty' 134 | return new Promise((resolve, reject) => { 135 | exec(`${CD_COMMAND}git shortlog -sn --no-merges < ${tty}`, { cwd: "./" }, (err, data) => { 136 | if (err) { 137 | reject(err) 138 | return 139 | } 140 | 141 | let author = data.split('\n').filter(item => item).map(item => { 142 | let msg = item.trim().split('\t') 143 | 144 | return { 145 | commit: parseInt(msg[0]) || 0, 146 | author: msg[1] 147 | } 148 | }) 149 | 150 | resolve(author) 151 | }) 152 | }) 153 | } 154 | 155 | // 统计文件夹下直接文件的数量 156 | async function countFolder(path, fileType) { 157 | let data = {} 158 | 159 | // 当前文件夹的文件类型 160 | for (let i = 0; i < fileType.length; i++) { 161 | let type = fileType[i] 162 | 163 | data[type] = await count(`${path}*.${type}`) 164 | } 165 | 166 | return data 167 | } 168 | 169 | /** 170 | * @desc 获取文件的后缀 171 | * @params {String} file 文件名称 172 | * 173 | * @return {String} 文件后缀 174 | */ 175 | function getFileExt(file) { 176 | // 以 . 开头的文件 177 | if (file[0] === '.') { 178 | return file[0] 179 | } 180 | let tmp = file.split('.') 181 | 182 | return tmp[tmp.length - 1] 183 | } 184 | 185 | /** 186 | * @desc 判断文件是否符合统计范围 187 | * @params {String} file 文件名称,不包含路径 188 | * @params {Array} fileType 统计的文件类型 189 | * 190 | * @return {Boolean} 判断文件是否有效 191 | */ 192 | function isValidFile(file, fileType) { 193 | // 跳过当前文件统计 194 | if (skipFile(file)) return false 195 | 196 | let ext = getFileExt(file) 197 | // 匹配所有的文件 198 | if (fileType[0] === '*') return true 199 | 200 | return fileType.indexOf(ext) > -1 201 | } 202 | 203 | /** 204 | * @desc 判断文件/文件夹是否在 git 跟踪下 205 | * @params {String} file 文件/文件夹的路径 206 | * 207 | * @return {Promise} 208 | */ 209 | function isGitTrack(file) { 210 | return new Promise((resolve, reject) => { 211 | exec(`${CD_COMMAND}git ls-files ${file}`, (err, data) => { 212 | if (err) { 213 | reject(err) 214 | return 215 | } 216 | 217 | if (!data) { 218 | // TODO need to write log 219 | // console.log(`${file} file is not track`) 220 | } 221 | 222 | resolve(!!data) 223 | }) 224 | }) 225 | } 226 | 227 | /** 228 | * @desc 判断文件是否需要跳过 229 | * @params {String} file 文件/文件夹的路径 230 | * 231 | * @return {Boolean} 232 | */ 233 | function skipFile(file) { 234 | return EXCLUDE_PATTERN.some(pattern => { 235 | // 通过字符串忽略文件路径 236 | if (isString(pattern)) { 237 | return file.includes(pattern) 238 | } 239 | 240 | return isRegExp(pattern) && pattern.test(file) 241 | }) 242 | } 243 | 244 | /** 245 | * @desc 合并两个 contribution 数组 246 | * @params {Array} target 合并目标的数组 247 | * @params {Array} source 需要合并的数组 248 | */ 249 | function mergeContribution(target, source) { 250 | // 没有需要合并的数组 251 | if (!source.length) return target 252 | 253 | let newAuthor = [] 254 | 255 | // 保留所有文件贡献数量 256 | source.forEach(item => { 257 | let i 258 | 259 | for (i = 0; i < target.length; i++) { 260 | // 作者相同,则合并贡献代码行数 261 | if (target[i].author === item.author) { 262 | target[i].line += item.line 263 | break 264 | } 265 | } 266 | 267 | // 没有找到匹配的作者 268 | if (i === target.length) { 269 | newAuthor.push({ 270 | author: item.author, 271 | line: item.line 272 | }) 273 | } 274 | }) 275 | // 只保留根目录的贡献数量 276 | // while (source.length) { 277 | // let item = source.splice(0, 1)[0] 278 | // let i 279 | 280 | // for (i = 0; i < target.length; i++) { 281 | // // 作者相同,则合并贡献代码行数 282 | // if (target[i].author === item.author) { 283 | // target[i].line += item.line 284 | // break 285 | // } 286 | // } 287 | 288 | // // 没有找到匹配的作者 289 | // if (i === target.length) { 290 | // newAuthor.push(item) 291 | // } 292 | // } 293 | 294 | return target.concat(newAuthor) 295 | } 296 | 297 | /** 298 | * @desc 递归统计文件夹 299 | * @params {String} path 需要统计的源路径 300 | * @params {String} folderName 文件夹的名称 301 | * @params {Array} fileType 统计的文件类型 302 | */ 303 | async function countProject(path, folderName, fileType = ['*']) { 304 | // 判断当前文件夹是否在git跟踪 305 | if (!await isGitTrack(path)) { 306 | return 307 | } 308 | 309 | // 是否需要跳过该路径 310 | if (skipFile(path)) { 311 | return 312 | } 313 | 314 | // 当前文件夹的数据结构 315 | let thisData = { 316 | id: _uid++, 317 | name: folderName, 318 | path: path.split(REPO_NAME).reverse()[0], 319 | // 这行代码是统计文件夹下所有文件的行数,文件夹下可能包括部分文件不在 git 跟踪;所以不准确 320 | // code: await countFolder(path, fileType), 321 | code: {}, 322 | line: 0, 323 | children: [], 324 | file: [], 325 | contribution: [] 326 | } 327 | 328 | // 深度优先遍历子文件夹 329 | let allFile = fs.readdirSync(path) 330 | let folderList = [] 331 | 332 | for (let i = 0; i < allFile.length; i++) { 333 | let fileName = allFile[i] 334 | let file = path + fileName 335 | 336 | if (isFolder(file)) { 337 | let folderData = await countProject(file + '/', fileName, fileType) 338 | 339 | if (folderData) { 340 | thisData.children.push(folderData) 341 | } 342 | 343 | continue 344 | } 345 | 346 | // 统计单个文件的代码行数,需要文件符合规范;并且需要在 git 的跟踪下 347 | if (isValidFile(fileName, fileType) && await isGitTrack(file)) { 348 | let fileDetail = { 349 | name: fileName, 350 | path: file.split(REPO_NAME).reverse()[0], 351 | line: 0, 352 | // line: await count(file), 353 | type: getFileExt(fileName), 354 | contribution: await countGitContribution(file) 355 | } 356 | 357 | fileDetail.line = fileDetail.contribution.reduce((base, item) => { 358 | return base + item.line 359 | }, 0) 360 | thisData.file.push(fileDetail) 361 | } 362 | } 363 | 364 | let line = 0 // 该文件夹包含子文件夹与文件的代码行数 365 | let code = {} // 该文件夹包含文件类型分类的代码数量 366 | let contribution = [] 367 | 368 | // 对该文件夹对应的文件进行遍历 369 | thisData.file.forEach(item => { 370 | let type = EXT_MAP[item.type] || item.type 371 | 372 | item.id = _uid++ 373 | // 统计具体文件数量类型 374 | code[type] = (code[type] || 0) + item.line 375 | // 统计行数 376 | line += item.line 377 | // 统计贡献代码数量 378 | contribution = mergeContribution(contribution, item.contribution) 379 | }) 380 | // 对子文件夹进行遍历 381 | thisData.children.forEach(item => { 382 | // 遍历子文件夹的所有文件类型 383 | for (let type in item.code) { 384 | code[type] = code[type] || 0 385 | code[type] += item.code[type] 386 | } 387 | 388 | // 统计行数 389 | line += item.line 390 | // 统计贡献代码数量 391 | contribution = mergeContribution(contribution, item.contribution) 392 | }) 393 | 394 | thisData.code = code 395 | thisData.line = line 396 | thisData.contribution = contribution 397 | 398 | return thisData 399 | } 400 | 401 | /** 402 | * @description 克隆远端的仓库 403 | * @param {string} repo 远端仓库的地址 404 | */ 405 | function gitClone(repo) { 406 | let tmpFolderPath = 'tmp_' 407 | let suffix = 0 408 | const generateTargetPath = () => { 409 | return CURRENT_PATH + '/' + tmpFolderPath + suffix 410 | } 411 | let targetPath = generateTargetPath() 412 | 413 | while (fs.existsSync(targetPath)) { 414 | suffix++ 415 | targetPath = generateTargetPath() 416 | } 417 | 418 | return new Promise((resolve, reject) => { 419 | fs.mkdirSync(tmpFolderPath + suffix) 420 | 421 | const child = spawn( 422 | 'git', 423 | [ 424 | 'clone', 425 | repo, 426 | // 在命令行能够显示克隆仓库的过程 427 | '--progress' 428 | ], 429 | { 430 | cwd: targetPath 431 | } 432 | ); 433 | // 解决使用子进行无法显示 git clone 过程信息 434 | child.stdout.pipe(process.stdout); 435 | child.stderr.pipe(process.stderr); 436 | 437 | child.on('exit', code => { 438 | if (code === 0) { 439 | console.log('\n clone repository successful.\n') 440 | resolve(targetPath) 441 | } else { 442 | console.log(`\n clone repository failder. error code is ${code}`) 443 | 444 | // 删除临时新建的文件夹 445 | fs.rmdirSync(tmpFolderPath, { recursive: true }) 446 | reject() 447 | } 448 | }) 449 | }) 450 | } 451 | 452 | // 读取输入的参数,git 为远端的仓库 453 | program.version('0.0.1') 454 | .option('-g, --git ', 'use origin git repository') 455 | .parse(process.argv) 456 | 457 | // 程序启动 458 | async function run() { 459 | let gitCloneTmpFolder 460 | let repoPath 461 | 462 | if (program.git) { 463 | try { 464 | gitCloneTmpFolder = (await gitClone(program.git)) + '/' 465 | // 临时文件夹中,只有一个文件夹,是克隆下来的 git 仓库 466 | REPO_NAME = fs.readdirSync(gitCloneTmpFolder)[0] 467 | repoPath = gitCloneTmpFolder + REPO_NAME + '/' 468 | CD_COMMAND = `cd ${repoPath} && ` 469 | } catch (err) { 470 | console.log('git clone error', err) 471 | return 472 | } 473 | } else { 474 | repoPath = CURRENT_PATH + '/' 475 | } 476 | 477 | console.time('count') 478 | console.log('analyzing...May be it take some times') 479 | 480 | Promise.all([ 481 | countProject( 482 | repoPath, 483 | REPO_NAME, 484 | ['*'] 485 | ), 486 | countGitCommit(), 487 | collectAuthorCommitMsg(repoPath), 488 | collectTime(repoPath), 489 | ]).then(data => { 490 | // 词云与commit数量 只保留前20个用户 491 | const showAuthor = data[1].slice(0, 20).map(d => d.author) 492 | const wordCloudData = {} 493 | 494 | showAuthor.concat(['all']).forEach(author => { 495 | wordCloudData[author] = data[2][author] 496 | }) 497 | let summary = { 498 | codeData: data[0], 499 | commitData: data[1].slice(0, 20), 500 | wordCloudData, 501 | commitTime: data[3] 502 | } 503 | 504 | let json = 'window._source = ' + JSON.stringify(summary, null, 2) 505 | let file = '_source.js' 506 | let targetPath = CURRENT_PATH + '/commit-analyze/' 507 | 508 | // 判断是否存在,存在则删除 509 | if (fs.existsSync(targetPath)) { 510 | rmdir(targetPath) 511 | } 512 | 513 | // 复制 html 等文件 514 | copyFolder(path.resolve(__dirname + '/../build'), targetPath) 515 | 516 | // 删除克隆仓库的文件夹 517 | if (gitCloneTmpFolder) { 518 | rmdir(gitCloneTmpFolder) 519 | } 520 | 521 | // 写入统计文件 522 | fs.writeFile(targetPath + file, json, 'utf-8', (err, data) => { 523 | if (err) { 524 | throw new Error('write file error', err) 525 | } 526 | 527 | console.timeEnd('count') 528 | console.log('succesful!') 529 | // 自动打开 530 | opn(targetPath + 'index.html') 531 | }) 532 | }).catch(error => { 533 | console.error('analyze error', error.toString()) 534 | }) 535 | } 536 | 537 | run() 538 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visualize-commit", 3 | "version": "1.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "bin": { 7 | "vsz-commit": "./bin/index.js" 8 | }, 9 | "author": "mojingzhi", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jingzhiMo/visualize-commit.git" 13 | }, 14 | "keywords": [ 15 | "git", 16 | "data visualize", 17 | "echarts" 18 | ], 19 | "license": "MIT", 20 | "dependencies": { 21 | "commander": "^6.0.0", 22 | "fs-extra": "^8.1.0", 23 | "lodash": "^4.17.20", 24 | "nodejieba": "^2.4.1", 25 | "open": "^7.0.3" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "homepage": "./", 49 | "devDependencies": { 50 | "antd": "^3.20.7", 51 | "classnames": "^2.2.6", 52 | "cz-conventional-changelog": "3.0.2", 53 | "echarts": "^4.2.1", 54 | "echarts-wordcloud": "^1.1.3", 55 | "react": "^16.13.1", 56 | "react-dom": "^16.13.1", 57 | "react-scripts": "^3.0.1" 58 | }, 59 | "config": { 60 | "commitizen": { 61 | "path": "./node_modules/cz-conventional-changelog" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/_source.js: -------------------------------------------------------------------------------- 1 | // TODO 该文件需要通过命令生成数据 2 | window._source = {} 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jingzhiMo/visualize-commit/f32d0ff94dbb7c563a1c9719cfaf94db68647ccf/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | visualize-commit 11 | 12 | 13 | 14 |
15 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useMemo } from 'react' 2 | import logo from './logo.svg' 3 | import './App.css' 4 | import Tree from './component/tree.jsx' 5 | import Echarts from './component/echarts.jsx' 6 | import AuthorFile from './component/author-file.jsx' 7 | import AuthorWordCloud from './component/author-wordcloud.jsx' 8 | import CommitPane from './component/commit-pane.jsx' 9 | import CommitTimePane from './component/commit-time' 10 | import { pie } from './service/echarts-pie' 11 | import treeContext from './context/tree-context' 12 | 13 | const { codeData, commitData, wordCloudData, commitTime } = window._source 14 | 15 | /** 16 | * @desc 深度优先查找文件 17 | * @param {Number} id 文件对应的id 18 | * @param {String} type 文件/文件夹 19 | * @param {Object} treeData 需要查找的树状数据 20 | */ 21 | function depthFindFile (id, type, treeData) { 22 | // 找到当前的节点数据 23 | if (id === treeData.id) return treeData 24 | 25 | // 选中节点的数据 26 | let nodeData = null 27 | 28 | // 查找文件夹 29 | if (type === 'folder') { 30 | for (let i = 0; i < treeData.children.length; i++) { 31 | nodeData = depthFindFile(id, type, treeData.children[i]) 32 | 33 | if (nodeData) break 34 | } 35 | 36 | return nodeData 37 | } 38 | 39 | // 查找文件 40 | // 文件所在当前文件夹内 41 | if (treeData.file.length && id >= treeData.file[0].id) { 42 | return treeData.file.find(item => item.id === id) 43 | } 44 | 45 | // 文件不在当前文件夹 46 | for (let i = 0; i < treeData.children.length; i++) { 47 | nodeData = depthFindFile(id, type, treeData.children[i]) 48 | 49 | if (nodeData) break 50 | } 51 | 52 | return nodeData 53 | } 54 | 55 | /** 56 | * @desc 提取当前文件夹的简要数据 57 | * @param {Object} detailData 当前文件夹的详细数据 58 | */ 59 | function extractSummary (detailData) { 60 | let key = ['code', 'contribution', 'file', 'id', 'line', 'name', 'path', 'type', 'children'] 61 | let summary = {} 62 | 63 | key.forEach(k => { 64 | summary[k] = detailData[k] 65 | }) 66 | 67 | return summary 68 | } 69 | 70 | /** 71 | * @desc 提取代码贡献数据 72 | * @param {Object} sourceData 树状图的源数据 73 | */ 74 | function extractContribution (sourceData) { 75 | let contribution = sourceData.contribution.slice(0).sort((a, b) => b.line - a.line) 76 | return { 77 | data: contribution.map(item => { 78 | return { 79 | value: item.line, 80 | name: item.author 81 | } 82 | }).sort((a, b) => b.value - a.value).slice(0, 20), 83 | legendData: contribution.map(item => item.author) 84 | } 85 | } 86 | 87 | /** 88 | * @desc 提取代码文件类型数据 89 | * @param {Object} sourceData 树状图的源数据 90 | */ 91 | function extractFileType (sourceData) { 92 | let code = sourceData.code 93 | let data = [] 94 | 95 | // 文件节点没有对应的代码 96 | if (!code) { 97 | return { 98 | data: [{ 99 | name: sourceData.type, 100 | value: sourceData.line 101 | }], 102 | legendData: [sourceData.type] 103 | } 104 | } 105 | 106 | for (let key in code) { 107 | data.push({ 108 | name: key, 109 | value: code[key] 110 | }) 111 | } 112 | 113 | data.sort((a, b) => b.value - a.value) 114 | 115 | return { 116 | data, 117 | legendData: data.map(item => item.name) 118 | } 119 | } 120 | 121 | function App() { 122 | const [selectNodeId, setSelectNodeId] = useState(0) 123 | const [treeData, setTreeData] = useState(extractSummary(depthFindFile(0, 'folder', codeData))) 124 | 125 | // 修改节点数据 126 | const selectNode = useCallback((id, type) => { 127 | setSelectNodeId(id) 128 | setTreeData(extractSummary(depthFindFile(id, type, codeData))) 129 | }, []) 130 | const contextValue = { 131 | selectNodeId, 132 | selectNode 133 | } 134 | 135 | const codeContribution = useMemo(() => { 136 | return pie(extractContribution(treeData), { 137 | title: '代码贡献占比' 138 | }) 139 | }, [treeData]) 140 | const fileContribution = useMemo(() => { 141 | return pie(extractFileType(treeData), { 142 | title: '文件数量占比' 143 | }) 144 | }, [treeData]) 145 | 146 | return
147 | 156 |
157 | logo 158 |

159 | 代码统计概览 160 |

161 |
162 |
163 |

文件路径:{treeData.path}

164 |

该文件/文件夹代码行数为:{treeData.line}

165 |
166 |
167 |
168 | 172 | 176 |
177 | 178 | 179 | 180 | 181 |
182 |
183 | } 184 | 185 | export default App; 186 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/asset/iconfont-2019-07-04.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jingzhiMo/visualize-commit/f32d0ff94dbb7c563a1c9719cfaf94db68647ccf/src/asset/iconfont-2019-07-04.woff -------------------------------------------------------------------------------- /src/component/author-file.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Select from 'antd/es/select' 3 | import 'antd/dist/antd.css' 4 | import Echarts from './echarts.jsx' 5 | import { pie } from '../service/echarts-pie' 6 | 7 | const { Option } = Select 8 | 9 | /** 10 | * @desc 合并两个用户贡献代码到到第一个用户 11 | * @param {Object} target 合并目标用户 { author: { json: xx, js: xx } } 12 | * @param {Object} source 合并源用户 13 | */ 14 | function mergeAuthor (target, source) { 15 | for (let name in source) { 16 | let targetAuthor = target[name] 17 | let sourceAuthor = source[name] 18 | 19 | if (targetAuthor) { 20 | for (let fileType in sourceAuthor) { 21 | targetAuthor[fileType] = (targetAuthor[fileType] || 0) + sourceAuthor[fileType] 22 | } 23 | } else { 24 | target[name] = source[name] 25 | } 26 | } 27 | 28 | return target 29 | } 30 | 31 | /** 32 | * @desc 该节点下,每个作者贡献的不同类型 33 | */ 34 | function extractAuthorFile (sourceData) { 35 | let author = {} // e.g { authorName: { vue: 100, json: 50 }} 36 | 37 | // 当前数据是只针对一个文件 38 | if (!sourceData.file && sourceData.contribution) { 39 | let fileType = sourceData.type 40 | 41 | return sourceData.contribution 42 | .reduce((base, item) => { 43 | base[item.author] = {} 44 | base[item.author][fileType] = item.line 45 | 46 | return base 47 | }, author) 48 | } 49 | // 遍历当前节点的文件类型 50 | for (let file of (sourceData.file || [])) { 51 | let fileType = file.type 52 | 53 | for (let contribution of file.contribution) { 54 | let fileLine = {} 55 | const { line, author: name } = contribution 56 | 57 | fileLine[fileType] = line // e.g { vue: 100 } 58 | 59 | if (author[name]) { 60 | author[name][fileType] = (author[name][fileType] || 0) + line 61 | } else { 62 | author[name] = fileLine 63 | } 64 | } 65 | } 66 | 67 | 68 | if (!sourceData.children || !sourceData.children.length) return author 69 | 70 | // 有对应的子节点 71 | return sourceData.children.reduce((base, child) => { 72 | const childAuthor = extractAuthorFile(child) 73 | 74 | // 合并子节点的数据 75 | return mergeAuthor(base, childAuthor) 76 | }, author) 77 | } 78 | 79 | function genAuthor (allAuthorData, authorName) { 80 | let authorData 81 | 82 | // 没有指定用户名,则取第一个 83 | if (!authorName) { 84 | authorName = Object.keys(allAuthorData)[0] 85 | } 86 | 87 | authorData = allAuthorData[authorName] 88 | 89 | let fileList = authorData ? Object.keys(authorData) : [] 90 | let data = fileList.map(type => { 91 | return { 92 | name: type, 93 | value: authorData[type] 94 | } 95 | }).sort((a, b) => b.value - a.value) 96 | return { 97 | title: `${authorName || ''} 贡献文件类型`, 98 | chartData: { 99 | data, 100 | legendData: data.map(item => item.name) 101 | } 102 | } 103 | } 104 | 105 | function AuthorFile (props) { 106 | const showAuthor = props.data.contribution 107 | .sort((a, b) => b.line - a.line) 108 | .slice(0, 20) 109 | .map(item => item.author) 110 | 111 | const allAuthorData = extractAuthorFile(props.data) 112 | const [authorData, setAuthorData] = useState(genAuthor(allAuthorData, showAuthor[0])) 113 | const [selectAuthor, setSelectAuthor] = useState(showAuthor[0]) 114 | 115 | function updateSelect (value) { 116 | setSelectAuthor(value) 117 | setAuthorData(genAuthor(allAuthorData, value)) 118 | } 119 | 120 | return
121 |

122 | 不同用户对应贡献的代码详情 123 |

124 | 135 | 138 |
139 | } 140 | 141 | export default AuthorFile 142 | -------------------------------------------------------------------------------- /src/component/author-wordcloud.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Select from 'antd/es/select' 3 | import Echarts from './echarts.jsx' 4 | import { wordcloud } from '../service/echarts-wordcloud' 5 | import { authorMap } from '../const' 6 | 7 | const { Option } = Select 8 | 9 | function AuthorWordCloud ({ data }) { 10 | const author = [ 11 | 'all', 12 | ...Object.keys(data).filter(name => name !== 'all') 13 | ] 14 | const [selectAuthor, setSelectAuthor] = useState(author[0]) // 默认选中所有人 15 | 16 | const updateSelect = value => { 17 | setSelectAuthor(value) 18 | } 19 | return
20 |

21 | 不同用户 commit 信息文案分析 22 |

23 | 34 | 35 |
36 | } 37 | 38 | export default AuthorWordCloud 39 | -------------------------------------------------------------------------------- /src/component/commit-pane.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { bar } from '../service/echarts-bar.js' 3 | import Echarts from './echarts.jsx' 4 | 5 | function commitPane ({commit: commitData, line: lineData}) { 6 | let chartData 7 | 8 | // 对贡献 commit 进行排序 9 | commitData = commitData.slice(0).sort((a, b) => b.commit - a.commit).map(item => { 10 | let author = lineData.filter(ld => ld.author === item.author)[0] 11 | 12 | if (!author) return null 13 | 14 | return { 15 | ...item, 16 | average: parseInt(author.line / item.commit).toFixed(2) 17 | } 18 | }).filter(item => !!item) 19 | 20 | chartData = bar(commitData) 21 | 22 | return
23 |

24 | 不同用户对应贡献的 commit 详情 25 |

26 | 30 |
31 | } 32 | 33 | export default commitPane 34 | -------------------------------------------------------------------------------- /src/component/commit-time.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Select from 'antd/es/select' 3 | import { commitTimeBar } from '../service/echarts-bar.js' 4 | import Echarts from './echarts.jsx' 5 | import { authorMap } from '../const' 6 | 7 | const { Option } = Select 8 | 9 | export default function CommitTime(props) { 10 | const { data } = props 11 | const [selectAuthor, setSelectAuthor] = useState('all') 12 | 13 | const selectData = data.filter(item => item.author === selectAuthor) 14 | const chartData = commitTimeBar(selectData.length ? selectData[0].time : []) 15 | 16 | return ( 17 |
18 |

19 | 一周每天代码详情 20 |

21 | 32 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/component/echarts.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | 3 | import echart from 'echarts/lib/echarts' 4 | import 'echarts/lib/component/tooltip' 5 | import 'echarts/lib/component/title' 6 | import 'echarts/lib/component/legendScroll' 7 | import 'echarts/lib/component/markLine' 8 | import 'echarts/lib/chart/pie' 9 | import 'echarts/lib/chart/bar' 10 | 11 | // 需要监听 onresize 的方法 12 | let instanceList = [] 13 | 14 | function addInstance (instance) { 15 | instanceList.push(instance) 16 | } 17 | 18 | function removeInstance (instance) { 19 | instanceList = instanceList.filter(item => item.id !== instance.id) 20 | } 21 | 22 | let timer 23 | let handler = () => { 24 | clearTimeout(timer) 25 | timer = setTimeout(() => { 26 | instanceList.forEach(instance => (instance.resize())) 27 | }, 300) 28 | } 29 | window.addEventListener('resize', handler) 30 | 31 | function Echarts (props) { 32 | let instance 33 | 34 | useEffect(() => { 35 | return () => { 36 | if (instance) { 37 | removeInstance(instance) 38 | } 39 | } 40 | }) 41 | 42 | const initRef = (element) => { 43 | if (!element) return 44 | 45 | instance = echart.init(element) 46 | instance.setOption(props.chartData) 47 | addInstance(instance) 48 | } 49 | 50 | return
51 |
echart container
52 |
53 | } 54 | 55 | export default Echarts 56 | -------------------------------------------------------------------------------- /src/component/tree.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react' 2 | import cx from 'classnames' 3 | import treeContext from '../context/tree-context' 4 | 5 | function Tree(props) { 6 | const { treeData, isFolder } = props 7 | const currentNodeId = treeData.id 8 | const { selectNodeId, selectNode } = useContext(treeContext) 9 | const [collapsed, setCollapsed] = useState( 10 | // 根目录默认打开 11 | currentNodeId === 0 12 | ? false 13 | : (props.collapsed || true) 14 | ) 15 | 16 | const toggleCollapse = ev => { 17 | setCollapsed(!collapsed) 18 | ev.stopPropagation() 19 | } 20 | 21 | let children // 当前文件夹的子文件夹 22 | let leaf // 当前文件夹的文件,叶子结点 23 | 24 | // 当前文件夹折叠 25 | if (collapsed) { 26 | children = [] 27 | leaf = [] 28 | } else { 29 | // 当前文件夹展开 30 | children = (treeData.children || []).map((item, idx) => { 31 | return 36 | }) 37 | leaf = (treeData.file || []).map((item, idx) => { 38 | let data = { 39 | ...item 40 | } 41 | 42 | return 47 | }) 48 | } 49 | 50 | return
51 |
selectNode(currentNodeId, isFolder ? 'folder' : 'file')} 54 | > 55 | { 56 | isFolder && 57 | 64 | } 65 | 74 | {treeData.name} 75 | 76 |
77 | {children} 78 | {leaf} 79 |
80 | } 81 | 82 | 83 | export default Tree 84 | -------------------------------------------------------------------------------- /src/const/index.js: -------------------------------------------------------------------------------- 1 | // 不需要统计的作者 2 | exports.BLACK_LIST = [ 3 | 'dependabot[bot]' 4 | ] 5 | // 作者名称的中文映射 6 | exports.authorMap = { 7 | all: '所有人' 8 | } 9 | -------------------------------------------------------------------------------- /src/context/tree-context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const treeContext = createContext({ 4 | selectNodeId: 0, 5 | selectNode: () => {} 6 | }) 7 | treeContext.displayName = 'treeContext' 8 | 9 | export default treeContext 10 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconfont'; 3 | src: url('./asset/iconfont-2019-07-04.woff') format('woff'); 4 | } 5 | 6 | 7 | .t-icon { 8 | display: inline-block; 9 | font-family: "iconfont" !important; 10 | font-style: normal; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | line-height: 1; 14 | } 15 | .t-icon:before { 16 | display: inline-block; 17 | } 18 | .t-icon-right-arrow:before { 19 | content: "\e643"; 20 | } 21 | body { 22 | margin: 0; 23 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 24 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 25 | sans-serif; 26 | -webkit-font-smoothing: antialiased; 27 | -moz-osx-font-smoothing: grayscale; 28 | } 29 | 30 | code { 31 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 32 | monospace; 33 | } 34 | 35 | html, body, #root { 36 | height: 100%; 37 | } 38 | body { 39 | flex: 1; 40 | } 41 | .tree { 42 | display: flex; 43 | height: 100%; 44 | overflow: hidden; 45 | } 46 | .tree-aside { 47 | flex: 0 0 300px; 48 | padding-bottom: 100px; 49 | background-color: #21252b; 50 | overflow-y: auto; 51 | } 52 | .tree-content { 53 | flex: 1 0 auto; 54 | padding: 0 24px; 55 | overflow-y: auto; 56 | } 57 | @keyframes round { 58 | from { 59 | transform: rotate(0); 60 | } 61 | to { 62 | transform: rotate(180deg); 63 | } 64 | } 65 | .t-logo { 66 | width: 100px; 67 | height: 100px; 68 | } 69 | .t-logo.loading { 70 | animation: 1.5s round linear infinite; 71 | } 72 | .t-line { 73 | display: flex; 74 | } 75 | 76 | /*树状节点*/ 77 | .t-node { 78 | display: flex; 79 | flex-direction: column; 80 | } 81 | .t-node > .t-node { 82 | padding-left: 16px; 83 | } 84 | .t-arrow { 85 | display: inline-block; 86 | padding: 4px 2px 4px; 87 | cursor: pointer; 88 | color: #9da5b4; 89 | font-size: 14px; 90 | transition: .15s ease transform; 91 | transform: rotate(0); 92 | } 93 | .t-arrow.rotate { 94 | transform: rotate(90deg); 95 | } 96 | .t-name { 97 | padding-left: 2px; 98 | flex: 1 0 auto; 99 | font-size: 14px; 100 | color: #9da5b4; 101 | } 102 | .t-name.file { 103 | padding-left: 22px; 104 | } 105 | .t-name.active { 106 | background-color: #464a50; 107 | } 108 | .t-name:hover { 109 | color: #fff; 110 | cursor: pointer; 111 | } 112 | .t-text { 113 | display: flex; 114 | flex: 1 0 auto; 115 | align-items: center; 116 | /*padding-left: 4px;*/ 117 | line-height: 22px; 118 | } 119 | .t-children { 120 | padding-left: 16px; 121 | } 122 | /*代码行数的显示*/ 123 | .t-code-line { 124 | color: #606266; 125 | } 126 | 127 | /* 图表相关 */ 128 | .echarts-container { 129 | height: 400px; 130 | padding-bottom: 40px; 131 | } 132 | 133 | .vsz-code-summary { 134 | display: flex; 135 | } 136 | .vsz-code-summary__echart { 137 | flex: 1 0 50%; 138 | } 139 | 140 | 141 | /* 标题相关*/ 142 | .vsz-title { 143 | padding: 26px 0 20px; 144 | font-size: 15px; 145 | font-weight: 700; 146 | color: #5097e9; 147 | text-align: left; 148 | } 149 | .vsz-title span { 150 | display: inline-block; 151 | border-left: 4px solid #5097e9; 152 | padding-left: 4px; 153 | line-height: 1; 154 | vertical-align: middle; 155 | } 156 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App.jsx'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/service/color.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | '#5097e9', 3 | '#00bcd4', 4 | '#ffa726', 5 | '#e57373', 6 | '#8d6e63', 7 | '#9575cd', 8 | '#ff8a65', 9 | '#81c784', 10 | '#ce93d8', 11 | '#90a4ae', 12 | '#9dc6f5', 13 | '#80deea', 14 | '#ffcc80', 15 | '#ef9a9a', 16 | '#bcaaa4', 17 | '#d1c4e9', 18 | '#ffccbc', 19 | '#c8e6c9', 20 | '#e1bee7', 21 | '#cfd8dc' 22 | ] 23 | -------------------------------------------------------------------------------- /src/service/echarts-bar.js: -------------------------------------------------------------------------------- 1 | import { groupBy } from 'lodash' 2 | import color from './color' 3 | 4 | export const bar = (data) => { 5 | let legendData = [] 6 | let commitData = [] 7 | let averageData = [] 8 | 9 | data.forEach(item => { 10 | legendData.push(item.author) 11 | commitData.push(item.commit) 12 | averageData.push(item.average) 13 | }) 14 | 15 | console.log('commit data', commitData) 16 | return { 17 | title: { 18 | text: 'commit 贡献', 19 | x: 'center', 20 | top: 0 21 | }, 22 | color, 23 | legend: { 24 | type: 'scroll', 25 | data: ['commit 贡献数量', 'commit 平均贡献行数'], 26 | width: '80%', 27 | bottom: 0 28 | }, 29 | tooltip : { 30 | trigger: 'item', 31 | showDelay: 20, 32 | formatter (param) { 33 | let tip 34 | 35 | // 平均贡献行数 36 | if (param.seriesIndex === 1) { 37 | tip = ' commit 平均贡献行数' 38 | } else { 39 | tip = '贡献 commit 数量' 40 | } 41 | return `${param.name}
${tip}:${param.data}` 42 | } 43 | }, 44 | xAxis: { 45 | type: 'category', 46 | data: legendData, 47 | axisTick: { 48 | show: false 49 | }, 50 | axisLine: { 51 | show: false, 52 | lineStyle: { 53 | color: '#d0d3da' 54 | } 55 | }, 56 | axisLabel: { 57 | color: '#909399' 58 | }, 59 | splitLine: { 60 | lineStyle: { 61 | color: '#909939', 62 | type: 'solid' 63 | } 64 | } 65 | }, 66 | yAxis: [ 67 | { 68 | name: 'commit 贡献数量', 69 | type: 'value', 70 | axisLabel: { 71 | color: '#909399' 72 | }, 73 | axisLine: { 74 | show: false, 75 | lineStyle: { 76 | color: '#d0d3da' 77 | } 78 | }, 79 | axisTick: { 80 | show: false 81 | }, 82 | splitLine: { 83 | lineStyle: { 84 | color: '#ebeef5' 85 | } 86 | } 87 | }, 88 | { 89 | name: 'commit平均贡献行数', 90 | type: 'value', 91 | axisLabel: { 92 | color: '#909399' 93 | }, 94 | axisLine: { 95 | show: false, 96 | lineStyle: { 97 | color: '#d0d3da' 98 | } 99 | }, 100 | axisTick: { 101 | show: false 102 | }, 103 | splitLine: { 104 | show: false, 105 | lineStyle: { 106 | color: '#ebeef5' 107 | } 108 | } 109 | } 110 | ], 111 | series : [ 112 | { 113 | name: 'commit 贡献数量', 114 | data: commitData, 115 | type: 'bar', 116 | barMaxWidth: '48px', 117 | barMinHeight: 4 118 | }, 119 | { 120 | name: 'commit 平均贡献行数', 121 | data: averageData, 122 | type: 'bar', 123 | barMaxWidth: '48px', 124 | barMinHeight: 4, 125 | yAxisIndex: 1 126 | } 127 | ] 128 | } 129 | } 130 | 131 | export const commitTimeBar = data => { 132 | if (!data.length) return 133 | 134 | const week = groupBy( 135 | // 先转为毫秒数 136 | data.map(time => Number(time + '000')), 137 | time => new Date(time).getDay(), 138 | ) 139 | let weekData = [0, 0, 0, 0, 0, 0, 0].map((item, index) => week[index] ? week[index].length : item) 140 | 141 | weekData = [...weekData.slice(1), weekData[0]] 142 | return { 143 | title: { 144 | text: '一周每天贡献', 145 | x: 'center', 146 | top: 0 147 | }, 148 | color, 149 | legend: { 150 | type: 'scroll', 151 | data: ['commit 贡献数量'], 152 | width: '80%', 153 | bottom: 0 154 | }, 155 | tooltip: { 156 | trigger: 'item', 157 | showDelay: 20, 158 | }, 159 | xAxis: { 160 | type: 'category', 161 | data: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 162 | axisTick: { 163 | show: false 164 | }, 165 | axisLine: { 166 | show: false, 167 | lineStyle: { 168 | color: '#d0d3da' 169 | } 170 | }, 171 | axisLabel: { 172 | color: '#909399' 173 | }, 174 | splitLine: { 175 | lineStyle: { 176 | color: '#909939', 177 | type: 'solid' 178 | } 179 | } 180 | }, 181 | yAxis: { 182 | name: 'commit 贡献数量', 183 | type: 'value', 184 | axisLabel: { 185 | color: '#909399' 186 | }, 187 | axisLine: { 188 | show: false, 189 | lineStyle: { 190 | color: '#d0d3da' 191 | } 192 | }, 193 | axisTick: { 194 | show: false 195 | }, 196 | splitLine: { 197 | lineStyle: { 198 | color: '#ebeef5' 199 | } 200 | } 201 | }, 202 | series: [ 203 | { 204 | name: 'commit 贡献数量', 205 | data: weekData, 206 | type: 'bar', 207 | barMaxWidth: '48px', 208 | barMinHeight: 4 209 | }, 210 | ] 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/service/echarts-commit-time.js: -------------------------------------------------------------------------------- 1 | import color from './color' 2 | export const bar = (data) => { 3 | let legendData = [] 4 | let commitData = [] 5 | let averageData = [] 6 | 7 | data.forEach(item => { 8 | legendData.push(item.author) 9 | commitData.push(item.commit) 10 | averageData.push(item.average) 11 | }) 12 | 13 | return { 14 | title: { 15 | text: '一周每天贡献', 16 | x: 'center', 17 | top: 0 18 | }, 19 | color, 20 | legend: { 21 | type: 'scroll', 22 | data: ['commit 贡献数量'], 23 | width: '80%', 24 | bottom: 0 25 | }, 26 | tooltip: { 27 | trigger: 'item', 28 | showDelay: 20, 29 | formatter(param) { 30 | let tip 31 | 32 | // 平均贡献行数 33 | if (param.seriesIndex === 1) { 34 | tip = ' commit 平均贡献行数' 35 | } else { 36 | tip = '贡献 commit 数量' 37 | } 38 | return `${param.name}
${tip}:${param.data}` 39 | } 40 | }, 41 | xAxis: { 42 | type: 'category', 43 | data: legendData, 44 | axisTick: { 45 | show: false 46 | }, 47 | axisLine: { 48 | show: false, 49 | lineStyle: { 50 | color: '#d0d3da' 51 | } 52 | }, 53 | axisLabel: { 54 | color: '#909399' 55 | }, 56 | splitLine: { 57 | lineStyle: { 58 | color: '#909939', 59 | type: 'solid' 60 | } 61 | } 62 | }, 63 | yAxis: [ 64 | { 65 | name: 'commit 贡献数量', 66 | type: 'value', 67 | axisLabel: { 68 | color: '#909399' 69 | }, 70 | axisLine: { 71 | show: false, 72 | lineStyle: { 73 | color: '#d0d3da' 74 | } 75 | }, 76 | axisTick: { 77 | show: false 78 | }, 79 | splitLine: { 80 | lineStyle: { 81 | color: '#ebeef5' 82 | } 83 | } 84 | }, 85 | { 86 | name: 'commit平均贡献行数', 87 | type: 'value', 88 | axisLabel: { 89 | color: '#909399' 90 | }, 91 | axisLine: { 92 | show: false, 93 | lineStyle: { 94 | color: '#d0d3da' 95 | } 96 | }, 97 | axisTick: { 98 | show: false 99 | }, 100 | splitLine: { 101 | show: false, 102 | lineStyle: { 103 | color: '#ebeef5' 104 | } 105 | } 106 | } 107 | ], 108 | series: [ 109 | { 110 | name: 'commit 贡献数量', 111 | data: commitData, 112 | type: 'bar', 113 | barMaxWidth: '48px', 114 | barMinHeight: 4 115 | }, 116 | { 117 | name: 'commit 平均贡献行数', 118 | data: averageData, 119 | type: 'bar', 120 | barMaxWidth: '48px', 121 | barMinHeight: 4, 122 | yAxisIndex: 1 123 | } 124 | ] 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/service/echarts-pie.js: -------------------------------------------------------------------------------- 1 | export const pie = (data, option = {}) => { 2 | return { 3 | title: { 4 | text: option.title, 5 | x:'center', 6 | top: 0 7 | }, 8 | color: [ 9 | '#5097e9', 10 | '#00bcd4', 11 | '#ffa726', 12 | '#e57373', 13 | '#8d6e63', 14 | '#9575cd', 15 | '#ff8a65', 16 | '#81c784', 17 | '#ce93d8', 18 | '#90a4ae', 19 | '#9dc6f5', 20 | '#80deea', 21 | '#ffcc80', 22 | '#ef9a9a', 23 | '#bcaaa4', 24 | '#d1c4e9', 25 | '#ffccbc', 26 | '#c8e6c9', 27 | '#e1bee7', 28 | '#cfd8dc' 29 | ], 30 | tooltip : { 31 | trigger: 'item', 32 | formatter: "{a}
{b} : {c} ({d}%)" 33 | }, 34 | legend: { 35 | type: 'scroll', 36 | data: data.legendData, 37 | width: '80%', 38 | bottom: 0 39 | }, 40 | series : [ 41 | { 42 | name: option.title, 43 | type: 'pie', 44 | radius : '80%', 45 | center: ['50%', '50%'], 46 | label: { 47 | normal: { 48 | show: false 49 | }, 50 | emphasis: { 51 | show: false 52 | } 53 | }, 54 | labelLine: { 55 | normal: { 56 | show: false 57 | } 58 | }, 59 | data: data.data 60 | } 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/service/echarts-wordcloud.js: -------------------------------------------------------------------------------- 1 | import colorSet from './color' 2 | import 'echarts-wordcloud' 3 | 4 | export const wordcloud = (data, chartOption) => { 5 | let option = { 6 | title: { 7 | text: '', 8 | left: 'center' 9 | }, 10 | tooltip: { 11 | padding: 10, 12 | trigger: 'item', 13 | textStyle: { 14 | color: '#fff' 15 | }, 16 | axisPointer: { 17 | type: 'line', 18 | lineStyle: { 19 | color: 'rgba(33, 35, 41, 0.1)' 20 | } 21 | }, 22 | formatter: '{b} 出现次数:{c} 次' 23 | }, 24 | visualMap: { 25 | show: false, 26 | type: 'continuous' 27 | }, 28 | series: [{ 29 | type: 'wordCloud', 30 | shape: 'circle', 31 | left: 'center', 32 | top: 'center', 33 | width: '100%', 34 | height: '80%', 35 | right: null, 36 | bottom: null, 37 | sizeRange: [20, 80], 38 | rotationRange: [0, 0], 39 | rotationStep: 45, 40 | gridSize: 14, 41 | drawOutOfBound: false, 42 | 43 | textStyle: { 44 | normal: { 45 | fontWeight: 'normal', 46 | color: function (params) { 47 | return colorSet[params.dataIndex % colorSet.length] 48 | } 49 | } 50 | }, 51 | data: data 52 | }] 53 | } 54 | 55 | return option 56 | } 57 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | --------------------------------------------------------------------------------