فهرست منبع

Merge branch 'dev' into dev_taskList

Jinyao 5 سال پیش
والد
کامیت
cb545c9888
62فایلهای تغییر یافته به همراه2007 افزوده شده و 447 حذف شده
  1. 2 0
      .env
  2. 3 0
      .env.dev
  3. 3 0
      .env.dev-mock
  4. 2 0
      .env.prod
  5. 2 0
      .eslintrc.js
  6. 10 0
      Dockerfile
  7. 15 3
      README.md
  8. 0 2
      babel.config.js
  9. 12 5
      mock/index.js
  10. 45 0
      mock/routers/deployment.js
  11. 114 0
      mock/routers/pipeline.js
  12. 56 12
      mock/routers/project.js
  13. 5 25
      mock/routers/user.js
  14. 5 2
      package.json
  15. 19 15
      src/App.vue
  16. 6 1
      src/api/index.ts
  17. 58 0
      src/api/pipeline.ts
  18. 5 0
      src/api/prefix.ts
  19. 11 5
      src/api/project.ts
  20. 15 0
      src/api/tree.ts
  21. 2 9
      src/api/user.ts
  22. 1 0
      src/assets/icon/add.svg
  23. 1 0
      src/assets/icon/delete.svg
  24. 97 123
      src/components/Menu.vue
  25. 11 22
      src/components/ProjectCard.vue
  26. 1 0
      src/components/RightForm.vue
  27. 68 99
      src/components/SideBar.vue
  28. 265 0
      src/components/Tree.vue
  29. 101 0
      src/components/TreeNode.vue
  30. 19 0
      src/composable/useGroup.ts
  31. 11 12
      src/composable/useProject.ts
  32. 23 0
      src/composable/useUser.ts
  33. 27 0
      src/element-plus.ts
  34. 6 7
      src/main.ts
  35. 28 35
      src/router/index.ts
  36. 9 0
      src/store/dispatchers/index.ts
  37. 11 0
      src/store/dispatchers/token.ts
  38. 9 0
      src/store/dispatchers/user.ts
  39. 15 0
      src/store/dispatchers/workspace.ts
  40. 14 19
      src/store/index.ts
  41. 9 0
      src/store/modules/index.ts
  42. 34 0
      src/store/modules/token.ts
  43. 41 0
      src/store/modules/user.ts
  44. 96 0
      src/store/modules/workspace.ts
  45. 40 0
      src/store/typings.ts
  46. 30 0
      src/typings/project.d.ts
  47. 0 0
      src/typings/shims-vue.d.ts
  48. 29 0
      src/typings/vuex.d.ts
  49. 15 18
      src/utils/axios.ts
  50. 15 0
      src/utils/debounce.ts
  51. 1 0
      src/utils/jsonlint.js
  52. 10 0
      src/utils/qs.ts
  53. 11 0
      src/utils/token.ts
  54. 4 0
      src/utils/user.ts
  55. 0 4
      src/utils/userStatus.ts
  56. 11 8
      src/views/Home.vue
  57. 2 2
      src/views/Login.vue
  58. 390 4
      src/views/Project/Pipeline.vue
  59. 63 2
      src/views/Project/Schedule.vue
  60. 11 13
      src/views/ProjectContainer.vue
  61. 8 0
      vue.config.js
  62. 80 0
      yarn.lock

+ 2 - 0
.env

@@ -0,0 +1,2 @@
+VUE_APP_PORTAL_HOST='http://p.seecoder.cn'
+VUE_APP_API_URL = '/api'

+ 3 - 0
.env.dev

@@ -0,0 +1,3 @@
+NODE_ENV = development
+VUE_APP_API_URL = 'http://localhost:8080'
+VUE_APP_ENV_NAME = '[开发环境]'

+ 3 - 0
.env.dev-mock

@@ -0,0 +1,3 @@
+NODE_ENV = development-mock
+VUE_APP_API_URL = 'http://localhost:4000/api'
+VUE_APP_ENV_NAME = '[Mock开发环境]'

+ 2 - 0
.env.prod

@@ -0,0 +1,2 @@
+NODE_ENV = production
+VUE_APP_ENV_NAME = ''

+ 2 - 0
.eslintrc.js

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

+ 10 - 0
Dockerfile

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

+ 15 - 3
README.md

@@ -1,11 +1,23 @@
 # devcloud-frontend
 
+## 命令介绍
+
 请使用`yarn`作为包管理器
 
 `yarn mock`启动`mock server`。
 
-`yarn serve`启动本地服务.
+`yarn serve`启动本地服务,需搭配本地运行在8080端口的后端服务。
+
+`yarn serve:mock` 启动本地服务,需搭配`mock server`进行使用。
+
+`yarn build:test`执行线上测试环境打包构建。
+
+`yarn build:prod`执行生产环境打包构建。
+
+`yarn lint --fix`进行格式化。
+
+## 环境变量说明
 
-`yarn build`执行构建。
+`.env`文件中,声明单点登录门户网站`host`
 
-`yarn lint`进行格式化。
+`.env.[mode]`文件中,声明当前环境的接口地址`API_URL`与环境名`ENV_NAME`

+ 0 - 2
babel.config.js

@@ -4,8 +4,6 @@ module.exports = {
       'import',
       {
         libraryName: 'element-plus',
-        // 由于 customStyleName 在配置中被声明的原因,`style: true` 会被直接忽略掉,
-        // 如果你需要使用 scss 源文件,把文件结尾的扩展名从 `.css` 替换成 `.scss` 就可以了
         customStyleName: (name) => `element-plus/lib/theme-chalk/${name}.css`
         ,
       },

+ 12 - 5
mock/index.js

@@ -11,15 +11,22 @@ app.use(bodyParser.json());
 // 添加跨域
 app.use((req, res, next) => {
   res.header('Access-Control-Allow-Origin', '*');
-  res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS');
-  res.header('Access-Control-Allow-Headers', 'X-Requested-With');
-  res.header('Access-Control-Allow-Headers', 'Content-Type');
-  next();
+  res.header('Access-Control-Allow-Methods', '*');
+  res.header('Access-Control-Allow-Headers', '*');
+  if (req.method === 'OPTIONS') {
+    res.sendStatus(200);
+  } else {
+    next();
+  }
 });
 
 const prefix = '/api';
 
-app.use(`${prefix}/user`, require('./routers/user'));
+app.use(`${prefix}/users`, require('./routers/user'));
 app.use(`${prefix}/project`, require('./routers/project'));
+app.use(`${prefix}/pipelines`, require('./routers/pipeline'));
+app.use(`${prefix}/deployments`, require('./routers/deployment'));
 
 app.listen(4000);
+
+console.log('Mock Server is now running at port 4000.');

+ 45 - 0
mock/routers/deployment.js

@@ -0,0 +1,45 @@
+/* eslint-disable import/no-extraneous-dependencies */
+/* eslint-disable @typescript-eslint/no-var-requires */
+const router = require('express').Router();
+const Mock = require('mockjs');
+const wrapper = require('../wrapper');
+
+const { Random } = Mock;
+
+router.delete('', (_, res) => {
+  res.json(wrapper('success'));
+});
+
+router.post('', (_, res) => {
+  res.json(wrapper('success'));
+});
+
+router.get('/list', (req, res) => {
+  res.json(wrapper(
+    Mock.mock(
+      {
+        'data|10': [{
+          accessUrl: Random.url('http', 'production.seecoder.cn'),
+          'name|1': ['MySQL', 'Java', 'Node'],
+          status: '正常',
+          'id|+5': 101,
+          pipelineId: 100,
+          deployedTime: {
+            date: 0,
+            day: 1,
+            'hours|+1': 12,
+            'minutes|+1': 30,
+            month: 3,
+            nanos: 0,
+            'seconds|+1': 15,
+            time: 0,
+            timezoneOffset: 0,
+            year: 2021,
+          },
+        }],
+      },
+    ).data,
+  ));
+});
+
+module.exports = router;

+ 114 - 0
mock/routers/pipeline.js

@@ -0,0 +1,114 @@
+/* eslint-disable max-len */
+/* eslint-disable import/no-extraneous-dependencies */
+/* eslint-disable @typescript-eslint/no-var-requires */
+const router = require('express').Router();
+const Mock = require('mockjs');
+const wrapper = require('../wrapper');
+
+const { Random } = Mock;
+
+router.delete('', (req, res) => {
+  res.json(wrapper('success'));
+});
+
+router.get('', (req, res) => {
+  res.json(wrapper({
+    configJson: {
+      code: 0,
+      data: {
+        configJson: 'string',
+        id: 0,
+      },
+      msg: 'string',
+    },
+    id: req.params.pipelineId,
+  }));
+});
+
+router.post('', (req, res) => {
+  res.json(wrapper('success'));
+});
+
+router.get('/templates', (req, res) => {
+  res.json(wrapper(['Java', 'MySQL', 'Node', 'Go', 'Android']));
+});
+
+router.put('/config', (req, res) => {
+  res.json(wrapper('success'));
+});
+
+router.get('/history/list', (req, res) => {
+  res.json(wrapper(
+    Mock.mock(
+      {
+        'data|10': [{
+          details: `2021-03-16 00:01:58.621  INFO 20584 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
+2021-03-16 00:01:58.622  INFO 20584 --- [           main] d.s.w.p.DocumentationPluginsBootstrapper : Context refreshed
+2021-03-16 00:01:58.634  INFO 20584 --- [           main] d.s.w.p.DocumentationPluginsBootstrapper : Found 1 custom documentation plugin(s)
+2021-03-16 00:01:58.641  INFO 20584 --- [           main] s.d.s.w.s.ApiListingReferenceScanner     : Scanning for api listing references
+2021-03-16 00:01:58.974  INFO 20584 --- [           main] DeferredRepositoryInitializationListener : Triggering deferred initialization of Spring Data repositories…
+2021-03-16 00:01:58.975  INFO 20584 --- [           main] DeferredRepositoryInitializationListener : Spring Data repositories initialized!
+2021-03-16 00:01:58.993  INFO 20584 --- [           main] s.devcloud.web.DevcloudWebApplication    : Started DevcloudWebApplication in 6.659 seconds (JVM running for 8.913)
+2021-03-16 00:12:39.211  INFO 20584 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
+2021-03-16 00:12:39.211  INFO 20584 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
+2021-03-16 00:12:39.222  INFO 20584 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 11 ms`,
+          'id|+5': 101,
+          pipelineId: 100,
+          'result|1': ['成功', '失败', '执行中'],
+          startTime: {
+            date: 0,
+            day: 1,
+            'hours|+1': 12,
+            'minutes|+1': 30,
+            month: 3,
+            nanos: 0,
+            'seconds|+1': 15,
+            time: 0,
+            timezoneOffset: 0,
+            year: 2021,
+          },
+          username: 'hkshu12',
+        }],
+      },
+    ).data,
+  ));
+});
+
+router.get('/list', (req, res) => {
+  res.json(wrapper(
+    Mock.mock(
+      {
+        'data|10': [{
+          details: `2021-03-16 00:01:58.621  INFO 20584 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
+2021-03-16 00:01:58.622  INFO 20584 --- [           main] d.s.w.p.DocumentationPluginsBootstrapper : Context refreshed
+2021-03-16 00:01:58.634  INFO 20584 --- [           main] d.s.w.p.DocumentationPluginsBootstrapper : Found 1 custom documentation plugin(s)
+2021-03-16 00:01:58.641  INFO 20584 --- [           main] s.d.s.w.s.ApiListingReferenceScanner     : Scanning for api listing references
+2021-03-16 00:01:58.974  INFO 20584 --- [           main] DeferredRepositoryInitializationListener : Triggering deferred initialization of Spring Data repositories…
+2021-03-16 00:01:58.975  INFO 20584 --- [           main] DeferredRepositoryInitializationListener : Spring Data repositories initialized!
+2021-03-16 00:01:58.993  INFO 20584 --- [           main] s.devcloud.web.DevcloudWebApplication    : Started DevcloudWebApplication in 6.659 seconds (JVM running for 8.913)
+2021-03-16 00:12:39.211  INFO 20584 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
+2021-03-16 00:12:39.211  INFO 20584 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
+2021-03-16 00:12:39.222  INFO 20584 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 11 ms`,
+          'id|+1': 100,
+          name: Random.cname(),
+          'result|1': ['成功', '失败', '执行中'],
+          startTime: {
+            date: 0,
+            day: 1,
+            'hours|+1': 12,
+            'minutes|+1': 30,
+            month: 3,
+            nanos: 0,
+            'seconds|+1': 15,
+            time: 0,
+            timezoneOffset: 0,
+            year: 2021,
+          },
+          username: 'hkshu12',
+        }],
+      },
+    ).data,
+  ));
+});
+
+module.exports = router;

+ 56 - 12
mock/routers/project.js

@@ -6,21 +6,65 @@ const wrapper = require('../wrapper');
 
 const { Random } = Mock;
 
-router.get('/all',
+const groups = [101, 102, 103, 104];
+
+router.get('/:projectId',
+  (req, res) => {
+    const { projectId: id } = req.params;
+    res.json(wrapper(
+      Mock.mock(
+        {
+          description: Random.csentence(5, 40),
+          gitRemoteUrl: Random.url('http', 'gitlab.seecoder.cn'),
+          groupId: 101,
+          id,
+          name: 'string',
+        },
+      ),
+    ));
+  });
+
+router.post('/create',
+  (req, res) => {
+    const { body } = req;
+    body.id = 101;
+    res.json(wrapper(body));
+  });
+
+router.get('/listByUser/:userId',
   (req, res) => {
-    const query = req.query || {};
-    const { userId } = query;
     res.json(wrapper(
-      Mock.mock({
-        userId,
-        'projectList|1-10': [
-          {
-            'projectId|+1': 100,
-            name: Random.ctitle(10),
-          },
-        ],
-      }),
+      Mock.mock(Mock.mock({
+        'data|5-10': [{
+          'id|+1': 100,
+          description: Random.csentence(5, 40),
+          gitRemoteUrl: Random.url('http', 'gitlab.seecoder.cn'),
+          'groupId|1': groups,
+          name: Random.cname(),
+        }],
+      }).data),
     ));
   });
 
+router.get('/listByGroup/:groupId',
+  (req, res) => {
+    const { groupId } = req.params;
+    res.json(wrapper(
+      Mock.mock(Mock.mock({
+        'data|3-5': [{
+          'id|+1': 100,
+          description: Random.csentence(5, 40),
+          gitRemoteUrl: Random.url('http', 'gitlab.seecoder.cn'),
+          groupId,
+          name: Random.cname(),
+        }],
+      }).data),
+    ));
+  });
+
+router.get('/delete/:projectId',
+  (req, res) => {
+    res.json(wrapper({}));
+  });
+
 module.exports = router;

+ 5 - 25
mock/routers/user.js

@@ -1,34 +1,14 @@
 /* eslint-disable import/no-extraneous-dependencies */
 /* eslint-disable @typescript-eslint/no-var-requires */
 const router = require('express').Router();
+const jwt = require('jsonwebtoken');
 const wrapper = require('../wrapper');
 
-router.get('/info/:id',
-  (req, res) => res.json(wrapper({
-    id: req.params.id,
-    username: 'kenny',
-  })));
-// query参数可以通过req.query.name取得
-router.get('/info',
-  (req, res) => {
-    const query = req.query || {};
-    return res.json(wrapper({
-      limit: query.limit,
-      offset: query.offset,
-      list: [{
-        username: 'admin1',
-        sex: 1,
-      }, {
-        username: 'admin2',
-        sex: 0,
-      }],
-    }));
-  });
+const SECRET = 'SEEC-1919810-OIDC-114514';
 
-router.post('/post',
+router.get('/self',
   (req, res) => {
-    console.log(req.body);
-    res.end();
+    const userinfo = jwt.decode(req.headers.authorization, SECRET);
+    res.json(wrapper(userinfo));
   });
-
 module.exports = router;

+ 5 - 2
package.json

@@ -3,8 +3,10 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "serve": "vue-cli-service serve",
-    "build": "vue-cli-service build",
+    "serve": "vue-cli-service serve --port 3000 --mode dev",
+    "serve:mock": "vue-cli-service serve --port 3000 --mode dev-mock",
+    "build:test": "vue-cli-service build --mode test",
+    "build:prod": "vue-cli-service build --mode prod",
     "lint": "vue-cli-service lint",
     "mock": "node mock/index.js"
   },
@@ -12,6 +14,7 @@
     "axios": "^0.21.1",
     "core-js": "^3.6.5",
     "element-plus": "^1.0.2-beta.31",
+    "jsonwebtoken": "^8.5.1",
     "vue": "^3.0.0",
     "vue-axios": "^3.2.4",
     "vue-class-component": "^8.0.0-0",

+ 19 - 15
src/App.vue

@@ -1,28 +1,32 @@
 <template>
-  <Menu
-    :project-list="projectList"
-    :selected-project="selectedProject"
-    @project-change="handleProjectChange"></Menu>
-  <router-view/>
+  <el-header>
+    <Menu></Menu>
+  </el-header>
+  <el-container>
+    <router-view/>
+  </el-container>
 </template>
 
 <script lang="ts">
-import {
-  defineComponent,
-} from 'vue';
+import { defineComponent } from 'vue';
 import Menu from '@/components/Menu.vue';
 
-import { useProject } from '@/composable/useProject';
+import { getVariableFromQueryString } from '@/utils/qs';
+import { WorkspaceDispatcher, UserDispatcher, TokenDispatcher } from './store/dispatchers';
+import { useStore } from './store';
 
 export default defineComponent({
   components: { Menu },
   setup() {
-    const { projectList, selectedProject, handleProjectChange } = useProject();
-    return {
-      projectList,
-      selectedProject,
-      handleProjectChange,
-    };
+    const store = useStore();
+    console.log(store.state);
+    const token = getVariableFromQueryString('id_token');
+    if (token) {
+      TokenDispatcher.updateToken(token);
+    }
+
+    UserDispatcher.updateUserInfo();
+    WorkspaceDispatcher.refreshProjectList();
   },
 });
 </script>

+ 6 - 1
src/api/index.ts

@@ -1,6 +1,11 @@
 import * as UserAPI from './user';
 import * as ProjectAPI from './project';
+import * as RequirementAPI from './tree';
+import * as PipelineAPI from './pipeline';
 
 export {
-  UserAPI, ProjectAPI,
+  UserAPI,
+  ProjectAPI,
+  RequirementAPI,
+  PipelineAPI,
 };

+ 58 - 0
src/api/pipeline.ts

@@ -0,0 +1,58 @@
+import axios from '@/utils/axios';
+
+import { PIPELINE_PREFIX, DEPLOYMENT_PREFIX } from './prefix';
+
+interface PipelinePayload {
+  projectId: number;
+  pipelineId: number;
+}
+
+type CreatePipelinePayload = PipelinePayload & {
+  name: string;
+  templateName: string;
+}
+
+type UpdatePipelinePayload = PipelinePayload & {
+  configJson: string;
+}
+
+export const deletePipeline = (pipeline: PipelinePayload) => axios.delete(`${PIPELINE_PREFIX}`, {
+  params: pipeline,
+});
+
+export const getPipeline = (pipeline: PipelinePayload) => axios.get(`${PIPELINE_PREFIX}`, {
+  params: pipeline,
+});
+
+export const createPipeline = (createPipelinePayload: CreatePipelinePayload) => axios.post(`${PIPELINE_PREFIX}`, createPipelinePayload);
+
+export const updatePipeline = (updatePipelinePayload: UpdatePipelinePayload) => axios.put(`${PIPELINE_PREFIX}/config`,
+  updatePipelinePayload);
+
+export const getPipelineTemplates = () => axios.get(`${PIPELINE_PREFIX}/templates`);
+
+export const getPipelineHistory = (pipeline: PipelinePayload) => axios.get(`${PIPELINE_PREFIX}/history/list`, {
+  params: pipeline,
+});
+
+export const getPipelinesByProjectId = (projectId: number) => axios.get(`${PIPELINE_PREFIX}/list`, {
+  params: {
+    projectId,
+  },
+});
+
+export const deleteInstance = (deploymentId: number) => axios.delete(`${DEPLOYMENT_PREFIX}`, {
+  params: {
+    deploymentId,
+  },
+});
+
+export const triggerDeployment = (pipeline: PipelinePayload) => axios.post(`${DEPLOYMENT_PREFIX}`, {
+  params: pipeline,
+});
+
+export const getDeploymentsByProjectId = (projectId: number) => axios.get(`${DEPLOYMENT_PREFIX}/list`, {
+  params: {
+    projectId,
+  },
+});

+ 5 - 0
src/api/prefix.ts

@@ -0,0 +1,5 @@
+export const PIPELINE_PREFIX = '/pipelines';
+export const DEPLOYMENT_PREFIX = '/deployments';
+export const USER_PREFIX = '/users';
+export const TREE_PREFIX = '/tree';
+export const PROJECT_PREFIX = '/project';

+ 11 - 5
src/api/project.ts

@@ -1,7 +1,13 @@
 import axios from '@/utils/axios';
 
-export const getProjectList = (userId: number) => axios.get('/project/all', {
-  params: {
-    userId,
-  },
-});
+import { PROJECT_PREFIX } from './prefix';
+
+export const getProjectById = (projectId: number) => axios.get(`${PROJECT_PREFIX}/${projectId}`);
+
+export const createProject = (project: Project) => axios.post(`${PROJECT_PREFIX}/create`, project);
+
+export const getProjectListByUserId = (userId: number) => axios.get(`${PROJECT_PREFIX}/listByUser/${userId}`);
+
+export const getProjectListByGroupId = (groupId: number) => axios.get(`${PROJECT_PREFIX}/listByGroup/${groupId}`);
+
+export const deleteProject = (projectId: string) => axios.get(`${PROJECT_PREFIX}/delete/${projectId}`);

+ 15 - 0
src/api/tree.ts

@@ -0,0 +1,15 @@
+import axios from '@/utils/axios';
+
+import { TREE_PREFIX } from './prefix';
+
+export const getAllTreeNodes = () => axios.get(`${TREE_PREFIX}/all`);
+
+export const insertTreeNode = (treenode: TreeNode) => axios.post(`${TREE_PREFIX}/node`, treenode);
+
+export const updateTreeNode = (treenode: TreeNode) => axios.put(`${TREE_PREFIX}/node`, treenode);
+
+export const deleteTreeNode = (nodeId: number) => axios.delete(`${TREE_PREFIX}/node/${nodeId}`);
+
+export const addChildNode = (parentId: number) => axios.post(`${TREE_PREFIX}/subNode/${parentId}`);
+
+export const getTreeNodesByProjectId = (projectId: number) => axios.get(`${TREE_PREFIX}/${projectId}`);

+ 2 - 9
src/api/user.ts

@@ -1,12 +1,5 @@
 import axios from '@/utils/axios';
 
-export const getUserInfo = (userId: number) => axios.get(`/user/info/${userId}`);
+import { USER_PREFIX } from './prefix';
 
-export const getUserInfo2 = (userId: number) => axios.get('/user/info', {
-  params: { userId },
-});
-
-export const post = () => axios.post('/user/post', {
-  userId: 3,
-  gender: 'male',
-});
+export const getUserInfo = () => axios.get(`${USER_PREFIX}/self`);

+ 1 - 0
src/assets/icon/add.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="1615360924760" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1836" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M896 544H128c-17.6 0-32-14.4-32-32s14.4-32 32-32h768c17.6 0 32 14.4 32 32s-14.4 32-32 32z" p-id="1837"></path><path d="M512 928c-17.6 0-32-14.4-32-32V128c0-17.6 14.4-32 32-32s32 14.4 32 32v768c0 17.6-14.4 32-32 32z" p-id="1838"></path></svg>

+ 1 - 0
src/assets/icon/delete.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="1615360913868" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2268" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M768 960H256c-52.8 0-96-43.2-96-96V288c0-17.6 14.4-32 32-32s32 14.4 32 32v576c0 17.6 14.4 32 32 32h512c17.6 0 32-14.4 32-32V288c0-17.6 14.4-32 32-32s32 14.4 32 32v576c0 52.8-43.2 96-96 96zM896 192H672c-8 0-16-3.2-22.4-9.6l-44.8-44.8c-6.4-6.4-14.4-9.6-24-9.6h-139.2c-8 0-16 3.2-22.4 9.6l-44.8 44.8c-6.4 6.4-14.4 9.6-22.4 9.6H128c-17.6 0-32-14.4-32-32s14.4-32 32-32h211.2l35.2-35.2C392 73.6 417.6 64 443.2 64h139.2c25.6 0 49.6 9.6 67.2 28.8L684.8 128H896c17.6 0 32 14.4 32 32s-14.4 32-32 32z" p-id="2269"></path><path d="M400 832c-17.6 0-32-14.4-32-32V288c0-17.6 14.4-32 32-32s32 14.4 32 32v512c0 17.6-14.4 32-32 32zM624 832c-17.6 0-32-14.4-32-32V288c0-17.6 14.4-32 32-32s32 14.4 32 32v512c0 17.6-14.4 32-32 32z" p-id="2270"></path></svg>

+ 97 - 123
src/components/Menu.vue

@@ -1,145 +1,119 @@
 <template>
-  <div id="menu" class="menu-container">
-    <div class="menu-logo">
+  <ElMenu class="menu" mode="horizontal" @select="handleSelect">
+    <ElMenuItem class="menu-logo" index="logo" disabled>
       LOGO
-    </div>
-    <div class="project-switcher" v-show="projectList.length>0" ref="switcher">
-      <div class="selected-project-name">{{selectedProject.name}}</div>
-      <div class="icon" @click="handleSwitcherClick"></div>
-      <transition name="expand">
-        <div class="dropdown" v-show="isDropDownExpand">
-          <div
-            v-for="item in projectList"
-            :key="item.id"
-            class="item"
-            @click="handleProjectChange(item)">{{item.name}}</div>
-        </div>
-      </transition>
-    </div>
-    <div class="notify"></div>
-    <div class="user"></div>
-  </div>
+    </ElMenuItem>
+    <ElSubmenu index="group">
+      <template #title>{{selectedGroup.name}}</template>
+      <template  v-if="groupList.length > 0">
+      <ElMenuItem v-for="group in groupList" :key="group.id" :index="`group-${group.id}`">{{group.name}}</ElMenuItem>
+      </template>
+      <template v-else>
+        <ElMenuItem disabled>暂未加入任何小组</ElMenuItem>
+      </template>
+    </ElSubmenu>
+    <ElSubmenu index="project">
+      <template #title>{{selectedProject.name}}</template>
+      <template v-if="projectList.length > 0">
+        <ElMenuItem v-for="project in projectList" :key="project.id" :index="`project-${project.id}`">{{project.name}}</ElMenuItem>
+      </template>
+      <template v-else-if="selectedGroup.id === -1">
+        <ElMenuItem disabled>请先选择小组</ElMenuItem>
+      </template>
+      <template v-else>
+        <ElMenuItem disabled>该小组暂无项目</ElMenuItem>
+      </template>
+    </ElSubmenu>
+      <template v-if="userLoggedIn">
+        <ElSubmenu index="user" class="user">
+          <template #title>{{userInfo.username}}</template>
+          <ElMenuItem index="user-create">创建小组</ElMenuItem>
+          <ElMenuItem index="user-join">加入小组</ElMenuItem>
+          <ElMenuItem index="user-logout">登出</ElMenuItem>
+        </ElSubmenu>
+      </template>
+      <template v-else>
+        <ElLink type="primary" href="/login" class="user">登录</ElLink>
+      </template>
+  </ElMenu>
 </template>
 
-<script>
-export default {
+<script lang="ts">
+import { useProjectList } from '@/composable/useProject';
+import { useGroupList } from '@/composable/useGroup';
+import { useUser } from '@/composable/useUser';
+import { defineComponent } from 'vue';
+
+export default defineComponent({
   name: 'Menu',
-  props: {
-    projectList: Array,
-    selectedProject: Object,
-  },
-  data() {
+  setup(_, { emit }) {
+    const { projectListOfCurrentGroup: projectList, selectedProject, handleProjectChange } = useProjectList();
+    const { groupList, selectedGroup, handleGroupChange } = useGroupList();
+    const { userInfo, userLoggedIn, userLogOut } = useUser();
+
+    const handleSelect = (index: string) => {
+      const [type, id] = index.split('-');
+      switch (type) {
+        case 'logo': {
+          break;
+        }
+        case 'project': {
+          handleProjectChange(parseInt(id, 10));
+          break;
+        }
+        case 'group': {
+          handleGroupChange(parseInt(id, 10));
+          break;
+        }
+        case 'user': {
+          if (id === 'create') {
+            emit('show-create-group-drawer');
+          } else if (id === 'join') {
+            emit('show-join-group-drawer');
+          } else if (id === 'logout') {
+            userLogOut();
+          }
+          break;
+        }
+        default:
+          console.warn('unknown type of sub-menu');
+          break;
+      }
+    };
+
     return {
-      isDropDownExpand: false,
+      projectList,
+      selectedProject,
+      groupList,
+      selectedGroup,
+      handleSelect,
+      userInfo,
+      userLoggedIn,
+      userLogOut,
     };
   },
-  mounted() {
-    document.addEventListener('click', (e) => {
-      if (!this.$refs.switcher.contains(e.target)) {
-        this.isDropDownExpand = false;
-      }
-    });
-  },
-  methods: {
-    handleSwitcherClick() {
-      this.isDropDownExpand = !this.isDropDownExpand;
-    },
-    handleProjectChange(item) {
-      this.$emit('project-change', item.id);
-      this.isDropDownExpand = !this.isDropDownExpand;
-    },
-  },
-};
+});
 </script>
 
 <style scoped>
-.menu-container{
-  height: 58px;
+.menu {
   width: 100%;
-  position: fixed;
-  top:0px;
-  background-color: #34495E;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: start;
 }
-.menu-logo{
-  color: #FFFFFF;
+.menu-logo {
+  color: #000000;
   font-size: 24px;
   font-weight: bolder;
   line-height: 58px;
   margin-left: 30px;
   transition: all .2s linear;
 }
-.menu-logo:hover{
-  color: #BDC3C7;
-}
-.project-switcher{
-  margin-left: 20px;
-  background-color: #2C3E50;
-  height: 30px;
-  padding: 5px;
-  width: 240px;
-  display: flex;
-  flex-direction: row;
-  justify-content: start;
-  align-items: center;
-  border-radius: 5px;
-  position: relative;
-}
-.project-switcher .icon{
-  width: 24px;
-  height: 24px;
-  cursor: pointer;
-  margin-left: auto;
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: 100% 100%;
-  transition: all .2s linear;
-  background-image: url("../assets/icon/menu.svg");
-}
-.project-switcher .icon:hover{
-  background-image: url("../assets/icon/menu-hover.svg");
-}
-.project-switcher .dropdown{
-  width: 240px;
-  position: absolute;
-  background-color: #2C3E50;
-  color: #FFFFFF;
-  top: 39px;
-  left: 0px;
-  padding: 5px;
-  border-radius: 0 0 5px 5px;
-  flex-direction: column;
-  justify-content: center;
-  align-items: start;
-  transform-origin: 0px 0px;
-}
-.expand-enter-active,.expand-leave-active{
-  transition: all .3s ease-in-out;
-}
-.expand-enter-from,.expand-leave-to{
-  opacity: 0;
-  transform: scaleY(0);
-}
-.project-switcher .selected-project-name{
-  width: 220px;
-  line-height: 30px;
-  color: #FFFFFF;
-  text-align: start;
-  text-overflow: ellipsis;
-  white-space:nowrap; overflow:hidden
-}
-.project-switcher .dropdown .item{
-  line-height: 24px;
-  width: 240px;
-  text-align: start;
-  text-overflow: ellipsis;
-  white-space:nowrap; overflow:hidden;
-  cursor: pointer;
+.menu-logo.is-disabled {
+  opacity: 1;
+  cursor: default;
 }
-.project-switcher .dropdown .item:hover{
-  background-color: #34495E;
+.user {
+  float: right;
+  height: 60px;
+  margin: 0 20px;
 }
 </style>

+ 11 - 22
src/components/ProjectCard.vue

@@ -1,10 +1,11 @@
 <template>
-  <div class="project-card" @click="goToProject(id)">
-    <div class="project-name">{{name}}</div>
-    <div class="project-description">
-      {{description}}
-    </div>
-  </div>
+  <ElCard class="project-card" shadow="hover">
+    <template #header>
+      <span>{{name}}</span>
+    </template>
+    <div>描述:{{description}}</div>
+    <div>链接:{{gitRemoteUrl}}</div>
+  </ElCard>
 </template>
 
 <script lang="ts">
@@ -15,27 +16,15 @@ export default defineComponent({
     id: Number,
     name: String,
     description: String,
-  },
-  methods: {
-    goToProject(id: number) {
-      this.$router.push(`/project/${id}/schedule`);
-    },
+    gitRemoteUrl: String,
+    groupId: Number,
   },
 });
 </script>
 
 <style lang="scss" scoped>
 .project-card {
-  display: inline-block;
-  box-shadow: 0 0 1px #BDC3C7;
-  border-radius: 5px;
-  padding: 10px;
-  height: 150px;
-  width: 320px;
-  margin: 10px auto;
-  transition: all .3s ease;
-}
-.project-card:hover {
-  box-shadow: 0 0 5px #BDC3C7;
+  width: calc(25% - 24px);
+  margin: 12px;
 }
 </style>

+ 1 - 0
src/components/RightForm.vue

@@ -37,6 +37,7 @@ export default {
   width: 800px;
   box-shadow: 0 0 5px #BDC3C7;
   background: #FFFFFF;
+  z-index: 101;
 }
 .translation-enter-active,.translation-leave-active {
   transition: all 0.3s ease-out;

+ 68 - 99
src/components/SideBar.vue

@@ -1,135 +1,104 @@
 <template>
-  <div class="side-bar-container" @click="handleClick">
-      <div :class="{icon:true,selected:selectedIndex === 0}" target="schedule"></div>
-      <div :class="{icon:true,selected:selectedIndex === 1}" target="taskList"></div>
-      <div :class="{icon:true,selected:selectedIndex === 2}" target="bugList"></div>
-      <div :class="{icon:true,selected:selectedIndex === 3}" target="statistics"></div>
-      <div :class="{icon:true,selected:selectedIndex === 4}" target="pipeline"></div>
-      <div :class="{icon:true,selected:selectedIndex === 5}" target="apitest"></div>
-  </div>
+  <el-menu default-active="schedule" class="side-bar" :collapse="true" :router="true">
+    <el-menu-item index="schedule">
+      <i class="icon" target="schedule"></i>
+    </el-menu-item>
+    <el-menu-item index="taskList">
+      <i class="icon" target="taskList"></i>
+    </el-menu-item>
+    <el-menu-item index="bugList">
+      <i class="icon" target="bugList"></i>
+    </el-menu-item>
+    <el-menu-item index="statistics">
+      <i class="icon" target="statistics"></i>
+    </el-menu-item>
+    <el-menu-item index="pipeline">
+      <i class="icon" target="pipeline"></i>
+    </el-menu-item>
+    <el-menu-item index="apitest">
+      <i class="icon" target="apitest"></i>
+    </el-menu-item>
+  </el-menu>
 </template>
 
 <script>
 export default {
   name: 'SideBar',
-  data() {
-    return {
-      selectedIndex: 0,
-    };
-  },
-  methods: {
-    handleClick(e) {
-      const value = e.target.attributes.target.nodeValue ? e.target.attributes.target.nodeValue : '';
-      switch (value) {
-        case 'schedule':
-          this.selectedIndex = 0;
-          break;
-        case 'taskList':
-          this.selectedIndex = 1;
-          break;
-        case 'bugList':
-          this.selectedIndex = 2;
-          break;
-        case 'statistics':
-          this.selectedIndex = 3;
-          break;
-        case 'pipeline':
-          this.selectedIndex = 4;
-          break;
-        case 'apitest':
-          this.selectedIndex = 5;
-          break;
-        default:
-          throw new TypeError('Unknown Tab Value!');
-      }
-
-      this.$emit('change', value);
-    },
-  },
 };
 </script>
 
-<style scoped>
-.side-bar-container{
-  width: 42px;
+<style lang="scss" scoped>
+.side-bar {
   height: 100%;
-  position: fixed;
-  top:58px;
-  left: 0;
-  background-color: #ecf0f1;
-  display: flex;
-  flex-direction: column;
-  justify-content: start;
-  align-items: center;
-  padding-top: 20px;
 }
-.icon{
+.icon {
   width: 28px;
   height: 28px;
+  float: right;
   background-position: center;
   background-repeat: no-repeat;
   background-size: 100% 100%;
-  margin-top:16px;
+  margin-top: 14px;
   transition: all .2s linear;
-}
-.icon:hover{
-  cursor: pointer;
-  transition-duration:0.2s;
-  transform:scale(1.1);
-  transition-timing-function:ease-in-out;
+  .el-menu-item:hover & {
+    cursor: pointer;
+    transition-duration:0.2s;
+    transform:scale(1.1);
+    transition-timing-function:ease-in-out;
+  }
 }
 .icon[target=schedule]{
   background-image: url("../assets/icon/navigation.svg");
-}
-.icon[target=schedule]:hover{
-  background-image: url("../assets/icon/navigation-hover.svg");
-}
-.icon[target=schedule].selected{
-  background-image: url("../assets/icon/navigation-using.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/navigation-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/navigation-using.svg");
+  }
 }
 .icon[target=taskList]{
   background-image: url("../assets/icon/task.svg");
-}
-.icon[target=taskList]:hover{
-  background-image: url("../assets/icon/task-hover.svg");
-}
-.icon[target=taskList].selected{
-  background-image: url("../assets/icon/task-using.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/task-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/task-using.svg");
+  }
 }
 .icon[target=bugList]{
   background-image: url("../assets/icon/bug.svg");
-}
-.icon[target=bugList]:hover{
-  background-image: url("../assets/icon/bug-hover.svg");
-}
-.icon[target=bugList].selected{
-  background-image: url("../assets/icon/bug-using.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/bug-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/bug-using.svg");
+  }
 }
 .icon[target=statistics]{
   background-image: url("../assets/icon/training.svg");
-}
-.icon[target=statistics]:hover{
-  background-image: url("../assets/icon/training-hover.svg");
-}
-.icon[target=statistics].selected{
-  background-image: url("../assets/icon/training-using.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/training-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/training-using.svg");
+  }
 }
 .icon[target=pipeline]{
   background-image: url("../assets/icon/training.svg");
-}
-.icon[target=pipeline]:hover{
-  background-image: url("../assets/icon/training-hover.svg");
-}
-.icon[target=pipeline].selected{
-  background-image: url("../assets/icon/training-using.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/training-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/training-using.svg");
+  }
 }
 .icon[target=apitest]{
-  background-image: url("../assets/icon/training.svg");
-}
-.icon[target=apitest]:hover{
-  background-image: url("../assets/icon/training-hover.svg");
-}
-.icon[target=apitest].selected{
-  background-image: url("../assets/icon/training-using.svg");
+   background-image: url("../assets/icon/training.svg");
+  .el-menu-item:hover & {
+    background-image: url("../assets/icon/training-hover.svg");
+  }
+  .el-menu-item.is-active:hover & {
+    background-image: url("../assets/icon/training-using.svg");
+  }
 }
 </style>

+ 265 - 0
src/components/Tree.vue

@@ -0,0 +1,265 @@
+<template>
+<div id="tree-container" :style="`height:${HEIGHT}px`">
+  <div id="node-container" :style="`height:${HEIGHT}px; width:${WIDTH}px`">
+    <TreeNode
+      v-for="requirement in data"
+      :key="requirement.id"
+      v-bind="requirement"
+      :ref="`treenode-${requirement.id}`"
+      @addChild="addChild"
+      @deleteNode="deleteNode"
+      @nodeClick="nodeClick"
+    />
+  </div>
+  <canvas id="line-drawer" :height="HEIGHT" :width="WIDTH"></canvas>
+</div>
+</template>
+
+<script>
+import TreeNode from './TreeNode.vue';
+
+// 容器宽高
+const HEIGHT = 600;
+const WIDTH = 1100;
+
+export default {
+  name: 'Tree',
+  components: { TreeNode },
+  props: {
+    data: Array,
+  },
+  data() {
+    return {
+      HEIGHT,
+      WIDTH,
+      parent2children: {},
+      depth2node: {},
+      layout: {},
+      ctx: null,
+    };
+  },
+  mounted() {
+    this.initCanvasCtx();
+    this.regenerateTree();
+  },
+  methods: {
+    initCanvasCtx() {
+      this.ctx = document.getElementById('line-drawer').getContext('2d');
+
+      this.ctx.lineWidth = 1;
+      this.ctx.strokeStyle = '#bbbbbb';
+    },
+    regenerateTree() {
+      this.initParent2Children();
+      this.initDepth2Node();
+      this.calculateLayout();
+      this.locateAndDraw();
+    },
+    initParent2Children() {
+      this.parent2children = {};
+      for (let i = 0; i < this.data.length; i += 1) {
+        const parentid = this.data[i].parent;
+        if (!Object.prototype.hasOwnProperty.call(this.parent2children, parentid)) {
+          this.parent2children[parentid] = [];
+        }
+        this.parent2children[parentid].push(this.data[i]);
+      }
+    },
+    initDepth2Node() {
+      this.depth2node = {};
+      let depth = 1;
+      let parentList = [null];
+      for (;depth < 5; depth += 1) {
+        this.depth2node[depth] = [];
+        for (let i = 0; i < this.data.length; i += 1) {
+          if (parentList.includes(this.data[i].parent)) {
+            this.depth2node[depth].push(this.data[i]);
+          }
+        }
+        parentList = this.depth2node[depth].map((node) => node.id);
+      }
+    },
+    calculateLayout() {
+      // clear the layout
+      this.layout = {};
+      const MARGIN_TOP = 40;
+      const MARGIN_LEFT = 100;
+      const COL_WIDTH = (WIDTH - MARGIN_LEFT) / 4;
+
+      for (let depth = 4; depth > 0; depth -= 1) {
+        const offsetLeft = MARGIN_LEFT + (COL_WIDTH * (depth * 2 - 1)) / 2;
+        let offsetTop = MARGIN_TOP;
+        for (let i = 0; i < this.depth2node[depth].length; i += 1) {
+          const treenode = this.$refs[`treenode-${this.depth2node[depth][i].id}`];
+          const { width, height } = treenode.getTreeNodeSize();
+          const offsetX = offsetLeft - width / 2;
+          let offsetY;
+          if (depth === 4) {
+            offsetY = offsetTop;
+            offsetTop += height + MARGIN_TOP;
+          } else if (Object.prototype.hasOwnProperty.call(this.parent2children, this.depth2node[depth][i].id)) {
+            const childrenList = this.parent2children[this.depth2node[depth][i].id].map((node) => node.id);
+            offsetY = childrenList.reduce(
+              (sum, curr) => sum + this.layout[curr].y + this.layout[curr].height / 2,
+              0,
+            ) / childrenList.length - height / 2;
+          } else {
+            offsetY = offsetTop;
+          }
+          offsetTop = offsetY + height + MARGIN_TOP;
+          this.layout[this.depth2node[depth][i].id] = {
+            x: offsetX,
+            y: offsetY,
+            width,
+            height,
+          };
+        }
+      }
+    },
+    locateAndDraw() {
+      // 定位TreeNodeContainer
+      // eslint-disable-next-line no-restricted-syntax
+      for (const id of Object.keys(this.layout)) {
+        const { x, y } = this.layout[id];
+        this.$refs[`treenode-${id}`].setTreeNodeOffset(x, y);
+      }
+      // clear the canvas
+      this.ctx.clearRect(0, 0, WIDTH, HEIGHT);
+
+      // 在canvas上绘制节点间连线
+      const kvs = Object.entries(this.parent2children);
+      // eslint-disable-next-line no-restricted-syntax
+      for (const [parent, children] of kvs) {
+        if (parent === 'null') {
+          // eslint-disable-next-line no-continue
+          continue;
+        }
+        const {
+          x: startX, y: startY, width: startW, height: startH,
+        } = this.layout[parent];
+        const start = {
+          x: startX + startW,
+          y: startY + startH / 2,
+        };
+        // eslint-disable-next-line no-restricted-syntax
+        for (const child of children) {
+          const {
+            x: endX, y: endY, height: endH,
+          } = this.layout[child.id];
+          const end = {
+            x: endX,
+            y: endY + endH / 2,
+          };
+          this.ctx.beginPath();
+          this.ctx.moveTo(start.x, start.y);
+          this.ctx.lineTo(end.x, end.y);
+          this.ctx.stroke();
+        }
+      }
+    },
+    addChild(id) {
+      this.$emit('change', {
+        type: 'addChild',
+        id,
+      });
+    },
+    deleteNode(id) {
+      this.$emit('change', {
+        type: 'deleteNode',
+        id,
+      });
+    },
+    nodeClick(id) {
+      this.$emit('node-click', id);
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+#tree-container {
+  display: flex;
+  justify-content:center;
+}
+#node-container {
+  position:absolute;
+  z-index: 11;
+}
+#line-drawer {
+  position: absolute;
+}
+.jsmind-inner {
+  position:relative;
+  overflow:auto;
+  width:100%;
+  height:100%;
+  user-select:none;
+}
+
+canvas {
+  position:absolute;
+  z-index:1;
+}
+
+jmnodes {
+  position:absolute;
+  z-index:2;
+  background-color:rgba(0,0,0,0);
+}
+jmnode {
+  position:absolute;
+  cursor:default;
+  max-width:400px;
+  white-space:nowrap;
+  overflow:hidden;
+  text-overflow:ellipsis;
+
+  padding:10px;
+  background-color:#fff;
+  color:#333;
+  border-radius:1px;
+  border: 1px solid #333;
+  font:16px/1.125 Verdana,Arial,Helvetica,sans-serif;
+
+  &:hover {
+    box-shadow:2px 2px 4px #333;
+    background-color:#ebebeb;
+    color:#333;
+  }
+
+  &.selected {
+    background-color:#11f;
+    color:#fff;
+    box-shadow:2px 2px 8px #000;
+  }
+
+  &.root {
+    font-size:24px;
+  }
+}
+jmexpander {
+  position:absolute;
+  width:11px;
+  height:11px;
+  display:block;
+  overflow:hidden;
+  line-height:12px;
+  font-size:12px;
+  text-align:center;
+  border-radius:6px;
+  border-width:1px;
+  border-style:solid;
+  cursor:pointer;
+
+  border-color:gray;
+
+  &:hover {
+    border-color:#000;
+  }
+}
+
+@media screen and (max-device-width: 1024px) {
+    jmnode{padding:5px;border-radius:3px;font-size:14px;}
+    jmnode.root{font-size:21px;}
+}
+</style>

+ 101 - 0
src/components/TreeNode.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="tree-node-container" ref="node">
+    <div class="tree-node" @click="nodeClick(id)">
+      <div class="node-state">{{state}}</div>
+      <div class="node-title">{{title}}</div>
+    </div>
+    <div class="node-postfix">
+      <div class="icon" target="delete" @click="deleteNode(id)"></div>
+      <div class="icon" target="add" @click="addChild(id)"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'TreeNode',
+  props: {
+    id: Number,
+    state: String,
+    title: String,
+  },
+  methods: {
+    setTreeNodeOffsetX(x) {
+      this.$refs.node.style.left = `${x}px`;
+    },
+    setTreeNodeOffsetY(y) {
+      this.$refs.node.style.top = `${y}px`;
+    },
+    setTreeNodeOffset(x, y) {
+      this.$refs.node.style.left = `${x}px`;
+      this.$refs.node.style.top = `${y}px`;
+    },
+    getTreeNodeSize() {
+      return {
+        height: this.$refs.node.offsetHeight,
+        width: this.$refs.node.offsetWidth,
+      };
+    },
+    addChild(id) {
+      this.$emit('add-child', id);
+    },
+    deleteNode(id) {
+      this.$emit('delete-node', id);
+    },
+    nodeClick(id) {
+      this.$emit('node-click', id);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.tree-node-container {
+  position: absolute;
+  height: 140px;
+  width: 20%;
+
+  user-select: none;
+  & .node-postfix {
+    position: absolute;
+    top: 120px;
+    width: 100%;
+    height: 20px;
+    display: none;
+  }
+
+  &:hover .node-postfix {
+    display: block;
+  }
+}
+.tree-node {
+  box-sizing: border-box;
+  border-radius: 2px;
+  border: 1px solid #3f3f3f;
+  height: 120px;
+  width: 100%;
+}
+.icon {
+  width: 14px;
+  height: 14px;
+  float: right;
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 100% 100%;
+  margin-top: 4px;
+  margin-right: 10px;
+  transition: all .2s linear;
+}
+.icon:hover{
+  cursor: pointer;
+  transition-duration: 0.2s;
+  transform: scale(1.1);
+  transition-timing-function:ease-in-out;
+}
+.icon[target=add]{
+  background-image: url("../assets/icon/add.svg");
+}
+.icon[target=delete]{
+  background-image: url("../assets/icon/delete.svg");
+}
+</style>

+ 19 - 0
src/composable/useGroup.ts

@@ -0,0 +1,19 @@
+import { computed } from 'vue';
+import { useStore } from '@/store';
+import { WorkspaceDispatcher } from '@/store/dispatchers';
+
+export const useGroupList = () => {
+  const store = useStore();
+
+  const groupList = computed(() => store.state.workspace.groupList);
+  const selectedGroup = computed(() => store.getters.currentGroupInfo);
+
+  const handleGroupChange = (groupId: number) => {
+    WorkspaceDispatcher.changeGroup(groupId);
+  };
+  return {
+    groupList,
+    selectedGroup,
+    handleGroupChange,
+  };
+};

+ 11 - 12
src/composable/useProject.ts

@@ -1,24 +1,23 @@
-import { computed, reactive } from 'vue';
+import { computed } from 'vue';
+import { useStore } from '@/store';
+import { WorkspaceDispatcher } from '@/store/dispatchers';
 import { useRouter } from 'vue-router';
-import { useStore } from 'vuex';
 
-export const useProject = () => {
+export const useProjectList = () => {
   const store = useStore();
   const router = useRouter();
 
-  const projectList = computed(() => store.state.projectList);
-  let selectedProject = reactive({});
+  const projectList = computed(() => store.state.workspace.projectList);
+  const projectListOfCurrentGroup = computed(() => store.getters.projectListOfCurrentGroup);
+  const selectedProject = computed(() => store.getters.currentProjectInfo);
 
-  const handleProjectChange = (id: number) => {
-    projectList.value.forEach((item: Project) => {
-      if (item.id === id) {
-        selectedProject = item;
-        router.push(`/project/${id}/schedule`);
-      }
-    });
+  const handleProjectChange = (projectId: number) => {
+    WorkspaceDispatcher.changeProject(projectId);
+    router.push(`/project/${projectId}/schedule`);
   };
   return {
     projectList,
+    projectListOfCurrentGroup,
     selectedProject,
     handleProjectChange,
   };

+ 23 - 0
src/composable/useUser.ts

@@ -0,0 +1,23 @@
+import { computed } from 'vue';
+import { useStore } from '@/store';
+import { UserDispatcher, TokenDispatcher } from '@/store/dispatchers';
+import { useRouter } from 'vue-router';
+
+export const useUser = () => {
+  const store = useStore();
+  const router = useRouter();
+
+  const userInfo = computed(() => store.state.user);
+  const userLoggedIn = computed(() => store.state.user.role !== 'GUEST');
+
+  const userLogOut = () => {
+    UserDispatcher.userLogOut();
+    TokenDispatcher.destroyToken();
+    router.push('/');
+  };
+  return {
+    userInfo,
+    userLoggedIn,
+    userLogOut,
+  };
+};

+ 27 - 0
src/element-plus.ts

@@ -1,14 +1,25 @@
 import {
+  ElAside,
   ElCard,
   ElCarousel,
   ElCarouselItem,
   ElContainer,
   ElDivider,
   ElHeader,
+  ElLink,
   ElMain,
+  ElMenu,
+  ElDialog,
+  ElIcon,
+  ElInput,
+  ElSubmenu,
+  ElMenuItem,
+  ElForm,
+  ElFormItem,
   ElRow,
   ElTable,
   ElTableColumn,
+  ElTag,
   ElPagination,
   ElButton,
   ElSelect,
@@ -17,21 +28,37 @@ import {
   ElTabPane,
 } from 'element-plus';
 
+import ZH_CN from 'element-plus/lib/locale/lang/zh-cn';
+import locale from 'element-plus/lib/locale';
+
+locale.use(ZH_CN);
+
 export const Components = [
+  ElAside,
   ElRow,
   ElContainer,
   ElHeader,
   ElMain,
+  ElMenu,
+  ElDialog,
+  ElLink,
+  ElMenuItem,
   ElCarousel,
+  ElForm,
+  ElFormItem,
+  ElIcon,
+  ElInput,
   ElCarouselItem,
   ElDivider,
   ElCard,
   ElTable,
   ElTableColumn,
+  ElTag,
   ElPagination,
   ElButton,
   ElSelect,
   ElOption,
+  ElSubmenu,
   ElTabs,
   ElTabPane,
 ];

+ 6 - 7
src/main.ts

@@ -1,14 +1,13 @@
 import { createApp } from 'vue';
-import axios from '@/utils/axios';
 import { Components, Plugins } from '@/element-plus';
-import 'element-plus/lib/theme-chalk/index.css';
-import App from './App.vue';
-import router from './router';
-import store from './store';
+import { store, key } from '@/store';
+import App from '@/App.vue';
+import router from '@/router';
 
 const app = createApp(App);
-app.use(router).use(store);
-app.config.globalProperties.axios = axios;
+
+app.use(router);
+app.use(store, key);
 
 Components.forEach((component) => {
   app.component(component.name, component);

+ 28 - 35
src/router/index.ts

@@ -1,4 +1,4 @@
-import { isUserLoggedIn } from '@/utils/userStatus';
+import { isUserLoggedIn } from '@/utils/user';
 import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
 import { HOME, LOGIN, PROJECT } from './name';
 
@@ -16,11 +16,11 @@ const routes: Array<RouteRecordRaw> = [
     path: '/',
     redirect: '/home',
   },
-  AuthWrapper({
+  {
     path: '/home',
     name: HOME,
     component: () => import('@/views/Home.vue'),
-  }),
+  },
   {
     path: '/login',
     name: LOGIN,
@@ -36,27 +36,27 @@ const routes: Array<RouteRecordRaw> = [
     children: [
       {
         path: 'schedule',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/Schedule.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/Schedule.vue'),
       },
       {
         path: 'taskList',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/TaskList.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/TaskList.vue'),
       },
       {
         path: 'bugList',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/BugList.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/BugList.vue'),
       },
       {
         path: 'statistics',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/Statistics.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/Statistics.vue'),
       },
       {
         path: 'pipeline',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/Pipeline.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/Pipeline.vue'),
       },
       {
         path: 'apitest',
-        component: import(/* webpackChunkName: "project" */'@/views/Project/APITest.vue'),
+        component: () => import(/* webpackChunkName: "project" */'@/views/Project/APITest.vue'),
       },
     ],
   }),
@@ -67,41 +67,34 @@ const router = createRouter({
   routes,
 });
 
-router.beforeEach((to, from, next) => {
-  if (to.matched.some((record) => record.meta.requiresAuth)) {
-    // this route requires auth, check if logged in
-    // if not, redirect to login page.
-    if (!isUserLoggedIn()) {
-      next({
-        name: LOGIN,
-        query: { redirect: to.fullPath },
-      });
-    } else {
-      next();
-    }
-  } else {
-    next();
-  }
-});
+// router.beforeEach((to, from, next) => {
+//   if (to.matched.some((record) => record.meta.requiresAuth)) {
+//     // this route requires auth, check if logged in
+//     // if not, redirect to login page.
+//     if (!isUserLoggedIn()) {
+//       next({
+//         name: LOGIN,
+//         query: { redirect: to.fullPath },
+//       });
+//     } else {
+//       next();
+//     }
+//   } else {
+//     next();
+//   }
+// });
 
 router.beforeEach((to, from, next) => {
-  if (to.name === LOGIN) {
-    if (isUserLoggedIn()) {
-      // eslint-disable-next-line no-alert
-      alert('you\'ve logged in');
-    } else {
-      next();
-    }
-  } else {
+  if (to.name !== LOGIN || !isUserLoggedIn()) {
     next();
   }
 });
 
 router.afterEach((to, from) => {
   if (to.matched.some((record) => record.meta.hideNavigator)) {
-    document.getElementById('menu')!.style.display = 'none';
+    document.getElementsByTagName('header')[0]!.style.display = 'none';
   } else {
-    document.getElementById('menu')!.style.display = 'flex';
+    document.getElementsByTagName('header')[0]!.style.display = 'flex';
   }
 });
 

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

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

+ 11 - 0
src/store/dispatchers/token.ts

@@ -0,0 +1,11 @@
+import { store } from '../index';
+
+export const updateToken = (token: string) => {
+  store.dispatch('updateToken', {
+    token,
+  });
+};
+
+export const destroyToken = () => {
+  store.dispatch('destoryToken');
+};

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

@@ -0,0 +1,9 @@
+import { store } from '../index';
+
+export const updateUserInfo = () => {
+  store.dispatch('updateUserInfo');
+};
+
+export const userLogOut = () => {
+  store.dispatch('userLogOut');
+};

+ 15 - 0
src/store/dispatchers/workspace.ts

@@ -0,0 +1,15 @@
+import { store } from '../index';
+
+export const refreshProjectList = () => {
+  store.dispatch('refreshProjectList', store.state.user.id);
+};
+
+export const changeGroup = (groupId: number) => {
+  store.dispatch('changeGroup', { groupId });
+  store.dispatch('resetCurrentProject');
+};
+
+export const changeProject = (projectId: number) => {
+  changeGroup(store.getters.getGroupIdFromProjectId(projectId));
+  store.dispatch('changeProject', { projectId });
+};

+ 14 - 19
src/store/index.ts

@@ -1,21 +1,16 @@
-import { createStore } from 'vuex';
+import { InjectionKey } from 'vue';
+import { createStore, Store, useStore as baseUseStore } from 'vuex';
 
-export default createStore({
-  state: {
-    projectList: [{ name: '项目一', description: 'asd', id: 0 },
-      { name: '项目二', description: 'asd', id: 1 },
-      { name: '项目三', description: 'asd', id: 2 },
-      { name: '长项目名12312312312123123312', description: 'asd', id: 3 },
-      { name: '项目一', description: 'asd', id: 4 },
-      { name: '项目二', description: 'asd', id: 5 },
-      { name: '项目三', description: 'asd', id: 6 },
-      { name: '长项目名12312312312123123312', description: 'asd', id: 7 }],
-  },
-  mutations: {
-    refreshProjectList(state) {
-      state.projectList = [{ name: '项目一', description: 'asd', id: 0 },
-        { name: '项目二', description: 'asd', id: 1 },
-        { name: '项目三', description: 'asd', id: 2 }];
-    },
-  },
+import { RootState } from './typings';
+
+import modules from './modules';
+
+export const key: InjectionKey<Store<RootState>> = Symbol('store');
+
+export const store = createStore<RootState>({
+  modules,
 });
+
+export function useStore() {
+  return baseUseStore(key);
+}

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

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

+ 34 - 0
src/store/modules/token.ts

@@ -0,0 +1,34 @@
+import { saveToken, destoryToken } from '@/utils/token';
+import { useRouter } from 'vue-router';
+import { ActionTree, Module, MutationTree } from 'vuex';
+import { RootState, TokenPayload, TokenState } from '../typings';
+
+const DEFAULT: TokenState = {
+  token: localStorage.getItem('token') || '',
+};
+
+const actions: ActionTree<TokenState, RootState> = {
+  updateToken({ commit }, payload: TokenPayload) {
+    commit('updateToken', payload);
+    saveToken(payload.token);
+  },
+  destoryToken({ commit }) {
+    commit('updateToken', {
+      token: '',
+    } as TokenPayload);
+    destoryToken();
+    useRouter().push('/home');
+  },
+};
+
+const mutations: MutationTree<TokenState> = {
+  updateToken(state, { token }: TokenPayload) {
+    state.token = token;
+  },
+};
+
+export const token: Module<TokenState, RootState> = {
+  state: () => DEFAULT,
+  mutations,
+  actions,
+};

+ 41 - 0
src/store/modules/user.ts

@@ -0,0 +1,41 @@
+import { UserAPI } from '@/api';
+import { ActionTree, Module, MutationTree } from 'vuex';
+import { RootState, UserState } from '../typings';
+
+const DEFAULT: UserState = {
+  username: 'GUEST',
+  email: '',
+  id: -1,
+  phone: '',
+  role: 'GUEST',
+};
+
+const actions: ActionTree<UserState, RootState> = {
+  async updateUserInfo({ commit }) {
+    const res = (await UserAPI.getUserInfo()) as unknown as {
+      user_info: UserState;
+    };
+    commit('updateUserInfo', res.user_info);
+  },
+  userLogOut({ commit }) {
+    commit('updateUserInfo', DEFAULT);
+  },
+};
+
+const mutations: MutationTree<UserState> = {
+  updateUserInfo(state, {
+    name, email, id, phone, role,
+  }) {
+    state.username = name;
+    state.email = email;
+    state.id = id;
+    state.phone = phone;
+    state.role = role;
+  },
+};
+
+export const user: Module<UserState, RootState> = {
+  state: () => DEFAULT,
+  actions,
+  mutations,
+};

+ 96 - 0
src/store/modules/workspace.ts

@@ -0,0 +1,96 @@
+import { ProjectAPI } from '@/api';
+import {
+  ActionTree, GetterTree, Module, MutationTree,
+} from 'vuex';
+import {
+  WorkspaceState, RootState, ChangeGroupPayload, ChangeProjectPayload,
+} from '../typings';
+
+const DEFAULT: WorkspaceState = {
+  currentProjectId: -1,
+  currentGroupId: -1,
+  groupList: [
+    {
+      id: 101,
+      name: 'Group-1',
+    },
+    {
+      id: 102,
+      name: 'Group-2',
+    },
+    {
+      id: 13,
+      name: 'Group-3',
+    },
+    {
+      id: 104,
+      name: 'Group-4',
+    },
+    {
+      id: 105,
+      name: 'Group-5',
+    },
+  ],
+  projectList: [],
+};
+
+const actions: ActionTree<WorkspaceState, RootState> = {
+  async refreshProjectList({ commit }, userId) {
+    const projectList = await ProjectAPI.getProjectListByUserId(userId);
+    commit('updateProjectList', projectList);
+  },
+  changeGroup({ commit }, payload: ChangeGroupPayload) {
+    commit('changeGroup', payload);
+  },
+  changeProject({ commit }, payload: ChangeProjectPayload) {
+    commit('changeProject', payload);
+  },
+  resetCurrentProject({ commit }) {
+    commit('changeProject', -1);
+  },
+};
+
+const mutations: MutationTree<WorkspaceState> = {
+  updateProjectList(state, payload: Array<Project>) {
+    state.projectList = payload;
+  },
+  changeGroup(state, { groupId }: ChangeGroupPayload) {
+    state.currentGroupId = groupId;
+  },
+  changeProject(state, { projectId }: ChangeProjectPayload) {
+    state.currentProjectId = projectId;
+  },
+};
+
+const getters: GetterTree<WorkspaceState, RootState> = {
+  projectListOfCurrentGroup(state) {
+    return state.projectList.filter((project) => project.groupId === state.currentGroupId);
+  },
+  getGroupIdFromProjectId(state) {
+    return (projectId: number) => {
+      const p = Array.prototype.find.call(state.projectList, (project) => project.id === projectId) as Project;
+      return p.groupId;
+    };
+  },
+  currentProjectInfo(state) {
+    return Array.prototype.find.call(state.projectList, (project) => project.id === state.currentProjectId)
+      || {
+        id: -1,
+        name: '未选择项目',
+      };
+  },
+  currentGroupInfo(state) {
+    return Array.prototype.find.call(state.groupList, (group) => group.id === state.currentGroupId)
+      || {
+        id: -1,
+        name: '未选择小组',
+      };
+  },
+};
+
+export const workspace: Module<WorkspaceState, RootState> = {
+  state: () => DEFAULT,
+  actions,
+  mutations,
+  getters,
+};

+ 40 - 0
src/store/typings.ts

@@ -0,0 +1,40 @@
+type GroupId = number;
+type ProjectId = number;
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface RootState {
+  token: TokenState;
+  user: UserState;
+  workspace: WorkspaceState;
+}
+
+export interface TokenState {
+  token: string;
+}
+
+export interface UserState {
+  email: string;
+  id: number;
+  phone: string;
+  role: Role;
+  username: string;
+}
+
+export interface WorkspaceState {
+  currentGroupId: GroupId;
+  currentProjectId: ProjectId;
+  groupList: Array<Group>;
+  projectList: Array<Project>;
+}
+
+export interface ChangeGroupPayload {
+  groupId: GroupId;
+}
+
+export interface ChangeProjectPayload {
+  projectId: ProjectId;
+}
+
+export interface TokenPayload {
+  token: string;
+}

+ 30 - 0
src/typings/project.d.ts

@@ -2,4 +2,34 @@ declare interface Project {
   id: number;
   name: string;
   description: string;
+  groupId: number;
+  gitRemoteUrl: string;
 }
+
+declare interface Group {
+  id: number;
+  name: string;
+}
+
+declare type TreeNodeType = '0:需求' | '1:任务';
+declare type TreeNodeState = '0:已创建' | '1:进行中' | '2:已结束';
+
+declare interface TreeNode {
+  deep: number;
+  description: string;
+  endTime: string;
+  groupId: number;
+  id: number;
+  leaf: boolean;
+  parentId: number;
+  priority: number;
+  projectId: number;
+  startTime: string;
+  state: TreeNodeState;
+  timeToTake: number;
+  title: string;
+  type: TreeNodeType;
+  userId: number;
+}
+
+declare type Role = 'TEACHER' | 'STUDENT' | 'ADMIN' | 'GUEST';

+ 0 - 0
src/shims-vue.d.ts → src/typings/shims-vue.d.ts


+ 29 - 0
src/typings/vuex.d.ts

@@ -0,0 +1,29 @@
+import { ComponentCustomProperties } from 'vue';
+import { Store } from 'vuex';
+
+declare module '@vue/runtime-core' {
+  // declare your own store states
+  interface State {
+    token: {
+      token: string;
+    };
+    user: {
+      email: string;
+      id: number;
+      phone: string;
+      role: Role;
+      username: string;
+    };
+    workspace: {
+      currentGroupId: GroupId;
+      currentProjectId: ProjectId;
+      groupList: Array<Group>;
+      projectList: Array<Project>;
+    };
+  }
+
+  // provide typings for `this.$store`
+  interface ComponentCustomProperties {
+    $store: Store<State>;
+  }
+}

+ 15 - 18
src/utils/axios.ts

@@ -1,24 +1,16 @@
 /* eslint-disable */
 import axios from 'axios';
+import { TokenDispatcher } from '@/store/dispatchers';
+
+const { destroyToken } = TokenDispatcher;
 
 // Full config:  https://github.com/axios/axios#request-config
 // axios.defaults.baseURL = process.env.baseURL || process.env.apiUrl || '';
 // axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
 // axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
 
-const getBaseUrl = () => {
-  switch (process.env.NODE_ENV) {
-    case 'development':
-      return 'http://localhost:4000/api';
-    case 'production':
-      return process.env.baseURL || process.env.apiUrl || '';
-    default:
-      return process.env.baseURL || process.env.apiUrl || '';
-  }
-};
-
 const config = {
-  baseURL: getBaseUrl(),
+  baseURL: process.env.VUE_APP_API_URL,
   // timeout: 60 * 1000, // Timeout
   // withCredentials: true, // Check cross-site Access-Control
 };
@@ -26,9 +18,10 @@ const config = {
 const _axios = axios.create(config);
 
 _axios.interceptors.request.use(
-  (config) =>
-    // Do something before request is sent
-    config,
+  (config) => {
+    config.headers.Authorization = localStorage.getItem('token');
+    return config;
+  },
   (error) =>
     // Do something with request error
     Promise.reject(error),
@@ -36,9 +29,13 @@ _axios.interceptors.request.use(
 
 // Add a response interceptor
 _axios.interceptors.response.use(
-  (response) =>
-    // Do something with response data
-    response.data.message,
+  (response) => {
+    if (response.data.code === 10110) {
+      destroyToken();
+      return;
+    }
+    return response.data.data;
+  },
   (error) =>
     // Do something with response error
     Promise.reject(error),

+ 15 - 0
src/utils/debounce.ts

@@ -0,0 +1,15 @@
+/* eslint-disable */
+const debounce = (fn: Function, delay: number) => {
+  let timer: NodeJS.Timeout;
+  return function () {
+    // @ts-ignore
+    const context = this;
+    const args = arguments;
+    if (timer) {
+      clearTimeout(timer);
+    }
+    timer = setTimeout(() => fn.apply(context, args), delay);
+  };
+};
+
+export default debounce;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
src/utils/jsonlint.js


+ 10 - 0
src/utils/qs.ts

@@ -0,0 +1,10 @@
+export const getVariableFromQueryString = (name: string) => {
+  const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`, 'i');
+
+  const res = window.location.search.substr(1).match(reg);
+
+  if (res != null) {
+    return unescape(res[2]);
+  }
+  return null;
+};

+ 11 - 0
src/utils/token.ts

@@ -0,0 +1,11 @@
+export const saveToken = (token: string) => {
+  localStorage.setItem('token', token);
+  localStorage.setItem('user', JSON.stringify({
+    login: true,
+  }));
+};
+
+export const destoryToken = () => {
+  localStorage.removeItem('token');
+  localStorage.removeItem('user');
+};

+ 4 - 0
src/utils/user.ts

@@ -0,0 +1,4 @@
+export const isUserLoggedIn = () => {
+  const user = JSON.parse(localStorage.getItem('user') || '{}');
+  return user.login || false;
+};

+ 0 - 4
src/utils/userStatus.ts

@@ -1,4 +0,0 @@
-export const isUserLoggedIn = () => {
-  const userStatus = JSON.parse(localStorage.getItem('user') || '{}');
-  return userStatus.login;
-};

+ 11 - 8
src/views/Home.vue

@@ -2,39 +2,42 @@
   <div class="home">
     <div class="poster">
     </div>
-    <div style="font-weight: bolder;font-size: 32px;padding: 30px">Hello,username</div>
+    <div style="font-weight: bolder;font-size: 32px;padding: 30px">Hello,{{username}}</div>
     <div class="project-list">
       <ProjectCard
         v-for="project in projectList"
         :key="project.id"
-        :id="project.id"
-        :name="project.name"
-        :description="project.description"
+        v-bind="project"
+        @click="handleProjectChange(project.id)"
       ></ProjectCard>
     </div>
   </div>
 </template>
 
 <script lang="ts">
-import { computed, defineComponent } from 'vue';
+import { defineComponent, computed } from 'vue';
 import ProjectCard from '@/components/ProjectCard.vue';
-import { useStore } from 'vuex';
+import { useProjectList } from '@/composable/useProject';
+import { useStore } from '@/store';
 
 export default defineComponent({
   components: {
     ProjectCard,
   },
   setup() {
+    const { projectList, handleProjectChange } = useProjectList();
     const store = useStore();
     return {
-      projectList: computed(() => store.state.projectList),
+      projectList,
+      username: computed(() => store.state.user.username),
+      handleProjectChange,
     };
   },
 });
 </script>
 <style scoped>
 .home{
-  margin-top: 58px ;
+  width: 100%;
 }
 .project-list {
   display:flex;

+ 2 - 2
src/views/Login.vue

@@ -11,8 +11,8 @@ import { defineComponent } from 'vue';
 export default defineComponent({
   methods: {
     async login() {
-      window.location = `http://p.seecoder.cn/api/interaction?scope=openid&client_id=seec-devcloud&redirect_uri=${encodeURI(
-        `http://${window.location.host}/api/auth/receive`,
+      window.location = `${process.env.VUE_APP_PORTAL_HOST}/api/interaction?scope=openid&client_id=seec-seec&redirect_uri=${encodeURI(
+        `http://${window.location.host}/`,
       )}` as unknown as Location;
     },
   },

+ 390 - 4
src/views/Project/Pipeline.vue

@@ -1,13 +1,399 @@
 <template>
-  project-pipeline
+  <ElTabs class="tabs" v-model="activeTab">
+    <ElTabPane label="流水线" name="pipeline">
+      <ElTable :data="pipelines" width="100%">
+        <ElTableColumn
+          prop="id"
+          label="ID"
+          align="center"
+          width="60"
+        ></ElTableColumn>
+        <ElTableColumn
+          prop="name"
+          label="名称"
+          align="center"
+          width="180"
+        ></ElTableColumn>
+        <ElTableColumn
+          prop="username"
+          label="最近执行人"
+          align="center"
+          width="180"
+        ></ElTableColumn>
+        <ElTableColumn
+          label="最近执行时间"
+          align="center"
+          :formatter="(row) => timeFormatter(row, 'startTime')"
+        ></ElTableColumn>
+        <ElTableColumn
+          prop="result"
+          label="执行状态"
+          align="center"
+          width="100"
+          :filters="[{ text: '成功', value: '成功' }, { text: '失败', value: '失败' }]"
+          :filter-method="(value, row) => {
+            return row.result === value;
+          }"
+          filter-placement="bottom-end"
+        >
+          <template #default="scope">
+            <template v-if="scope.row.result === '成功'">
+              <ElTag type="success">{{scope.row.result}}</ElTag>
+            </template>
+            <template v-else-if="scope.row.result === '失败'">
+              <ElTag type="danger">{{scope.row.result}}</ElTag>
+            </template>
+            <template v-else-if="scope.row.result === '执行中'">
+              <ElTag>{{scope.row.result}}<i class="el-icon-loading"></i></ElTag>
+            </template>
+            <template v-else>
+              <ElTag type="warning">未知状态</ElTag>
+            </template>
+          </template>
+        </ElTableColumn>
+        <ElTableColumn
+          label="详情"
+          align="center"
+        >
+          <template #default="scope">
+            <ElButton
+              @click="handleClick('detail', scope.row.id)"
+              type="text"
+              size="small"
+              :disabled="scope.row.result==='执行中'"
+            >最近执行详情</ElButton>
+            <ElButton @click="handleClick('history', scope.row.id)" type="text" size="small">历史</ElButton>
+          </template>
+        </ElTableColumn>
+        <ElTableColumn
+          align="center"
+        >
+          <template #header>
+            <span>操作</span>
+            <ElButton @click="handleClick('create')" type="text" class="add-button">新建</ElButton>
+          </template>
+          <template #default="scope">
+            <ElButton @click="handleClick('trigger', scope.row.id)" type="text" size="small">触发</ElButton>
+            <ElButton @click="handleClick('edit', scope.row.id)" type="text" size="small">编辑</ElButton>
+            <ElButton @click="handleClick('delete', scope.row.id)" type="text" size="small">删除</ElButton>
+          </template>
+        </ElTableColumn>
+      </ElTable>
+      <ElDialog v-model="showPipelineDetailModal" destroy-on-close>
+        <template #title>最近一次流水线构建的执行结果详情</template>
+        <pre class="pipeline-detail">{{detail}}</pre>
+      </ElDialog>
+      <ElDialog v-model="showPipelineHistoryModal" destroy-on-close>
+        <template #title>流水线构建历史</template>
+        <ElTable
+          :data="pipelineHistory"
+          :default-sort="{
+            props: 'id',
+          }"
+          height="400"
+        >
+          <ElTableColumn type="expand">
+            <template #default="scope">
+              <pre class="pipeline-detail">{{scope.row.details}}</pre>
+            </template>
+          </ElTableColumn>
+          <ElTableColumn
+            prop="id"
+            label="构建ID"
+            align="center"
+            width="100"
+          ></ElTableColumn>
+          <ElTableColumn
+            prop="pipelineId"
+            label="流水线ID"
+            align="center"
+            width="100"
+          ></ElTableColumn>
+          <ElTableColumn
+            prop="username"
+            label="触发人"
+            align="center"
+            width="180"
+          ></ElTableColumn>
+          <ElTableColumn
+            label="执行时间"
+            align="center"
+            :formatter="(row) => timeFormatter(row, 'startTime')"
+          ></ElTableColumn>
+        </ElTable>
+      </ElDialog>
+      <ElDialog
+        v-model="showPipelineEditModal"
+        :before-close="(done) => {
+          jsonValid=true;
+          done();
+        }"
+        destroy-on-close
+      >
+        <template #title>
+          <span>编辑流水线配置</span>
+          <span class="button-group">
+            <ElButton type="text">脚本教程</ElButton>
+          </span>
+        </template>
+        <ElInput
+          type="textarea"
+          :rows="16"
+          placeholder="请输入流水线配置"
+          @input="lintJson"
+          v-model="pipelineConfig">
+        ></ElInput>
+        <pre v-if="!jsonValid" :innerText="hint"></pre>
+        <template #footer>
+          <span class="dialog-footer">
+            <el-button type="primary" @click="handlePipelineUpdateConfirm" :disabled="!jsonValid">确 定</el-button>
+          </span>
+        </template>
+      </ElDialog>
+      <ElDialog
+        v-model="showPipelineCreateModal"
+        :before-close="(done) => {
+          $refs['createPipelineForm'].resetFields();
+          done();
+        }"
+        destroy-on-close
+      >
+        <template #title>
+          <span>创建流水线</span>
+          <span class="button-group">
+            <ElButton type="text">模板详解</ElButton>
+          </span>
+        </template>
+        <ElForm
+          :model="createPipelineForm"
+          ref="createPipelineForm"
+          label-position="top"
+          label-suffix=":"
+          class="create-pipeline-form"
+        >
+          <ElFormItem label="名称" prop="name" required>
+            <ElInput v-model="createPipelineForm.name" placeholder="请输入流水线名称"></ElInput>
+          </ElFormItem>
+          <ElFormItem label="模板" prop="template" required>
+            <ElSelect v-model="createPipelineForm.template" placeholder="请选择模板">
+              <ElOption
+                v-for="(template, index) in templates"
+                :key="index" :label="template"
+                :value="template"
+              ></ElOption>
+            </ElSelect>
+          </ElFormItem>
+        </ElForm>
+        <template #footer>
+          <span class="dialog-footer">
+            <el-button type="primary" @click="handlePipelineCreateConfirm">确 定</el-button>
+          </span>
+        </template>
+      </ElDialog>
+    </ElTabPane>
+    <ElTabPane label="部署产物" name="production">
+      <ElTable :data="productions" width="100%">
+        <ElTableColumn
+          prop="id"
+          label="ID"
+          align="center"
+          width="60"
+        ></ElTableColumn>
+        <ElTableColumn
+          prop="name"
+          label="名称"
+          align="center"
+        ></ElTableColumn>
+        <ElTableColumn
+          prop="pipelineId"
+          label="流水线ID"
+          align="center"
+          width="100"
+        ></ElTableColumn>
+        <ElTableColumn
+          label="部署时间"
+          align="center"
+          :formatter="(row) => timeFormatter(row, 'deployedTime')"
+        ></ElTableColumn>
+        <ElTableColumn
+          label="URL"
+          align="center"
+        >
+          <template #default="scope">
+              <ElButton type="text" @click="open(scope.row.accessUrl, '_blank')">
+                {{scope.row.accessUrl}}
+              </ElButton>
+          </template>
+        </ElTableColumn>
+        <ElTableColumn
+          label="运行状态"
+          align="center"
+        >
+          <template #default="scope">
+            <template v-if="scope.row.status === '正常'">
+              <ElTag type="success">{{scope.row.status}}</ElTag>
+            </template>
+            <template v-else>
+              <ElTag type="danger">{{scope.row.status}}</ElTag>
+            </template>
+          </template>
+        </ElTableColumn>
+        <ElTableColumn>
+          <template #default="scope">
+            <ElButton type="text" @click="deleteProduction(scope.row.id)">
+              <i class="el-icon-delete"></i>
+            </ElButton>
+          </template>
+        </ElTableColumn>
+      </ElTable>
+    </ElTabPane>
+  </ElTabs>
 </template>
 
 <script>
-export default {
+import jsonlint from '@/utils/jsonlint';
+
+import { PipelineAPI } from '@/api';
+import debounce from '@/utils/debounce';
 
+export default {
+  data() {
+    return {
+      activeTab: 'pipeline',
+      pipelines: [],
+      productions: [],
+      showPipelineCreateModal: false,
+      showPipelineEditModal: false,
+      showPipelineHistoryModal: false,
+      showPipelineDetailModal: false,
+      detail: '',
+      pipelineHistory: [],
+      pipelineConfig: '',
+      jsonValid: true,
+      editingPipelineId: -1,
+      templates: [],
+      createPipelineForm: {
+        name: '',
+        template: '',
+      },
+    };
+  },
+  async mounted() {
+    this.pipelines = await PipelineAPI.getPipelinesByProjectId(this.$store.state.workspace.currentProjectId);
+    this.productions = await PipelineAPI.getDeploymentsByProjectId(this.$store.state.workspace.currentProjectId);
+    this.templates = await PipelineAPI.getPipelineTemplates();
+  },
+  methods: {
+    timeFormatter: (row, property) => `${row[property].year}/${row[property].month}/${row[property].day} `
+            + `${row[property].hours}:${row[property].minutes}:${row[property].seconds}`,
+    async handleClick(action, id) {
+      switch (action) {
+        case 'create': {
+          this.showPipelineCreateModal = true;
+          break;
+        }
+        case 'trigger': {
+          await PipelineAPI.triggerDeployment({
+            projectId: this.$store.state.workspace.currentProjectId,
+            pipelineId: id,
+          });
+          this.productions = await PipelineAPI.getDeploymentsByProjectId(this.$store.state.workspace.currentProjectId);
+          break;
+        }
+        case 'detail': {
+          this.showPipelineDetailModal = true;
+          this.detail = Array.prototype.find.call(this.pipelines, (pipeline) => pipeline.id === id).details;
+          break;
+        }
+        case 'history': {
+          this.showPipelineHistoryModal = true;
+          this.pipelineHistory = await PipelineAPI.getPipelineHistory({
+            projectId: this.$store.state.workspace.currentProjectId,
+            pipelineId: id,
+          });
+          break;
+        }
+        case 'edit': {
+          this.showPipelineEditModal = true;
+          this.editingPipelineId = id;
+          this.pipelineConfig = await PipelineAPI.getPipeline({
+            projectId: this.$store.state.workspace.currentProjectId,
+            pipelineId: id,
+          }).configJson || '{}';
+          break;
+        }
+        case 'delete': {
+          PipelineAPI.deletePipeline({
+            projectId: this.$store.state.workspace.currentProjectId,
+            pipelineId: id,
+          });
+          break;
+        }
+        default: {
+          console.warn('Unknown type of action.');
+        }
+      }
+    },
+    // eslint-disable-next-line func-names
+    lintJson: debounce(function (value) {
+      this.jsonValid = true;
+      try {
+        const result = jsonlint.parse(value);
+        this.pipelineConfig = JSON.stringify(result, null, '  ');
+      } catch (e) {
+        this.jsonValid = false;
+        this.hint = `${value}\n${e}`;
+      }
+    }, 500),
+    async handlePipelineUpdateConfirm() {
+      await PipelineAPI.updatePipeline({
+        projectId: this.$store.state.workspace.currentProjectId,
+        pipelineId: this.editingPipelineId,
+        configJson: JSON.stringify(JSON.parse(this.pipelineConfig)),
+      });
+      this.showPipelineEditModal = false;
+    },
+    async handlePipelineCreateConfirm() {
+      this.$refs.createPipelineForm.validate(async (valid) => {
+        if (valid) {
+          await PipelineAPI.createPipeline({
+            projectId: this.$store.state.workspace.currentProjectId,
+            pipelineId: this.editingPipelineId,
+            name: this.createPipelineForm.name,
+            templateName: this.createPipelineForm.template,
+          });
+          this.showPipelineCreateModal = false;
+        }
+      });
+    },
+    open(url, target) {
+      window.open(url, target);
+    },
+    async deleteProduction(id) {
+      await PipelineAPI.deleteInstance(id);
+    },
+  },
 };
 </script>
 
-<style>
-
+<style scoped>
+.tabs {
+  width: 80%;
+  margin: auto auto;
+}
+.add-button {
+  margin-left: 14px;
+}
+.pipeline-detail {
+  width: 100%;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+.create-pipeline-form {
+  width: 60%;
+  margin: 0 auto;
+}
+.button-group {
+  margin-left: 40px;
+}
 </style>

+ 63 - 2
src/views/Project/Schedule.vue

@@ -1,10 +1,71 @@
 <template>
-  project-schedule
+  <Tree :data="requirements" @change="handleTreeChange" @nodeClick="handleNodeClick" ref="tree"></Tree>
+  <RightForm ref="rightForm"/>
 </template>
 
 <script>
-export default {
+import Tree from '@/components/Tree.vue';
+import RightForm from '@/components/RightForm.vue';
+
+import { RequirementAPI } from '@/api';
 
+export default {
+  name: 'Schedule',
+  components: { Tree, RightForm },
+  data() {
+    return {
+      requirements: [
+        {
+          id: 1,
+          title: 't1',
+          parent: null,
+          state: '未开始',
+        },
+        {
+          id: 2,
+          title: 't2',
+          parent: 1,
+          state: '未开始',
+        },
+        {
+          id: 3,
+          title: 't3',
+          parent: 1,
+          state: '未开始',
+        },
+        {
+          id: 4,
+          title: 't4',
+          parent: 2,
+          state: '未开始',
+        },
+        {
+          id: 5,
+          title: 't5',
+          parent: 2,
+          state: '进行中',
+        },
+        {
+          id: 6,
+          title: 't6',
+          parent: 4,
+          state: '已完成',
+        },
+      ],
+    };
+  },
+  methods: {
+    async handleTreeChange(msg) {
+      // TODO: 补充API定义
+      const { type, id } = msg;
+      await RequirementAPI[type](id);
+      // this.requirements = await RequirementAPI.getTreeNode(projectId);
+      this.$refs.tree.regenerateTree();
+    },
+    handleNodeClick(id) {
+      this.$refs.rightForm.showForm();
+    },
+  },
 };
 </script>
 

+ 11 - 13
src/views/ProjectContainer.vue

@@ -1,22 +1,22 @@
 <template>
-  <div>
-    <SideBar @change="handleTabChange"></SideBar>
-    <div class="container">
-      <router-view></router-view><!--此处路由路由4个功能页-->
-    </div>
-  </div>
+  <el-aside width="65">
+    <SideBar></SideBar>
+  </el-aside>
+  <el-main class="container">
+    <router-view></router-view>
+  </el-main>
 </template>
 
 <script>
 import SideBar from '@/components/SideBar.vue';
+import { WorkspaceDispatcher } from '@/store/dispatchers';
 
 export default {
   name: 'ProjectContainer',
   components: { SideBar },
-  methods: {
-    handleTabChange(value) {
-      this.$router.push(value);
-    },
+  mounted() {
+    const { projectId } = this.$route.params;
+    WorkspaceDispatcher.changeProject(parseInt(projectId, 10));
   },
 };
 </script>
@@ -25,8 +25,6 @@ export default {
 .container{
   background-color: #FFFFFF;
   width: calc(100% - 42px);
-  margin-top: 58px;
-  margin-left: 42px;
   min-height: 60vh;
 }
 /* 用于功能页组件最外层 */
@@ -36,7 +34,7 @@ export default {
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
-  justify-content: start;
+  justify-content: flex-start;
   align-items: center;
 }
 </style>

+ 8 - 0
vue.config.js

@@ -0,0 +1,8 @@
+module.exports = {
+  chainWebpack: (config) => {
+    config.plugin('html').tap((args) => {
+      args[0].title = `${process.env.VUE_APP_ENV_NAME}Devcloud | SEEC`;
+      return args;
+    });
+  },
+};

+ 80 - 0
yarn.lock

@@ -2365,6 +2365,11 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4
     escalade "^3.1.1"
     node-releases "^1.1.70"
 
+buffer-equal-constant-time@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.npm.taobao.org/buffer-equal-constant-time/download/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
+  integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
+
 buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.npm.taobao.org/buffer-from/download/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -3634,6 +3639,13 @@ ecc-jsbn@~0.1.1:
     jsbn "~0.1.0"
     safer-buffer "^2.1.0"
 
+ecdsa-sig-formatter@1.0.11:
+  version "1.0.11"
+  resolved "https://registry.npm.taobao.org/ecdsa-sig-formatter/download/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
+  integrity sha1-rg8PothQRe8UqBfao86azQSJ5b8=
+  dependencies:
+    safe-buffer "^5.0.1"
+
 ee-first@1.1.1:
   version "1.1.1"
   resolved "https://registry.npm.taobao.org/ee-first/download/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -5719,6 +5731,22 @@ jsonfile@^6.0.1:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
+jsonwebtoken@^8.5.1:
+  version "8.5.1"
+  resolved "https://registry.npm.taobao.org/jsonwebtoken/download/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
+  integrity sha1-AOceC431TCEhofJhN98igGc7zA0=
+  dependencies:
+    jws "^3.2.2"
+    lodash.includes "^4.3.0"
+    lodash.isboolean "^3.0.3"
+    lodash.isinteger "^4.0.4"
+    lodash.isnumber "^3.0.3"
+    lodash.isplainobject "^4.0.6"
+    lodash.isstring "^4.0.1"
+    lodash.once "^4.0.0"
+    ms "^2.1.1"
+    semver "^5.6.0"
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.npm.taobao.org/jsprim/download/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -5729,6 +5757,23 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
+jwa@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.npm.taobao.org/jwa/download/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
+  integrity sha1-dDwymFy56YZVUw1TZBtmyGRbA5o=
+  dependencies:
+    buffer-equal-constant-time "1.0.1"
+    ecdsa-sig-formatter "1.0.11"
+    safe-buffer "^5.0.1"
+
+jws@^3.2.2:
+  version "3.2.2"
+  resolved "https://registry.npm.taobao.org/jws/download/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
+  integrity sha1-ABCZ82OUaMlBQADpmZX6UvtHgwQ=
+  dependencies:
+    jwa "^1.4.1"
+    safe-buffer "^5.0.1"
+
 killable@^1.0.1:
   version "1.0.1"
   resolved "https://registry.npm.taobao.org/killable/download/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@@ -5934,6 +5979,36 @@ lodash.defaultsdeep@^4.6.1:
   resolved "https://registry.npm.taobao.org/lodash.defaultsdeep/download/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6"
   integrity sha1-US6b1yHSctlOPTpjZT+hdRZ0HKY=
 
+lodash.includes@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.npm.taobao.org/lodash.includes/download/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
+  integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
+
+lodash.isboolean@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.npm.taobao.org/lodash.isboolean/download/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
+  integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
+
+lodash.isinteger@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.npm.taobao.org/lodash.isinteger/download/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
+  integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
+
+lodash.isnumber@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.npm.taobao.org/lodash.isnumber/download/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
+  integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=
+
+lodash.isplainobject@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.npm.taobao.org/lodash.isplainobject/download/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
+lodash.isstring@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.npm.taobao.org/lodash.isstring/download/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
+  integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
+
 lodash.kebabcase@^4.1.1:
   version "4.1.1"
   resolved "https://registry.npm.taobao.org/lodash.kebabcase/download/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@@ -5949,6 +6024,11 @@ lodash.memoize@^4.1.2:
   resolved "https://registry.npm.taobao.org/lodash.memoize/download/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
   integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
 
+lodash.once@^4.0.0:
+  version "4.1.1"
+  resolved "https://registry.npm.taobao.org/lodash.once/download/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+  integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
+
 lodash.transform@^4.6.0:
   version "4.6.0"
   resolved "https://registry.npm.taobao.org/lodash.transform/download/lodash.transform-4.6.0.tgz#12306422f63324aed8483d3f38332b5f670547a0"

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است