Explorar el Código

Merge branch 'dev_codereview'

# Conflicts:
#	package.json
#	src/api/index.ts
#	src/router/index.ts
山山 hace 4 años
padre
commit
07e33827f0

+ 1 - 1
.eslintrc.js

@@ -15,7 +15,7 @@ module.exports = {
     'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
     'import/prefer-default-export': 'off',
-    'max-len': ['error', { code: 140, ignoreTrailingComments: true }],
+    'max-len': ['error', { code: 190, ignoreTrailingComments: true }],
     'no-param-reassign': [2, { props: false }],
     'linebreak-style': [0, 'error', 'windows'],
     'import/no-cycle': 0,

+ 0 - 11
Dockerfile

@@ -1,11 +0,0 @@
-FROM node:10-alpine as builder
-WORKDIR /code
-RUN yarn config set registry https://registry.npm.taobao.org/
-ADD package.json /code
-ADD yarn.lock /code
-RUN yarn install
-ADD . /code
-RUN yarn build:prod
-
-FROM nginx:1.19-alpine
-COPY --from=builder /code/dist/ /var/www/html/dist/

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 98 - 320
package-lock.json


+ 3 - 1
package.json

@@ -12,11 +12,13 @@
   },
   "dependencies": {
     "@gitgraph/js": "^1.4.0",
+    "@highlightjs/vue-plugin": "^2.0.1",
     "axios": "^0.21.1",
     "core-js": "^3.6.5",
     "diff2html": "^3.4.16",
     "echarts": "^5.1.1",
     "element-plus": "^1.0.2-beta.31",
+    "highlight.js": "^11.5.1",
     "jsonwebtoken": "^8.5.1",
     "vue": "^3.0.0",
     "vue-axios": "^3.2.4",
@@ -34,7 +36,7 @@
     "@vue/cli-plugin-typescript": "~4.5.0",
     "@vue/cli-plugin-vuex": "~4.5.0",
     "@vue/cli-service": "~4.5.0",
-    "@vue/compiler-sfc": "^3.0.0",
+    "@vue/compiler-sfc": "^3.0.4",
     "@vue/eslint-config-airbnb": "^5.0.2",
     "@vue/eslint-config-typescript": "^5.0.2",
     "babel-plugin-component": "^1.1.1",

+ 4 - 1
src/App.vue

@@ -12,7 +12,9 @@ import { defineComponent, onMounted } from 'vue';
 import Menu from '@/components/Menu.vue';
 
 import { getVariableFromQueryString } from '@/utils/qs';
-import { WorkspaceDispatcher, UserDispatcher, TokenDispatcher } from './store/dispatchers';
+import {
+  WorkspaceDispatcher, UserDispatcher, TokenDispatcher, MessageDispatcher,
+} from './store/dispatchers';
 import { useStore } from './store';
 
 export default defineComponent({
@@ -32,6 +34,7 @@ export default defineComponent({
 
     UserDispatcher.updateUserInfo();
     WorkspaceDispatcher.refreshProjectList();
+    MessageDispatcher.updateMessageList();
 
     onMounted(() => {
       window.onbeforeunload = () => {

+ 77 - 0
src/api/devcloud/codereview.ts

@@ -0,0 +1,77 @@
+import axios from '@/utils/axios';
+
+import { CODEREVIEW_PREFIX, COMMENT_PREFIX, FILEREVIEW_PREFIX } from './prefix';
+
+interface CodeReviewPayload {
+    projectId: number;
+    sourceBranch: string;
+    targetBranch: string;
+    creatorId: number;
+    reviewerId: number;
+    title: string;
+    description: string;
+    deleteSourceBranch: boolean;
+}
+
+interface FileReviewPayload {
+    projectId: number;
+    creatorId: number;
+    reviewerList: Array<number>;
+    fileList: Array<string>;
+    branch: string;
+    title: string;
+}
+
+interface CommentPayload {
+    codeReviewId: number;
+    creatorId: number;
+    content: string;
+    commentType: string;
+    replyId: number;
+}
+
+export const getProjectBranches = (projectId: number) => axios.get(`${CODEREVIEW_PREFIX}/branches/${projectId}`);
+
+export const createCodeReview = (codeReview: CodeReviewPayload) => axios.post(`${CODEREVIEW_PREFIX}/branch_review`, codeReview);
+
+export const getCodeReviewList = (projectId: number) => axios.get(`${CODEREVIEW_PREFIX}/${projectId}`);
+
+export const compareBranch = (projectId: number, fromBash: string, toBash: string) => axios.get(`${CODEREVIEW_PREFIX}/compare?projectId=${projectId}&fromBash=${fromBash}&toBash=${toBash}`);
+
+export const getCodeReviewDetail = (codeReviewId: number) => axios.get(`${CODEREVIEW_PREFIX}/branch_review/${codeReviewId}`);
+
+export const createComment = (comment: CommentPayload) => axios.post(`${COMMENT_PREFIX}/create`, comment);
+
+export const getComment = (codeReviewId: number) => axios.get(`${COMMENT_PREFIX}/${codeReviewId}`);
+
+export const closeCodeReview = (codeReviewId: number) => axios.put(`${CODEREVIEW_PREFIX}/close/${codeReviewId}`);
+
+export const reopenCodeReview = (codeReviewId: number) => axios.put(`${CODEREVIEW_PREFIX}/reopen/${codeReviewId}`);
+
+export const mergeCodeReview = (codeReviewId: number) => axios.put(`${CODEREVIEW_PREFIX}/merge/${codeReviewId}`);
+
+export const getRepositoryTree = (projectId: number, branch: string) => axios.get(`${CODEREVIEW_PREFIX}/repository_tree?projectId=${projectId}&branch=${branch}`);
+
+export const getSubTree = (projectId: number, branch: string, path: string) => axios.get(`${CODEREVIEW_PREFIX}/repository_tree?projectId=${projectId}&branch=${branch}&path=${path}`);
+
+export const inviteUser = (phoneNumber: string) => axios.get(`${CODEREVIEW_PREFIX}/invite?phoneNumber=${phoneNumber}`);
+
+export const createFileReview = (fileReview: FileReviewPayload) => axios.post(`${CODEREVIEW_PREFIX}/file_review`, fileReview);
+
+export const getFileReviewDetail = (fileReviewId: number) => axios.get(`${CODEREVIEW_PREFIX}/file_review/${fileReviewId}`);
+
+export const updateFileList = (fileReviewId: number, fileList: Array<string>) => axios.put(`${CODEREVIEW_PREFIX}/file_review?fileReviewId=${fileReviewId}&fileList=${fileList}`);
+
+export const getFileContent = (projectId: number, branch: string, path: string) => axios.get(`${CODEREVIEW_PREFIX}/file_content?projectId=${projectId}&branch=${branch}&path=${path}`);
+
+export const submitToInspection = (fileReviewId: number) => axios.put(`${CODEREVIEW_PREFIX}/inspect?fileReviewId=${fileReviewId}`);
+
+export const createCommentForFileReview = (comment: CommentPayload) => axios.post(`${COMMENT_PREFIX}/createForFileReview`, comment);
+
+export const getFileReviewComment = (fileReviewId: number) => axios.get(`${COMMENT_PREFIX}/listForFileReview/${fileReviewId}`);
+
+export const submitToRework = (fileReviewId: number, reviewerId: number) => axios.put(`${CODEREVIEW_PREFIX}/rework?fileReviewId=${fileReviewId}&reviewerId=${reviewerId}`);
+
+export const submitToComplete = (fileReviewId: number, reviewerId: number) => axios.put(`${CODEREVIEW_PREFIX}/complete?fileReviewId=${fileReviewId}&reviewerId=${reviewerId}`);
+
+export const checkInspected = (fileReviewId: number, reviewerId: number) => axios.get(`${CODEREVIEW_PREFIX}/hasInspected?fileReviewId=${fileReviewId}&reviewerId=${reviewerId}`);

+ 9 - 0
src/api/devcloud/message.ts

@@ -0,0 +1,9 @@
+import axios from '@/utils/axios';
+
+import { MESSAGE_PREFIX } from './prefix';
+
+export const getMessage = (userId: number) => axios.get(`${MESSAGE_PREFIX}/${userId}`);
+
+export const changeStatusToRead = (messageId: number) => axios.get(`${MESSAGE_PREFIX}/read/${messageId}`);
+
+export const listByUser = () => axios.get(`${MESSAGE_PREFIX}/listByUser`);

+ 4 - 0
src/api/devcloud/prefix.ts

@@ -12,3 +12,7 @@ export const FUNCTEST_PREFIX = '/func_test';
 export const AUTOTEST_PREFIX = '/auto_test';
 export const INTERFACETEST_PREFIX = '/interfaces';
 export const SQL_PREFIX = '/sql';
+export const CODEREVIEW_PREFIX = '/code_review';
+export const COMMENT_PREFIX = '/comment';
+export const MESSAGE_PREFIX = '/message';
+export const FILEREVIEW_PREFIX = './file_review';

+ 4 - 0
src/api/index.ts

@@ -9,6 +9,8 @@ import * as AutoTestAPI from './devcloud/autotest';
 import * as InterfaceTestAPI from './devcloud/interfacetest';
 import * as SQLExecAPI from './devcloud/sql';
 import * as PortalAPI from './portal';
+import * as CodeReviewAPI from './devcloud/codereview';
+import * as MessageAPI from './devcloud/message';
 
 export {
   UserAPI,
@@ -22,4 +24,6 @@ export {
   AutoTestAPI,
   SQLExecAPI,
   InterfaceTestAPI,
+  CodeReviewAPI,
+  MessageAPI,
 };

+ 1 - 0
src/assets/icon_new/codereview-hover.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1639038288217" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3628" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M704 739.84V338.752a64 64 0 0 0-64-64H576v50.752a32 32 0 0 1-54.656 22.592L406.656 233.344a32 32 0 0 1 0-45.248l114.688-114.752A32 32 0 0 1 576 96v50.752h64a192 192 0 0 1 192 192v401.088a128 128 0 1 1-128 0zM192 321.6a128 128 0 1 1 128 0v418.24a128 128 0 1 1-128 0V321.6z m64-46.848a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m0 640a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m512 0a64 64 0 1 0 0-128 64 64 0 0 0 0 128z" p-id="3629" fill="#2c2c2c"></path></svg>

+ 1 - 0
src/assets/icon_new/codereview-using.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1639038288217" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3628" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M704 739.84V338.752a64 64 0 0 0-64-64H576v50.752a32 32 0 0 1-54.656 22.592L406.656 233.344a32 32 0 0 1 0-45.248l114.688-114.752A32 32 0 0 1 576 96v50.752h64a192 192 0 0 1 192 192v401.088a128 128 0 1 1-128 0zM192 321.6a128 128 0 1 1 128 0v418.24a128 128 0 1 1-128 0V321.6z m64-46.848a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m0 640a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m512 0a64 64 0 1 0 0-128 64 64 0 0 0 0 128z" p-id="3629" fill="#1296db"></path></svg>

+ 1 - 0
src/assets/icon_new/codereview.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1639038288217" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3628" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M704 739.84V338.752a64 64 0 0 0-64-64H576v50.752a32 32 0 0 1-54.656 22.592L406.656 233.344a32 32 0 0 1 0-45.248l114.688-114.752A32 32 0 0 1 576 96v50.752h64a192 192 0 0 1 192 192v401.088a128 128 0 1 1-128 0zM192 321.6a128 128 0 1 1 128 0v418.24a128 128 0 1 1-128 0V321.6z m64-46.848a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m0 640a64 64 0 1 0 0-128 64 64 0 0 0 0 128z m512 0a64 64 0 1 0 0-128 64 64 0 0 0 0 128z" p-id="3629" fill="#8a8a8a"></path></svg>

+ 41 - 0
src/components/CodeDifferencePlate.vue

@@ -0,0 +1,41 @@
+<template>
+  <div class="container">
+    <div v-for="item in data" :key="item.newPath" v-html="toHtml(item.diff)" class="diffPlate"></div>
+  </div>
+</template>
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import * as Diff2Html from 'diff2html';
+import 'diff2html/bundles/css/diff2html.min.css';
+
+interface DifferenceMsg {
+  a_mode: string;
+  b_mode: string;
+  deleteFile: boolean;
+  diff: string;
+  newFile: boolean;
+  newPath: string;
+  oldPath: string;
+  renamedFile: boolean;
+}
+
+export default defineComponent({
+  name: 'CodeDifferencePlate',
+  props: {
+    data: Array as PropType<DifferenceMsg[]>,
+  },
+  setup() {
+    const toHtml = (msg: string) => Diff2Html.html(msg, {
+      drawFileList: false,
+    });
+    return {
+      toHtml,
+    };
+  },
+});
+</script>
+<style lang="scss" scoped>
+.diff-plate {
+  width: 100%;
+}
+</style>

+ 262 - 0
src/components/CodeReviewAddDrawer.vue

@@ -0,0 +1,262 @@
+<template>
+  <el-drawer v-model="showDrawer" :size="650" :before-close="handleDrawerClose">
+    <template #title>
+      <h3 v-if="showSelectBranch">选择分支</h3>
+      <h3 v-if="showCreateCodeReview">创建代码评审任务</h3>
+    </template>
+    <div class="drawer-content" v-if="showSelectBranch">
+      <div class="branch-table">
+        <div class="branch-row" style="background-color: #F5F5F5;">Source Branch</div>
+        <div class="branch-row" >
+          <el-select v-model="sourceProject" placeholder="请选择源项目" class="inline-selector left" style="margin-right:1%">
+            <el-option v-for="item in projectOptions" :key="item" :value="item" :label="item"/>
+          </el-select>
+          <el-select v-model="sourceBranch" placeholder="请选择源分支" class="inline-selector right" style="margin-left:1%">
+            <el-option v-for="item in branchOptions" :key="item" :value="item" :label="item"/>
+          </el-select>
+        </div>
+        <div class="branch-row" style="background-color: #F5F5F5; border-bottom-width: 0px" v-if="sourceBranch !== ''">
+            <div style="display: inline-block">
+              <div>{{branches[branchOptions.indexOf(sourceBranch)].commit.title}}</div>
+              <div style="font-weight:200; font-size: 0.875em; color: #585858">
+                {{getAuthorName(sourceBranch)}} commited at {{getCreateTime(sourceBranch)}}
+              </div>
+            </div>
+            <div class="short-id-block">
+              <div class="short-id-line">{{branches[branchOptions.indexOf(sourceBranch)].commit.shortId}}</div>
+              <div class="copy-button"><i class="el-icon-document-copy"/></div>
+            </div>
+        </div>
+      </div>
+      <div class="branch-table" style="margin-top: 15px">
+        <div class="branch-row" style="background-color: #F5F5F5;">Target Branch</div>
+        <div class="branch-row" >
+          <el-select v-model="targetProject" placeholder="请选择目标项目" class="inline-selector left" style="margin-right:1%">
+            <el-option v-for="item in projectOptions" :key="item" :value="item" :label="item"/>
+          </el-select>
+          <el-select v-model="targetBranch" placeholder="请选择目标分支" class="inline-selector right" style="margin-left:1%">
+            <el-option v-for="item in branchOptions" :key="item" :value="item" :label="item"/>
+          </el-select>
+        </div>
+        <div class="branch-row" style="background-color: #F5F5F5; border-bottom-width: 0px" v-if="targetBranch !== ''">
+            <div style="display: inline-block">
+              <div>{{branches[branchOptions.indexOf(targetBranch)].commit.title}}</div>
+              <div style="font-weight:200; font-size: 0.875em; color: #585858">
+                {{getAuthorName(targetBranch)}} commited at {{getCreateTime(targetBranch)}}
+              </div>
+            </div>
+            <div class="short-id-block">
+              <div class="short-id-line">{{branches[branchOptions.indexOf(targetBranch)].commit.shortId}}</div>
+              <div class="copy-button"><i class="el-icon-document-copy"/></div>
+            </div>
+        </div>
+      </div>
+      <el-button type="success" style="margin-top: 7px" @click="compareBranch()" size="medium">继续</el-button>
+    </div>
+    <div class="drawer-content" v-if="showCreateCodeReview">
+      <el-form ref="createForm" label-position="right" label-width="80px" :model="codeReviewCreateForm" :rules="formRules">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="codeReviewCreateForm.title"/>
+        </el-form-item>
+        <el-form-item label="详细描述" prop="description">
+          <el-input type="textarea" v-model="codeReviewCreateForm.description"/>
+        </el-form-item>
+        <el-form-item label="评审人" prop="reviewerId">
+          <el-select v-model="codeReviewCreateForm.reviewerId" placeholder="请选择评审人">
+            <el-option v-for=" member in projectMemberList" :label="member.label" :key="member.id" :value="member.value"/>
+          </el-select>
+        </el-form-item>
+        <!-- <el-form-item prop="deleteSourceBranch">
+          <el-switch v-model="codeReviewCreateForm.deleteSourceBranch"
+            active-text="合并后删除源分支"
+            active-color="#13ce66"/>
+        </el-form-item> -->
+        <el-form-item>
+          <el-button type="success" v-loading="submitLoading" size="medium" @click="submitForm">立即创建</el-button>
+          <el-button type="text" size="medium" @click="backToSelectBranch">重新选择分支</el-button>
+        </el-form-item>
+      </el-form>
+      <CodeDifferencePlate :data="diffMsg"/>
+    </div>
+  </el-drawer>
+</template>
+<script>
+import CodeDifferencePlate from './CodeDifferencePlate.vue';
+import { CodeReviewAPI } from '../api';
+
+export default {
+  name: 'CodeReviewAddDrawer',
+  components: { CodeDifferencePlate },
+  data() {
+    return {
+      sourceProject: '',
+      targetProject: '',
+      projectOptions: [''],
+      sourceBranch: '',
+      targetBranch: 'master',
+      branchOptions: [],
+      showDrawer: false,
+      showSelectBranch: true,
+      showCreateCodeReview: false,
+      branches: [],
+      codeReviewCreateForm: {
+        projectId: -1,
+        sourceBranch: '',
+        targetBranch: '',
+        creatorId: '',
+        reviewerId: undefined,
+        title: '',
+        description: '',
+        deleteSourceBranch: false,
+      },
+      formRules: {
+        title: [{required: true, message: '请输入代码评审任务标题', trigger: 'blur',}],
+        reviewerId: [{required: true, message: '请选择一位评审人', trigger: 'blur'}],
+      },
+      submitLoading: false,
+      diffMsg: [],
+    };
+  },
+  computed: {
+    projectMemberList() {
+      return this.$store.getters.userOptions;
+    },
+    currentProjectInfo() {
+      return this.$store.getters.currentProjectInfo;
+    },
+    currentUser() {
+      return this.$store.state.user;
+    }
+  },
+  methods: {
+    show() {
+      this.showDrawer = true;
+    },
+    getAuthorName(branchName) {
+      return this.branches[this.branchOptions.indexOf(branchName)].commit.authorName;
+    },
+    getCreateTime(branchName) {
+      return this.branches[this.branchOptions.indexOf(branchName)].commit.createdAt.split('T')[0];
+    },
+    getBranchBash(branchName) {
+      return this.branches[this.branchOptions.indexOf(branchName)].commit.id;
+    },
+    getProjectNameFromUrl(url) {
+      let addList = url.split('/');
+      return addList[addList.length - 2] + '/' + addList[addList.length - 1];
+    },
+    async compareBranch() {
+      if(this.sourceBranch === '')this.$message.error('源分支不能为空!');
+      else if (this.sourceBranch === this.targetBranch)this.$message.error('源分支与目标分支相同,请重新选择!')
+      else {
+        this.codeReviewCreateForm.sourceBranch = this.sourceBranch;
+        this.codeReviewCreateForm.targetBranch = this.targetBranch;
+        this.diffMsg = await CodeReviewAPI.compareBranch(this.codeReviewCreateForm.projectId, this.targetBranch, this.sourceBranch);
+        this.showSelectBranch = false;
+        this.showCreateCodeReview = true;
+      }
+    },
+    handleDrawerClose(done) {
+      this.showSelectBranch = true;
+      this.showCreateCodeReview = false;
+      this.sourceBranch = '';
+      this.targetBranch = 'master';
+      this.resetForm();
+      done();
+    },
+    backToSelectBranch() {
+      this.showSelectBranch = true;
+      this.showCreateCodeReview = false;
+      this.codeReviewCreateForm.reviewerId = undefined;
+      this.codeReviewCreateForm.title = '';
+      this.codeReviewCreateForm.description = '';
+      this.codeReviewCreateForm.deleteSourceBranch = false;
+    },
+    submitForm() {
+      this.$refs.createForm.validate((valid) => {
+        if(valid) {
+          this.submitLoading = true;
+          CodeReviewAPI.createCodeReview(this.codeReviewCreateForm).then(
+            (res) => {
+              this.showDrawer = false;
+              this.$parent.refreshCodeReviewList();
+              this.handleDrawerClose(() => {});
+            }
+          );
+          this.submitLoading = false;
+        }
+      });
+    },
+    resetForm() {
+      this.codeReviewCreateForm = {
+        projectId: this.currentProjectInfo.id,
+        sourceBranch: '',
+        targetBranch: '',
+        creatorId: this.currentUser.id,
+        reviewerId: undefined,
+        title: '',
+        description: '',
+        deleteSourceBranch: false,
+      };
+    },
+  },
+  async mounted(){
+      //更新项目名称,项目Id,创建者Id
+      this.projectOptions[0] = this.sourceProject = this.targetProject = this.currentProjectInfo.name;
+      this.codeReviewCreateForm.projectId = this.currentProjectInfo.id;
+      this.codeReviewCreateForm.creatorId = this.currentUser.id;
+      //获取项目的分支信息
+      this.branches = await CodeReviewAPI.getProjectBranches(this.currentProjectInfo.id);
+      for(let item in this.branches) {
+          this.branchOptions.push(this.branches[item].name);
+      }
+  },
+};
+</script>
+<style scoped>
+.drawer-content {
+  width: 100%;
+  padding: 10px;
+}
+.branch-table {
+  border-style: solid;
+  border-width: 1px;
+  border-color: gainsboro;
+}
+.branch-row {
+  padding: 10px 2%;
+}
+.inline-selector {
+  display: inline-block;
+  width: 49%;
+}
+.short-id-block {
+  float: right;
+  border: 2px solid gainsboro;
+  border-radius: 5px;
+}
+.short-id-line {
+  display: inline-block;
+  font-family: 'Courier New', Courier, monospace;
+  font-weight: 600;
+  padding: 7px;
+  border-right: 2px solid gainsboro;
+}
+.copy-button {
+  display: inline-block;
+  padding: 7px;
+  background-color: white;
+  cursor: pointer;
+}
+.copy-button:hover {
+  background-color: whitesmoke;
+}
+</style>
+<style>
+.el-drawer__body {
+    overflow: auto;
+}
+.el-drawer__container ::-webkit-scrollbar{
+    display: none;
+}
+</style>

+ 184 - 0
src/components/FileReviewAddDrawer.vue

@@ -0,0 +1,184 @@
+<template>
+  <el-drawer v-model="showDrawer" :size="650" :before-close="handleDrawerClose">
+    <template #title>
+      <h3>创建新的文件评审任务</h3>
+    </template>
+    <div class="drawer-content">
+      <el-select v-model="fileReviewForm.branch" placeholder="请选择需要评审的分支" @change="changeBranch">
+        <el-option v-for="item in branchOptions" :key="item" :value="item" :label="item"/>
+      </el-select>
+      <div>选择需要评审的文件</div>
+      <el-tree :load="loadNode" :props="fileListProps" lazy show-checkbox ref="fileTree" node-key="path"></el-tree>
+      <el-select v-model="fileReviewForm.reviewerList" multiple placeholder="请选择评审人,可以通过手机号邀请">
+        <el-option v-for="item in reviewerOption" :key="item.id" :label="item.label" :value="item.value"/>
+      </el-select>
+      <el-popover width="300" trigger="click">
+        <el-row>
+          <el-input v-model="phoneNumber" placeholder="请输入手机号码">
+            <template #append>
+              <el-button icon="el-icon-search" circle size="mini" @click="inviteUser"></el-button>
+            </template>
+          </el-input>
+          <!-- <el-button icon="el-icon-search" circle size="mini" @click="inviteUser"></el-button> -->
+        </el-row>
+        <template #reference><el-button type="primary" style="margin-left: 20px">邀请用户</el-button></template>
+      </el-popover>
+      <el-input v-model="fileReviewForm.title" placeholder="请输入标题" minlength="1" maxlength="20" show-word-limit></el-input>
+      <el-button @click="createFileReview" type="success" style="display:block">创建</el-button>
+    </div>
+  </el-drawer>
+</template>
+<script>
+import { CodeReviewAPI } from '../api';
+
+export default {
+  name: 'FileReviewAddDrawer',
+  data() {
+    return {
+      showDrawer: false,
+      fileReviewForm: {
+        projectId: -1,
+        creatorId: -1,
+        reviewerList: [],
+        fileList: [],
+        branch: '',
+        title: '',
+      },
+      phoneNumber: '',
+      branchOptions: [],
+      fileListProps: {
+        label: 'name',
+        children: 'children',
+        isLeaf: 'leaf',
+      },
+      fileList: [{
+        label: 'file1',
+        path: '',
+      }],
+      reviewerOption: [{
+        label: '',
+        value: -1,
+      }],
+      nodeTreeConfig: {
+        nodeLevel0: {},
+        resolveLevel0: {},
+      },
+    };
+  },
+  computed: {
+    currentProjectInfo() {
+      return this.$store.getters.currentProjectInfo;
+    },
+    currentUser() {
+      return this.$store.state.user;
+    },
+    projectMemberList() {
+      return this.$store.getters.userOptions;
+    },
+  },
+  methods: {
+    show() {
+      this.showDrawer = true;
+    },
+    async changeBranch() {
+      this.fileList = await CodeReviewAPI.getRepositoryTree(this.fileReviewForm.projectId, this.fileReviewForm.branch);
+      this.nodeTreeConfig.nodeLevel0.childNodes = [];
+      this.loadNode(this.nodeTreeConfig.nodeLevel0, this.nodeTreeConfig.resolveLevel0);
+    },
+    async loadNode(node, resolve) {
+      if (node.level === 0) {
+        this.nodeTreeConfig.nodeLevel0 = node;
+        this.nodeTreeConfig.resolveLevel0 = resolve;
+        return resolve(this.fileList);
+      }
+      if (node.level > 0) {
+        const subTree = await CodeReviewAPI.getSubTree(this.fileReviewForm.projectId, this.fileReviewForm.branch, node.data.path);
+        return resolve(subTree);
+      }
+      return resolve([]);
+    },
+    createFileReview() {
+      this.fileReviewForm.fileList = this.$refs.fileTree.getCheckedKeys();
+      if (this.fileReviewForm.fileList.length === 0) {
+        this.$message({ message: '文件列表不能为空', type: 'error' });
+        return;
+      }
+      if (this.fileReviewForm.reviewerList.length === 0) {
+        this.$message({ message: '评审不能为空', type: 'error' });
+        return;
+      }
+      if (this.fileReviewForm.title === '') {
+        this.$message({ message: '标题不能为空', type: 'error' });
+        return;
+      }
+      CodeReviewAPI.createFileReview(this.fileReviewForm).then(
+        (res) => {
+          this.showDrawer = false;
+          this.handleDrawerClose(
+            () => {
+              console.log('finishAdd');
+              this.$parent.refreshCodeReviewList();
+            },
+          );
+        },
+      );
+    },
+    async inviteUser() {
+      const user = await CodeReviewAPI.inviteUser(this.phoneNumber);
+      if (user !== undefined) {
+        this.reviewerOption.push({
+          label: user.username,
+          value: user.id,
+        });
+        this.fileReviewForm.reviewerList.push(user.id);
+      }
+      this.phoneNumber = '';
+    },
+    handleDrawerClose(done) {
+      this.fileReviewForm.reviewerList = [];
+      this.fileReviewForm.fileList = [];
+      this.title = '';
+      this.branch = 'master';
+      done();
+    },
+  },
+  async mounted() {
+    this.fileReviewForm.projectId = this.currentProjectInfo.id;
+    this.fileReviewForm.creatorId = this.currentUser.id;
+    this.reviewerOption = this.projectMemberList;
+    const branches = await CodeReviewAPI.getProjectBranches(this.currentProjectInfo.id);
+    Object.keys(branches).forEach(
+      (key) => {
+        this.branchOptions.push(branches[key].name);
+      },
+    );
+    [this.fileReviewForm.branch] = this.branchOptions;
+    this.fileList = await CodeReviewAPI.getRepositoryTree(this.fileReviewForm.projectId, this.fileReviewForm.branch);
+  },
+};
+</script>
+<style lang="scss" scoped>
+.drawer-content {
+  width: 100%;
+  padding: 10px;
+  > * {
+    margin-top: 10px;
+  }
+}
+.el-tree {
+  border-style: solid;
+  border-width: 1px;
+  border-color: gainsboro;
+  border-radius: 5px;
+}
+.el-select {
+  width: 60%;
+}
+::v-deep {
+  .el-tree {
+    .el-tree-node__content {
+      height: 35px;
+    }
+  }
+}
+</style>

+ 23 - 2
src/components/Menu.vue

@@ -19,6 +19,14 @@
           <ElMenuItem index="user-invite">邀请成员</ElMenuItem>
           <ElMenuItem index="user-logout">登出</ElMenuItem>
         </ElSubmenu>
+        <ElSubmenu index="message" class="message" text-color="#909399">
+          <template #title>
+            <i class="el-icon-news"></i>
+            <i v-if="messageList.length > 0">{{messageList.length}}</i>
+          </template>
+          <ElMenuItem v-if="messageList.length === 0">暂无未读消息</ElMenuItem>
+          <ElMenuItem v-for="(item, index) in messageList" :key="item.id" :index="`message-${index}`">{{item.content}}</ElMenuItem>
+        </ElSubmenu>
       </template>
       <template v-else>
         <ElButton type="text" @click="login" class="user">登录</ElButton>
@@ -74,6 +82,7 @@ import { ElMessage } from 'element-plus';
 
 import { useProjectList } from '@/composable/useProject';
 import { useUser } from '@/composable/useUser';
+import { useMessage } from '@/composable/useMessage';
 import { defineComponent, ref, reactive } from 'vue';
 import { ProjectAPI } from '@/api';
 
@@ -85,6 +94,7 @@ export default defineComponent({
   setup() {
     const { projectList, selectedProject, handleProjectChange } = useProjectList();
     const { userInfo, userLoggedIn, userLogOut } = useUser();
+    const { messageList, handleMessageChange } = useMessage();
 
     const router = useRouter();
 
@@ -154,6 +164,10 @@ export default defineComponent({
           }
           break;
         }
+        case 'message': {
+          handleMessageChange(parseInt(id, 10));
+          break;
+        }
         default:
           console.warn('unknown type of sub-menu');
           break;
@@ -168,6 +182,7 @@ export default defineComponent({
 
     return {
       projectList,
+      messageList,
       selectedProject,
 
       handleSelect,
@@ -190,7 +205,7 @@ export default defineComponent({
 });
 </script>
 
-<style scoped>
+<style lang="scss" scoped>
 .menu {
   margin-left: -20px;
   width: 100%;
@@ -213,7 +228,7 @@ export default defineComponent({
 .user {
   float: right;
   height: 60px;
-  margin: 0 20px;
+  margin: 0 20px 0 0;
 }
 .drawer-content {
   display: flex;
@@ -222,4 +237,10 @@ export default defineComponent({
 .form-container {
   width: 80%;
 }
+.message {
+  float: right;
+  .el-badge {
+    height: 20px;
+  }
+}
 </style>

+ 12 - 0
src/components/SideBar.vue

@@ -31,6 +31,7 @@ const sidebar = [
   { name: 'apitest', description: '测试', hide: false },
   { name: 'invite', description: '项目关联', hide: false },
   { name: 'git', description: '提交记录', hide: false },
+  { name: 'codereview', description: '代码评审', hide: false },
 ];
 
 export default {
@@ -181,6 +182,17 @@ export default {
     color: #1296db;
   }
 }
+.icon[target=codereview]{
+  background-image: url("../assets/icon_new/codereview.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon_new/codereview-hover.svg");
+    color: #2c2c2c;
+  }
+  .el-menu-item.is-active & {
+    background-image: url("../assets/icon_new/codereview-using.svg");
+    color: #1296db;
+  }
+}
 .title{
   font-size: 9px;
   margin-top: 12px;

+ 22 - 0
src/composable/useMessage.ts

@@ -0,0 +1,22 @@
+import { computed } from 'vue';
+import { useStore } from '@/store';
+import { MessageDispatcher } from '@/store/dispatchers';
+import { useRouter } from 'vue-router';
+import router from '@/router';
+
+export const useMessage = () => {
+  const store = useStore();
+  const route = useRouter();
+
+  const messageList = computed(() => store.state.message.messageList);
+
+  const handleMessageChange = (messageIndex: number) => {
+    MessageDispatcher.clickMessage(messageIndex);
+    router.push(messageList.value[messageIndex].linkTo);
+  };
+
+  return {
+    messageList,
+    handleMessageChange,
+  };
+};

+ 10 - 0
src/element-plus.ts

@@ -43,6 +43,11 @@ import {
   ElTimeline,
   ElTimelineItem,
   ElTooltip,
+  ElTree,
+  ElCollapse,
+  ElCollapseItem,
+  ElPopover,
+  ElBadge,
 } from 'element-plus';
 
 import ZH_CN from 'element-plus/lib/locale/lang/zh-cn';
@@ -91,7 +96,12 @@ export const Components = [
   ElTag,
   ElTimeline,
   ElTimelineItem,
+  ElTree,
   ElTooltip,
+  ElCollapse,
+  ElCollapseItem,
+  ElPopover,
+  ElBadge,
 ];
 
 export const Plugins = [

+ 12 - 0
src/router/index.ts

@@ -70,6 +70,18 @@ const routes: Array<RouteRecordRaw> = [
         path: 'git',
         component: () => import(/* webpackChunkName: "project" */'@/views/Project/Commit.vue'),
       },
+      {
+        path: 'codereview',
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/CodeReview.vue'),
+      },
+      {
+        path: 'codereviewdetail',
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/CodeReviewDetail.vue'),
+      },
+      {
+        path: 'filereviewdetail',
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/FileReviewDetail.vue'),
+      },
       {
         path: 'commit-detail',
         component: () => import(/* webpackChunkName: "project" */'@/views/Project/CommitDetails.vue'),

+ 2 - 0
src/store/dispatchers/index.ts

@@ -1,9 +1,11 @@
 import * as UserDispatcher from './user';
 import * as WorkspaceDispatcher from './workspace';
 import * as TokenDispatcher from './token';
+import * as MessageDispatcher from './message';
 
 export {
   UserDispatcher,
   WorkspaceDispatcher,
   TokenDispatcher,
+  MessageDispatcher,
 };

+ 9 - 0
src/store/dispatchers/message.ts

@@ -0,0 +1,9 @@
+import { store } from '../index';
+
+export const updateMessageList = () => {
+  store.dispatch('refreshMessageList');
+};
+
+export const clickMessage = (index: number) => {
+  store.dispatch('clickMessage', index);
+};

+ 2 - 0
src/store/modules/index.ts

@@ -1,9 +1,11 @@
 import { user } from './user';
 import { workspace } from './workspace';
 import { token } from './token';
+import { message } from './message';
 
 export default {
   user,
   workspace,
   token,
+  message,
 };

+ 33 - 0
src/store/modules/message.ts

@@ -0,0 +1,33 @@
+import { MessageAPI } from '@/api';
+import { ActionTree, Module, MutationTree } from 'vuex';
+import { MessageState, Message, RootState } from '../typings';
+
+const DEFAULT: MessageState = {
+  messageList: [],
+};
+
+const actions: ActionTree<MessageState, RootState> = {
+  async refreshMessageList({ commit }) {
+    const messages = await MessageAPI.listByUser();
+    commit('updateMessageList', messages);
+  },
+  clickMessage({ commit }, index: number) {
+    commit('deleteMessage', index);
+  },
+};
+
+const mutations: MutationTree<MessageState> = {
+  updateMessageList(state, payload: Array<Message>) {
+    state.messageList = payload;
+  },
+  async deleteMessage(state, index: number) {
+    await MessageAPI.changeStatusToRead(state.messageList[index].id);
+    state.messageList.splice(index, 1);
+  },
+};
+
+export const message: Module<MessageState, RootState> = {
+  state: () => DEFAULT,
+  actions,
+  mutations,
+};

+ 14 - 0
src/store/typings.ts

@@ -4,6 +4,7 @@ export interface RootState {
   token: TokenState;
   user: UserState;
   workspace: WorkspaceState;
+  message: MessageState;
 }
 
 export interface TokenState {
@@ -32,3 +33,16 @@ export interface ChangeProjectPayload {
 export interface TokenPayload {
   token: string;
 }
+
+export interface Message {
+  id: number;
+  receiverId: number;
+  content: string;
+  linkTo: string;
+  createTime: string;
+  status: string;
+}
+
+export interface MessageState {
+  messageList: Array<Message>;
+}

+ 187 - 0
src/views/Project/CodeReview.vue

@@ -0,0 +1,187 @@
+<template>
+  <div class="container">
+    <CodeReviewAddDrawer ref="addDrawer"/>
+    <FileReviewAddDrawer ref="fileReviewAddDrawer"/>
+<!--    <el-row>-->
+<!--      <h2>代码评审列表</h2>-->
+<!--    </el-row>-->
+<!--    <div class = "tabs">-->
+<!--      <div class="tab-pane" :active="tabActive[0]" @click="changeTab(0)">-->
+<!--        Opened <div class="tab-pane-inline-circle">{{tabCount[0]}}</div>-->
+<!--      </div>-->
+<!--      <div class="tab-pane" :active="tabActive[1]" @click="changeTab(1)">-->
+<!--        Merged <div class="tab-pane-inline-circle">{{tabCount[1]}}</div>-->
+<!--      </div>-->
+<!--      <div class="tab-pane" :active="tabActive[2]" @click="changeTab(2)">-->
+<!--        Closed <div class="tab-pane-inline-circle">{{tabCount[2]}}</div>-->
+<!--      </div>-->
+<!--      <div class="tab-pane" :active="tabActive[3]" @click="changeTab(3)">-->
+<!--        All <div class="tab-pane-inline-circle">{{tabCount[3]}}</div>-->
+<!--      </div>-->
+<!--    </div>-->
+    <el-row>
+      <h2>代码评审列表</h2>
+      <div class="button-plate">
+        <el-button type="success" size="medium" @click="addCodeReview">新建分支评审</el-button>
+        <el-button type="primary" size="medium" @click="addFileReview">新建文件评审</el-button>
+      </div>
+      <!-- <el-button type="success" id="addButton" size="medium" @click="add">
+        新建代码评审
+      </el-button>
+      <el-button type="success" id="addFileReviewButton" size="medium">
+        新建文件评审
+      </el-button> -->
+    </el-row>
+    <ElTabs class="tabs" v-model="activeTab">
+      <ElTabPane :label="'Opened(' + codeReviewList.opened.length + ')'" name="Opened">
+        <div class="code-review-line" v-for="item in codeReviewList.opened" :key="item.id">
+          <div class="code-review-titleline" @click="directToDetail(item.id)">{{item.title}}</div>
+          <div class="code-review-timeline">opened at {{item.createTime.split('T')[0]}}</div>
+        </div>
+      </ElTabPane>
+      <ElTabPane :label="'Merged(' + codeReviewList.merged.length + ')'" name="Merged">
+        <div class="code-review-line" v-for="item in codeReviewList.merged" :key="item.id">
+          <div class="code-review-titleline" @click="directToDetail(item.id)">{{item.title}}</div>
+          <div class="code-review-timeline">opened at {{item.createTime.split('T')[0]}}</div>
+        </div>
+      </ElTabPane>
+      <ElTabPane :label="'Closed(' + codeReviewList.closed.length + ')'" name="Closed">
+        <div class="code-review-line" v-for="item in codeReviewList.closed" :key="item.id">
+          <div class="code-review-titleline" @click="directToDetail(item.id)">{{item.title}}</div>
+          <div class="code-review-timeline">opened at {{item.createTime.split('T')[0]}}</div>
+        </div>
+      </ElTabPane>
+      <ElTabPane :label="'FileReview(' + codeReviewList.fileReview.length + ')'" name="FileReview">
+        <div class="code-review-line" v-for="item in codeReviewList.fileReview" :key="item.id">
+          <div class="code-review-titleline" @click="directToFileReviewDetail(item.id)">{{item.title}}</div>
+          <div class="code-review-timeline">opened at {{item.createTime.split('T')[0]}}</div>
+        </div>
+      </ElTabPane>
+    </ElTabs>
+  </div>
+</template>
+<script>
+import CodeReviewAddDrawer from '@/components/CodeReviewAddDrawer.vue';
+import FileReviewAddDrawer from '@/components/FileReviewAddDrawer.vue';
+import { CodeReviewAPI } from '../../api';
+
+export default {
+  components: { CodeReviewAddDrawer, FileReviewAddDrawer },
+  name: 'CodeReview.vue',
+  data() {
+    return {
+      activeTab: 'Opened',
+      codeReviewList: {
+        opened: [{
+          id: 1,
+          projectId: -1,
+          sourceBranch: '',
+          targetBranch: '',
+          creatorId: '',
+          reviewerId: '',
+          status: '',
+          title: 'frontEndTest',
+          description: '',
+          createTime: '2022.1.26',
+        }, {
+          id: 1,
+          projectId: -1,
+          sourceBranch: '',
+          targetBranch: '',
+          creatorId: '',
+          reviewerId: '',
+          status: '',
+          title: 'frontEndTest',
+          description: '',
+          createTime: '2022.1.26',
+        }],
+        closed: [],
+        merged: [],
+        fileReview: [],
+      },
+    };
+  },
+  computed: {
+    currentProjectInfo() {
+      return this.$store.getters.currentProjectInfo;
+    },
+  },
+  methods: {
+    // changeTab(index) {
+    //   if (this.tabActive[index]) return null;
+    //   this.tabActive = [false, false, false, false];
+    //   this.tabActive[index] = true;
+    //   return null;
+    // },
+    addCodeReview() {
+      this.$refs.addDrawer.show();
+    },
+    addFileReview() {
+      this.$refs.fileReviewAddDrawer.show();
+    },
+    async refreshCodeReviewList() {
+      this.codeReviewList = await CodeReviewAPI.getCodeReviewList(this.currentProjectInfo.id);
+    },
+    directToDetail(id) {
+      this.$router.push(`codereviewdetail?codeReviewId=${id}`);
+    },
+    directToFileReviewDetail(id) {
+      this.$router.push(`filereviewdetail?fileReviewId=${id}`);
+    },
+  },
+  async mounted() {
+    this.codeReviewList = await CodeReviewAPI.getCodeReviewList(this.currentProjectInfo.id);
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+::v-deep {
+  .el-tabs__item{
+    font-size: 1em;
+  }
+}
+.container {
+  margin: 50px 100px;
+  & .tabs {
+    width: 90%;
+    margin: 10px 0px;
+  }
+}
+.button-plate {
+  position: absolute;
+  top: 100%;
+  right: 10%;
+  margin-top: 10px;
+  z-index: 10;
+}
+#addButton {
+  position: absolute;
+  top: 100%;
+  right: 10%;
+  margin-top: 10px;
+  z-index: 10;
+}
+#addFileReviewButton {
+  position: absolute;
+  top: 100%;
+  right: 30%;
+  margin-top: 10px;
+  z-index: 10;
+}
+.code-review-line {
+  border-bottom: 1px solid gainsboro;
+  padding: 5px 10px;
+}
+.code-review-titleline {
+  cursor: pointer;
+}
+.code-review-titleline:hover {
+  text-decoration: underline;
+}
+.code-review-timeline {
+  color: #909090;
+  font-size: 0.9em;
+  margin-top: 5px;
+}
+</style>

+ 252 - 0
src/views/Project/CodeReviewDetail.vue

@@ -0,0 +1,252 @@
+<template>
+  <div class="container">
+    <h2>{{codeReviewDetail.title}}</h2>
+    <el-divider/>
+    <div class="title-line">
+      <el-tag type="success" effect="dark" v-if="codeReviewDetail.status === 'Opened'">Open</el-tag>
+      <el-tag type="danger" effect="dark" v-if="codeReviewDetail.status === 'Closed'">Closed</el-tag>
+      <el-tag type="info" effect="dark" v-if="codeReviewDetail.status === 'Merged'">Merged</el-tag>
+      <div>opened on {{codeReviewDetail.createTime.split('T')[0]}} by {{getCreatorName(codeReviewDetail.creatorId)}}</div>
+      <el-button type="warning" plain size="medium" v-if="codeReviewDetail.status === 'Opened'" @click="closeCodeReview()">Close merge request</el-button>
+      <el-button type="success" plain size="medium" v-if="codeReviewDetail.status === 'Closed'" @click="reopenCodeReview()">Reopen merge request</el-button>
+    </div>
+    <el-divider/>
+    <div>Request to merge <span class="branch-name">{{codeReviewDetail.sourceBranch}}</span> into <span class="branch-name">{{codeReviewDetail.targetBranch}}</span></div>
+    <div class="operation-line">
+      <el-button type="success" size="medium" plain
+      :disabled="disableMergeButton()"
+      @click="mergeCodeReview()">
+        Merge
+      </el-button>
+      <!-- <el-switch v-model="codeReviewDetail.deleteSourceBranch" active-text="合并后删除源分支"></el-switch> -->
+    </div>
+    <el-divider/>
+    <ElTabs v-model="activeTab">
+      <ElTabPane :label="'评审意见'" name="comments">
+        <!-- <div v-for="comment in commentList" :key="comment.id" class="comment-line">
+          <div>
+            <i class="el-icon-plus"/>{{comment.creatorName}}
+            <span class="vice-comment"> @ {{comment.creatorName}} {{comment.commentType}}ed at {{comment.time}}</span>
+          </div>
+          <div class="comment-content">{{comment.content}}</div>
+        </div> -->
+        <el-timeline reverse = "true">
+          <el-timeline-item v-for="(item, index) in commentList" :key="index" :timestamp="formatTime(item.time)" class="comment-item">
+            <el-row>
+              <el-tag size="small" type="primary" v-if="item.commentType==='Commit'">Commited</el-tag>
+              <el-tag size="small" type="info" v-if="item.commentType==='Comment'">Commented</el-tag>
+              <el-tag size="small" type="success" v-if="item.commentType==='Reopen'">Reopened</el-tag>
+              <el-tag size="small" type="danger" v-if="item.commentType==='Close'">Closed</el-tag>
+              <el-tag size="small" type="success" v-if="item.commentType==='Merge'">Merged</el-tag>
+              <span class="creator-name-plate">by {{item.creatorName}}</span>
+            </el-row>
+            <el-row>{{item.content}}</el-row>
+          </el-timeline-item>
+        </el-timeline>
+        <div class="text-container">
+          <div>创建新的评审意见</div>
+          <el-input type="textarea" :rows="5" v-model="newComment.content"/>
+          <el-button type="success" size="medium" plain :disabled="newComment.length === 0" @click="submitComment">创建</el-button>
+        </div>
+      </ElTabPane>
+      <ElTabPane :label="'代码差异'" name="codeDiff">
+        <CodeDifferencePlate :data="codeDiffMsg"/>
+      </ElTabPane>
+    </ElTabs>
+  </div>
+</template>
+<script>
+import CodeDifferencePlate from '../../components/CodeDifferencePlate.vue';
+import { CodeReviewAPI } from '../../api';
+
+export default {
+  name: 'CodeResviewDetail.vue',
+  components: { CodeDifferencePlate },
+  data() {
+    return {
+      activeTab: 'comments',
+      codeReviewId: 0,
+      codeReviewDetail: {
+        creatorId: 0,
+        createTime: '',
+        reviewerId: 0,
+      },
+      commentList: [
+        {
+          id: 0,
+          codeReviewId: 1,
+          creatorId: 1479,
+          creatorName: 'Shanshan',
+          content: '4893310c - feat: commit message',
+          time: '2022-02-25',
+          commentType: 'Commit',
+        },
+        {
+          id: 1,
+          codeReviewId: 1,
+          creatorId: 1479,
+          creatorName: 'Shanshan',
+          content: 'Commene Content, Point out some flaws',
+          time: '2022-02-26',
+          commentType: 'Comment',
+        },
+        {
+          id: 2,
+          codeReviewId: 1,
+          creatorId: 1479,
+          creatorName: 'Shanshan',
+          content: 'bd308312 - feat: fix some flaws',
+          time: '2022-02-27',
+          commentType: 'Commit',
+        },
+      ],
+      codeDiffMsg: [],
+      newComment: {
+        codeReviewId: 0,
+        creatorId: 0,
+        creatorName: '',
+        content: '',
+        commentType: 'Comment',
+        replyId: 0,
+      },
+    };
+  },
+  methods: {
+    getCreatorName(creatorId) {
+      const members = this.projectMemberList;
+      let creatorName = 'Unknown User';
+      Object.keys(members).forEach((key) => {
+        if (members[key].value === creatorId) creatorName = members[key].label;
+        return true;
+      });
+      return creatorName;
+    },
+    submitComment() {
+      CodeReviewAPI.createComment(this.newComment).then(
+        async (res) => {
+          this.commentList = await CodeReviewAPI.getComment(this.codeReviewId);
+          this.newComment.content = '';
+        },
+      );
+    },
+    formatTime(timestamp) {
+      return new Date(timestamp).toLocaleString();
+    },
+    closeCodeReview() {
+      CodeReviewAPI.closeCodeReview(this.codeReviewId).then(
+        async (res) => {
+          this.codeReviewDetail.status = 'Closed';
+          this.$message('代码评审任务已关闭');
+          this.commentList = await CodeReviewAPI.getComment(this.codeReviewId);
+        },
+      );
+    },
+    reopenCodeReview() {
+      CodeReviewAPI.reopenCodeReview(this.codeReviewId).then(
+        async (res) => {
+          this.codeReviewDetail.status = 'Opened';
+          this.$message('代码评审任务已重新开启');
+          this.commentList = await CodeReviewAPI.getComment(this.codeReviewId);
+        },
+      );
+    },
+    disableMergeButton() {
+      if (this.codeReviewDetail.status !== 'Opened') return true;
+      if (this.currentUser.id !== this.codeReviewDetail.reviewerId) return true;
+      return false;
+    },
+    mergeCodeReview() {
+      CodeReviewAPI.mergeCodeReview(this.codeReviewId).then(
+        async (res) => {
+          if (res === null) {
+            this.codeReviewDetail.status = 'Merged';
+            this.$message('代码评审任务已合并分支');
+            this.commentList = await CodeReviewAPI.getComment(this.codeReviewId);
+          }
+        },
+      );
+    },
+  },
+  computed: {
+    projectMemberList() {
+      return this.$store.getters.userOptions;
+    },
+    currentUser() {
+      return this.$store.state.user;
+    },
+  },
+  async mounted() {
+    this.codeReviewId = this.$route.query.codeReviewId;
+    this.newComment.codeReviewId = this.codeReviewId;
+    this.newComment.creatorId = this.currentUser.id;
+    this.newComment.creatorName = this.currentUser.username;
+    this.codeReviewDetail = await CodeReviewAPI.getCodeReviewDetail(this.codeReviewId);
+    this.commentList = await CodeReviewAPI.getComment(this.codeReviewId);
+    this.codeDiffMsg = await CodeReviewAPI.compareBranch(this.codeReviewDetail.projectId, this.codeReviewDetail.targetBranch, this.codeReviewDetail.sourceBranch);
+  },
+};
+</script>
+<style lang="scss" scoped>
+::v-deep {
+  .el-tabs__item{
+    font-size: 16px;
+  }
+}
+.container {
+  margin: 20px 50px;
+  .el-divider {
+    width: 90%;
+    margin-top: 12px;
+    margin-bottom: 12px;
+  }
+  .el-tabs {
+    width: 90%;
+    font-size: 2em;
+  }
+}
+.title-line {
+  .el-tag {
+    display: inline-block;
+    font-size: 0.95em;
+  }
+  div {
+    display: inline-block;
+    margin-left: 10px;
+  }
+  .el-button {
+    position: absolute;
+    right: 10%;
+  }
+}
+.operation-line {
+  margin-top: 8px;
+  .el-switch {
+    margin-left: 10px;
+  }
+}
+.branch-name {
+  color: #1b69b6;
+  font-weight: 600;
+}
+.comment-item {
+  .el-row {
+    padding: 3px;
+  }
+}
+.creator-name-plate {
+  margin-left: 6px;
+}
+.text-container {
+  font-size: 16px;
+  border: solid gainsboro 1px;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 12px 20px;
+  div {
+    margin-bottom: 8px;;
+  }
+  .el-input {
+    font-size: 13px;
+  }
+}
+</style>

+ 363 - 0
src/views/Project/FileReviewDetail.vue

@@ -0,0 +1,363 @@
+<template>
+  <div class="container">
+    <el-row>
+      <h2>{{fileReview.title}}</h2>
+      <el-tag class="status-tag" size="medium" v-if="fileReview.status === 'Planning'">计划中</el-tag>
+      <el-tag class="status-tag" size="medium" v-if="fileReview.status === 'Inspecting'">审查中</el-tag>
+      <el-tag class="status-tag" size="medium" v-if="fileReview.status === 'Rework'">修改中</el-tag>
+      <el-tag class="status-tag" size="medium" v-if="fileReview.status === 'Completed'">已结束</el-tag>
+    </el-row>
+    <el-row class="status-information">
+      <i class="el-icon-info"></i>
+      <div v-if="fileReview.status === 'Planning' && userRole === 'WORKER'">目前正处于计划阶段,您可以更改递交审查的文件列表</div>
+      <div v-if="fileReview.status === 'Planning' && userRole === 'CREATOR'">目前正处于计划阶段,您可以更改递交审查的文件列表,确认无误后请点击递交审查进入下一阶段</div>
+      <div v-if="fileReview.status === 'Planning' && userRole === 'REVIEWER'">目前正处于计划阶段,您可以预览需评审文件,但暂时无法发表评论</div>
+      <div v-if="fileReview.status === 'Inspecting' && userRole === 'WORKER'">目前正处于评审阶段,请等待所有评审人评审完成</div>
+      <div v-if="fileReview.status === 'Inspecting' && userRole === 'CREATOR'">目前正处于评审阶段,请等待所有评审人评审完成</div>
+      <div v-if="fileReview.status === 'Inspecting' && userRole === 'REVIEWER' && !hasInspected">目前正处于评审阶段,请提出您的评审意见,并根据结果选择返工或是结束</div>
+      <div v-if="fileReview.status === 'Inspecting' && userRole === 'REVIEWER' && hasInspected">目前正处于评审阶段,您已提交了您的评审结果,请等待其他人评审结束</div>
+      <div v-if="fileReview.status === 'Rework' && userRole === 'WORKER'">目前正处于返工阶段,请根据评审意见完成修改</div>
+      <div v-if="fileReview.status === 'Rework' && userRole === 'CREATOR'">目前正处于返工阶段,请根据评审意见完成修改,修改完毕后请点击递交审查进入下一阶段</div>
+      <div v-if="fileReview.status === 'Rework' && userRole === 'REVIEWER'">目前正处于返工阶段,请等待修改完毕</div>
+      <div v-if="fileReview.status === 'Completed'">代码已通过评审</div>
+    </el-row>
+    <el-row>
+      <h3>需评审文件列表</h3>
+      <div class="button-plate">
+        <el-button type="primary" size="small" v-if="showUpdateFileList()" @click="updateFileList">更新文件列表</el-button>
+        <el-button type="success" size="small" v-if="showMoveToInspect()" @click="submitToInspect()">递交审查</el-button>
+        <el-button type="primary" size="small" v-if="showSubmitToRework()" @click="submitToRework()" :disabled="hasInspected">递交返工</el-button>
+        <el-button type="success" size="small" v-if="showSubmitToRework()" @click="submitToComplete()" :disabled="hasInspected">结束评审</el-button>
+      </div>
+    </el-row>
+    <el-tree :load="loadNode" :props="fileListProps" lazy show-checkbox ref="fileTree" node-key="path" @node-click="clickTreeNode"></el-tree>
+    <ElTabs v-model="activeTab" style="width: 90%">
+      <ElTabPane :label="'代码展示'" name="codePlate">
+        <div class="file-headline">{{fileName}}</div>
+        <div class="code-plate">
+          <el-row v-for="(line, index) in code.split('\n')" :key="index">
+            <el-col :span="1" class="line-number">
+              <button @click="clickLine(index+1)">{{index+1}}</button>
+            </el-col>
+            <el-col :span="23">
+              <highlightjs language="java" :code="line" v-if="line !== ''" />
+              <highlightjs language="java" :code="'&emsp;'" v-if="line === ''"/>
+            </el-col>
+          </el-row>
+          <el-drawer v-model="showAddCommentDrawer" >
+            <template #title><h4>添加评论</h4></template>
+            <el-form ref="addComment" :model="addCommentForm" :rules="addCommentRules">
+              <el-form-item label="选择类型">
+                <el-select v-model="addCommentForm.commentType">
+                  <el-option label="普通" value="Comment"/>
+                  <el-option label="警告" value="Warning"/>
+                  <el-option label="严重错误" value="Error"/>
+                </el-select>
+              </el-form-item>
+              <el-form-item label="填写意见" prop="content">
+                <el-input type="textarea" :autosize="{minRows:3, maxRows: 5}" v-model="addCommentForm.content"></el-input>
+              </el-form-item>
+              <el-form-item @click="addComment()">
+                <el-button type="success">创建</el-button>
+              </el-form-item>
+            </el-form>
+          </el-drawer>
+        </div>
+      </ElTabPane>
+      <ElTabPane :label="'评审意见'" name="commentList">
+        <div v-if="commentList.length!==0">
+          <div class="comment-line" v-for="item in commentList" :key="item.id">
+            <el-row>
+              <el-tag size="mini" effect="plain" type="warning" v-if="item.commentType==='Warning'">Warning</el-tag>
+              <el-tag size="mini" effect="plain" type="danger" v-if="item.commentType==='Error'">Error</el-tag>
+              at &nbsp;<span class="path">{{item.content.split('\t')[0]}}</span>, Line&nbsp;<span class="path"> {{item.content.split('\t')[1]}}</span>
+            </el-row>
+            <el-row>{{item.content.split('\t')[2]}}</el-row>
+          </div>
+        </div>
+      </ElTabPane>
+    </ElTabs>
+  </div>
+</template>
+<script>
+import 'highlight.js/lib/common';
+import 'highlight.js/styles/base16/dracula.css';
+import hljsVuePlugin from '@highlightjs/vue-plugin';
+import { CodeReviewAPI } from '../../api';
+
+export default {
+  name: 'FileReviewDetail.vue',
+  components: { highlightjs: hljsVuePlugin.component },
+  data() {
+    return {
+      activeTab: 'codePlate',
+      showAddCommentDrawer: false,
+      fileReviewId: -1,
+      fileReview: {
+        id: -1,
+        projectId: -1,
+        branch: '',
+        creatorId: -1,
+        reviewerList: [],
+        fileList: [],
+        createTime: '',
+        status: '',
+      },
+      userRole: '',
+      fileListProps: {
+        label: 'name',
+        children: 'children',
+        isLeaf: 'leaf',
+      },
+      fileList: [{
+        name: 'file1',
+        path: '',
+      }],
+      nodeTreeRootConfig: {
+        nodeLevel0: {},
+        resolveLevel0: {},
+      },
+      fileName: '',
+      code: 'const i = 100;\nvar adc = true;\n',
+      commentList: [{
+        id: -1,
+        creatorId: -1,
+        creatorName: 'ShanShan',
+        content: 'main.java\t13\tthe veriable i should have an another name',
+        commentType: 'Error',
+      }],
+      addCommentForm: {
+        codeReviewId: -1,
+        creatorId: -1,
+        creatorName: '',
+        commentType: 'Comment',
+        content: '',
+        replyId: -1,
+      },
+      selectedLine: {
+        path: '',
+        line: -1,
+      },
+      addCommentRules: {
+        content: [{ required: true, message: '请输入评审意见', trigger: 'blur' }],
+      },
+      hasInspected: false,
+    };
+  },
+  methods: {
+    async loadNode(node, resolve) {
+      if (node.level === 0) {
+        this.nodeTreeRootConfig = {
+          nodeLevel0: node,
+          resolveLevel0: resolve,
+        };
+        return resolve(this.fileList);
+      }
+      if (node.level > 0) {
+        const subTree = await CodeReviewAPI.getSubTree(this.fileReview.projectId, this.fileReview.branch, node.data.path);
+        return resolve(subTree);
+      }
+      return resolve([]);
+    },
+    setUserRole() {
+      if (this.currentUserId === this.fileReview.creatorId) this.userRole = 'CREATOR';
+      else if (this.fileReview.reviewerList.includes(this.currentUserId)) this.userRole = 'REVIEWER';
+      else this.userRole = 'WORKER';
+    },
+    showUpdateFileList() {
+      return this.fileReview.status === 'Planning' && (this.userRole === 'WORKER' || this.userRole === 'CREATOR');
+    },
+    updateFileList() {
+      this.fileReview.fileList = this.$refs.fileTree.getCheckedKeys();
+      CodeReviewAPI.updateFileList(this.fileReview.id, this.fileReview.fileList).then(
+        (res) => {
+          this.$message({
+            message: '文件列表更新成功',
+            type: 'success',
+          });
+        },
+      );
+    },
+    showMoveToInspect() {
+      return (this.fileReview.status === 'Planning' || this.fileReview.status === 'Rework') && this.userRole === 'CREATOR';
+    },
+    submitToInspect() {
+      CodeReviewAPI.submitToInspection(this.fileReview.id).then(
+        (res) => {
+          this.$message({ message: '递交审查成功', type: 'success' });
+          this.fileReview.status = 'Inspecting';
+        },
+      );
+    },
+    showSubmitToRework() {
+      return this.fileReview.status === 'Inspecting' && this.userRole === 'REVIEWER';
+    },
+    async submitToRework() {
+      this.fileReview.status = await CodeReviewAPI.submitToRework(this.fileReview.id, this.currentUserId);
+      this.$message({ message: '递交结果成功', type: 'success' });
+      this.hasInspected = true;
+    },
+    async submitToComplete() {
+      this.fileReview.status = await CodeReviewAPI.submitToComplete(this.fileReview.id, this.currentUserId);
+      this.$message({ message: '递交结果成功', type: 'success' });
+      this.hasInspected = true;
+    },
+    async clickTreeNode(data, node, obj) {
+      if (node.isLeaf) {
+        if (this.fileReview.fileList.includes(data.path)) {
+          this.fileName = data.path;
+          this.code = await CodeReviewAPI.getFileContent(this.fileReview.projectId, this.fileReview.branch, this.fileName);
+        } else {
+          this.$message('选中的文件不在评审列表中');
+        }
+      }
+    },
+    clickLine(lineNum) {
+      if (this.userRole === 'REVIEWER' && this.fileReview.status === 'Inspecting' && !this.hasInspected) {
+        this.selectedLine.path = this.fileName;
+        this.selectedLine.line = lineNum;
+        this.showAddCommentDrawer = true;
+      }
+      // this.selectedLine.path = this.fileName;
+      // this.selectedLine.line = lineNum;
+      // this.showAddCommentDrawer = true;
+    },
+    addComment() {
+      this.$refs.addComment.validate(
+        (valid) => {
+          if (valid) {
+            this.addCommentForm.content = `${this.selectedLine.path}\t${this.selectedLine.line}\t${this.addCommentForm.content}`;
+            CodeReviewAPI.createCommentForFileReview(this.addCommentForm).then(
+              async (res) => {
+                this.$message({ message: '创建评审意见成功', type: 'success' });
+                this.$refs.addComment.resetFields();
+                this.commentList = await CodeReviewAPI.getFileReviewComment(this.fileReview.id);
+                this.showAddCommentDrawer = false;
+              },
+            );
+          }
+        },
+      );
+    },
+    getFirstFile() {
+      let ret = '';
+      this.fileReview.fileList.forEach((file) => {
+        if (file.includes('.')) ret = file;
+      });
+      return ret;
+    },
+  },
+  computed: {
+    currentUserId() {
+      return this.$store.state.user.id;
+    },
+  },
+  async beforeCreate() {
+    this.fileReviewId = this.$route.query.fileReviewId;
+    this.fileReview = await CodeReviewAPI.getFileReviewDetail(this.fileReviewId);
+    this.fileList = await CodeReviewAPI.getRepositoryTree(this.fileReview.projectId, this.fileReview.branch);
+    this.nodeTreeRootConfig.nodeLevel0.childNodes = [];
+    this.loadNode(this.nodeTreeRootConfig.nodeLevel0, this.nodeTreeRootConfig.resolveLevel0);
+    this.$refs.fileTree.setCheckedKeys(this.fileReview.fileList);
+    this.setUserRole();
+    this.fileName = this.getFirstFile();
+    this.code = await CodeReviewAPI.getFileContent(this.fileReview.projectId, this.fileReview.branch, this.fileName);
+    this.commentList = await CodeReviewAPI.getFileReviewComment(this.fileReview.id);
+    this.addCommentForm.codeReviewId = this.fileReview.id;
+    this.addCommentForm.creatorId = this.currentUserId;
+    this.hasInspected = await CodeReviewAPI.checkInspected(this.fileReview.id, this.currentUserId);
+  },
+};
+</script>
+<style lang="scss" scoped>
+.container {
+  margin: 20px 50px;
+  > * {
+    margin-top: 10px;
+  }
+}
+.status-tag {
+  margin-left:25px;
+}
+.status-information {
+  color: #909399;
+  font-size: 14px;
+}
+.button-plate {
+  position: absolute;
+  right: 10%;
+  z-index: 10;
+}
+.el-tree {
+  border-style: solid;
+  border-width: 1px;
+  border-color: gainsboro;
+  border-radius: 5px;
+  width: 90%;
+}
+.file-headline {
+  width: 90%;
+  height: 24px;
+  padding: 5px;
+  font-size: 19px;
+}
+.code-plate {
+  width: 100%;
+  max-height: 450px;
+  margin-top: 0px;
+  overflow-y: auto;
+  .el-row {
+    height: 30px;
+    .line-number {
+      border-right: solid 1px #909399;
+      button {
+        width: 100%;
+        height: 100%;
+        padding: 5px;
+        border-width: 0px;
+        text-align: right;
+        background-color: #282936;
+        color: #909399;
+        font-family: Consolas, Monaco, monospace;
+        font-size: 14px;
+        cursor: pointer;
+      }
+      :focus-within {
+        background-color: #535353;
+      }
+    }
+  }
+}
+.comment-line {
+  padding: 5px 10px;
+  border-bottom: solid 1px gainsboro;
+  .el-row {
+    padding: 2px;
+    .el-tag {
+      margin-right: 20px;
+    }
+    .path {
+      color: #409EFF;
+    }
+  }
+}
+.el-form {
+  padding: 12px;
+}
+::v-deep {
+  .el-tree {
+    .el-tree-node__content {
+      height: 35px;
+    }
+  }
+  .code-plate {
+    code {
+      padding-top: 5px;
+      padding-bottom: 5px;
+      height: 20px;
+      font-family: Consolas, Monaco, monospace;
+      font-size: 14px;
+    }
+  }
+}
+</style>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio