Ver código fonte

feat: 完成消息通知以及添加文件评审

山山 4 anos atrás
pai
commit
39fe6f06f0

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

+ 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 = () => {

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

@@ -0,0 +1,55 @@
+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}/create`, 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}/get/${codeReviewId}`);
+
+export const createComment = (comment: CommentPayload) => axios.post(`${COMMENT_PREFIX}/create`, comment);
+
+export const getComment = (codeReviewId: number) => axios.get(`${COMMENT_PREFIX}/${codeReviewId}`);
+
+export const getRepositoryTree = (projectId: number, branch: string) => axios.get(`${FILEREVIEW_PREFIX}/repository_tree?projectId=${projectId}&branch=${branch}`);
+
+export const getSubTree = (projectId: number, branch: string, path: string) => axios.get(`${FILEREVIEW_PREFIX}/repository_tree?projectId=${projectId}&branch=${branch}&path=${path}`);
+
+export const inviteUser = (phoneNumber: string) => axios.get(`${FILEREVIEW_PREFIX}/invite?phoneNumber=${phoneNumber}`);
+
+export const createFileReview = (fileReview: FileReviewPayload) => axios.post(`${FILEREVIEW_PREFIX}/create`, fileReview);
+
+export const getFileReviewDetail = (fileReviewId: number) => axios.get(`${FILEREVIEW_PREFIX}/${fileReviewId}`);

+ 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

@@ -11,3 +11,7 @@ export const COMMIT_PREFIX = '/commits';
 export const FUNCTEST_PREFIX = '/func_test';
 export const AUTOTEST_PREFIX = '/auto_test';
 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

@@ -8,6 +8,8 @@ import * as FuncTestAPI from './devcloud/functest';
 import * as AutoTestAPI from './devcloud/autotest';
 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,
@@ -20,4 +22,6 @@ export {
   FuncTestAPI,
   AutoTestAPI,
   SQLExecAPI,
+  CodeReviewAPI,
+  MessageAPI,
 };

+ 80 - 0
src/components/CodeDifferencePlate.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="container">
+    <ElCollapse v-model="activeNames" v-for="item in data" :key="item.newPath">
+      <ElCollapseItem name="item.newPath">
+        <template v-slot:title><i class="el-icon-s-order"/>{{item.newPath}}</template>
+        <!-- <el-input type="textarea" :autosize="{maxRows: 25}" v-model="item.diff" readonly></el-input> -->
+        <table class="code-area" cellspacing="0"  v-for="msg in item.diff.split('\n')" :key="item.diff.indexOf(msg)">
+          <tr class="normal-line" v-if="getLineType(msg) === 0">{{msg}}</tr>
+          <tr class="delete-line" v-if="getLineType(msg) === 1">{{msg}}</tr>
+          <tr class="insert-line" v-if="getLineType(msg) === 2">{{msg}}</tr>
+        </table>
+      </ElCollapseItem>
+    </ElCollapse>
+  </div>
+</template>
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+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 getLineType = (msg: string) => {
+      if (msg.startsWith('+++') || msg.startsWith('---')) return 3;
+      if (msg.startsWith('+')) return 2;
+      if (msg.startsWith('-')) return 1;
+      if (msg.startsWith('\\') || msg.startsWith('@')) return 3;
+      if (msg.length === 0) return 3;
+      return 0;
+    };
+    const activeNames: string[] = [];
+    return {
+      activeNames,
+      getLineType,
+    };
+  },
+});
+</script>
+<style lang="scss" scoped>
+::v-deep {
+  .el-collapse {
+    .el-collapse-item__header {
+      font-size: 17px;
+      font-weight: 500;
+    }
+  }
+}
+.el-icon-s-order {
+    margin-right: 5px;
+    color: gray;
+}
+.code-area {
+  font-family: "Lucida Console";
+  width: 100%;
+  margin-top: 0px;
+  margin-bottom: 0px;
+  .tr {
+    height: 100%;
+  }
+}
+.delete-line {
+  background-color: #fbe9eb;
+}
+.insert-line {
+  background-color: #ecfdf0;
+}
+</style>

+ 261 - 0
src/components/CodeReviewAddDrawer.vue

@@ -0,0 +1,261 @@
+<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.short_id}}</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.short_id}}</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.res"/>
+    </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.author_name;
+    },
+    getCreateTime(branchName) {
+      return this.branches[this.branchOptions.indexOf(branchName)].commit.created_at.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.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.getProjectNameFromUrl(this.currentProjectInfo.gitRemoteUrl);
+      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>

+ 173 - 0
src/components/FileReviewAddDrawer.vue

@@ -0,0 +1,173 @@
+<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="230" trigger="click">
+        <div class="popoverContent">
+          <el-input v-model="phoneNumber" placeholder="请输入手机号码"></el-input>
+          <el-button icon="el-icon-search" circle size="mini" @click="inviteUser"></el-button>
+        </div>
+        <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();
+      console.log(this.fileReviewForm);
+      CodeReviewAPI.createFileReview(this.fileReviewForm).then(
+        (res) => {
+          this.showDrawer = false;
+          this.handleDrawerClose(
+            () => {
+              console.log('finishAdd');
+            },
+          );
+        },
+      );
+    },
+    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%;
+}
+.popoverContent {
+  > * {
+    display: inline-block;
+  }
+}
+::v-deep {
+  .el-tree {
+    .el-tree-node__content {
+      height: 35px;
+    }
+  }
+}
+</style>

+ 19 - 1
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,
@@ -210,7 +225,7 @@ export default defineComponent({
 .user {
   float: right;
   height: 60px;
-  margin: 0 20px;
+  margin: 0 20px 0 0;
 }
 .drawer-content {
   display: flex;
@@ -219,4 +234,7 @@ export default defineComponent({
 .form-container {
   width: 80%;
 }
+.message {
+  float: right;
+}
 </style>

+ 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,
+  };
+};

+ 8 - 0
src/element-plus.ts

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

+ 8 - 0
src/router/index.ts

@@ -74,6 +74,14 @@ const routes: Array<RouteRecordRaw> = [
         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'),
+      },
     ],
   }),
 ];

+ 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>;
+}

+ 132 - 51
src/views/Project/CodeReview.vue

@@ -1,5 +1,7 @@
 <template>
   <div class="container">
+    <CodeReviewAddDrawer ref="addDrawer"/>
+    <FileReviewAddDrawer ref="fileReviewAddDrawer"/>
 <!--    <el-row>-->
 <!--      <h2>代码评审列表</h2>-->
 <!--    </el-row>-->
@@ -19,29 +21,92 @@
 <!--    </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(' + openedNum + ')'" name="Opened">已开放的代码评审</ElTabPane>
-      <ElTabPane :label="'Merged(' + mergedNum + ')'" name="Merged">已合并的代码评审</ElTabPane>
-      <ElTabPane :label="'Closed(' + closedNum + ')'" name="Closed">已关闭的代码评审</ElTabPane>
-      <ElTabPane :label="'All(' + totalNum + ')'" name="All">全部代码评审</ElTabPane>
+      <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 { useRouter } from 'vue-router';
+import { CodeReviewAPI } from '../../api';
+
 export default {
+  components: { CodeReviewAddDrawer, FileReviewAddDrawer },
   name: 'CodeReview.vue',
   data() {
     return {
       activeTab: 'Opened',
-      openedNum: 0,
-      mergedNum: 0,
-      closedNum: 0,
-      totalNum: 0,
-      // tabActive: [true, false, false, false],
-      // tabCount: [0, 0, 0, 0],
+      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;
@@ -49,56 +114,72 @@ export default {
     //   this.tabActive[index] = true;
     //   return null;
     // },
+    addCodeReview() {
+      this.$refs.addDrawer.show();
+    },
+    addFileReview() {
+      this.$refs.fileReviewAddDrawer.show();
+    },
+    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);
   },
-  // async mounted() { console.log('Null'); },
 };
 </script>
 
 <style lang="scss" scoped>
+::v-deep {
+  .el-tabs__item{
+    font-size: 1em;
+  }
+}
 .container {
   margin: 50px 100px;
   & .tabs {
     width: 90%;
-    margin-top: 10px;
+    margin: 10px 0px;
   }
 }
-/*.tabs {*/
-/*  margin: 15px auto;*/
-/*  height: 52px;*/
-/*  border-style: solid;*/
-/*  border-color: #8a8a8a white;*/
-/*  border-width: 0.5px 0px;*/
-/*  !* overflow: hidden; *!*/
-/*}*/
-/*.tab-pane {*/
-/*  display: inline-block;*/
-/*  width: 10%;*/
-/*  padding: 15px 5px;*/
-/*  text-align: center;*/
-/*  border-style: solid;*/
-/*  border-color: white;*/
-/*  border-width: 2px 0px;*/
-/*  height: 18px;*/
-/*  font-size: 15px;*/
-/*  color: #7a7a7a;*/
-/*  cursor: pointer;*/
-/*}*/
-/*.tab-pane:hover{*/
-/*  border-bottom-width: 2px;*/
-/*  border-bottom-color: #8a8a8a;*/
-/*  color: #000;*/
-/*}*/
-/*.tab-pane[active=true]{*/
-/*  border-bottom-color: #1296db;*/
-/*  border-bottom-width: 2px;*/
-/*  color: black;*/
-/*  font-weight: bold;*/
-/*}*/
-/*.tab-pane-inline-circle {*/
-/*  display: inline-block;*/
-/*  padding: 2px;*/
-/*  border-radius: 50%;*/
-/*  width: 15px;*/
-/*  background-color: #eeeeee;*/
-/*}*/
+.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>

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

@@ -0,0 +1,200 @@
+<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'">Close 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="!codeReviewDetail.status === 'Opened'">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>
+        <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.res"/>
+      </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: '',
+      },
+      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(
+        (res) => {
+          this.commentList.push(JSON.parse(JSON.stringify(this.newComment)));
+          this.newComment.content = '';
+        },
+      );
+    },
+  },
+  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-line {
+  border-bottom: gainsboro solid 1px;
+  font-size: 16px;
+  padding: 7px 10px;
+}
+.vice-comment {
+  color: #707070;
+}
+.comment-content {
+  margin-top: 7px;
+  margin-left: 50px;
+}
+.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>

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

@@ -0,0 +1,65 @@
+<template>
+  <div class="container">
+    <h2>{{fileReview.title}}</h2>
+    <el-tree :load="loadNode" :props="fileListProps" lazy show-checkbox ref="fileTree" node-key="path"></el-tree>
+  </div>
+</template>
+<script>
+import { CodeReviewAPI } from '../../api';
+
+export default {
+  name: 'FileReviewDetail.vue',
+  data() {
+    return {
+      fileReviewId: -1,
+      fileReview: {
+        id: -1,
+        projectId: -1,
+        branch: '',
+        creatorId: -1,
+        reviewerList: [],
+        fileList: [],
+        createTime: '',
+        status: '',
+      },
+      fileListProps: {
+        label: 'name',
+        children: 'children',
+        isLeaf: 'leaf',
+      },
+      fileList: [{
+        name: 'file1',
+        path: '',
+      }],
+    };
+  },
+  methods: {
+    async loadNode(node, resolve) {
+      if (node.level === 0) {
+        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([]);
+    },
+  },
+  async created() {
+    this.fileReviewId = this.$route.query.fileReviewId;
+    this.fileReview = await CodeReviewAPI.getFileReviewDetail(this.fileReviewId);
+    this.fileList = await CodeReviewAPI.getRepositoryTree(this.fileReview.projectId, this.fileReview.branch);
+    console.log(this.fileList);
+    this.$refs.fileTree.setCheckedKeys(this.fileReview.fileList);
+    console.log(this.fileReview);
+  },
+};
+</script>
+<style lang="scss" scoped>
+.container {
+  margin: 20px 50px;
+  > * {
+    margin-top: 10px;
+  }
+}
+</style>