Просмотр исходного кода

Merge pull request #484 from meolu/2.0/feature/permission

walle 2.0 permission :star:
huamanshu 6 лет назад
Родитель
Сommit
1afe0ab8d5
100 измененных файлов с 10816 добавлено и 51 удалено
  1. 2 2
      README.md
  2. 0 1
      admin.sh
  3. 1 0
      fe/.eslintignore
  4. 70 0
      fe/.eslintrc
  5. 18 0
      fe/.gitignore
  6. 1623 0
      fe/README.md
  7. BIN
      fe/avatar/.jpg
  8. 38 0
      fe/config/env.js
  9. 14 0
      fe/config/jest/cssTransform.js
  10. 12 0
      fe/config/jest/fileTransform.js
  11. 81 0
      fe/config/paths.js
  12. 16 0
      fe/config/polyfills.js
  13. 218 0
      fe/config/webpack.config.dev.js
  14. 250 0
      fe/config/webpack.config.prod.js
  15. BIN
      fe/favicon.ico
  16. 1 0
      fe/index.html
  17. 97 0
      fe/package.json
  18. BIN
      fe/public/avater/walle09.jpg
  19. BIN
      fe/public/favicon.ico
  20. 31 0
      fe/public/index.html
  21. 158 0
      fe/scripts/build.js
  22. 316 0
      fe/scripts/start.js
  23. 21 0
      fe/scripts/test.js
  24. 45 0
      fe/src/components/breadcrumb/WalleBreadCrumb.jsx
  25. 54 0
      fe/src/components/header/WalleHeader.jsx
  26. 7 0
      fe/src/components/header/style.css
  27. 42 0
      fe/src/components/menu/WalleMenu.jsx
  28. 7 0
      fe/src/components/page/HomePage.jsx
  29. 7 0
      fe/src/components/page/NotFountPage.jsx
  30. 44 0
      fe/src/containers/App.jsx
  31. 230 0
      fe/src/containers/page/EnvironmentListPage.jsx
  32. 248 0
      fe/src/containers/page/GroupListPage.jsx
  33. 230 0
      fe/src/containers/page/RoleListPage.jsx
  34. 275 0
      fe/src/containers/page/UserListPage.jsx
  35. 0 0
      fe/src/containers/panel/EnvironmentDetailsPanel.jsx
  36. 186 0
      fe/src/containers/panel/GroupDetailsPanel.jsx
  37. 105 0
      fe/src/containers/panel/RoleDetailsPanel.jsx
  38. 220 0
      fe/src/containers/panel/UserDetailsPanel.jsx
  39. 19 0
      fe/src/index.js
  40. 12 0
      fe/src/routes/config/index.js
  41. 19 0
      fe/src/routes/config/role/add/index.js
  42. 19 0
      fe/src/routes/config/role/edit/index.js
  43. 13 0
      fe/src/routes/config/role/index.js
  44. 19 0
      fe/src/routes/config/role/list/index.js
  45. 19 0
      fe/src/routes/config/user/add/index.js
  46. 19 0
      fe/src/routes/config/user/edit/index.js
  47. 13 0
      fe/src/routes/config/user/index.js
  48. 19 0
      fe/src/routes/config/user/list/index.js
  49. 0 0
      fe/src/routes/project/index.js
  50. 19 0
      fe/src/styles/App.css
  51. 20 0
      fe/src/styles/index.css
  52. 16 0
      fe/src/utils/fetch.js
  53. 1 0
      fe/static/css/app.cb846d34b23d6a18862bae3ff3a0166b.css
  54. BIN
      fe/static/css/app.cb846d34b23d6a18862bae3ff3a0166b.css.gz
  55. BIN
      fe/static/favicon.ico
  56. BIN
      fe/static/fonts/element-icons.6f0a763.ttf
  57. BIN
      fe/static/img/login-bg.23543c2.jpg
  58. BIN
      fe/static/img/welcom.af49de2.jpg
  59. 1 0
      fe/static/js/0.66d62c86fdd4dffbfa47.js
  60. 10 0
      fe/static/js/1.ec2b6425464010499973.js
  61. BIN
      fe/static/js/1.ec2b6425464010499973.js.gz
  62. 1 0
      fe/static/js/10.81153c92483c066e7a10.js
  63. 1 0
      fe/static/js/11.500397fabc9ca9ee9459.js
  64. 1 0
      fe/static/js/12.f24e7785f04042f67a43.js
  65. 1 0
      fe/static/js/13.c16e586de2ac95b46807.js
  66. 1 0
      fe/static/js/2.a03b85543b555322ee39.js
  67. BIN
      fe/static/js/2.a03b85543b555322ee39.js.gz
  68. 1 0
      fe/static/js/3.a6f9029be6570481d60a.js
  69. BIN
      fe/static/js/3.a6f9029be6570481d60a.js.gz
  70. 1 0
      fe/static/js/4.32a15687cee599140d42.js
  71. 1 0
      fe/static/js/5.428e2f91341880c00185.js
  72. 1 0
      fe/static/js/6.f6980112f3aea5d30bae.js
  73. 1 0
      fe/static/js/7.037953757bdcf1f06a43.js
  74. 1 0
      fe/static/js/8.358a956df46c943203b9.js
  75. 1 0
      fe/static/js/9.67749142c80327d9227a.js
  76. 1 0
      fe/static/js/app.196f94098e15976a42c9.js
  77. BIN
      fe/static/js/app.196f94098e15976a42c9.js.gz
  78. 1 0
      fe/static/js/manifest.b54b5e75bc33d32cbb82.js
  79. 39 0
      fe/static/js/vendor.d7347356ea72231c0e43.js
  80. BIN
      fe/static/js/vendor.d7347356ea72231c0e43.js.gz
  81. BIN
      fe/static/logo.png
  82. 5739 0
      fe/yarn.lock
  83. 1 1
      tests/test_08_api_task.py
  84. 1 1
      tests/test_models.py
  85. 7 0
      walle/api/api.py
  86. 2 2
      walle/api/deploy.py
  87. 5 4
      walle/api/environment.py
  88. 2 2
      walle/api/general.py
  89. 2 1
      walle/api/group.py
  90. 4 5
      walle/api/project.py
  91. 5 3
      walle/api/server.py
  92. 23 8
      walle/api/space.py
  93. 6 4
      walle/api/task.py
  94. 6 4
      walle/api/user.py
  95. 0 2
      walle/app.py
  96. 2 1
      walle/config/settings_dev.py
  97. 51 7
      walle/config/settings_prod.py
  98. 1 1
      walle/config/settings_test.py
  99. 1 2
      walle/form/environment.py
  100. 0 0
      walle/form/project.py

+ 2 - 2
README.md

@@ -4,7 +4,7 @@ Walle 2.0 - [官方主页](https://www.walle-web.io)
 =========================
 [![Build Status](https://travis-ci.org/meolu/walle-web.svg?branch=master)](https://travis-ci.org/meolu/walle-web)
 
-功能强大,且免费开源的`walle-web 瓦力`终于更新`2.0`了!占用了我几乎所有业余时间,望各位喜欢 `star` 以示感谢。目前 `2.0` 预览版尚未达到完全企业可用状态,请保持关注,我会在公众号更新(在最下面)。
+功能强大,且免费开源的`walle-web 瓦力`终于更新`2.0`了!占用了我几乎所有业余时间,望各位喜欢顺手 `star` 以示支持。目前 `2.0` 预览版尚未达到完全企业可用状态,请保持关注,我会在公众号更新(在最下面)。  
 老版本已迁移到 [walle 1.x](https://github.com/meolu/walle-web-v1.x) 的同学**务必不要再更新了**,两个版本不兼容。
 
 特性
@@ -43,7 +43,7 @@ Discussing
 
 新的惊喜
 =========================
-后续更新和解剖讨论将会放到公众号:walle-web,晨间除了写开源,也会写千字文,关注不迷路,谢谢:)
+后续更新和解剖讨论将会放到公众号:walle-web,晨间除了写开源,也会写千字文,关注不迷路:)
 
 <img src="https://raw.githubusercontent.com/meolu/walle-web/master/screenshot/wechat-gzh.jpg" width="244" height="314" alt="公众号 walle-web" />
 

+ 0 - 1
admin.sh

@@ -12,7 +12,6 @@ function start() {
     echo "start walle"
     echo "----------------"
     source ./venv/bin/activate
-    export FLASK_DEBUG=1
     nohup python $APP &
 }
  

+ 1 - 0
fe/.eslintignore

@@ -0,0 +1 @@
+./src/index.js

+ 70 - 0
fe/.eslintrc

@@ -0,0 +1,70 @@
+{
+    //文件名 .eslintrc.json
+    "env": {
+        "browser": true,
+        "es6": true,
+        "node": true,
+        "commonjs": true
+    },
+    "extends": "airbnb",
+    "installedESLint": true,
+    "parserOptions": {
+        "ecmaFeatures": {
+            "experimentalObjectRestSpread": true,
+            "jsx": true,
+            "arrowFunctions": true,
+            "classes": true,
+            "modules": true,
+            "defaultParams": true
+        },
+        "sourceType": "module"
+    },
+    "parser": "babel-eslint",
+    "plugins": [
+        "react"
+    ],
+    "rules": {
+        // 警告
+        "semi": 1,
+        "no-unused-vars": 1,
+        // 缩进调整为 4 空格,airbnb 为 2 空格
+        "indent": [2, 4, { "SwitchCase": 1 }],
+        "react/jsx-filename-extension": "off",
+        // 不禁用 console
+        "no-console": "off",
+        // 不强制函数表达式有名字
+        "func-names": "off",
+        // 不禁用 amd 的 require
+        "import/no-amd": "off",
+        // 不禁用 ES6 import 无法解析的模块
+        "import/no-unresolved": "off",
+        // 不强制全局 require
+        "global-require": "off",
+        // 由于是 4 空格缩进,将每行最大长度放宽到 240,airbnb 为 100
+        "max-len": [2, 240, 2, { "ignoreUrls": true, "ignoreComments": false }],
+        // 不禁止 for in 循环
+        "no-restricted-syntax": [2, "DebuggerStatement", "LabeledStatement", "WithStatement"],
+        // 不禁止使用下划线作为变量名前缀
+        "no-underscore-dangle": ["off"],
+        // 不强制 return
+        "consistent-return": ["off"],
+        // 不强制使用 => 代替函数
+        "prefer-arrow-callback": ["off"],
+        // 不强制使用 camel case 命名
+        "camelcase": ["error"],
+        // 允许修改参数
+        "no-param-reassign": ["off"],
+        // 不强制链式操作另起一行
+        "newline-per-chained-call": ["off"],
+        "new-parens": "off",
+        "arrow-parens": "off",
+        "no-plusplus": "off",
+        "no-mixed-operators": "off",
+        "import/extensions": "off",
+        "import/newline-after-import": "off",
+        "import/no-extraneous-dependencies": "off",
+        "import/no-dynamic-require": "off",
+        "import/imports-first": "off",
+        "no-unused-expressions": "off",
+    }
+}

+ 18 - 0
fe/.gitignore

@@ -0,0 +1,18 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+

Разница между файлами не показана из-за своего большого размера
+ 1623 - 0
fe/README.md


BIN
fe/avatar/.jpg


+ 38 - 0
fe/config/env.js

@@ -0,0 +1,38 @@
+'use strict';
+
+// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
+// injected into the application via DefinePlugin in Webpack configuration.
+
+var REACT_APP = /^REACT_APP_/i;
+
+function getClientEnvironment(publicUrl) {
+  var raw = Object
+    .keys(process.env)
+    .filter(key => REACT_APP.test(key))
+    .reduce((env, key) => {
+      env[key] = process.env[key];
+      return env;
+    }, {
+      // Useful for determining whether we’re running in production mode.
+      // Most importantly, it switches React into the correct mode.
+      'NODE_ENV': process.env.NODE_ENV || 'development',
+      // Useful for resolving the correct path to static assets in `public`.
+      // For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
+      // This should only be used as an escape hatch. Normally you would put
+      // images into the `src` and `import` them in code to get their paths.
+      'PUBLIC_URL': publicUrl
+    });
+  // Stringify all values so we can feed into Webpack DefinePlugin
+  var stringified = {
+    'process.env': Object
+      .keys(raw)
+      .reduce((env, key) => {
+        env[key] = JSON.stringify(raw[key]);
+        return env;
+      }, {})
+  };
+
+  return { raw, stringified };
+}
+
+module.exports = getClientEnvironment;

+ 14 - 0
fe/config/jest/cssTransform.js

@@ -0,0 +1,14 @@
+'use strict';
+
+// This is a custom Jest transformer turning style imports into empty objects.
+// http://facebook.github.io/jest/docs/tutorial-webpack.html
+
+module.exports = {
+  process() {
+    return 'module.exports = {};';
+  },
+  getCacheKey() {
+    // The output is always the same.
+    return 'cssTransform';
+  },
+};

+ 12 - 0
fe/config/jest/fileTransform.js

@@ -0,0 +1,12 @@
+'use strict';
+
+const path = require('path');
+
+// This is a custom Jest transformer turning file imports into filenames.
+// http://facebook.github.io/jest/docs/tutorial-webpack.html
+
+module.exports = {
+  process(src, filename) {
+    return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
+  },
+};

+ 81 - 0
fe/config/paths.js

@@ -0,0 +1,81 @@
+'use strict';
+
+var path = require('path');
+var fs = require('fs');
+var url = require('url');
+
+// Make sure any symlinks in the project folder are resolved:
+// https://github.com/facebookincubator/create-react-app/issues/637
+var appDirectory = fs.realpathSync(process.cwd());
+function resolveApp(relativePath) {
+  return path.resolve(appDirectory, relativePath);
+}
+
+// We support resolving modules according to `NODE_PATH`.
+// This lets you use absolute paths in imports inside large monorepos:
+// https://github.com/facebookincubator/create-react-app/issues/253.
+
+// It works similar to `NODE_PATH` in Node itself:
+// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
+
+// We will export `nodePaths` as an array of absolute paths.
+// It will then be used by Webpack configs.
+// Jest doesn’t need this because it already handles `NODE_PATH` out of the box.
+
+// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
+// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
+// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421
+
+var nodePaths = (process.env.NODE_PATH || '')
+  .split(process.platform === 'win32' ? ';' : ':')
+  .filter(Boolean)
+  .filter(folder => !path.isAbsolute(folder))
+  .map(resolveApp);
+
+var envPublicUrl = process.env.PUBLIC_URL;
+
+function ensureSlash(path, needsSlash) {
+  var hasSlash = path.endsWith('/');
+  if (hasSlash && !needsSlash) {
+    return path.substr(path, path.length - 1);
+  } else if (!hasSlash && needsSlash) {
+    return path + '/';
+  } else {
+    return path;
+  }
+}
+
+function getPublicUrl(appPackageJson) {
+  return envPublicUrl || require(appPackageJson).homepage;
+}
+
+// We use `PUBLIC_URL` environment variable or "homepage" field to infer
+// "public path" at which the app is served.
+// Webpack needs to know it to put the right <script> hrefs into HTML even in
+// single-page apps that may serve index.html for nested URLs like /todos/42.
+// We can't use a relative path in HTML because we don't want to load something
+// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
+function getServedPath(appPackageJson) {
+  var publicUrl = getPublicUrl(appPackageJson);
+  var servedUrl = envPublicUrl || (
+    publicUrl ? url.parse(publicUrl).pathname : '/'
+  );
+  return ensureSlash(servedUrl, true);
+}
+
+// config after eject: we're in ./config/
+module.exports = {
+  appBuild: resolveApp('build'),
+  appPublic: resolveApp('public'),
+  appHtml: resolveApp('public/index.html'),
+  appIndexJs: resolveApp('src/index.js'),
+  appPackageJson: resolveApp('package.json'),
+  appSrc: resolveApp('src'),
+  yarnLockFile: resolveApp('yarn.lock'),
+  testsSetup: resolveApp('src/setupTests.js'),
+  appNodeModules: resolveApp('node_modules'),
+  nodePaths: nodePaths,
+  publicUrl: getPublicUrl(resolveApp('package.json')),
+  servedPath: getServedPath(resolveApp('package.json')),
+  rootPath: path.join(__dirname, '../'),
+};

+ 16 - 0
fe/config/polyfills.js

@@ -0,0 +1,16 @@
+'use strict';
+
+if (typeof Promise === 'undefined') {
+  // Rejection tracking prevents a common issue where React gets into an
+  // inconsistent state due to an error, but it gets swallowed by a Promise,
+  // and the user has no idea what causes React's erratic future behavior.
+  require('promise/lib/rejection-tracking').enable();
+  window.Promise = require('promise/lib/es6-extensions.js');
+}
+
+// fetch() polyfill for making API calls.
+require('whatwg-fetch');
+
+// Object.assign() is commonly used with React.
+// It will use the native implementation if it's present and isn't buggy.
+Object.assign = require('object-assign');

+ 218 - 0
fe/config/webpack.config.dev.js

@@ -0,0 +1,218 @@
+'use strict';
+
+var autoprefixer = require('autoprefixer');
+var webpack = require('webpack');
+var HtmlWebpackPlugin = require('html-webpack-plugin');
+var CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
+var InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
+var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
+var getClientEnvironment = require('./env');
+var paths = require('./paths');
+
+
+
+// Webpack uses `publicPath` to determine where the app is being served from.
+// In development, we always serve from the root. This makes config easier.
+var publicPath = '/';
+// `publicUrl` is just like `publicPath`, but we will provide it to our app
+// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
+// Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
+var publicUrl = '';
+// Get environment variables to inject into our app.
+var env = getClientEnvironment(publicUrl);
+
+// This is the development configuration.
+// It is focused on developer experience and fast rebuilds.
+// The production configuration is different and lives in a separate file.
+module.exports = {
+  // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
+  // See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
+  devtool: 'cheap-module-source-map',
+  // These are the "entry points" to our application.
+  // This means they will be the "root" imports that are included in JS bundle.
+  // The first two entry points enable "hot" CSS and auto-refreshes for JS.
+  entry: [
+    // Include an alternative client for WebpackDevServer. A client's job is to
+    // connect to WebpackDevServer by a socket and get notified about changes.
+    // When you save a file, the client will either apply hot updates (in case
+    // of CSS changes), or refresh the page (in case of JS changes). When you
+    // make a syntax error, this client will display a syntax error overlay.
+    // Note: instead of the default WebpackDevServer client, we use a custom one
+    // to bring better experience for Create React App users. You can replace
+    // the line below with these two lines if you prefer the stock client:
+    // require.resolve('webpack-dev-server/client') + '?/',
+    // require.resolve('webpack/hot/dev-server'),
+    require.resolve('react-dev-utils/webpackHotDevClient'),
+    // We ship a few polyfills by default:
+    require.resolve('./polyfills'),
+    // Finally, this is your app's code:
+    paths.appIndexJs
+    // We include the app code last so that if there is a runtime error during
+    // initialization, it doesn't blow up the WebpackDevServer client, and
+    // changing JS code would still trigger a refresh.
+  ],
+  output: {
+    // Next line is not used in dev but WebpackDevServer crashes without it:
+    path: paths.appBuild,
+    // Add /* filename */ comments to generated require()s in the output.
+    pathinfo: true,
+    // This does not produce a real file. It's just the virtual path that is
+    // served by WebpackDevServer in development. This is the JS bundle
+    // containing code from all our entry points, and the Webpack runtime.
+    filename: 'static/js/bundle.js',
+    // This is the URL that app is served from. We use "/" in development.
+    publicPath: publicPath
+  },
+  resolve: {
+    // This allows you to set a fallback for where Webpack should look for modules.
+    // We read `NODE_PATH` environment variable in `paths.js` and pass paths here.
+    // We use `fallback` instead of `root` because we want `node_modules` to "win"
+    // if there any conflicts. This matches Node resolution mechanism.
+    // https://github.com/facebookincubator/create-react-app/issues/253
+    fallback: paths.nodePaths,
+    // These are the reasonable defaults supported by the Node ecosystem.
+    // We also include JSX as a common component filename extension to support
+    // some tools, although we do not recommend using it, see:
+    // https://github.com/facebookincubator/create-react-app/issues/290
+    extensions: ['.js', '.json', '.jsx', ''],
+    alias: {
+      // Support React Native Web
+      // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
+      'react-native': 'react-native-web',
+      components: `${paths.rootPath}src/components`,
+      containers: `${paths.rootPath}src/containers`,
+      styles: `${paths.rootPath}src/styles`,
+      utils: `${paths.rootPath}src/utils`,
+    }
+  },
+
+  module: {
+    // First, run the linter.
+    // It's important to do this before Babel processes the JS.
+    preLoaders: [
+      {
+        test: /\.(js|jsx)$/,
+        loader: 'eslint',
+        include: paths.appSrc,
+      }
+    ],
+    loaders: [
+      // ** ADDING/UPDATING LOADERS **
+      // The "url" loader handles all assets unless explicitly excluded.
+      // The `exclude` list *must* be updated with every change to loader extensions.
+      // When adding a new loader, you must add its `test`
+      // as a new entry in the `exclude` list for "url" loader.
+
+      // "url" loader embeds assets smaller than specified size as data URLs to avoid requests.
+      // Otherwise, it acts like the "file" loader.
+      {
+        exclude: [
+          /\.html$/,
+          // We have to write /\.(js|jsx)(\?.*)?$/ rather than just /\.(js|jsx)$/
+          // because you might change the hot reloading server from the custom one
+          // to Webpack's built-in webpack-dev-server/client?/, which would not
+          // get properly excluded by /\.(js|jsx)$/ because of the query string.
+          // Webpack 2 fixes this, but for now we include this hack.
+          // https://github.com/facebookincubator/create-react-app/issues/1713
+          /\.(js|jsx)(\?.*)?$/,
+          /\.css$/,
+          /\.json$/,
+          /\.svg$/
+        ],
+        loader: 'url',
+        query: {
+          limit: 10000,
+          name: 'static/media/[name].[hash:8].[ext]'
+        }
+      },
+      // Process JS with Babel.
+      {
+        test: /\.(js|jsx)$/,
+        include: paths.appSrc,
+        loader: 'babel',
+        query: {
+          plugins: [
+            ['import', [{ libraryName: "antd", style: 'css' }]],
+          ],
+
+          // This is a feature of `babel-loader` for webpack (not Babel itself).
+          // It enables caching results in ./node_modules/.cache/babel-loader/
+          // directory for faster rebuilds.
+          cacheDirectory: true
+        }
+      },
+      // "postcss" loader applies autoprefixer to our CSS.
+      // "css" loader resolves paths in CSS and adds assets as dependencies.
+      // "style" loader turns CSS into JS modules that inject <style> tags.
+      // In production, we use a plugin to extract that CSS to a file, but
+      // in development "style" loader enables hot editing of CSS.
+      {
+        test: /\.css$/,
+        loader: 'style!css?importLoaders=1!postcss'
+      },
+      // JSON is not enabled by default in Webpack but both Node and Browserify
+      // allow it implicitly so we also enable it.
+      {
+        test: /\.json$/,
+        loader: 'json'
+      },
+      // "file" loader for svg
+      {
+        test: /\.svg$/,
+        loader: 'file',
+        query: {
+          name: 'static/media/[name].[hash:8].[ext]'
+        }
+      }
+      // ** STOP ** Are you adding a new loader?
+      // Remember to add the new extension(s) to the "url" loader exclusion list.
+    ]
+  },
+
+  // We use PostCSS for autoprefixing only.
+  postcss: function() {
+    return [
+      autoprefixer({
+        browsers: [
+          '>1%',
+          'last 4 versions',
+          'Firefox ESR',
+          'not ie < 9', // React doesn't support IE8 anyway
+        ]
+      }),
+    ];
+  },
+  plugins: [
+    // Makes some environment variables available in index.html.
+    // The public URL is available as %PUBLIC_URL% in index.html, e.g.:
+    // <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    // In development, this will be an empty string.
+    new InterpolateHtmlPlugin(env.raw),
+    // Generates an `index.html` file with the <script> injected.
+    new HtmlWebpackPlugin({
+      inject: true,
+      template: paths.appHtml,
+    }),
+    // Makes some environment variables available to the JS code, for example:
+    // if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
+    new webpack.DefinePlugin(env.stringified),
+    // This is necessary to emit hot updates (currently CSS only):
+    new webpack.HotModuleReplacementPlugin(),
+    // Watcher doesn't work well if you mistype casing in a path so we use
+    // a plugin that prints an error when you attempt to do this.
+    // See https://github.com/facebookincubator/create-react-app/issues/240
+    new CaseSensitivePathsPlugin(),
+    // If you require a missing module and then `npm install` it, you still have
+    // to restart the development server for Webpack to discover it. This plugin
+    // makes the discovery automatic so you don't have to restart.
+    // See https://github.com/facebookincubator/create-react-app/issues/186
+    new WatchMissingNodeModulesPlugin(paths.appNodeModules)
+  ],
+  // Some libraries import Node modules but don't use them in the browser.
+  // Tell Webpack to provide empty mocks for them so importing them works.
+  node: {
+    fs: 'empty',
+    net: 'empty',
+    tls: 'empty'
+  }
+};

+ 250 - 0
fe/config/webpack.config.prod.js

@@ -0,0 +1,250 @@
+'use strict';
+
+var autoprefixer = require('autoprefixer');
+var webpack = require('webpack');
+var HtmlWebpackPlugin = require('html-webpack-plugin');
+var ExtractTextPlugin = require('extract-text-webpack-plugin');
+var ManifestPlugin = require('webpack-manifest-plugin');
+var InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
+var paths = require('./paths');
+var getClientEnvironment = require('./env');
+
+
+
+// Webpack uses `publicPath` to determine where the app is being served from.
+// It requires a trailing slash, or the file assets will get an incorrect path.
+var publicPath = paths.servedPath;
+// Some apps do not use client-side routing with pushState.
+// For these, "homepage" can be set to "." to enable relative asset paths.
+var shouldUseRelativeAssetPaths = publicPath === './';
+// `publicUrl` is just like `publicPath`, but we will provide it to our app
+// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
+// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
+var publicUrl = publicPath.slice(0, -1);
+// Get environment variables to inject into our app.
+var env = getClientEnvironment(publicUrl);
+
+// Assert this just to be safe.
+// Development builds of React are slow and not intended for production.
+if (env.stringified['process.env'].NODE_ENV !== '"production"') {
+  throw new Error('Production builds must have NODE_ENV=production.');
+}
+
+// Note: defined here because it will be used more than once.
+const cssFilename = 'static/css/[name].[contenthash:8].css';
+
+// ExtractTextPlugin expects the build output to be flat.
+// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
+// However, our output is structured with css, js and media folders.
+// To have this structure working with relative paths, we have to use custom options.
+const extractTextPluginOptions = shouldUseRelativeAssetPaths
+  // Making sure that the publicPath goes back to to build folder.
+  ? { publicPath: Array(cssFilename.split('/').length).join('../') }
+  : undefined;
+
+// This is the production configuration.
+// It compiles slowly and is focused on producing a fast and minimal bundle.
+// The development configuration is different and lives in a separate file.
+module.exports = {
+  // Don't attempt to continue if there are any errors.
+  bail: true,
+  // We generate sourcemaps in production. This is slow but gives good results.
+  // You can exclude the *.map files from the build during deployment.
+  devtool: 'source-map',
+  // In production, we only want to load the polyfills and the app code.
+  entry: [
+    require.resolve('./polyfills'),
+    paths.appIndexJs
+  ],
+  output: {
+    // The build folder.
+    path: paths.appBuild,
+    // Generated JS file names (with nested folders).
+    // There will be one main bundle, and one file per asynchronous chunk.
+    // We don't currently advertise code splitting but Webpack supports it.
+    filename: 'static/js/[name].[chunkhash:8].js',
+    chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
+    // We inferred the "public path" (such as / or /my-project) from homepage.
+    publicPath: publicPath
+  },
+  resolve: {
+    // This allows you to set a fallback for where Webpack should look for modules.
+    // We read `NODE_PATH` environment variable in `paths.js` and pass paths here.
+    // We use `fallback` instead of `root` because we want `node_modules` to "win"
+    // if there any conflicts. This matches Node resolution mechanism.
+    // https://github.com/facebookincubator/create-react-app/issues/253
+    fallback: paths.nodePaths,
+    // These are the reasonable defaults supported by the Node ecosystem.
+    // We also include JSX as a common component filename extension to support
+    // some tools, although we do not recommend using it, see:
+    // https://github.com/facebookincubator/create-react-app/issues/290
+    extensions: ['.js', '.json', '.jsx', ''],
+    alias: {
+      // Support React Native Web
+      // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
+      'react-native': 'react-native-web',
+      components: `../src/components`,
+    }
+  },
+  
+  module: {
+    // First, run the linter.
+    // It's important to do this before Babel processes the JS.
+    preLoaders: [
+      {
+        test: /\.(js|jsx)$/,
+        loader: 'eslint',
+        include: paths.appSrc
+      }
+    ],
+    loaders: [
+      // ** ADDING/UPDATING LOADERS **
+      // The "url" loader handles all assets unless explicitly excluded.
+      // The `exclude` list *must* be updated with every change to loader extensions.
+      // When adding a new loader, you must add its `test`
+      // as a new entry in the `exclude` list in the "url" loader.
+
+      // "url" loader embeds assets smaller than specified size as data URLs to avoid requests.
+      // Otherwise, it acts like the "file" loader.
+      {
+        exclude: [
+          /\.html$/,
+          /\.(js|jsx)$/,
+          /\.css$/,
+          /\.json$/,
+          /\.svg$/
+        ],
+        loader: 'url',
+        query: {
+          limit: 10000,
+          name: 'static/media/[name].[hash:8].[ext]'
+        }
+      },
+      // Process JS with Babel.
+      {
+        test: /\.(js|jsx)$/,
+        include: paths.appSrc,
+        loader: 'babel',
+        query: {
+          plugins: [
+            ['import', [{ libraryName: "antd", style: 'css' }]],
+          ],
+        }
+      },
+      // The notation here is somewhat confusing.
+      // "postcss" loader applies autoprefixer to our CSS.
+      // "css" loader resolves paths in CSS and adds assets as dependencies.
+      // "style" loader normally turns CSS into JS modules injecting <style>,
+      // but unlike in development configuration, we do something different.
+      // `ExtractTextPlugin` first applies the "postcss" and "css" loaders
+      // (second argument), then grabs the result CSS and puts it into a
+      // separate file in our build process. This way we actually ship
+      // a single CSS file in production instead of JS code injecting <style>
+      // tags. If you use code splitting, however, any async bundles will still
+      // use the "style" loader inside the async code so CSS from them won't be
+      // in the main CSS file.
+      {
+        test: /\.css$/,
+        loader: ExtractTextPlugin.extract(
+          'style',
+          'css?importLoaders=1!postcss',
+          extractTextPluginOptions
+        )
+        // Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
+      },
+      // JSON is not enabled by default in Webpack but both Node and Browserify
+      // allow it implicitly so we also enable it.
+      {
+        test: /\.json$/,
+        loader: 'json'
+      },
+      // "file" loader for svg
+      {
+        test: /\.svg$/,
+        loader: 'file',
+        query: {
+          name: 'static/media/[name].[hash:8].[ext]'
+        }
+      }
+      // ** STOP ** Are you adding a new loader?
+      // Remember to add the new extension(s) to the "url" loader exclusion list.
+    ]
+  },
+  
+  // We use PostCSS for autoprefixing only.
+  postcss: function() {
+    return [
+      autoprefixer({
+        browsers: [
+          '>1%',
+          'last 4 versions',
+          'Firefox ESR',
+          'not ie < 9', // React doesn't support IE8 anyway
+        ]
+      }),
+    ];
+  },
+  plugins: [
+    // Makes some environment variables available in index.html.
+    // The public URL is available as %PUBLIC_URL% in index.html, e.g.:
+    // <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    // In production, it will be an empty string unless you specify "homepage"
+    // in `package.json`, in which case it will be the pathname of that URL.
+    new InterpolateHtmlPlugin(env.raw),
+    // Generates an `index.html` file with the <script> injected.
+    new HtmlWebpackPlugin({
+      inject: true,
+      template: paths.appHtml,
+      minify: {
+        removeComments: true,
+        collapseWhitespace: true,
+        removeRedundantAttributes: true,
+        useShortDoctype: true,
+        removeEmptyAttributes: true,
+        removeStyleLinkTypeAttributes: true,
+        keepClosingSlash: true,
+        minifyJS: true,
+        minifyCSS: true,
+        minifyURLs: true
+      }
+    }),
+    // Makes some environment variables available to the JS code, for example:
+    // if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
+    // It is absolutely essential that NODE_ENV was set to production here.
+    // Otherwise React will be compiled in the very slow development mode.
+    new webpack.DefinePlugin(env.stringified),
+    // This helps ensure the builds are consistent if source hasn't changed:
+    new webpack.optimize.OccurrenceOrderPlugin(),
+    // Try to dedupe duplicated modules, if any:
+    new webpack.optimize.DedupePlugin(),
+    // Minify the code.
+    new webpack.optimize.UglifyJsPlugin({
+      compress: {
+        screw_ie8: true, // React doesn't support IE8
+        warnings: false
+      },
+      mangle: {
+        screw_ie8: true
+      },
+      output: {
+        comments: false,
+        screw_ie8: true
+      }
+    }),
+    // Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
+    new ExtractTextPlugin(cssFilename),
+    // Generate a manifest file which contains a mapping of all asset filenames
+    // to their corresponding output file so that tools can pick it up without
+    // having to parse `index.html`.
+    new ManifestPlugin({
+      fileName: 'asset-manifest.json'
+    })
+  ],
+  // Some libraries import Node modules but don't use them in the browser.
+  // Tell Webpack to provide empty mocks for them so importing them works.
+  node: {
+    fs: 'empty',
+    net: 'empty',
+    tls: 'empty'
+  }
+};

BIN
fe/favicon.ico


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/index.html


+ 97 - 0
fe/package.json

@@ -0,0 +1,97 @@
+{
+  "name": "fe",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "antd": "^2.8.2",
+    "config": "^1.26.1",
+    "nprogress": "^0.2.0",
+    "prop-types": "^15.5.10",
+    "react": "^15.4.2",
+    "react-dom": "^15.4.2",
+    "react-redux": "^5.0.4",
+    "react-router-dom": "^4.1.1",
+    "redux": "^3.6.0",
+    "reqwest": "^2.0.5",
+    "styles": "^0.2.1"
+  },
+  "devDependencies": {
+    "autoprefixer": "6.7.2",
+    "babel-core": "6.22.1",
+    "babel-eslint": "^7.2.3",
+    "babel-jest": "18.0.0",
+    "babel-loader": "6.2.10",
+    "babel-plugin-import": "^1.1.1",
+    "babel-preset-react-app": "^2.2.0",
+    "babel-runtime": "^6.20.0",
+    "case-sensitive-paths-webpack-plugin": "1.1.4",
+    "chalk": "1.1.3",
+    "connect-history-api-fallback": "1.3.0",
+    "cross-spawn": "4.0.2",
+    "css-loader": "0.26.1",
+    "detect-port": "1.1.0",
+    "dotenv": "2.0.0",
+    "eslint": "^3.19.0",
+    "eslint-config-airbnb": "^14.1.0",
+    "eslint-config-react-app": "^0.6.2",
+    "eslint-loader": "^1.6.0",
+    "eslint-plugin-flowtype": "2.21.0",
+    "eslint-plugin-import": "^2.2.0",
+    "eslint-plugin-jsx-a11y": "4.0.0",
+    "eslint-plugin-react": "^7.0.0",
+    "extract-text-webpack-plugin": "1.0.1",
+    "file-loader": "0.10.0",
+    "fs-extra": "0.30.0",
+    "html-webpack-plugin": "2.24.0",
+    "http-proxy-middleware": "0.17.3",
+    "jest": "18.1.0",
+    "json-loader": "0.5.4",
+    "object-assign": "4.1.1",
+    "postcss-loader": "1.2.2",
+    "promise": "7.1.1",
+    "react-dev-utils": "^0.5.2",
+    "style-loader": "0.13.1",
+    "url-loader": "0.5.7",
+    "webpack": "1.14.0",
+    "webpack-dev-server": "1.16.2",
+    "webpack-manifest-plugin": "1.1.0",
+    "whatwg-fetch": "2.0.2"
+  },
+  "scripts": {
+    "start": "node scripts/start.js",
+    "build": "node scripts/build.js",
+    "test": "node scripts/test.js --env=jsdom"
+  },
+  "jest": {
+    "collectCoverageFrom": [
+      "src/**/*.{js,jsx}"
+    ],
+    "setupFiles": [
+      "<rootDir>/config/polyfills.js"
+    ],
+    "testPathIgnorePatterns": [
+      "<rootDir>[/\\\\](build|docs|node_modules|scripts)[/\\\\]"
+    ],
+    "testEnvironment": "node",
+    "testURL": "http://localhost",
+    "transform": {
+      "^.+\\.(js|jsx)$": "<rootDir>/node_modules/babel-jest",
+      "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
+      "^(?!.*\\.(js|jsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
+    },
+    "transformIgnorePatterns": [
+      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
+    ],
+    "moduleNameMapper": {
+      "^react-native$": "react-native-web"
+    }
+  },
+  "babel": {
+    "presets": [
+      "react-app"
+    ]
+  },
+  "eslintConfig": {
+    "extends": "react-app"
+  }
+}

BIN
fe/public/avater/walle09.jpg


BIN
fe/public/favicon.ico


+ 31 - 0
fe/public/index.html

@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    <!--
+      Notice the use of %PUBLIC_URL% in the tag above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>Walle-瓦力</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start`.
+      To create a production bundle, use `npm run build`.
+    -->
+  </body>
+</html>

+ 158 - 0
fe/scripts/build.js

@@ -0,0 +1,158 @@
+'use strict';
+
+// Do this as the first thing so that any code reading it knows the right env.
+process.env.NODE_ENV = 'production';
+
+// Load environment variables from .env file. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set.
+// https://github.com/motdotla/dotenv
+require('dotenv').config({silent: true});
+
+var chalk = require('chalk');
+var fs = require('fs-extra');
+var path = require('path');
+var url = require('url');
+var webpack = require('webpack');
+var config = require('../config/webpack.config.prod');
+var paths = require('../config/paths');
+var checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
+var FileSizeReporter = require('react-dev-utils/FileSizeReporter');
+var measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild;
+var printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
+
+var useYarn = fs.existsSync(paths.yarnLockFile);
+
+// Warn and crash if required files are missing
+if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
+  process.exit(1);
+}
+
+// First, read the current file sizes in build directory.
+// This lets us display how much they changed later.
+measureFileSizesBeforeBuild(paths.appBuild).then(previousFileSizes => {
+  // Remove all content but keep the directory so that
+  // if you're in it, you don't end up in Trash
+  fs.emptyDirSync(paths.appBuild);
+
+  // Start the webpack build
+  build(previousFileSizes);
+
+  // Merge with the public folder
+  copyPublicFolder();
+});
+
+// Print out errors
+function printErrors(summary, errors) {
+  console.log(chalk.red(summary));
+  console.log();
+  errors.forEach(err => {
+    console.log(err.message || err);
+    console.log();
+  });
+}
+
+// Create the production build and print the deployment instructions.
+function build(previousFileSizes) {
+  console.log('Creating an optimized production build...');
+  webpack(config).run((err, stats) => {
+    if (err) {
+      printErrors('Failed to compile.', [err]);
+      process.exit(1);
+    }
+
+    if (stats.compilation.errors.length) {
+      printErrors('Failed to compile.', stats.compilation.errors);
+      process.exit(1);
+    }
+
+    if (process.env.CI && stats.compilation.warnings.length) {
+     printErrors('Failed to compile. When process.env.CI = true, warnings are treated as failures. Most CI servers set this automatically.', stats.compilation.warnings);
+     process.exit(1);
+   }
+
+    console.log(chalk.green('Compiled successfully.'));
+    console.log();
+
+    console.log('File sizes after gzip:');
+    console.log();
+    printFileSizesAfterBuild(stats, previousFileSizes);
+    console.log();
+
+    var appPackage  = require(paths.appPackageJson);
+    var publicUrl = paths.publicUrl;
+    var publicPath = config.output.publicPath;
+    var publicPathname = url.parse(publicPath).pathname;
+    if (publicUrl && publicUrl.indexOf('.github.io/') !== -1) {
+      // "homepage": "http://user.github.io/project"
+      console.log('The project was built assuming it is hosted at ' + chalk.green(publicPathname) + '.');
+      console.log('You can control this with the ' + chalk.green('homepage') + ' field in your '  + chalk.cyan('package.json') + '.');
+      console.log();
+      console.log('The ' + chalk.cyan('build') + ' folder is ready to be deployed.');
+      console.log('To publish it at ' + chalk.green(publicUrl) + ', run:');
+      // If script deploy has been added to package.json, skip the instructions
+      if (typeof appPackage.scripts.deploy === 'undefined') {
+        console.log();
+        if (useYarn) {
+          console.log('  ' + chalk.cyan('yarn') +  ' add --dev gh-pages');
+        } else {
+          console.log('  ' + chalk.cyan('npm') +  ' install --save-dev gh-pages');
+        }
+        console.log();
+        console.log('Add the following script in your ' + chalk.cyan('package.json') + '.');
+        console.log();
+        console.log('    ' + chalk.dim('// ...'));
+        console.log('    ' + chalk.yellow('"scripts"') + ': {');
+        console.log('      ' + chalk.dim('// ...'));
+        console.log('      ' + chalk.yellow('"predeploy"') + ': ' + chalk.yellow('"npm run build",'));
+        console.log('      ' + chalk.yellow('"deploy"') + ': ' + chalk.yellow('"gh-pages -d build"'));
+        console.log('    }');
+        console.log();
+        console.log('Then run:');
+      }
+      console.log();
+      console.log('  ' + chalk.cyan(useYarn ? 'yarn' : 'npm') +  ' run deploy');
+      console.log();
+    } else if (publicPath !== '/') {
+      // "homepage": "http://mywebsite.com/project"
+      console.log('The project was built assuming it is hosted at ' + chalk.green(publicPath) + '.');
+      console.log('You can control this with the ' + chalk.green('homepage') + ' field in your '  + chalk.cyan('package.json') + '.');
+      console.log();
+      console.log('The ' + chalk.cyan('build') + ' folder is ready to be deployed.');
+      console.log();
+    } else {
+      if (publicUrl) {
+        // "homepage": "http://mywebsite.com"
+        console.log('The project was built assuming it is hosted at ' + chalk.green(publicUrl) +  '.');
+        console.log('You can control this with the ' + chalk.green('homepage') + ' field in your '  + chalk.cyan('package.json') + '.');
+        console.log();
+      } else {
+        // no homepage
+        console.log('The project was built assuming it is hosted at the server root.');
+        console.log('To override this, specify the ' + chalk.green('homepage') + ' in your '  + chalk.cyan('package.json') + '.');
+        console.log('For example, add this to build it for GitHub Pages:')
+        console.log();
+        console.log('  ' + chalk.green('"homepage"') + chalk.cyan(': ') + chalk.green('"http://myname.github.io/myapp"') + chalk.cyan(','));
+        console.log();
+      }
+      var build = path.relative(process.cwd(), paths.appBuild);
+      console.log('The ' + chalk.cyan(build) + ' folder is ready to be deployed.');
+      console.log('You may serve it with a static server:');
+      console.log();
+      if (useYarn) {
+        console.log(`  ${chalk.cyan('yarn')} global add serve`);
+      } else {
+        console.log(`  ${chalk.cyan('npm')} install -g serve`);
+      }
+      console.log(`  ${chalk.cyan('serve')} -s build`);
+      console.log();
+    }
+  });
+}
+
+function copyPublicFolder() {
+  fs.copySync(paths.appPublic, paths.appBuild, {
+    dereference: true,
+    filter: file => file !== paths.appHtml
+  });
+}

+ 316 - 0
fe/scripts/start.js

@@ -0,0 +1,316 @@
+'use strict';
+
+process.env.NODE_ENV = 'development';
+
+// Load environment variables from .env file. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set.
+// https://github.com/motdotla/dotenv
+require('dotenv').config({silent: true});
+
+var chalk = require('chalk');
+var webpack = require('webpack');
+var WebpackDevServer = require('webpack-dev-server');
+var historyApiFallback = require('connect-history-api-fallback');
+var httpProxyMiddleware = require('http-proxy-middleware');
+var detect = require('detect-port');
+var clearConsole = require('react-dev-utils/clearConsole');
+var checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
+var formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
+var getProcessForPort = require('react-dev-utils/getProcessForPort');
+var openBrowser = require('react-dev-utils/openBrowser');
+var prompt = require('react-dev-utils/prompt');
+var fs = require('fs');
+var config = require('../config/webpack.config.dev');
+var paths = require('../config/paths');
+
+var useYarn = fs.existsSync(paths.yarnLockFile);
+var cli = useYarn ? 'yarn' : 'npm';
+var isInteractive = process.stdout.isTTY;
+
+// Warn and crash if required files are missing
+if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
+  process.exit(1);
+}
+
+// Tools like Cloud9 rely on this.
+var DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
+var compiler;
+var handleCompile;
+
+// You can safely remove this after ejecting.
+// We only use this block for testing of Create React App itself:
+var isSmokeTest = process.argv.some(arg => arg.indexOf('--smoke-test') > -1);
+if (isSmokeTest) {
+  handleCompile = function (err, stats) {
+    if (err || stats.hasErrors() || stats.hasWarnings()) {
+      process.exit(1);
+    } else {
+      process.exit(0);
+    }
+  };
+}
+
+function setupCompiler(host, port, protocol) {
+  // "Compiler" is a low-level interface to Webpack.
+  // It lets us listen to some events and provide our own custom messages.
+  compiler = webpack(config, handleCompile);
+
+  // "invalid" event fires when you have changed a file, and Webpack is
+  // recompiling a bundle. WebpackDevServer takes care to pause serving the
+  // bundle, so if you refresh, it'll wait instead of serving the old one.
+  // "invalid" is short for "bundle invalidated", it doesn't imply any errors.
+  compiler.plugin('invalid', function() {
+    if (isInteractive) {
+      clearConsole();
+    }
+    console.log('Compiling...');
+  });
+
+  var isFirstCompile = true;
+
+  // "done" event fires when Webpack has finished recompiling the bundle.
+  // Whether or not you have warnings or errors, you will get this event.
+  compiler.plugin('done', function(stats) {
+    if (isInteractive) {
+      clearConsole();
+    }
+
+    // We have switched off the default Webpack output in WebpackDevServer
+    // options so we are going to "massage" the warnings and errors and present
+    // them in a readable focused way.
+    var messages = formatWebpackMessages(stats.toJson({}, true));
+    var isSuccessful = !messages.errors.length && !messages.warnings.length;
+    var showInstructions = isSuccessful && (isInteractive || isFirstCompile);
+
+    if (isSuccessful) {
+      console.log(chalk.green('Compiled successfully!'));
+    }
+
+    if (showInstructions) {
+      console.log();
+      console.log('The app is running at:');
+      console.log();
+      console.log('  ' + chalk.cyan(protocol + '://' + host + ':' + port + '/'));
+      console.log();
+      console.log('Note that the development build is not optimized.');
+      console.log('To create a production build, use ' + chalk.cyan(cli + ' run build') + '.');
+      console.log();
+      isFirstCompile = false;
+    }
+
+    // If errors exist, only show errors.
+    if (messages.errors.length) {
+      console.log(chalk.red('Failed to compile.'));
+      console.log();
+      messages.errors.forEach(message => {
+        console.log(message);
+        console.log();
+      });
+      return;
+    }
+
+    // Show warnings if no errors were found.
+    if (messages.warnings.length) {
+      console.log(chalk.yellow('Compiled with warnings.'));
+      console.log();
+      messages.warnings.forEach(message => {
+        console.log(message);
+        console.log();
+      });
+      // Teach some ESLint tricks.
+      console.log('You may use special comments to disable some warnings.');
+      console.log('Use ' + chalk.yellow('// eslint-disable-next-line') + ' to ignore the next line.');
+      console.log('Use ' + chalk.yellow('/* eslint-disable */') + ' to ignore all warnings in a file.');
+    }
+  });
+}
+
+// We need to provide a custom onError function for httpProxyMiddleware.
+// It allows us to log custom error messages on the console.
+function onProxyError(proxy) {
+  return function(err, req, res){
+    var host = req.headers && req.headers.host;
+    console.log(
+      chalk.red('Proxy error:') + ' Could not proxy request ' + chalk.cyan(req.url) +
+      ' from ' + chalk.cyan(host) + ' to ' + chalk.cyan(proxy) + '.'
+    );
+    console.log(
+      'See https://nodejs.org/api/errors.html#errors_common_system_errors for more information (' +
+      chalk.cyan(err.code) + ').'
+    );
+    console.log();
+
+    // And immediately send the proper error response to the client.
+    // Otherwise, the request will eventually timeout with ERR_EMPTY_RESPONSE on the client side.
+    if (res.writeHead && !res.headersSent) {
+        res.writeHead(500);
+    }
+    res.end('Proxy error: Could not proxy request ' + req.url + ' from ' +
+      host + ' to ' + proxy + ' (' + err.code + ').'
+    );
+  }
+}
+
+function addMiddleware(devServer) {
+  // `proxy` lets you to specify a fallback server during development.
+  // Every unrecognized request will be forwarded to it.
+  var proxy = require(paths.appPackageJson).proxy;
+  devServer.use(historyApiFallback({
+    // Paths with dots should still use the history fallback.
+    // See https://github.com/facebookincubator/create-react-app/issues/387.
+    disableDotRule: true,
+    // For single page apps, we generally want to fallback to /index.html.
+    // However we also want to respect `proxy` for API calls.
+    // So if `proxy` is specified, we need to decide which fallback to use.
+    // We use a heuristic: if request `accept`s text/html, we pick /index.html.
+    // Modern browsers include text/html into `accept` header when navigating.
+    // However API calls like `fetch()` won’t generally accept text/html.
+    // If this heuristic doesn’t work well for you, don’t use `proxy`.
+    htmlAcceptHeaders: proxy ?
+      ['text/html'] :
+      ['text/html', '*/*']
+  }));
+  if (proxy) {
+    if (typeof proxy !== 'string') {
+      console.log(chalk.red('When specified, "proxy" in package.json must be a string.'));
+      console.log(chalk.red('Instead, the type of "proxy" was "' + typeof proxy + '".'));
+      console.log(chalk.red('Either remove "proxy" from package.json, or make it a string.'));
+      process.exit(1);
+    }
+
+    // Otherwise, if proxy is specified, we will let it handle any request.
+    // There are a few exceptions which we won't send to the proxy:
+    // - /index.html (served as HTML5 history API fallback)
+    // - /*.hot-update.json (WebpackDevServer uses this too for hot reloading)
+    // - /sockjs-node/* (WebpackDevServer uses this for hot reloading)
+    // Tip: use https://jex.im/regulex/ to visualize the regex
+    var mayProxy = /^(?!\/(index\.html$|.*\.hot-update\.json$|sockjs-node\/)).*$/;
+
+    // Pass the scope regex both to Express and to the middleware for proxying
+    // of both HTTP and WebSockets to work without false positives.
+    var hpm = httpProxyMiddleware(pathname => mayProxy.test(pathname), {
+      target: proxy,
+      logLevel: 'silent',
+      onProxyReq: function(proxyReq) {
+        // Browers may send Origin headers even with same-origin
+        // requests. To prevent CORS issues, we have to change
+        // the Origin to match the target URL.
+        if (proxyReq.getHeader('origin')) {
+          proxyReq.setHeader('origin', proxy);
+        }
+      },
+      onError: onProxyError(proxy),
+      secure: false,
+      changeOrigin: true,
+      ws: true,
+      xfwd: true
+    });
+    devServer.use(mayProxy, hpm);
+
+    // Listen for the websocket 'upgrade' event and upgrade the connection.
+    // If this is not done, httpProxyMiddleware will not try to upgrade until
+    // an initial plain HTTP request is made.
+    devServer.listeningApp.on('upgrade', hpm.upgrade);
+  }
+
+  // Finally, by now we have certainly resolved the URL.
+  // It may be /index.html, so let the dev server try serving it again.
+  devServer.use(devServer.middleware);
+}
+
+function runDevServer(host, port, protocol) {
+  var devServer = new WebpackDevServer(compiler, {
+    // Enable gzip compression of generated files.
+    compress: true,
+    // Silence WebpackDevServer's own logs since they're generally not useful.
+    // It will still show compile warnings and errors with this setting.
+    clientLogLevel: 'none',
+    // By default WebpackDevServer serves physical files from current directory
+    // in addition to all the virtual build products that it serves from memory.
+    // This is confusing because those files won’t automatically be available in
+    // production build folder unless we copy them. However, copying the whole
+    // project directory is dangerous because we may expose sensitive files.
+    // Instead, we establish a convention that only files in `public` directory
+    // get served. Our build script will copy `public` into the `build` folder.
+    // In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
+    // <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
+    // In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
+    // Note that we only recommend to use `public` folder as an escape hatch
+    // for files like `favicon.ico`, `manifest.json`, and libraries that are
+    // for some reason broken when imported through Webpack. If you just want to
+    // use an image, put it in `src` and `import` it from JavaScript instead.
+    contentBase: paths.appPublic,
+    // Enable hot reloading server. It will provide /sockjs-node/ endpoint
+    // for the WebpackDevServer client so it can learn when the files were
+    // updated. The WebpackDevServer client is included as an entry point
+    // in the Webpack development configuration. Note that only changes
+    // to CSS are currently hot reloaded. JS changes will refresh the browser.
+    hot: true,
+    // It is important to tell WebpackDevServer to use the same "root" path
+    // as we specified in the config. In development, we always serve from /.
+    publicPath: config.output.publicPath,
+    // WebpackDevServer is noisy by default so we emit custom message instead
+    // by listening to the compiler events with `compiler.plugin` calls above.
+    quiet: true,
+    // Reportedly, this avoids CPU overload on some systems.
+    // https://github.com/facebookincubator/create-react-app/issues/293
+    watchOptions: {
+      ignored: /node_modules/
+    },
+    // Enable HTTPS if the HTTPS environment variable is set to 'true'
+    https: protocol === "https",
+    host: host
+  });
+
+  // Our custom middleware proxies requests to /index.html or a remote API.
+  addMiddleware(devServer);
+
+  // Launch WebpackDevServer.
+  devServer.listen(port, err => {
+    if (err) {
+      return console.log(err);
+    }
+
+    if (isInteractive) {
+      clearConsole();
+    }
+    console.log(chalk.cyan('Starting the development server...'));
+    console.log();
+
+    openBrowser(protocol + '://' + host + ':' + port + '/');
+  });
+}
+
+function run(port) {
+  var protocol = process.env.HTTPS === 'true' ? "https" : "http";
+  var host = process.env.HOST || 'localhost';
+  setupCompiler(host, port, protocol);
+  runDevServer(host, port, protocol);
+}
+
+// We attempt to use the default port but if it is busy, we offer the user to
+// run on a different port. `detect()` Promise resolves to the next free port.
+detect(DEFAULT_PORT).then(port => {
+  if (port === DEFAULT_PORT) {
+    run(port);
+    return;
+  }
+
+  if (isInteractive) {
+    clearConsole();
+    var existingProcess = getProcessForPort(DEFAULT_PORT);
+    var question =
+      chalk.yellow('Something is already running on port ' + DEFAULT_PORT + '.' +
+        ((existingProcess) ? ' Probably:\n  ' + existingProcess : '')) +
+        '\n\nWould you like to run the app on another port instead?';
+
+    prompt(question, true).then(shouldChangePort => {
+      if (shouldChangePort) {
+        run(port);
+      }
+    });
+  } else {
+    console.log(chalk.red('Something is already running on port ' + DEFAULT_PORT + '.'));
+  }
+});

+ 21 - 0
fe/scripts/test.js

@@ -0,0 +1,21 @@
+'use strict';
+
+process.env.NODE_ENV = 'test';
+process.env.PUBLIC_URL = '';
+
+// Load environment variables from .env file. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set.
+// https://github.com/motdotla/dotenv
+require('dotenv').config({silent: true});
+
+const jest = require('jest');
+const argv = process.argv.slice(2);
+
+// Watch unless on CI or in coverage mode
+if (!process.env.CI && argv.indexOf('--coverage') < 0) {
+  argv.push('--watch');
+}
+
+
+jest.run(argv);

+ 45 - 0
fe/src/components/breadcrumb/WalleBreadCrumb.jsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Link,
+} from 'react-router-dom';
+import { Breadcrumb } from 'antd';
+
+const WalleBreadCrumb = ({
+    location,
+}) => {
+    let pathname = location.pathname;
+    let breads = pathname === '/' ? [] : ['/'];
+
+    while (pathname) {
+        breads.unshift(pathname);
+        pathname = pathname.replace(/\/[\w]*$/, '');
+    }
+    breads = breads.reverse();
+
+    return (
+      <Breadcrumb style={{ margin: '12px 0' }}>
+        {
+            breads.map((item, index) => {
+                const lastPath = item.match(/\/([\w]+)$/);
+                const text = lastPath ? lastPath[1] : 'Home';
+                return (
+                  <Breadcrumb.Item key={item}>
+                    {
+                        index === breads.length - 1 ? text : (
+                          <Link to={item}>{text}</Link>
+                        )
+                    }
+                  </Breadcrumb.Item>
+                );
+            })
+        }
+      </Breadcrumb>
+    );
+};
+
+WalleBreadCrumb.propTypes = {
+    location: PropTypes.shape().isRequired,
+};
+
+export default WalleBreadCrumb;

+ 54 - 0
fe/src/components/header/WalleHeader.jsx

@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Layout, Menu, Icon, Dropdown, Badge } from 'antd';
+import style from './style.css';
+
+const { Header } = Layout;
+
+const WalleHeader = ({
+    username,
+}) => {
+    const menu = (
+      <Menu>
+        <Menu.Item>
+          <a // eslint-disable-line
+            rel="noopener noreferrer" href="#"
+          >退出</a>
+        </Menu.Item>
+      </Menu>
+    );
+
+    return (
+      <Header className="header">
+        <div className="logo">
+          Walle 瓦力
+        </div>
+        <Menu
+          theme="dark"
+          mode="horizontal"
+          style={{ lineHeight: '64px' }}
+        >
+          <Dropdown overlay={menu} trigger={['click']}>
+            <a className={`${style.dropDown} ant-dropdown-link pull-right`}>
+              {username} <Icon type="down" />
+            </a>
+          </Dropdown>
+          <Menu.Item key="/message" style={{ float: 'right' }}>
+            <Badge dot>
+              <Icon type="message" />
+            </Badge>
+          </Menu.Item>
+        </Menu>
+      </Header>
+    );
+};
+
+WalleHeader.propTypes = {
+    username: PropTypes.string,
+};
+
+WalleHeader.defaultProps = {
+    username: '登录',
+};
+
+export default WalleHeader;

+ 7 - 0
fe/src/components/header/style.css

@@ -0,0 +1,7 @@
+:local .dropDown {
+  color:rgba(255, 255, 255, 0.67);
+}
+
+:local .dropDown:hover {
+  color: #fff;
+}

+ 42 - 0
fe/src/components/menu/WalleMenu.jsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Menu, Icon } from 'antd';
+import { Link } from 'react-router-dom';
+const { SubMenu } = Menu;
+
+const WalleMenu = ({
+  location,
+}) => {
+    const pathname = location.pathname;
+    return (
+      <Menu
+        mode="inline"
+        defaultOpenKeys={['config', 'project']}
+        defaultSelectedKeys={[pathname]}
+        style={{ height: '100%' }}
+      >
+        <SubMenu key="config" title={<span><Icon type="user" />用户中心</span>}>
+          <Menu.Item key="/user">
+            <Link to="/user">用户列表</Link>
+          </Menu.Item>
+          <Menu.Item key="/group">
+            <Link to="/group">用户组列表</Link>
+          </Menu.Item>
+          <Menu.Item key="/role">
+            <Link to="/role">角色列表</Link>
+          </Menu.Item>
+        </SubMenu>
+        <SubMenu key="project" title={<span><Icon type="laptop" />配置中心</span>}>
+          <Menu.Item key="/environment">
+            <Link to="/environment">环境管理</Link>
+          </Menu.Item>
+        </SubMenu>
+      </Menu>
+    );
+};
+
+WalleMenu.propTypes = {
+    location: PropTypes.shape().isRequired,
+};
+
+export default WalleMenu;

+ 7 - 0
fe/src/components/page/HomePage.jsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+export default () => (
+  <div>
+    Welcome
+  </div>
+);

+ 7 - 0
fe/src/components/page/NotFountPage.jsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+export default () => (
+  <div>
+    404 Not Found
+  </div>
+);

+ 44 - 0
fe/src/containers/App.jsx

@@ -0,0 +1,44 @@
+import React from 'react';
+import {
+  Route,
+  Switch,
+} from 'react-router-dom';
+import { Layout } from 'antd';
+import WalleHeader from 'components/header/WalleHeader';
+import WalleMenu from 'components/menu/WalleMenu';
+import WalleBreadCrumb from 'components/breadcrumb/WalleBreadCrumb';
+import HomePage from 'components/page/HomePage';
+import UserListPage from 'containers/page/UserListPage';
+import RoleListPage from 'containers/page/RoleListPage';
+import GroupListPage from 'containers/page/GroupListPage';
+import EnvironmentListPage from 'containers/page/EnvironmentListPage';
+import NotFountPage from 'components/page/NotFountPage';
+import 'styles/App.css';
+
+const { Content, Sider } = Layout;
+
+const App = () => (
+  <Layout>
+    <WalleHeader />
+    <Layout>
+      <Sider width={200} style={{ background: '#fff' }}>
+        <Route path="/" component={WalleMenu} />
+      </Sider>
+      <Layout style={{ padding: '0 24px 24px' }}>
+        <Route path="/" component={WalleBreadCrumb} />
+        <Content style={{ background: '#fff', padding: 24, margin: 0, minHeight: 280 }}>
+          <Switch>
+            <Route exact path="/" component={HomePage} />
+            <Route path="/user" component={UserListPage} />
+            <Route path="/role" component={RoleListPage} />
+            <Route path="/group" component={GroupListPage} />
+            <Route path="/environment" component={EnvironmentListPage} />
+            <Route component={NotFountPage} />
+          </Switch>
+        </Content>
+      </Layout>
+    </Layout>
+  </Layout>
+);
+
+export default App;

+ 230 - 0
fe/src/containers/page/EnvironmentListPage.jsx

@@ -0,0 +1,230 @@
+import React, { Component } from 'react';
+import {
+    Table,
+    Popconfirm,
+    Button,
+    message,
+    Input,
+} from 'antd';
+import fetch from 'utils/fetch';
+
+import EnvironmentDetailsPanel from '../panel/EnvironmentDetailsPanel';
+
+const Search = Input.Search;
+
+class EnvironmentListPage extends Component {
+
+    constructor(...args) {
+        super(...args);
+        this.columns = [{
+            title: 'id',
+            dataIndex: 'id',
+            key: 'id',
+        }, {
+            title: '环境名',
+            dataIndex: 'env_name',
+            key: 'env_name',
+        }, {
+            title: '状态',
+            dataIndex: 'status',
+            key: 'status',
+        }, {
+            title: '操作',
+            key: 'action',
+            render: (text, record) => (
+              <span>
+                <a // eslint-disable-line
+                  onClick={() => {
+                      this.showEnvironmentDetailsPanel(EnvironmentDetailsPanel.TYPE.edit, record.id);
+                  }}
+                >编辑</a>
+                <span className="ant-divider" />
+                <Popconfirm
+                  title={`确定删除${record.env_name}?`}
+                  okText="确定"
+                  cancelText="取消"
+                  onConfirm={() => {
+                      this.deleteEnvironment(record.id);
+                  }}
+                >
+                  <a // eslint-disable-line
+                    href="#"
+                  >删除</a>
+                </Popconfirm>
+              </span>
+            ),
+        }];
+    }
+
+    state = {
+        data: [],
+        loading: false,
+        pagination: {},
+        detailsPanel: {
+            visible: false,
+        },
+    }
+
+    componentWillMount() {
+        this.fetchEnvironmentList();
+    }
+
+    /**
+     * 获取环境列表
+     * @memberof EnvironmentListPage
+     */
+    fetchEnvironmentList = (params = {}) => {
+        this.setState({ loading: true });
+        fetch({
+            url: '/environment/',
+            data: {
+                ...params,
+                size: 10,
+            },
+        }).then(resp => {
+            const { data: { count, list } } = resp;
+            this.setState({
+                data: list,
+                loading: false,
+                pagination: {
+                    total: count,
+                },
+            });
+        });
+    }
+
+    /**
+     * 表格分页
+     * @memberof EnvironmentListPage
+     */
+    handleTableChange = (pagination) => {
+        const pager = { ...this.state.pagination };
+        pager.current = pagination.current;
+        this.setState({
+            pagination: pager,
+        });
+        this.fetchEnvironmentList({
+            page: pagination.current,
+        });
+    }
+
+    /**
+     * 添加环境
+     * @memberof EnvironmentListPage
+     */
+    createEnvironment = (env) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: '/environment/',
+            method: 'post',
+            data: env,
+        }).then(() => {
+            hide();
+            this.hideEnvironmentDetailsPanel();
+            this.fetchEnvironmentList();
+        }).catch(hide);
+    }
+
+    /**
+     * 更新环境
+     * @memberof EnvironmentListPage
+     */
+    updateEnvironment = (id, env) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/environment/${id}`,
+            method: 'put',
+            data: env,
+        }).then(() => {
+            hide();
+            message.success('已更新');
+            this.hideEnvironmentDetailsPanel();
+            this.fetchEnvironmentList();
+        }).catch(hide);
+    }
+
+    /**
+     * 删除环境
+     * @memberof EnvironmentListPage
+     */
+    deleteEnvironment = (id) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/environment/${id}`,
+            method: 'delete',
+        }).then(() => {
+            hide();
+            message.success('已删除');
+            this.fetchEnvironmentList();
+        }).catch(hide);
+    }
+
+    /**
+     * 展示环境面板
+     * @memberof EnvironmentListPage
+     */
+    showEnvironmentDetailsPanel = (type, id) => {
+        this.setState({
+            detailsPanel: {
+                visible: true,
+                type,
+                onSubmit: (env) => {
+                    this.updateEnvironment(id, env);
+                },
+                onCancel: this.hideEnvironmentDetailsPanel,
+                data: {},
+            },
+        });
+    }
+
+    /**
+     * 隐藏环境面板
+     * @memberof EnvironmentListPage
+     */
+    hideEnvironmentDetailsPanel = () => {
+        this.setState({
+            detailsPanel: {
+                visible: false,
+            },
+        });
+    }
+
+    render() {
+        const { data, pagination, loading, detailsPanel } = this.state;
+        return (
+          <div>
+            <div>
+              <Button
+                type="primary"
+                onClick={() => {
+                    this.showEnvironmentDetailsPanel(EnvironmentDetailsPanel.TYPE.add);
+                }}
+              >添加环境</Button>
+              <Search
+                placeholder="搜索环境"
+                style={{ width: 200, marginLeft: 20 }}
+                onSearch={value => this.fetchEnvironmentList({ kw: value })}
+              />
+            </div>
+            <br />
+            <div>
+              <Table
+                columns={this.columns}
+                rowKey={record => record.id}
+                dataSource={data}
+                pagination={pagination}
+                loading={loading}
+                onChange={this.handleTableChange}
+              />
+            </div>
+            {
+                detailsPanel.visible ? (
+                  <EnvironmentDetailsPanel {...detailsPanel} />
+                ) : ''
+            }
+          </div>
+        );
+    }
+}
+
+export default EnvironmentListPage;

+ 248 - 0
fe/src/containers/page/GroupListPage.jsx

@@ -0,0 +1,248 @@
+import React, { Component } from 'react';
+import {
+    Table,
+    Popconfirm,
+    Button,
+    message,
+    Input,
+} from 'antd';
+import fetch from 'utils/fetch';
+
+import GroupDetailsPanel from '../panel/GroupDetailsPanel';
+
+const Search = Input.Search;
+
+class GroupListPage extends Component {
+
+    constructor(...args) {
+        super(...args);
+        this.columns = [{
+            title: 'id',
+            dataIndex: 'id',
+            key: 'id',
+        }, {
+            title: '用户组名',
+            dataIndex: 'name',
+            key: 'name',
+        }, {
+            title: '用户数',
+            dataIndex: 'users',
+            key: 'users',
+        }, {
+            title: '操作',
+            key: 'action',
+            render: (text, record) => (
+              <span>
+                <a // eslint-disable-line
+                  onClick={() => {
+                      this.showGroupDetailsPanel(GroupDetailsPanel.TYPE.edit, record.id);
+                  }}
+                >编辑</a>
+                <span className="ant-divider" />
+                <Popconfirm
+                  title={`确定删除${record.name}?`}
+                  okText="确定"
+                  cancelText="取消"
+                  onConfirm={() => {
+                      this.deleteGroup(record.id);
+                  }}
+                >
+                  <a // eslint-disable-line
+                    href="#"
+                  >删除</a>
+                </Popconfirm>
+              </span>
+            ),
+        }];
+    }
+
+    state = {
+        data: [],
+        loading: false,
+        pagination: {},
+        detailsPanel: {
+            visible: false,
+        },
+    }
+
+    componentWillMount() {
+        this.fetchGroupList();
+    }
+
+    /**
+     * 获取用户组列表
+     * @memberof GroupListPage
+     */
+    fetchGroupList = (params = {}) => {
+        this.setState({ loading: true });
+        fetch({
+            url: '/group/',
+            data: {
+                ...params,
+                size: 10,
+            },
+        }).then(resp => {
+            const { data: { count, list } } = resp;
+            this.setState({
+                data: list,
+                loading: false,
+                pagination: {
+                    total: count,
+                },
+            });
+        });
+    }
+
+    /**
+     * 表格分页
+     * @memberof GroupListPage
+     */
+    handleTableChange = (pagination) => {
+        const pager = { ...this.state.pagination };
+        pager.current = pagination.current;
+        this.setState({
+            pagination: pager,
+        });
+        this.fetchGroupList({
+            page: pagination.current,
+        });
+    }
+
+    /**
+     * 添加用户组
+     * @memberof GroupListPage
+     */
+    createGroup = (group) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: '/group/',
+            method: 'post',
+            data: group,
+        }).then(() => {
+            hide();
+            this.hideGroupDetailsPanel();
+            this.fetchGroupList();
+        }).catch(hide);
+    }
+
+    /**
+     * 更新用户组
+     * @memberof GroupListPage
+     */
+    updateGroup = (id, group) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/group/${id}`,
+            method: 'put',
+            data: group,
+        }).then(() => {
+            hide();
+            message.success('已更新');
+            this.hideGroupDetailsPanel();
+            this.fetchGroupList();
+        }).catch(hide);
+    }
+
+    /**
+     * 删除用户组
+     * @memberof GroupListPage
+     */
+    deleteGroup = (id, group) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/group/${id}`,
+            method: 'delete',
+            data: group,
+        }).then(() => {
+            hide();
+            message.success('已删除');
+            this.fetchGroupList();
+        }).catch(hide);
+    }
+
+    /**
+     * 展示用户组面板
+     * @memberof GroupListPage
+     */
+    showGroupDetailsPanel = (type, id) => {
+        if (type === GroupDetailsPanel.TYPE.add) {
+            this.setState({
+                detailsPanel: {
+                    visible: true,
+                    type,
+                    onSubmit: (group) => {
+                        this.createGroup(id, group);
+                    },
+                    onCancel: this.hideGroupDetailsPanel,
+                },
+            });
+        } else {
+            const hide = message.loading('处理中...', 0);
+            fetch({ url: `/group/${id}` }).then(resp => {
+                hide();
+                this.setState({
+                    detailsPanel: {
+                        visible: true,
+                        type,
+                        onSubmit: (group) => {
+                            this.updateGroup(id, group);
+                        },
+                        onCancel: this.hideGroupDetailsPanel,
+                        data: resp.data,
+                    },
+                });
+            }).catch(hide);
+        }
+    }
+
+    /**
+     * 隐藏用户组面板
+     * @memberof GroupListPage
+     */
+    hideGroupDetailsPanel = () => {
+        this.setState({
+            detailsPanel: {
+                visible: false,
+            },
+        });
+    }
+
+    render() {
+        const { data, pagination, loading, detailsPanel } = this.state;
+        return (
+          <div>
+            <div>
+              <Button
+                type="primary"
+                onClick={() => {
+                    this.showGroupDetailsPanel(GroupDetailsPanel.TYPE.add);
+                }}
+              >添加用户组</Button>
+              <Search
+                placeholder="输入用户组名或邮箱"
+                style={{ width: 200, marginLeft: 20 }}
+                onSearch={value => this.fetchGroupList({ kw: value })}
+              />
+            </div>
+            <br />
+            <div>
+              <Table
+                columns={this.columns}
+                rowKey={record => record.id}
+                dataSource={data}
+                pagination={pagination}
+                loading={loading}
+                onChange={this.handleTableChange}
+              />
+            </div>
+            {
+                detailsPanel.visible ? (
+                  <GroupDetailsPanel {...detailsPanel} />
+                ) : ''
+            }
+          </div>
+        );
+    }
+}
+
+export default GroupListPage;

+ 230 - 0
fe/src/containers/page/RoleListPage.jsx

@@ -0,0 +1,230 @@
+import React, { Component } from 'react';
+import {
+    Table,
+    Popconfirm,
+    Button,
+    message,
+    Input,
+} from 'antd';
+import fetch from 'utils/fetch';
+
+import RoleDetailsPanel from '../panel/RoleDetailsPanel';
+
+const Search = Input.Search;
+
+class RoleListPage extends Component {
+
+    constructor(...args) {
+        super(...args);
+        this.columns = [{
+            title: 'id',
+            dataIndex: 'id',
+            key: 'id',
+        }, {
+            title: '角色名',
+            dataIndex: 'role_name',
+            key: 'role_name',
+        }, {
+            title: '用户数',
+            dataIndex: 'users',
+            key: 'users',
+        }, {
+            title: '操作',
+            key: 'action',
+            render: (text, record) => (
+              <span>
+                <a // eslint-disable-line
+                  onClick={() => {
+                      this.showRoleDetailsPanel(RoleDetailsPanel.TYPE.edit, record.id);
+                  }}
+                >编辑</a>
+                <span className="ant-divider" />
+                <Popconfirm
+                  title={`确定删除${record.role_name}?`}
+                  okText="确定"
+                  cancelText="取消"
+                  onConfirm={() => {
+                      this.deleteRole(record.id);
+                  }}
+                >
+                  <a // eslint-disable-line
+                    href="#"
+                  >删除</a>
+                </Popconfirm>
+              </span>
+            ),
+        }];
+    }
+
+    state = {
+        data: [],
+        loading: false,
+        pagination: {},
+        detailsPanel: {
+            visible: false,
+        },
+    }
+
+    componentWillMount() {
+        this.fetchRoleList();
+    }
+
+    /**
+     * 获取角色列表
+     * @memberof RoleListPage
+     */
+    fetchRoleList = (params = {}) => {
+        this.setState({ loading: true });
+        fetch({
+            url: '/role/',
+            data: {
+                ...params,
+                size: 10,
+            },
+        }).then(resp => {
+            const { data: { count, list } } = resp;
+            this.setState({
+                data: list,
+                loading: false,
+                pagination: {
+                    total: count,
+                },
+            });
+        });
+    }
+
+    /**
+     * 表格分页
+     * @memberof RoleListPage
+     */
+    handleTableChange = (pagination) => {
+        const pager = { ...this.state.pagination };
+        pager.current = pagination.current;
+        this.setState({
+            pagination: pager,
+        });
+        this.fetchRoleList({
+            page: pagination.current,
+        });
+    }
+
+    /**
+     * 添加角色
+     * @memberof RoleListPage
+     */
+    createRole = (role) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: '/role/',
+            method: 'post',
+            data: role,
+        }).then(() => {
+            hide();
+            this.hideRoleDetailsPanel();
+            this.fetchRoleList();
+        }).catch(hide);
+    }
+
+    /**
+     * 更新角色
+     * @memberof RoleListPage
+     */
+    updateRole = (id, role) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/role/${id}`,
+            method: 'put',
+            data: role,
+        }).then(() => {
+            hide();
+            message.success('已更新');
+            this.hideRoleDetailsPanel();
+            this.fetchRoleList();
+        }).catch(hide);
+    }
+
+    /**
+     * 删除角色
+     * @memberof RoleListPage
+     */
+    deleteRole = (id) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/role/${id}`,
+            method: 'delete',
+        }).then(() => {
+            hide();
+            message.success('已删除');
+            this.fetchRoleList();
+        }).catch(hide);
+    }
+
+    /**
+     * 展示角色面板
+     * @memberof RoleListPage
+     */
+    showRoleDetailsPanel = (type, id) => {
+        this.setState({
+            detailsPanel: {
+                visible: true,
+                type,
+                onSubmit: (role) => {
+                    this.updateRole(id, role);
+                },
+                onCancel: this.hideRoleDetailsPanel,
+                data: {},
+            },
+        });
+    }
+
+    /**
+     * 隐藏角色面板
+     * @memberof RoleListPage
+     */
+    hideRoleDetailsPanel = () => {
+        this.setState({
+            detailsPanel: {
+                visible: false,
+            },
+        });
+    }
+
+    render() {
+        const { data, pagination, loading, detailsPanel } = this.state;
+        return (
+          <div>
+            <div>
+              <Button
+                type="primary"
+                onClick={() => {
+                    this.showRoleDetailsPanel(RoleDetailsPanel.TYPE.add);
+                }}
+              >添加角色</Button>
+              <Search
+                placeholder="搜索角色"
+                style={{ width: 200, marginLeft: 20 }}
+                onSearch={value => this.fetchRoleList({ kw: value })}
+              />
+            </div>
+            <br />
+            <div>
+              <Table
+                columns={this.columns}
+                rowKey={record => record.id}
+                dataSource={data}
+                pagination={pagination}
+                loading={loading}
+                onChange={this.handleTableChange}
+              />
+            </div>
+            {
+                detailsPanel.visible ? (
+                  <RoleDetailsPanel {...detailsPanel} />
+                ) : ''
+            }
+          </div>
+        );
+    }
+}
+
+export default RoleListPage;

+ 275 - 0
fe/src/containers/page/UserListPage.jsx

@@ -0,0 +1,275 @@
+import React, { Component } from 'react';
+import {
+    Table,
+    Popconfirm,
+    Button,
+    message,
+    Input,
+} from 'antd';
+import fetch from 'utils/fetch';
+
+import UserDetailsPanel from '../panel/UserDetailsPanel';
+
+const Search = Input.Search;
+
+class UserListPage extends Component {
+
+    constructor(...args) {
+        super(...args);
+        this.columns = [{
+            title: 'id',
+            dataIndex: 'id',
+            key: 'id',
+        }, {
+            title: '用户名',
+            dataIndex: 'username',
+            key: 'username',
+        }, {
+            title: '角色',
+            dataIndex: 'role_name',
+            key: 'role_name',
+        }, {
+            title: '邮箱',
+            dataIndex: 'email',
+            key: 'email',
+        }, {
+            title: '状态',
+            dataIndex: 'status',
+            key: 'status',
+        }, {
+            title: '操作',
+            key: 'action',
+            render: (text, record) => (
+              <span>
+                <a // eslint-disable-line
+                  onClick={() => {
+                      this.showUserDetailsPanel(UserDetailsPanel.TYPE.edit, record.id);
+                  }}
+                >编辑</a>
+                <span className="ant-divider" />
+                <a // eslint-disable-line
+                  onClick={() => {
+                      this.changeUserStatus(record.id, record.status);
+                  }}
+                >{record.status === 0 ? '冻结' : '解冻'}</a>
+                <span className="ant-divider" />
+                <Popconfirm
+                  title={`确定删除${record.username}?`}
+                  okText="确定"
+                  cancelText="取消"
+                  onConfirm={() => {
+                      this.deleteUser(record.id);
+                  }}
+                >
+                  <a // eslint-disable-line
+                    href="#"
+                  >删除</a>
+                </Popconfirm>
+              </span>
+            ),
+        }];
+    }
+
+    state = {
+        data: [],
+        loading: false,
+        pagination: {},
+        detailsPanel: {
+            visible: false,
+        },
+    }
+
+    componentWillMount() {
+        this.fetchUserList();
+    }
+
+    /**
+     * 获取用户列表
+     * @memberof UserListPage
+     */
+    fetchUserList = (params = {}) => {
+        this.setState({ loading: true });
+        fetch({
+            url: '/user/',
+            data: {
+                ...params,
+                size: 10,
+            },
+        }).then(resp => {
+            const { data: { count, list } } = resp;
+            this.setState({
+                data: list,
+                loading: false,
+                pagination: {
+                    total: count,
+                },
+            });
+        });
+    }
+
+    /**
+     * 表格分页
+     * @memberof UserListPage
+     */
+    handleTableChange = (pagination) => {
+        const pager = { ...this.state.pagination };
+        pager.current = pagination.current;
+        this.setState({
+            pagination: pager,
+        });
+        this.fetchUserList({
+            page: pagination.current,
+        });
+    }
+
+    /**
+     * 添加用户
+     * @memberof UserListPage
+     */
+    createUser = (user) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: '/user/',
+            method: 'post',
+            data: user,
+        }).then(() => {
+            hide();
+            this.hideUserDetailsPanel();
+            this.fetchUserList();
+        }).catch(hide);
+    }
+
+    /**
+     * 更新用户
+     * @memberof UserListPage
+     */
+    updateUser = (id, user) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/user/${id}`,
+            method: 'put',
+            data: user,
+        }).then(() => {
+            hide();
+            message.success('已更新');
+            this.hideUserDetailsPanel();
+            this.fetchUserList();
+        }).catch(hide);
+    }
+
+    /**
+     * 删除用户
+     * @memberof UserListPage
+     */
+    deleteUser = (id, user) => {
+        const hide = message.loading('正在处理...', 0);
+        fetch({
+            url: `/user/${id}`,
+            method: 'delete',
+            data: user,
+        }).then(() => {
+            hide();
+            message.success('已删除');
+            this.fetchUserList();
+        }).catch(hide);
+    }
+
+    /**
+     * 展示用户面板
+     * @memberof UserListPage
+     */
+    showUserDetailsPanel = (type, id) => {
+        const hide = message.loading('处理中...', 0);
+        const roleFetch = fetch({ url: '/role/' });
+
+        if (type === UserDetailsPanel.TYPE.edit) {
+            const fetchs = [
+                fetch({ url: `/user/${id}` }),
+                roleFetch,
+            ];
+
+            Promise.all(fetchs).then(results => {
+                hide();
+                const [userResp, roleResp] = results;
+                this.setState({
+                    detailsPanel: {
+                        visible: true,
+                        type,
+                        onSubmit: (user) => {
+                            this.updateUser(id, user);
+                        },
+                        onCancel: this.hideUserDetailsPanel,
+                        data: userResp.data,
+                        roleList: roleResp.data.list,
+                    },
+                });
+            }).catch(hide);
+        } else {
+            roleFetch.then(resp => {
+                hide();
+                this.setState({
+                    detailsPanel: {
+                        visible: true,
+                        type,
+                        onSubmit: (user) => {
+                            this.createUser(user);
+                        },
+                        onCancel: this.hideUserDetailsPanel,
+                        roleList: resp.data.list,
+                    },
+                });
+            }).catch(hide);
+        }
+    }
+
+    /**
+     * 隐藏用户面板
+     * @memberof UserListPage
+     */
+    hideUserDetailsPanel = () => {
+        this.setState({
+            detailsPanel: {
+                visible: false,
+            },
+        });
+    }
+
+    render() {
+        const { data, pagination, loading, detailsPanel } = this.state;
+        return (
+          <div>
+            <div>
+              <Button
+                type="primary"
+                onClick={() => {
+                    this.showUserDetailsPanel(UserDetailsPanel.TYPE.add);
+                }}
+              >添加用户</Button>
+              <Search
+                placeholder="输入用户名或邮箱搜索"
+                style={{ width: 200, marginLeft: 20 }}
+                onSearch={value => this.fetchUserList({ kw: value })}
+              />
+            </div>
+            <br />
+            <div>
+              <Table
+                columns={this.columns}
+                rowKey={record => record.id}
+                dataSource={data}
+                pagination={pagination}
+                loading={loading}
+                onChange={this.handleTableChange}
+              />
+            </div>
+            {
+                detailsPanel.visible ? (
+                  <UserDetailsPanel {...detailsPanel} />
+                ) : ''
+            }
+          </div>
+        );
+    }
+}
+
+export default UserListPage;

+ 0 - 0
fe/src/containers/panel/EnvironmentDetailsPanel.jsx


+ 186 - 0
fe/src/containers/panel/GroupDetailsPanel.jsx

@@ -0,0 +1,186 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+    Modal,
+    Form,
+    Row,
+    Col,
+    Input,
+    Select,
+    Spin,
+    Icon,
+} from 'antd';
+import fetch from 'utils/fetch';
+import debounce from 'lodash.debounce';
+
+const FormItem = Form.Item;
+const Option = Select.Option;
+
+const TYPE = {
+    add: 'add',
+    edit: 'edit',
+};
+
+const getTitle = (type) => {
+    switch (type) {
+        case TYPE.add:
+            return '新增用户组';
+        case TYPE.edit:
+            return '编辑用户组';
+        default:
+            return '';
+    }
+};
+
+class Panel extends Component {
+
+    static propTypes = {
+        type: PropTypes.string.isRequired,
+        onCancel: PropTypes.func.isRequired,
+    };
+
+    static defaultProps = {
+        data: {},
+        roleList: [],
+    };
+
+    static TYPE = TYPE;
+
+    constructor(props) {
+        super(props);
+        this.fetchUser = debounce(this.fetchUser, 800);
+    }
+
+    state = {
+        searchKw: [],
+        searchResults: [],
+        searchFetching: false,
+        users: [
+            {
+                id: 1,
+                username: '用户一',
+            }, {
+                id: 2,
+                username: '用户二',
+            },
+        ],
+    };
+
+    fetchUser = (kw) => {
+        this.setState({ searchFetching: true });
+        fetch({
+            url: '/user/',
+            data: {
+                kw,
+            },
+        }).then(resp => {
+            const { data: { list } } = resp;
+            this.setState({
+                searchResults: list,
+                searchFetching: false,
+            });
+        });
+    }
+
+    handleSearchChange = (searchKw) => {
+        this.setState({
+            searchKw,
+            searchResults: [],
+            searchFetching: false,
+        });
+    }
+
+    addUser = ({ key, label }) => {
+        this.setState({
+            users: [
+                ...this.state.users,
+                {
+                    id: key,
+                    username: label,
+                },
+            ],
+        });
+    }
+
+    render() {
+        const { type, onCancel } = this.props;
+        const { searchFetching, searchResults, searchKw, users } = this.state;
+
+        const title = getTitle(type);
+
+        const formItemLayout = {
+            labelCol: { span: 5 },
+            wrapperCol: { span: 19 },
+        };
+        return (
+          <Modal
+            title={title}
+            visible
+            onOk={() => {
+
+            }}
+            onCancel={onCancel}
+          >
+            <Form
+              className="ant-advanced-search-form"
+            >
+              <Row>
+                <Col span={12}>
+                  <FormItem {...formItemLayout} label="用户组">
+                    <Input placeholder="用户组名称" />
+                  </FormItem>
+                </Col>
+                <Col span={11} offset={1}>
+                  <FormItem {...formItemLayout}>
+                    <Select
+                      mode="multiple"
+                      labelInValue
+                      value={searchKw}
+                      placeholder="搜索用户"
+                      notFoundContent={searchFetching ? <Spin size="small" /> : null}
+                      filterOption={false}
+                      onSearch={this.fetchUser}
+                      onChange={this.handleSearchChange}
+                      onSelect={this.addUser}
+                      style={{ width: '100%' }}
+                    >
+                      {searchResults.map(d => <Option key={d.id}>{d.username}</Option>)}
+                    </Select>
+                  </FormItem>
+                </Col>
+              </Row>
+              <Row className="user-group-wrapper">
+                {
+                  users.map((user, index) => (
+                    <Col
+                      key={user.id}
+                      span={8}
+                    >
+                      <i className="user-icon" />
+                      <p className="user-name">{user.username}</p>
+                      <a // eslint-disable-line
+                        className="delete-btn"
+                        onClick={() => {
+                            console.log(users.slice(0, index));
+                            console.log(users.slice(index));
+                            this.setState({
+                                users: [
+                                    ...users.slice(0, index),
+                                    ...users.slice(index + 1),
+                                ],
+                            })
+                        }}
+                      >
+                        <Icon type="minus-circle-o" style={{ color: 'red' }} />
+                      </a>
+                    </Col>
+                  ))
+                }
+              </Row>
+            </Form>
+          </Modal>
+        );
+    }
+}
+
+export default Panel;

+ 105 - 0
fe/src/containers/panel/RoleDetailsPanel.jsx

@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+    Modal,
+    Form,
+    Input,
+} from 'antd';
+const FormItem = Form.Item;
+
+const TYPE = {
+    add: 'add',
+    edit: 'edit',
+};
+
+const getTitle = (type) => {
+    switch (type) {
+        case TYPE.add:
+            return '新增用户';
+        case TYPE.edit:
+            return '编辑用户';
+        default:
+            return '';
+    }
+};
+
+const Panel = ({
+    type,
+    onSubmit,
+    onCancel,
+    form,
+    data,
+}) => {
+    const title = getTitle(type);
+    const { getFieldDecorator } = form;
+
+    const formItemLayout = {
+        labelCol: {
+            xs: { span: 24 },
+            sm: { span: 6 },
+        },
+        wrapperCol: {
+            xs: { span: 24 },
+            sm: { span: 14 },
+        },
+    };
+
+    return (
+      <Modal
+        title={title}
+        visible
+        onOk={() => {
+            form.validateFieldsAndScroll((err, values) => {
+                if (!err) {
+                    onSubmit(values);
+                }
+            });
+        }}
+        onCancel={onCancel}
+      >
+        <Form>
+          {
+              type === TYPE.edit ? (
+                <FormItem
+                  {...formItemLayout}
+                  label="id"
+                >
+                  <span className="ant-form-text">{data.id}</span>
+                </FormItem>
+              ) : ''
+          }
+          <FormItem
+            {...formItemLayout}
+            label="角色名"
+            hasFeedback
+          >
+            {getFieldDecorator('name', {
+                rules: [{
+                    required: true, message: '请输入角色名',
+                }],
+                initialValue: data.name,
+            })(
+              <Input placeholder="请输入角色名" />,
+            )}
+          </FormItem>
+        </Form>
+      </Modal>
+    );
+};
+
+Panel.propTypes = {
+    type: PropTypes.string.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onCancel: PropTypes.func.isRequired,
+    form: PropTypes.shape().isRequired,
+    data: PropTypes.shape(),
+};
+
+Panel.defaultProps = {
+    data: {},
+};
+
+const RoleDetailsPanel = Form.create()(Panel);
+RoleDetailsPanel.TYPE = TYPE;
+
+export default RoleDetailsPanel;

+ 220 - 0
fe/src/containers/panel/UserDetailsPanel.jsx

@@ -0,0 +1,220 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+    Modal,
+    Form,
+    Input,
+    Select,
+    Row,
+    Col,
+    Button,
+} from 'antd';
+const FormItem = Form.Item;
+const Option = Select.Option;
+
+const TYPE = {
+    add: 'add',
+    edit: 'edit',
+};
+
+const getTitle = (type) => {
+    switch (type) {
+        case TYPE.add:
+            return '新增用户';
+        case TYPE.edit:
+            return '编辑用户';
+        default:
+            return '';
+    }
+};
+
+const Panel = ({
+    type,
+    onSubmit,
+    onCancel,
+    form,
+    data,
+    roleList,
+}) => {
+    const title = getTitle(type);
+    const { getFieldDecorator } = form;
+
+    const formItemLayout = {
+        labelCol: {
+            xs: { span: 24 },
+            sm: { span: 6 },
+        },
+        wrapperCol: {
+            xs: { span: 24 },
+            sm: { span: 14 },
+        },
+    };
+
+    /**
+     * 随机生成密码
+     */
+    const createRandomPassword = () => {
+        const chars = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_@*!';
+        let password = '';
+        while (password.length < 35) {
+            password += chars[Math.floor(Math.random() * chars.length)];
+        }
+        if (!/\d/.test(password)) {
+            /**
+             * 密码必须含有数字,如果不含有,随机选取一个字符,将其替换成一个随机数字
+             */
+            const letter = password[Math.floor(Math.random() * password.length)];
+            password = password.replace(letter, Math.floor(Math.random() * 10));
+        }
+        if (!/[a-zA-Z]/.test(password)) {
+            /**
+             * 密码必须含有字母,如果不含有,随机选取一个数字,将其替换成一个随机字母
+             */
+            const number = password[Math.floor(Math.random() * password.length)];
+            password = password.replace(number, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 52)]);
+        }
+        form.setFieldsValue({ password });
+    };
+
+    return (
+      <Modal
+        title={title}
+        visible
+        onOk={() => {
+            form.validateFieldsAndScroll((err, values) => {
+                if (!err) {
+                    onSubmit(values);
+                }
+            });
+        }}
+        onCancel={onCancel}
+      >
+        <Form>
+          {
+              type === TYPE.edit ? (
+                <FormItem
+                  {...formItemLayout}
+                  label="id"
+                >
+                  <span className="ant-form-text">{data.id}</span>
+                </FormItem>
+              ) : ''
+          }
+          <FormItem
+            {...formItemLayout}
+            label="用户名"
+            hasFeedback
+          >
+            {getFieldDecorator('username', {
+                rules: [{
+                    required: true, message: '请输入用户名',
+                }],
+                initialValue: data.username,
+            })(
+              <Input placeholder="请输入用户名" />,
+            )}
+          </FormItem>
+          <FormItem
+            {...formItemLayout}
+            label="密码"
+          >
+            <Row gutter={8}>
+              <Col span={16}>
+                {getFieldDecorator('password', {
+                    rules: [{
+                        required: type === TYPE.add,
+                        message: '请输入密码',
+                    }, {
+                        min: 6,
+                        max: 35,
+                        message: '密码长度范围为 8-35',
+                    }],
+                })(
+                  <Input placeholder="请输入密码" />,
+                )}
+              </Col>
+              <Col span={8}>
+                <Button
+                  size="large"
+                  onClick={createRandomPassword}
+                >随机生成</Button>
+              </Col>
+            </Row>
+          </FormItem>
+          <FormItem
+            {...formItemLayout}
+            label="E-mail"
+            hasFeedback
+          >
+            {getFieldDecorator('email', {
+                rules: [{
+                    type: 'email', message: '请输入合法的邮箱',
+                }, {
+                    required: true, message: '请输入邮箱',
+                }],
+                initialValue: data.email,
+            })(
+              <Input placeholder="请输入邮箱" disabled={type === TYPE.edit} />,
+            )}
+          </FormItem>
+          <FormItem
+            {...formItemLayout}
+            label="角色"
+            hasFeedback
+          >
+            {getFieldDecorator('role_id', {
+                rules: [
+                { required: true, message: '请选择角色' },
+                ],
+                initialValue: data.role_id ? `${data.role_id}` : undefined,
+            })(
+              <Select placeholder="请选择角色">
+                {
+                    roleList.map((role) => (
+                      <Option key={role.id} value={`${role.id}`}>{role.role_name}</Option>
+                    ))
+                }
+              </Select>,
+            )}
+          </FormItem>
+          {
+              type === TYPE.edit ? (
+                <div>
+                  <FormItem
+                    {...formItemLayout}
+                    label="创建时间"
+                  >
+                    <span className="ant-form-text">{data.created_at}</span>
+                  </FormItem>
+                  <FormItem
+                    {...formItemLayout}
+                    label="修改时间"
+                  >
+                    <span className="ant-form-text">{data.updated_at}</span>
+                  </FormItem>
+                </div>
+              ) : ''
+          }
+        </Form>
+      </Modal>
+    );
+};
+
+Panel.propTypes = {
+    type: PropTypes.string.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onCancel: PropTypes.func.isRequired,
+    form: PropTypes.shape().isRequired,
+    data: PropTypes.shape(),
+    roleList: PropTypes.arrayOf(PropTypes.object),
+};
+
+Panel.defaultProps = {
+    data: {},
+    roleList: [],
+};
+
+const UserDetailsPanel = Form.create()(Panel);
+UserDetailsPanel.TYPE = TYPE;
+
+export default UserDetailsPanel;

+ 19 - 0
fe/src/index.js

@@ -0,0 +1,19 @@
+import React from 'react';
+import {
+  BrowserRouter as Router,
+} from 'react-router-dom';
+
+import { render } from 'react-dom';
+
+import App from 'containers/App';
+
+import 'styles/index.css';
+
+render(
+  (
+    <Router>
+      <App />
+    </Router>
+  ),
+  document.getElementById('root'),
+);

+ 12 - 0
fe/src/routes/config/index.js

@@ -0,0 +1,12 @@
+module.exports = {
+    path: 'config',
+
+    getChildRoutes(partialNextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, [
+                require('./role'),
+                require('./user'),
+            ]);
+        });
+    },
+};

+ 19 - 0
fe/src/routes/config/role/add/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'add',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/RoleDetailsPanel').default);
+        }, 'roleDetailsPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 19 - 0
fe/src/routes/config/role/edit/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'edit',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/RoleDetailsPanel').default);
+        }, 'roleDetailsPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 13 - 0
fe/src/routes/config/role/index.js

@@ -0,0 +1,13 @@
+module.exports = {
+    path: 'role',
+
+    getChildRoutes(partialNextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, [
+                require('./add'),
+                require('./edit'),
+                require('./list'),
+            ]);
+        });
+    },
+};

+ 19 - 0
fe/src/routes/config/role/list/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'list',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/RoleListPanel').default);
+        }, 'roleListPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 19 - 0
fe/src/routes/config/user/add/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'add',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/UserDetailsPanel').default);
+        }, 'userDetailsPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 19 - 0
fe/src/routes/config/user/edit/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'edit',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/UserDetailsPanel').default);
+        }, 'userDetailsPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 13 - 0
fe/src/routes/config/user/index.js

@@ -0,0 +1,13 @@
+module.exports = {
+    path: 'user',
+
+    getChildRoutes(partialNextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, [
+                require('./add'),
+                require('./edit'),
+                require('./list'),
+            ]);
+        });
+    },
+};

+ 19 - 0
fe/src/routes/config/user/list/index.js

@@ -0,0 +1,19 @@
+const NProgress = require('nprogress');
+
+module.exports = {
+    path: 'list',
+
+    getComponent(nextState, cb) {
+        require.ensure([], (require) => {
+            cb(null, require('components/page/UserListPanel').default);
+        }, 'userListPanel');
+    },
+
+    onEnter: () => {
+        NProgress.done();
+    },
+
+    onLeave: () => {
+        NProgress.start();
+    },
+};

+ 0 - 0
fe/src/routes/project/index.js


+ 19 - 0
fe/src/styles/App.css

@@ -0,0 +1,19 @@
+#root, .ant-layout {
+  height: 100%;
+}
+
+.ant-layout-content {
+  min-height: inherit !important;
+}
+
+.logo {
+  width: 120px;
+  height: 31px;
+  line-height: 31px;
+  background: #333;
+  color: #fff;
+  text-align: center;
+  border-radius: 6px;
+  margin: 16px 28px 16px 0;
+  float: left;
+}

+ 20 - 0
fe/src/styles/index.css

@@ -0,0 +1,20 @@
+.pull-right {
+    float: right;
+}
+
+/* 用户组 */
+.user-group-wrapper .user-icon {
+    float: left;
+    margin-right: 10px;
+    width: 50px;
+    height: 50px;
+    background-color: #000;
+}
+
+.user-group-wrapper .user-name {
+    margin-bottom: 14px;
+}
+
+.user-group-wrapper .delete-btn {
+    font-size: 14px;
+}

+ 16 - 0
fe/src/utils/fetch.js

@@ -0,0 +1,16 @@
+import reqwest from 'reqwest';
+import { message } from 'antd';
+
+export default (options) => new Promise((resolve, reject) => {
+    options.url = `/api${options.url}`;
+    return reqwest(options).then(data => {
+        const { code } = data;
+        if (code !== 0) {
+            return message.error(data.message[Object.keys(data.message)[0]]);
+        }
+        resolve(data);
+    }).catch((err) => {
+        message.error('请求失败,请检查网络设置');
+        reject(err);
+    });
+});

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/css/app.cb846d34b23d6a18862bae3ff3a0166b.css


BIN
fe/static/css/app.cb846d34b23d6a18862bae3ff3a0166b.css.gz


BIN
fe/static/favicon.ico


BIN
fe/static/fonts/element-icons.6f0a763.ttf


BIN
fe/static/img/login-bg.23543c2.jpg


BIN
fe/static/img/welcom.af49de2.jpg


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/0.66d62c86fdd4dffbfa47.js


Разница между файлами не показана из-за своего большого размера
+ 10 - 0
fe/static/js/1.ec2b6425464010499973.js


BIN
fe/static/js/1.ec2b6425464010499973.js.gz


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/10.81153c92483c066e7a10.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/11.500397fabc9ca9ee9459.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/12.f24e7785f04042f67a43.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/13.c16e586de2ac95b46807.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/2.a03b85543b555322ee39.js


BIN
fe/static/js/2.a03b85543b555322ee39.js.gz


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/3.a6f9029be6570481d60a.js


BIN
fe/static/js/3.a6f9029be6570481d60a.js.gz


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/4.32a15687cee599140d42.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/5.428e2f91341880c00185.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/6.f6980112f3aea5d30bae.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/7.037953757bdcf1f06a43.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/8.358a956df46c943203b9.js


+ 1 - 0
fe/static/js/9.67749142c80327d9227a.js

@@ -0,0 +1 @@
+webpackJsonp([9],{"/Fi9":function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var s={render:function(){this.$createElement;this._self._c;return this._m(0)},staticRenderFns:[function(){var e=this.$createElement,t=this._self._c||e;return t("div",{staticClass:"wl-home"},[t("h2",[this._v("更快,更优,更 Cool !"),t("br"),this._v("欢迎使用 WALLE")])])}]};var i=n("VU/8")(null,s,!1,function(e){n("Cs2V")},null,null);t.default=i.exports},Cs2V:function(e,t){}});

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/app.196f94098e15976a42c9.js


BIN
fe/static/js/app.196f94098e15976a42c9.js.gz


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
fe/static/js/manifest.b54b5e75bc33d32cbb82.js


Разница между файлами не показана из-за своего большого размера
+ 39 - 0
fe/static/js/vendor.d7347356ea72231c0e43.js


BIN
fe/static/js/vendor.d7347356ea72231c0e43.js.gz


BIN
fe/static/logo.png


Разница между файлами не показана из-за своего большого размера
+ 5739 - 0
fe/yarn.lock


+ 1 - 1
tests/test_08_api_task.py

@@ -5,7 +5,7 @@ import types
 import urllib
 import pytest
 from utils import *
-from walle.model.deploy import TaskModel
+from walle.model.task import TaskModel
 
 class TestApiTask:
     """api role testing"""

+ 1 - 1
tests/test_models.py

@@ -6,7 +6,7 @@ import pytest
 
 from walle.model.user import RoleModel
 from walle.model.user import UserModel
-from walle.model.deploy import EnvironmentModel
+from walle.model.environment import EnvironmentModel
 from .factories import UserFactory
 
 

+ 7 - 0
walle/api/api.py

@@ -14,6 +14,7 @@ from functools import wraps
 from walle.service.code import Code
 from flask import current_app, session
 from flask_login import current_user
+from walle.service.rbac.role import *
 
 class ApiResource(Resource):
     module = None
@@ -55,6 +56,12 @@ class SecurityResource(ApiResource):
     controller = None
     action = None
 
+    space_id = None
+
+    def __init__(self):
+        if current_user.is_authenticated:
+            self.space_id = None if current_user.role == SUPER else session['space_id']
+
     # @login_required
     def get(self, *args, **kwargs):
         self.action = 'get'

+ 2 - 2
walle/api/deploy.py

@@ -10,7 +10,7 @@
 
 from flask import request
 from walle.api.api import SecurityResource
-from walle.model.deploy import TaskRecordModel
+from walle.model.record import RecordModel
 from walle.service.deployer import Deployer
 
 
@@ -50,7 +50,7 @@ class DeployAPI(SecurityResource):
             return self.render_json(code=-1)
         wi = Deployer(task_id)
         ret = wi.walle_deploy()
-        record = TaskRecordModel().fetch(task_id)
+        record = RecordModel().fetch(task_id)
         return self.render_json(data={
             'command': '',
             'record': record,

+ 5 - 4
walle/api/environment.py

@@ -10,9 +10,10 @@
 
 from flask import request
 from walle.form.environment import EnvironmentForm
-from walle.model.deploy import EnvironmentModel
+from walle.model.environment import EnvironmentModel
 from walle.api.api import SecurityResource
-
+from walle.service.extensions import permission
+from walle.service.rbac.role import *
 
 class EnvironmentAPI(SecurityResource):
 
@@ -38,7 +39,7 @@ class EnvironmentAPI(SecurityResource):
 
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
 
         table = [
@@ -51,7 +52,7 @@ class EnvironmentAPI(SecurityResource):
 
         env_model = EnvironmentModel()
         env_list, count = env_model.list(page=page, size=size, kw=kw)
-        return self.list_json(list=env_list, count=count, table=table)
+        return self.list_json(list=env_list, count=count, table=table, enable_create=permission.enable_role(MASTER) and current_user.role <> SUPER)
 
     def item(self, env_id):
         """

+ 2 - 2
walle/api/general.py

@@ -12,7 +12,7 @@ import os
 from flask import request, abort, session, current_app
 from flask_login import current_user, login_required
 from walle.api.api import SecurityResource
-from walle.model.deploy import TaskRecordModel
+from walle.model.record import RecordModel
 from walle.model.user import MenuModel
 from walle.model.user import UserModel
 from walle.service import emails
@@ -81,7 +81,7 @@ class GeneralAPI(SecurityResource):
         task_id = 12
         wi = Deployer(task_id)
         ret = wi.walle_deploy()
-        record = TaskRecordModel().fetch(task_id)
+        record = RecordModel().fetch(task_id)
         return self.render_json(data={
             'command': ret,
             'record': record,

+ 2 - 1
walle/api/group.py

@@ -16,6 +16,7 @@ from walle.model.tag import TagModel
 from walle.api.api import SecurityResource
 from flask import current_app
 from walle.service.rbac.role import *
+from walle.service.extensions import permission
 
 class GroupAPI(SecurityResource):
 
@@ -39,7 +40,7 @@ class GroupAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
         filter = {'name': {'like': kw}} if kw else {}
         space_model = SpaceModel()

+ 4 - 5
walle/api/project.py

@@ -14,7 +14,7 @@ from flask import request, current_app
 from flask_login import login_required
 from walle.api.api import SecurityResource
 from walle.form.project import ProjectForm
-from walle.model.deploy import ProjectModel
+from walle.model.project import ProjectModel
 from walle.model.user import MemberModel
 from walle.service.rbac.role import *
 from walle.service.extensions import permission
@@ -42,14 +42,13 @@ class ProjectAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
         environment_id = request.values.get('environment_id', '')
 
         project_model = ProjectModel()
-        space_id = None if current_user.role == SUPER else session['space_id']
-        project_list, count = project_model.list(page=page, size=size, kw=kw, environment_id=environment_id, space_id=space_id)
-        return self.list_json(list=project_list, count=count)
+        project_list, count = project_model.list(page=page, size=size, kw=kw, environment_id=environment_id, space_id=self.space_id)
+        return self.list_json(list=project_list, count=count, enable_create=permission.enable_role(MASTER) and current_user.role <> SUPER)
 
     def item(self, project_id):
         """

+ 5 - 3
walle/api/server.py

@@ -11,7 +11,9 @@
 from flask import request
 from walle.api.api import SecurityResource
 from walle.form.server import ServerForm
-from walle.model.deploy import ServerModel
+from walle.model.server import ServerModel
+from walle.service.extensions import permission
+from walle.service.rbac.role import *
 
 
 class ServerAPI(SecurityResource):
@@ -34,12 +36,12 @@ class ServerAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
 
         server_model = ServerModel()
         server_list, count = server_model.list(page=page, size=size, kw=kw)
-        return self.list_json(list=server_list, count=count)
+        return self.list_json(list=server_list, count=count, enable_create=permission.enable_role(MASTER))
 
     def item(self, id):
         """

+ 23 - 8
walle/api/space.py

@@ -8,17 +8,20 @@
     :author: wushuiyong@walle-web.io
 """
 
-from flask import request, current_app, session
+import json
+
+from flask import request, abort
 from walle.api.api import SecurityResource
 from walle.form.space import SpaceForm
 from walle.model.user import SpaceModel, MemberModel, UserModel
-import json
-from walle.service.rbac.role import *
 from walle.service.extensions import permission
+from walle.service.rbac.role import *
+
 
 class SpaceAPI(SecurityResource):
+    actions = ['members', 'item', 'list']
 
-    def get(self, space_id=None):
+    def get(self, space_id=None, action=None):
         """
         fetch space list or one item
         /space/<int:space_id>
@@ -26,10 +29,16 @@ class SpaceAPI(SecurityResource):
         :return:
         """
         super(SpaceAPI, self).get()
+        if action is None:
+            action = 'item' if space_id else 'list'
 
-        return self.item(space_id) if space_id else self.list()
+        if action in self.actions:
+            self_action = getattr(self, action.lower(), None)
+            return self_action(space_id)
+        else:
+            abort(404)
 
-    def list(self):
+    def list(self, space_id=None):
         """
         fetch space list
 
@@ -37,7 +46,7 @@ class SpaceAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
 
         space_model = SpaceModel()
@@ -133,4 +142,10 @@ class SpaceAPI(SecurityResource):
         current_user.last_space = space_id
         current_user.save()
         UserModel.fresh_session()
-        return self.render_json()
+        return self.render_json()
+
+    def members(self, space_id):
+        page = int(request.args.get('page', 1))
+        size = int(request.args.get('size', 10))
+        members = MemberModel(group_id=space_id).members(page=page, size=size)
+        return self.list_json(list=members['members'], count=members['count'], enable_create=permission.enable_role(OWNER))

+ 6 - 4
walle/api/task.py

@@ -11,7 +11,9 @@
 from flask import request, current_app, abort
 from walle.api.api import SecurityResource
 from walle.form.task import TaskForm
-from walle.model.deploy import TaskModel
+from walle.model.task import TaskModel
+from walle.service.extensions import permission
+from walle.service.rbac.role import *
 
 
 class TaskAPI(SecurityResource):
@@ -34,12 +36,12 @@ class TaskAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
         kw = request.values.get('kw', '')
 
         task_model = TaskModel()
-        task_list, count = task_model.list(page=page, size=size, kw=kw)
-        return self.list_json(list=task_list, count=count)
+        task_list, count = task_model.list(page=page, size=size, kw=kw, space_id=self.space_id)
+        return self.list_json(list=task_list, count=count, enable_create=permission.enable_role(REPORT) and current_user.role <> SUPER)
 
     def item(self, task_id):
         """

+ 6 - 4
walle/api/user.py

@@ -42,16 +42,17 @@ class UserAPI(SecurityResource):
         """
         page = int(request.args.get('page', 0))
         page = page - 1 if page else 0
-        size = float(request.args.get('size', 10))
+        size = int(request.args.get('size', 10))
+        space_id = int(request.args.get('space_id', 0))
         kw = request.values.get('kw', '')
 
         uids = []
-        if current_user.role <> SUPER:
+        if current_user.role <> SUPER and space_id:
             members = MemberModel(group_id=current_user.last_space).members()
             uids = members['user_ids']
 
         user_model = UserModel()
-        user_list, count = user_model.list(uids=uids, page=page, size=size, kw=kw)
+        user_list, count = user_model.list(uids=uids, page=page, size=size, space_id=space_id, kw=kw)
         filters = {
             'username': ['线上', '线下'],
             'status': ['正常', '禁用']
@@ -127,7 +128,8 @@ class UserAPI(SecurityResource):
         MemberModel().remove(user_id=user_id)
         return self.render_json(message='')
 
-    def table(self, filter={}):
+    @staticmethod
+    def table(filter={}):
         table = {
             'username': {
                 'sort': 0

+ 0 - 2
walle/app.py

@@ -47,8 +47,6 @@ def create_app(config_object=ProdConfig):
 
     @app.before_request
     def before_request():
-        # current_app.logger.info(request)
-        # current_app.logger.info(app.request_class.url_rule)
         # TODO
         app.logger.info('============ @app.before_request ============')
 

+ 2 - 1
walle/config/settings_dev.py

@@ -9,6 +9,7 @@ class DevConfig(Config):
 
     HOST = 'dev.admin.walle-web.io'
     PORT = 5000
+    # SERVER_NAME = 'dev.admin.walle-web.io:5000'
     ENV = 'dev'
     DEBUG = True
     DB_NAME = 'walle_python'
@@ -23,7 +24,7 @@ class DevConfig(Config):
     PERMANENT_SESSION_LIFETIME = timedelta(days=1) #设置session的保存时间。
 
     # 前端项目部署路径
-    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
+    FE_PATH = os.path.abspath(Config.PROJECT_ROOT + '/../walle-fe/')
     AVATAR_PATH = 'avatar/'
     UPLOAD_AVATAR = FE_PATH + 'dist/' + AVATAR_PATH
 

+ 51 - 7
walle/config/settings_prod.py

@@ -1,20 +1,64 @@
 # -*- coding: utf-8 -*-
-"""Application configuration."""
+"""
+    walle-web
+    Application configuration.
+
+    :copyright: © 2015-2019 walle-web.io
+    :created time: 2018-11-24 07:05:35
+    :author: wushuiyong@walle-web.io
+"""
+from datetime import timedelta
+
 import os
 from walle.config.settings import Config
 
 
 class ProdConfig(Config):
-    """Production configuration."""
+    """Development configuration."""
+
     HOST = 'admin.walle-web.io'
     PORT = 5000
+    # SERVER_NAME = 'dev.admin.walle-web.io:5000'
     ENV = 'prod'
     DEBUG = False
-    SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/example'  # TODO: Change me
-    DEBUG_TB_ENABLED = False  # Disable Debug toolbar
-
+    PROPAGATE_EXCEPTIONS = True
+    DB_NAME = 'walle_python'
+    # Put the db file in project root
+    WTF_CSRF_ENABLED = False
+    DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME)
+    SQLALCHEMY_DATABASE_URI = 'mysql://root:@localhost/walle_python'
+    DEBUG_TB_ENABLED = False
+    ASSETS_DEBUG = True  # Don't bundle/minify static assets
+    CACHE_TYPE = 'simple'  # Can be "memcached", "redis", etc.
+    # 设置session的保存时间。
+    PERMANENT_SESSION_LIFETIME = timedelta(days=1)
 
     # 前端项目部署路径
-    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
-    UPLOAD_AVATER = FE_PATH + 'dist/avater/'
+    FE_PATH = os.path.abspath(Config.PROJECT_ROOT + '/../walle-fe/') + '/'
+    AVATAR_PATH = 'avatar/'
+    UPLOAD_AVATAR = FE_PATH + 'dist/' + AVATAR_PATH
+
+    # email config
+    MAIL_SERVER = 'smtp.exmail.qq.com'
+    MAIL_PORT = 465
+    MAIL_USE_SSL = True
+    MAIL_USE_TLS = False
+    MAIL_DEFAULT_SENDER = 'service@walle-web.io'
+    MAIL_USERNAME = 'service@walle-web.io'
+    MAIL_PASSWORD = 'Ki9y&3U82'
+
+    LOG_PATH = os.path.join(Config.PROJECT_ROOT, 'logs')
+    LOG_PATH_ERROR = os.path.join(LOG_PATH, 'error.log')
+    LOG_PATH_INFO = os.path.join(LOG_PATH, 'info.log')
+    LOG_PATH_DEBUG = os.path.join(LOG_PATH, 'debug.log')
+    LOG_FILE_MAX_BYTES = 100 * 1024 * 1024
+
+    # 轮转数量是 10 个
+    LOG_FILE_BACKUP_COUNT = 10
+    LOG_FORMAT = "%(asctime)s %(thread)d %(message)s"
+
+    LOCAL_SERVER_HOST = '127.0.0.1'
+    LOCAL_SERVER_USER = 'work'
+    LOCAL_SERVER_PORT = 22
 
+    SQLALCHEMY_ECHO = False

+ 1 - 1
walle/config/settings_test.py

@@ -23,7 +23,7 @@ class TestConfig(Config):
     CACHE_TYPE = 'simple'  # Can be "memcached", "redis", etc.
 
     # 前端项目部署路径
-    FE_PATH = '/Users/wushuiyong/workspace/meolu/walle-fe/'
+    FE_PATH = os.path.abspath(Config.PROJECT_ROOT + '/../walle-fe/')
     AVATAR_PATH = 'avatar/'
     UPLOAD_AVATAR = FE_PATH + 'dist/' + AVATAR_PATH
 

+ 1 - 2
walle/form/environment.py

@@ -11,11 +11,10 @@ try:
 except ImportError:
     from flask_wtf import Form as FlaskForm  # Fallback to Flask-WTF v0.12 or older
 from flask_wtf import Form
+from walle.model.environment import EnvironmentModel
 from wtforms import TextField
 from wtforms import validators, ValidationError
 
-from walle.model.deploy import EnvironmentModel
-
 
 class EnvironmentForm(Form):
     env_name = TextField('env_name', [validators.Length(min=1, max=100)])

+ 0 - 0
walle/form/project.py


Некоторые файлы не были показаны из-за большого количества измененных файлов