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等几部分*/} + + +
+ + +
+ + + {/*TODO: 这里要组件化*/} +
+
+ {this.state.loading ? '' : this.props.children} +
+
+ +
+ + +
+
+ ); + } +} + +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(); + }); + + return this.colWrapper(( + + ), 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(); + }); + + return this.colWrapper(( + + ), 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 ( +
+ {rows} + + + + + {this.props.tableConfig.showExport ? + : ''} + {this.props.tableConfig.showImport ? + : ''} + + +
+ ); + } + +} + +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 */} + + + + + +
+ {formItems} +
+
+
+ + + + ); + } + +} + +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 && + + Fork me on GitHub + } + +
+

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

+ Welcome, 这里是欢迎界面, 欢迎访问我的blog. +
+ 项目地址: https://github.com/jiangxy/react-antd-admin +

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