diff --git a/.eslintrc b/.eslintrc
index be511fe..e030fa3 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,3 +1,20 @@
{
- "extends": "eslint-config-airbnb"
+ "extends": "eslint-config-airbnb",
+ "parser": "babel-eslint",
+ "rules": {
+ "spaced-comment": [0],
+ "no-unused-vars": [0],
+ "no-empty": [0],
+ "react/wrap-multilines": [0],
+ "react/no-multi-comp": [0],
+ "no-constant-condition": [0],
+ "react/jsx-no-bind": [0],
+ "react/prop-types": [0],
+ "arrow-body-style": [0],
+ "react/prefer-stateless-function": [0],
+ "semi": [0]
+ },
+ "ecmaFeatures": {
+ "experimentalObjectRestSpread": true
+ }
}
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index bdb0cab..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,17 +0,0 @@
-# Auto detect text files and perform LF normalization
-* text=auto
-
-# Custom for Visual Studio
-*.cs diff=csharp
-
-# Standard to msysgit
-*.doc diff=astextplain
-*.DOC diff=astextplain
-*.docx diff=astextplain
-*.DOCX diff=astextplain
-*.dot diff=astextplain
-*.DOT diff=astextplain
-*.pdf diff=astextplain
-*.PDF diff=astextplain
-*.rtf diff=astextplain
-*.RTF diff=astextplain
diff --git a/.gitignore b/.gitignore
index ce0665e..4bef053 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,14 @@
+# Intellij
+.idea
*.iml
-.idea/
-.ipr
-.iws
-*~
-~*
-*.diff
-*.patch
-*.bak
+*.iws
+
+# Mac
.DS_Store
-Thumbs.db
-.project
-.*proj
-.svn/
-*.swp
-*.swo
-*.pyc
-*.pyo
+
+# node
node_modules
+npm-debug.log
+
+# other
dist
diff --git a/.lesshintrc b/.lesshintrc
new file mode 100644
index 0000000..8d51d12
--- /dev/null
+++ b/.lesshintrc
@@ -0,0 +1,18 @@
+{
+ "propertyOrdering": false,
+ "hexLength": "short",
+ "stringQuotes": false,
+ "decimalZero": false,
+ "importantRule": false,
+ "zeroUnit": "no_unit",
+ "qualifyingElement": false,
+ "duplicateProperty": false,
+ "importPath": false,
+ "finalNewline": false,
+ "spaceAroundComma": false,
+
+ "fileExtensions": [".less", ".css"],
+ "excludedFiles": [
+ "test.less"
+ ]
+}
diff --git a/.stylelintrc b/.stylelintrc
new file mode 100644
index 0000000..2fe96fd
--- /dev/null
+++ b/.stylelintrc
@@ -0,0 +1,15 @@
+{
+ "extends": "stylelint-config-standard",
+ "rules": {
+ "string-quotes": "single",
+ "selector-pseudo-class-no-unknown": [
+ true,
+ {
+ "ignorePseudoClasses": [
+ "global",
+ "local"
+ ]
+ }
+ ]
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 82ad55e..e69de29 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +0,0 @@
-# antd-demo
-
-## Environment
-
-```
-node >= 4
-```
-
-## Code Style
-
-https://github.com/airbnb/javascript
-
-## Develop
-
-```
-npm run dev
-```
-
-访问 http://127.0.0.1:8989
-
-## Build
-
-```
-npm run build
-```
diff --git a/admin/index.html b/admin/index.html
deleted file mode 100644
index 2b37c3b..0000000
--- a/admin/index.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/admin/user.html b/admin/user.html
deleted file mode 100644
index edcf13f..0000000
--- a/admin/user.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/index.html b/index.html
deleted file mode 100644
index 50206ad..0000000
--- a/index.html
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package.json b/package.json
index 4555c70..943ebff 100644
--- a/package.json
+++ b/package.json
@@ -1,41 +1,60 @@
{
- "name": "antd-demo",
+ "name": "react-antd-admin",
"version": "1.0.0",
- "entry": {
- "index": "./src/entry/index.jsx",
- "admin/index": ["./src/entry/admin/index.jsx"],
- "admin/user": ["./src/entry/admin/user.jsx"]
+ "description": "React Admin Boilerplate, with React + Ant Design",
+ "author": "jiangxy ",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=5.0 <7",
+ "npm": ">=3.3 <4"
},
- "output": {
- "path": "dist",
- "filename": "index_bundle.js"
- },
- "plugins": "[new HtmlWebpackPlugin()]",
- "dependencies": {
- "antd": "0.11.x",
- "atool-build": "0.4.x",
- "babel-plugin-antd": "^0.1.2",
- "es3ify-loader": "^0.1.0",
- "react": "~0.14.3",
- "react-dom": "~0.14.3"
- },
- "devDependencies": {
- "dora": "0.2.x",
- "dora-plugin-atool-build": "0.4.x",
- "dora-plugin-hmr": "0.3.x",
- "dora-plugin-proxy": "0.5.x",
- "eslint": "~1.10.3",
- "eslint-config-airbnb": "~2.1.0",
- "eslint-plugin-react": "~3.11.3",
- "html-webpack-plugin": "^2.7.2",
- "pre-commit": "1.x"
- },
- "pre-commit": [
- "lint"
+ "keywords": [
+ "ant",
+ "react",
+ "admin",
+ "frontend"
],
+ "devDependencies": {
+ "antd": "1.10.0",
+ "babel-core": "6.4.5",
+ "babel-eslint": "6.0.0",
+ "babel-loader": "6.2.1",
+ "babel-plugin-antd": "0.4.0",
+ "babel-polyfill": "6.9.1",
+ "babel-preset-es2015": "6.3.13",
+ "babel-preset-react": "6.3.13",
+ "babel-preset-stage-0": "6.5.0",
+ "css-loader": "0.23.1",
+ "eslint": "2.7.0",
+ "eslint-config-airbnb": "6.x",
+ "eslint-plugin-react": "4.x",
+ "file-loader": "0.9.0",
+ "less": "2.7.1",
+ "less-loader": "2.2.3",
+ "lesshint": "2.0.2",
+ "react": "15.0.2",
+ "react-cookie": "0.4.7",
+ "react-dom": "15.0.2",
+ "react-hot-loader": "1.3.0",
+ "react-router": "2.0.1",
+ "strip-loader": "0.1.2",
+ "style-loader": "0.13.1",
+ "stylelint": "6.6.0",
+ "stylelint-config-standard": "9.0.0",
+ "url-loader": "0.5.7",
+ "webpack": "1.12.12",
+ "webpack-dev-server": "1.14.1"
+ },
+ "dependencies": {
+ "superagent": "2.0.0"
+ },
"scripts": {
- "dev": "dora -p 8001 --plugins atool-build,proxy,hmr",
- "lint": "eslint --ext .js,.jsx src",
- "build": "atool-build -o ./dist"
+ "build": "webpack --progress --colors --display-error-details",
+ "prod": "NODE_ENV=production webpack --progress --colors --config webpack.config.prod.js -p --display-error-details",
+ "clean": "rm dist/bundle*",
+ "dev": "webpack-dev-server --devtool eval --progress --colors --content-base dist --hot --inline --display-error-details",
+ "eslint": "eslint --ext .js,.jsx src",
+ "stylelint": "stylelint \"src/**/*.css\"",
+ "lesshint": "lesshint src/"
}
}
diff --git a/pages/index.html b/pages/index.html
deleted file mode 100644
index 15b3303..0000000
--- a/pages/index.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/pages/user.html b/pages/user.html
deleted file mode 100644
index be4482d..0000000
--- a/pages/user.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/common/lib.js b/src/common/lib.js
deleted file mode 100644
index 19438ec..0000000
--- a/src/common/lib.js
+++ /dev/null
@@ -1 +0,0 @@
-import 'antd/lib/index.css';
diff --git a/src/component/App.jsx b/src/component/App.jsx
deleted file mode 100644
index 0a37080..0000000
--- a/src/component/App.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import { DatePicker , message , Button , Menu, Dropdown, Icon } from 'antd';
-
-const App = React.createClass({
- getInitialState() {
- return {
- date: ''
- };
- },
- handleChange(value) {
- message.info('您选择的日期是: ' + value.toString());
- this.setState({
- date: value
- });
- },
- render() {
- return
-
-
当前日期:{this.state.date.toString()}
-
;
- }
-});
-
-export default App;
diff --git a/src/components/App/index.js b/src/components/App/index.js
new file mode 100644
index 0000000..edf889c
--- /dev/null
+++ b/src/components/App/index.js
@@ -0,0 +1,112 @@
+import React from 'react';
+import {Spin, message} from 'antd';
+import Header from '../Header';
+import Footer from '../Footer';
+import Sidebar from '../Sidebar';
+import Login from '../Login';
+import Breadcrumb from '../Breadcrumb';
+import './index.less';
+import globalConfig from 'config.js';
+import ajax from '../../utils/ajax';
+import Logger from '../../utils/Logger';
+
+const logger = Logger.getLogger('App');
+
+/**
+ * App组件
+ * 定义整个页面的布局
+ */
+class App extends React.Component {
+
+ // 全局的状态
+ state = {
+ loading: true, // 是否在loading状态
+ login: false, // 是否登录
+ userName: '未登录', // 登录后的用户名
+ };
+
+ componentWillMount() {
+ logger.debug('mount and add styles');
+ // 为了兼容登录组件
+ document.body.style.background = 'white'; // FIXME: gross hack
+ }
+
+ componentDidMount() {
+ if (!this.state.login) {
+ const hide = message.loading('正在获取用户信息...', 0);
+
+ // 先去服务端验证下, 说不定已经登录了
+ ajax.get(`${globalConfig.getAPIPath()}${globalConfig.login.getCurrentUser}`).end((err, res) => {
+
+ // 注意这里, debug模式下每次刷新都必须重新登录
+ if (ajax.isSuccess(res) && !globalConfig.debug) {
+ hide();
+ this.loginSuccess(res.body.data);
+ } else {
+ // 如果服务端说没有登录, 就要跳转到sso或者登录界面
+ if (globalConfig.isSSO()) {
+ // debug模式不支持调试单点登录
+ // 因为没有单点登录的地址啊...跳不回来
+ hide();
+ logger.debug('not login, redirect to SSO login page');
+ const redirect = encodeURIComponent(window.location.href);
+ window.location.href = `${globalConfig.login.sso}${redirect}`;
+ } else {
+ hide();
+ message.error('获取用户信息失败, 请重新登录');
+ logger.debug('not login, redirect to Login component');
+ this.setState({loading: false, login: false});
+ }
+ }
+ });
+ }
+ }
+
+ // 登录成功后的回调
+ loginSuccess = (name, showMsg) => {
+ logger.debug('callback logingSuccess, name = %s', name);
+ if (showMsg)
+ message.success('登录成功');
+ document.body.style.background = 'white'; // FIXME: gross hack
+ this.setState({loading: false, login: true, userName: name});
+ }
+
+ render() {
+ // 显示一个加载中
+ if (this.state.loading) {
+ return
;
+ }
+
+ // 跳转到登录界面
+ if (!this.state.login) {
+ return ;
+ }
+
+ return (
+
+ {/*整个页面被一个ant-layout-aside的div包围, 分为sidebar/header/footer/content等几部分*/}
+
+
+
+
+ );
+ }
+}
+
+export default App;
diff --git a/src/components/App/index.less b/src/components/App/index.less
new file mode 100644
index 0000000..44aff70
--- /dev/null
+++ b/src/components/App/index.less
@@ -0,0 +1,154 @@
+/*定义整体layout的样式, 直接从antd官网copy的, 稍微修改了一些*/
+/*我本来想把样式拆到各个模块去, 但是太麻烦了...*/
+
+.ant-layout-aside {
+ position: relative;
+ height: 100%;
+}
+
+.ant-layout-aside .ant-layout-logo {
+ width: 200px;
+ height: 32px;
+ background: #333;
+ border-radius: 6px;
+ margin: 16px 8px 16px 12px;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside-collapse .ant-layout-logo {
+ width: 32px;
+ margin: 16px;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside .ant-layout-sider {
+ width: 224px;
+ background: #404040;
+ position: absolute;
+ overflow: auto;
+ padding-bottom: 24px;
+ height: 100%;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider {
+ width: 64px;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside .ant-layout-sider > .ant-menu {
+ margin-bottom: 20px;
+}
+
+.ant-layout-aside .ant-layout-sider > .ant-menu > .ant-menu-item {
+ margin: 16px 0;
+}
+
+.ant-layout-aside .ant-layout-sider > .ant-menu > .ant-menu-item .nav-text {
+ vertical-align: baseline;
+ display: inline-block;
+}
+
+.ant-layout-aside .ant-layout-sider > .ant-menu > .ant-menu-item > .anticon {
+ transition: font-size .3s;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider > .ant-menu > .ant-menu-item {
+ transition: all 0s ease;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider > .ant-menu > .ant-menu-item > .anticon {
+ font-size: 16px;
+ display: inline-block;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider > .ant-menu > .ant-menu-item .nav-text {
+ display: none;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider > .ant-menu > .ant-menu-item:hover {
+ background: #2db7f5;
+ color: #fff;
+ transition: all 0s ease;
+}
+
+.ant-layout-aside-collapse .ant-layout-sider > .ant-menu > .ant-menu-item:hover .nav-text {
+ display: inline-block;
+ vertical-align: top;
+ background: #2db7f5;
+ color: #fff;
+ padding-right: 16px;
+ border-radius: 0 5px 5px 0;
+}
+
+/* 实际使用中需要改成 position: fixed */
+.ant-layout-aside .ant-aside-action {
+ height: 42px;
+ width: 224px;
+ position: absolute;
+ bottom: 0;
+ background: #656565;
+ color: #fff;
+ text-align: center;
+ line-height: 42px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside-collapse .ant-aside-action {
+ width: 64px;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside .ant-layout-header {
+ background: #fff;
+ height: 48px;
+ border-bottom: 1px solid #e9e9e9;
+}
+
+.ant-layout-aside .ant-layout-breadcrumb {
+ margin: 7px 0 -17px 24px;
+}
+
+.ant-layout-aside .ant-layout-main {
+ margin-left: 224px;
+ transition: all 0.3s ease;
+ overflow: auto;
+ height: 100%;
+}
+
+.ant-layout-aside-collapse .ant-layout-main {
+ margin-left: 64px;
+ transition: all 0.3s ease;
+}
+
+.ant-layout-aside .ant-layout-container {
+ margin: 24px 16px;
+}
+
+.ant-layout-aside .ant-layout-content {
+ background: #fff;
+ padding: 24px;
+}
+
+.ant-layout-aside .ant-layout-footer {
+ height: 64px;
+ line-height: 64px;
+ text-align: center;
+ font-size: 12px;
+ color: #999;
+ background: #fff;
+ border-top: 1px solid #e9e9e9;
+ width: 100%;
+}
+
+// 为了在屏幕中间显示一个loading
+.center-div {
+ position: absolute; /*绝对定位*/
+ top: 50%; /* 距顶部50%*/
+ left: 50%; /* 距左边50%*/
+ height: 2px;
+ margin-top: -1px; /*margin-top为height一半的负值*/
+ width: 2px;
+ margin-left: -1px; /*margin-left为width一半的负值*/
+}
diff --git a/src/components/Breadcrumb/index.js b/src/components/Breadcrumb/index.js
new file mode 100644
index 0000000..297cee5
--- /dev/null
+++ b/src/components/Breadcrumb/index.js
@@ -0,0 +1,81 @@
+import React from 'react';
+import {Breadcrumb, Icon} from 'antd';
+import items from 'menu.js';
+import Logger from '../../utils/Logger';
+
+const Item = Breadcrumb.Item;
+const logger = Logger.getLogger('Breadcrumb');
+
+/**
+ * 定义面包屑导航, 由于和已有的组件重名, 所以改个类名
+ */
+class Bread extends React.Component {
+
+ static inited = false; // 表示下面两个map是否初始化
+ static iconMap = new Map(); // 暂存menu.js中key->icon的对应关系
+ static nameMap = new Map(); // 暂存menu.js中key->name的对应关系
+
+ // 初始化iconMap和nameMap
+ static init() {
+ // 箭头函数还是很好用的
+ items.forEach((level1) => {
+ Bread.nameMap.set(level1.key, level1.name);
+ logger.debug('nameMap add entry: key=%s, value=%s', level1.key, level1.name);
+ Bread.iconMap.set(level1.key, level1.icon);
+ logger.debug('iconMap add entry: key=%s, value=%s', level1.key, level1.icon);
+
+ if (level1.child) {
+ level1.child.forEach((level2) => {
+ Bread.nameMap.set(level2.key, level2.name);
+ logger.debug('nameMap add entry: key=%s, value=%s', level2.key, level2.name);
+
+ if (level2.child) {
+ level2.child.forEach((level3)=> {
+ Bread.nameMap.set(level3.key, level3.name);
+ logger.debug('nameMap add entry: key=%s, value=%s', level3.key, level3.name);
+ });
+ }
+ });
+ }
+ });
+ }
+
+ render() {
+ // render之前判断是否要初始化
+ if (!Bread.inited) {
+ logger.debug('not inited, calling init method');
+ Bread.init();
+ Bread.inited = true;
+ }
+
+ const itemArray = [];
+
+ // 面包屑导航的最开始都是一个home图标, 并且这个图标时可以点击的
+ itemArray.push( );
+
+ for (const route of this.props.routes) {
+ logger.debug('path=%s, route=%o', route.path, route);
+ const name = Bread.nameMap.get(route.path);
+
+ if (name) {
+ const icon = Bread.iconMap.get(route.path);
+ if (icon) {
+ itemArray.push( {name} ); // 有图标的话带上图标
+ } else {
+ // 这个key属性不是antd需要的, 只是react要求同一个array中各个元素要是不同的, 否则有warning
+ itemArray.push(- {name}
);
+ }
+ }
+ }
+
+ // 这个面包屑是不可点击的(除了第一级的home图标), 只是给用户一个提示
+ return (
+
+ {itemArray}
+
+ );
+ }
+
+}
+
+export default Bread;
diff --git a/src/components/DBTable/InnerForm.js b/src/components/DBTable/InnerForm.js
new file mode 100644
index 0000000..3d9cf4d
--- /dev/null
+++ b/src/components/DBTable/InnerForm.js
@@ -0,0 +1,449 @@
+import React from 'react';
+import {
+ Form,
+ Input,
+ Row,
+ Col,
+ Button,
+ DatePicker,
+ Select,
+ Icon,
+ Radio,
+ InputNumber,
+ Checkbox,
+ message,
+ Upload,
+ notification
+} from 'antd';
+import globalConfig from 'config.js';
+import Logger from '../../utils/Logger';
+
+const FormItem = Form.Item;
+const RadioGroup = Radio.Group;
+const CheckboxGroup = Checkbox.Group;
+const Option = Select.Option;
+
+const logger = Logger.getLogger('InnerForm');
+
+/**
+ * 内部表单组件
+ */
+class InnerForm extends React.Component {
+
+ // 本来想在初始化时解析schema, 结果发现不行
+ // 解析schema的过程还是改到render里了
+
+ /**
+ * 辅助函数, 将一个input元素包装下
+ *
+ * @param formItem
+ * @param field
+ * @returns {XML}
+ */
+ colWrapper = (formItem, field) => {
+ //const {getFieldProps, getFieldError, isFieldValidating} = this.props.form;
+ return (
+
+
+ {formItem}
+
+
+ );
+ }
+
+ /**
+ * 将schema中的一列转换为下拉框
+ *
+ * @param field
+ */
+ transformSelect = (field) => {
+ // TODO: 这里是否要做schema校验
+ const options = [];
+ const {getFieldProps} = this.props.form;
+
+ logger.debug('transform field %o to Select component', field);
+ field.options.forEach((option) => {
+ options.push({option.value} );
+ });
+
+ return this.colWrapper((
+
+ {options}
+
+ ), field);
+ }
+
+ /**
+ * 将schema中的一列转换为单选框
+ *
+ * @param field
+ */
+ transformRadio = (field) => {
+ const options = [];
+ const {getFieldProps} = this.props.form;
+
+ logger.debug('transform field %o to Radio component', field);
+ field.options.forEach((option) => {
+ options.push({option.value} );
+ });
+
+ return this.colWrapper((
+
+ {options}
+
+ ), field);
+ }
+
+ /**
+ * 将schema中的一列转换为checkbox
+ *
+ * @param field
+ */
+ transformCheckbox = (field) => {
+ const options = [];
+ const {getFieldProps} = this.props.form;
+
+ logger.debug('transform field %o to Checkbox component', field);
+ field.options.forEach((option) => {
+ options.push({label: option.value, value: option.key});
+ });
+
+ return this.colWrapper((
+
+ ), field);
+ }
+
+ /**
+ * 转换为下拉多选框
+ *
+ * @param field
+ * @returns {XML}
+ */
+ transformMultiSelect = (field) => {
+ const options = [];
+ const {getFieldProps} = this.props.form;
+
+ logger.debug('transform field %o to MultipleSelect component', field);
+ field.options.forEach((option) => {
+ options.push({option.value} );
+ });
+
+ return this.colWrapper((
+
+ {options}
+
+ ), field);
+ }
+
+ /**
+ * 将schema中的一列转换为普通输入框
+ *
+ * @param field
+ * @returns {XML}
+ */
+ transformNormal = (field) => {
+ const {getFieldProps} = this.props.form;
+ switch (field.dataType) {
+ case 'int':
+ logger.debug('transform field %o to integer input component', field);
+ return this.colWrapper((
+
+ ), field);
+ case 'float':
+ logger.debug('transform field %o to float input component', field);
+ return this.colWrapper((
+
+ ), field);
+ case 'datetime':
+ logger.debug('transform field %o to datetime input component', field);
+ return this.colWrapper((
+
+ ), field);
+ default: // 默认就是普通的输入框
+ logger.debug('transform field %o to varchar input component', field);
+ return this.colWrapper((
+
+ ), field);
+ }
+ }
+
+ /**
+ * 也是个辅助函数, 由于是范围查询, 输入的formItem是两个, 一个用于begin一个用于end
+ *
+ * @param beginFormItem
+ * @param endFormItem
+ * @param field
+ * @returns {XML}
+ */
+ betweenColWrapper = (beginFormItem, endFormItem, field) => {
+ // 布局真是个麻烦事
+ // col内部又用了一个row做布局
+ // const {getFieldProps} = this.props.form;
+ return (
+
+
+
+
+ {beginFormItem}
+
+
+
+
+ {endFormItem}
+
+
+
+
+ );
+ }
+
+ /**
+ * between类型比较特殊, 普通元素每个宽度是8, int和float的between元素宽度也是8, 但datetime类型的between元素宽度是16
+ * 否则排版出来不能对齐, 太丑了, 逼死强迫症
+ * 而且普通的transform函数返回是一个object, 而这个函数返回是一个array, 就是因为datetime的between要占用两列
+ *
+ * @param field
+ */
+ transformBetween = (field) => {
+ const cols = [];
+ let beginFormItem;
+ let endFormItem;
+ const {getFieldProps} = this.props.form;
+
+ switch (field.dataType) {
+ case 'int':
+ logger.debug('transform field %o to integer BETWEEN component', field);
+ beginFormItem = ( );
+ endFormItem = ( );
+ cols.push(this.betweenColWrapper(beginFormItem, endFormItem, field));
+ break;
+ case 'float':
+ logger.debug('transform field %o to float BETWEEN component', field);
+ beginFormItem = ( );
+ endFormItem = ( );
+ cols.push(this.betweenColWrapper(beginFormItem, endFormItem, field));
+ break;
+ // datetime类型的between要占用两个Col
+ // 不写辅助函数了, 直接这里写jsx吧...
+ case 'datetime':
+ logger.debug('transform field %o to datetime BETWEEN component', field);
+ cols.push(
+
+
+
+
+
+ );
+ cols.push(
+
+
+
+ );
+ break;
+ default:
+ // 理论上来说不会出现这种情况
+ logger.error('unknown dataType: %s', field.dataType);
+ }
+ return cols;
+ }
+
+ /**
+ * 表单的查询条件不能直接传给后端, 要处理一下
+ *
+ * @param oldObj
+ * @returns {{}}
+ */
+ filterQueryObj(oldObj) {
+ // 将提交的值中undefined的去掉
+ const newObj = {};
+ for (const key in oldObj) {
+ if (oldObj[key]) {
+ // 对于js的日期类型, 要转换成字符串再传给后端
+ if (oldObj[key] instanceof Date) {
+ newObj[key] = oldObj[key].format('yyyy-MM-dd HH:mm:ss');
+ } else {
+ newObj[key] = oldObj[key];
+ }
+ }
+ }
+ logger.debug('old queryObj: %o, new queryObj %o', oldObj, newObj);
+ return newObj;
+ }
+
+ /**
+ * 处理表单提交
+ *
+ * @param e
+ */
+ handleSubmit = (e) => {
+ e.preventDefault();
+
+ const oldObj = this.props.form.getFieldsValue();
+ const newObj = this.filterQueryObj(oldObj);
+
+ // 还是要交给上层组件处理, 因为要触发table组件的状态变化...
+ this.props.parentHandleSubmit(newObj);
+ }
+
+ handleReset = (e) => {
+ e.preventDefault();
+ this.props.form.resetFields();
+ }
+
+ /**
+ * 处理数据导入
+ */
+ handleImport = (info) => {
+ logger.debug('upload status: %s', info.file.status);
+ // 正在导入时显示一个提示信息
+ if (info.file.status === 'uploading') {
+ if (!this.hideLoading) {
+ let hide = message.loading('正在导入...');
+ this.hideLoading = hide;
+ }
+ }
+ // 导入完成, 无论成功或失败, 必须给出提示, 并且要用户手动关闭
+ else if (info.file.status === 'error') {
+ this.hideLoading();
+ this.hideLoading = undefined;
+ notification.error({
+ message: '导入失败',
+ description: '文件上传失败, 请联系管理员',
+ duration: 0,
+ });
+ }
+ // done的情况下还要判断返回值
+ else if (info.file.status === 'done') {
+ this.hideLoading();
+ this.hideLoading = undefined;
+ logger.debug('upload result %o', info.file.response);
+ if (!info.file.response.success) {
+ notification.error({
+ message: '导入失败',
+ description: `请联系管理员, 错误信息: ${info.file.response.message}`,
+ duration: 0,
+ });
+ } else {
+ notification.success({
+ message: '导入成功',
+ description: info.file.response.data,
+ duration: 0,
+ });
+ }
+ }
+ }
+
+ /**
+ * 处理数据导出
+ * 本质上也是提交表单, 跟handleSubmit有点类似
+ * 但不用再提交给上层组件处理了, 因为不需要改变表格组件的状态
+ */
+ handleExport = (e) => {
+ e.preventDefault();
+
+ const oldObj = this.props.form.getFieldsValue();
+ const newObj = this.filterQueryObj(oldObj);
+
+ // 导出前必须选定了一些查询条件, 不允许导出全表
+ // 防止误操作
+ if (Object.keys(newObj).length === 0) {
+ message.warning('导出时查询条件不能为空', 4.5);
+ return;
+ }
+
+ // ajax是不能处理下载请求的, 必须交给浏览器自己去处理
+ // 坏处是我就不知道用户的下载是否成功了
+ const url = `${globalConfig.getAPIPath()}/${this.props.tableName}/export`;
+ window.open(`${url}?q=${encodeURIComponent(JSON.stringify(newObj))}`); // 注意url编码
+ }
+
+ render() {
+ const rows = [];
+ let cols = [];
+
+ // 参见antd的布局, 每行被分为24个格子
+ // 普通的字段每个占用8格, between类型的字段每个占用16格
+ let spaceLeft = 24;
+ this.props.schema.forEach((field)=> {
+ // 当前列需要占用几个格子? 普通的都是8, 只有datetime between是16
+ let spaceNeed = 8;
+ if (field.showType === 'between' && field.dataType === 'datetime') {
+ spaceNeed = 16;
+ }
+
+ // 如果当前行空间不足, 就换行
+ if (spaceLeft < spaceNeed) {
+ rows.push({cols}
);
+ cols = []; // 不知array有没有clear之类的方法
+ spaceLeft = 24; // 剩余空间重置
+ }
+
+ // 开始push各种FormItem
+ switch (field.showType) {
+ case 'select':
+ cols.push(this.transformSelect(field));
+ break;
+ case 'radio':
+ cols.push(this.transformRadio(field));
+ break;
+ case 'checkbox':
+ cols.push(this.transformCheckbox(field));
+ break;
+ case 'multiSelect':
+ cols.push(this.transformMultiSelect(field));
+ break;
+ case 'between':
+ for (const col of this.transformBetween(field)) { // between类型比较特殊, 返回的是一个数组
+ cols.push(col)
+ }
+ break;
+ default:
+ cols.push(this.transformNormal(field));
+ }
+
+ spaceLeft -= spaceNeed;
+ });
+
+ // 别忘了最后一行
+ if (cols.length > 0) {
+ rows.push({cols}
);
+ }
+
+ // 上传相关配置
+ const uploadProps = {
+ name: 'file',
+ action: `${globalConfig.getAPIPath()}/${this.props.tableName}/import`,
+ showUploadList: false,
+ onChange: this.handleImport,
+ };
+
+ // 表单的前面是一堆输入框, 最后一行是按钮
+ return (
+
+ );
+ }
+
+}
+
+InnerForm = Form.create()(InnerForm); // antd中的表单组件还要这么包装一层
+
+export default InnerForm;
diff --git a/src/components/DBTable/InnerPagination.js b/src/components/DBTable/InnerPagination.js
new file mode 100644
index 0000000..da016c4
--- /dev/null
+++ b/src/components/DBTable/InnerPagination.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import {Pagination, Select} from 'antd';
+
+/**
+ * 内部分页器组件
+ */
+class InnerPagination extends React.Component {
+
+ render() {
+ // 有些状态要传到父组件中去处理
+ return (
+
+
`每页${this.props.pageSize}条, 共 ${total} 条`}
+ pageSize={this.props.pageSize} defaultCurrent={1}
+ current={this.props.currentPage}
+ onChange={this.props.parentHandlePageChange}
+ />
+
+ );
+ }
+
+}
+
+export default InnerPagination;
diff --git a/src/components/DBTable/InnerTable.js b/src/components/DBTable/InnerTable.js
new file mode 100644
index 0000000..ee88c02
--- /dev/null
+++ b/src/components/DBTable/InnerTable.js
@@ -0,0 +1,425 @@
+import React from 'react';
+import {
+ Form,
+ Input,
+ Button,
+ DatePicker,
+ Select,
+ Table,
+ Icon,
+ Radio,
+ InputNumber,
+ Checkbox,
+ Modal,
+ message,
+ notification,
+ Affix
+} from 'antd';
+import globalConfig from 'config.js';
+import Logger from '../../utils/Logger';
+import ajax from '../../utils/ajax';
+
+const FormItem = Form.Item;
+const ButtonGroup = Button.Group;
+
+const logger = Logger.getLogger('InnerTable');
+
+/**
+ * 内部表格组件
+ */
+class InnerTable extends React.Component {
+
+ // 很多时候都要在antd的组件上再包一层
+ state = {
+ modalVisible: false, // modal是否可见
+ modalTitle: '新增', // modal标题
+ modalInsert: true, // 当前modal是用来insert还是update
+
+ selectedRowKeys: [], // 当前有哪些行被选中, 这里只保存key
+ selectedRows: [], // 当前有哪些行被选中, 保存完整数据
+ };
+
+ /**
+ * InnerTable组件的重render有两种可能:
+ * 1. 上层组件调用的render方法, 这个时候会触发componentWillReceiveProps方法
+ * 2. 自身状态变化引起的重新render
+ * 注意区分
+ *
+ * 对于第一种情况, 要将组件的状态还原到初始状态
+ *
+ * @param nextProps
+ */
+ componentWillReceiveProps = (nextProps) => {
+ logger.debug('receive new props and try to render, nextProps=%o', nextProps);
+ // 其实传入的props和当前的props可能是一样的, 这个方法不会判断修改才触发
+ // 要自己判断props是否有变化
+
+ // 蛋疼的是, 要区分是上层组件引起的这个方法调用还是InnerTable组件本身引起的
+ // 本来自身的变化是不应该触发的, 但不知antd的form组件做了什么, 每次给表单设置值的时候就会触发, 我讨厌黑盒...
+ // 利用了一个特性: 如果是上层触发的, tableLoading必定是true
+ if (nextProps.tableLoading === true) {
+ // 所有状态都要手动还原到初始值
+ // 这个方法里setState不会触发render
+ this.setState({
+ modalVisible: false,
+ modalTitle: '新增',
+ modalInsert: true,
+
+ selectedRowKeys: [],
+ selectedRows: [],
+ });
+ }
+ }
+
+ /**
+ * 点击新增按钮, 弹出一个内嵌表单的modal
+ *
+ * @param e
+ */
+ onClickInsert = (e) => {
+ e.preventDefault();
+ this.props.form.resetFields();
+ this.setState({modalVisible: true, modalTitle: '新增', modalInsert: true});
+ }
+
+ /**
+ * 点击更新按钮, 弹出一个内嵌表单的modal
+ * 注意区分单条更新和批量更新
+ *
+ * @param e
+ */
+ onClickUpdate = (e) => {
+ e.preventDefault();
+ this.props.form.resetFields();
+ const multiSelected = this.state.selectedRowKeys.length > 1; // 是否选择了多项
+ // 如果只选择了一项, 就把原来的值填到表单里
+ // 否则就只把要更新的主键填到表单里
+ if (!multiSelected) {
+ logger.debug('update single record, and fill original values');
+ this.props.form.setFieldsValue(this.state.selectedRows.pop()); // FIXME: 理论上来说不应该这样去修改state中的值
+ } else {
+ const tmpObj = {};
+ tmpObj[this.primaryKey] = this.state.selectedRowKeys.join(', ');
+ logger.debug('update multiple records, keys = %s', tmpObj[this.primaryKey]);
+ this.props.form.setFieldsValue(tmpObj);
+ }
+
+ if (multiSelected) {
+ this.setState({modalVisible: true, modalTitle: '批量更新', modalInsert: false});
+ } else {
+ this.setState({modalVisible: true, modalTitle: '更新', modalInsert: false});
+ }
+ }
+
+ /**
+ * 点击删除按钮, 弹出一个确认对话框
+ * 注意区分单条删除和批量删除
+ *
+ * @param e
+ */
+ onClickDelete = (e) => {
+ e.preventDefault();
+ Modal.confirm({
+ title: this.state.selectedRowKeys.length > 1 ? '确认批量删除' : '确认删除',
+ content: `当前被选中的行: ${this.state.selectedRowKeys.join(', ')}`,
+ // 这里注意要用箭头函数, 否则this不生效
+ onOk: () => {
+ this.handleDelete();
+ },
+ });
+ }
+
+ /**
+ * 隐藏modal
+ */
+ hideModal = () => {
+ this.setState({modalVisible: false});
+ }
+
+ /**
+ * 点击modal中确认按钮的回调
+ */
+ handleModalOk = () => {
+ // 将表单中的undefined去掉
+ // 表单传过来的主键是逗号分隔的字符串, 这里转换成数组
+ const newObj = {};
+ const primaryKeyArray = [];
+ const oldObj = this.props.form.getFieldsValue();
+ for (const key in oldObj) {
+ if (!oldObj[key])
+ continue;
+
+ if (key === this.primaryKey && typeof oldObj[key] === 'string') {
+ for (const str of oldObj[key].split(', ')) {
+ // 按schema中的约定, 主键只能是int/varchar
+ if (this.primaryKeyType === 'int') {
+ primaryKeyArray.push(parseInt(str));
+ } else {
+ primaryKeyArray.push(str);
+ }
+ }
+ } else {
+ newObj[key] = oldObj[key];
+ }
+ }
+ if (this.primaryKey && primaryKeyArray.length > 0) {
+ newObj[this.primaryKey] = primaryKeyArray;
+ }
+ this.hideModal();
+ logger.debug('click modal OK and the form obj = %o', newObj);
+
+ if (this.state.modalInsert) {
+ this.handleInsert(newObj);
+ } else {
+ this.handleUpdate(newObj);
+ }
+ }
+
+
+ /*下面开始才是真正的数据库操作*/
+
+
+ /**
+ * 真正去处理新增数据
+ */
+ handleInsert = (obj) => {
+ const url = `${globalConfig.getAPIPath()}/${this.props.tableName}/insert`;
+ const hide = message.loading('正在新增...', 0);
+ logger.debug('handleInsert: url = %s, obj = %o', url, obj);
+
+ ajax.post(url).send(obj).end((err, res) => {
+ hide();
+ logger.debug('handleInsert: return error = %o, res = %o', err, res);
+ // err就是一个字符串
+ // res是一个Response对象, 其中的body字段才是服务端真正返回的数据
+ if (err || !res.body.success) {
+ notification.error({
+ message: '新增失败',
+ description: err ? '请求insert接口出错, 请联系管理员' : `请联系管理员, 错误信息: ${res.body.message}`,
+ duration: 0,
+ });
+ } else {
+ notification.success({
+ message: '新增成功',
+ description: this.primaryKey ? `新增数据行 主键=${res.body.data[this.primaryKey]}` : '',
+ duration: 0,
+ });
+ }
+ });
+ }
+
+ /**
+ * 真正去更新数据
+ */
+ handleUpdate = (obj) => {
+ const keys = obj[this.primaryKey];
+ const url = `${globalConfig.getAPIPath()}/${this.props.tableName}/update?keys=${keys instanceof Array ? keys.join(',') : keys}`;
+ const hide = message.loading('正在更新...', 0);
+ obj[this.primaryKey] = undefined;
+
+ logger.debug('handleUpdate: url = %s, obj = %o', url, obj);
+
+ ajax.post(url).send(obj).end((err, res) => {
+ hide();
+ logger.debug('handleUpdate: return error = %o, res = %o', err, res);
+
+ if (err || !res.body.success) {
+ notification.error({
+ message: '更新失败',
+ description: err ? '请求update接口出错, 请联系管理员' : `请联系管理员, 错误信息: ${res.body.message}`,
+ duration: 0,
+ });
+ } else {
+ notification.success({
+ message: '更新成功',
+ description: `更新${res.body.data}条数据`,
+ duration: 0,
+ });
+ // 更新数据后, 注意刷新下整个页面
+ this.props.refresh();
+ }
+ });
+ }
+
+ /**
+ * 真正去删除数据
+ */
+ handleDelete = () => {
+ const url = `${globalConfig.getAPIPath()}/${this.props.tableName}/delete?keys=${this.state.selectedRowKeys.join(',')}`;
+ const hide = message.loading('正在删除...', 0);
+ logger.debug('handleDelete: url = %s', url);
+
+ ajax.post(url).end((err, res) => {
+ hide();
+ logger.debug('handleDelete: return error = %o, res = %o', err, res);
+ if (err || !res.body.success) {
+ notification.error({
+ message: '删除失败',
+ description: err ? '请求delete接口出错, 请联系管理员' : `请联系管理员, 错误信息: ${res.body.message}`,
+ duration: 0,
+ });
+ } else {
+ notification.success({
+ message: '删除成功',
+ description: `删除${res.body.data}条数据`,
+ duration: 0,
+ });
+ // 更新数据后, 注意刷新下整个页面
+ this.props.refresh();
+ }
+ });
+ }
+
+ /**
+ * 辅助函数
+ *
+ * @param formItem
+ * @param field
+ * @returns {XML}
+ */
+ colWrapper = (formItem, field) => {
+ //const {getFieldProps, getFieldError, isFieldValidating} = this.props.form;
+ return (
+
+ {formItem}
+
+ );
+ }
+
+ /**
+ * 将schema中的一个字段转换为表单的一项
+ *
+ * @param field
+ */
+ transFormField = (field) => {
+ const {getFieldProps} = this.props.form;
+
+ // 对于主键, 直接返回一个不可编辑的textarea
+ if (this.primaryKey === field.key) {
+ logger.debug('key %o is primary, transform to text area', field);
+ return this.colWrapper((
+
+ ), field);
+ }
+
+ switch (field.dataType) {
+ case 'int':
+ logger.debug('transform field %o to integer input', field);
+ return this.colWrapper((
+
+ ), field);
+ case 'float':
+ logger.debug('transform field %o to float input', field);
+ return this.colWrapper((
+
+ ), field);
+ case 'datetime':
+ logger.debug('transform field %o to datetime input', field);
+ return this.colWrapper((
+
+ ), field);
+ default: // 默认就是普通的输入框
+ logger.debug('transform field %o to varchar input', field);
+ return this.colWrapper((
+
+ ), field);
+ }
+ }
+
+ /**
+ * 处理表格的选择事件
+ *
+ * @param selectedRowKeys
+ * @param selectedRows
+ */
+ handleSelectChange = (selectedRowKeys, selectedRows) => {
+ this.setState({selectedRowKeys, selectedRows});
+ }
+
+ render() {
+ // 解析schema
+ const newCols = [];
+ this.props.schema.forEach((field) => {
+ const col = {};
+ col.key = field.key;
+ col.dataIndex = field.key;
+ col.title = field.title;
+ if (field.render) {
+ col.render = field.render;
+ }
+ newCols.push(col);
+ // 当前列是否是主键?
+ if (field.primary) {
+ this.primaryKey = field.key;
+ this.primaryKeyType = field.dataType;
+ }
+ });
+
+ // 对数据也要处理一下
+ // 每行数据都必须有个key属性, 如果指定了主键, 就以主键为key
+ // 否则直接用个自增数字做key
+ const newData = [];
+ let i = 0;
+ this.props.data.forEach((obj) => {
+ const newObj = Object.assign({}, obj);
+ if (this.primaryKey) {
+ newObj.key = obj[this.primaryKey];
+ } else {
+ newObj.key = i;
+ i++;
+ }
+ newData.push(newObj);
+ });
+
+ // 生成表单项
+ const formItems = [];
+ this.props.schema.forEach((field) => {
+ formItems.push(this.transFormField(field));
+ });
+
+ const rowSelection = {
+ selectedRowKeys: this.state.selectedRowKeys,
+ onChange: this.handleSelectChange,
+ };
+
+ const hasSelected = this.state.selectedRowKeys.length > 0; // 是否选择
+ const multiSelected = this.state.selectedRowKeys.length > 1; // 是否选择了多项
+
+ return (
+
+
+
document.getElementById('main-content-div')}>
+
+
+ 新增
+
+ {/* 注意这里, 如果schema中没有定义主键, 不允许update或delete */}
+
+ {multiSelected ? '批量修改' : '修改'}
+
+
+ {multiSelected ? '批量删除' : '删除'}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+InnerTable = Form.create()(InnerTable);
+
+export default InnerTable;
diff --git a/src/components/DBTable/index.js b/src/components/DBTable/index.js
new file mode 100644
index 0000000..de77996
--- /dev/null
+++ b/src/components/DBTable/index.js
@@ -0,0 +1,276 @@
+import React from 'react';
+import {message, notification} from 'antd';
+import Error from '../Error';
+import InnerForm from './InnerForm.js';
+import InnerTable from './InnerTable.js';
+import InnerPagination from './InnerPagination.js';
+import './index.less';
+import globalConfig from 'config.js';
+import ajax from '../../utils/ajax';
+import Logger from '../../utils/Logger';
+
+const logger = Logger.getLogger('DBTable');
+
+/**
+ * 操作数据库中的一张表的组件, 又可以分为3个组件: 表单+表格+分页器
+ */
+class DBTable extends React.Component {
+
+ // 代替componentWillMount
+ constructor(props) {
+ super(props);
+ // 组件初始化时尝试获取schema
+ this.tryFetchSchema(this.props);
+ }
+
+ // 父组件要保存子组件的状态...非常蛋疼...
+ // 破坏了子组件的"封闭"原则
+
+ // 注意: 向父组件传状态, 通过回调函数的形式
+ // 从父组件接收状态, 通过props的形式
+
+ state = {
+ // 表单组件的状态
+ queryObj: {}, // 表单中的查询条件
+
+ // 表格组件的状态
+ data: [], // 表格中显示的数据
+ tableLoading: false, // 表格是否是loading状态
+
+ // 分页器的状态
+ currentPage: 1, // 当前第几页
+ pageSize: 50, // pageSize暂时不可修改, 固定50
+ total: 0, // 总共有多少条数据
+ };
+
+ /**
+ * 尝试获取某个表的querySchema和dataSchema
+ * 无论是从远端获取还是从本地配置读取, 这个方法必须是同步的
+ *
+ * @param dbName
+ * @param tableName
+ */
+ tryFetchSchema(props) {
+ const routes = props.routes;
+ // 这个tableName是路由表配置中传过来的
+ // 可以用这个方法向组件传值
+ const tableName = routes.pop().tableName;
+ if (tableName) {
+ logger.info('init component DBTable with tableName = %s', tableName);
+ } else {
+ logger.error('can not find tableName, check your router config');
+ this.inited = false; // 是否成功获取schema
+ this.errorMsg = '找不到表名, 请检查路由配置'; // 如果没能成功获取schema, 错误信息是什么?
+ return;
+ }
+
+ // 其实很多这种this.xxx变量也可以做成状态, 看情况了
+ // 关键是这些变量变化时, 是否要触发重新render?
+
+ this.tableName = tableName;
+
+ // 尝试加载querySchema
+ try {
+ this.querySchema = require(`../../schema/${tableName}.querySchema.js`);
+ } catch (e) {
+ logger.error('load query schema error: %o', e);
+ this.inited = false;
+ this.errorMsg = `加载${tableName}表的querySchema出错, 请检查配置`;
+ return;
+ }
+
+ // 尝试加载dataSchema
+ try {
+ this.dataSchema = require(`../../schema/${tableName}.dataSchema.js`);
+ } catch (e) {
+ logger.error('load data schema error: %o', e);
+ this.inited = false;
+ this.errorMsg = `加载${tableName}表的dataSchema出错, 请检查配置`;
+ return;
+ }
+
+ // 尝试加载个性化配置, 加载失败也没影响
+ try {
+ this.tableConfig = require(`../../schema/${tableName}.config.js`);
+ } catch (e) {
+ logger.warn('can not find config for table %s, use default instead', tableName);
+
+ // TODO: 默认配置写在这里
+ this.tableConfig = {
+ showExport: true, // 显示导出按钮, 默认true
+ showImport: true, // 显示导入按钮, 默认true
+ }
+ }
+
+ this.inited = true;
+ }
+
+ /**
+ * 刚进入页面时触发一次查询
+ */
+ componentDidMount() {
+ this.refresh();
+ }
+
+ // 在react router中切换时, 组件不会重新mount, 只有props会变化
+ componentWillReceiveProps = (nextProps) => {
+ logger.debug('receive new props and try to render, nextProps = %o', nextProps);
+ this.tryFetchSchema(nextProps); // 根据新的props重新获取配置
+ // 所有状态都要手动还原到初始值
+ // this.setState({
+ // queryObj: {},
+ // data: [],
+ // tableLoading: false,
+ // currentPage: 1,
+ // pageSize: 50,
+ // total: 0,
+ // });
+
+ // 这种写法和setState有何区别?
+ // 似乎setState不是"立即"发生的, 会导致下面refresh时有bug
+ this.state.queryObj = {};
+ this.state.data = [];
+ this.state.tableLoading = false;
+ this.state.currentPage = 1;
+ this.state.pageSize = 50;
+ this.state.total = 0;
+
+ // 切换时也要重新查询一次数据
+ this.refresh();
+ }
+
+ /**
+ * 切换分页时触发查询
+ *
+ * @param page
+ */
+ handlePageChange = (page) => {
+ logger.debug('handlePageChange, page = %d', page);
+ this.setState({tableLoading: true});
+ this.select(this.state.queryObj, page, this.state.pageSize).then((result) => {
+ //message.success('查询成功');
+ this.setState({
+ currentPage: page,
+ data: result.data,
+ total: result.total,
+ tableLoading: false,
+ });
+ }, this.handleError);
+ }
+
+ /**
+ * 点击提交按钮时触发查询
+ *
+ * @param queryObj
+ */
+ handleFormSubmit = (queryObj) => {
+ logger.debug('handleFormSubmit, queryObj = %o', queryObj);
+ this.setState({tableLoading: true});
+ // 这时查询条件已经变了, 要从第一页开始查
+ this.select(queryObj, 1, this.state.pageSize).then((result) => {
+ //message.success('查询成功');
+ this.setState({
+ currentPage: 1,
+ data: result.data,
+ total: result.total,
+ tableLoading: false,
+ queryObj: queryObj,
+ });
+ }, this.handleError);
+ }
+
+ /**
+ * 统一处理ajax失败时的回调
+ *
+ * @param errorMsg
+ */
+ handleError = (errorMsg) => {
+ // 对于错误信息, 要很明显的提示用户, 这个通知框要用户手动关闭
+ notification.error({
+ message: '出错啦!',
+ description: `请联系管理员, 错误信息: ${errorMsg}`,
+ duration: 0,
+ });
+ this.setState({tableLoading: false});
+ };
+
+ /**
+ * 按当前的查询条件重新查询一次
+ */
+ refresh = () => {
+ this.setState({tableLoading: true});
+ this.select(this.state.queryObj, this.state.currentPage, this.state.pageSize).then((result) => {
+ //message.success('查询成功');
+ this.setState({
+ data: result.data,
+ total: result.total,
+ tableLoading: false,
+ });
+ }, this.handleError);
+ }
+
+ /**
+ * 向服务端发送select请求, 会返回一个promise对象
+ *
+ * @param queryObj 包含了form中所有的查询条件, 再加上page和pageSize, 后端就能拼成完整的sql
+ * @param page
+ * @param pageSize
+ * @returns {Promise}
+ */
+ select(queryObj, page, pageSize) {
+ const hide = message.loading('正在查询...', 0);
+ // superagent请求会直接返回一个promise对象, 但不能直接用, 还是要包装一层
+ const tmpObj = Object.assign({}, queryObj); // 创建一个新的临时对象, 其实直接修改queryObj也可以
+ tmpObj.page = page;
+ tmpObj.pageSize = pageSize;
+ const url = `${globalConfig.getAPIPath()}/${this.tableName}/select`; // 拼接要请求的url地址
+ logger.debug('select: url = %s, queryObj = %o', url, tmpObj);
+
+ // 只是为了试下ES6的Promise特性...其实直接用superagent的API也可以
+ const promise = new Promise((resolve, reject) => {
+ ajax.post(url).send(tmpObj).end((err, res) => {
+ hide();
+ // err就是一个字符串
+ // res是一个Response对象, 其中的body字段才是服务端真正返回的数据
+ if (err || !res.body.success) {
+ reject(err ? '请求select接口出错, 请联系管理员' : res.body.message);
+ } else {
+ resolve(res.body);
+ }
+ });
+ });
+
+ return promise;
+ }
+
+ render() {
+ // 如果没能成功加载schema, 显示错误信息
+ if (!this.inited) {
+ return (
+
+ );
+ }
+
+ // 之前是直接{...this.state}, 感觉会影响效率
+ // 父组件传进去的方法名都是parentHandleXXX
+
+ // InnerForm和InnerPagination都是无状态的
+ // 但InnerTable还是要维护自己的一些状态
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+
+export default DBTable;
diff --git a/src/components/DBTable/index.less b/src/components/DBTable/index.less
new file mode 100644
index 0000000..dd923ea
--- /dev/null
+++ b/src/components/DBTable/index.less
@@ -0,0 +1,32 @@
+.ant-advanced-search-form {
+ padding: 16px 8px;
+ background: #f8f8f8;
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+}
+
+/* 由于输入标签长度不确定,所以需要微调使之看上去居中 */
+.ant-advanced-search-form > .ant-row {
+ position: relative;
+ left: -6px;
+}
+
+.ant-advanced-search-form .ant-btn + .ant-btn {
+ margin-left: 8px;
+}
+
+/* 我讨厌写css... */
+.db-pagination {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ float: left;
+}
+
+.db-table-button {
+ margin-top: 8px;
+ margin-bottom: 8px;
+}
+
+.ant-upload {
+ margin-left: 8px;
+}
diff --git a/src/components/Error/index.js b/src/components/Error/index.js
new file mode 100644
index 0000000..37fd177
--- /dev/null
+++ b/src/components/Error/index.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import {Icon} from 'antd';
+import './index.less';
+
+/**
+ * 显示错误信息
+ * 可以当404页来用
+ */
+class Error extends React.Component {
+
+ render() {
+ return (
+
+
+
{this.props.errorMsg || '404 Not Found'}
+
+ );
+ }
+
+}
+
+export default Error;
diff --git a/src/components/Error/index.less b/src/components/Error/index.less
new file mode 100644
index 0000000..1883a04
--- /dev/null
+++ b/src/components/Error/index.less
@@ -0,0 +1,8 @@
+.not-found {
+ color: black;
+ text-align: center;
+}
+
+.not-found h1 {
+ font-family: "PingFang SC",
+}
diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js
new file mode 100644
index 0000000..99c4b2c
--- /dev/null
+++ b/src/components/Footer/index.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import {BackTop} from 'antd';
+import globalConfig from 'config.js';
+
+/**
+ * 定义Footer组件
+ */
+class Footer extends React.Component {
+
+ render() {
+ // FIXME: gross hack
+ // footer组件第一次render的时候, 不能返回BackTop, 因为main-content-div还没render
+ if (this.inited) {
+ return (
+
+
document.getElementById('main-content-div')}/>
+
+ {globalConfig.footer || 'footer被弄丢啦!'}
+
+
+ );
+ } else {
+ this.inited = true;
+ return (
+
+
+ {globalConfig.footer || 'footer被弄丢啦!'}
+
+
+ );
+ }
+
+ }
+
+}
+
+export default Footer;
diff --git a/src/components/Header/index.js b/src/components/Header/index.js
new file mode 100644
index 0000000..ddc45d3
--- /dev/null
+++ b/src/components/Header/index.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import {Icon, Menu, Dropdown} from 'antd';
+import globalConfig from 'config';
+import './index.less';
+
+const SubMenu = Menu.SubMenu; // 为了使用方便
+
+/**
+ * 定义Header组件, 包括登录/注销的链接, 以及一些自定义链接
+ */
+class Header extends React.Component {
+
+ render() {
+ return (
+
+ {/*定义header中的菜单*/}
+
+ {this.props.userName}}>
+
+ 注销
+
+
+
+
+ );
+ }
+
+}
+
+export default Header;
diff --git a/src/components/Header/index.less b/src/components/Header/index.less
new file mode 100644
index 0000000..1f32a43
--- /dev/null
+++ b/src/components/Header/index.less
@@ -0,0 +1,8 @@
+.ant-layout-header {
+ background: #fff;
+ .header-menu {
+ > li {
+ float: right;
+ }
+ }
+}
diff --git a/src/components/Hello/index.js b/src/components/Hello/index.js
new file mode 100644
index 0000000..216cda7
--- /dev/null
+++ b/src/components/Hello/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import './index.less';
+
+/**
+ * 测试用
+ */
+class Hello extends React.Component {
+
+ render() {
+ return
Hello, React! ;
+ }
+
+}
+
+export default Hello;
diff --git a/src/components/Hello/index.less b/src/components/Hello/index.less
new file mode 100644
index 0000000..f0a3ff6
--- /dev/null
+++ b/src/components/Hello/index.less
@@ -0,0 +1,3 @@
+.testStyle {
+ color: red;
+}
diff --git a/src/components/Login/index.js b/src/components/Login/index.js
new file mode 100644
index 0000000..f2f62bd
--- /dev/null
+++ b/src/components/Login/index.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import globalConfig from 'config';
+import ajax from '../../utils/ajax';
+import Logger from '../../utils/Logger';
+import {message} from 'antd';
+import './index.less';
+
+const logger = Logger.getLogger('Login');
+
+/**
+ * 定义Login组件
+ */
+class Login extends React.Component {
+
+ // 这个login组件是直接从网上找的: https://colorlib.com/wp/html5-and-css3-login-forms/
+ // 不是react风格...
+ // TODO: 以后要把这个login组件改掉
+
+ handleSubmit = (e) => {
+ e.preventDefault(); // 这个很重要, 防止跳转
+ const hide = message.loading('正在验证...', 0);
+
+ const username = ReactDOM.findDOMNode(this.refs.user).value;
+ const password = ReactDOM.findDOMNode(this.refs.pass).value;
+ logger.debug('username = %s, password = %s', username, password);
+
+ const button = ReactDOM.findDOMNode(this.refs.button);
+ button.setAttribute('disabled', 'true'); // 暂时屏蔽提交按钮, 防止用户重复点击
+
+ // 服务端验证
+ ajax.post(`${globalConfig.getAPIPath()}${globalConfig.login.validate}`).type('form').send({
+ username,
+ password,
+ }).end((err, res) => {
+ hide();
+ logger.debug('login validate return: error %o result %o', err, res);
+
+ if (ajax.isSuccess(res)) {
+ // 如果登录成功, 调用回调函数, 把状态往上层组件传
+ if (this.props.loginSuccess)
+ this.props.loginSuccess(res.body.data, true);
+ else
+ message.info(`登录成功, 用户名: ${res.body.data}`);
+ } else {
+ message.error(`登录失败: ${res.body.message}, 请联系管理员`);
+ button.removeAttribute('disabled'); // 登录失败的话, 重新让按钮可用, 让用户重新登录
+ }
+ });
+ }
+
+ componentWillMount() {
+ logger.debug('mount and add styles');
+ document.body.style.background = null;
+ }
+
+ componentWillUnmount() {
+ logger.debug('unmount and clear styles');
+ // 组件unmount时设置下样式, 不然其他组件的显示会有问题
+ // TODO: 不知道有没有更好的办法
+ document.body.style.background = 'white';
+ }
+
+ render() {
+ return (
+
+ {/*debug模式下显示fork me on github*/}
+ {globalConfig.debug &&
+
+
+ }
+
+
+
{globalConfig.name}
+
+
+
+
+ );
+ }
+
+}
+
+export default Login;
diff --git a/src/components/Login/index.less b/src/components/Login/index.less
new file mode 100644
index 0000000..519fa04
--- /dev/null
+++ b/src/components/Login/index.less
@@ -0,0 +1,164 @@
+// 都是直接抄来的样式
+// 对我来说就像天书一样好嘛...
+// 这玩意真的是人工手写出来的???
+
+.btn {
+ display: inline-block;
+ *display: inline;
+ *zoom: 1;
+ padding: 4px 10px 4px;
+ margin-bottom: 0;
+ font-size: 13px;
+ line-height: 18px;
+ color: #333333;
+ text-align: center;
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+ vertical-align: middle;
+ background-color: #f5f5f5;
+ background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6);
+ background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6);
+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6));
+ background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6);
+ background-image: -o-linear-gradient(top, #ffffff, #e6e6e6);
+ background-image: linear-gradient(top, #ffffff, #e6e6e6);
+ background-repeat: repeat-x;
+ filter: progid:dximagetransform.microsoft.gradient(startColorstr=#ffffff, endColorstr=#e6e6e6, GradientType=0);
+ border-color: #e6e6e6 #e6e6e6 #e6e6e6;
+ border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
+ border: 1px solid #e6e6e6;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ cursor: pointer;
+ *margin-left: .3em;
+}
+
+.btn:hover, .btn:active, .btn.active, .btn.disabled, .btn[disabled] {
+ background-color: #e6e6e6;
+}
+
+.btn-large {
+ padding: 9px 14px;
+ font-size: 15px;
+ line-height: normal;
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+ border-radius: 5px;
+}
+
+.btn:hover {
+ color: #333333;
+ text-decoration: none;
+ background-color: #e6e6e6;
+ background-position: 0 -15px;
+ -webkit-transition: background-position 0.1s linear;
+ -moz-transition: background-position 0.1s linear;
+ -ms-transition: background-position 0.1s linear;
+ -o-transition: background-position 0.1s linear;
+ transition: background-position 0.1s linear;
+}
+
+.btn-primary, .btn-primary:hover {
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ color: #ffffff;
+}
+
+.btn-primary.active {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.btn-primary {
+ background-color: #4a77d4;
+ background-image: -moz-linear-gradient(top, #6eb6de, #4a77d4);
+ background-image: -ms-linear-gradient(top, #6eb6de, #4a77d4);
+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#6eb6de), to(#4a77d4));
+ background-image: -webkit-linear-gradient(top, #6eb6de, #4a77d4);
+ background-image: -o-linear-gradient(top, #6eb6de, #4a77d4);
+ background-image: linear-gradient(top, #6eb6de, #4a77d4);
+ background-repeat: repeat-x;
+ filter: progid:dximagetransform.microsoft.gradient(startColorstr=#6eb6de, endColorstr=#4a77d4, GradientType=0);
+ border: 1px solid #3762bc;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.5);
+}
+
+.btn-primary:hover, .btn-primary:active, .btn-primary.active, .btn-primary.disabled, .btn-primary[disabled] {
+ filter: none;
+ background-color: #4a77d4;
+}
+
+.btn-block {
+ width: 100%;
+ display: block;
+}
+
+* {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ -o-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+html {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ width: 100%;
+ height: 100%;
+ font-family: 'Open Sans', sans-serif;
+ background: #092756;
+ background: -moz-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -moz-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -moz-linear-gradient(-45deg, #670d10 0%, #092756 100%);
+ background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -webkit-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -webkit-linear-gradient(-45deg, #670d10 0%, #092756 100%);
+ background: -o-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -o-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -o-linear-gradient(-45deg, #670d10 0%, #092756 100%);
+ background: -ms-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -ms-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -ms-linear-gradient(-45deg, #670d10 0%, #092756 100%);
+ background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), linear-gradient(to bottom, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), linear-gradient(135deg, #670d10 0%, #092756 100%);
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#3E1D6D', endColorstr='#092756', GradientType=1);
+}
+
+.login {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -150px 0 0 -150px;
+ width: 300px;
+ height: 300px;
+}
+
+.login h1 {
+ color: #fff;
+ text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
+ letter-spacing: 1px;
+ text-align: center;
+ padding-bottom: 15px;
+}
+
+.login-input {
+ width: 100%;
+ margin-bottom: 10px;
+ background: rgba(0, 0, 0, 0.3);
+ border: none;
+ outline: none;
+ padding: 10px;
+ font-size: 13px;
+ color: #fff;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ border-radius: 4px;
+ box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.2), 0 1px 1px rgba(255, 255, 255, 0.2);
+ -webkit-transition: box-shadow .5s ease;
+ -moz-transition: box-shadow .5s ease;
+ -o-transition: box-shadow .5s ease;
+ -ms-transition: box-shadow .5s ease;
+ transition: box-shadow .5s ease;
+}
+
+.login-input:focus {
+ box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.4), 0 1px 1px rgba(255, 255, 255, 0.2);
+}
diff --git a/src/components/Logo/index.js b/src/components/Logo/index.js
new file mode 100644
index 0000000..0b98cf7
--- /dev/null
+++ b/src/components/Logo/index.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import globalConfig from 'config.js';
+import './index.less';
+
+/**
+ * 定义Logo组件
+ */
+class Logo extends React.Component {
+
+ render() {
+ // TODO: 这个logo的样式还是要优化下
+ return (
+
+
+ {globalConfig.name}
+
+
+ );
+ }
+
+}
+
+export default Logo;
diff --git a/src/components/Logo/index.less b/src/components/Logo/index.less
new file mode 100644
index 0000000..bedb839
--- /dev/null
+++ b/src/components/Logo/index.less
@@ -0,0 +1,6 @@
+.logo-text {
+ text-align: center;
+ font-family: "Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,SimSun,sans-serif";
+ font-size: 22px;
+ color: #2db7f5;
+}
diff --git a/src/components/Sidebar/index.js b/src/components/Sidebar/index.js
new file mode 100644
index 0000000..378448d
--- /dev/null
+++ b/src/components/Sidebar/index.js
@@ -0,0 +1,113 @@
+import React from 'react';
+import {Link} from 'react-router';
+import {Menu, Icon} from 'antd';
+import Logo from '../Logo';
+import Logger from '../../utils/Logger';
+import items from 'menu.js'; // 由于webpack中的设置, 不用写完整路径
+
+const SubMenu = Menu.SubMenu;
+const MenuItem = Menu.Item;
+
+const logger = Logger.getLogger('Sidebar');
+
+/**
+ * 定义sidebar组件
+ */
+class Sidebar extends React.Component {
+
+ /**
+ * 将菜单项配置转换为对应的MenuItem组件
+ *
+ * @param obj sidebar菜单配置项
+ * @param paths 父级目录, array
+ * @returns {XML}
+ */
+ transFormMenuItem(obj, paths) {
+ const parentPath = paths.join('/'); // 将各级父目录组合成完整的路径
+ logger.debug('transform %o to path %s', obj, parentPath);
+
+ // 这个表达式还是有点恶心的...
+ // JSX虽然方便, 但是很容易被滥用, ES6也是
+ return (
+
+ {obj.child ? obj.name : {obj.name} }
+
+ );
+ }
+
+ // 在每次组件挂载的时候parse一次菜单, 不用每次render都解析
+ componentWillMount() {
+ const paths = []; // 暂存各级路径, 当作stack用
+ const defaultOpenKeys = [];
+
+ // 菜单项是从配置中读取的, parse过程还是有点复杂的
+ // map函数很好用
+ const menu = items.map((level1) => {
+ // parse一级菜单
+ paths.push(level1.key);
+ if (defaultOpenKeys.length === 0) {
+ defaultOpenKeys.push(level1.key); // 默认展开第一个菜单
+ }
+
+ // 是否有子菜单?
+ if (level1.child) {
+ const level2menu = level1.child.map((level2) => {
+ // parse二级菜单
+ paths.push(level2.key);
+ if (level2.child) {
+ const level3menu = level2.child.map((level3) => {
+ // parse三级菜单, 不可能再有子菜单了
+ paths.push(level3.key);
+ const tmp = this.transFormMenuItem(level3, paths);
+ paths.pop();
+ return tmp;
+ });
+
+ paths.pop();
+ return (
+
+ {level3menu}
+
+ );
+
+ } else {
+ const tmp = this.transFormMenuItem(level2, paths);
+ paths.pop();
+ return tmp;
+ }
+ });
+
+ paths.pop();
+ return (
+ {level1.name}}>
+ {level2menu}
+
+ )
+ }
+ // 没有子菜单, 直接转换为MenuItem
+ else {
+ const tmp = this.transFormMenuItem(level1, paths);
+ paths.pop(); // return之前别忘了pop
+ return tmp;
+ }
+ });
+
+ this.menu = menu;
+ this.defaultOpenKeys = defaultOpenKeys;
+ }
+
+ render() {
+ // 这些样式其实是在App/index.less中定义的
+ return (
+
+ );
+ }
+
+}
+
+export default Sidebar;
diff --git a/src/components/Welcome/index.js b/src/components/Welcome/index.js
new file mode 100644
index 0000000..a01d2d5
--- /dev/null
+++ b/src/components/Welcome/index.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import './index.less';
+
+/**
+ * 展示欢迎界面
+ */
+class Welcome extends React.Component {
+
+ render() {
+ return (
+
+ );
+ }
+
+}
+
+export default Welcome;
diff --git a/src/components/Welcome/index.less b/src/components/Welcome/index.less
new file mode 100644
index 0000000..998c32f
--- /dev/null
+++ b/src/components/Welcome/index.less
@@ -0,0 +1,3 @@
+.welcome {
+ color: red;
+}
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..f83b294
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,117 @@
+/**
+ * 定义整个项目的全局配置
+ */
+
+// 约定优于配置
+// 我可以提供尽量多的配置, 但尽量不要太个性化, 接口的路径/名称/格式之类的
+// 遵循统一的规范, 好维护, 交给其他人也比较简单
+
+module.exports = {
+ name: 'OOXX管理后台', // 项目的名字
+ footer: 'xxx版权所有 © 2015-2099', // footer中显示的字
+
+ debug: true, // 是否开启debug模式, 不会请求后端接口, 使用mock的数据
+ logLevel: 'info', // 日志级别, 目前支持debug/info/warn/error 4种级别
+
+ api: { // 对后端请求的相关配置
+ host: 'http://remoteHost:8080', // 调用ajax接口的地址, 默认值空, 如果是跨域的, 服务端要支持CORS
+ path: '/api', // ajax请求的路径
+ timeout: 15000, // 请求的超时时间, 单位毫秒
+ },
+
+ login: { // 登录相关配置
+ getCurrentUser: '/getCurrentUser', // 后端必须要提供接口校验当前用户的身份, 如果拿不到用户信息, 才会尝试登录
+
+ // 登录有两种情况:
+
+ // 1. 使用sso登录, 直接跳转就可以了
+ sso: '', // 是否使用单点登录? 是的话我会把地址encode后加到后面, 然后跳转, 如果这个是空字符串, 说明不使用单点登录
+ // 2. 不使用sso, 使用我提供的一个登录界面
+ validate: '/login', // 校验用户信息, 表单的submit地址. 如果登录成功, 必须返回用户名
+
+ logout: '/logout', // 退出的url, 用户点击退出时, 浏览器会直接跳转到这个链接
+ },
+
+ // 以下一些辅助的函数, 不要修改
+ // 不能使用箭头函数, 会导致this是undefined
+
+ /**
+ * 是否要跨域请求
+ *
+ * @returns {boolean}
+ */
+ isCrossDomain: function () {
+ if (this.api.host && this.api.host !== '') {
+ return true;
+ } else {
+ return false;
+ }
+ },
+
+ /**
+ * 是否单点登录
+ *
+ * @returns {boolean}
+ */
+ isSSO: function () {
+ if (this.login.sso && this.login.sso !== '') {
+ return true;
+ } else {
+ return false;
+ }
+ },
+
+ /**
+ * 获得api请求的路径
+ *
+ * @returns {*}
+ */
+ getAPIPath: function () {
+ if (this.tmpApiPath) { // 缓存
+ return this.tmpApiPath;
+ }
+
+ const paths = [];
+
+ // js的字符串处理真是麻烦
+ if (this.isCrossDomain()) {
+ // 去除结尾的'/'
+ const tmp = this.api.host;
+ let index = tmp.length - 1;
+ // 如果超出指定的 index 范围,charAt返回一个空字符串
+ while (tmp.charAt(index) === '/') {
+ index--;
+ }
+ if (index < 0)
+ paths.push('');
+ else
+ paths.push(tmp.substring(0, index + 1));
+ } else {
+ paths.push('');
+ }
+
+ if (this.api.path) {
+ const tmp = this.api.path;
+ let begin = 0;
+ let end = tmp.length - 1;
+
+ while (tmp.charAt(begin) === '/') {
+ begin++;
+ }
+ while (tmp.charAt(end) === '/') {
+ end--;
+ }
+ if (begin > end)
+ paths.push('');
+ else
+ paths.push(tmp.substring(begin, end + 1));
+ } else {
+ paths.push('');
+ }
+
+ const tmpApiPath = paths.join('/');
+ this.tmpApiPath = tmpApiPath;
+ return tmpApiPath;
+ },
+
+};
diff --git a/src/entry/admin/index.jsx b/src/entry/admin/index.jsx
deleted file mode 100644
index 7682e54..0000000
--- a/src/entry/admin/index.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import '../../common/lib';
-import { Button ,Switch ,Icon} from 'antd';
-import ReactDOM from 'react-dom';
-import React from 'react';
-
-function onChange(checked){
- console.log('switch to ' + checked);
-}
-
-ReactDOM.render(后台首页
- } unCheckedChildren={ } />
-
, document.getElementById('react-content'));
diff --git a/src/entry/admin/user.jsx b/src/entry/admin/user.jsx
deleted file mode 100644
index 1bbba4a..0000000
--- a/src/entry/admin/user.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import '../../common/lib';
-import { Button ,Switch ,Icon} from 'antd';
-import ReactDOM from 'react-dom';
-import React from 'react';
-
-function onChange(checked){
- console.log('switch to ' + checked);
-}
-
-ReactDOM.render(用户
- } unCheckedChildren={ } />
-
, document.getElementById('react-content'));
diff --git a/src/entry/index.jsx b/src/entry/index.jsx
deleted file mode 100644
index f4e3585..0000000
--- a/src/entry/index.jsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import '../common/lib';
-import App from '../component/App';
-import ReactDOM from 'react-dom';
-import React from 'react';
-
-ReactDOM.render( , document.getElementById('react-content'));
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..50e7fc0
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,58 @@
+/**
+ * 程序的入口, 类似java中的main
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {Router, Route, IndexRoute, hashHistory} from 'react-router';
+import './utils/index.js'; // 引入各种prototype辅助方法
+
+// 开始引入各种自定义的组件
+import App from './components/App';
+import Welcome from './components/Welcome';
+import Error from './components/Error';
+import Hello from './components/Hello';
+import DBTable from './components/DBTable';
+
+// 路由表, 只要menu.js中所有的叶子节点配置了路由就可以了
+// 我本来想根据menu.js自动生成路由表, 但那样太不灵活了, 还是自己配置好些
+const routes = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+ReactDOM.render(routes, document.getElementById('root'));
diff --git a/src/menu.js b/src/menu.js
new file mode 100644
index 0000000..3735884
--- /dev/null
+++ b/src/menu.js
@@ -0,0 +1,103 @@
+/**
+ * 定义sidebar中的菜单项
+ *
+ * 一些约定:
+ * 1.菜单最多3层;
+ * 2.只有顶层的菜单可以带图标;
+ * 3.只有"叶子"节点才能跳转;
+ * 4.所有的key都不能重复;
+ */
+
+module.exports = [
+ {
+ key: 'index', // route时url中的值
+ name: '菜单哈哈哈', // 在菜单中显示的名称
+ icon: 'smile', // 只有顶层菜单可以带图标
+ child: [
+ {
+ key: 'option1',
+ name: '模拟CRUD',
+ },
+ {
+ key: 'option2',
+ name: '短信表管理',
+ },
+ {
+ key: 'option3',
+ name: '选项3',
+ },
+ ],
+ },
+ {
+ key: 'daohang',
+ name: '导航',
+ icon: 'appstore',
+ child: [
+ {
+ key: '555',
+ name: '选项5',
+ },
+ {
+ key: 'sanji', // 最多只能到三级导航
+ name: '三级导航',
+ child: [
+ {
+ key: '666',
+ name: '选项6',
+ },
+ {
+ key: '777',
+ name: '选项7',
+ },
+ {
+ key: '888',
+ name: '选项8',
+ },
+ {
+ key: '999',
+ name: '选项9',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ key: 'test',
+ name: '测试',
+ icon: 'eye',
+ child: [
+ {
+ key: 'aaa',
+ name: '选项a',
+ },
+ {
+ key: 'bbb',
+ name: '选项b',
+ },
+ {
+ key: 'ccc',
+ name: '选项c',
+ },
+ {
+ key: 'sanjiaaa', // 最多只能到三级导航
+ name: '三级导航aaa',
+ child: [
+ {
+ key: '666aa',
+ name: '选项6',
+ },
+ ],
+ },
+ {
+ key: 'sanjibbb', // 最多只能到三级导航
+ name: '三级导航bbb',
+ child: [
+ {
+ key: '666bb',
+ name: '选项6',
+ },
+ ],
+ },
+ ],
+ },
+];
diff --git a/src/schema/test.config.js b/src/schema/test.config.js
new file mode 100644
index 0000000..9604054
--- /dev/null
+++ b/src/schema/test.config.js
@@ -0,0 +1,8 @@
+// 定义某个表渲染时的配置
+// 如果不需要个性化, 可以不要这个文件, 所有配置项都会使用默认值
+// 注意! config/dataSchema/querySchema文件的命名应该遵循CamelCase原则, 不要包含下划线/中划线
+
+module.exports = {
+ showExport: false, // 显示导出按钮, 默认true
+ showImport: false, // 显示导入按钮, 默认true
+}
diff --git a/src/schema/test.dataSchema.js b/src/schema/test.dataSchema.js
new file mode 100644
index 0000000..ede4395
--- /dev/null
+++ b/src/schema/test.dataSchema.js
@@ -0,0 +1,48 @@
+// 定义某个表的dataSchema, 结构跟querySchema很相似, 见下面的例子
+// 注意: 所有的key不能重复
+
+module.exports = [
+ {
+ key: 'id', // 传递给后端的key
+ title: 'ID', // 前端显示的名字
+ dataType: 'int', // 数据类型, 目前可用的: int/float/varchar/datetime
+
+ // 这一列是否是主键?
+ // 如果不指定主键, 不能update/delete, 但可以insert
+ // 如果指定了主键, insert/update时不能填写主键的值;
+ // 如果是批量update/delete, 会把主键的一个数组传给后端
+ // 只有int/varchar可以作为主键
+ primary: true,
+
+ // showType目前还不支持, 其实dataSchema和querySchema一样, 都是有showType属性的
+ // 以后有空加上
+ // showType: checkbox/radio
+
+ // 扩展接口, 决定了这一列渲染成什么样子
+ render: (text, record) => text,
+ },
+
+ {
+ key: 'name',
+ title: '用户名',
+ dataType: 'varchar',
+ },
+ {
+ key: 'score',
+ title: '分数',
+ dataType: 'int',
+ },
+ {
+ key: 'gpa',
+ title: 'GPA',
+ dataType: 'float',
+ },
+ {
+ key: 'birthday',
+ title: '生日',
+ // 对于日期类型要注意下, 在js端日期被表示为yyyy-MM-dd HH:mm:ss的字符串, 在java端日期被表示为java.util.Date对象
+ // fastjson反序列化时可以自动识别
+ // 序列化倒是不用特别配置, 看自己需求, fastjson会序列化为一个字符串, 前端原样展示
+ dataType: 'datetime',
+ },
+];
diff --git a/src/schema/test.querySchema.js b/src/schema/test.querySchema.js
new file mode 100644
index 0000000..ec7d72e
--- /dev/null
+++ b/src/schema/test.querySchema.js
@@ -0,0 +1,101 @@
+// 定义某个表的querySchema
+// schema的结构和含义参考下面的例子
+// 注意: 所有的key不能重复
+
+module.exports = [
+ {
+ key: 'name', // 传递给后端的字段名
+ title: '用户名', // 前端显示的名称
+ placeholder: '请输入用户名', // 提示语, 可选
+
+ // 数据类型, 前端会根据数据类型展示不同的输入框
+ // 目前可用的dataType: int/float/varchar/datetime
+ dataType: 'varchar',
+
+ // 显示类型, 一些可枚举的字段, 比如type, 可以被显示为单选框或下拉框
+ // 默认显示类型是normal, 就是一个普通的输入框, 这时可以省略showType字段
+ // 目前可用的showType: normal/select/radio/between
+ // select和radio只能用于int和varchar
+ // between只能用于int/float/datetime, 会显示2个输入框, 用于范围查询
+ showType: 'normal',
+
+ // 有一点要注意, 就算showType是normal, 也不意味是等值查询, 只是说传递给后台的是单独的一个值
+ // 至于后台用这个值做等值/大于/小于/like, 前端不关心
+ },
+
+ {
+ key: 'age',
+ title: '年龄',
+ placeholder: '请输入年龄',
+ dataType: 'int',
+ },
+ {
+ key: 'weight',
+ title: '体重(kg)',
+ dataType: 'float', // 小数会统一保留2位
+ },
+ {
+ key: 'type',
+ title: '类型',
+ dataType: 'int',
+ showType: 'select', // 下拉框选择, dataType可以是int或varchar, 但必须和options中的key类型一致
+ options: [{key: 1, value: '类型1'}, {key: 2, value: '类型2'}],
+ },
+ {
+ key: 'userType',
+ title: '用户类型',
+ dataType: 'varchar', // 这个数据类型要和options中key的类型一致
+ showType: 'radio', // 单选框, 和下拉框schema是一样的, 只是显示时有差别
+ options: [{key: 'typeA', value: '类型A'}, {key: 'typeB', value: '类型B'}],
+ },
+ {
+ key: 'score',
+ title: '分数',
+ dataType: 'int',
+ showType: 'between', // 整数范围查询, 对于范围查询, 会自动生成xxBegin/xxEnd两个key传递给后端
+ },
+ {
+ key: 'gpa',
+ title: 'GPA',
+ dataType: 'float',
+ showType: 'between', // 小数也可以范围查询, 固定两位小数
+ placeholderBegin: '哈哈', // 对于范围查询, 可以定义placeholderBegin和placeholderBegin, 用于两个框的提示语
+ placeholderEnd: '切克闹', // 如果不定义, 对于int/float的范围查询, 提示语是"最小值"/"最大值", 对于日期的范围查询, 提示语是"开始日期"/"结束日期"
+ },
+ {
+ key: 'height',
+ title: '身高(cm)',
+ dataType: 'float', // 小数等值查询
+ },
+ {
+ key: 'duoxuan1',
+ title: '多选1',
+ dataType: 'int',
+ showType: 'checkbox', // checkbox
+ options: [{key: 1, value: '类型1'}, {key: 2, value: '类型2'}],
+ },
+ {
+ key: 'duoxuan2',
+ title: '多选2',
+ dataType: 'varchar',
+ showType: 'multiSelect', // 另一种多选
+ options: [{key: 'type1', value: '类型1'}, {key: 'type2', value: '类型2'}],
+ },
+ {
+ key: 'primarySchool',
+ title: '入学日期',
+ dataType: 'datetime', // 日期范围查询, 日期范围查询占用的显示空间会很大, 注意排版
+ showType: 'between',
+ },
+ {
+ key: 'birthday',
+ title: '出生日期',
+ dataType: 'datetime',
+ showType: 'between',
+ },
+ {
+ key: 'xxbirthday',
+ title: 'XX日期',
+ dataType: 'datetime', // 日期等值查询
+ },
+];
diff --git a/src/schema/testSms.config.js b/src/schema/testSms.config.js
new file mode 100644
index 0000000..3da4109
--- /dev/null
+++ b/src/schema/testSms.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ showExport: false, // 显示导出按钮, 默认true
+ showImport: false, // 显示导入按钮, 默认true
+}
diff --git a/src/schema/testSms.dataSchema.js b/src/schema/testSms.dataSchema.js
new file mode 100644
index 0000000..e1ddd7b
--- /dev/null
+++ b/src/schema/testSms.dataSchema.js
@@ -0,0 +1,34 @@
+module.exports = [
+ {
+ key: 'id',
+ title: 'ID',
+ dataType: 'int',
+ primary: true,
+ },
+ {
+ key: 'content',
+ title: '内容',
+ dataType: 'varchar',
+ },
+ {
+ key: 'phoneModel',
+ title: '手机型号',
+ dataType: 'varchar',
+ },
+ {
+ key: 'experience',
+ title: '使用经验',
+ dataType: 'varchar',
+ },
+ {
+ key: 'frequency',
+ title: '使用频率',
+ dataType: 'varchar',
+ },
+ {
+ key: 'isNative',
+ title: '是否母语',
+ dataType: 'varchar',
+ placeholder: '字符串yes/no',
+ },
+];
diff --git a/src/schema/testSms.querySchema.js b/src/schema/testSms.querySchema.js
new file mode 100644
index 0000000..7b1ae0a
--- /dev/null
+++ b/src/schema/testSms.querySchema.js
@@ -0,0 +1,38 @@
+// 定义某个表的querySchema
+// schema的结构和含义参考下面的例子
+// 注意: 所有的key不能重复
+
+module.exports = [
+ {
+ key: 'id', // 传递给后端的字段名
+ title: 'ID', // 前端显示的名称
+ dataType: 'int',
+ },
+ {
+ key: 'content',
+ title: '内容',
+ dataType: 'varchar',
+ },
+ {
+ key: 'phoneModel',
+ title: '手机型号',
+ dataType: 'varchar',
+ },
+ {
+ key: 'experience',
+ title: '使用经验',
+ dataType: 'varchar',
+ },
+ {
+ key: 'frequency',
+ title: '使用频率',
+ dataType: 'varchar',
+ },
+ {
+ key: 'isNative',
+ title: '是否母语',
+ dataType: 'varchar',
+ showType: 'radio',
+ options: [{key: 'yes', value: '是'}, {key: 'no', value: '否'}],
+ },
+];
diff --git a/src/utils/Logger.js b/src/utils/Logger.js
new file mode 100644
index 0000000..ce26a49
--- /dev/null
+++ b/src/utils/Logger.js
@@ -0,0 +1,168 @@
+import globalConfig from '../config';
+
+/**
+ * 日志工具类
+ *
+ *
+ * 工程变大之后, 日志就很重要了, 总不能一直console.log.
+ * 本来想看看有没有现成的工具, 找到一个log4js的库, 但只能用在node环境下, 浏览器环境下似乎没什么好用的.
+ * 作为一个用惯了slf4j的人, 干脆自己写个吧, 练练手.
+ * 目前有以下功能:
+ *
+ * 支持常用的日志级别: debug/info/warn/error, 说实话其他的级别极少用到
+ * 支持变量替换: 类似slf4j中的logger.info("a={}",a)这种, 其实console本身已经支持的
+ * 根据日志级别设置样式: debug是黑色, info是默认, warn是黄色, error是红色, 看起来清晰很多
+ * 定义每个logger的名字: 也算是常规功能了吧
+ *
+ * 我是尽量按着slf4j的习惯来设计的, 目前还比较简单.
+ * 不支持pattern/appender之类的, 但对于二手前端来说, 也算够用了.
+ *
+ */
+class Logger {
+
+ // 定义一些预设的日志级别
+ // 目前只有4种级别
+
+ static LOG_LEVEL_DEBUG = 1;
+ static LOG_LEVEL_INFO = 2;
+ static LOG_LEVEL_WARN = 3;
+ static LOG_LEVEL_ERROR = 4;
+
+ /*暂存所有logger*/
+ static loggerMap = new Map();
+
+ /*默认的logger*/
+ static defaultLogger = new Logger();
+
+ /**
+ * 获取一个Logger实例
+ *
+ * @param name
+ * @returns {*}
+ */
+ static getLogger(name) {
+ if (name && name !== '') {
+ // 缓存
+ if (Logger.loggerMap.has(name)) {
+ return Logger.loggerMap.get(name);
+ }
+
+ const logger = new Logger(name);
+ Logger.loggerMap.set(name, logger);
+ return logger;
+ } else {
+ return Logger.defaultLogger;
+ }
+ }
+
+ constructor(name) {
+ this.name = name; // logger的名字
+ const configLogLevel = globalConfig.logLevel;
+
+ if (configLogLevel === 'debug') {
+ this.logLevel = Logger.LOG_LEVEL_DEBUG;
+ } else if (configLogLevel === 'info') {
+ this.logLevel = Logger.LOG_LEVEL_INFO;
+ } else if (configLogLevel === 'warn') {
+ this.logLevel = Logger.LOG_LEVEL_WARN;
+ } else if (configLogLevel === 'error') {
+ this.logLevel = Logger.LOG_LEVEL_ERROR;
+ } else {
+ // 默认都是info级别
+ this.error('unsupported logLevel: %s, use INFO instead', configLogLevel);
+ this.logLevel = Logger.LOG_LEVEL_INFO;
+ }
+ }
+
+ /**
+ * 设置日志级别, 只有4种级别可选
+ *
+ * @param newLogLevel 1~4之间的一个数字
+ */
+ setLogLevel(newLogLevel) {
+ if (isNaN(newLogLevel)) {
+ this.error('setLogLevel error, not a number: %s', newLogLevel);
+ }
+
+ if (newLogLevel < 1 || newLogLevel > 4) {
+ this.error('setLogLevel error, input = %s, must between 1 and 4', newLogLevel);
+ }
+
+ this.logLevel = newLogLevel;
+ }
+
+ /**
+ * 打印info日志
+ *
+ * @param pattern 日志格式, 支持%d/%s等占位符
+ * @param args 可变参数, 用于替换pattern中的占位符
+ */
+ info(pattern, ...args) {
+ // 先判断日志级别
+ if (this.logLevel > Logger.LOG_LEVEL_INFO)
+ return;
+
+ if (this.name)
+ args.unshift(`${this.name}: ${pattern}`);
+ else
+ args.unshift(pattern);
+ console.log.apply(console, args);
+ }
+
+ /**
+ * 打印error日志
+ *
+ * @param pattern
+ * @param args
+ */
+ error(pattern, ...args) {
+ if (this.logLevel > Logger.LOG_LEVEL_ERROR)
+ return;
+
+ args.unshift('background: red; color: #bada55;');
+ if (this.name)
+ args.unshift(`%c${this.name}: ${pattern}`);
+ else
+ args.unshift(`%c${pattern}`);
+ console.error.apply(console, args);
+ }
+
+ /**
+ * 打印debug日志
+ *
+ * @param pattern
+ * @param args
+ */
+ debug(pattern, ...args) {
+ if (this.logLevel > Logger.LOG_LEVEL_DEBUG)
+ return;
+
+ args.unshift('background: black; color: #bada55;');
+ if (this.name)
+ args.unshift(`%c${this.name}: ${pattern}`);
+ else
+ args.unshift(`%c${pattern}`);
+ console.debug.apply(console, args);
+
+ }
+
+ /**
+ * 打印warn日志
+ *
+ * @param pattern
+ * @param args
+ */
+ warn(pattern, ...args) {
+ if (this.logLevel > Logger.LOG_LEVEL_WARN)
+ return;
+
+ args.unshift('background: yellow; color: black;');
+ if (this.name)
+ args.unshift(`%c${this.name}: ${pattern}`);
+ else
+ args.unshift(`%c${pattern}`);
+ console.warn.apply(console, args);
+ }
+}
+
+export default Logger;
diff --git a/src/utils/ajax.js b/src/utils/ajax.js
new file mode 100644
index 0000000..6113452
--- /dev/null
+++ b/src/utils/ajax.js
@@ -0,0 +1,227 @@
+/**
+ * 对superagent api的一个mock, 用于debug
+ */
+
+import Logger from './Logger';
+import superagent from 'superagent';
+import globalConfig from '../config';
+
+// superagent的文档: http://visionmedia.github.io/superagent/#error-handling
+// 常用的api形式: 只要mock这两种就可以了
+// ajax.get(url).end(func(err,res))
+// ajax.post(url).send(obj).end(func(err,res))
+
+const logger = Logger.getLogger('mockAjax');
+
+let currentUrl; // 一个临时变量, 当前正在请求的url
+const error = {}; // 理论上服务端不会返回error, 都是HTTP200
+const result = { // 暂存mock的ajax返回, 总共有5个字段
+ success: true,
+ code: 0,
+ message: 'just a mock ;) ',
+ total: 10000,
+ data: {},
+};
+
+const successResult = (data) => {
+ result.success = true;
+ result.data = data;
+ result.code = 0;
+ result.message = '';
+ result.total = 10000;
+};
+
+const errorResult = (code, message) => {
+ result.success = false;
+ result.data = {};
+ result.code = code;
+ result.message = message;
+ result.total = 0;
+};
+
+// 从url中取出表名
+const getTableNameFromUrl = (url) => {
+ // url的格式是apiPath/{tableName}/[insert|update|delete]
+ const apiPath = globalConfig.getAPIPath();
+ let begin = apiPath.length + 1;
+ while (url.charAt(begin) === '/') {
+ begin++;
+ }
+ let end = begin;
+ while (url.charAt(end) !== '/') {
+ end++;
+ }
+
+ const tableName = url.substring(begin, end);
+ logger.debug('getTableNameFromUrl: begin=%d, end=%d, tableName=%s', begin, end, tableName);
+
+ return tableName;
+};
+
+// 根据某个表的dataSchema创造mock数据
+const mockResult = (tableName, queryObj) => {
+ logger.debug('begin to mock data for table %s', tableName);
+
+ // 尝试加载schema文件
+ let schema;
+ try {
+ schema = require(`../schema/${tableName}.dataSchema.js`);
+ } catch (e) {
+ logger.error('can not find dataSchema file for table %s', tableName);
+ logger.error('%o', e); // 这个方式有点不爽...但是如果不设置pattern, 就不能用样式了, 纠结...
+ // 设置返回结果为失败
+ errorResult(100, `can not find dataSchema file for table ${tableName}`);
+
+ return;
+ }
+
+ // 一般来说, 传入的查询条件都是肯定会有page/pageSize的, 以防万一
+ if (!queryObj.page) {
+ queryObj.page = 1;
+ }
+ if (!queryObj.pageSize) {
+ queryObj.pageSize = 50;
+ }
+
+ const tmp = [];
+ for (let i = 0; i < queryObj.pageSize; i++) {
+ const record = {};
+ // 为了让mock的数据有些区别, 把page算进去
+ schema.forEach((column)=> {
+ if (column.dataType === 'int') {
+ record[column.key] = 10000 * queryObj.page + i;
+ } else if (column.dataType === 'float') {
+ record[column.key] = 1.0 * queryObj.page + i * 0.01;
+ } else if (column.dataType === 'varchar') {
+ record[column.key] = `page=${queryObj.page} num=${i}`;
+ } else if (column.dataType === 'datetime') {
+ record[column.key] = new Date().plusDays(i).format('yyyy-MM-dd HH:mm:ss');
+ } else {
+ logger.error('unsupported dataType %s', column.dataType);
+ }
+ });
+ tmp.push(record);
+ }
+
+ successResult(tmp);
+};
+
+// 开始mock supergent的各个方法
+
+const end = (func) => {
+ // 模拟一个延时
+ // 总是感觉setTimeout有点low啊, 不知道有没有更好的办法
+ setTimeout(func, 1500, null, {body: result}); // 延时执行func函数, 参数跟在最后
+};
+
+const get = (url) => {
+ logger.debug('get url %s', url);
+ currentUrl = url;
+
+ // TODO: 要根据url返回不同的数据, 这里设置各种get的url
+ if (url === `${globalConfig.getAPIPath()}${globalConfig.login.getCurrentUser}`)
+ successResult('guest'); // debug模式下都是guest用户
+ else
+ errorResult(200, `unsupported url ${url}`);
+
+ return {end};
+};
+
+const type = (str) => {
+ logger.debug('set type = %s', str);
+ return {send};
+};
+
+const send = (obj) => {
+ logger.debug('send obj: %o', obj);
+
+ // TODO: 要根据url返回不同的数据, 这里设置各种post的url
+ if (currentUrl === `${globalConfig.getAPIPath()}${globalConfig.login.validate}`) {
+ // 验证登录的用户名和密码
+ if (obj.username === 'guest' && obj.password === 'guest') {
+ successResult('guest');
+ } else {
+ errorResult(300, 'error username or password');
+ }
+ } else {
+ // 如果不是登录请求, 目前只能是CRUD请求, 当然以后可能会扩展
+ const tableName = getTableNameFromUrl(currentUrl);
+ if (tableName) {
+ // 要根据增删改查的情况分别模拟
+ if (currentUrl.indexOf('select') > 0) {
+ mockResult(tableName, obj);
+ } else if (currentUrl.indexOf('insert') > 0) {
+ mockResult(tableName, obj);
+ const tmp = result.data[0];
+ successResult(tmp);
+ } else if (currentUrl.indexOf('update') > 0) {
+ const tmp = currentUrl.indexOf('keys=');
+ successResult(currentUrl.substring(tmp).split(',').length);
+ } else {
+ errorResult(400, `unsupported url ${currentUrl}`);
+ }
+ } else {
+ errorResult(400, `unsupported url ${currentUrl}`);
+ }
+ }
+
+ return {end};
+};
+
+const post = (url) => {
+ logger.debug('post url %s', url);
+ currentUrl = url;
+
+ // TODO: 要根据url返回不同的数据, 这里设置各种post的url
+ if (currentUrl.indexOf('delete') > 0) {
+ const tmp = currentUrl.indexOf('keys=');
+ successResult(currentUrl.substring(tmp).split(',').length);
+ }
+
+ return {send, type, end};
+};
+
+// 一个辅助的方法, superagent判断返回是否成功太麻烦了啊...要判断3个条件
+const isSuccess = (res) => {
+ if (res && res.body && res.body.success) {
+ return true;
+ } else {
+ return false;
+ }
+};
+
+// mock的版本只提供简单的API, 以后可能要扩展
+const mockAjax = {get, post, isSuccess};
+
+// 对superagent的API简单包装下
+const realAjax = {
+ get: function (url) {
+ return this.innerConfig(superagent.get(url));
+ },
+
+ post: function (url) {
+ return this.innerConfig(superagent.post(url));
+ },
+
+ // 一些全局的配置
+ innerConfig: function (req) {
+ let tmp = req;
+ // 是否是跨域请求
+ if (globalConfig.isCrossDomain()) {
+ tmp = tmp.withCredentials();
+ }
+ // 设置全局的超时时间
+ if (globalConfig.api.timeout && !isNaN(globalConfig.api.timeout)) {
+ tmp = tmp.timeout(globalConfig.api.timeout);
+ }
+ return tmp;
+ },
+
+ isSuccess,
+};
+
+if (globalConfig.debug === true) {
+ module.exports = mockAjax;
+} else {
+ module.exports = realAjax;
+}
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 0000000..717787d
--- /dev/null
+++ b/src/utils/index.js
@@ -0,0 +1,56 @@
+// 一些辅助用的工具方法
+// 很多都是gross hack, 属于历史遗留问题
+
+/** 对Date的扩展,将 Date 转化为指定格式的String * 月(M)、日(d)、12小时(h)、24小时(H)、分(m)、秒(s)、周(E)、季度(q)
+ * 可以用 1-2 个占位符 * 年(y)可以用 1-4 个占位符,毫秒(S)只能用 1 个占位符(是 1-3 位的数字)
+ * eg:
+ * (new Date()).pattern("yyyy-MM-dd hh:mm:ss.S")==> 2006-07-02 08:09:04.423
+ * (new Date()).pattern("yyyy-MM-dd E HH:mm:ss") ==> 2009-03-10 二 20:09:04
+ * (new Date()).pattern("yyyy-MM-dd EE hh:mm:ss") ==> 2009-03-10 周二 08:09:04
+ * (new Date()).pattern("yyyy-MM-dd EEE hh:mm:ss") ==> 2009-03-10 星期二 08:09:04
+ * (new Date()).pattern("yyyy-M-d h:m:s.S") ==> 2006-7-2 8:9:4.18
+ */
+Date.prototype.format = function (fmt) {
+ var o = {
+ "M+": this.getMonth() + 1, //月份
+ "d+": this.getDate(), //日
+ "h+": this.getHours() % 12 == 0 ? 12 : this.getHours() % 12, //小时
+ "H+": this.getHours(), //小时
+ "m+": this.getMinutes(), //分
+ "s+": this.getSeconds(), //秒
+ "q+": Math.floor((this.getMonth() + 3) / 3), //季度
+ "S": this.getMilliseconds() //毫秒
+ };
+ var week = {
+ "0": "/u65e5",
+ "1": "/u4e00",
+ "2": "/u4e8c",
+ "3": "/u4e09",
+ "4": "/u56db",
+ "5": "/u4e94",
+ "6": "/u516d"
+ };
+ if (/(y+)/.test(fmt)) {
+ fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));
+ }
+ if (/(E+)/.test(fmt)) {
+ fmt = fmt.replace(RegExp.$1, ((RegExp.$1.length > 1) ? (RegExp.$1.length > 2 ? "/u661f/u671f" : "/u5468") : "") + week[this.getDay() + ""]);
+ }
+ for (var k in o) {
+ if (new RegExp("(" + k + ")").test(fmt)) {
+ fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
+ }
+ }
+ return fmt;
+};
+
+/**
+ * 在当前日期的基础上再增加几天
+ *
+ * @param num 要增加的天数
+ */
+Date.prototype.plusDays = function (num) {
+ var tmp = new Date();
+ tmp.setDate(this.getDate() + num);
+ return tmp;
+};
diff --git a/webpack.config.js b/webpack.config.js
index 4a8567b..7707994 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,23 +1,55 @@
-// require('./admin/index.html');
+const webpack = require('webpack');
-module.exports = function(webpackConfig) {
- webpackConfig.module.loaders.push({
- test: /\.html$/,
- loader: 'file?name=[name].[ext]'
- });
- webpackConfig.module.loaders.forEach(function(loader) {
- if (loader.loader === 'babel') {
- // https://github.com/ant-design/babel-plugin-antd
- loader.query.plugins.push('antd');
- }
- return loader;
- });
+// 将babel-loader的配置独立出来, 因为webpack的限制: http://stackoverflow.com/questions/33117136/how-to-add-a-query-to-a-webpack-loader-with-multiple-loaders
+const babelLoaderConfig = {
+ presets: ['es2015', 'stage-0', 'react'], // 开启ES6、部分ES7、react特性, preset相当于预置的插件集合
+ plugins: [['antd', {'style': true}]], // antd模块化加载, https://github.com/ant-design/babel-plugin-antd
+};
- // Fix ie8 compatibility
- webpackConfig.module.loaders.unshift({
- test: /\.jsx?$/,
- loader: 'es3ify',
- });
+module.exports = {
+ entry: [
+ 'webpack-dev-server/client?http://0.0.0.0:8080', // WebpackDevServer host and port
+ 'webpack/hot/only-dev-server', // "only" prevents reload on syntax errors
+ 'babel-polyfill', // 可以使用完整的ES6特性, 大概增加100KB
+ './src/index.js', // 编译的入口
+ ],
- return webpackConfig;
+ output: { // 输出的目录和文件名
+ path: __dirname + '/dist',
+ filename: 'bundle.js',
+ },
+
+ resolve: {
+ modulesDirectories: ['node_modules', './src'], // import时到哪些地方去寻找模块
+ extensions: ['', '.js', '.jsx'], // require的时候可以直接使用require('file'),不用require('file.js')
+ alias: {
+ antdcss: 'antd/dist/antd.min.css', // import时的别名
+ },
+ },
+
+ module: {
+ loaders: [ // 定义各种loader
+ {
+ test: /\.jsx?$/,
+ loaders: ['react-hot', 'babel-loader?' + JSON.stringify(babelLoaderConfig)], // react-hot-loader可以不用刷新页面, 如果用普通的dev-server的话会自动刷新页面
+ exclude: /node_modules/,
+ }, {
+ test: /\.css$/,
+ loader: 'style!css',
+ }, {
+ test: /\.less$/,
+ loader: 'style!css!less', // 用!去链式调用loader
+ }, {
+ test: /\.(png|jpg|svg)$/,
+ loader: 'url?limit=25000', // 图片小于一定值的话转成base64
+ },
+ ],
+ },
+
+ plugins: [
+ new webpack.BannerPlugin('This file is created by jxy'), // 生成文件时加上注释
+ new webpack.DefinePlugin({
+ __DEV__: JSON.stringify(JSON.parse(process.env.NODE_ENV === 'production' ? 'false' : 'true')), // magic globals, 用于打印一些调试的日志, webpack -p时会删除
+ }),
+ ],
};
diff --git a/webpack.config.prod.js b/webpack.config.prod.js
new file mode 100644
index 0000000..fa25e32
--- /dev/null
+++ b/webpack.config.prod.js
@@ -0,0 +1,67 @@
+const webpack = require('webpack');
+
+const babelLoaderConfig = {
+ presets: ['es2015', 'stage-0', 'react'],
+ plugins: [['antd', {'style': true}]],
+};
+
+module.exports = {
+ entry: [
+ // 可能需要polyfill
+ './src/index.js',
+ ],
+
+ output: {
+ path: __dirname + '/dist',
+ filename: 'bundle.min.js',
+ // publicPath: 'http://mycdn.com/', // require时用来生成图片的地址
+ },
+
+ resolve: {
+ modulesDirectories: ['node_modules', './src'],
+ extensions: ['', '.js', '.jsx'],
+ },
+
+ module: {
+ loaders: [
+ {
+ test: /\.jsx?$/,
+ // 删除一些debug语句
+ loaders: ['babel-loader?' + JSON.stringify(babelLoaderConfig), 'strip-loader?strip[]=logger.debug,strip[]=console.log,strip[]=console.debug'],
+ exclude: /node_modules/,
+ }, {
+ test: /\.css$/,
+ loader: 'style!css',
+ }, {
+ test: /\.less$/,
+ loader: 'style!css!less',
+ }, {
+ test: /\.(png|jpg|svg)$/,
+ loader: 'url?limit=25000',
+ },
+ ],
+ },
+
+ plugins: [
+ // 代码压缩
+ new webpack.optimize.UglifyJsPlugin({
+ minimize: true,
+ compress: {warnings: false},
+ }),
+
+ // 抽离公共部分
+ // webpack.optimize.CommonsChunkPlugin
+
+ new webpack.optimize.DedupePlugin(),
+ // 比对id的使用频率和分布来得出最短的id分配给使用频率高的模块
+ new webpack.optimize.OccurenceOrderPlugin(),
+ new webpack.optimize.AggressiveMergingPlugin(),
+ // 允许错误不打断程序
+ new webpack.NoErrorsPlugin(),
+
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
+ __DEV__: JSON.stringify(JSON.parse(process.env.NODE_ENV === 'production' ? 'false' : 'true')),
+ }),
+ ],
+};