This is an automated email from the ASF dual-hosted git repository. hanahmily pushed a commit to branch feature/5.0.0 in repository https://gitbox.apache.org/repos/asf/incubator-skywalking-ui.git
commit b22b60dcb931ad01ee702a4abee19a30636995bd Author: gaohongtao <[email protected]> AuthorDate: Thu Dec 14 09:49:57 2017 +0800 Init frontend --- src/main/frontend/.editorconfig | 16 + src/main/frontend/.eslintrc | 53 +++ src/main/frontend/.ga | 3 + src/main/frontend/.gitignore | 15 + src/main/frontend/.roadhogrc | 23 ++ src/main/frontend/.roadhogrc.mock.js | 58 ++++ src/main/frontend/.stylelintrc | 25 ++ src/main/frontend/.travis.yml | 33 ++ src/main/frontend/LICENSE | 201 +++++++++++ src/main/frontend/README.md | 1 + src/main/frontend/appveyor.yml | 22 ++ src/main/frontend/mock/.gitkeep | 0 src/main/frontend/mock/notices.js | 85 +++++ src/main/frontend/mock/rule.js | 127 +++++++ src/main/frontend/mock/utils.js | 45 +++ src/main/frontend/package.json | 107 ++++++ src/main/frontend/public/index.html | 15 + src/main/frontend/src/common/nav.js | 26 ++ .../src/components/StandardFormRow/index.js | 26 ++ .../src/components/StandardFormRow/index.less | 71 ++++ .../frontend/src/components/StandardTable/index.js | 154 +++++++++ .../src/components/StandardTable/index.less | 13 + src/main/frontend/src/e2e/home.e2e.js | 9 + src/main/frontend/src/index.js | 22 ++ src/main/frontend/src/index.less | 16 + src/main/frontend/src/layouts/BasicLayout.js | 375 +++++++++++++++++++++ src/main/frontend/src/layouts/BasicLayout.less | 113 +++++++ src/main/frontend/src/layouts/BlankLayout.js | 3 + src/main/frontend/src/layouts/PageHeaderLayout.js | 12 + .../frontend/src/layouts/PageHeaderLayout.less | 11 + src/main/frontend/src/models/dashboard.js | 16 + src/main/frontend/src/models/global.js | 76 +++++ src/main/frontend/src/models/index.js | 11 + src/main/frontend/src/models/rule.js | 80 +++++ src/main/frontend/src/models/user.js | 66 ++++ src/main/frontend/src/polyfill.js | 7 + src/main/frontend/src/router.js | 60 ++++ .../frontend/src/routes/Dashboard/Dashboard.js | 13 + .../frontend/src/routes/Dashboard/Dashboard.less | 23 ++ src/main/frontend/src/routes/List/TableList.js | 326 ++++++++++++++++++ src/main/frontend/src/routes/List/TableList.less | 45 +++ src/main/frontend/src/services/api.js | 30 ++ src/main/frontend/src/services/user.js | 9 + src/main/frontend/src/theme.js | 5 + src/main/frontend/src/utils/request.js | 56 +++ src/main/frontend/src/utils/utils.js | 94 ++++++ src/main/frontend/src/utils/utils.less | 50 +++ src/main/frontend/tests/jasmine.js | 1 + src/main/frontend/tests/run-tests.js | 35 ++ src/main/frontend/tests/setupTests.js | 13 + src/main/frontend/tests/styleMock.js | 1 + 51 files changed, 2697 insertions(+) diff --git a/src/main/frontend/.editorconfig b/src/main/frontend/.editorconfig new file mode 100755 index 0000000..7e3649a --- /dev/null +++ b/src/main/frontend/.editorconfig @@ -0,0 +1,16 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/src/main/frontend/.eslintrc b/src/main/frontend/.eslintrc new file mode 100755 index 0000000..5209bd9 --- /dev/null +++ b/src/main/frontend/.eslintrc @@ -0,0 +1,53 @@ +{ + "parser": "babel-eslint", + "extends": "airbnb", + "env": { + "browser": true, + "node": true, + "es6": true, + "mocha": true, + "jest": true, + "jasmine": true + }, + "rules": { + "generator-star-spacing": [0], + "consistent-return": [0], + "react/forbid-prop-types": [0], + "react/jsx-filename-extension": [1, { "extensions": [".js"] }], + "global-require": [1], + "import/prefer-default-export": [0], + "react/jsx-no-bind": [0], + "react/prop-types": [0], + "react/prefer-stateless-function": [0], + "no-else-return": [0], + "no-restricted-syntax": [0], + "import/no-extraneous-dependencies": [0], + "no-use-before-define": [0], + "jsx-a11y/no-static-element-interactions": [0], + "jsx-a11y/no-noninteractive-element-interactions": [0], + "jsx-a11y/click-events-have-key-events": [0], + "jsx-a11y/anchor-is-valid": [0], + "no-nested-ternary": [0], + "arrow-body-style": [0], + "import/extensions": [0], + "no-bitwise": [0], + "no-cond-assign": [0], + "import/no-unresolved": [0], + "comma-dangle": ["error", { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "ignore" + }], + "object-curly-newline": [0], + "function-paren-newline": [0], + "no-restricted-globals": [0], + "require-yield": [1] + }, + "parserOptions": { + "ecmaFeatures": { + "experimentalObjectRestSpread": true + } + } +} diff --git a/src/main/frontend/.ga b/src/main/frontend/.ga new file mode 100644 index 0000000..1932bb5 --- /dev/null +++ b/src/main/frontend/.ga @@ -0,0 +1,3 @@ +{ + "code":"UA-72788897-6" +} diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore new file mode 100755 index 0000000..c03ea9a --- /dev/null +++ b/src/main/frontend/.gitignore @@ -0,0 +1,15 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +# roadhog-api-doc ignore +/src/utils/request-temp.js + +# production +/dist + +# misc +.DS_Store +npm-debug.log* + +/coverage diff --git a/src/main/frontend/.roadhogrc b/src/main/frontend/.roadhogrc new file mode 100755 index 0000000..e64dacf --- /dev/null +++ b/src/main/frontend/.roadhogrc @@ -0,0 +1,23 @@ +{ + "entry": "src/index.js", + "extraBabelPlugins": [ + "transform-runtime", + "transform-decorators-legacy", + "transform-class-properties", + ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }] + ], + "env": { + "development": { + "extraBabelPlugins": [ + "dva-hmr" + ] + } + }, + "externals": { + "g2": "G2", + "g-cloud": "Cloud", + "g2-plugin-slider": "G2.Plugin.slider" + }, + "ignoreMomentLocale": true, + "theme": "./src/theme.js" +} diff --git a/src/main/frontend/.roadhogrc.mock.js b/src/main/frontend/.roadhogrc.mock.js new file mode 100644 index 0000000..11cfdbc --- /dev/null +++ b/src/main/frontend/.roadhogrc.mock.js @@ -0,0 +1,58 @@ +import mockjs from 'mockjs'; +import { getRule, postRule } from './mock/rule'; +import { imgMap } from './mock/utils'; +import { getNotices } from './mock/notices'; +import { delay } from 'roadhog-api-doc'; + +// 是否禁用代理 +const noProxy = process.env.NO_PROXY === 'true'; + +// 代码中会兼容本地 service mock 以及部署站点的静态数据 +const proxy = { + // 支持值为 Object 和 Array + 'GET /api/currentUser': { + $desc: "获取当前用户接口", + $params: { + pageSize: { + desc: '分页', + exp: 2, + }, + }, + $body: { + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/dRFVcIqZOYPcSNrlJsqQ.png', + userid: '00000001', + notifyCount: 12, + }, + }, + // GET POST 可省略 + 'GET /api/users': [{ + key: '1', + name: 'John Brown', + age: 32, + address: 'New York No. 1 Lake Park', + }, { + key: '2', + name: 'Jim Green', + age: 42, + address: 'London No. 1 Lake Park', + }, { + key: '3', + name: 'Joe Black', + age: 32, + address: 'Sidney No. 1 Lake Park', + }], + 'GET /api/rule': getRule, + 'POST /api/rule': { + $params: { + pageSize: { + desc: '分页', + exp: 2, + }, + }, + $body: postRule, + }, + 'GET /api/notices': getNotices, +}; + +export default noProxy ? {} : delay(proxy, 1000); diff --git a/src/main/frontend/.stylelintrc b/src/main/frontend/.stylelintrc new file mode 100644 index 0000000..b4331fb --- /dev/null +++ b/src/main/frontend/.stylelintrc @@ -0,0 +1,25 @@ +{ + "extends": "stylelint-config-standard", + "rules": { + "selector-pseudo-class-no-unknown": null, + "shorthand-property-no-redundant-values": null, + "at-rule-empty-line-before": null, + "at-rule-name-space-after": null, + "comment-empty-line-before": null, + "declaration-bang-space-before": null, + "declaration-empty-line-before": null, + "function-comma-newline-after": null, + "function-name-case": null, + "function-parentheses-newline-inside": null, + "function-max-empty-lines": null, + "function-whitespace-after": null, + "number-leading-zero": null, + "number-no-trailing-zeros": null, + "rule-empty-line-before": null, + "selector-combinator-space-after": null, + "selector-list-comma-newline-after": null, + "selector-pseudo-element-colon-notation": null, + "unit-no-unknown": null, + "value-list-max-empty-lines": null + } +} diff --git a/src/main/frontend/.travis.yml b/src/main/frontend/.travis.yml new file mode 100644 index 0000000..7115c31 --- /dev/null +++ b/src/main/frontend/.travis.yml @@ -0,0 +1,33 @@ +language: node_js + +node_js: + - "8" + +env: + matrix: + - TEST_TYPE=lint + - TEST_TYPE=test-all + - TEST_TYPE=test-dist + +addons: + apt: + packages: + - xvfb + +install: + - export DISPLAY=':99.0' + - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + - npm install + +script: + - | + if [ "$TEST_TYPE" = lint ]; then + npm run lint + elif [ "$TEST_TYPE" = test-all ]; then + npm run test:all + elif [ "$TEST_TYPE" = test-dist ]; then + npm run site + mv dist/* ./ + php -S localhost:8000 & + npm test .e2e.js + fi diff --git a/src/main/frontend/LICENSE b/src/main/frontend/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/src/main/frontend/LICENSE @@ -0,0 +1,201 @@ + 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/src/main/frontend/README.md b/src/main/frontend/README.md new file mode 100644 index 0000000..7a6a4c1 --- /dev/null +++ b/src/main/frontend/README.md @@ -0,0 +1 @@ +# SkyWalking UI diff --git a/src/main/frontend/appveyor.yml b/src/main/frontend/appveyor.yml new file mode 100644 index 0000000..ee06d65 --- /dev/null +++ b/src/main/frontend/appveyor.yml @@ -0,0 +1,22 @@ +# Test against the latest version of this Node.js version +environment: + nodejs_version: "8" + +# Install scripts. (runs after repo cloning) +install: + # Get the latest stable version of Node.js or io.js + - ps: Install-Product node $env:nodejs_version + # install modules + - npm install + # Output useful info for debugging. + - node --version + - npm --version + +# Post-install test scripts. +test_script: + - npm run lint + - npm run test:all + - npm run build + +# Don't actually build. +build: off diff --git a/src/main/frontend/mock/.gitkeep b/src/main/frontend/mock/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/frontend/mock/notices.js b/src/main/frontend/mock/notices.js new file mode 100644 index 0000000..2b69a25 --- /dev/null +++ b/src/main/frontend/mock/notices.js @@ -0,0 +1,85 @@ +export default { + getNotices(req, res) { + res.json([{ + id: '000000001', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png', + title: '你收到了 14 份新周报', + datetime: '2017-08-09', + type: '告警', + }, { + id: '000000002', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png', + title: '你推荐的 曲妮妮 已通过第三轮面试', + datetime: '2017-08-08', + type: '告警', + }, { + id: '000000003', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png', + title: '这种模板可以区分多种通知类型', + datetime: '2017-08-07', + read: true, + type: '告警', + }, { + id: '000000004', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png', + title: '左侧图标用于区分不同的类型', + datetime: '2017-08-07', + type: '通知', + }, { + id: '000000005', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png', + title: '内容不要超过两行字,超出时自动截断', + datetime: '2017-08-07', + type: '通知', + }, { + id: '000000006', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '曲丽丽 评论了你', + description: '描述信息描述信息描述信息', + datetime: '2017-08-07', + type: '消息', + }, { + id: '000000007', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '朱偏右 回复了你', + description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像', + datetime: '2017-08-07', + type: '消息', + }, { + id: '000000008', + avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg', + title: '标题', + description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像', + datetime: '2017-08-07', + type: '消息', + }, { + id: '000000009', + title: '任务名称', + description: '任务需要在 2017-01-12 20:00 前启动', + extra: '未开始', + status: 'todo', + type: '待办', + }, { + id: '000000010', + title: '第三方紧急代码变更', + description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务', + extra: '马上到期', + status: 'urgent', + type: '待办', + }, { + id: '000000011', + title: '信息安全考试', + description: '指派竹尔于 2017-01-09 前完成更新并发布', + extra: '已耗时 8 天', + status: 'doing', + type: '待办', + }, { + id: '000000012', + title: 'ABCD 版本发布', + description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务', + extra: '进行中', + status: 'processing', + type: '待办', + }]); + }, +}; diff --git a/src/main/frontend/mock/rule.js b/src/main/frontend/mock/rule.js new file mode 100644 index 0000000..4a1a4da --- /dev/null +++ b/src/main/frontend/mock/rule.js @@ -0,0 +1,127 @@ +import { getUrlParams } from './utils'; + +// mock tableListDataSource +let tableListDataSource = []; +for (let i = 0; i < 46; i += 1) { + tableListDataSource.push({ + key: i, + disabled: ((i % 6) === 0), + href: 'https://ant.design', + avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2], + no: `TradeCode ${i}`, + title: `一个任务名称 ${i}`, + owner: '曲丽丽', + description: '这是一段描述', + callNo: Math.floor(Math.random() * 1000), + status: Math.floor(Math.random() * 10) % 4, + updatedAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`), + createdAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`), + progress: Math.ceil(Math.random() * 100), + }); +} + +export function getRule(req, res, u) { + let url = u; + if (!url || Object.prototype.toString.call(url) !== '[object String]') { + url = req.url; // eslint-disable-line + } + + const params = getUrlParams(url); + + let dataSource = [...tableListDataSource]; + + if (params.sorter) { + const s = params.sorter.split('_'); + dataSource = dataSource.sort((prev, next) => { + if (s[1] === 'descend') { + return next[s[0]] - prev[s[0]]; + } + return prev[s[0]] - next[s[0]]; + }); + } + + if (params.status) { + const s = params.status.split(','); + if (s.length === 1) { + dataSource = dataSource.filter(data => parseInt(data.status, 10) === parseInt(s[0], 10)); + } + } + + if (params.no) { + dataSource = dataSource.filter(data => data.no.indexOf(params.no) > -1); + } + + let pageSize = 10; + if (params.pageSize) { + pageSize = params.pageSize * 1; + } + + const result = { + list: dataSource, + pagination: { + total: dataSource.length, + pageSize, + current: parseInt(params.currentPage, 10) || 1, + }, + }; + + if (res && res.json) { + res.json(result); + } else { + return result; + } +} + +export function postRule(req, res, u, b) { + let url = u; + if (!url || Object.prototype.toString.call(url) !== '[object String]') { + url = req.url; // eslint-disable-line + } + + const body = (b && b.body) || req.body; + const { method, no, description } = body; + + switch (method) { + /* eslint no-case-declarations:0 */ + case 'delete': + tableListDataSource = tableListDataSource.filter(item => no.indexOf(item.no) === -1); + break; + case 'post': + const i = Math.ceil(Math.random() * 10000); + tableListDataSource.unshift({ + key: i, + href: 'https://ant.design', + avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2], + no: `TradeCode ${i}`, + title: `一个任务名称 ${i}`, + owner: '曲丽丽', + description, + callNo: Math.floor(Math.random() * 1000), + status: Math.floor(Math.random() * 10) % 2, + updatedAt: new Date(), + createdAt: new Date(), + progress: Math.ceil(Math.random() * 100), + }); + break; + default: + break; + } + + const result = { + list: tableListDataSource, + pagination: { + total: tableListDataSource.length, + }, + }; + + if (res && res.json) { + res.json(result); + } else { + return result; + } +} + +export default { + getRule, + postRule, +}; diff --git a/src/main/frontend/mock/utils.js b/src/main/frontend/mock/utils.js new file mode 100644 index 0000000..8438a26 --- /dev/null +++ b/src/main/frontend/mock/utils.js @@ -0,0 +1,45 @@ +export const imgMap = { + user: 'https://gw.alipayobjects.com/zos/rmsportal/UjusLxePxWGkttaqqmUI.png', + a: 'https://gw.alipayobjects.com/zos/rmsportal/ZrkcSjizAKNWwJTwcadT.png', + b: 'https://gw.alipayobjects.com/zos/rmsportal/KYlwHMeomKQbhJDRUVvt.png', + c: 'https://gw.alipayobjects.com/zos/rmsportal/gabvleTstEvzkbQRfjxu.png', + d: 'https://gw.alipayobjects.com/zos/rmsportal/jvpNzacxUYLlNsHTtrAD.png', +}; + +// refers: https://www.sitepoint.com/get-url-parameters-with-javascript/ +export function getUrlParams(url) { + const d = decodeURIComponent; + let queryString = url ? url.split('?')[1] : window.location.search.slice(1); + const obj = {}; + if (queryString) { + queryString = queryString.split('#')[0]; // eslint-disable-line + const arr = queryString.split('&'); + for (let i = 0; i < arr.length; i += 1) { + const a = arr[i].split('='); + let paramNum; + const paramName = a[0].replace(/\[\d*\]/, (v) => { + paramNum = v.slice(1, -1); + return ''; + }); + const paramValue = typeof (a[1]) === 'undefined' ? true : a[1]; + if (obj[paramName]) { + if (typeof obj[paramName] === 'string') { + obj[paramName] = d([obj[paramName]]); + } + if (typeof paramNum === 'undefined') { + obj[paramName].push(d(paramValue)); + } else { + obj[paramName][paramNum] = d(paramValue); + } + } else { + obj[paramName] = d(paramValue); + } + } + } + return obj; +} + +export default { + getUrlParams, + imgMap, +}; diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json new file mode 100755 index 0000000..2deb001 --- /dev/null +++ b/src/main/frontend/package.json @@ -0,0 +1,107 @@ +{ + "name": "skywalking-ui", + "version": "5.0.0-alpha", + "description": "A web interface of SkyWalking", + "private": true, + "scripts": { + "start": "roadhog server", + "start:no-proxy": "cross-env NO_PROXY=true roadhog server", + "build": "roadhog build", + "site": "roadhog-api-doc static && gh-pages -d dist", + "analyze": "roadhog build --analyze", + "lint:style": "stylelint \"src/**/*.less\" --syntax less", + "lint": "eslint --ext .js src mock tests && npm run lint:style", + "lint:fix": "eslint --fix --ext .js src mock tests && npm run lint:style", + "lint-staged": "lint-staged", + "lint-staged:js": "eslint --ext .js", + "test": "jest", + "test:all": "node ./tests/run-tests.js" + }, + "dependencies": { + "ant-design-pro": "^0.2.3-rc.1", + "antd": "^3.0.0-beta.1", + "babel-runtime": "^6.9.2", + "classnames": "^2.2.5", + "core-js": "^2.5.1", + "dva": "^2.0.3", + "lodash": "^4.17.4", + "lodash-decorators": "^4.4.1", + "lodash.clonedeep": "^4.5.0", + "moment": "^2.19.1", + "numeral": "^2.0.6", + "prop-types": "^15.5.10", + "qs": "^6.5.0", + "react": "^16.0.0", + "react-container-query": "^0.9.1", + "react-document-title": "^2.0.3", + "react-dom": "^16.0.0", + "react-fittext": "^1.0.0" + }, + "devDependencies": { + "babel-eslint": "^8.0.1", + "babel-jest": "^21.0.0", + "babel-plugin-dva-hmr": "^0.3.2", + "babel-plugin-import": "^1.2.1", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-transform-runtime": "^6.9.0", + "babel-preset-env": "^1.6.1", + "babel-preset-react": "^6.24.1", + "cross-env": "^5.1.1", + "cross-port-killer": "^1.0.1", + "enzyme": "^3.1.0", + "enzyme-adapter-react-16": "^1.0.2", + "eslint": "^4.8.0", + "eslint-config-airbnb": "^16.0.0", + "eslint-plugin-babel": "^4.0.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-jsx-a11y": "^6.0.0", + "eslint-plugin-markdown": "^1.0.0-beta.6", + "eslint-plugin-react": "^7.0.1", + "gh-pages": "^1.0.0", + "husky": "^0.14.3", + "jest": "^21.0.1", + "lint-staged": "^4.3.0", + "mockjs": "^1.0.1-beta3", + "pro-download": "^1.0.0", + "react-test-renderer": "^16.0.0", + "redbox-react": "^1.3.2", + "roadhog": "^1.3.1", + "roadhog-api-doc": "^0.2.5", + "stylelint": "^8.1.0", + "stylelint-config-standard": "^17.0.0" + }, + "optionalDependencies": { + "nightmare": "^2.10.0" + }, + "babel": { + "presets": [ + "env", + "react" + ], + "plugins": [ + "transform-decorators-legacy", + "transform-class-properties" + ] + }, + "jest": { + "setupFiles": [ + "<rootDir>/tests/setupTests.js" + ], + "testMatch": [ + "**/?(*.)(spec|test|e2e).js?(x)" + ], + "setupTestFrameworkScriptFile": "<rootDir>/tests/jasmine.js", + "moduleFileExtensions": [ + "js", + "jsx" + ], + "moduleNameMapper": { + "\\.(css|less)$": "<rootDir>/tests/styleMock.js" + } + }, + "lint-staged": { + "**/*.{js,jsx}": "lint-staged:js", + "**/*.less": "stylelint --syntax less" + } +} diff --git a/src/main/frontend/public/index.html b/src/main/frontend/public/index.html new file mode 100755 index 0000000..5d5ec75 --- /dev/null +++ b/src/main/frontend/public/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Apache SkyWalking</title> + <link rel="stylesheet" href="index.css" /> +</head> +<body> + <div id="root"></div> + <script src="https://gw.alipayobjects.com/as/g/??datavis/g2/2.3.12/index.js,datavis/g-cloud/1.0.2/index.js,datavis/g2-plugin-slider/1.2.1/slider.js"></script> + <script src="index.js"></script> +</body> +</html> diff --git a/src/main/frontend/src/common/nav.js b/src/main/frontend/src/common/nav.js new file mode 100644 index 0000000..2d7e9dd --- /dev/null +++ b/src/main/frontend/src/common/nav.js @@ -0,0 +1,26 @@ +import dynamic from 'dva/dynamic'; + +// wrapper of dynamic +const dynamicWrapper = (app, models, component) => dynamic({ + app, + models: () => models.map(m => import(`../models/${m}.js`)), + component, +}); + +// nav data +export const getNavData = app => [ + { + component: dynamicWrapper(app, ['user'], () => import('../layouts/BasicLayout')), + layout: 'BasicLayout', + name: 'Main', // for breadcrumb + path: '/', + children: [ + { + name: 'Dashboard', + icon: 'dashboard', + path: 'dashboard', + component: dynamicWrapper(app, ['dashboard'], () => import('../routes/Dashboard/Dashboard')), + }, + ], + }, +]; diff --git a/src/main/frontend/src/components/StandardFormRow/index.js b/src/main/frontend/src/components/StandardFormRow/index.js new file mode 100644 index 0000000..4ed6d9d --- /dev/null +++ b/src/main/frontend/src/components/StandardFormRow/index.js @@ -0,0 +1,26 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './index.less'; + +export default ({ title, children, last, block, grid, ...rest }) => { + const cls = classNames(styles.standardFormRow, { + [styles.standardFormRowBlock]: block, + [styles.standardFormRowLast]: last, + [styles.standardFormRowGrid]: grid, + }); + + return ( + <div className={cls} {...rest}> + { + title && ( + <div className={styles.label}> + <span>{title}</span> + </div> + ) + } + <div className={styles.content}> + {children} + </div> + </div> + ); +}; diff --git a/src/main/frontend/src/components/StandardFormRow/index.less b/src/main/frontend/src/components/StandardFormRow/index.less new file mode 100644 index 0000000..291d127 --- /dev/null +++ b/src/main/frontend/src/components/StandardFormRow/index.less @@ -0,0 +1,71 @@ +@import "~antd/lib/style/themes/default.less"; + +.standardFormRow { + border-bottom: 1px dashed @border-color-split; + padding-bottom: 16px; + margin-bottom: 16px; + display: flex; + :global { + .ant-form-item { + margin-right: 24px; + } + .ant-form-item-label label { + color: @text-color; + margin-right: 0; + } + .ant-form-item-label { + padding: 0; + line-height: 32px; + } + } + .label { + color: @heading-color; + font-size: @font-size-base; + margin-right: 24px; + flex: 0 0 auto; + text-align: right; + & > span { + display: inline-block; + height: 32px; + line-height: 32px; + &:after { + content: ':'; + } + } + } + .content { + flex: 1 1 0; + :global { + .ant-form-item:last-child { + margin-right: 0; + } + } + } +} + +.standardFormRowLast { + border: none; + padding-bottom: 0; + margin-bottom: 0; +} + +.standardFormRowBlock { + :global { + .ant-form-item, + div.ant-form-item-control-wrapper { + display: block; + } + } +} + +.standardFormRowGrid { + :global { + .ant-form-item, + div.ant-form-item-control-wrapper { + display: block; + } + .ant-form-item-label { + float: left; + } + } +} diff --git a/src/main/frontend/src/components/StandardTable/index.js b/src/main/frontend/src/components/StandardTable/index.js new file mode 100644 index 0000000..3a4ec8a --- /dev/null +++ b/src/main/frontend/src/components/StandardTable/index.js @@ -0,0 +1,154 @@ +import React, { PureComponent } from 'react'; +import moment from 'moment'; +import { Table, Alert, Badge, Divider } from 'antd'; +import styles from './index.less'; + +const statusMap = ['default', 'processing', 'success', 'error']; +class StandardTable extends PureComponent { + state = { + selectedRowKeys: [], + totalCallNo: 0, + }; + + componentWillReceiveProps(nextProps) { + // clean state + if (nextProps.selectedRows.length === 0) { + this.setState({ + selectedRowKeys: [], + totalCallNo: 0, + }); + } + } + + handleRowSelectChange = (selectedRowKeys, selectedRows) => { + const totalCallNo = selectedRows.reduce((sum, val) => { + return sum + parseFloat(val.callNo, 10); + }, 0); + + if (this.props.onSelectRow) { + this.props.onSelectRow(selectedRows); + } + + this.setState({ selectedRowKeys, totalCallNo }); + } + + handleTableChange = (pagination, filters, sorter) => { + this.props.onChange(pagination, filters, sorter); + } + + cleanSelectedKeys = () => { + this.handleRowSelectChange([], []); + } + + render() { + const { selectedRowKeys, totalCallNo } = this.state; + const { data: { list, pagination }, loading } = this.props; + + const status = ['关闭', '运行中', '已上线', '异常']; + + const columns = [ + { + title: '规则编号', + dataIndex: 'no', + }, + { + title: '描述', + dataIndex: 'description', + }, + { + title: '服务调用次数', + dataIndex: 'callNo', + sorter: true, + render: val => ( + <div style={{ textAlign: 'center' }}> + {val} 万 + </div> + ), + }, + { + title: '状态', + dataIndex: 'status', + filters: [ + { + text: status[0], + value: 0, + }, + { + text: status[1], + value: 1, + }, + { + text: status[2], + value: 2, + }, + { + text: status[3], + value: 3, + }, + ], + render(val) { + return <Badge status={statusMap[val]} text={status[val]} />; + }, + }, + { + title: '更新时间', + dataIndex: 'updatedAt', + sorter: true, + render: val => <span>{moment(val).format('YYYY-MM-DD HH:mm:ss')}</span>, + }, + { + title: '操作', + render: () => ( + <div> + <a href="">配置</a> + <Divider type="vertical" /> + <a href="">订阅警报</a> + </div> + ), + }, + ]; + + const paginationProps = { + showSizeChanger: true, + showQuickJumper: true, + ...pagination, + }; + + const rowSelection = { + selectedRowKeys, + onChange: this.handleRowSelectChange, + getCheckboxProps: record => ({ + disabled: record.disabled, + }), + }; + + return ( + <div className={styles.standardTable}> + <div className={styles.tableAlert}> + <Alert + message={( + <div> + 已选择 <a style={{ fontWeight: 600 }}>{selectedRowKeys.length}</a> 项 + 服务调用总计 <span style={{ fontWeight: 600 }}>{totalCallNo}</span> 万 + <a onClick={this.cleanSelectedKeys} style={{ marginLeft: 24 }}>清空</a> + </div> + )} + type="info" + showIcon + /> + </div> + <Table + loading={loading} + rowKey={record => record.key} + rowSelection={rowSelection} + dataSource={list} + columns={columns} + pagination={paginationProps} + onChange={this.handleTableChange} + /> + </div> + ); + } +} + +export default StandardTable; diff --git a/src/main/frontend/src/components/StandardTable/index.less b/src/main/frontend/src/components/StandardTable/index.less new file mode 100644 index 0000000..4ced9e7 --- /dev/null +++ b/src/main/frontend/src/components/StandardTable/index.less @@ -0,0 +1,13 @@ +@import "~antd/lib/style/themes/default.less"; + +.standardTable { + :global { + .ant-table-pagination { + margin-top: 24px; + } + } + + .tableAlert { + margin-bottom: 16px; + } +} diff --git a/src/main/frontend/src/e2e/home.e2e.js b/src/main/frontend/src/e2e/home.e2e.js new file mode 100644 index 0000000..6de577b --- /dev/null +++ b/src/main/frontend/src/e2e/home.e2e.js @@ -0,0 +1,9 @@ +import Nightmare from 'nightmare'; + +describe('Homepage', () => { + it('it should have logo text', async () => { + const page = Nightmare().goto('http://localhost:8000'); + const text = await page.evaluate(() => document.body.innerHTML).end(); + expect(text).toContain('<h1>Ant Design Pro</h1>'); + }); +}); diff --git a/src/main/frontend/src/index.js b/src/main/frontend/src/index.js new file mode 100644 index 0000000..aad2aaa --- /dev/null +++ b/src/main/frontend/src/index.js @@ -0,0 +1,22 @@ +import dva from 'dva'; +import 'ant-design-pro/dist/ant-design-pro.css'; +// import browserHistory from 'history/createBrowserHistory'; +import './polyfill'; +import './index.less'; + +// 1. Initialize +const app = dva({ + // history: browserHistory(), +}); + +// 2. Plugins +// app.use({}); + +// 3. Register global model +app.model(require('./models/global')); + +// 4. Router +app.router(require('./router')); + +// 5. Start +app.start('#root'); diff --git a/src/main/frontend/src/index.less b/src/main/frontend/src/index.less new file mode 100644 index 0000000..e1a890a --- /dev/null +++ b/src/main/frontend/src/index.less @@ -0,0 +1,16 @@ + + +html, body, :global(#root) { + height: 100%; +} + +body { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.globalSpin { + width: 100%; + margin: 40px 0 !important; +} diff --git a/src/main/frontend/src/layouts/BasicLayout.js b/src/main/frontend/src/layouts/BasicLayout.js new file mode 100644 index 0000000..6eab185 --- /dev/null +++ b/src/main/frontend/src/layouts/BasicLayout.js @@ -0,0 +1,375 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DocumentTitle from 'react-document-title'; + +import { connect } from 'dva'; +import { Link, Route, Redirect, Switch } from 'dva/router'; +import { Layout, Menu, Icon, Dropdown, Tag, message } from 'antd'; + +import NoticeIcon from 'ant-design-pro/lib/NoticeIcon'; +import GlobalFooter from 'ant-design-pro/lib/GlobalFooter'; + +import moment from 'moment'; +import groupBy from 'lodash/groupBy'; +import classNames from 'classnames'; +import { ContainerQuery } from 'react-container-query'; + +import styles from './BasicLayout.less'; + +const { Header, Sider, Content } = Layout; +const { SubMenu } = Menu; + +const query = { + 'screen-xs': { + maxWidth: 575, + }, + 'screen-sm': { + minWidth: 576, + maxWidth: 767, + }, + 'screen-md': { + minWidth: 768, + maxWidth: 991, + }, + 'screen-lg': { + minWidth: 992, + maxWidth: 1199, + }, + 'screen-xl': { + minWidth: 1200, + }, +}; + +class BasicLayout extends React.PureComponent { + static childContextTypes = { + location: PropTypes.object, + breadcrumbNameMap: PropTypes.object, + } + constructor(props) { + super(props); + // 把一级 Layout 的 children 作为菜单项 + this.menus = props.navData.reduce((arr, current) => arr.concat(current.children), []); + this.state = { + openKeys: this.getDefaultCollapsedSubMenus(props), + }; + } + getChildContext() { + const { location, navData, getRouteData } = this.props; + const routeData = getRouteData('BasicLayout'); + const firstMenuData = navData.reduce((arr, current) => arr.concat(current.children), []); + const menuData = this.getMenuData(firstMenuData, ''); + const breadcrumbNameMap = {}; + + routeData.concat(menuData).forEach((item) => { + breadcrumbNameMap[item.path] = item.name; + }); + return { location, breadcrumbNameMap }; + } + componentDidMount() { + this.props.dispatch({ + type: 'user/fetchCurrent', + }); + } + componentWillUnmount() { + clearTimeout(this.resizeTimeout); + } + onCollapse = (collapsed) => { + this.props.dispatch({ + type: 'global/changeLayoutCollapsed', + payload: collapsed, + }); + } + onMenuClick = ({ key }) => { + if (key === 'logout') { + this.props.dispatch({ + type: 'login/logout', + }); + } + } + getMenuData = (data, parentPath) => { + let arr = []; + data.forEach((item) => { + if (item.children) { + arr.push({ path: `${parentPath}/${item.path}`, name: item.name }); + arr = arr.concat(this.getMenuData(item.children, `${parentPath}/${item.path}`)); + } + }); + return arr; + } + getDefaultCollapsedSubMenus(props) { + const currentMenuSelectedKeys = [...this.getCurrentMenuSelectedKeys(props)]; + currentMenuSelectedKeys.splice(-1, 1); + if (currentMenuSelectedKeys.length === 0) { + return ['dashboard']; + } + return currentMenuSelectedKeys; + } + getCurrentMenuSelectedKeys(props) { + const { location: { pathname } } = props || this.props; + const keys = pathname.split('/').slice(1); + if (keys.length === 1 && keys[0] === '') { + return [this.menus[0].key]; + } + return keys; + } + getNavMenuItems(menusData, parentPath = '') { + if (!menusData) { + return []; + } + return menusData.map((item) => { + if (!item.name) { + return null; + } + let itemPath; + if (item.path.indexOf('http') === 0) { + itemPath = item.path; + } else { + itemPath = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/'); + } + if (item.children && item.children.some(child => child.name)) { + return ( + <SubMenu + title={ + item.icon ? ( + <span> + <Icon type={item.icon} /> + <span>{item.name}</span> + </span> + ) : item.name + } + key={item.key || item.path} + > + {this.getNavMenuItems(item.children, itemPath)} + </SubMenu> + ); + } + const icon = item.icon && <Icon type={item.icon} />; + return ( + <Menu.Item key={item.key || item.path}> + { + /^https?:\/\//.test(itemPath) ? ( + <a href={itemPath} target={item.target}> + {icon}<span>{item.name}</span> + </a> + ) : ( + <Link + to={itemPath} + target={item.target} + replace={itemPath === this.props.location.pathname} + > + {icon}<span>{item.name}</span> + </Link> + ) + } + </Menu.Item> + ); + }); + } + getPageTitle() { + const { location, getRouteData } = this.props; + const { pathname } = location; + let title = 'SkyWalking'; + getRouteData('BasicLayout').forEach((item) => { + if (item.path === pathname) { + title = `${item.name} - SkyWalking`; + } + }); + return title; + } + getNoticeData() { + const { notices = [] } = this.props; + if (notices.length === 0) { + return {}; + } + const newNotices = notices.map((notice) => { + const newNotice = { ...notice }; + if (newNotice.datetime) { + newNotice.datetime = moment(notice.datetime).fromNow(); + } + // transform id to item key + if (newNotice.id) { + newNotice.key = newNotice.id; + } + if (newNotice.extra && newNotice.status) { + const color = ({ + todo: '', + processing: 'blue', + urgent: 'red', + doing: 'gold', + })[newNotice.status]; + newNotice.extra = <Tag color={color} style={{ marginRight: 0 }}>{newNotice.extra}</Tag>; + } + return newNotice; + }); + return groupBy(newNotices, 'type'); + } + handleOpenChange = (openKeys) => { + const lastOpenKey = openKeys[openKeys.length - 1]; + const isMainMenu = this.menus.some( + item => lastOpenKey && (item.key === lastOpenKey || item.path === lastOpenKey) + ); + this.setState({ + openKeys: isMainMenu ? [lastOpenKey] : [...openKeys], + }); + } + toggle = () => { + const { collapsed } = this.props; + this.props.dispatch({ + type: 'global/changeLayoutCollapsed', + payload: !collapsed, + }); + this.resizeTimeout = setTimeout(() => { + const event = document.createEvent('HTMLEvents'); + event.initEvent('resize', true, false); + window.dispatchEvent(event); + }, 600); + } + handleNoticeClear = (type) => { + message.success(`清空了${type}`); + this.props.dispatch({ + type: 'global/clearNotices', + payload: type, + }); + } + handleNoticeVisibleChange = (visible) => { + if (visible) { + this.props.dispatch({ + type: 'global/fetchNotices', + }); + } + } + render() { + const { currentUser, collapsed, fetchingNotices, getRouteData } = this.props; + + const menu = ( + <Menu selectedKeys={['1']} onClick={this.onMenuClick}> + <Menu.Item key="1">Last 15 minutes</Menu.Item> + <Menu.Item key="2">Last 1 hour</Menu.Item> + </Menu> + ); + const noticeData = this.getNoticeData(); + + // Don't show popup menu when it is been collapsed + const menuProps = collapsed ? {} : { + openKeys: this.state.openKeys, + }; + + const layout = ( + <Layout> + <Sider + trigger={null} + collapsible + collapsed={collapsed} + breakpoint="md" + onCollapse={this.onCollapse} + width={256} + className={styles.sider} + > + <div className={styles.logo}> + <Link to="/"> + <img src="https://camo.githubusercontent.com/4ac940361b7345156ff71aa21efdb42a449e67d7/68747470733a2f2f736b7977616c6b696e67746573742e6769746875622e696f2f706167652d7265736f75726365732f332e302f736b7977616c6b696e672e706e67" alt="logo" /> + </Link> + </div> + <Menu + theme="dark" + mode="inline" + {...menuProps} + onOpenChange={this.handleOpenChange} + selectedKeys={this.getCurrentMenuSelectedKeys()} + style={{ margin: '16px 0', width: '100%' }} + > + {this.getNavMenuItems(this.menus)} + </Menu> + </Sider> + <Layout> + <Header className={styles.header}> + <Icon + className={styles.trigger} + type={collapsed ? 'menu-unfold' : 'menu-fold'} + onClick={this.toggle} + /> + <div className={styles.right}> + <Dropdown overlay={menu}> + <span className={`${styles.action}`}> + Last 15 minutes + </span> + </Dropdown> + <NoticeIcon + className={styles.action} + count={currentUser.notifyCount} + onItemClick={(item, tabProps) => { + console.log(item, tabProps); // eslint-disable-line + }} + onClear={this.handleNoticeClear} + onPopupVisibleChange={this.handleNoticeVisibleChange} + loading={fetchingNotices} + popupAlign={{ offset: [20, -16] }} + > + <NoticeIcon.Tab + list={noticeData['告警']} + title="告警" + emptyText="无告警信息" + emptyImage="https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg" + /> + <NoticeIcon.Tab + list={noticeData['消息']} + title="消息" + emptyText="您已读完所有消息" + emptyImage="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg" + /> + </NoticeIcon> + </div> + </Header> + <Content style={{ margin: '24px 24px 0', height: '100%' }}> + <Switch> + { + getRouteData('BasicLayout').map(item => + ( + <Route + exact={item.exact} + key={item.path} + path={item.path} + component={item.component} + /> + ) + ) + } + <Redirect exact from="/" to="/dashboard" /> + </Switch> + <GlobalFooter + links={[{ + title: 'SkyWalking', + href: 'http://skywalking.io', + blankTarget: true, + }, { + title: 'GitHub', + href: 'https://github.com/apache/incubator-skywalking', + blankTarget: true, + }]} + copyright={ + <div> + Copyright <Icon type="copyright" /> 2018 SkyWalking + </div> + } + /> + </Content> + </Layout> + </Layout> + ); + + return ( + <DocumentTitle title={this.getPageTitle()}> + <ContainerQuery query={query}> + {params => <div className={classNames(params)}>{layout}</div>} + </ContainerQuery> + </DocumentTitle> + ); + } +} + +export default connect(state => ({ + currentUser: state.user.currentUser, + collapsed: state.global.collapsed, + fetchingNotices: state.global.fetchingNotices, + notices: state.global.notices, +}))(BasicLayout); diff --git a/src/main/frontend/src/layouts/BasicLayout.less b/src/main/frontend/src/layouts/BasicLayout.less new file mode 100644 index 0000000..6ab937a --- /dev/null +++ b/src/main/frontend/src/layouts/BasicLayout.less @@ -0,0 +1,113 @@ +@import "~antd/lib/style/themes/default.less"; + +.header { + padding: 0 12px 0 0; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 21, 41, .08); + position: relative; +} + +.logo { + height: 64px; + position: relative; + line-height: 64px; + padding-left: 24px; + transition: all .3s; + background: #002140; + overflow: hidden; + img { + display: inline-block; + vertical-align: middle; + height: 32px; + } + h1 { + color: #fff; + display: inline-block; + vertical-align: middle; + font-size: 20px; + margin: 0 0 0 12px; + font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-weight: 600; + } +} + +:global(.ant-layout-sider-collapsed) .logo { + padding-left: 24px; + > a { + width: 32px; + } +} + +.trigger { + font-size: 20px; + line-height: 64px; + cursor: pointer; + transition: all .3s; + padding: 0 24px; + &:hover { + background: @primary-1; + } +} + +@media screen and (max-width: @screen-xs) { + .trigger { + display: none; + } +} + +.right { + float: right; + height: 100%; + .action { + cursor: pointer; + padding: 0 12px; + display: inline-block; + transition: all .3s; + height: 100%; + > i { + font-size: 16px; + vertical-align: middle; + } + &:global(.ant-popover-open), + &:hover { + background: @primary-1; + } + } + .search { + padding: 0; + margin: 0 12px; + &:hover { + background: transparent; + } + } + .account { + .avatar { + margin: 20px 8px 20px 0; + color: @primary-color; + background: rgba(255, 255, 255, .85); + vertical-align: middle; + } + } +} + +.menu { + :global(.anticon) { + margin-right: 8px; + } + :global(.ant-dropdown-menu-item) { + width: 160px; + } +} + +:global { + .ant-layout { + overflow-x: hidden; + } +} + +.sider { + min-height: 100vh; + box-shadow: 2px 0 6px rgba(0, 21, 41, .35); + position: relative; + z-index: 10; +} diff --git a/src/main/frontend/src/layouts/BlankLayout.js b/src/main/frontend/src/layouts/BlankLayout.js new file mode 100644 index 0000000..505270f --- /dev/null +++ b/src/main/frontend/src/layouts/BlankLayout.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export default props => <div {...props} />; diff --git a/src/main/frontend/src/layouts/PageHeaderLayout.js b/src/main/frontend/src/layouts/PageHeaderLayout.js new file mode 100644 index 0000000..bc4a05c --- /dev/null +++ b/src/main/frontend/src/layouts/PageHeaderLayout.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { Link } from 'dva/router'; +import PageHeader from 'ant-design-pro/lib/PageHeader'; +import styles from './PageHeaderLayout.less'; + +export default ({ children, wrapperClassName, top, ...restProps }) => ( + <div style={{ margin: '-24px -24px 0' }} className={wrapperClassName}> + {top} + <PageHeader {...restProps} linkElement={Link} /> + {children ? <div className={styles.content}>{children}</div> : null} + </div> +); diff --git a/src/main/frontend/src/layouts/PageHeaderLayout.less b/src/main/frontend/src/layouts/PageHeaderLayout.less new file mode 100644 index 0000000..a0c0a6e --- /dev/null +++ b/src/main/frontend/src/layouts/PageHeaderLayout.less @@ -0,0 +1,11 @@ +@import "~antd/lib/style/themes/default.less"; + +.content { + margin: 24px 24px 0; +} + +@media screen and (max-width: @screen-sm) { + .content { + margin: 24px 0 0; + } +} diff --git a/src/main/frontend/src/models/dashboard.js b/src/main/frontend/src/models/dashboard.js new file mode 100644 index 0000000..8370d25 --- /dev/null +++ b/src/main/frontend/src/models/dashboard.js @@ -0,0 +1,16 @@ +// import { xxx } from '../services/xxx'; +export default { + namespace: 'dashboar', + state: {}, + effects: { + *fetch({ payload }, { call, put }) { + }, + }, + reducers: { + save(state, action) { + return { + ...state, + }; + }, + }, +}; diff --git a/src/main/frontend/src/models/global.js b/src/main/frontend/src/models/global.js new file mode 100644 index 0000000..91127ed --- /dev/null +++ b/src/main/frontend/src/models/global.js @@ -0,0 +1,76 @@ +import { queryNotices } from '../services/api'; + +export default { + namespace: 'global', + + state: { + collapsed: false, + notices: [], + fetchingNotices: false, + }, + + effects: { + *fetchNotices(_, { call, put }) { + yield put({ + type: 'changeNoticeLoading', + payload: true, + }); + const data = yield call(queryNotices); + yield put({ + type: 'saveNotices', + payload: data, + }); + }, + *clearNotices({ payload }, { put, select }) { + const count = yield select(state => state.global.notices.length); + yield put({ + type: 'user/changeNotifyCount', + payload: count, + }); + + yield put({ + type: 'saveClearedNotices', + payload, + }); + }, + }, + + reducers: { + changeLayoutCollapsed(state, { payload }) { + return { + ...state, + collapsed: payload, + }; + }, + saveNotices(state, { payload }) { + return { + ...state, + notices: payload, + fetchingNotices: false, + }; + }, + saveClearedNotices(state, { payload }) { + return { + ...state, + notices: state.notices.filter(item => item.type !== payload), + }; + }, + changeNoticeLoading(state, { payload }) { + return { + ...state, + fetchingNotices: payload, + }; + }, + }, + + subscriptions: { + setup({ history }) { + // Subscribe history(url) change, trigger `load` action if pathname is `/` + return history.listen(({ pathname, search }) => { + if (typeof window.ga !== 'undefined') { + window.ga('send', 'pageview', pathname + search); + } + }); + }, + }, +}; diff --git a/src/main/frontend/src/models/index.js b/src/main/frontend/src/models/index.js new file mode 100644 index 0000000..3666614 --- /dev/null +++ b/src/main/frontend/src/models/index.js @@ -0,0 +1,11 @@ +// Use require.context to require reducers automatically +// Ref: https://webpack.github.io/docs/context.html +const context = require.context('./', false, /\.js$/); +const keys = context.keys().filter(item => item !== './index.js'); + +const models = []; +for (let i = 0; i < keys.length; i += 1) { + models.push(context(keys[i])); +} + +export default models; diff --git a/src/main/frontend/src/models/rule.js b/src/main/frontend/src/models/rule.js new file mode 100644 index 0000000..8b36ba3 --- /dev/null +++ b/src/main/frontend/src/models/rule.js @@ -0,0 +1,80 @@ +import { queryRule, removeRule, addRule } from '../services/api'; + +export default { + namespace: 'rule', + + state: { + data: { + list: [], + pagination: {}, + }, + loading: true, + }, + + effects: { + *fetch({ payload }, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(queryRule, payload); + yield put({ + type: 'save', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + }, + *add({ payload, callback }, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(addRule, payload); + yield put({ + type: 'save', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + + if (callback) callback(); + }, + *remove({ payload, callback }, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(removeRule, payload); + yield put({ + type: 'save', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + + if (callback) callback(); + }, + }, + + reducers: { + save(state, action) { + return { + ...state, + data: action.payload, + }; + }, + changeLoading(state, action) { + return { + ...state, + loading: action.payload, + }; + }, + }, +}; diff --git a/src/main/frontend/src/models/user.js b/src/main/frontend/src/models/user.js new file mode 100644 index 0000000..197e362 --- /dev/null +++ b/src/main/frontend/src/models/user.js @@ -0,0 +1,66 @@ +import { query as queryUsers, queryCurrent } from '../services/user'; + +export default { + namespace: 'user', + + state: { + list: [], + loading: false, + currentUser: {}, + }, + + effects: { + *fetch(_, { call, put }) { + yield put({ + type: 'changeLoading', + payload: true, + }); + const response = yield call(queryUsers); + yield put({ + type: 'save', + payload: response, + }); + yield put({ + type: 'changeLoading', + payload: false, + }); + }, + *fetchCurrent(_, { call, put }) { + const response = yield call(queryCurrent); + yield put({ + type: 'saveCurrentUser', + payload: response, + }); + }, + }, + + reducers: { + save(state, action) { + return { + ...state, + list: action.payload, + }; + }, + changeLoading(state, action) { + return { + ...state, + loading: action.payload, + }; + }, + saveCurrentUser(state, action) { + return { + ...state, + currentUser: action.payload, + }; + }, + changeNotifyCount(state, action) { + return { + ...state, + currentUser: { + ...state.currentUser, + notifyCount: action.payload, + }, + }; + }, + }, +}; diff --git a/src/main/frontend/src/polyfill.js b/src/main/frontend/src/polyfill.js new file mode 100644 index 0000000..a358959 --- /dev/null +++ b/src/main/frontend/src/polyfill.js @@ -0,0 +1,7 @@ +import 'core-js/es6/map'; +import 'core-js/es6/set'; + +global.requestAnimationFrame = + global.requestAnimationFrame || function requestAnimationFrame(callback) { + setTimeout(callback, 0); + }; diff --git a/src/main/frontend/src/router.js b/src/main/frontend/src/router.js new file mode 100644 index 0000000..1c51d96 --- /dev/null +++ b/src/main/frontend/src/router.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { Router, Route, Switch } from 'dva/router'; +import { Spin } from 'antd'; +import dynamic from 'dva/dynamic'; +import cloneDeep from 'lodash/cloneDeep'; +import { getNavData } from './common/nav'; +import { getPlainNode } from './utils/utils'; + +import styles from './index.less'; + +dynamic.setDefaultLoadingComponent(() => { + return <Spin size="large" className={styles.globalSpin} />; +}); + +function getRouteData(navData, path) { + if (!navData.some(item => item.layout === path) || + !(navData.filter(item => item.layout === path)[0].children)) { + return null; + } + const route = cloneDeep(navData.filter(item => item.layout === path)[0]); + const nodeList = getPlainNode(route.children); + return nodeList; +} + +function getLayout(navData, path) { + if (!navData.some(item => item.layout === path) || + !(navData.filter(item => item.layout === path)[0].children)) { + return null; + } + const route = navData.filter(item => item.layout === path)[0]; + return { + component: route.component, + layout: route.layout, + name: route.name, + path: route.path, + }; +} + +function RouterConfig({ history, app }) { + const navData = getNavData(app); + const BasicLayout = getLayout(navData, 'BasicLayout').component; + + const passProps = { + app, + navData, + getRouteData: (path) => { + return getRouteData(navData, path); + }, + }; + + return ( + <Router history={history}> + <Switch> + <Route path="/" render={props => <BasicLayout {...props} {...passProps} />} /> + </Switch> + </Router> + ); +} + +export default RouterConfig; diff --git a/src/main/frontend/src/routes/Dashboard/Dashboard.js b/src/main/frontend/src/routes/Dashboard/Dashboard.js new file mode 100644 index 0000000..353eca8 --- /dev/null +++ b/src/main/frontend/src/routes/Dashboard/Dashboard.js @@ -0,0 +1,13 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; + +@connect(state => ({ + dashboard: state.dashboard, +})) +export default class Dashboard extends PureComponent { + render() { + return ( + <div>test</div> + ); + } +} diff --git a/src/main/frontend/src/routes/Dashboard/Dashboard.less b/src/main/frontend/src/routes/Dashboard/Dashboard.less new file mode 100644 index 0000000..e52dd30 --- /dev/null +++ b/src/main/frontend/src/routes/Dashboard/Dashboard.less @@ -0,0 +1,23 @@ +@import "~antd/lib/style/themes/default.less"; +@import "../../utils/utils.less"; + +.mapChart { + padding-top: 24px; + height: 457px; + text-align: center; + img { + display: inline-block; + max-width: 100%; + max-height: 437px; + } +} + +.pieCard :global(.pie-stat) { + font-size: 24px!important; +} + +@media screen and (max-width: @screen-lg) { + .mapChart { + height: auto; + } +} diff --git a/src/main/frontend/src/routes/List/TableList.js b/src/main/frontend/src/routes/List/TableList.js new file mode 100644 index 0000000..332cd14 --- /dev/null +++ b/src/main/frontend/src/routes/List/TableList.js @@ -0,0 +1,326 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'dva'; +import { Row, Col, Card, Form, Input, Select, Icon, Button, Dropdown, Menu, InputNumber, DatePicker, Modal, message } from 'antd'; +import StandardTable from '../../components/StandardTable'; +import PageHeaderLayout from '../../layouts/PageHeaderLayout'; + +import styles from './TableList.less'; + +const FormItem = Form.Item; +const { Option } = Select; +const getValue = obj => Object.keys(obj).map(key => obj[key]).join(','); + +@connect(state => ({ + rule: state.rule, +})) [email protected]() +export default class TableList extends PureComponent { + state = { + addInputValue: '', + modalVisible: false, + expandForm: false, + selectedRows: [], + formValues: {}, + }; + + componentDidMount() { + const { dispatch } = this.props; + dispatch({ + type: 'rule/fetch', + }); + } + + handleStandardTableChange = (pagination, filtersArg, sorter) => { + const { dispatch } = this.props; + const { formValues } = this.state; + + const filters = Object.keys(filtersArg).reduce((obj, key) => { + const newObj = { ...obj }; + newObj[key] = getValue(filtersArg[key]); + return newObj; + }, {}); + + const params = { + currentPage: pagination.current, + pageSize: pagination.pageSize, + ...formValues, + ...filters, + }; + if (sorter.field) { + params.sorter = `${sorter.field}_${sorter.order}`; + } + + dispatch({ + type: 'rule/fetch', + payload: params, + }); + } + + handleFormReset = () => { + const { form, dispatch } = this.props; + form.resetFields(); + dispatch({ + type: 'rule/fetch', + payload: {}, + }); + } + + toggleForm = () => { + this.setState({ + expandForm: !this.state.expandForm, + }); + } + + handleMenuClick = (e) => { + const { dispatch } = this.props; + const { selectedRows } = this.state; + + if (!selectedRows) return; + + switch (e.key) { + case 'remove': + dispatch({ + type: 'rule/remove', + payload: { + no: selectedRows.map(row => row.no).join(','), + }, + callback: () => { + this.setState({ + selectedRows: [], + }); + }, + }); + break; + default: + break; + } + } + + handleSelectRows = (rows) => { + this.setState({ + selectedRows: rows, + }); + } + + handleSearch = (e) => { + e.preventDefault(); + + const { dispatch, form } = this.props; + + form.validateFields((err, fieldsValue) => { + if (err) return; + + const values = { + ...fieldsValue, + updatedAt: fieldsValue.updatedAt && fieldsValue.updatedAt.valueOf(), + }; + + this.setState({ + formValues: values, + }); + + dispatch({ + type: 'rule/fetch', + payload: values, + }); + }); + } + + handleModalVisible = (flag) => { + this.setState({ + modalVisible: !!flag, + }); + } + + handleAddInput = (e) => { + this.setState({ + addInputValue: e.target.value, + }); + } + + handleAdd = () => { + this.props.dispatch({ + type: 'rule/add', + payload: { + description: this.state.addInputValue, + }, + }); + + message.success('添加成功'); + this.setState({ + modalVisible: false, + }); + } + + renderSimpleForm() { + const { getFieldDecorator } = this.props.form; + return ( + <Form onSubmit={this.handleSearch} layout="inline"> + <Row gutter={{ md: 8, lg: 24, xl: 48 }}> + <Col md={8} sm={24}> + <FormItem label="规则编号"> + {getFieldDecorator('no')( + <Input placeholder="请输入" /> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <FormItem label="使用状态"> + {getFieldDecorator('status')( + <Select placeholder="请选择" style={{ width: '100%' }}> + <Option value="0">关闭</Option> + <Option value="1">运行中</Option> + </Select> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <span className={styles.submitButtons}> + <Button type="primary" htmlType="submit">查询</Button> + <Button style={{ marginLeft: 8 }} onClick={this.handleFormReset}>重置</Button> + <a style={{ marginLeft: 8 }} onClick={this.toggleForm}> + 展开 <Icon type="down" /> + </a> + </span> + </Col> + </Row> + </Form> + ); + } + + renderAdvancedForm() { + const { getFieldDecorator } = this.props.form; + return ( + <Form onSubmit={this.handleSearch} layout="inline"> + <Row gutter={{ md: 8, lg: 24, xl: 48 }}> + <Col md={8} sm={24}> + <FormItem label="规则编号"> + {getFieldDecorator('no')( + <Input placeholder="请输入" /> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <FormItem label="使用状态"> + {getFieldDecorator('status')( + <Select placeholder="请选择" style={{ width: '100%' }}> + <Option value="0">关闭</Option> + <Option value="1">运行中</Option> + </Select> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <FormItem label="调用次数"> + {getFieldDecorator('number')( + <InputNumber style={{ width: '100%' }} /> + )} + </FormItem> + </Col> + </Row> + <Row gutter={{ md: 8, lg: 24, xl: 48 }}> + <Col md={8} sm={24}> + <FormItem label="更新日期"> + {getFieldDecorator('date')( + <DatePicker style={{ width: '100%' }} placeholder="请输入更新日期" /> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <FormItem label="使用状态"> + {getFieldDecorator('status3')( + <Select placeholder="请选择" style={{ width: '100%' }}> + <Option value="0">关闭</Option> + <Option value="1">运行中</Option> + </Select> + )} + </FormItem> + </Col> + <Col md={8} sm={24}> + <FormItem label="使用状态"> + {getFieldDecorator('status4')( + <Select placeholder="请选择" style={{ width: '100%' }}> + <Option value="0">关闭</Option> + <Option value="1">运行中</Option> + </Select> + )} + </FormItem> + </Col> + </Row> + <div style={{ overflow: 'hidden' }}> + <span style={{ float: 'right', marginBottom: 24 }}> + <Button type="primary" htmlType="submit">查询</Button> + <Button style={{ marginLeft: 8 }} onClick={this.handleFormReset}>重置</Button> + <a style={{ marginLeft: 8 }} onClick={this.toggleForm}> + 收起 <Icon type="up" /> + </a> + </span> + </div> + </Form> + ); + } + + renderForm() { + return this.state.expandForm ? this.renderAdvancedForm() : this.renderSimpleForm(); + } + + render() { + const { rule: { loading: ruleLoading, data } } = this.props; + const { selectedRows, modalVisible, addInputValue } = this.state; + + const menu = ( + <Menu onClick={this.handleMenuClick} selectedKeys={[]}> + <Menu.Item key="remove">删除</Menu.Item> + <Menu.Item key="approval">批量审批</Menu.Item> + </Menu> + ); + + return ( + <PageHeaderLayout title="查询表格"> + <Card bordered={false}> + <div className={styles.tableList}> + <div className={styles.tableListForm}> + {this.renderForm()} + </div> + <div className={styles.tableListOperator}> + <Button icon="plus" type="primary" onClick={() => this.handleModalVisible(true)}>新建</Button> + { + selectedRows.length > 0 && ( + <span> + <Button>批量操作</Button> + <Dropdown overlay={menu}> + <Button> + 更多操作 <Icon type="down" /> + </Button> + </Dropdown> + </span> + ) + } + </div> + <StandardTable + selectedRows={selectedRows} + loading={ruleLoading} + data={data} + onSelectRow={this.handleSelectRows} + onChange={this.handleStandardTableChange} + /> + </div> + </Card> + <Modal + title="新建规则" + visible={modalVisible} + onOk={this.handleAdd} + onCancel={() => this.handleModalVisible()} + > + <FormItem + labelCol={{ span: 5 }} + wrapperCol={{ span: 15 }} + label="描述" + > + <Input placeholder="请输入" onChange={this.handleAddInput} value={addInputValue} /> + </FormItem> + </Modal> + </PageHeaderLayout> + ); + } +} diff --git a/src/main/frontend/src/routes/List/TableList.less b/src/main/frontend/src/routes/List/TableList.less new file mode 100644 index 0000000..6c9efa1 --- /dev/null +++ b/src/main/frontend/src/routes/List/TableList.less @@ -0,0 +1,45 @@ +@import "~antd/lib/style/themes/default.less"; +@import "../../utils/utils.less"; + +.tableList { + .tableListOperator { + margin-bottom: 16px; + button { + margin-right: 8px; + } + } +} + +.tableListForm { + :global { + .ant-form-item { + margin-bottom: 24px; + margin-right: 0; + display: flex; + > .ant-form-item-label { + width: auto; + line-height: 32px; + padding-right: 8px; + } + } + .ant-form-item-control-wrapper { + flex: 1; + } + } + .submitButtons { + white-space: nowrap; + margin-bottom: 24px; + } +} + +@media screen and (max-width: @screen-lg) { + .tableListForm :global(.ant-form-item) { + margin-right: 24px; + } +} + +@media screen and (max-width: @screen-md) { + .tableListForm :global(.ant-form-item) { + margin-right: 8px; + } +} diff --git a/src/main/frontend/src/services/api.js b/src/main/frontend/src/services/api.js new file mode 100644 index 0000000..aef7f34 --- /dev/null +++ b/src/main/frontend/src/services/api.js @@ -0,0 +1,30 @@ +import { stringify } from 'qs'; +import request from '../utils/request'; + +export async function queryRule(params) { + return request(`/api/rule?${stringify(params)}`); +} + +export async function removeRule(params) { + return request('/api/rule', { + method: 'POST', + body: { + ...params, + method: 'delete', + }, + }); +} + +export async function addRule(params) { + return request('/api/rule', { + method: 'POST', + body: { + ...params, + method: 'post', + }, + }); +} + +export async function queryNotices() { + return request('/api/notices'); +} diff --git a/src/main/frontend/src/services/user.js b/src/main/frontend/src/services/user.js new file mode 100644 index 0000000..c4defb4 --- /dev/null +++ b/src/main/frontend/src/services/user.js @@ -0,0 +1,9 @@ +import request from '../utils/request'; + +export async function query() { + return request('/api/users'); +} + +export async function queryCurrent() { + return request('/api/currentUser'); +} diff --git a/src/main/frontend/src/theme.js b/src/main/frontend/src/theme.js new file mode 100644 index 0000000..9e12511 --- /dev/null +++ b/src/main/frontend/src/theme.js @@ -0,0 +1,5 @@ +// https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less +module.exports = { + // 'primary-color': '#10e99b', + 'card-actions-background': '#f5f8fa', +}; diff --git a/src/main/frontend/src/utils/request.js b/src/main/frontend/src/utils/request.js new file mode 100644 index 0000000..094f7fc --- /dev/null +++ b/src/main/frontend/src/utils/request.js @@ -0,0 +1,56 @@ +import fetch from 'dva/fetch'; +import { notification } from 'antd'; + +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + notification.error({ + message: `请求错误 ${response.status}: ${response.url}`, + description: response.statusText, + }); + const error = new Error(response.statusText); + error.response = response; + throw error; +} + +/** + * Requests a URL, returning a promise. + * + * @param {string} url The URL we want to request + * @param {object} [options] The options we want to pass to "fetch" + * @return {object} An object containing either "data" or "err" + */ +export default function request(url, options) { + const defaultOptions = { + credentials: 'include', + }; + const newOptions = { ...defaultOptions, ...options }; + if (newOptions.method === 'POST' || newOptions.method === 'PUT') { + newOptions.headers = { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + ...newOptions.headers, + }; + newOptions.body = JSON.stringify(newOptions.body); + } + + return fetch(url, newOptions) + .then(checkStatus) + .then(response => response.json()) + .catch((error) => { + if (error.code) { + notification.error({ + message: error.name, + description: error.message, + }); + } + if ('stack' in error && 'message' in error) { + notification.error({ + message: `请求错误: ${url}`, + description: error.message, + }); + } + return error; + }); +} diff --git a/src/main/frontend/src/utils/utils.js b/src/main/frontend/src/utils/utils.js new file mode 100644 index 0000000..e0dfd71 --- /dev/null +++ b/src/main/frontend/src/utils/utils.js @@ -0,0 +1,94 @@ +import moment from 'moment'; + +export function fixedZero(val) { + return val * 1 < 10 ? `0${val}` : val; +} + +export function getTimeDistance(type) { + const now = new Date(); + const oneDay = 1000 * 60 * 60 * 24; + + if (type === 'today') { + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + return [moment(now), moment(now.getTime() + (oneDay - 1000))]; + } + + if (type === 'week') { + let day = now.getDay(); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + + if (day === 0) { + day = 6; + } else { + day -= 1; + } + + const beginTime = now.getTime() - (day * oneDay); + + return [moment(beginTime), moment(beginTime + ((7 * oneDay) - 1000))]; + } + + if (type === 'month') { + const year = now.getFullYear(); + const month = now.getMonth(); + const nextDate = moment(now).add(1, 'months'); + const nextYear = nextDate.year(); + const nextMonth = nextDate.month(); + + return [moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000)]; + } + + if (type === 'year') { + const year = now.getFullYear(); + + return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)]; + } +} + +export function getPlainNode(nodeList, parentPath = '') { + const arr = []; + nodeList.forEach((node) => { + const item = node; + item.path = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/'); + item.exact = true; + if (item.children && !item.component) { + arr.push(...getPlainNode(item.children, item.path)); + } else { + if (item.children && item.component) { + item.exact = false; + } + arr.push(item); + } + }); + return arr; +} + +export function digitUppercase(n) { + const fraction = ['角', '分']; + const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; + const unit = [ + ['元', '万', '亿'], + ['', '拾', '佰', '仟'], + ]; + let num = Math.abs(n); + let s = ''; + fraction.forEach((item, index) => { + s += (digit[Math.floor(num * 10 * (10 ** index)) % 10] + item).replace(/零./, ''); + }); + s = s || '整'; + num = Math.floor(num); + for (let i = 0; i < unit[0].length && num > 0; i += 1) { + let p = ''; + for (let j = 0; j < unit[1].length && num > 0; j += 1) { + p = digit[num % 10] + unit[1][j] + p; + num = Math.floor(num / 10); + } + s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s; + } + + return s.replace(/(零.)*零元/, '元').replace(/(零.)+/g, '零').replace(/^整$/, '零元整'); +} diff --git a/src/main/frontend/src/utils/utils.less b/src/main/frontend/src/utils/utils.less new file mode 100644 index 0000000..1ec1efb --- /dev/null +++ b/src/main/frontend/src/utils/utils.less @@ -0,0 +1,50 @@ +.textOverflow() { + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; +} + +.textOverflowMulti(@line: 3, @bg: #fff) { + overflow: hidden; + position: relative; + line-height: 1.5em; + max-height: @line * 1.5em; + text-align: justify; + margin-right: -1em; + padding-right: 1em; + &:before { + background: @bg; + content: '...'; + padding: 0 1px; + position: absolute; + right: 14px; + bottom: 0; + } + &:after { + background: white; + content: ''; + margin-top: 0.2em; + position: absolute; + right: 14px; + width: 1em; + height: 1em; + } +} + +// mixins for clearfix +// ------------------------ +.clearfix() { + zoom: 1; + &:before, + &:after { + content: " "; + display: table; + } + &:after { + clear: both; + visibility: hidden; + font-size: 0; + height: 0; + } +} diff --git a/src/main/frontend/tests/jasmine.js b/src/main/frontend/tests/jasmine.js new file mode 100644 index 0000000..5ff26bf --- /dev/null +++ b/src/main/frontend/tests/jasmine.js @@ -0,0 +1 @@ +jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000; diff --git a/src/main/frontend/tests/run-tests.js b/src/main/frontend/tests/run-tests.js new file mode 100644 index 0000000..46ef9bb --- /dev/null +++ b/src/main/frontend/tests/run-tests.js @@ -0,0 +1,35 @@ +const { spawn } = require('child_process'); +const { kill } = require('cross-port-killer'); + +const env = Object.create(process.env); +env.BROWSER = 'none'; +const startServer = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['start'], { + env, +}); + +startServer.stderr.on('data', (data) => { + // eslint-disable-next-line + console.log(data); +}); + +startServer.on('exit', () => { + kill(process.env.PORT || 8000); +}); + +// eslint-disable-next-line +console.log('Starting development server for e2e tests...'); +startServer.stdout.on('data', (data) => { + // eslint-disable-next-line + console.log(data.toString()); + if (data.toString().indexOf('The app is running at') >= 0 || + data.toString().indexOf('Compiled with warnings') >= 0) { + // eslint-disable-next-line + console.log('Development server is started, ready to run tests.'); + const testCmd = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['test'], { + stdio: 'inherit', + }); + testCmd.on('exit', () => { + startServer.kill(); + }); + } +}); diff --git a/src/main/frontend/tests/setupTests.js b/src/main/frontend/tests/setupTests.js new file mode 100644 index 0000000..bb003dd --- /dev/null +++ b/src/main/frontend/tests/setupTests.js @@ -0,0 +1,13 @@ +/* eslint-disable import/first */ +import '../src/polyfill'; +import { jsdom } from 'jsdom'; +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); + +// fixed jsdom miss +const documentHTML = '<!doctype html><html><body><div id="root"></div></body></html>'; +global.document = jsdom(documentHTML); +global.window = document.defaultView; +global.navigator = global.window.navigator; diff --git a/src/main/frontend/tests/styleMock.js b/src/main/frontend/tests/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/src/main/frontend/tests/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; -- To stop receiving notification emails like this one, please contact "[email protected]" <[email protected]>.
