You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@submarine.apache.org by li...@apache.org on 2019/11/11 01:29:33 UTC

[submarine] branch master updated: SUBMARINE-257. Submarine web user manager page with angular

This is an automated email from the ASF dual-hosted git repository.

liuxun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/submarine.git


The following commit(s) were added to refs/heads/master by this push:
     new 4e7571f  SUBMARINE-257. Submarine web user manager page with angular
4e7571f is described below

commit 4e7571f164274a5e3f78f183d011768e8536fd1f
Author: lleohao <ll...@hotmail.com>
AuthorDate: Sat Nov 9 16:03:00 2019 +0800

    SUBMARINE-257. Submarine web user manager page with angular
    
    ### What is this PR for?
    Submarine web, add user manager page with angular
    
    ### What type of PR is it?
    [Feature]
    
    ### What is the Jira issue?
    https://issues.apache.org/jira/browse/SUBMARINE-257
    
    ### How should this be tested?
    https://travis-ci.org/lleohao/hadoop-submarine/builds/609567077
    
    ### Screenshots (if appropriate)
    
    1. user list
    ![image](https://user-images.githubusercontent.com/3677382/68528827-cc5b3900-0332-11ea-9094-081ec7e12c8c.png)
    
    2. add user
    ![image](https://user-images.githubusercontent.com/3677382/68528836-e1d06300-0332-11ea-93f5-61ee42a2a53c.png)
    
    3. view user detail
    
    ![image](https://user-images.githubusercontent.com/3677382/68528857-088e9980-0333-11ea-9747-fc992451983e.png)
    
    4. edit user
    ![image](https://user-images.githubusercontent.com/3677382/68528859-0debe400-0333-11ea-8d23-25e0535a2277.png)
    
    5. change user password
    ![image](https://user-images.githubusercontent.com/3677382/68528851-fca2d780-0332-11ea-82fc-b2d42e8f5eb9.png)
    
    6. delete user
    ![image](https://user-images.githubusercontent.com/3677382/68528848-f7de2380-0332-11ea-939c-b4d9a751158e.png)
    
    7. Log out
    ![image](https://user-images.githubusercontent.com/3677382/68528854-02002200-0333-11ea-9269-bdfea5664c9a.png)
    
    ### Questions:
    * Does the licenses files need update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Author: lleohao <ll...@hotmail.com>
    
    Closes #89 from lleohao/SUBMARINE-257 and squashes the following commits:
    
    66e7c44 [lleohao] Merge branch 'master' into SUBMARINE-257
    d6ddf35 [lleohao] [SUBMARINE-257] ci: fix build ng error
    af4dff5 [lleohao] [SUBMARINE-257] test: fix e2e test case
    79663bb [lleohao] [SUBMARINE-257] ci: add unit test
    b977f89 [lleohao] [SUBMARINE-257] feat: add manager user feature
    3d7055e [lleohao] [SUBMARINE-257] feat: add page header and logout feature
    028b399 [lleohao] [SUBMARINE-257] feat: add sidebar and logo
    20b96b6 [lleohao] [SUBMARINE-257] feat: init workbench module
---
 .gitignore                                         |   5 +
 .travis.yml                                        |  25 ++-
 .../org/apache/submarine/rest/LoginRestApi.java    |  10 +-
 submarine-workbench/workbench-web-ng/angular.json  |   5 +-
 .../protractor-ci.conf.js}                         |  22 +-
 .../workbench-web-ng/e2e/src/app.e2e-spec.ts       |   4 +-
 .../workbench-web-ng/e2e/src/app.po.ts             |   2 +-
 submarine-workbench/workbench-web-ng/karma.conf.js |   6 +
 submarine-workbench/workbench-web-ng/package.json  |   2 +
 submarine-workbench/workbench-web-ng/pom.xml       |   1 +
 .../workbench-web-ng/src/app/app-routing.module.ts |   6 +-
 .../workbench-web-ng/src/app/app.component.html    |  52 -----
 .../workbench-web-ng/src/app/app.component.scss    |  77 -------
 .../workbench-web-ng/src/app/app.component.spec.ts |  16 +-
 .../workbench-web-ng/src/app/app.module.ts         |   2 +-
 .../components.module.ts}                          |  13 +-
 .../page-layout/page-layout.component.html}        |  18 +-
 .../page-layout/page-layout.component.scss}        |   4 +
 .../page-layout/page-layout.component.ts}          |  20 +-
 .../public-api.ts => interfaces/action.ts}         |   6 +-
 .../app/interfaces/base-entity.ts}                 |  20 +-
 .../src/app/interfaces/permission.ts               |  31 ++-
 .../src/app/interfaces/public-api.ts               |   4 +-
 .../workbench-web-ng/src/app/interfaces/rest.ts    |  31 +++
 .../workbench-web-ng/src/app/interfaces/role.ts    |  25 ++-
 .../app/interfaces/sys-dept-select.ts}             |  20 +-
 .../app/interfaces/sys-dict-item.ts}               |  24 +-
 .../src/app/interfaces/{rest.ts => sys-user.ts}    |  21 +-
 .../src/app/interfaces/{user.ts => user-info.ts}   |  41 +++-
 .../src/app/pages/user/login/login.component.ts    |  11 +-
 .../src/app/pages/user/user.component.ts           |   1 +
 .../manager/data-dict/data-dict.component.html}    |   2 +-
 .../manager/data-dict/data-dict.component.scss}    |   0
 .../manager/data-dict/data-dict.component.ts}      |  14 +-
 .../manager/manager-routing.module.ts              |   7 +-
 .../{ => workbench}/manager/manager.component.html |   9 +-
 .../{ => workbench}/manager/manager.component.scss |   0
 .../{ => workbench}/manager/manager.component.ts   |  30 ++-
 .../{ => workbench}/manager/manager.module.ts      |  10 +-
 .../manager/user-drawer/user-drawer.component.html | 206 +++++++++++++++++
 .../user-drawer/user-drawer.component.scss}        |  22 +-
 .../manager/user-drawer/user-drawer.component.ts   | 243 +++++++++++++++++++++
 .../user-password-modal.component.html             |  62 ++++++
 .../user-password-modal.component.scss}            |   0
 .../user-password-modal.component.ts               |  83 +++++++
 .../workbench/manager/user/user.component.html     | 131 +++++++++++
 .../manager/user/user.component.scss               |  11 +
 .../pages/workbench/manager/user/user.component.ts | 172 +++++++++++++++
 .../workbench-routing.module.ts}                   |  17 +-
 .../app/pages/workbench/workbench.component.html   |  82 +++++++
 .../workbench/workbench.component.scss}            |  38 ++--
 .../src/app/pages/workbench/workbench.component.ts |  88 ++++++++
 .../workbench.module.ts}                           |  13 +-
 .../src/app/services/auth.service.ts               |  56 +++--
 .../src/app/services/base-api.service.ts           |  35 +++
 .../{base-api.service.ts => department.service.ts} |  48 ++--
 .../src/app/services/public-api.ts                 |   5 +
 .../src/app/services/system-utils.service.ts       |  95 ++++++++
 .../src/app/services/user.service.ts               | 139 ++++++++++++
 .../workbench-web-ng/src/assets/logo.png           | Bin 0 -> 16547 bytes
 submarine-workbench/workbench-web-ng/src/main.ts   |   3 +-
 .../workbench-web-ng/src/polyfills.ts              |   3 +-
 submarine-workbench/workbench-web-ng/src/test.ts   |  10 +-
 63 files changed, 1798 insertions(+), 361 deletions(-)

diff --git a/.gitignore b/.gitignore
index a514d00..521d78c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,4 +67,9 @@ submarine-workbench/workbench-web/.env.*.local
 submarine-workbench/workbench-web/npm-debug.log*
 submarine-workbench/workbench-web/yarn-debug.log*
 submarine-workbench/workbench-web/yarn-error.log*
+submarine-workbench/workbench-web-ng/node
+submarine-workbench/workbench-web-ng/node_modules
+submarine-workbench/workbench-web-ng/dist
+submarine-workbench/workbench-web-ng/package-lock.json
+submarine-workbench/workbench-web-ng/npm-debug.log*
 submarine-test/e2e/Driver
diff --git a/.travis.yml b/.travis.yml
index 313743e..1b25f56 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -22,8 +22,10 @@ cache:
   apt: true
   directories:
     - ${HOME}/.m2
-    -  submarine-workbench/workbench-web/node
-    -  submarine-workbench/workbench-web/node_modules
+    - submarine-workbench/workbench-web/node
+    - submarine-workbench/workbench-web/node_modules
+    - submarine-workbench/workbench-web-ng/node
+    - submarine-workbench/workbench-web-ng/node_modules
 
 addons:
   apt:
@@ -139,6 +141,25 @@ matrix:
       dist: xenial
       env: NAME="Test submarine distribution" PROFILE="-Phadoop-2.9" BUILD_FLAG="clean package install -DskipTests" TEST_FLAG="test -DskipRat -am" MODULES="" TEST_MODULES="" TEST_PROJECTS=""
 
+    # Test submarine web-ng
+    - language: node_js
+      node_js:
+        - "10"
+      before_install:
+        - cd submarine-workbench/workbench-web-ng
+      before_script:
+        npm install
+      addons:
+        apt:
+          sources:
+            - google-chrome
+          packages:
+            - google-chrome-stable
+      script:
+        - npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
+        - npm run e2e -- --protractor-config=e2e/protractor-ci.conf.js
+      env: NAME="Build workbench-web-ng"
+
 install:
   - mvn --version
   - echo "[$NAME] > mvn $BUILD_FLAG $MODULES $PROFILE -B"
diff --git a/submarine-workbench/workbench-server/src/main/java/org/apache/submarine/rest/LoginRestApi.java b/submarine-workbench/workbench-server/src/main/java/org/apache/submarine/rest/LoginRestApi.java
index df21f29..f0b89c7 100644
--- a/submarine-workbench/workbench-server/src/main/java/org/apache/submarine/rest/LoginRestApi.java
+++ b/submarine-workbench/workbench-server/src/main/java/org/apache/submarine/rest/LoginRestApi.java
@@ -59,11 +59,12 @@ public class LoginRestApi {
     try (SqlSession sqlSession = MyBatisUtil.getSqlSession()) {
       SysUserMapper sysUserMapper = sqlSession.getMapper(SysUserMapper.class);
       sysUser = sysUserMapper.login(mapParams);
+
+      sysUser.setToken("mock_token");
     } catch (Exception e) {
       LOG.error(e.getMessage(), e);
       return new JsonResponse.Builder<>(Response.Status.OK).success(false).build();
     }
-    sysUser.setToken("mock_token");
 
     return new JsonResponse.Builder<SysUser>(Response.Status.OK).success(true).result(sysUser).build();
   }
@@ -76,4 +77,11 @@ public class LoginRestApi {
 
     return new JsonResponse.Builder<String>(Response.Status.OK).success(true).result(data).build();
   }
+
+  @POST
+  @Path("/logout")
+  @SubmarineApi
+  public Response logout() {
+    return new JsonResponse.Builder<Boolean>(Response.Status.OK).success(true).result(true).build();
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/angular.json b/submarine-workbench/workbench-web-ng/angular.json
index c47d29d..b8ea29d 100644
--- a/submarine-workbench/workbench-web-ng/angular.json
+++ b/submarine-workbench/workbench-web-ng/angular.json
@@ -101,7 +101,8 @@
             "styles": [
               "src/styles.scss"
             ],
-            "scripts": []
+            "scripts": [],
+            "codeCoverage": true
           }
         },
         "lint": {
@@ -133,4 +134,4 @@
     }
   },
   "defaultProject": "workbench-web-ng"
-}
\ No newline at end of file
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.ts b/submarine-workbench/workbench-web-ng/e2e/protractor-ci.conf.js
similarity index 70%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.ts
rename to submarine-workbench/workbench-web-ng/e2e/protractor-ci.conf.js
index 8c3d582..11abcc4 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.ts
+++ b/submarine-workbench/workbench-web-ng/e2e/protractor-ci.conf.js
@@ -17,15 +17,17 @@
  * under the License.
  */
 
-import { Component, OnInit } from '@angular/core';
+// @ts-check
+// Protractor configuration file, see link for more information
+// https://github.com/angular/protractor/blob/master/lib/config.ts
 
-@Component({
-  selector: 'submarine-manager-user',
-  templateUrl: './user.component.html',
-  styleUrls: ['./user.component.scss']
-})
-export class UserComponent implements OnInit {
-  constructor() {}
+const config = require('./protractor.conf').config;
 
-  ngOnInit() {}
-}
+config.capabilities = {
+  browserName: 'chrome',
+  chromeOptions: {
+    args: ['--headless', '--no-sandbox']
+  }
+};
+
+exports.config = config;
diff --git a/submarine-workbench/workbench-web-ng/e2e/src/app.e2e-spec.ts b/submarine-workbench/workbench-web-ng/e2e/src/app.e2e-spec.ts
index 5b0d8f6..d91a449 100644
--- a/submarine-workbench/workbench-web-ng/e2e/src/app.e2e-spec.ts
+++ b/submarine-workbench/workbench-web-ng/e2e/src/app.e2e-spec.ts
@@ -27,9 +27,9 @@ describe('workspace-project App', () => {
     page = new AppPage();
   });
 
-  it('should display welcome message', () => {
+  it('should display submarine', () => {
     page.navigateTo();
-    expect(page.getTitleText()).toEqual('workbench-web-ng app is running!');
+    expect(page.getTitleText()).toEqual('Submarine');
   });
 
   afterEach(async () => {
diff --git a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts b/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
index 493318b..62e9fb6 100644
--- a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
+++ b/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
@@ -25,6 +25,6 @@ export class AppPage {
   }
 
   getTitleText() {
-    return element(by.css('app-root .content span')).getText() as Promise<string>;
+    return element(by.css('submarine-root submarine-user .title')).getText() as Promise<string>;
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/karma.conf.js b/submarine-workbench/workbench-web-ng/karma.conf.js
index 00f2741..bfa1d61 100644
--- a/submarine-workbench/workbench-web-ng/karma.conf.js
+++ b/submarine-workbench/workbench-web-ng/karma.conf.js
@@ -45,6 +45,12 @@ module.exports = function (config) {
     logLevel: config.LOG_INFO,
     autoWatch: true,
     browsers: ['Chrome'],
+    customLaunchers: {
+      ChromeHeadlessCI: {
+        base: 'ChromeHeadless',
+        flags: ['--no-sandbox']
+      }
+    },
     singleRun: false,
     restartOnFileChange: true
   });
diff --git a/submarine-workbench/workbench-web-ng/package.json b/submarine-workbench/workbench-web-ng/package.json
index c2deab2..8fc2e31 100644
--- a/submarine-workbench/workbench-web-ng/package.json
+++ b/submarine-workbench/workbench-web-ng/package.json
@@ -19,6 +19,8 @@
     "@angular/platform-browser": "~8.2.9",
     "@angular/platform-browser-dynamic": "~8.2.9",
     "@angular/router": "~8.2.9",
+    "date-fns": "^2.6.0",
+    "lodash": "^4.17.15",
     "md5": "^2.2.1",
     "ng-zorro-antd": "8.1.2",
     "rxjs": "~6.4.0",
diff --git a/submarine-workbench/workbench-web-ng/pom.xml b/submarine-workbench/workbench-web-ng/pom.xml
index 081d2d7..f74883d 100644
--- a/submarine-workbench/workbench-web-ng/pom.xml
+++ b/submarine-workbench/workbench-web-ng/pom.xml
@@ -48,6 +48,7 @@
   </properties>
 
   <build>
+    <defaultGoal>compile</defaultGoal>
     <plugins>
       <plugin>
         <groupId>org.apache.rat</groupId>
diff --git a/submarine-workbench/workbench-web-ng/src/app/app-routing.module.ts b/submarine-workbench/workbench-web-ng/src/app/app-routing.module.ts
index 6cb4725..6ca7ea4 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app-routing.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/app-routing.module.ts
@@ -25,12 +25,12 @@ const routes: Routes = [
   {
     path: '',
     pathMatch: 'full',
-    redirectTo: '/manager/user'
+    redirectTo: 'workbench'
   },
   {
-    path: 'manager',
+    path: 'workbench',
     canActivate: [AuthGuard],
-    loadChildren: () => import('./pages/manager/manager.module').then(m => m.ManagerModule)
+    loadChildren: () => import('./pages/workbench/workbench.module').then(m => m.WorkbenchModule)
   },
   {
     path: 'user',
diff --git a/submarine-workbench/workbench-web-ng/src/app/app.component.html b/submarine-workbench/workbench-web-ng/src/app/app.component.html
index 6c447c0..970329b 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app.component.html
+++ b/submarine-workbench/workbench-web-ng/src/app/app.component.html
@@ -19,56 +19,4 @@
 
 <nz-layout class="app-layout">
   <router-outlet></router-outlet>
-<!--  <nz-sider class="menu-sidebar"-->
-<!--            nzCollapsible-->
-<!--            nzWidth="256px"-->
-<!--            nzBreakpoint="md"-->
-<!--            [(nzCollapsed)]="isCollapsed"-->
-<!--            [nzTrigger]="null">-->
-<!--    <div class="sidebar-logo">-->
-<!--      <a href="https://ng.ant.design/" target="_blank">-->
-<!--        <img src="https://ng.ant.design/assets/img/logo.svg" alt="logo">-->
-<!--        <h1>Ant Design Of Angular</h1>-->
-<!--      </a>-->
-<!--    </div>-->
-<!--    <ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed">-->
-<!--      <li nz-submenu nzOpen nzTitle="Dashboard" nzIcon="dashboard">-->
-<!--        <ul>-->
-<!--          <li nz-menu-item nzMatchRouter>-->
-<!--            <a routerLink="/welcome">Welcome</a>-->
-<!--          </li>-->
-<!--          <li nz-menu-item nzMatchRouter>-->
-<!--            <a>Monitor</a>-->
-<!--          </li>-->
-<!--          <li nz-menu-item nzMatchRouter>-->
-<!--            <a>Workplace</a>-->
-<!--          </li>-->
-<!--        </ul>-->
-<!--      </li>-->
-<!--      <li nz-submenu nzOpen nzTitle="Form" nzIcon="form">-->
-<!--        <ul>-->
-<!--          <li nz-menu-item nzMatchRouter>-->
-<!--            <a>Basic Form</a>-->
-<!--          </li>-->
-<!--        </ul>-->
-<!--      </li>-->
-<!--    </ul>-->
-<!--  </nz-sider>-->
-<!--  <nz-layout>-->
-<!--    <nz-header>-->
-<!--      <div class="app-header">-->
-<!--        <span class="header-trigger" (click)="isCollapsed = !isCollapsed">-->
-<!--            <i class="trigger"-->
-<!--               nz-icon-->
-<!--               [nzType]="isCollapsed ? 'menu-unfold' : 'menu-fold'"-->
-<!--            ></i>-->
-<!--        </span>-->
-<!--      </div>-->
-<!--    </nz-header>-->
-<!--    <nz-content>-->
-<!--      <div class="inner-content">-->
-<!--        <router-outlet></router-outlet>-->
-<!--      </div>-->
-<!--    </nz-content>-->
-<!--  </nz-layout>-->
 </nz-layout>
diff --git a/submarine-workbench/workbench-web-ng/src/app/app.component.scss b/submarine-workbench/workbench-web-ng/src/app/app.component.scss
index 67bafe6..a3b6485 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/app.component.scss
@@ -17,83 +17,6 @@
  * under the License.
  */
 
-:host {
-  display: flex;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
 .app-layout {
   height: 100vh;
 }
-
-.menu-sidebar {
-  position: relative;
-  z-index: 10;
-  min-height: 100vh;
-  box-shadow: 2px 0 6px rgba(0,21,41,.35);
-}
-
-.header-trigger {
-  height: 64px;
-  padding: 20px 24px;
-  font-size: 20px;
-  cursor: pointer;
-  transition: all .3s,padding 0s;
-}
-
-.trigger:hover {
-  color: #1890ff;
-}
-
-.sidebar-logo {
-  position: relative;
-  height: 64px;
-  padding-left: 24px;
-  overflow: hidden;
-  line-height: 64px;
-  background: #001529;
-  transition: all .3s;
-}
-
-.sidebar-logo img {
-  display: inline-block;
-  height: 32px;
-  width: 32px;
-  vertical-align: middle;
-}
-
-.sidebar-logo h1 {
-  display: inline-block;
-  margin: 0 0 0 20px;
-  color: #fff;
-  font-weight: 600;
-  font-size: 14px;
-  font-family: Avenir,Helvetica Neue,Arial,Helvetica,sans-serif;
-  vertical-align: middle;
-}
-
-nz-header {
-  padding: 0;
-  width: 100%;
-  z-index: 2;
-}
-
-.app-header {
-  position: relative;
-  height: 64px;
-  padding: 0;
-  background: #fff;
-  box-shadow: 0 1px 4px rgba(0,21,41,.08);
-}
-
-nz-content {
-  margin: 24px;
-}
-
-.inner-content {
-  padding: 24px;
-  background: #fff;
-  height: 100%;
-}
diff --git a/submarine-workbench/workbench-web-ng/src/app/app.component.spec.ts b/submarine-workbench/workbench-web-ng/src/app/app.component.spec.ts
index e59a500..c26eb91 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app.component.spec.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/app.component.spec.ts
@@ -19,12 +19,13 @@
 
 import { async, TestBed } from '@angular/core/testing';
 import { RouterTestingModule } from '@angular/router/testing';
+import { NgZorroAntdModule } from 'ng-zorro-antd';
 import { AppComponent } from './app.component';
 
 describe('AppComponent', () => {
   beforeEach(async(() => {
     TestBed.configureTestingModule({
-      imports: [RouterTestingModule],
+      imports: [RouterTestingModule, NgZorroAntdModule],
       declarations: [AppComponent]
     }).compileComponents();
   }));
@@ -34,17 +35,4 @@ describe('AppComponent', () => {
     const app = fixture.debugElement.componentInstance;
     expect(app).toBeTruthy();
   });
-
-  it(`should have as title 'workbench-web-ng'`, () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.debugElement.componentInstance;
-    expect(app.title).toEqual('workbench-web-ng');
-  });
-
-  it('should render title', () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    fixture.detectChanges();
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.content span').textContent).toContain('workbench-web-ng app is running!');
-  });
 });
diff --git a/submarine-workbench/workbench-web-ng/src/app/app.module.ts b/submarine-workbench/workbench-web-ng/src/app/app.module.ts
index 59c9cfd..64acdb4 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/app.module.ts
@@ -25,7 +25,7 @@ import { HttpClientModule } from '@angular/common/http';
 import zh from '@angular/common/locales/zh';
 import { FormsModule } from '@angular/forms';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import { LocalStorageService } from '@submarine/services/local-storage.service';
+import { LocalStorageService } from '@submarine/services';
 import { zh_CN, NgZorroAntdModule, NZ_I18N } from 'ng-zorro-antd';
 import { AppRoutingModule } from './app-routing.module';
 import { AppComponent } from './app.component';
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts b/submarine-workbench/workbench-web-ng/src/app/components/components.module.ts
similarity index 74%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
copy to submarine-workbench/workbench-web-ng/src/app/components/components.module.ts
index cfabafe..bfbd54c 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/components/components.module.ts
@@ -19,13 +19,14 @@
 
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
 import { NgZorroAntdModule } from 'ng-zorro-antd';
-import { ManagerRoutingModule } from './manager-routing.module';
-import { ManagerComponent } from './manager.component';
-import { UserComponent } from './user/user.component';
+import { PageLayoutComponent } from './page-layout/page-layout.component';
 
 @NgModule({
-  declarations: [UserComponent, ManagerComponent],
-  imports: [CommonModule, ManagerRoutingModule, NgZorroAntdModule]
+  declarations: [PageLayoutComponent],
+  imports: [CommonModule, RouterModule, NgZorroAntdModule],
+  exports: [PageLayoutComponent]
 })
-export class ManagerModule {}
+export class ComponentsModule {
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.html
similarity index 62%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html
copy to submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.html
index c5412cf..bf9287d 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html
+++ b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.html
@@ -17,4 +17,20 @@
   ~ under the License.
   -->
 
-<p>user works!</p>
+<div [ngStyle]="!title ? {margin: '-24px -24px 0'} : null">
+  <nz-page-header [nzTitle]="title|titlecase" *ngIf="breadCrumb">
+    <nz-breadcrumb nz-page-header-breadcrumb>
+      <nz-breadcrumb-item *ngFor="let item of breadCrumb">
+        {{item|titlecase}}
+      </nz-breadcrumb-item>
+    </nz-breadcrumb>
+    <nz-page-header-content *ngIf="description">
+      <p>{{description}}</p>
+    </nz-page-header-content>
+  </nz-page-header>
+
+  <div class="content">
+    <ng-content></ng-content>
+  </div>
+</div>
+
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.scss b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.scss
similarity index 94%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.scss
index 510f082..ecea51c 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.scss
@@ -17,3 +17,7 @@
  * under the License.
  */
 
+nz-page-header {
+  margin: -24px -24px 16px;
+}
+
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.ts
similarity index 68%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
copy to submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.ts
index efa6503..77a8a28 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/components/page-layout/page-layout.component.ts
@@ -17,15 +17,21 @@
  * under the License.
  */
 
-import { Component, OnInit } from '@angular/core';
+import { Component, Input, OnInit } from '@angular/core';
 
 @Component({
-  selector: 'submarine-manager',
-  templateUrl: './manager.component.html',
-  styleUrls: ['./manager.component.scss']
+  selector: 'submarine-page-layout',
+  templateUrl: './page-layout.component.html',
+  styleUrls: ['./page-layout.component.scss']
 })
-export class ManagerComponent implements OnInit {
-  constructor() {}
+export class PageLayoutComponent implements OnInit {
+  @Input() title: string;
+  @Input() description: string;
+  @Input() breadCrumb: string[];
 
-  ngOnInit() {}
+  constructor() {
+  }
+
+  ngOnInit() {
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/action.ts
similarity index 89%
copy from submarine-workbench/workbench-web-ng/src/app/services/public-api.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/action.ts
index a853cff..525ed78 100644
--- a/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/action.ts
@@ -17,4 +17,8 @@
  * under the License.
  */
 
-export * from './auth.service';
+export interface Action {
+  action: string;
+  defaultCheck: boolean;
+  describe: string;
+}
diff --git a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/base-entity.ts
similarity index 71%
copy from submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/base-entity.ts
index 493318b..00dbaa3 100644
--- a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/base-entity.ts
@@ -17,14 +17,18 @@
  * under the License.
  */
 
-import { browser, by, element } from 'protractor';
+export class BaseEntity {
+  id: string;
+  createBy: string;
+  createTime: number;
+  updateBy: string;
+  updateTime: number;
 
-export class AppPage {
-  navigateTo() {
-    return browser.get(browser.baseUrl) as Promise<any>;
-  }
-
-  getTitleText() {
-    return element(by.css('app-root .content span')).getText() as Promise<string>;
+  constructor(data: BaseEntity) {
+    this.id = data.id;
+    this.createBy = data.createBy;
+    this.createTime = data.createTime;
+    this.updateBy = data.updateBy;
+    this.updateTime = data.updateTime;
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/permission.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/permission.ts
index fffef9e..349649a 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/permission.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/permission.ts
@@ -17,19 +17,30 @@
  * under the License.
  */
 
-export interface PermissionActionEntitySet {
+import { Action } from './action';
+
+export interface ActionEntitySet {
   action: string;
-  defaultChecked: boolean;
+  defaultCheck: boolean;
   describe: string;
 }
 
-export type PermissionAction = PermissionActionEntitySet;
-
 export class Permission {
-  permissionId = '';
-  permissionName = '';
-  roleId = '';
-  actionList = null;
-  actionEntitySet: PermissionActionEntitySet[] = [];
-  actions: PermissionAction[] = [];
+  roleId: string;
+  permissionId: string;
+  permissionName: string;
+  dataAccess?: any;
+  actionList?: any;
+  actions: Action[];
+  actionEntitySet: ActionEntitySet[];
+
+  constructor(permission: Permission) {
+    this.roleId = permission.roleId;
+    this.permissionId = permission.permissionId;
+    this.permissionName = permission.permissionName;
+    this.dataAccess = permission.dataAccess;
+    this.actionList = permission.actionList;
+    this.actions = permission.actions;
+    this.actionEntitySet = permission.actionEntitySet;
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/public-api.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/public-api.ts
index 8958522..4123a93 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/public-api.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/public-api.ts
@@ -19,5 +19,7 @@
 
 export * from './permission';
 export * from './role';
-export * from './user';
+export * from './user-info';
 export * from './rest';
+export * from './action';
+export * from './sys-user';
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts
index dcf39fc..61f5d46 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts
@@ -17,10 +17,41 @@
  * under the License.
  */
 
+/**
+ * REST api abstract interface
+ *
+ * @example
+ * ```typescript
+ * const res = Rest<{userName: string, password: string}>;
+ *
+ * // res.result.userName is string
+ * // res.result.password is string
+ * ```
+ */
 export interface Rest<T> {
+  /**
+   * request result
+   */
   result: T;
+  /**
+   * request http status code
+   */
   code: number;
   message: string;
   status: string;
   success: boolean;
 }
+
+/**
+ * Array result
+ */
+export interface ListResult<T> {
+  /**
+   * result list
+   */
+  records: T[];
+  /**
+   * result list length
+   */
+  total: number;
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/role.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/role.ts
index f3f8669..1464300 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/role.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/role.ts
@@ -19,13 +19,24 @@
 
 import { Permission } from './permission';
 
-export type RoleId = string;
-
 export class Role {
-  createTime: number = -1;
-  creatorId = '';
-  describe = '';
-  id = '';
-  name = '';
+  id: string;
+  name: string;
+  describe: string;
+  status: number;
+  creatorId: string;
+  createTime: number;
+  deleted: number;
   permissions: Permission[];
+
+  constructor(role: Role) {
+    this.id = role.id;
+    this.name = role.name;
+    this.describe = role.describe;
+    this.status = role.status;
+    this.creatorId = role.creatorId;
+    this.createTime = role.createTime;
+    this.deleted = role.deleted;
+    this.permissions = role.permissions.map(permission => new Permission(permission));
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dept-select.ts
similarity index 69%
copy from submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dept-select.ts
index 493318b..5929e7b 100644
--- a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dept-select.ts
@@ -17,14 +17,18 @@
  * under the License.
  */
 
-import { browser, by, element } from 'protractor';
+export class SysDeptSelect {
+  key: string;
+  value: string;
+  title: string;
+  disabled: boolean;
+  children: SysDeptSelect[];
 
-export class AppPage {
-  navigateTo() {
-    return browser.get(browser.baseUrl) as Promise<any>;
-  }
-
-  getTitleText() {
-    return element(by.css('app-root .content span')).getText() as Promise<string>;
+  constructor(data: SysDeptSelect) {
+    this.key = data.key;
+    this.value = data.value;
+    this.title = data.title;
+    this.disabled = data.disabled;
+    this.children = data.children.map(item => new SysDeptSelect(item));
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dict-item.ts
similarity index 61%
copy from submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dict-item.ts
index 493318b..dbe56d1 100644
--- a/submarine-workbench/workbench-web-ng/e2e/src/app.po.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-dict-item.ts
@@ -17,14 +17,24 @@
  * under the License.
  */
 
-import { browser, by, element } from 'protractor';
+import { BaseEntity } from '@submarine/interfaces/base-entity';
 
-export class AppPage {
-  navigateTo() {
-    return browser.get(browser.baseUrl) as Promise<any>;
-  }
+export class SysDictItem extends BaseEntity {
+  dictCode: string;
+  itemCode: string;
+  itemName: string;
+  description: string;
+  sortOrder: number;
+  deleted: number;
+
+  constructor(data: SysDictItem) {
+    super(data);
 
-  getTitleText() {
-    return element(by.css('app-root .content span')).getText() as Promise<string>;
+    this.dictCode = data.dictCode;
+    this.itemCode = data.itemCode;
+    this.itemName = data.itemName;
+    this.description = data.description;
+    this.sortOrder = data.sortOrder;
+    this.deleted = data.deleted;
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-user.ts
similarity index 71%
copy from submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/sys-user.ts
index dcf39fc..18571e7 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/rest.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/sys-user.ts
@@ -17,10 +17,21 @@
  * under the License.
  */
 
-export interface Rest<T> {
-  result: T;
-  code: number;
-  message: string;
+import { BaseEntity } from './base-entity';
+
+export interface SysUser extends BaseEntity {
+  userName: string;
+  realName: string;
+  password: string;
+  avatar: string;
+  sex: string;
   status: string;
-  success: boolean;
+  phone: string;
+  email: string;
+  deptCode: string;
+  deptName: string;
+  roleCode: string;
+  birthday: number;
+  deleted: number;
+  token: string;
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/user-info.ts
similarity index 50%
copy from submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts
copy to submarine-workbench/workbench-web-ng/src/app/interfaces/user-info.ts
index c68c424..c159497 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/user-info.ts
@@ -19,13 +19,38 @@
 
 import { Role } from './role';
 
-export class User {
-  avatar = '';
-  id = '';
-  name = '';
-  telephone = '';
-  username = '';
+export class UserInfo {
+  id: string;
+  name: string;
+  username: string;
+  password: string;
+  avatar: string;
+  status: number;
+  telephone: string;
+  lastLoginIp: string;
+  lastLoginTime: number;
+  creatorId: string;
+  createTime: number;
+  merchantCode: string;
+  deleted: number;
+  roleId: string;
   role: Role;
-  roleId: number;
-  token: string;
+
+  constructor(res: UserInfo) {
+    this.id = res.id;
+    this.name = res.name;
+    this.username = res.username;
+    this.password = res.password;
+    this.avatar = res.avatar;
+    this.status = res.status;
+    this.telephone = res.telephone;
+    this.lastLoginIp = res.lastLoginIp;
+    this.lastLoginTime = res.lastLoginTime;
+    this.creatorId = res.creatorId;
+    this.createTime = res.createTime;
+    this.merchantCode = res.merchantCode;
+    this.deleted = res.deleted;
+    this.roleId = res.roleId;
+    this.role = new Role(res.role);
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/user/login/login.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/user/login/login.component.ts
index 9190cea..2158604 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/user/login/login.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/user/login/login.component.ts
@@ -20,8 +20,8 @@
 import { Component, OnInit } from '@angular/core';
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { Router } from '@angular/router';
+import { AuthService } from '@submarine/services';
 import { NzNotificationService } from 'ng-zorro-antd';
-import { AuthService } from '../../../services';
 
 @Component({
   selector: 'submarine-login',
@@ -38,7 +38,7 @@ export class LoginComponent implements OnInit {
     private router: Router
   ) {
     if (this.authService.isLoggedIn) {
-      this.router.navigate(['/manager/user']);
+      this.router.navigate(['/workbench']);
     }
   }
 
@@ -55,7 +55,6 @@ export class LoginComponent implements OnInit {
           this.loginSuccess();
         },
         error => {
-          console.log(error);
           this.requestFailed(error);
         }
       );
@@ -71,11 +70,7 @@ export class LoginComponent implements OnInit {
   }
 
   loginSuccess() {
-    this.router.navigate(['/manager/user']);
-
-    setTimeout(() => {
-      this.nzNotificationService.success('Welcome', 'Welcome back');
-    }, 1000);
+    this.router.navigate(['/workbench']);
   }
 
   requestFailed(error: Error) {
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/user/user.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/user/user.component.ts
index b38a643..c4f6328 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/user/user.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/user/user.component.ts
@@ -18,6 +18,7 @@
  */
 
 import { Component, OnInit } from '@angular/core';
+import { UserService } from '@submarine/services';
 
 @Component({
   selector: 'submarine-user',
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.html
similarity index 97%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.html
index c5412cf..5f8f81f 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.html
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.html
@@ -17,4 +17,4 @@
   ~ under the License.
   -->
 
-<p>user works!</p>
+<p>data-dict works!</p>
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.scss
similarity index 100%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.scss
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.ts
similarity index 80%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.ts
index efa6503..3dde811 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/data-dict/data-dict.component.ts
@@ -20,12 +20,14 @@
 import { Component, OnInit } from '@angular/core';
 
 @Component({
-  selector: 'submarine-manager',
-  templateUrl: './manager.component.html',
-  styleUrls: ['./manager.component.scss']
+  selector: 'submarine-data-dict',
+  templateUrl: './data-dict.component.html',
+  styleUrls: ['./data-dict.component.scss']
 })
-export class ManagerComponent implements OnInit {
-  constructor() {}
+export class DataDictComponent implements OnInit {
+  constructor() {
+  }
 
-  ngOnInit() {}
+  ngOnInit() {
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager-routing.module.ts
similarity index 86%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager-routing.module.ts
index 97c775f..52757dc 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager-routing.module.ts
@@ -19,6 +19,7 @@
 
 import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
+import { DataDictComponent } from '@submarine/pages/workbench/manager/data-dict/data-dict.component';
 import { ManagerComponent } from './manager.component';
 import { UserComponent } from './user/user.component';
 
@@ -30,11 +31,15 @@ const routes: Routes = [
       {
         path: '',
         pathMatch: 'full',
-        redirectTo: '/manager/user'
+        redirectTo: 'user'
       },
       {
         path: 'user',
         component: UserComponent
+      },
+      {
+        path: 'data-dict',
+        component: DataDictComponent
       }
     ]
   }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.html
similarity index 79%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.html
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.html
index 4f806ff..598d7db 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.html
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.html
@@ -17,5 +17,10 @@
   ~ under the License.
   -->
 
-<p>manager works!</p>
-<router-outlet></router-outlet>
+<submarine-page-layout
+  [title]="currentHeaderInfo?.title"
+  [breadCrumb]="currentHeaderInfo?.breadCrumb"
+  [description]="currentHeaderInfo?.description"
+>
+  <router-outlet></router-outlet>
+</submarine-page-layout>
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.scss
similarity index 100%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.scss
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.scss
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.ts
similarity index 50%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.ts
index efa6503..15f42c5 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.component.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.component.ts
@@ -17,15 +17,41 @@
  * under the License.
  */
 
+import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
 import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import _ from 'lodash';
+
+interface HeaderInfo {
+  title: string;
+  description: string;
+  breadCrumb: string[];
+}
 
 @Component({
   selector: 'submarine-manager',
   templateUrl: './manager.component.html',
-  styleUrls: ['./manager.component.scss']
+  styleUrls: ['./manager.component.scss'],
+  providers: [Location, { provide: LocationStrategy, useClass: PathLocationStrategy }]
 })
 export class ManagerComponent implements OnInit {
-  constructor() {}
+  private headerInfo: { [key: string]: HeaderInfo } = {
+    user: {
+      title: 'user',
+      description: 'You can check the user, delete the user, lock and unlock the user, etc.',
+      breadCrumb: ['manager', 'user']
+    }
+  };
+  currentHeaderInfo: HeaderInfo;
+
+  constructor(private route: ActivatedRoute, private location: Location, private router: Router) {
+    this.router.events.subscribe(event => {
+      if (event instanceof NavigationEnd) {
+        const lastMatch = _.last(event.urlAfterRedirects.split('/'));
+        this.currentHeaderInfo = this.headerInfo[lastMatch];
+      }
+    });
+  }
 
   ngOnInit() {}
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.module.ts
similarity index 68%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.module.ts
index cfabafe..94048f5 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/manager.module.ts
@@ -19,13 +19,19 @@
 
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { ComponentsModule } from '@submarine/components/components.module';
 import { NgZorroAntdModule } from 'ng-zorro-antd';
+
+import { DataDictComponent } from './data-dict/data-dict.component';
 import { ManagerRoutingModule } from './manager-routing.module';
 import { ManagerComponent } from './manager.component';
+import { UserDrawerComponent } from './user-drawer/user-drawer.component';
+import { UserPasswordModalComponent } from './user-password-modal/user-password-modal.component';
 import { UserComponent } from './user/user.component';
 
 @NgModule({
-  declarations: [UserComponent, ManagerComponent],
-  imports: [CommonModule, ManagerRoutingModule, NgZorroAntdModule]
+  declarations: [UserComponent, ManagerComponent, DataDictComponent, UserPasswordModalComponent, UserDrawerComponent],
+  imports: [CommonModule, ManagerRoutingModule, NgZorroAntdModule, ComponentsModule, FormsModule, ReactiveFormsModule]
 })
 export class ManagerModule {}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.html
new file mode 100644
index 0000000..9ae9b64
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.html
@@ -0,0 +1,206 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<nz-drawer
+  [nzTitle]="title"
+  [nzMaskClosable]="false"
+  [nzWidth]="720"
+  [nzVisible]="visible"
+  (nzOnClose)="onClose()"
+  [nzBodyStyle]="{ height: 'calc(100% - 55px)', overflow: 'auto', 'padding-bottom': '53px' }"
+>
+  <form nz-form [formGroup]="form" nzLayout="horizontal">
+    <!-- Account Name-->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan" nzRequired>
+        Account Name
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" nzHasFeedback [nzErrorTip]="usernameErrorTpl">
+        <input
+          type="text"
+          nz-input
+          formControlName="userName"
+          placeholder="User's account name"
+          [readOnly]="sysUser?.id"
+        />
+
+        <ng-template #usernameErrorTpl let-control>
+          <ng-container *ngIf="control.hasError('required')">
+            Please input account's name!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('duplicated')">
+            Account name already exist!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('error')">
+            Error
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Password -->
+    <nz-form-item *ngIf="!sysUser?.id">
+      <nz-form-label [nzSpan]="labelSpan" nzRequired>
+        Password
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" [nzErrorTip]="passwordErrorTpl">
+        <input
+          type="password"
+          nz-input
+          formControlName="password"
+          (ngModelChange)="validateConfirmPassword()"
+          placeholder="Users's account password"
+        />
+
+        <ng-template #passwordErrorTpl let-control>
+          <ng-container *ngIf="control.hasError('pattern')">
+            The password consists of 8 digits, uppercase and lowercase letters and special symbols!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('required')">
+            Pleas input account's password!
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Confirm -->
+    <nz-form-item *ngIf="!sysUser?.id">
+      <nz-form-label [nzSpan]="labelSpan" nzRequired>
+        Confirm
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" [nzErrorTip]="confirmErrorTpl">
+        <input type="password" nz-input formControlName="confirm" placeholder="Please confirm password"/>
+
+        <ng-template #confirmErrorTpl let-control>
+          <ng-container *ngIf="control.hasError('required')">
+            Please confirm your password!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('confirm')">
+            Password is inconsistent!
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Real Name -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan" nzRequired>
+        Real Name
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" nzErrorTip="Please input account's real name">
+        <input type="text" nz-input formControlName="realName" placeholder="User's real name" [readOnly]="readonly"/>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Department -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        Department
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan">
+        <nz-tree-select
+          nzAllowClear
+          nzDefaultExpandAll
+          nzShowSearch
+          nzPlaceHolder="Please select"
+          formControlName="deptCode"
+          [nzNodes]="sysDeptTreeList"
+        ></nz-tree-select>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Birthday -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        Birthday
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan">
+        <nz-date-picker formControlName="birthday"></nz-date-picker>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Sex -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        Sex
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan">
+        <nz-select formControlName="sex" nzPlaceHolder="Please Select">
+          <nz-option [nzValue]="item.itemCode" [nzLabel]="item.itemName" *ngFor="let item of sexDictSelect"></nz-option>
+        </nz-select>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- email -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        email
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" [nzErrorTip]="emailErrorTip">
+        <input type="email" nz-input formControlName="email" [readOnly]="readonly"/>
+
+        <ng-template #emailErrorTip let-control>
+          <ng-container *ngIf="control.hasError('email')">
+            Email is incorrect!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('duplicated')">
+            Email name already exist!
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Phone -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        Phone
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan" [nzErrorTip]="phoneErrorTip">
+        <input type="text" nz-input formControlName="phone" [readOnly]="readonly"/>
+
+        <ng-template #phoneErrorTip let-control>
+          <ng-container *ngIf="control.hasError('duplicated')">
+            Phone already exist!
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+
+    <!-- Status -->
+    <nz-form-item>
+      <nz-form-label [nzSpan]="labelSpan">
+        Status
+      </nz-form-label>
+      <nz-form-control [nzSpan]="controlSpan">
+        <nz-select formControlName="status" nzPlaceHolder="Please Select">
+          <nz-option
+            [nzValue]="item.itemCode"
+            [nzLabel]="item.itemName"
+            *ngFor="let item of statusDictSelect"
+          ></nz-option>
+        </nz-select>
+      </nz-form-control>
+    </nz-form-item>
+  </form>
+
+  <div class="footer" *ngIf="!readonly">
+    <button type="button" (click)="onClose()" class="ant-btn" style="margin-right: 8px;"><span>Cancel</span></button>
+    <button type="button" (click)="onSubmit()" class="ant-btn ant-btn-primary"><span>Submit</span></button>
+  </div>
+</nz-drawer>
diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.scss
similarity index 81%
rename from submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.scss
index c68c424..7750c7a 100644
--- a/submarine-workbench/workbench-web-ng/src/app/interfaces/user.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.scss
@@ -1,4 +1,4 @@
-/*
+/*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
@@ -17,15 +17,13 @@
  * under the License.
  */
 
-import { Role } from './role';
-
-export class User {
-  avatar = '';
-  id = '';
-  name = '';
-  telephone = '';
-  username = '';
-  role: Role;
-  roleId: number;
-  token: string;
+.footer {
+  position: absolute;
+  bottom: 0;
+  width: 100%;
+  border-top: 1px solid rgb(232, 232, 232);
+  padding: 10px 16px;
+  text-align: right;
+  left: 0;
+  background: #fff;
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.ts
new file mode 100644
index 0000000..a480af4
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-drawer/user-drawer.component.ts
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup, ValidationErrors, Validators } from '@angular/forms';
+import { SysUser } from '@submarine/interfaces';
+import { SysDeptSelect } from '@submarine/interfaces/sys-dept-select';
+import { SysDictItem } from '@submarine/interfaces/sys-dict-item';
+import { SystemUtilsService, SysDictCode } from '@submarine/services';
+import { format } from 'date-fns';
+import { zip, Observable, Observer } from 'rxjs';
+import { filter, map, startWith, take } from 'rxjs/operators';
+
+@Component({
+  selector: 'submarine-user-drawer',
+  templateUrl: './user-drawer.component.html',
+  styleUrls: ['./user-drawer.component.scss']
+})
+export class UserDrawerComponent implements OnInit, OnChanges {
+  @Input() visible: boolean;
+  @Input() readonly: boolean;
+  @Input() sysUser: SysUser;
+  @Input() sysDeptTreeList: SysDeptSelect[];
+  @Output() readonly close: EventEmitter<any> = new EventEmitter<any>();
+  @Output() readonly submit: EventEmitter<Partial<SysUser>> = new EventEmitter<Partial<SysUser>>();
+
+  form: FormGroup;
+  labelSpan = 5;
+  controlSpan = 14;
+  sexDictSelect: SysDictItem[] = [];
+  statusDictSelect: SysDictItem[] = [];
+  title = 'Add';
+
+  constructor(private fb: FormBuilder, private systemUtilsService: SystemUtilsService) {
+  }
+
+  ngOnInit() {
+    this.form = this.fb.group(
+      {
+        userName: new FormControl('',
+          {
+            updateOn: 'blur',
+            validators: [Validators.required],
+            asyncValidators: [this.userNameValidator]
+          }
+        ),
+        password: ['', [Validators.required, Validators.pattern(/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/)]],
+        confirm: ['', this.confirmValidator],
+        realName: ['', Validators.required],
+        deptCode: [],
+        sex: [],
+        status: [],
+        birthday: [],
+        email: new FormControl('', {
+          updateOn: 'blur',
+          validators: [Validators.email],
+          asyncValidators: [this.emailValidator]
+        }),
+        phone: new FormControl('', {
+          updateOn: 'blur',
+          asyncValidators: [this.phoneValidator]
+        })
+      }
+    );
+
+    zip(
+      this.systemUtilsService.fetchSysDictByCode(SysDictCode.USER_SEX),
+      this.systemUtilsService.fetchSysDictByCode(SysDictCode.USER_STATUS)
+    )
+      .pipe(map(([item1, item2]) => [item1.records, item2.records]))
+      .subscribe(([sexDictSelect, statusDictSelect]) => {
+        this.sexDictSelect = sexDictSelect;
+        this.statusDictSelect = statusDictSelect;
+      });
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (!changes.visible) {
+      return;
+    }
+
+    if (changes.visible.currentValue) {
+      const sysUser = this.sysUser;
+      const readOnly = this.readonly;
+
+      this.form.reset({
+        userName: sysUser ? sysUser.userName : '',
+        password: sysUser ? sysUser.password : '',
+        confirm: sysUser ? sysUser.password : '',
+        realName: sysUser ? sysUser.realName : '',
+        deptCode: {
+          value: sysUser ? sysUser.deptCode : '',
+          disabled: readOnly
+        },
+        sex: {
+          value: sysUser ? sysUser.sex : '',
+          disabled: readOnly
+        },
+        status: {
+          value: sysUser ? sysUser.status : '',
+          disabled: readOnly
+        },
+        birthday: {
+          value: (sysUser && sysUser.birthday) ? new Date(sysUser.birthday) : '',
+          disabled: readOnly
+        },
+        email: sysUser ? sysUser.email : '',
+        phone: sysUser ? sysUser.phone : ''
+      });
+      this.title = this.generateTitle();
+    }
+  }
+
+  onClose() {
+    this.close.emit();
+  }
+
+  onSubmit() {
+    for (const key in this.form.controls) {
+      this.form.controls[key].markAsDirty();
+      this.form.controls[key].updateValueAndValidity();
+    }
+
+    this.form.statusChanges.pipe(
+      startWith(this.form.status),
+      filter(status => status !== 'PENDING'),
+      take(1),
+      map(status => status === 'VALID')
+    ).subscribe(valid => {
+      if (valid) {
+        const sysUser = this.sysUser ? this.sysUser : {};
+        const formData = { ...sysUser, ...this.form.value };
+
+        delete formData.confirm;
+        delete formData['sex@dict'];
+        delete formData['status@dict'];
+
+        if (formData.birthday) {
+          formData.birthday = format(formData.birthday, 'yyyy-MM-dd HH:mm:ss');
+        }
+
+        this.submit.emit(formData);
+      }
+    });
+  }
+
+  generateTitle(): string {
+    if (this.readonly) {
+      return 'Details';
+    } else if (this.sysUser && this.sysUser.id) {
+      return 'Edit';
+    } else {
+      return 'Add';
+    }
+  }
+
+  confirmValidator = (control: FormControl): { [s: string]: boolean } => {
+    if (!control.value) {
+      return { error: true, required: true };
+    } else if (control.value !== this.form.controls.password.value) {
+      return { error: true, confirm: true };
+    }
+
+    return {};
+  };
+
+  validateConfirmPassword = () => {
+    setTimeout(() => {
+      this.form.controls.confirm.updateValueAndValidity();
+    });
+  };
+
+  userNameValidator = (control: FormControl) =>
+    new Observable((observer: Observer<ValidationErrors | null>) => {
+      this.systemUtilsService
+        .duplicateCheckUsername(control.value, this.sysUser && this.sysUser.id)
+        .subscribe(status => {
+          if (status) {
+            observer.next(null);
+          } else {
+            observer.next({ error: true, duplicated: true });
+          }
+
+          observer.complete();
+        });
+    });
+
+  emailValidator = (control: FormControl) =>
+    new Observable((observer: Observer<ValidationErrors | null>) => {
+      if (!control.value) {
+        observer.next(null);
+        return observer.complete();
+      }
+
+      this.systemUtilsService
+        .duplicateCheckUserEmail(control.value, this.sysUser && this.sysUser.id)
+        .subscribe(status => {
+          if (status) {
+            observer.next(null);
+          } else {
+            observer.next({ error: true, duplicated: true });
+          }
+
+          observer.complete();
+        });
+    });
+
+  phoneValidator = (control: FormControl) =>
+    new Observable((observer: Observer<ValidationErrors | null>) => {
+      if (!control.value) {
+        observer.next(null);
+        return observer.complete();
+      }
+
+      this.systemUtilsService
+        .duplicateCheckUserPhone(control.value, this.sysUser && this.sysUser.id)
+        .subscribe(status => {
+          if (status) {
+            observer.next(null);
+          } else {
+            observer.next({ error: true, duplicated: true });
+          }
+
+          observer.complete();
+        });
+    });
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.html
new file mode 100644
index 0000000..9ae1ea9
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.html
@@ -0,0 +1,62 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<nz-modal [nzVisible]="visible" nzTitle="Reset Password" nzCancelText="Close" nzOkText="Ok" (nzOnCancel)="hideModal()"
+          (nzOnOk)="submitForm()">
+  <form nz-form nzLayout="horizontal" [formGroup]="form">
+    <nz-form-item>
+      <nz-form-label>
+        Account Name
+      </nz-form-label>
+      <nz-form-control>
+        <input type="text" nz-input readonly formControlName="accountName"/>
+      </nz-form-control>
+    </nz-form-item>
+    <nz-form-item>
+      <nz-form-label nzRequired>
+        Password
+      </nz-form-label>
+      <nz-form-control nzHasFeedback nzErrorTip="Please input new password">
+        <input
+          nz-input
+          type="password"
+          formControlName="password"
+          (ngModelChange)="validateConfirmPassword()"
+        />
+      </nz-form-control>
+    </nz-form-item>
+    <nz-form-item>
+      <nz-form-label nzRequired>
+        Confirm Password
+      </nz-form-label>
+      <nz-form-control nzHasFeedback [nzErrorTip]="passwordErrorTpl">
+        <input type="password" nz-input formControlName="confirm"/>
+
+        <ng-template #passwordErrorTpl let-control>
+          <ng-container *ngIf="control.hasError('required')">
+            Please confirm your password!
+          </ng-container>
+          <ng-container *ngIf="control.hasError('confirm')">
+            Password is inconsistent!
+          </ng-container>
+        </ng-template>
+      </nz-form-control>
+    </nz-form-item>
+  </form>
+</nz-modal>
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.scss
similarity index 100%
copy from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.scss
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.ts
new file mode 100644
index 0000000..2478ff6
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user-password-modal/user-password-modal.component.ts
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
+
+@Component({
+  selector: 'submarine-user-password-modal',
+  templateUrl: './user-password-modal.component.html',
+  styleUrls: ['./user-password-modal.component.scss']
+})
+export class UserPasswordModalComponent implements OnChanges {
+  @Input() accountName: string;
+  @Input() visible: boolean;
+  @Output() readonly close: EventEmitter<any> = new EventEmitter();
+  @Output() readonly ok: EventEmitter<string> = new EventEmitter();
+  form: FormGroup;
+
+  constructor(private fb: FormBuilder) {
+    this.form = this.fb.group({
+      accountName: [''],
+      password: ['', Validators.required],
+      confirm: ['', this.confirmValidator]
+    });
+  }
+
+  ngOnChanges(changes: SimpleChanges) {
+    this.form.reset({
+      accountName: this.accountName,
+      password: '',
+      confirm: ''
+    });
+  }
+
+  confirmValidator = (control: FormControl): { [s: string]: boolean } => {
+    if (!control.value) {
+      return { error: true, required: true };
+    } else if (control.value !== this.form.controls.password.value) {
+      return { error: true, confirm: true };
+    }
+
+    return {};
+  };
+
+  validateConfirmPassword = () => {
+    setTimeout(() => {
+      this.form.controls.confirm.updateValueAndValidity();
+    });
+  };
+
+  hideModal() {
+    this.close.emit();
+  }
+
+  submitForm() {
+    for (const key in this.form.controls) {
+      this.form.controls[key].markAsDirty();
+      this.form.controls[key].updateValueAndValidity();
+    }
+
+    if (!this.form.valid) {
+      return;
+    }
+
+    this.ok.emit(this.form.value.password);
+  }
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.html
new file mode 100644
index 0000000..d13467a
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.html
@@ -0,0 +1,131 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<nz-card>
+  <div class="user-table-operate">
+    <form nz-form [nzLayout]="'inline'" [formGroup]="form">
+      <nz-form-item>
+        <nz-form-label>Department</nz-form-label>
+        <nz-form-control>
+          <nz-tree-select
+            style="width: 171px"
+            nzAllowClear
+            nzDefaultExpandAll
+            nzShowSearch
+            nzPlaceHolder="Please select"
+            formControlName="deptCode"
+            [nzNodes]="sysDeptTreeList"
+          ></nz-tree-select>
+        </nz-form-control>
+      </nz-form-item>
+      <nz-form-item>
+        <nz-form-label>Account Name</nz-form-label>
+        <nz-form-control>
+          <input nz-input formControlName="accountName"/>
+        </nz-form-control>
+      </nz-form-item>
+      <nz-form-item>
+        <nz-form-label>Email</nz-form-label>
+        <nz-form-control>
+          <input nz-input formControlName="email"/>
+        </nz-form-control>
+      </nz-form-item>
+      <nz-form-item>
+        <nz-form-control>
+          <button nz-button nzType="primary" (click)="queryUserList()">
+            <i nz-icon nzType="search"></i>
+            Query
+          </button>
+          <button nz-button style="margin-left: 8px" (click)="onShowUserDrawer()">
+            <i nz-icon nzType="plus"></i>
+            Add User
+          </button>
+        </nz-form-control>
+      </nz-form-item>
+    </form>
+  </div>
+
+  <nz-table #table [nzData]="userList" [nzScroll]="{ x: '1100px' }" nzNoResult="No result" nzBordered>
+    <thead>
+    <tr>
+      <th>Account Name</th>
+      <th>Real Name</th>
+      <th>Department</th>
+      <th>Role</th>
+      <th>Status</th>
+      <th>Sex</th>
+      <th>Email</th>
+      <th nzWidth="120px">Create Time</th>
+      <th nzWidth="120px" nzRight="0px">Action</th>
+    </tr>
+    </thead>
+    <tbody>
+    <tr *ngFor="let data of table.data">
+      <td>{{ data.userName }}</td>
+      <td>{{ data.realName }}</td>
+      <td>{{ data.deptName }}</td>
+      <td>{{ data.roleName }}</td>
+      <td>{{ data['status@dict'] }}</td>
+      <td>{{ data['sex@dict'] }}</td>
+      <td>{{ data.email }}</td>
+      <td>{{ data.createTime }}</td>
+      <td class="td-action" nzRight="0px">
+        <a (click)="onShowUserDrawer(data)">Edit</a>
+        <a nz-dropdown [nzDropdownMenu]="more">
+          More
+          <i nz-icon nzType="down"></i>
+        </a>
+        <nz-dropdown-menu #more="nzDropdownMenu">
+          <ul nz-menu nzSelectable>
+            <li nz-menu-item (click)="onShowUserDrawer(data, true)">Details</li>
+            <li nz-menu-item (click)="onShowResetPasswordModal(data)">Password</li>
+            <li
+              nz-menu-item
+              nz-popconfirm
+              nzPlacement="left"
+              nzTitle="Confirm to delete?"
+              nzCancelText="Cancel"
+              nzOkText="Ok"
+              (nzOnConfirm)="onDeleteUser(data)"
+            >
+              Delete
+            </li>
+          </ul>
+        </nz-dropdown-menu>
+      </td>
+    </tr>
+    </tbody>
+  </nz-table>
+</nz-card>
+
+<submarine-user-password-modal
+  [visible]="resetPasswordModalVisible"
+  [accountName]="currentSysUser?.userName"
+  (close)="onHideResetPasswordModal()"
+  (ok)="onChangePassword($event)"
+></submarine-user-password-modal>
+
+<submarine-user-drawer
+  [visible]="userDrawerVisible"
+  [readonly]="userDrawerReadonly"
+  [sysDeptTreeList]="sysDeptTreeList"
+  [sysUser]="currentSysUser"
+  (close)="onCloseUserDrawer()"
+  (submit)="onSubmitUserDrawer($event)"
+></submarine-user-drawer>
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.scss
similarity index 86%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.scss
index 510f082..937c4c8 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/user/user.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.scss
@@ -17,3 +17,14 @@
  * under the License.
  */
 
+.td-action a + a {
+  margin-left: 8px;
+}
+
+.user-table-operate {
+  margin-bottom: 16px;
+}
+
+nz-table td {
+  word-break: break-all;
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.ts
new file mode 100644
index 0000000..3c544e0
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/manager/user/user.component.ts
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from '@angular/core';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { SysUser } from '@submarine/interfaces';
+import { SysDeptSelect } from '@submarine/interfaces/sys-dept-select';
+import { DepartmentService, UserService } from '@submarine/services';
+import { NzMessageService } from 'ng-zorro-antd';
+
+@Component({
+  selector: 'submarine-manager-user',
+  templateUrl: './user.component.html',
+  styleUrls: ['./user.component.scss']
+})
+export class UserComponent implements OnInit {
+  column: string = 'createdTime';
+  order: string = 'desc';
+  field = [
+    'id',
+    'userName',
+    'realName',
+    'deptName',
+    'roleCode',
+    'status@dict',
+    'sex@dict',
+    'email',
+    'createTime',
+    'action'
+  ];
+  accountName: string = '';
+  email: string = '';
+  deptCode: string = '';
+  pageNo: number = 1;
+  pageSize: number = 10;
+  userList: SysUser[] = [];
+  total: number = 0;
+  sysDeptTreeList: SysDeptSelect[] = [];
+  form: FormGroup;
+  resetPasswordModalVisible: boolean = false;
+  currentSysUser: SysUser;
+  userDrawerVisible: boolean = false;
+  private userDrawerReadonly: boolean = false;
+
+  constructor(
+    private userService: UserService,
+    private deptService: DepartmentService,
+    private fb: FormBuilder,
+    private nzMessageService: NzMessageService
+  ) {
+  }
+
+  ngOnInit() {
+    this.fetchUserList();
+
+    this.deptService.fetchSysDeptSelect().subscribe(list => {
+      this.sysDeptTreeList = list;
+    });
+
+    this.form = this.fb.group({
+      deptCode: [this.deptCode],
+      accountName: [this.accountName],
+      email: [this.email]
+    });
+  }
+
+  queryUserList() {
+    const { deptCode, accountName, email } = this.form.getRawValue();
+    this.deptCode = deptCode;
+    this.accountName = accountName;
+    this.email = email;
+
+    this.fetchUserList();
+  }
+
+  fetchUserList() {
+    this.userService
+      .fetchUserList({
+        column: this.column,
+        order: this.order,
+        field: this.field.join(','),
+        accountName: this.accountName,
+        email: this.email,
+        deptCode: this.deptCode ? this.deptCode : '',
+        pageNo: '' + this.pageNo,
+        pageSize: '' + this.pageSize
+      })
+      .subscribe(({ records, total }) => {
+        this.total = total;
+        this.userList = records;
+      });
+  }
+
+  onShowResetPasswordModal(data: SysUser) {
+    this.currentSysUser = data;
+    this.resetPasswordModalVisible = true;
+  }
+
+  onHideResetPasswordModal() {
+    this.currentSysUser = null;
+    this.resetPasswordModalVisible = false;
+  }
+
+  onChangePassword(password: string) {
+    const { id } = this.currentSysUser;
+
+    this.resetPasswordModalVisible = false;
+
+    this.userService.changePassword(id, password).subscribe(() => {
+      this.nzMessageService.success('Change password success!');
+    }, () => {
+      this.nzMessageService.error('Change password error');
+    });
+  }
+
+  onShowUserDrawer(sysUser?: SysUser, readOnly = false) {
+    this.currentSysUser = sysUser;
+    this.userDrawerReadonly = readOnly;
+    this.userDrawerVisible = true;
+  }
+
+  onCloseUserDrawer() {
+    this.userDrawerVisible = false;
+  }
+
+  onSubmitUserDrawer(formData: Partial<SysUser>) {
+    this.userDrawerVisible = false;
+
+    if (formData.id) {
+      this.userService.updateUser(formData).subscribe(() => {
+        this.nzMessageService.success('Update user success!');
+        this.queryUserList();
+      }, err => {
+        this.nzMessageService.error(err.message);
+      });
+    } else {
+      this.userService.createUser(formData).subscribe(() => {
+        this.nzMessageService.success('Add user success!');
+        this.queryUserList();
+      }, err => {
+        this.nzMessageService.error(err.message);
+      });
+    }
+  }
+
+  onDeleteUser(data: SysUser) {
+    this.userService.deleteUser(data.id).subscribe(
+      () => {
+        this.nzMessageService.success('Delete user success!');
+        this.fetchUserList();
+      }, err => {
+        this.nzMessageService.success(err.message);
+      }
+    );
+  }
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench-routing.module.ts
similarity index 75%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench-routing.module.ts
index 97c775f..3c23d98 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager-routing.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench-routing.module.ts
@@ -19,29 +19,28 @@
 
 import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
-import { ManagerComponent } from './manager.component';
-import { UserComponent } from './user/user.component';
+import { WorkbenchComponent } from '@submarine/pages/workbench/workbench.component';
 
 const routes: Routes = [
   {
     path: '',
-    component: ManagerComponent,
+    component: WorkbenchComponent,
     children: [
       {
         path: '',
         pathMatch: 'full',
-        redirectTo: '/manager/user'
+        redirectTo: 'manager'
       },
       {
-        path: 'user',
-        component: UserComponent
+        path: 'manager',
+        loadChildren: () => import('./manager/manager.module').then(m => m.ManagerModule)
       }
     ]
   }
 ];
 
 @NgModule({
-  imports: [RouterModule.forChild(routes)],
-  exports: [RouterModule]
+  imports: [RouterModule.forChild(routes)]
 })
-export class ManagerRoutingModule {}
+export class WorkbenchRoutingModule {
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.html
new file mode 100644
index 0000000..f5b21c1
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.html
@@ -0,0 +1,82 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<nz-sider
+  class="menu-sidebar"
+  nzCollapsible
+  nzWidth="256px"
+  nzBreakpoint="md"
+  [(nzCollapsed)]="isCollapsed"
+  [nzTrigger]="null"
+>
+  <div class="sidebar-logo">
+    <a routerLink="/workbench/dashboard" target="_blank">
+      <img src="/assets/logo.png" alt="logo"/>
+      <h1>Submarine</h1>
+    </a>
+  </div>
+  <ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed">
+    <ng-container *ngFor="let menu of menus">
+      <li *ngIf="menu.children" nz-submenu nzOpen [nzTitle]="menu.title" [nzIcon]="menu.iconType">
+        <ul>
+          <li nz-menu-item nzMatchRouter *ngFor="let subItem of menu.children">
+            <a [routerLink]="subItem.routerLink">{{ subItem.title }}</a>
+          </li>
+        </ul>
+      </li>
+      <li *ngIf="!menu.children" nz-menu-item nz-tooltip nzPlacement="right" [nzTitle]="menu.title" nzMatchRouter>
+        <i nz-icon [nzType]="menu.iconType"></i>
+        <span>
+          <a [routerLink]="menu.routerLink">{{ menu.title }}</a>
+        </span>
+      </li>
+    </ng-container>
+  </ul>
+</nz-sider>
+<nz-layout [ngClass]="isCollapsed ? 'close' : ''">
+  <nz-header>
+    <div class="app-header">
+      <div class="header-trigger" (click)="isCollapsed = !isCollapsed">
+        <i class="trigger" nz-icon [nzType]="isCollapsed ? 'menu-unfold' : 'menu-fold'"></i>
+      </div>
+
+      <div class="header-operation">
+        <div nz-dropdown *ngIf="userInfo$ | async as userInfo" [nzDropdownMenu]="userInfoMenu"
+             class="operation-user-info">
+          <nz-avatar nzIcon="user" nzSize="small"></nz-avatar>
+          {{userInfo.name}}
+        </div>
+        <nz-dropdown-menu #userInfoMenu="nzDropdownMenu">
+          <ul nz-menu>
+            <li nz-menu-item nzDisabled>
+              <i nz-icon nzType="setting"></i>UserInfo setting
+            </li>
+            <li nz-menu-divider></li>
+            <li nz-menu-item (click)="logout()">
+              <i nz-icon nzType="logout"></i>Sign out
+            </li>
+          </ul>
+        </nz-dropdown-menu>
+      </div>
+    </div>
+  </nz-header>
+  <nz-content>
+    <router-outlet></router-outlet>
+  </nz-content>
+</nz-layout>
diff --git a/submarine-workbench/workbench-web-ng/src/app/app.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.scss
similarity index 78%
copy from submarine-workbench/workbench-web-ng/src/app/app.component.scss
copy to submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.scss
index 67bafe6..486c21f 100644
--- a/submarine-workbench/workbench-web-ng/src/app/app.component.scss
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.scss
@@ -24,23 +24,26 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
-.app-layout {
-  height: 100vh;
-}
-
 .menu-sidebar {
   position: relative;
   z-index: 10;
   min-height: 100vh;
-  box-shadow: 2px 0 6px rgba(0,21,41,.35);
+  box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
 }
 
 .header-trigger {
   height: 64px;
-  padding: 20px 24px;
   font-size: 20px;
   cursor: pointer;
-  transition: all .3s,padding 0s;
+  transition: all .3s, padding 0s;
+}
+
+.operation-user-info {
+  color: #333;
+}
+
+nz-avatar {
+  margin-right: 8px;
 }
 
 .trigger:hover {
@@ -69,8 +72,8 @@
   margin: 0 0 0 20px;
   color: #fff;
   font-weight: 600;
-  font-size: 14px;
-  font-family: Avenir,Helvetica Neue,Arial,Helvetica,sans-serif;
+  font-size: 20px;
+  font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
   vertical-align: middle;
 }
 
@@ -83,17 +86,22 @@ nz-header {
 .app-header {
   position: relative;
   height: 64px;
-  padding: 0;
+  padding: 0 24px;
   background: #fff;
-  box-shadow: 0 1px 4px rgba(0,21,41,.08);
+  box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
 }
 
 nz-content {
   margin: 24px;
 }
 
-.inner-content {
-  padding: 24px;
-  background: #fff;
-  height: 100%;
+nz-layout {
+  max-width: calc(100% - 256px);
+
+  &.close {
+    max-width: calc(100% - 80px);
+  }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.ts
new file mode 100644
index 0000000..62732e2
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.component.ts
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { UserInfo } from '@submarine/interfaces';
+import { AuthService, UserService } from '@submarine/services';
+import { NzNotificationService } from 'ng-zorro-antd';
+import { Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
+
+interface SidebarMenu {
+  title: string;
+  iconType: string;
+  routerLink?: string;
+  children?: Array<{
+    title: string;
+    routerLink?: string;
+  }>;
+}
+
+@Component({
+  selector: 'submarine-workbench',
+  templateUrl: './workbench.component.html',
+  styleUrls: ['./workbench.component.scss']
+})
+export class WorkbenchComponent implements OnInit {
+  isCollapsed: boolean = false;
+  menus: SidebarMenu[] = [
+    {
+      title: 'Manager',
+      iconType: 'setting',
+      children: [
+        {
+          title: 'User',
+          routerLink: '/workbench/manager/user'
+        },
+        {
+          title: 'Data dict',
+          routerLink: '/workbench/manager/data-dict'
+        }
+      ]
+    }
+  ];
+  userInfo$: Observable<UserInfo>;
+
+  constructor(
+    private router: Router,
+    private authService: AuthService,
+    private userService: UserService,
+    private nzNotificationService: NzNotificationService
+  ) {
+  }
+
+  ngOnInit() {
+    if (this.authService.isLoggedIn) {
+      this.userInfo$ = this.userService.fetchUserInfo().pipe(
+        tap(userInfo => {
+          this.nzNotificationService.success('Welcome', `Welcome back, ${userInfo.name}`);
+        })
+      );
+    }
+  }
+
+  logout() {
+    this.authService.logout().subscribe(isLogout => {
+      if (isLogout) {
+        this.router.navigate(['/user/login']);
+      }
+    });
+  }
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
similarity index 73%
rename from submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
rename to submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
index cfabafe..c49faca 100644
--- a/submarine-workbench/workbench-web-ng/src/app/pages/manager/manager.module.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/workbench.module.ts
@@ -19,13 +19,14 @@
 
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { WorkbenchRoutingModule } from '@submarine/pages/workbench/workbench-routing.module';
 import { NgZorroAntdModule } from 'ng-zorro-antd';
-import { ManagerRoutingModule } from './manager-routing.module';
-import { ManagerComponent } from './manager.component';
-import { UserComponent } from './user/user.component';
+import { WorkbenchComponent } from './workbench.component';
 
 @NgModule({
-  declarations: [UserComponent, ManagerComponent],
-  imports: [CommonModule, ManagerRoutingModule, NgZorroAntdModule]
+  declarations: [WorkbenchComponent],
+  imports: [CommonModule, WorkbenchRoutingModule, NgZorroAntdModule, RouterModule]
 })
-export class ManagerModule {}
+export class WorkbenchModule {
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/auth.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/auth.service.ts
index 1f3865a..099ec2f 100644
--- a/submarine-workbench/workbench-web-ng/src/app/services/auth.service.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/services/auth.service.ts
@@ -19,12 +19,12 @@
 
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
-import { Rest, User } from '@submarine/interfaces';
-import { BaseApiService } from '@submarine/services/base-api.service';
-import { LocalStorageService } from '@submarine/services/local-storage.service';
+import { Rest, SysUser } from '@submarine/interfaces';
 import * as md5 from 'md5';
-import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
+import { of, Observable } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
+import { BaseApiService } from './base-api.service';
+import { LocalStorageService } from './local-storage.service';
 
 @Injectable({
   providedIn: 'root'
@@ -45,26 +45,36 @@ export class AuthService {
     this.isLoggedIn = !!authToken;
   }
 
-  login(userForm: { userName: string; password: string }): Observable<boolean> {
-    return this.httpClient
-      .post<Rest<User>>(this.baseApi.getRestApi('/auth/login'), {
-        username: userForm.userName,
-        password: md5(userForm.password)
-      })
-      .pipe(
-        map(res => {
-          if (res.success) {
-            this.isLoggedIn = true;
-            this.localStorageService.set(this.authTokenKey, res.result.token);
-          }
+  login(userForm: { userName: string; password: string }): Observable<SysUser> {
+    const apiUrl = this.baseApi.getRestApi('/auth/login');
+    const params = {
+      username: userForm.userName,
+      password: md5(userForm.password)
+    };
 
-          return res.success;
-        })
-      );
+    return this.httpClient.post<Rest<SysUser>>(apiUrl, params).pipe(
+      switchMap(res => {
+        if (res.success) {
+          this.isLoggedIn = true;
+          this.localStorageService.set(this.authTokenKey, res.result.token);
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'post', params);
+        }
+      })
+    );
   }
 
-  logout(): void {
-    this.isLoggedIn = false;
-    this.localStorageService.remove(this.authTokenKey);
+  logout() {
+    return this.httpClient.post<Rest<boolean>>(this.baseApi.getRestApi('/auth/logout'), {}).pipe(
+      map(res => {
+        if (res.result) {
+          this.isLoggedIn = false;
+          this.localStorageService.remove(this.authTokenKey);
+        }
+
+        return res.result;
+      })
+    );
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts
index 076ab1f..86b2710 100644
--- a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts
@@ -18,6 +18,37 @@
  */
 
 import { Injectable } from '@angular/core';
+import { environment } from '@submarine/environment';
+
+class HttpError extends Error {
+  code: number;
+  url: string;
+  method: string;
+  params: object;
+
+  constructor(message: string, code: number, url?: string, method?: string, params?: object) {
+    super(message);
+
+    this.code = code;
+    this.url = url;
+    this.method = method;
+    this.params = params;
+
+    if (!environment.production) {
+      this.logError();
+    }
+  }
+
+  logError() {
+    console.group('Http error');
+    console.log('error message: ', this.message);
+    console.log('error code: ', this.code);
+    console.log('-------------------------------');
+    console.log('url: ', this.url);
+    console.log('method: ', this.method);
+    console.log('params: ', this.params);
+  }
+}
 
 @Injectable({
   providedIn: 'root'
@@ -52,6 +83,10 @@ export class BaseApiService {
     return `${this.getRestApiBase()}${str}`;
   }
 
+  createRequestError(message: string, code: number, url?: string, method?: string, params?: any) {
+    return new HttpError(message, code, url, method, params);
+  }
+
   private skipTrailingSlash(path) {
     return path.replace(/\/$/, '');
   }
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/department.service.ts
similarity index 52%
copy from submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts
copy to submarine-workbench/workbench-web-ng/src/app/services/department.service.ts
index 076ab1f..0872420 100644
--- a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/services/department.service.ts
@@ -17,42 +17,32 @@
  * under the License.
  */
 
+import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
+import { Rest } from '@submarine/interfaces';
+import { SysDeptSelect } from '@submarine/interfaces/sys-dept-select';
+import { of, Observable } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
+import { BaseApiService } from './base-api.service';
 
 @Injectable({
   providedIn: 'root'
 })
-export class BaseApiService {
-  baseApi: string;
+export class DepartmentService {
 
-  getPort() {
-    let port = Number(location.port);
-    if (!port) {
-      port = 80;
-      if (location.protocol === 'https:') {
-        port = 443;
-      }
-    }
-    return port;
+  constructor(private baseApi: BaseApiService, private httpClient: HttpClient) {
   }
 
-  getBase() {
-    return `${location.protocol}//${location.hostname}:${this.getPort()}`;
-  }
-
-  getRestApiBase() {
-    if (!this.baseApi) {
-      this.baseApi = this.skipTrailingSlash(this.getBase()) + '/api';
-    }
-
-    return this.baseApi;
-  }
-
-  getRestApi(str: string): string {
-    return `${this.getRestApiBase()}${str}`;
-  }
-
-  private skipTrailingSlash(path) {
-    return path.replace(/\/$/, '');
+  fetchSysDeptSelect(): Observable<SysDeptSelect[]> {
+    const apiUrl = this.baseApi.getRestApi('/sys/dept/queryIdTree');
+    return this.httpClient.get<Rest<SysDeptSelect[]>>(apiUrl).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'get');
+        }
+      })
+    );
   }
 }
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts b/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts
index a853cff..4bc5376 100644
--- a/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts
+++ b/submarine-workbench/workbench-web-ng/src/app/services/public-api.ts
@@ -18,3 +18,8 @@
  */
 
 export * from './auth.service';
+export * from './base-api.service';
+export * from './department.service';
+export * from './local-storage.service';
+export * from './system-utils.service';
+export * from './user.service';
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/system-utils.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/system-utils.service.ts
new file mode 100644
index 0000000..6046968
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/services/system-utils.service.ts
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ListResult, Rest } from '@submarine/interfaces';
+import { SysDictItem } from '@submarine/interfaces/sys-dict-item';
+import { BaseApiService } from './base-api.service';
+import { of, Observable } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
+
+export enum SysDictCode {
+  'USER_SEX' = 'SYS_USER_SEX',
+  'USER_STATUS' = 'SYS_USER_STATUS'
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class SystemUtilsService {
+  dictItemCache: { [s: string]: ListResult<any> } = {};
+
+  constructor(private httpClient: HttpClient, private baseApi: BaseApiService) {
+  }
+
+  fetchSysDictByCode(code: SysDictCode): Observable<ListResult<SysDictItem>> {
+    if (this.dictItemCache[code]) {
+      return of(this.dictItemCache[code]);
+    }
+
+    const apiUrl = `${this.baseApi.getRestApi('/sys/dictItem/getDictItems')}/${code}`;
+
+    return this.httpClient.get<Rest<ListResult<SysDictItem>>>(apiUrl).pipe(
+      switchMap(res => {
+        if (res.success) {
+          this.dictItemCache[code] = res.result;
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'get', code);
+        }
+      })
+    );
+  }
+
+  duplicateCheckUsername(userName: string, userId?: string) {
+    return this.duplicateCheck('sys_user', 'user_name', userName, userId);
+  }
+
+  duplicateCheckUserEmail(email: string, userId?: string) {
+    return this.duplicateCheck('sys_user', 'email', email, userId);
+  }
+
+  duplicateCheckUserPhone(phone: string, userId?: string) {
+    return this.duplicateCheck('sys_user', 'phone', phone, userId);
+  }
+
+  private duplicateCheck(
+    tableName: string,
+    fieldName: string,
+    fieldVal: string,
+    dataId?: string
+  ): Observable<boolean> {
+    const apiUrl = this.baseApi.getRestApi('/sys/duplicateCheck');
+    const params = {
+      tableName,
+      fieldName,
+      fieldVal,
+      dataId: dataId
+    };
+
+    return this.httpClient.get<Rest<string>>(apiUrl, {
+      params
+    }).pipe(
+      switchMap(res => {
+        return of(res.success);
+      })
+    );
+  }
+}
diff --git a/submarine-workbench/workbench-web-ng/src/app/services/user.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/user.service.ts
new file mode 100644
index 0000000..65f914d
--- /dev/null
+++ b/submarine-workbench/workbench-web-ng/src/app/services/user.service.ts
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ListResult, Rest, SysUser, UserInfo } from '@submarine/interfaces';
+import * as md5 from 'md5';
+import { of, Observable } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
+import { BaseApiService } from './base-api.service';
+
+interface UserListQueryParams {
+  accountName: string;
+  email: string;
+  deptCode: string;
+  column: string;
+  order: string;
+  field: string;
+  pageNo: string;
+  pageSize: string;
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {
+  private userInfo: UserInfo;
+
+  constructor(private httpClient: HttpClient, private baseApi: BaseApiService) {
+  }
+
+  fetchUserInfo(): Observable<UserInfo> {
+    const apiUrl = this.baseApi.getRestApi('/sys/user/info');
+    return this.httpClient.get<Rest<UserInfo>>(apiUrl).pipe(
+      switchMap(res => {
+        if (res.success) {
+          this.userInfo = new UserInfo(res.result);
+          return of(this.userInfo);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'get');
+        }
+      })
+    );
+  }
+
+  fetchUserList(queryParams: Partial<UserListQueryParams>): Observable<ListResult<SysUser>> {
+    const apiUrl = this.baseApi.getRestApi('/sys/user/list');
+    return this.httpClient.get<Rest<ListResult<SysUser>>>(apiUrl, {
+      params: queryParams
+    }).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'get', queryParams);
+        }
+      })
+    );
+  }
+
+  changePassword(id: string, password: string): Observable<boolean> {
+    const apiUrl = this.baseApi.getRestApi('/sys/user/changePassword');
+
+    return this.httpClient.put<Rest<any>>(apiUrl, {
+      id,
+      password: md5(password)
+    }).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(true);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'put', { id, password });
+        }
+      })
+    );
+  }
+
+  createUser(sysUser: Partial<SysUser>): Observable<SysUser> {
+    const apiUrl = this.baseApi.getRestApi('/sys/user/add');
+
+    return this.httpClient.post<Rest<SysUser>>(apiUrl, sysUser).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'post', sysUser);
+        }
+      })
+    );
+  }
+
+  updateUser(sysUser: Partial<SysUser>): Observable<SysUser> {
+    const apiUrl = this.baseApi.getRestApi('/sys/user/edit');
+
+    return this.httpClient.put<Rest<SysUser>>(apiUrl, sysUser).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(res.result);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'put', sysUser);
+        }
+      })
+    );
+  }
+
+  deleteUser(id: string): Observable<boolean> {
+    const apiUrl = this.baseApi.getRestApi(`/sys/user/delete`);
+
+    return this.httpClient.delete<Rest<any>>(apiUrl, {
+      params: {
+        id
+      }
+    }).pipe(
+      switchMap(res => {
+        if (res.success) {
+          return of(true);
+        } else {
+          throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'delete', id);
+        }
+      })
+    );
+  }
+}
diff --git a/submarine-workbench/workbench-web-ng/src/assets/logo.png b/submarine-workbench/workbench-web-ng/src/assets/logo.png
new file mode 100644
index 0000000..ff55ec2
Binary files /dev/null and b/submarine-workbench/workbench-web-ng/src/assets/logo.png differ
diff --git a/submarine-workbench/workbench-web-ng/src/main.ts b/submarine-workbench/workbench-web-ng/src/main.ts
index 6274974..4613957 100644
--- a/submarine-workbench/workbench-web-ng/src/main.ts
+++ b/submarine-workbench/workbench-web-ng/src/main.ts
@@ -27,5 +27,6 @@ if (environment.production) {
   enableProdMode();
 }
 
-platformBrowserDynamic().bootstrapModule(AppModule)
+platformBrowserDynamic()
+  .bootstrapModule(AppModule)
   .catch(err => console.error(err));
diff --git a/submarine-workbench/workbench-web-ng/src/polyfills.ts b/submarine-workbench/workbench-web-ng/src/polyfills.ts
index 15f6f9c..7f618f4 100644
--- a/submarine-workbench/workbench-web-ng/src/polyfills.ts
+++ b/submarine-workbench/workbench-web-ng/src/polyfills.ts
@@ -74,8 +74,7 @@
 /***************************************************************************************************
  * Zone JS is required by default for Angular itself.
  */
-import 'zone.js/dist/zone';  // Included with Angular CLI.
-
+import 'zone.js/dist/zone'; // Included with Angular CLI.
 
 /***************************************************************************************************
  * APPLICATION IMPORTS
diff --git a/submarine-workbench/workbench-web-ng/src/test.ts b/submarine-workbench/workbench-web-ng/src/test.ts
index b90b1d8..d9617db 100644
--- a/submarine-workbench/workbench-web-ng/src/test.ts
+++ b/submarine-workbench/workbench-web-ng/src/test.ts
@@ -21,18 +21,12 @@
 
 import 'zone.js/dist/zone-testing';
 import { getTestBed } from '@angular/core/testing';
-import {
-  BrowserDynamicTestingModule,
-  platformBrowserDynamicTesting
-} from '@angular/platform-browser-dynamic/testing';
+import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
 
 declare const require: any;
 
 // First, initialize the Angular testing environment.
-getTestBed().initTestEnvironment(
-  BrowserDynamicTestingModule,
-  platformBrowserDynamicTesting()
-);
+getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
 // Then we find all the tests.
 const context = require.context('./', true, /\.spec\.ts$/);
 // And load the modules.


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@submarine.apache.org
For additional commands, e-mail: dev-help@submarine.apache.org