├── .prettierignore ├── .stylelintignore ├── .eslintignore ├── public ├── config.js ├── client │ ├── favicon.ico │ ├── imgs │ │ ├── cli.jpg │ │ ├── favs.jpg │ │ ├── leave.jpg │ │ ├── type.jpg │ │ ├── window.jpg │ │ └── logo-icon-rotate.svg │ └── index.html ├── nginx.conf ├── package.json └── start.js ├── babel.config.js ├── postcss.config.js ├── .stylelintrc.json ├── env ├── local │ └── readme.txt └── demo │ └── elux.config.js ├── src ├── modules │ ├── dashboard │ │ ├── entity.ts │ │ ├── index.ts │ │ ├── package.json │ │ ├── views │ │ │ ├── Workplace │ │ │ │ └── index.module.less │ │ │ └── Main.tsx │ │ └── model.ts │ ├── stage │ │ ├── assets │ │ │ ├── css │ │ │ │ ├── var.less │ │ │ │ ├── antd-var.json │ │ │ │ └── global.module.less │ │ │ └── imgs │ │ │ │ ├── loading48x48.gif │ │ │ │ └── logo-icon.svg │ │ ├── components │ │ │ ├── MSelect │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── DateTime.tsx │ │ │ ├── MTable │ │ │ │ └── index.module.less │ │ │ ├── ErrorPage │ │ │ │ └── index.tsx │ │ │ ├── LoadingPanel │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── MSearch │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── DialogPage │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── views │ │ │ ├── ForgotPassword │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── Agreement │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── RegistryForm │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── LoginForm │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── Main.tsx │ │ ├── package.json │ │ ├── utils │ │ │ ├── const.ts │ │ │ ├── errors.ts │ │ │ ├── request.ts │ │ │ ├── base.ts │ │ │ ├── tools.ts │ │ │ └── resource.ts │ │ ├── entity.ts │ │ ├── api.ts │ │ └── model.ts │ ├── admin │ │ ├── views │ │ │ ├── Menu │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── Main │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── Flag │ │ │ │ ├── index.tsx │ │ │ │ └── index.module.less │ │ │ ├── Tabs │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── Editor.tsx │ │ │ └── Header │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── package.json │ │ ├── entity.ts │ │ ├── api.ts │ │ └── model.ts │ ├── article │ │ ├── index.ts │ │ ├── model.ts │ │ ├── package.json │ │ ├── views │ │ │ ├── Edit.tsx │ │ │ ├── Index.tsx │ │ │ ├── Main.tsx │ │ │ ├── SearchForm.tsx │ │ │ ├── Detail.tsx │ │ │ ├── EditorForm.tsx │ │ │ ├── Maintain.tsx │ │ │ └── ListTable.tsx │ │ ├── api.ts │ │ └── entity.ts │ └── member │ │ ├── index.ts │ │ ├── model.ts │ │ ├── package.json │ │ ├── views │ │ ├── Edit.tsx │ │ ├── Main.tsx │ │ ├── SearchForm.tsx │ │ ├── Selector.tsx │ │ ├── Detail.tsx │ │ ├── Maintain.tsx │ │ ├── EditorForm.tsx │ │ └── ListTable.tsx │ │ ├── api.ts │ │ └── entity.ts ├── .eslintrc.js ├── env.d.ts ├── tsconfig.json ├── index.ts ├── Global.ts └── Project.ts ├── mock ├── public │ ├── imgs │ │ ├── admin.jpg │ │ └── guest.png │ └── index.html ├── package.json ├── tsconfig.json ├── start.js └── src │ ├── index.ts │ ├── database.ts │ └── routes │ ├── session.ts │ ├── article.ts │ └── member.ts ├── commitlint.config.js ├── .prettierrc.js ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── .vscode ├── extensions.json └── settings.json ├── .eslintrc.js ├── .docs.js ├── LICENSE ├── elux.config.js └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.tsx 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | template/local/src/config.js -------------------------------------------------------------------------------- /public/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: 4003, 3 | proxy: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@elux', {ui: 'vue'}]], 3 | }; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('autoprefixer')], 3 | }; 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@elux/stylelint-config/less"], 3 | "rules": {} 4 | } 5 | -------------------------------------------------------------------------------- /env/local/readme.txt: -------------------------------------------------------------------------------- 1 | - 默认本地环境设置,可不上传Git,本目录中的内容可以覆盖项目中的: 2 | /public 3 | /elux.config.js 4 | -------------------------------------------------------------------------------- /src/modules/dashboard/entity.ts: -------------------------------------------------------------------------------- 1 | export enum CurView { 2 | 'workplace' = 'workplace', 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/stage/assets/css/var.less: -------------------------------------------------------------------------------- 1 | @import '~ant-design-vue/lib/style/themes/default.less'; 2 | -------------------------------------------------------------------------------- /public/client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/favicon.ico -------------------------------------------------------------------------------- /mock/public/imgs/admin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/mock/public/imgs/admin.jpg -------------------------------------------------------------------------------- /mock/public/imgs/guest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/mock/public/imgs/guest.png -------------------------------------------------------------------------------- /public/client/imgs/cli.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/imgs/cli.jpg -------------------------------------------------------------------------------- /public/client/imgs/favs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/imgs/favs.jpg -------------------------------------------------------------------------------- /public/client/imgs/leave.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/imgs/leave.jpg -------------------------------------------------------------------------------- /public/client/imgs/type.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/imgs/type.jpg -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /public/client/imgs/window.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/public/client/imgs/window.jpg -------------------------------------------------------------------------------- /src/modules/admin/views/Menu/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | padding-top: 15px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | bracketSpacing: false, 5 | }; 6 | -------------------------------------------------------------------------------- /src/modules/stage/assets/css/antd-var.json: -------------------------------------------------------------------------------- 1 | { 2 | "@layout-header-background": "#1e2d3d", 3 | "@menu-dark-inline-submenu-bg": "#192639" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .stylelintcache 3 | .eslintcache 4 | *.log 5 | dist/ 6 | node_modules/ 7 | deploy_versions/ 8 | .temp/ 9 | .rn_temp/ 10 | -------------------------------------------------------------------------------- /src/modules/stage/assets/imgs/loading48x48.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiisea/elux-vue-antd-admin/HEAD/src/modules/stage/assets/imgs/loading48x48.gif -------------------------------------------------------------------------------- /src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@elux/vue'], 4 | env: { 5 | browser: true, 6 | node: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/stage/components/MSelect/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 100%; 4 | 5 | .icon-button { 6 | user-select: none; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/article/index.ts: -------------------------------------------------------------------------------- 1 | import {exportModule} from '@elux/vue-web'; 2 | import {Model} from './model'; 3 | import main from './views/Main'; 4 | 5 | export default exportModule('article', Model, {main}); 6 | -------------------------------------------------------------------------------- /src/modules/member/index.ts: -------------------------------------------------------------------------------- 1 | import {exportModule} from '@elux/vue-web'; 2 | import {Model} from './model'; 3 | import main from './views/Main'; 4 | 5 | export default exportModule('member', Model, {main}); 6 | -------------------------------------------------------------------------------- /src/modules/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | import {exportModule} from '@elux/vue-web'; 2 | import {Model} from './model'; 3 | import main from './views/Main'; 4 | 5 | export default exportModule('dashboard', Model, {main}); 6 | -------------------------------------------------------------------------------- /src/modules/admin/index.ts: -------------------------------------------------------------------------------- 1 | //封装并导出本模块 2 | import {exportModule} from '@elux/vue-web'; 3 | import {Model} from './model'; 4 | import main from './views/Main'; 5 | 6 | export default exportModule('admin', Model, {main}); 7 | -------------------------------------------------------------------------------- /src/modules/stage/index.ts: -------------------------------------------------------------------------------- 1 | //封装并导出本模块 2 | import {exportModule} from '@elux/vue-web'; 3 | import {Model} from './model'; 4 | import main from './views/Main'; 5 | 6 | export default exportModule('stage', Model, {main}); 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 150 11 | -------------------------------------------------------------------------------- /src/modules/stage/views/ForgotPassword/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 380px; 4 | 5 | .btn-send-captcha { 6 | float: right; 7 | width: 110px; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@elux/babel-preset/tsconfig.lint.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "rootDir": "./" 6 | }, 7 | "include": ["./"], 8 | "exclude": ["./dist"] 9 | } 10 | -------------------------------------------------------------------------------- /public/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name admin-react-antd.eluxjs.com; 4 | root /var/www/admin-react-antd/html; 5 | index index.html; 6 | location / { 7 | try_files $uri /client/index.html; 8 | } 9 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", "stylelint.vscode-stylelint", "esbenp.prettier-vscode"], 3 | "unwantedRecommendations": ["editorconfig.editorconfig", "octref.vetur"] 4 | } 5 | -------------------------------------------------------------------------------- /mock/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Express 5 | 6 | 7 | 8 | 9 |

Express

10 |

Welcome to Express

11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const tsconfig = require('./tsconfig.json'); 2 | 3 | module.exports = { 4 | root: true, 5 | extends: ['plugin:@elux/common'], 6 | env: { 7 | browser: false, 8 | node: true, 9 | }, 10 | rules: {}, 11 | ignorePatterns: tsconfig.exclude, 12 | }; 13 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg'; 2 | declare module '*.png'; 3 | declare module '*.jpg'; 4 | declare module '*.gif'; 5 | declare module '*.less'; 6 | declare module '*.scss'; 7 | declare module '*.vue' { 8 | const Component: (props: any) => JSX.Element; 9 | export default Component; 10 | } 11 | -------------------------------------------------------------------------------- /env/demo/elux.config.js: -------------------------------------------------------------------------------- 1 | //该配置文件可以覆盖项目根目录下的elux.config.js 2 | //使用yarn build:demo时会使用本配置文件进行编译 3 | module.exports = { 4 | prod: { 5 | clientGlobalVar: { 6 | ApiPrefix: 'http://api-admin-demo.eluxjs.com/', 7 | StaticPrefix: 'http://api-admin-demo.eluxjs.com/', 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/modules/stage/views/Agreement/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 800px; 4 | font-size: 16px; 5 | 6 | .footer { 7 | padding: 20px 0 10px; 8 | text-align: center; 9 | 10 | .ant-btn { 11 | width: 200px; 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /mock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/mock", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@types/express": "^4.17.9", 6 | "@types/morgan": "^1.9.3", 7 | "@types/mockjs": "^1.0.4", 8 | "mockjs": "^1.1.0", 9 | "express": "^4.0.0", 10 | "morgan": "^1.9.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/member/model.ts: -------------------------------------------------------------------------------- 1 | import {BaseResource} from '@elux-admin-antd/stage/utils/resource'; 2 | import api from './api'; 3 | import {MemberResource, defaultListSearch} from './entity'; 4 | 5 | export class Model extends BaseResource { 6 | protected api = api; 7 | protected defaultListSearch = defaultListSearch; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/article/model.ts: -------------------------------------------------------------------------------- 1 | import {BaseResource} from '@elux-admin-antd/stage/utils/resource'; 2 | import api from './api'; 3 | import {ArticleResource, defaultListSearch} from './entity'; 4 | 5 | export class Model extends BaseResource { 6 | protected api = api; 7 | protected defaultListSearch = defaultListSearch; 8 | } 9 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@elux/babel-preset/tsconfig.esm.json", 3 | "compilerOptions": { 4 | "lib": ["es2020", "DOM"], 5 | "emitDeclarationOnly": true, 6 | "jsx": "preserve", 7 | "outDir": "../dist", 8 | "rootDir": "./", 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/Global": ["./Global"] 12 | } 13 | }, 14 | "include": ["./"] 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/stage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/stage", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "peerDependencies": { 6 | "@ant-design/icons-vue": "*", 7 | "@elux/vue-web": "*", 8 | "path-to-regexp": "*", 9 | "query-string": "*", 10 | "dayjs": "*", 11 | "ant-design-vue": "*", 12 | "vue": "*", 13 | "axios": "*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 该文件是应用的入口文件 3 | */ 4 | import {createApp} from '@elux/vue-web'; 5 | import {appConfig} from './Project'; 6 | 7 | createApp(appConfig) 8 | //.use() //可以用vue.use 9 | .render() 10 | .then(() => { 11 | const initLoading = document.getElementById('root-loading'); 12 | if (initLoading) { 13 | initLoading.parentNode!.removeChild(initLoading); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/modules/stage/components/DateTime.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | const dateFormat = 'YYYY-MM-DD HH:mm:ss'; 4 | 5 | interface Props { 6 | date: string | number; 7 | } 8 | 9 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 10 | const Component = ({date}: Props) => { 11 | return <>{date ? dayjs(date).format(dateFormat) : ''}; 12 | }; 13 | 14 | export default Component; 15 | -------------------------------------------------------------------------------- /src/modules/admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/admin", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "peerDependencies": { 6 | "@elux-admin-antd/stage": "*", 7 | "@ant-design/icons-vue": "*", 8 | "@elux/vue-web": "*", 9 | "path-to-regexp": "*", 10 | "query-string": "*", 11 | "dayjs": "*", 12 | "ant-design-vue": "*", 13 | "vue": "*", 14 | "axios": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /public/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "start.js", 6 | "scripts": { 7 | "start": "node ./start.js" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "chalk": "^4.0.0", 13 | "express": "^4.0.0", 14 | "express-history-api-fallback": "^2.0.0", 15 | "http-proxy-middleware": "^1.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/article/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/article", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "peerDependencies": { 6 | "@elux-admin-antd/stage": "*", 7 | "@ant-design/icons-vue": "*", 8 | "@elux/vue-web": "*", 9 | "path-to-regexp": "*", 10 | "query-string": "*", 11 | "dayjs": "*", 12 | "ant-design-vue": "*", 13 | "vue": "*", 14 | "axios": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/member/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/member", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "peerDependencies": { 6 | "@elux-admin-antd/stage": "*", 7 | "@ant-design/icons-vue": "*", 8 | "@elux/vue-web": "*", 9 | "path-to-regexp": "*", 10 | "query-string": "*", 11 | "dayjs": "*", 12 | "ant-design-vue": "*", 13 | "vue": "*", 14 | "axios": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/stage/utils/const.ts: -------------------------------------------------------------------------------- 1 | export const DialogPageClassname: string = '_dialog'; 2 | export const LoginUrl = (from?: string): string => `/stage/login?__c=${DialogPageClassname}&from=${encodeURIComponent(from || '')}`; 3 | export const GuestHomeUrl: string = `/stage/login?__c=${DialogPageClassname}`; 4 | export const AdminHomeUrl: string = '/admin/dashboard/workplace'; 5 | export const FavoritesUrlStorageKey: string = 'EluxFavoritesUrl'; 6 | -------------------------------------------------------------------------------- /src/modules/dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elux-admin-antd/dashboard", 3 | "version": "1.0.0", 4 | "main": "index.ts", 5 | "peerDependencies": { 6 | "@elux-admin-antd/stage": "*", 7 | "@ant-design/icons-vue": "*", 8 | "@elux/vue-web": "*", 9 | "path-to-regexp": "*", 10 | "query-string": "*", 11 | "dayjs": "*", 12 | "ant-design-vue": "*", 13 | "vue": "*", 14 | "axios": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/stage/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import {ActionError} from '@elux/vue-web'; 2 | 3 | export enum CommonErrorCode { 4 | unauthorized = 'unauthorized', 5 | forbidden = 'forbidden', 6 | notFound = 'notFound', 7 | unkown = 'unkown', 8 | } 9 | export class CustomError implements ActionError { 10 | public constructor(public code: string, public message: string, public detail?: Detail, public quiet?: boolean) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/stage/views/RegistryForm/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 380px; 4 | 5 | .agreement-form-item { 6 | display: flex; 7 | align-items: center; 8 | 9 | a { 10 | margin-bottom: 24px; 11 | } 12 | } 13 | 14 | .footer { 15 | padding-top: 10px; 16 | color: #666; 17 | text-align: right; 18 | border-top: 1px dashed #ddd; 19 | 20 | .anticon { 21 | opacity: 0.7; 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/stage/views/LoginForm/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 380px; 4 | 5 | .btn-forgot { 6 | float: right; 7 | } 8 | 9 | .footer { 10 | display: flex; 11 | justify-content: space-between; 12 | padding-top: 10px; 13 | border-top: 1px dashed #ddd; 14 | 15 | .other-login { 16 | a { 17 | margin-right: 5px; 18 | } 19 | } 20 | 21 | .anticon { 22 | font-size: 16px; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/stage/components/MTable/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | > .hd { 4 | display: flex; 5 | margin: 20px 0; 6 | 7 | > * { 8 | margin-right: 10px; 9 | } 10 | 11 | .em { 12 | font-weight: bold; 13 | } 14 | 15 | .tip { 16 | margin-left: 5px; 17 | font-size: 12px; 18 | } 19 | 20 | .ant-alert-info { 21 | padding: 5px 15px; 22 | font-size: 13px; 23 | border-radius: 5px; 24 | } 25 | } 26 | } 27 | 28 | :local(.batchConfirm) { 29 | .em { 30 | font-weight: bold; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/admin/views/Main/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | width: 100%; 4 | height: 100%; 5 | overflow: auto; 6 | 7 | > .ant-layout { 8 | width: 100%; 9 | min-width: 1200px; 10 | height: 100%; 11 | 12 | > .ant-layout { 13 | background: #fff; 14 | } 15 | 16 | .ant-layout-sider { 17 | overflow: hidden auto; 18 | } 19 | 20 | .ant-layout-header { 21 | height: 87px; 22 | padding: 0; 23 | overflow: hidden; 24 | background: #fff; 25 | } 26 | 27 | .ant-layout-content { 28 | padding: 2px 5px; 29 | overflow: auto; 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/stage/components/ErrorPage/index.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from '@elux/vue-web'; 2 | import {Button, Result} from 'ant-design-vue'; 3 | 4 | export interface Props { 5 | message?: string; 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 9 | const Component = function (props: Props) { 10 | const {message = '(404) 没有找到相关内容!'} = props; 11 | return ( 12 | 18 | 19 | 20 | } 21 | /> 22 | ); 23 | }; 24 | 25 | export default Component; 26 | -------------------------------------------------------------------------------- /src/modules/stage/components/LoadingPanel/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | z-index: 999; 7 | box-sizing: border-box; 8 | display: none; 9 | width: 100%; 10 | height: 100%; 11 | background-color: rgba(255, 255, 255, 0); 12 | 13 | .loading-icon { 14 | position: absolute; 15 | top: 50%; 16 | left: 50%; 17 | width: 32px; 18 | height: 32px; 19 | transform: translate(-50%, -50%); 20 | } 21 | 22 | &.start { 23 | display: block; 24 | } 25 | 26 | &.depth { 27 | display: block; 28 | background-color: rgba(165, 156, 156, 0.1); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/admin/views/Flag/index.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '@elux-admin-antd/stage/assets/imgs/logo-icon.svg'; 2 | import {AdminHomeUrl} from '@elux-admin-antd/stage/utils/const'; 3 | import {Link} from '@elux/vue-web'; 4 | import styles from './index.module.less'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 7 | const Component = () => { 8 | return ( 9 |
10 | 11 | 12 |
Elux管理系统
13 | V2.0.0 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default Component; 20 | -------------------------------------------------------------------------------- /src/modules/stage/components/LoadingPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import {LoadingState} from '@elux/vue-web'; 2 | import {Spin} from 'ant-design-vue'; 3 | import styles from './index.module.less'; 4 | 5 | interface Props { 6 | class?: string; 7 | loadingState?: LoadingState; 8 | } 9 | 10 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 11 | const Component = function (props: Props) { 12 | const {loadingState, class: className = ''} = props; 13 | return ( 14 |
15 |
16 | 17 |
18 |
19 | ); 20 | }; 21 | 22 | Component.displayName = styles.root; 23 | 24 | export default Component; 25 | -------------------------------------------------------------------------------- /src/Global.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 该文件可以看作应用的先导文件,主要用来导出一些全局常用的方法、变量、和类型 3 | * 注意为了避免循环依赖,请保持该文件的独立性,不要引用任何其它项目文件 4 | */ 5 | /// 6 | import {API, Facade, getApi} from '@elux/vue-web'; 7 | import {IModuleGetter} from './Project'; 8 | 9 | type APP = API>; 10 | 11 | export type APPState = APP['State']; 12 | export type PatchActions = APP['Actions']; // 使用demote命令兼容IE时使用 13 | 14 | //几个全局常用的方法,参见 https://eluxjs.com/api/react-web.getapi.html 15 | export const {Modules, LoadComponent, GetActions, GetClientRouter, useStore, useRouter} = getApi(); 16 | 17 | //脚手架编译时,会把elux.config.js中的`clientGlobalVar`值放入process.env.PROJ_ENV中,在此可以获取 18 | //相当于在编译时就固化某些全局变量,你可以用来传递不同环境要用到的不同变量,比如url请求的前缀 19 | export const {StaticPrefix, ApiPrefix} = process.env.PROJ_ENV as { 20 | StaticPrefix: string; 21 | ApiPrefix: string; 22 | }; 23 | -------------------------------------------------------------------------------- /src/modules/dashboard/views/Workplace/index.module.less: -------------------------------------------------------------------------------- 1 | @import '@elux-admin-antd/stage/assets/css/var.less'; 2 | 3 | :global { 4 | :local(.root) { 5 | max-width: 950px; 6 | padding: 10px 30px 30px; 7 | font-size: 15px; 8 | 9 | h2 { 10 | padding-bottom: 10px; 11 | margin: 30px 0 20px; 12 | border-bottom: 1px solid @border-color-split; 13 | } 14 | 15 | li { 16 | margin-bottom: 10px; 17 | } 18 | 19 | blockquote { 20 | padding: 0 20px; 21 | color: #666; 22 | background-color: #eee; 23 | border-left: 2px solid #ccc; 24 | } 25 | 26 | pre { 27 | background-color: #eee; 28 | 29 | > code { 30 | display: block; 31 | padding: 10px 20px; 32 | } 33 | } 34 | 35 | code { 36 | padding: 0 5px; 37 | color: #666; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "files.encoding": "utf8", 4 | "files.trimFinalNewlines": true, 5 | "files.trimTrailingWhitespace": true, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "typescript.updateImportsOnFileMove.enabled": "never", 8 | "editor.formatOnSave": false, 9 | "editor.codeActionsOnSave": ["source.fixAll"], 10 | "stylelint.validate": ["less", "scss", "vue"], 11 | "[markdown]": { 12 | "files.trimTrailingWhitespace": false 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "search.exclude": { 18 | "**/.eslintcache": true, 19 | "**/.stylelintcache": true, 20 | "**/package-lock": true, 21 | "**/yarn.lock": true, 22 | "**/node_modules": true, 23 | "**/dist": true, 24 | "**/types": true, 25 | "**/storage": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/admin/views/Tabs/index.module.less: -------------------------------------------------------------------------------- 1 | @import '@elux-admin-antd/stage/assets/css/var.less'; 2 | 3 | :global { 4 | :local(.root) { 5 | .ant-tabs-tab-btn { 6 | font-size: 13px; 7 | } 8 | 9 | .ant-tabs-nav-add { 10 | padding: 6px 10px !important; 11 | font-size: 13px; 12 | } 13 | 14 | .ant-tabs-nav-more { 15 | padding: 6px 16px !important; 16 | } 17 | 18 | .btn-add { 19 | white-space: nowrap; 20 | } 21 | 22 | .btn-refresh { 23 | display: block; 24 | padding: 0 20px; 25 | margin-left: 2px; 26 | line-height: 33px; 27 | color: #666; 28 | cursor: pointer; 29 | background-color: @background-color-light; 30 | border: 1px solid @border-color-split; 31 | border-bottom: none; 32 | border-radius: @border-radius-base 0 0; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/dashboard/views/Main.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from '@elux-admin-antd/stage/components/ErrorPage'; 2 | import {Switch, connectStore, exportView} from '@elux/vue-web'; 3 | import {defineComponent} from 'vue'; 4 | import {APPState} from '@/Global'; 5 | import {CurView} from '../entity'; 6 | import Workplace from './Workplace'; 7 | 8 | export interface StoreProps { 9 | curView?: CurView; 10 | } 11 | 12 | function mapStateToProps(appState: APPState): StoreProps { 13 | return {curView: appState.dashboard!.curView}; 14 | } 15 | 16 | const Component = defineComponent({ 17 | setup() { 18 | const storeProps = connectStore(mapStateToProps); 19 | return () => { 20 | const {curView} = storeProps; 21 | return }>{curView === 'workplace' && }; 22 | }; 23 | }, 24 | }); 25 | 26 | export default exportView(Component); 27 | -------------------------------------------------------------------------------- /public/start.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const path = require('path'); 3 | const chalk = require('chalk'); 4 | const express = require('express'); 5 | const fallback = require('express-history-api-fallback'); 6 | const {createProxyMiddleware} = require('http-proxy-middleware'); 7 | const config = require('./config'); 8 | 9 | const {proxy, port} = config || {}; 10 | const serverUrl = `http://localhost:${port}`; 11 | const staticPath = path.join(__dirname, './client'); 12 | 13 | const app = express(); 14 | Object.keys(proxy).forEach((key) => { 15 | app.use(key, createProxyMiddleware(proxy[key])); 16 | }); 17 | app.use('/client', express.static(staticPath)); 18 | 19 | app.use(fallback('index.html', {root: staticPath})); 20 | 21 | app.listen(port, () => 22 | console.info(`\n🚀...Starting ${chalk.yellowBright.bgRedBright(' ProdServer ')} on ${chalk.underline.redBright(serverUrl)} \n`) 23 | ); 24 | -------------------------------------------------------------------------------- /.docs.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const {marked} = require('marked'); 4 | 5 | const md = fs.readFileSync(path.join(__dirname, 'readme.md'), 'utf-8').toString(); 6 | const html = marked.parse(md); 7 | fs.writeFileSync( 8 | path.join(__dirname, 'src/modules/dashboard/views/Workplace/summary.html'), 9 | html.replace(/(.+?)<\/a>/g, (str, url, text) => { 10 | return `${text}`; 11 | }) 12 | .replace(/
([\w\W]*?)<\/pre>/g, (str, code) => {
13 |       return `
`;
14 |     })
15 |     .replace(/ align="center"/, '')
16 |     .replace(/]*?)>/g, '')
17 |     .replace(/public\/client\/imgs\//g, '/client/imgs/')
18 | );
19 | console.log('src/modules/dashboard/views/Workplace/summary.html');
20 | 


--------------------------------------------------------------------------------
/src/modules/article/views/Edit.tsx:
--------------------------------------------------------------------------------
 1 | import DialogPage from '@elux-admin-antd/stage/components/DialogPage';
 2 | import {loadingPlaceholder} from '@elux-admin-antd/stage/utils/tools';
 3 | import {Dispatch} from '@elux/vue-web';
 4 | import {Skeleton} from 'ant-design-vue';
 5 | import {ItemDetail} from '../entity';
 6 | import EditorForm from './EditorForm';
 7 | 
 8 | interface Props {
 9 |   dispatch: Dispatch;
10 |   itemDetail?: ItemDetail;
11 | }
12 | 
13 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
14 | const Component = ({itemDetail, dispatch}: Props) => {
15 |   const title = loadingPlaceholder(itemDetail && (itemDetail.id ? '修改文章' : '新增文章'));
16 |   return (
17 |     
18 |       
{itemDetail ? : }
19 |
20 | ); 21 | }; 22 | 23 | export default Component; 24 | -------------------------------------------------------------------------------- /src/modules/admin/entity.ts: -------------------------------------------------------------------------------- 1 | import {IRequest} from '@elux-admin-antd/stage/utils/base'; 2 | 3 | export enum SubModule { 4 | 'dashboard' = 'dashboard', 5 | 'member' = 'member', 6 | 'article' = 'article', 7 | } 8 | 9 | export interface Notices { 10 | num: number; 11 | } 12 | 13 | export interface Tab { 14 | id: string; 15 | title: string; 16 | url: string; 17 | } 18 | 19 | export interface TabData { 20 | list: Tab[]; 21 | maps: {[id: string]: Tab}; 22 | } 23 | 24 | export interface MenuItem { 25 | key: string; 26 | label: string; 27 | icon?: string; 28 | match?: string | string[]; 29 | link?: string; 30 | children?: MenuItem[]; 31 | disable?: boolean; 32 | } 33 | 34 | export interface MenuData { 35 | items: MenuItem[]; 36 | keyToParents: {[key: string]: string[]}; 37 | keyToLink: {[key: string]: string}; 38 | matchToKey: {[match: string]: string}; 39 | } 40 | export type IGetNotices = IRequest<{}, Notices>; 41 | 42 | export type IGetMenu = IRequest<{}, MenuItem[]>; 43 | -------------------------------------------------------------------------------- /src/modules/admin/views/Flag/index.module.less: -------------------------------------------------------------------------------- 1 | @import '@elux-admin-antd/stage/assets/css/var.less'; 2 | 3 | :global { 4 | :local(.root) { 5 | height: 85px; 6 | overflow: hidden; 7 | background-color: @menu-dark-inline-submenu-bg; 8 | 9 | > .wrap { 10 | display: block; 11 | width: 180px; 12 | padding: 20px 0 0 10px; 13 | } 14 | 15 | .title { 16 | font-size: 16px; 17 | font-weight: bold; 18 | color: #fff; 19 | } 20 | 21 | .ver { 22 | display: inline-block; 23 | padding: 0 5px; 24 | font-size: 12px; 25 | line-height: 14px; 26 | color: black; 27 | background: #fff; 28 | border-radius: 10px; 29 | opacity: 0.5; 30 | } 31 | 32 | .logo { 33 | float: left; 34 | padding-top: 2px; 35 | margin-right: 8px; 36 | margin-left: 8px; 37 | } 38 | } 39 | 40 | .ant-layout-sider-collapsed :local(.root) { 41 | .title, 42 | .ver { 43 | display: none; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/admin/views/Header/index.module.less: -------------------------------------------------------------------------------- 1 | :global { 2 | :local(.root) { 3 | display: flex; 4 | justify-content: space-between; 5 | height: 50px; 6 | 7 | > * { 8 | display: flex; 9 | align-items: center; 10 | 11 | > * { 12 | padding: 0 10px; 13 | } 14 | } 15 | 16 | > .side { 17 | .toggle-sider { 18 | padding-left: 20px; 19 | font-size: 20px; 20 | } 21 | } 22 | 23 | > .main { 24 | > * { 25 | line-height: 50px; 26 | 27 | &:hover { 28 | background: #eee; 29 | } 30 | } 31 | 32 | .notice { 33 | display: block; 34 | padding-right: 20px; 35 | padding-left: 20px; 36 | cursor: pointer; 37 | 38 | .anticon { 39 | font-size: 18px; 40 | } 41 | } 42 | 43 | .account { 44 | padding: 0 20px; 45 | 46 | .ant-avatar { 47 | margin-right: 8px; 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mock/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "types": ["node"], 5 | "lib": ["es2020"], 6 | "target": "ESNext", 7 | "moduleResolution": "node", 8 | "skipLibCheck": true, 9 | "declaration": true, 10 | "allowJs": true, 11 | "checkJs": false, 12 | "strict": true, 13 | "isolatedModules": true, 14 | "preserveConstEnums": true, 15 | "sourceMap": false, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "newLine": "LF", 19 | "removeComments": true, 20 | "resolveJsonModule": true, 21 | "noImplicitReturns": true, 22 | "importHelpers": false, 23 | "forceConsistentCasingInFileNames": true, 24 | "suppressImplicitAnyIndexErrors": true, 25 | "allowSyntheticDefaultImports": true, 26 | "esModuleInterop": true, 27 | "outDir": "../dist/mock", 28 | "rootDir": "./", 29 | "baseUrl": "./", 30 | "paths": { 31 | "@/*": ["./src/*"] 32 | } 33 | }, 34 | "include": ["./"] 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/member/views/Edit.tsx: -------------------------------------------------------------------------------- 1 | import DialogPage from '@elux-admin-antd/stage/components/DialogPage'; 2 | import {loadingPlaceholder} from '@elux-admin-antd/stage/utils/tools'; 3 | import {Dispatch} from '@elux/vue-web'; 4 | import {Skeleton} from 'ant-design-vue'; 5 | import {ItemDetail} from '../entity'; 6 | import EditorForm from './EditorForm'; 7 | 8 | interface Props { 9 | dispatch: Dispatch; 10 | itemDetail?: ItemDetail; 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 14 | const Component = ({itemDetail, dispatch}: Props) => { 15 | const title = loadingPlaceholder(itemDetail && (itemDetail.id ? '修改用户' : '新增用户')); 16 | return ( 17 | 18 |
19 | {itemDetail ? : } 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default Component; 26 | -------------------------------------------------------------------------------- /src/modules/dashboard/model.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from '@elux/vue-web'; 2 | import {pathToRegexp} from 'path-to-regexp'; 3 | import {CurView} from './entity'; 4 | 5 | export interface ModuleState { 6 | curView?: CurView; 7 | } 8 | 9 | interface RouteParams { 10 | curView?: CurView; 11 | } 12 | 13 | export class Model extends BaseModel { 14 | protected routeParams!: RouteParams; 15 | protected privateActions = this.getPrivateActions({}); 16 | 17 | protected getRouteParams(): RouteParams { 18 | const {pathname} = this.getRouter().location; 19 | const [, , , curViewStr = ''] = pathToRegexp('/:admin/:dashboard/:curView').exec(pathname) || []; 20 | const curView: CurView | undefined = CurView[curViewStr] || undefined; 21 | return {curView}; 22 | } 23 | 24 | public onMount(): void { 25 | this.routeParams = this.getRouteParams(); 26 | const {curView} = this.routeParams; 27 | const initState: ModuleState = {curView}; 28 | this.dispatch(this.privateActions._initState(initState)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hiisea 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/modules/article/views/Index.tsx: -------------------------------------------------------------------------------- 1 | import DialogPage from '@elux-admin-antd/stage/components/DialogPage'; 2 | import {connectStore} from '@elux/vue-web'; 3 | import {defineComponent} from 'vue'; 4 | import {APPState} from '@/Global'; 5 | import ListTable from './ListTable'; 6 | 7 | interface StoreProps { 8 | prefixPathname: string; 9 | curRender?: string; 10 | } 11 | 12 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 13 | const {prefixPathname, curRender} = state.article!; 14 | return {prefixPathname, curRender}; 15 | }; 16 | 17 | const selection = {limit: -1}; 18 | 19 | const Component = defineComponent({ 20 | setup() { 21 | const storeProps = connectStore(mapStateToProps); 22 | 23 | return () => { 24 | const {prefixPathname, curRender} = storeProps; 25 | return ( 26 | 27 |
28 | 29 |
30 |
31 | ); 32 | }; 33 | }, 34 | }); 35 | 36 | export default Component; 37 | -------------------------------------------------------------------------------- /mock/start.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-fallthrough, no-console */ 2 | 'use strict'; 3 | var __importDefault = 4 | (this && this.__importDefault) || 5 | function (mod) { 6 | return mod && mod.__esModule ? mod : {default: mod}; 7 | }; 8 | Object.defineProperty(exports, '__esModule', {value: true}); 9 | const http_1 = __importDefault(require('http')); 10 | 11 | const port = 3003; 12 | const app = require('./src'); 13 | const server = http_1.default.createServer(app); 14 | app.set('port', port); 15 | server.listen(port); 16 | server.on('error', (error) => { 17 | if (error.syscall !== 'listen') { 18 | throw error; 19 | } 20 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; 21 | switch (error.code) { 22 | case 'EACCES': 23 | console.error(`${bind} requires elevated privileges`); 24 | process.exit(1); 25 | case 'EADDRINUSE': 26 | console.error(`${bind} is already in use`); 27 | process.exit(1); 28 | default: 29 | throw error; 30 | } 31 | }); 32 | server.on('listening', () => { 33 | console.log(`\n....running at http://localhost:${port}/`); 34 | }); 35 | ['SIGINT', 'SIGTERM'].forEach((signal) => { 36 | process.on(signal, () => { 37 | process.exit(1); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /mock/src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express, {Request, Response} from 'express'; 3 | import logger from 'morgan'; 4 | import articleRouter from './routes/article'; 5 | import memberRouter from './routes/member'; 6 | import sessionRouter from './routes/session'; 7 | 8 | const app = express(); 9 | 10 | app.use( 11 | logger('dev', { 12 | skip(req: Request, res: Response) { 13 | const contentType = res.getHeader('Content-Type')?.toString() || ''; 14 | return !contentType || contentType.indexOf('application/json') < 0; 15 | }, 16 | }) 17 | ); 18 | 19 | const allowCrossDomain = function (req: Request, res: Response, next: (err?: Error) => void) { 20 | res.header('Access-Control-Allow-Origin', '*'); 21 | res.header('Access-Control-Allow-Methods', '*'); 22 | res.header('Access-Control-Allow-Headers', '*'); 23 | if (req.method == 'OPTIONS') { 24 | res.sendStatus(200); 25 | } else { 26 | next(); 27 | } 28 | }; 29 | app.use(allowCrossDomain); 30 | 31 | app.use(express.json()); 32 | app.use(express.urlencoded({extended: false})); 33 | app.use(express.static(path.join(__dirname, '../public'))); 34 | 35 | app.use('/session', sessionRouter); 36 | app.use('/member', memberRouter); 37 | app.use('/article', articleRouter); 38 | 39 | export = app; 40 | -------------------------------------------------------------------------------- /elux.config.js: -------------------------------------------------------------------------------- 1 | //工程配置文件,参见 https://eluxjs.com/guide/configure.html 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | const antdVars = require('@elux-admin-antd/stage/assets/css/antd-var.json'); 4 | const {getLocalIP} = require('@elux/cli-utils'); 5 | const serverPort = 4003; 6 | const apiHosts = { 7 | local: `http://${getLocalIP()}:3003/`, 8 | demo: 'http://api-admin-demo.eluxjs.com/', 9 | }; 10 | const APP_ENV = process.env.APP_ENV || 'local'; 11 | module.exports = { 12 | type: 'vue', 13 | mockServer: {port: 3003}, 14 | cssProcessors: {less: {javascriptEnabled: true, modifyVars: antdVars}}, 15 | all: { 16 | //开发和生成环境都使用的配置 17 | serverPort, 18 | clientGlobalVar: { 19 | ApiPrefix: apiHosts[APP_ENV], 20 | StaticPrefix: apiHosts[APP_ENV], 21 | }, 22 | webpackConfigTransform: (config) => { 23 | //此处可以自定义webpack配置 24 | //console.log(config.module.rules[0]); 25 | return config; 26 | }, 27 | }, 28 | dev: { 29 | //开发环境专用配置 30 | eslint: false, 31 | stylelint: false, 32 | //要使用开发代理可以放开下面代码 33 | // apiProxy: { 34 | // '/api': { 35 | // target: apiHosts[APP_ENV], 36 | // pathRewrite: { 37 | // '^/api': '', 38 | // }, 39 | // }, 40 | // }, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/modules/stage/components/MSearch/index.module.less: -------------------------------------------------------------------------------- 1 | @import '../../assets/css/var.less'; 2 | 3 | :global { 4 | :local(.root) { 5 | padding: 15px 0 10px !important; 6 | border-bottom: 1px solid @border-color-split; 7 | 8 | .ant-form { 9 | display: flex; 10 | flex-flow: row wrap; 11 | 12 | > div { 13 | display: flex; 14 | padding-right: 1%; 15 | padding-left: 1%; 16 | margin: 0; 17 | margin-bottom: 15px; 18 | } 19 | } 20 | 21 | > .col4 { 22 | > div { 23 | width: 25%; 24 | } 25 | } 26 | 27 | > .col3 { 28 | > div { 29 | width: 33.3%; 30 | } 31 | } 32 | 33 | .ant-form-item-label { 34 | flex: none; 35 | 36 | .label { 37 | display: inline-block; 38 | } 39 | } 40 | 41 | .ant-form-item-control { 42 | flex: auto; 43 | } 44 | 45 | .ant-calendar-picker { 46 | width: 100% !important; 47 | } 48 | 49 | .form-btns { 50 | flex: 1; 51 | align-items: center; 52 | justify-content: flex-end; 53 | 54 | > * { 55 | margin-left: 10px; 56 | } 57 | 58 | .expand { 59 | display: block; 60 | padding: 5px; 61 | font-size: 12px; 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/stage/entity.ts: -------------------------------------------------------------------------------- 1 | import {IRequest} from '@elux-admin-antd/stage/utils/base'; 2 | 3 | export interface CurUser { 4 | id: string; 5 | username: string; 6 | avatar: string; 7 | mobile: string; 8 | hasLogin: boolean; 9 | } 10 | 11 | export interface LoginParams { 12 | username: string; 13 | password: string; 14 | keep: boolean; 15 | } 16 | 17 | export interface RegisterParams { 18 | username: string; 19 | password: string; 20 | } 21 | 22 | export interface SendCaptchaParams { 23 | phone: string; 24 | } 25 | 26 | export interface ResetPasswordParams { 27 | phone: string; 28 | password: string; 29 | captcha: string; 30 | } 31 | 32 | export type IGetCurUser = IRequest<{}, CurUser>; 33 | 34 | export type ILogin = IRequest; 35 | 36 | export type ILogout = IRequest<{}, CurUser>; 37 | 38 | export type IRegistry = IRequest; 39 | 40 | export type ISendCaptcha = IRequest; 41 | 42 | export type IResetPassword = IRequest; 43 | 44 | export enum SubModule { 45 | 'shop' = 'shop', 46 | 'admin' = 'admin', 47 | } 48 | 49 | export enum CurView { 50 | 'login' = 'login', 51 | 'registry' = 'registry', 52 | 'agreement' = 'agreement', 53 | 'forgetPassword' = 'forgetPassword', 54 | } 55 | -------------------------------------------------------------------------------- /src/modules/stage/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosError, AxiosResponse} from 'axios'; 2 | import {ApiPrefix} from '@/Global'; 3 | import {CommonErrorCode, CustomError} from './errors'; 4 | 5 | function mapHttpErrorCode(code: string): CommonErrorCode { 6 | const HttpErrorCode = { 7 | '401': CommonErrorCode.unauthorized, 8 | '403': CommonErrorCode.forbidden, 9 | '404': CommonErrorCode.notFound, 10 | }; 11 | return HttpErrorCode[code] || CommonErrorCode.unkown; 12 | } 13 | const instance = axios.create({timeout: 15000}); 14 | 15 | instance.interceptors.request.use((req) => { 16 | const token = localStorage.getItem('token'); 17 | return {...req, headers: {Authorization: token}, url: req.url!.replace('/api/', ApiPrefix)}; 18 | }); 19 | 20 | instance.interceptors.response.use( 21 | (response: AxiosResponse) => { 22 | return response; 23 | }, 24 | (error: AxiosError<{message: string}>) => { 25 | const httpErrorCode = error.response ? error.response.status : 0; 26 | const statusText = error.response ? error.response.statusText : ''; 27 | const responseData: any = error.response ? error.response.data : ''; 28 | const errorMessage = responseData.message || `${statusText}, failed to call ${error.config.url}`; 29 | throw new CustomError(mapHttpErrorCode(httpErrorCode.toString()), errorMessage, responseData); 30 | } 31 | ); 32 | 33 | export default instance; 34 | -------------------------------------------------------------------------------- /src/Project.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import stage from '@elux-admin-antd/stage'; 3 | import {AdminHomeUrl} from '@elux-admin-antd/stage/utils/const'; 4 | import {AppConfig, setConfig} from '@elux/vue-web'; 5 | import {parse, stringify} from 'query-string'; 6 | 7 | //定义模块的获取方式,同步或者异步都可以, 注意key名必需和模块名保持一致 8 | //配置成异步import(...)可以按需加载 9 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 10 | export const ModuleGetter = { 11 | //通常stage为根模块,使用同步加载,如果根模块要用别的名字,需要同时在以下setConfig中设置 12 | stage: () => stage, 13 | admin: () => import('@elux-admin-antd/admin'), 14 | dashboard: () => import('@elux-admin-antd/dashboard'), 15 | member: () => import('@elux-admin-antd/member'), 16 | article: () => import('@elux-admin-antd/article'), 17 | }; 18 | 19 | //Elux全局设置,参见 https://eluxjs.com/api/react-web.setconfig.html 20 | export const appConfig: AppConfig = setConfig({ 21 | ModuleGetter, 22 | //Elux并没定死怎么解析路由参数,你可以使用常用的'query-string'或者'json' 23 | //只需要将parse(解析)和stringify(序列化)方法设置给Elux 24 | QueryString: {parse, stringify}, 25 | //elux内部使用的虚拟路由是独立自主的,但可以映射到原生路由 26 | NativePathnameMapping: { 27 | in(nativePathname) { 28 | if (nativePathname === '/') { 29 | nativePathname = AdminHomeUrl; 30 | } 31 | return nativePathname; 32 | }, 33 | out(internalPathname) { 34 | return internalPathname; 35 | }, 36 | }, 37 | }); 38 | 39 | export type IModuleGetter = typeof ModuleGetter; 40 | -------------------------------------------------------------------------------- /src/modules/article/views/Main.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from '@elux-admin-antd/stage/components/ErrorPage'; 2 | import {Switch, connectStore, exportView} from '@elux/vue-web'; 3 | import {defineComponent} from 'vue'; 4 | import {APPState} from '@/Global'; 5 | import {CurRender, CurView, ItemDetail} from '../entity'; 6 | import Detail from './Detail'; 7 | import Edit from './Edit'; 8 | import Index from './Index'; 9 | import Maintain from './Maintain'; 10 | 11 | export interface StoreProps { 12 | curView?: CurView; 13 | curRender?: CurRender; 14 | itemDetail?: ItemDetail; 15 | } 16 | 17 | function mapStateToProps(appState: APPState): StoreProps { 18 | const {curView, curRender, itemDetail} = appState.article!; 19 | return {curView, curRender, itemDetail}; 20 | } 21 | 22 | const Component = defineComponent({ 23 | setup() { 24 | const storeProps = connectStore(mapStateToProps); 25 | 26 | return () => { 27 | const {curView, curRender, itemDetail, dispatch} = storeProps; 28 | return ( 29 | }> 30 | {curView === 'list' && curRender === 'maintain' && } 31 | {curView === 'list' && curRender === 'index' && } 32 | {curView === 'item' && curRender === 'detail' && } 33 | {curView === 'item' && curRender === 'edit' && } 34 | 35 | ); 36 | }; 37 | }, 38 | }); 39 | 40 | export default exportView(Component); 41 | -------------------------------------------------------------------------------- /src/modules/member/views/Main.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from '@elux-admin-antd/stage/components/ErrorPage'; 2 | import {Switch, connectStore, exportView} from '@elux/vue-web'; 3 | import {defineComponent} from 'vue'; 4 | import {APPState} from '@/Global'; 5 | import {CurRender, CurView, ItemDetail} from '../entity'; 6 | import Detail from './Detail'; 7 | import Edit from './Edit'; 8 | import Maintain from './Maintain'; 9 | import Selector from './Selector'; 10 | 11 | export interface StoreProps { 12 | curView?: CurView; 13 | curRender?: CurRender; 14 | itemDetail?: ItemDetail; 15 | } 16 | 17 | function mapStateToProps(appState: APPState): StoreProps { 18 | const {curView, curRender, itemDetail} = appState.member!; 19 | return {curView, curRender, itemDetail}; 20 | } 21 | 22 | const Component = defineComponent({ 23 | setup() { 24 | const storeProps = connectStore(mapStateToProps); 25 | 26 | return () => { 27 | const {curView, curRender, itemDetail, dispatch} = storeProps; 28 | return ( 29 | }> 30 | {curView === 'list' && curRender === 'maintain' && } 31 | {curView === 'list' && curRender === 'selector' && } 32 | {curView === 'item' && curRender === 'detail' && } 33 | {curView === 'item' && curRender === 'edit' && } 34 | 35 | ); 36 | }; 37 | }, 38 | }); 39 | 40 | export default exportView(Component); 41 | -------------------------------------------------------------------------------- /src/modules/member/views/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import MSearch from '@elux-admin-antd/stage/components/MSearch'; 2 | import {useSearch} from '@elux-admin-antd/stage/utils/resource'; 3 | import {SearchFromItems} from '@elux-admin-antd/stage/utils/tools'; 4 | import {Input, Select} from 'ant-design-vue'; 5 | import {defineComponent} from 'vue'; 6 | import {DRole, DStatus, ListSearch, ListSearchFormData, defaultListSearch} from '../entity'; 7 | 8 | const formItems: SearchFromItems = [ 9 | {name: 'name', label: '用户名', formItem: }, 10 | {name: 'nickname', label: '呢称', formItem: }, 11 | { 12 | name: 'status', 13 | label: '状态', 14 | formItem: , 20 | }, 21 | { 22 | name: 'email', 23 | label: 'Email', 24 | formItem: , 25 | }, 26 | ]; 27 | 28 | interface Props { 29 | listPathname: string; 30 | listSearch: ListSearch; 31 | fixedFields?: Partial; 32 | } 33 | 34 | const Component = defineComponent({ 35 | props: ['listPathname', 'listSearch', 'fixedFields'] as any, 36 | setup(props) { 37 | const {onSearch} = useSearch(props, defaultListSearch); 38 | 39 | return () => { 40 | const {listSearch, fixedFields} = props; 41 | return ( 42 | 43 | values={listSearch} 44 | fixedFields={fixedFields} 45 | expand={!!listSearch.email} 46 | items={formItems} 47 | onSearch={onSearch} 48 | /> 49 | ); 50 | }; 51 | }, 52 | }); 53 | 54 | export default Component; 55 | -------------------------------------------------------------------------------- /src/modules/stage/api.ts: -------------------------------------------------------------------------------- 1 | import request from '@elux-admin-antd/stage/utils/request'; 2 | import {CurUser, IGetCurUser, ILogin, ILogout, IRegistry, IResetPassword, ISendCaptcha} from './entity'; 3 | 4 | export const guest: CurUser = { 5 | id: '', 6 | username: '游客', 7 | hasLogin: false, 8 | avatar: '', 9 | mobile: '', 10 | }; 11 | 12 | class API { 13 | public getCurUser(): Promise { 14 | return request 15 | .get('/api/session') 16 | .then((res) => { 17 | return res.data; 18 | }) 19 | .catch(() => { 20 | return guest; 21 | }); 22 | } 23 | public login(params: ILogin['Request']): Promise { 24 | return request.put('/api/session', params).then((res) => { 25 | localStorage.setItem('token', res.data.id + res.data.username); 26 | return res.data; 27 | }); 28 | } 29 | 30 | public logout(): Promise { 31 | return request.delete('/api/session').then((res) => { 32 | localStorage.removeItem('token'); 33 | return res.data; 34 | }); 35 | } 36 | 37 | public registry(params: IRegistry['Request']): Promise { 38 | return request.post('/api/session', params).then((res) => { 39 | localStorage.setItem('token', res.data.id + res.data.username); 40 | return res.data; 41 | }); 42 | } 43 | 44 | public resetPassword(params: IResetPassword['Request']): Promise { 45 | return request.put('/api/session/resetPassword', params).then((res) => { 46 | return res.data; 47 | }); 48 | } 49 | 50 | public sendCaptcha(params: ISendCaptcha['Request']): Promise { 51 | return request.post('/api/session/sendCaptcha', params).then((res) => { 52 | return res.data; 53 | }); 54 | } 55 | } 56 | 57 | export default new API(); 58 | -------------------------------------------------------------------------------- /src/modules/member/views/Selector.tsx: -------------------------------------------------------------------------------- 1 | import DialogPage from '@elux-admin-antd/stage/components/DialogPage'; 2 | import {MColumns} from '@elux-admin-antd/stage/components/MTable'; 3 | import {connectStore} from '@elux/vue-web'; 4 | import {defineComponent} from 'vue'; 5 | import {APPState, useRouter} from '@/Global'; 6 | import {ListItem, ListSearch, LocationState} from '../entity'; 7 | import ListTable from './ListTable'; 8 | import SearchForm from './SearchForm'; 9 | 10 | interface StoreProps { 11 | prefixPathname: string; 12 | listSearch: ListSearch; 13 | curRender?: string; 14 | } 15 | 16 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 17 | const {prefixPathname, curRender, listSearch} = state.member!; 18 | return {prefixPathname, curRender, listSearch}; 19 | }; 20 | 21 | const mergeColumns: {[field: string]: MColumns} = { 22 | articles: {disable: true}, 23 | }; 24 | 25 | const Component = defineComponent({ 26 | setup() { 27 | const router = useRouter(); 28 | const {selectLimit, showSearch, fixedSearch, selectedRows} = (router.location.state || {}) as LocationState; 29 | const storeProps = connectStore(mapStateToProps); 30 | 31 | return () => { 32 | const {listSearch, prefixPathname, curRender} = storeProps; 33 | return ( 34 | 35 |
36 | {showSearch && } 37 | 43 |
44 |
45 | ); 46 | }; 47 | }, 48 | }); 49 | 50 | export default Component; 51 | -------------------------------------------------------------------------------- /src/modules/member/api.ts: -------------------------------------------------------------------------------- 1 | import {BaseApi} from '@elux-admin-antd/stage/utils/base'; 2 | import request from '@elux-admin-antd/stage/utils/request'; 3 | import {IAlterItems, ICreateItem, IDeleteItems, IGetItem, IGetList, IUpdateItem} from './entity'; 4 | 5 | export class API implements BaseApi { 6 | public getList(params: IGetList['Request']): Promise { 7 | return request.get('/api/member', {params}).then((res) => { 8 | return res.data; 9 | }); 10 | } 11 | 12 | public getItem(params: IGetItem['Request']): Promise { 13 | if (!params.id) { 14 | return Promise.resolve({} as any); 15 | } 16 | return request.get(`/api/member/${params.id}`).then((res) => { 17 | return res.data; 18 | }); 19 | } 20 | 21 | public alterItems(params: IAlterItems['Request']): Promise { 22 | const {id, data} = params; 23 | const ids = typeof id === 'string' ? [id] : id; 24 | return request.put(`/api/member/${ids.join(',')}`, data).then((res) => { 25 | return res.data; 26 | }); 27 | } 28 | 29 | public updateItem(params: IUpdateItem['Request']): Promise { 30 | const {id, data} = params; 31 | return request.put(`/api/member/${id}`, data).then((res) => { 32 | return res.data; 33 | }); 34 | } 35 | 36 | public deleteItems(params: IDeleteItems['Request']): Promise { 37 | const {id} = params; 38 | const ids = typeof id === 'string' ? [id] : id; 39 | return request.delete(`/api/member/${ids.join(',')}`).then((res) => { 40 | return res.data; 41 | }); 42 | } 43 | 44 | public createItem(params: ICreateItem['Request']): Promise { 45 | return request.post<{id: string}>(`/api/member`, params).then((res) => { 46 | return res.data; 47 | }); 48 | } 49 | } 50 | 51 | export default new API(); 52 | -------------------------------------------------------------------------------- /src/modules/article/api.ts: -------------------------------------------------------------------------------- 1 | import {BaseApi} from '@elux-admin-antd/stage/utils/base'; 2 | import request from '@elux-admin-antd/stage/utils/request'; 3 | import {IAlterItems, ICreateItem, IDeleteItems, IGetItem, IGetList, IUpdateItem} from './entity'; 4 | 5 | export class API implements BaseApi { 6 | public getList(params: IGetList['Request']): Promise { 7 | return request.get('/api/article', {params}).then((res) => { 8 | return res.data; 9 | }); 10 | } 11 | 12 | public getItem(params: IGetItem['Request']): Promise { 13 | if (!params.id) { 14 | return Promise.resolve({} as any); 15 | } 16 | return request.get(`/api/article/${params.id}`).then((res) => { 17 | return res.data; 18 | }); 19 | } 20 | 21 | public alterItems(params: IAlterItems['Request']): Promise { 22 | const {id, data} = params; 23 | const ids = typeof id === 'string' ? [id] : id; 24 | return request.put(`/api/article/${ids.join(',')}`, data).then((res) => { 25 | return res.data; 26 | }); 27 | } 28 | 29 | public updateItem(params: IUpdateItem['Request']): Promise { 30 | const {id, data} = params; 31 | return request.put(`/api/article/${id}`, data).then((res) => { 32 | return res.data; 33 | }); 34 | } 35 | 36 | public deleteItems(params: IDeleteItems['Request']): Promise { 37 | const {id} = params; 38 | const ids = typeof id === 'string' ? [id] : id; 39 | return request.delete(`/api/article/${ids.join(',')}`).then((res) => { 40 | return res.data; 41 | }); 42 | } 43 | 44 | public createItem(params: ICreateItem['Request']): Promise { 45 | return request.post(`/api/article`, params).then((res) => { 46 | return res.data; 47 | }); 48 | } 49 | } 50 | 51 | export default new API(); 52 | -------------------------------------------------------------------------------- /src/modules/article/views/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import {ListSearch as MemberListSearch, Role, Status} from '@elux-admin-antd/member/entity'; 2 | import MSearch from '@elux-admin-antd/stage/components/MSearch'; 3 | import MSelect from '@elux-admin-antd/stage/components/MSelect'; 4 | import {useSearch} from '@elux-admin-antd/stage/utils/resource'; 5 | import {SearchFromItems} from '@elux-admin-antd/stage/utils/tools'; 6 | import {Input, Select} from 'ant-design-vue'; 7 | import {defineComponent} from 'vue'; 8 | import {DStatus, ListSearch, ListSearchFormData, defaultListSearch} from '../entity'; 9 | 10 | interface Props { 11 | listPathname: string; 12 | listSearch: ListSearch; 13 | } 14 | 15 | const formItems: SearchFromItems = [ 16 | {name: 'title', label: '标题', formItem: }, 17 | { 18 | name: 'author', 19 | label: '作者', 20 | formItem: placeholder="请选择作者" selectorPathname="/admin/member/list/selector" limit={1} showSearch>, 21 | }, 22 | { 23 | name: 'editor', 24 | label: '责任编辑', 25 | formItem: ( 26 | 27 | placeholder="请选择责任编辑" 28 | selectorPathname="/admin/member/list/selector" 29 | fixedSearch={{role: Role.责任编辑, status: Status.启用}} 30 | limit={1} 31 | showSearch 32 | > 33 | ), 34 | }, 35 | { 36 | name: 'status', 37 | label: '状态', 38 | formItem: 60 | 61 | 62 |
63 | 66 | 67 |
68 |
69 | 70 | 71 | ); 72 | }; 73 | }, 74 | }); 75 | 76 | export default Component; 77 | -------------------------------------------------------------------------------- /src/modules/article/views/Detail.tsx: -------------------------------------------------------------------------------- 1 | import DateTime from '@elux-admin-antd/stage/components/DateTime'; 2 | import DialogPage from '@elux-admin-antd/stage/components/DialogPage'; 3 | import {splitIdName} from '@elux-admin-antd/stage/utils/tools'; 4 | import {Link} from '@elux/vue-web'; 5 | import {Descriptions, Skeleton} from 'ant-design-vue'; 6 | import {DStatus, ItemDetail} from '../entity'; 7 | 8 | const DescriptionsItem = Descriptions.Item; 9 | 10 | interface Props { 11 | itemDetail?: ItemDetail; 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 15 | const Component = ({itemDetail}: Props) => { 16 | let id = '', 17 | name = ''; 18 | return ( 19 | 20 |
21 | {itemDetail ? ( 22 | 23 | 24 | {itemDetail.title} 25 | 26 | 27 | {({id, name} = splitIdName(itemDetail.author)) && ( 28 | 29 | {name} 30 | 31 | )} 32 | 33 | 34 |
35 | {itemDetail.editors.map( 36 | (editor) => 37 | ({id, name} = splitIdName(editor)) && ( 38 | 39 | {name} 40 | 41 | ) 42 | )} 43 |
44 |
45 | {DStatus.valueToLabel[itemDetail.status]} 46 | 47 | 48 | 49 | 50 | {itemDetail.summary} 51 | 52 | 53 | {itemDetail.content} 54 | 55 |
56 | ) : ( 57 | 58 | )} 59 |
60 |
61 | ); 62 | }; 63 | 64 | export default Component; 65 | -------------------------------------------------------------------------------- /public/client/imgs/logo-icon-rotate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 24 | 28 | 32 | 36 | 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elux-admin-antd", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "hiisea ", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=14.0.0" 9 | }, 10 | "private": true, 11 | "workspaces": [ 12 | "./mock", 13 | "./public", 14 | "./src/modules/*" 15 | ], 16 | "browserslist": [ 17 | "chrome >= 80" 18 | ], 19 | "scripts": { 20 | "lint:type": "tsc --project ./src --noEmit --emitDeclarationOnly false", 21 | "lint:es": "cross-env NODE_ENV=production eslint --fix --cache \"**/*.{js,ts,tsx}\"", 22 | "lint:css": "cross-env NODE_ENV=production stylelint --fix --cache \"**/*.{css,less}\"", 23 | "lint:json": "prettier --write **/*.json", 24 | "recommit": "git commit --amend --no-edit", 25 | "demote": "elux demote", 26 | "dev": "elux webpack-dev", 27 | "build": "elux webpack-build", 28 | "build:localhost": "cross-env APP_ENV=localhost elux webpack-build", 29 | "build:demo": "elux webpack-build demo", 30 | "build:mock": "tsc --project ./mock", 31 | "mock": "elux mock --watch", 32 | "start": "run-p mock dev", 33 | "dist": "node ./dist/local/start.js", 34 | "demo": "run-p mock dist" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged", 39 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 40 | } 41 | }, 42 | "config": { 43 | "commitizen": { 44 | "path": "cz-conventional-changelog" 45 | } 46 | }, 47 | "lint-staged": { 48 | "*.{js,jsx,ts,tsx,vue}": "cross-env NODE_ENV=production eslint --fix --quiet --cache", 49 | "*.{css,less}": "cross-env NODE_ENV=production stylelint --fix --quiet --cache", 50 | "*.json": "prettier --write" 51 | }, 52 | "bundledDependencies": [ 53 | "dayjs", 54 | "@ant-design/icons-vue" 55 | ], 56 | "dependencies": { 57 | "@elux/vue-web": "^2.6.3", 58 | "vue": "^3.2.22", 59 | "axios": "^0.21.1", 60 | "query-string": "^7.1.1", 61 | "path-to-regexp": "^6.2.0", 62 | "ant-design-vue": "^3.2.10" 63 | }, 64 | "devDependencies": { 65 | "@elux/cli": "^2.5.2", 66 | "@elux/cli-utils": "^2.3.1", 67 | "@elux/cli-webpack": "^2.2.1", 68 | "@elux/cli-mock": "^2.1.0", 69 | "@elux/babel-preset": "^1.0.2", 70 | "@elux/eslint-plugin": "^1.2.2", 71 | "@elux/stylelint-config": "^1.1.1", 72 | "marked": "^4.0.18", 73 | "npm-run-all": "~4.1.5", 74 | "cross-env": "~7.0.0", 75 | "typescript": "~4.7.0", 76 | "autoprefixer": "~10.4.0", 77 | "less": "~3.12.2", 78 | "less-loader": "~7.1.0", 79 | "@commitlint/cli": "~12.1.1", 80 | "@commitlint/config-conventional": "~12.1.1", 81 | "husky": "~4.3.8", 82 | "commitizen": "~4.2.3", 83 | "cz-lerna-changelog": "~2.0.3", 84 | "lint-staged": "~10.5.4" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/modules/admin/views/Menu/index.tsx: -------------------------------------------------------------------------------- 1 | import {DashboardOutlined, ProfileOutlined, TeamOutlined} from '@ant-design/icons-vue'; 2 | import {CurUser} from '@elux-admin-antd/stage/entity'; 3 | import {connectStore} from '@elux/vue-web'; 4 | import {Menu} from 'ant-design-vue'; 5 | import {computed, defineComponent, ref, watch} from 'vue'; 6 | import {APPState, GetActions} from '@/Global'; 7 | import {MenuItem} from '../../entity'; 8 | import styles from './index.module.less'; 9 | 10 | export type Key = string | number; 11 | 12 | const ICONS: {[key: string]: any} = { 13 | dashboard: , 14 | user: , 15 | post: , 16 | }; 17 | 18 | function generateMenu(menuData: MenuItem[]) { 19 | return menuData.map(({label, key, icon, children}) => { 20 | const Icon = icon ? ICONS[icon] || ICONS['dashboard'] : null; 21 | if (children && children.length) { 22 | return ( 23 | 24 | {generateMenu(children)} 25 | 26 | ); 27 | } 28 | return ( 29 | 30 | {label} 31 | 32 | ); 33 | }); 34 | } 35 | 36 | export interface StoreProps { 37 | curUser: CurUser; 38 | siderCollapsed?: boolean; 39 | menuData: MenuItem[]; 40 | menuSelected: {selected: string[]; open: string[]}; 41 | } 42 | 43 | function mapStateToProps(appState: APPState): StoreProps { 44 | const {curUser} = appState.stage!; 45 | const {siderCollapsed, menuData, menuSelected} = appState.admin!; 46 | return {curUser, siderCollapsed, menuData: menuData.items, menuSelected}; 47 | } 48 | 49 | const {admin: adminActions} = GetActions('admin'); 50 | 51 | const Component = defineComponent({ 52 | name: styles.root, 53 | setup() { 54 | const storeProps = connectStore(mapStateToProps); 55 | const menuItems = computed(() => generateMenu(storeProps.menuData)); 56 | const openKeys = ref([]); 57 | watch( 58 | () => storeProps.menuSelected.open, 59 | (val) => (openKeys.value = val), 60 | {immediate: true} 61 | ); 62 | 63 | const onClick = ({key}: any) => storeProps.dispatch(adminActions.clickMenu(key)); 64 | const onOpenChange = (keys: Key[]) => (openKeys.value = keys); 65 | 66 | return () => { 67 | const {menuSelected} = storeProps; 68 | return ( 69 |
70 | 78 | {menuItems.value} 79 | 80 |
81 | ); 82 | }; 83 | }, 84 | }); 85 | 86 | export default Component; 87 | -------------------------------------------------------------------------------- /src/modules/member/entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseCurRender, 3 | BaseCurView, 4 | BaseListItem, 5 | BaseListSearch, 6 | BaseListSummary, 7 | BaseLocationState, 8 | BaseModuleState, 9 | BaseRouteParams, 10 | DefineResource, 11 | IRequest, 12 | enumOptions, 13 | } from '@elux-admin-antd/stage/utils/base'; 14 | 15 | export enum Gender { 16 | '男' = 'male', 17 | '女' = 'female', 18 | '未知' = 'unknow', 19 | } 20 | 21 | export const DGender = enumOptions(Gender); 22 | 23 | export enum Status { 24 | '启用' = 'enable', 25 | '禁用' = 'disable', 26 | } 27 | export const DStatus = enumOptions(Status); 28 | 29 | export enum Role { 30 | '普通用户' = 'consumer', 31 | '管理员' = 'admin', 32 | '责任编辑' = 'editor', 33 | } 34 | 35 | export const DRole = enumOptions(Role); 36 | 37 | export type CurView = BaseCurView; 38 | export type CurRender = BaseCurRender; 39 | 40 | export type LocationState = BaseLocationState; 41 | 42 | export interface ListSearch extends BaseListSearch { 43 | name?: string; 44 | nickname?: string; 45 | email?: string; 46 | role?: Role; 47 | status?: Status; 48 | } 49 | export interface ListItem extends BaseListItem { 50 | name: string; 51 | nickname: string; 52 | gender: Gender; 53 | role: Role; 54 | status: Status; 55 | articles: number; 56 | email: string; 57 | createdTime: number; 58 | } 59 | export interface ListSummary extends BaseListSummary {} 60 | export interface ItemDetail extends ListItem {} 61 | export type UpdateItem = Omit; 62 | 63 | export type ListSearchFormData = Omit; 64 | 65 | export interface MemberResource extends DefineResource { 66 | RouteParams: RouteParams; 67 | ModuleState: ModuleState; 68 | ListSearch: ListSearch; 69 | ListItem: ListItem; 70 | ListSummary: ListSummary; 71 | ItemDetail: ItemDetail; 72 | UpdateItem: UpdateItem; 73 | CreateItem: UpdateItem; 74 | CurView: CurView; 75 | CurRender: CurRender; 76 | } 77 | 78 | export type RouteParams = BaseRouteParams; 79 | export type ModuleState = BaseModuleState; 80 | 81 | export const defaultListSearch: ListSearch = { 82 | pageCurrent: 1, 83 | pageSize: 10, 84 | sorterOrder: undefined, 85 | sorterField: '', 86 | name: '', 87 | nickname: '', 88 | email: '', 89 | role: undefined, 90 | status: undefined, 91 | }; 92 | 93 | export type IGetList = IRequest; 94 | export type IGetItem = IRequest<{id: string}, ItemDetail>; 95 | export type IAlterItems = IRequest<{id: string | string[]; data: Partial}, void>; 96 | export type IDeleteItems = IRequest<{id: string | string[]}, void>; 97 | export type IUpdateItem = IRequest<{id: string | string[]; data: UpdateItem}, void>; 98 | export type ICreateItem = IRequest; 99 | -------------------------------------------------------------------------------- /src/modules/admin/views/Main/index.tsx: -------------------------------------------------------------------------------- 1 | //通常模块可以定义一个根视图,根视图中显示什么由模块自行决定,父级不干涉,相当于子路由 2 | import ErrorPage from '@elux-admin-antd/stage/components/ErrorPage'; 3 | import {CurUser} from '@elux-admin-antd/stage/entity'; 4 | import {Switch, connectStore, exportView} from '@elux/vue-web'; 5 | import {Layout} from 'ant-design-vue'; 6 | import {computed, defineComponent} from 'vue'; 7 | import {APPState, LoadComponent} from '@/Global'; 8 | import {SubModule} from '../../entity'; 9 | import Flag from '../Flag'; 10 | import Header from '../Header'; 11 | import Menu from '../Menu'; 12 | import Tabs from '../Tabs'; 13 | import styles from './index.module.less'; 14 | 15 | //LoadComponent是懒执行的,不用担心 16 | const SubModuleViews: {[moduleName: string]: () => JSX.Element} = Object.keys(SubModule).reduce((cache, moduleName) => { 17 | cache[moduleName] = LoadComponent(moduleName as any, 'main'); 18 | return cache; 19 | }, {}); 20 | 21 | export interface StoreProps { 22 | curUser: CurUser; 23 | dialogMode: boolean; 24 | subModule?: SubModule; 25 | siderCollapsed?: boolean; 26 | } 27 | 28 | function mapStateToProps(appState: APPState): StoreProps { 29 | const {curUser} = appState.stage!; 30 | const {subModule, dialogMode, siderCollapsed} = appState.admin!; 31 | return {curUser, subModule, dialogMode, siderCollapsed}; 32 | } 33 | 34 | const Component = defineComponent({ 35 | name: styles.root, 36 | setup() { 37 | const storeProps = connectStore(mapStateToProps); 38 | const content = computed(() => { 39 | const {subModule} = storeProps; 40 | return ( 41 | }> 42 | {subModule && 43 | Object.keys(SubModule).map((moduleName) => { 44 | if (subModule === moduleName) { 45 | const SubView = SubModuleViews[subModule]; 46 | return ; 47 | } else { 48 | return null; 49 | } 50 | })} 51 | 52 | ); 53 | }); 54 | return () => { 55 | const {curUser, dialogMode, siderCollapsed} = storeProps; 56 | if (!curUser.hasLogin) { 57 | return null; 58 | } 59 | if (dialogMode) { 60 | return content.value; 61 | } 62 | return ( 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 | 73 | 74 | {content.value} 75 | 76 | 77 |
78 | ); 79 | }; 80 | }, 81 | }); 82 | 83 | export default exportView(Component); 84 | -------------------------------------------------------------------------------- /mock/src/database.ts: -------------------------------------------------------------------------------- 1 | import {CurUser} from '@elux-admin-antd/stage/entity'; 2 | import mockjs from 'mockjs'; 3 | import type {ItemDetail as Article} from '@elux-admin-antd/article/entity'; 4 | import type {ItemDetail as Member} from '@elux-admin-antd/member/entity'; 5 | 6 | const timestamp = Date.now(); 7 | 8 | export const guestUser: CurUser = { 9 | id: '0', 10 | username: 'guest', 11 | hasLogin: false, 12 | avatar: 'imgs/guest.png', 13 | mobile: '', 14 | }; 15 | export const adminUser: CurUser = { 16 | id: '1', 17 | username: 'admin', 18 | hasLogin: true, 19 | avatar: 'imgs/admin.jpg', 20 | mobile: '18498982234', 21 | }; 22 | 23 | function createMembers(): {[id: string]: Member} { 24 | const listData = {}; 25 | mockjs 26 | .mock({ 27 | 'list|50': [ 28 | { 29 | 'id|+1': 1, 30 | name: '@last', 31 | nickname: '@cname', 32 | 'gender|1': ['male', 'female', 'unknow'], 33 | 'role|1': ['consumer', 'admin', 'editor'], 34 | 'status|1': ['enable', 'disable', 'enable'], 35 | articles: 0, 36 | email: '@email', 37 | loginTime: timestamp, 38 | createdTime: timestamp, 39 | }, 40 | ], 41 | }) 42 | .list.forEach((item: Member, index: number) => { 43 | item.createdTime = timestamp + index * 1000; 44 | item.id = `${item.id}`; 45 | listData[item.id] = item; 46 | }); 47 | return listData; 48 | } 49 | 50 | const members = createMembers(); 51 | 52 | function createArticles(): {[id: string]: Article} { 53 | const authors: string[] = []; 54 | const editors: string[] = []; 55 | 56 | for (const id in members) { 57 | const member = members[id]; 58 | authors.push([id, member.name].join(',')); 59 | if (member.role === 'editor' && member.status === 'enable') { 60 | editors.push([id, member.name].join(',')); 61 | } 62 | authors.splice(0, authors.length - 5); 63 | editors.splice(0, editors.length - 5); 64 | } 65 | 66 | const listData = {}; 67 | 68 | mockjs 69 | .mock({ 70 | 'list|50': [ 71 | { 72 | 'id|+1': 1, 73 | title: '@ctitle(10, 20)', 74 | summary: '@cparagraph(5, 8)', 75 | content: '@cparagraph(10, 20)', 76 | 'author|1': authors, 77 | editors: () => { 78 | const start = Math.floor(Math.random() * (editors.length - 1)); 79 | return editors.slice(start, start + 2); 80 | }, 81 | createdTime: timestamp, 82 | 'status|1': ['pending', 'resolved', 'rejected'], 83 | }, 84 | ], 85 | }) 86 | .list.forEach((item: Article, index: number) => { 87 | item.createdTime = timestamp + index * 1000; 88 | item.id = `${item.id}`; 89 | const authorId = item.author.split(',', 1)[0]; 90 | members[authorId].articles++; 91 | listData[item.id] = item; 92 | }); 93 | return listData; 94 | } 95 | 96 | const articles = createArticles(); 97 | 98 | export const database = { 99 | curUser: guestUser, 100 | members, 101 | articles, 102 | }; 103 | -------------------------------------------------------------------------------- /src/modules/admin/views/Header/index.tsx: -------------------------------------------------------------------------------- 1 | //通常模块可以定义一个根视图,根视图中显示什么由模块自行决定,父级不干涉,相当于子路由 2 | import {BellOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, QuestionCircleOutlined, UserOutlined} from '@ant-design/icons-vue'; 3 | import {CurUser} from '@elux-admin-antd/stage/entity'; 4 | import {AdminHomeUrl} from '@elux-admin-antd/stage/utils/const'; 5 | import {connectStore} from '@elux/vue-web'; 6 | import {Avatar, Badge, Dropdown, Menu} from 'ant-design-vue'; 7 | import {computed, defineComponent} from 'vue'; 8 | import {APPState, GetActions, StaticPrefix, useRouter} from '@/Global'; 9 | import {Notices} from '../../entity'; 10 | import styles from './index.module.less'; 11 | 12 | const {stage: stageActions, admin: adminActions} = GetActions('stage', 'admin'); 13 | 14 | export interface StoreProps { 15 | curUser: CurUser; 16 | siderCollapsed?: boolean; 17 | notices?: Notices; 18 | } 19 | 20 | function mapStateToProps(appState: APPState): StoreProps { 21 | const {curUser} = appState.stage!; 22 | const {notices, siderCollapsed} = appState.admin!; 23 | return {curUser, notices, siderCollapsed}; 24 | } 25 | 26 | const Component = defineComponent({ 27 | name: styles.root, 28 | setup() { 29 | const router = useRouter(); 30 | const storeProps = connectStore(mapStateToProps); 31 | const toggleSider = () => { 32 | storeProps.dispatch(adminActions.putSiderCollapsed(!storeProps.siderCollapsed)); 33 | }; 34 | const onUserMenuClick = ({key}: {key: string | number}) => { 35 | if (key === 'logout') { 36 | storeProps.dispatch(stageActions.logout()); 37 | } else if (key === 'home') { 38 | router.relaunch({url: AdminHomeUrl}, 'window'); 39 | } 40 | }; 41 | const userMenu = computed(() => { 42 | return ( 43 | 44 | }> 45 | 个人中心 46 | 47 | }> 48 | 退出登录 49 | 50 | 51 | ); 52 | }); 53 | 54 | return () => { 55 | const {siderCollapsed, notices, curUser} = storeProps; 56 | return ( 57 |
58 | 68 |
69 | 70 | 71 | 72 | 73 | 77 | 78 |
79 |
80 | ); 81 | }; 82 | }, 83 | }); 84 | 85 | export default Component; 86 | -------------------------------------------------------------------------------- /src/modules/stage/components/DialogPage/index.module.less: -------------------------------------------------------------------------------- 1 | @import '../../assets/css/var.less'; 2 | 3 | :global { 4 | ._dialog > :local(.root) > .control { 5 | display: block; 6 | } 7 | 8 | :local(.root) { 9 | position: relative; 10 | background-color: #fff; 11 | 12 | &.show-brand { 13 | padding-top: 45px !important; 14 | 15 | > .content > .subject { 16 | margin: 20px 0 0; 17 | font-size: 25px; 18 | text-align: center; 19 | border-bottom: none; 20 | } 21 | } 22 | 23 | > .control { 24 | position: absolute; 25 | top: 0; 26 | right: 0; 27 | z-index: 99999999; 28 | display: none; 29 | padding: 0 10px; 30 | font-size: 13px; 31 | text-align: center; 32 | background-color: #4d5763; 33 | border-radius: 0 0 0 15px; 34 | 35 | > * { 36 | display: inline-block; 37 | width: 30px; 38 | height: 30px; 39 | line-height: 30px; 40 | color: #fff; 41 | cursor: pointer; 42 | opacity: 0.6; 43 | 44 | &:last-child { 45 | opacity: 1; 46 | } 47 | 48 | &:hover { 49 | opacity: 1; 50 | } 51 | } 52 | } 53 | 54 | > .resize { 55 | position: absolute; 56 | top: 5px; 57 | left: 5px; 58 | z-index: 99999999; 59 | font-size: 10px; 60 | line-height: 0; 61 | cursor: zoom-in; 62 | background-color: #fff; 63 | border-radius: 100px; 64 | 65 | .btn { 66 | display: block; 67 | width: 1.6em; 68 | height: 1.6em; 69 | overflow: hidden; 70 | line-height: 1.4em; 71 | transform: rotate(45deg); 72 | } 73 | 74 | .anticon { 75 | color: #999; 76 | 77 | &:last-child { 78 | margin-left: -0.4em; 79 | } 80 | } 81 | 82 | &:hover { 83 | background-color: #4d5763; 84 | 85 | .anticon { 86 | color: #fff; 87 | } 88 | } 89 | } 90 | 91 | > .brand { 92 | position: absolute; 93 | top: 0; 94 | left: 0; 95 | width: 100%; 96 | padding-left: 35px; 97 | line-height: 35px; 98 | color: #777; 99 | background: rgba(0, 0, 0, 0.06) url('../../assets/imgs/logo-icon.svg') no-repeat 10px center; 100 | background-size: 20px; 101 | border-bottom: rgba(255, 255, 255, 0.5) 1px solid; 102 | 103 | .ver { 104 | font-size: 12px; 105 | font-weight: bold; 106 | } 107 | } 108 | 109 | > .content { 110 | box-sizing: border-box; 111 | height: 100%; 112 | overflow: auto; 113 | background: #fff; 114 | 115 | .subject { 116 | padding-bottom: 10px; 117 | margin: 20px; 118 | font-size: 18px; 119 | font-weight: bold; 120 | border-bottom: 1px solid @border-color-split; 121 | } 122 | } 123 | 124 | &.size-max { 125 | > .resize { 126 | cursor: zoom-out; 127 | 128 | .anticon { 129 | transform: scale(-1); 130 | } 131 | } 132 | } 133 | } 134 | 135 | :local(.mask) { 136 | position: absolute; 137 | top: 0; 138 | right: 0; 139 | bottom: 0; 140 | left: 0; 141 | z-index: -1; 142 | cursor: default; 143 | background: rgba(0, 0, 0, 0.2); 144 | 145 | &.no-mask { 146 | background-color: rgba(0, 0, 0, 0); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /mock/src/routes/session.ts: -------------------------------------------------------------------------------- 1 | import {Router} from 'express'; 2 | import {adminUser, database, guestUser} from '../database'; 3 | import type {IGetMenu, IGetNotices} from '@elux-admin-antd/admin/entity'; 4 | import type {IGetCurUser, ILogin, ILogout, IRegistry, IResetPassword, ISendCaptcha} from '@elux-admin-antd/stage/entity'; 5 | 6 | const router = Router(); 7 | 8 | router.get('/', function (req, res, next) { 9 | const result: IGetCurUser['Response'] = database.curUser; 10 | setTimeout(() => res.json(result), 500); 11 | }); 12 | 13 | router.put('/', function ({body}: {body: ILogin['Request']}, res, next) { 14 | const {username = '', password = ''} = body; 15 | if (username === 'admin' && password === '123456') { 16 | database.curUser = adminUser; 17 | const result: ILogin['Response'] = adminUser; 18 | setTimeout(() => res.json(result), 500); 19 | } else { 20 | res.status(422).json({ 21 | message: '用户名或密码错误!', 22 | }); 23 | } 24 | }); 25 | 26 | router.post('/', function ({body}: {body: IRegistry['Request']}, res, next) { 27 | const {username = '', password = ''} = body; 28 | if (username && password) { 29 | const curUser = {...adminUser, username, password}; 30 | database.curUser = curUser; 31 | const result: IRegistry['Response'] = curUser; 32 | setTimeout(() => res.json(result), 500); 33 | } else { 34 | res.status(422).json({ 35 | message: '用户名或密码错误!', 36 | }); 37 | } 38 | }); 39 | 40 | router.delete('/', function (req, res, next) { 41 | database.curUser = guestUser; 42 | const result: ILogout['Response'] = database.curUser; 43 | setTimeout(() => res.json(result), 500); 44 | }); 45 | 46 | router.put('/resetPassword', function ({body}: {body: IResetPassword['Request']}, res, next) { 47 | const {phone = '', password = '', captcha = ''} = body; 48 | if (phone && password && captcha) { 49 | setTimeout(() => res.json({}), 500); 50 | } else { 51 | res.status(422).json({ 52 | message: '参数错误!', 53 | }); 54 | } 55 | }); 56 | 57 | router.post('/sendCaptcha', function ({body}: {body: ISendCaptcha['Request']}, res, next) { 58 | const {phone = ''} = body; 59 | if (phone) { 60 | setTimeout(() => res.json({}), 500); 61 | } else { 62 | res.status(422).json({ 63 | message: '参数错误!', 64 | }); 65 | } 66 | }); 67 | 68 | router.get('/notices', function (req, res, next) { 69 | const result: IGetNotices['Response'] = {num: Math.floor(Math.random() * 100)}; 70 | setTimeout(() => res.json(result), 500); 71 | }); 72 | 73 | router.get('/menu', function (req, res, next) { 74 | const result: IGetMenu['Response'] = [ 75 | { 76 | key: 'dashboard', 77 | label: '概要总览', 78 | icon: 'dashboard', 79 | match: '/admin/dashboard/', 80 | link: '/admin/dashboard/workplace', 81 | }, 82 | { 83 | key: 'member', 84 | label: '用户管理', 85 | icon: 'user', 86 | match: '/admin/member/', 87 | link: '/admin/member/list/maintain', 88 | }, 89 | { 90 | key: 'article', 91 | label: '文章管理', 92 | icon: 'post', 93 | children: [ 94 | { 95 | key: 'articleList', 96 | label: '文章列表', 97 | match: '/admin/article/', 98 | link: '/admin/article/list/maintain', 99 | }, 100 | { 101 | key: 'commentList', 102 | label: '评论列表', 103 | match: '/admin/comment/', 104 | link: '/admin/comment/list/maintain', 105 | }, 106 | ], 107 | }, 108 | ]; 109 | setTimeout(() => res.json(result), 500); 110 | }); 111 | 112 | export default router; 113 | -------------------------------------------------------------------------------- /src/modules/stage/utils/base.ts: -------------------------------------------------------------------------------- 1 | import {LoadingState} from '@elux/vue-web'; 2 | 3 | export interface IRequest { 4 | Request: Req; 5 | Response: Res; 6 | } 7 | export type BaseCurView = 'list' | 'item'; 8 | export type BaseCurRender = 'maintain' | 'index' | 'selector' | 'edit' | 'detail'; 9 | export interface BaseListSearch { 10 | pageCurrent?: number; 11 | pageSize?: number; 12 | sorterOrder?: 'ascend' | 'descend'; 13 | sorterField?: string; 14 | } 15 | 16 | export interface BaseListItem { 17 | id: string; 18 | } 19 | 20 | export interface BaseListSummary { 21 | pageCurrent: number; 22 | pageSize: number; 23 | totalItems: number; 24 | totalPages: number; 25 | categorys?: {id: string; name: string; ids: string[]}[]; 26 | } 27 | 28 | export interface BaseItemDetail extends BaseListItem {} 29 | 30 | export interface BaseLocationState { 31 | selectLimit?: number | [number, number]; 32 | selectedRows?: Partial[]; 33 | showSearch?: boolean; 34 | fixedSearch?: {[field: string]: any}; 35 | onEditSubmit?: (id: string, data: Record) => Promise; 36 | onSelectedSubmit?: (rows: Partial[]) => void; 37 | } 38 | export interface BaseModuleState { 39 | prefixPathname: string; 40 | curView?: TDefineResource['CurView']; 41 | curRender?: TDefineResource['CurRender']; 42 | listSearch: TDefineResource['ListSearch']; 43 | list?: TDefineResource['ListItem'][]; 44 | listSummary?: TDefineResource['ListSummary']; 45 | listLoading?: LoadingState; 46 | itemId?: string; 47 | itemDetail?: TDefineResource['ItemDetail']; 48 | } 49 | 50 | export interface BaseRouteParams { 51 | prefixPathname: string; 52 | curView?: TDefineResource['CurView']; 53 | curRender?: TDefineResource['CurRender']; 54 | listSearch?: TDefineResource['ListSearch']; 55 | itemId?: string; 56 | } 57 | 58 | export interface BaseApi { 59 | getList?(params: BaseListSearch): Promise<{list: BaseListItem[]; listSummary: BaseListSummary}>; 60 | getItem?(params: {id: string}): Promise; 61 | alterItems?(params: {id: string | string[]; data: Record}): Promise; 62 | updateItem?(params: {id: string; data: any}): Promise; 63 | createItem?(params: Record): Promise<{id: string}>; 64 | deleteItems?(params: {id: string | string[]}): Promise; 65 | } 66 | 67 | export interface DefineResource { 68 | RouteParams: BaseRouteParams; 69 | ModuleState: BaseModuleState; 70 | ListSearch: BaseListSearch; 71 | ListItem: BaseListItem; 72 | ListSummary: BaseListSummary; 73 | CurView: BaseCurView; 74 | CurRender: BaseCurRender; 75 | ItemDetail: BaseItemDetail; 76 | UpdateItem: any; 77 | CreateItem: any; 78 | } 79 | 80 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 81 | export function enumOptions(data: T) { 82 | const options: {value: string; label: string}[] = []; 83 | const labelToValue: {[key in keyof T]: T[key]} = {} as any; 84 | const valueToLabel: {[key in T[keyof T]]: string} = {} as any; 85 | Object.keys(data).forEach((label) => { 86 | options.push({label, value: data[label]}); 87 | (labelToValue as any)[label] = data[label]; 88 | valueToLabel[data[label]] = label; 89 | }); 90 | return { 91 | valueToLabel, 92 | labelToValue, 93 | options, 94 | }; 95 | } 96 | 97 | export function arrayToMap(arr: T[], key = 'id'): {[key: string]: T} { 98 | return arr.reduce((pre, cur) => { 99 | pre[cur[key]] = cur; 100 | return pre; 101 | }, {}); 102 | } 103 | -------------------------------------------------------------------------------- /src/modules/member/views/Maintain.tsx: -------------------------------------------------------------------------------- 1 | import {PlusOutlined} from '@ant-design/icons-vue'; 2 | import {MBatchActions} from '@elux-admin-antd/stage/components/MTable'; 3 | import {useAlter, useShowDetail} from '@elux-admin-antd/stage/utils/resource'; 4 | import {DocumentHead, connectStore} from '@elux/vue-web'; 5 | import {Button, Popconfirm} from 'ant-design-vue'; 6 | import {ColumnProps} from 'ant-design-vue/lib/table'; 7 | import {defineComponent} from 'vue'; 8 | import {APPState, GetActions} from '@/Global'; 9 | import {ListItem, ListSearch, Status} from '../entity'; 10 | import ListTable from './ListTable'; 11 | import SearchForm from './SearchForm'; 12 | 13 | interface StoreProps { 14 | prefixPathname: string; 15 | listSearch: ListSearch; 16 | curRender?: string; 17 | } 18 | 19 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 20 | const {prefixPathname, curRender, listSearch} = state.member!; 21 | return {prefixPathname, curRender, listSearch}; 22 | }; 23 | 24 | const {member: memberActions} = GetActions('member'); 25 | 26 | const Component = defineComponent({ 27 | setup() { 28 | const storeProps = connectStore(mapStateToProps); 29 | const {selectedRows, deleteItems, alterItems, updateItem} = useAlter(storeProps, memberActions); 30 | const {onShowDetail, onShowEditor} = useShowDetail(storeProps); 31 | const commActions = ( 32 | 35 | ); 36 | const batchActions: MBatchActions = { 37 | actions: [ 38 | {key: 'delete', label: '批量删除', confirm: true}, 39 | {key: 'enable', label: '批量启用', confirm: true}, 40 | {key: 'disable', label: '批量禁用', confirm: true}, 41 | ], 42 | handler: (item: {key: string}, ids: (string | number)[]) => { 43 | if (item.key === 'delete') { 44 | deleteItems(ids as string[]); 45 | } else if (item.key === 'enable') { 46 | alterItems(ids as string[], {status: Status.启用}); 47 | } else if (item.key === 'disable') { 48 | alterItems(ids as string[], {status: Status.禁用}); 49 | } 50 | }, 51 | }; 52 | const actionColumns: ColumnProps = { 53 | title: '操作', 54 | dataIndex: 'id', 55 | width: '200px', 56 | align: 'center', 57 | customRender: ({value, record}) => { 58 | return ( 59 | 69 | ); 70 | }, 71 | }; 72 | 73 | return () => { 74 | const {prefixPathname, curRender, listSearch} = storeProps; 75 | return ( 76 |
77 | 78 |
79 | 80 | 87 |
88 |
89 | ); 90 | }; 91 | }, 92 | }); 93 | 94 | export default Component; 95 | -------------------------------------------------------------------------------- /src/modules/member/views/EditorForm.tsx: -------------------------------------------------------------------------------- 1 | // import {useUpdateItem} from '@elux-admin-antd/stage/utils/resource'; 2 | import {getFormDecorators} from '@elux-admin-antd/stage/utils/tools'; 3 | import {Dispatch} from '@elux/vue-web'; 4 | import {Button, Form, FormInstance, Input, Select} from 'ant-design-vue'; 5 | import {defineComponent, ref, shallowReactive} from 'vue'; 6 | import {GetActions, useRouter} from '@/Global'; 7 | import {DGender, DRole, DStatus, ItemDetail, UpdateItem} from '../entity'; 8 | 9 | const FormItem = Form.Item; 10 | 11 | export const formItemLayout = { 12 | labelCol: { 13 | span: 4, 14 | }, 15 | wrapperCol: { 16 | span: 19, 17 | }, 18 | }; 19 | 20 | const fromDecorators = getFormDecorators({ 21 | name: {label: '用户名', rules: [{required: true, message: '请输入用户名'}]}, 22 | nickname: {label: '呢称', rules: [{required: true, message: '请输入呢称'}]}, 23 | role: {label: '角色', rules: [{required: true, message: '请选择角色'}]}, 24 | gender: {label: '性别', rules: [{required: true, message: '请选择性别'}]}, 25 | email: {label: 'Email', rules: [{required: true, type: 'email', message: '请输入Email'}]}, 26 | status: {label: '状态', rules: [{required: true, message: '请选择用户状态'}]}, 27 | }); 28 | 29 | interface Props { 30 | dispatch: Dispatch; 31 | itemDetail: ItemDetail; 32 | } 33 | 34 | const {member: memberActions} = GetActions('member'); 35 | 36 | const Component = defineComponent({ 37 | props: ['dispatch', 'itemDetail'] as any, 38 | setup(props) { 39 | const router = useRouter(); 40 | const formRef = ref(); 41 | const formState = shallowReactive({ 42 | ...props.itemDetail, 43 | }); 44 | 45 | const onFinish = (values: UpdateItem) => { 46 | const id = props.itemDetail.id; 47 | if (id) { 48 | props.dispatch(memberActions.updateItem(id, values)); 49 | } else { 50 | props.dispatch(memberActions.createItem(values)); 51 | } 52 | }; 53 | 54 | const onReset = () => { 55 | formRef.value?.resetFields(); 56 | }; 57 | 58 | const goBack = () => router.back(1, 'window'); 59 | 60 | // const {loading, onFinish} = useUpdateItem(itemDetail.id, dispatch, memberActions); 61 | 62 | return () => { 63 | return ( 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | 79 | 80 | 81 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 |
86 | 89 | 92 | 93 |
94 |
95 | ); 96 | }; 97 | }, 98 | }); 99 | 100 | export default Component; 101 | -------------------------------------------------------------------------------- /src/modules/article/views/Maintain.tsx: -------------------------------------------------------------------------------- 1 | import {PlusOutlined} from '@ant-design/icons-vue'; 2 | import {MBatchActions} from '@elux-admin-antd/stage/components/MTable'; 3 | import {useAlter, useShowDetail} from '@elux-admin-antd/stage/utils/resource'; 4 | import {DocumentHead, connectStore} from '@elux/vue-web'; 5 | import {Button, Dropdown, Menu, Popconfirm} from 'ant-design-vue'; 6 | import {ColumnProps} from 'ant-design-vue/lib/table'; 7 | import {defineComponent} from 'vue'; 8 | import {APPState, GetActions} from '@/Global'; 9 | import {ListItem, ListSearch, Status} from '../entity'; 10 | import ListTable from './ListTable'; 11 | import SearchForm from './SearchForm'; 12 | 13 | interface StoreProps { 14 | prefixPathname: string; 15 | listSearch: ListSearch; 16 | curRender?: string; 17 | } 18 | 19 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 20 | const {prefixPathname, curRender, listSearch} = state.article!; 21 | return {prefixPathname, curRender, listSearch}; 22 | }; 23 | 24 | const {article: articleActions} = GetActions('article'); 25 | 26 | const Component = defineComponent({ 27 | setup() { 28 | const storeProps = connectStore(mapStateToProps); 29 | const {selectedRows, deleteItems, alterItems, updateItem} = useAlter(storeProps, articleActions); 30 | const {onShowDetail, onShowEditor} = useShowDetail(storeProps); 31 | const commActions = ( 32 | 35 | ); 36 | const batchActions: MBatchActions = { 37 | actions: [ 38 | {key: 'delete', label: '批量删除', confirm: true}, 39 | {key: 'resolved', label: '批量通过', confirm: true}, 40 | {key: 'rejected', label: '批量拒绝', confirm: true}, 41 | ], 42 | handler: (item: {key: string}, ids: (string | number)[]) => { 43 | if (item.key === 'delete') { 44 | deleteItems(ids as string[]); 45 | } else if (item.key === 'resolved') { 46 | alterItems(ids as string[], {status: Status.审核通过}); 47 | } else if (item.key === 'rejected') { 48 | alterItems(ids as string[], {status: Status.审核拒绝}); 49 | } 50 | }, 51 | }; 52 | const actionColumns: ColumnProps = { 53 | title: '操作', 54 | dataIndex: 'id', 55 | width: '200px', 56 | align: 'center', 57 | customRender: ({value, record}) => { 58 | return ( 59 |
60 | onShowDetail(value)}>详细 61 | { 65 | alterItems([value], {status: key}); 66 | }} 67 | > 68 | 审核通过 69 | 审核拒绝 70 | 71 | } 72 | > 73 | 审核 74 | 75 | onShowEditor(value, updateItem)}>修改 76 | deleteItems(value)}> 77 | 删除 78 | 79 |
80 | ); 81 | }, 82 | }; 83 | 84 | return () => { 85 | const {prefixPathname, curRender, listSearch} = storeProps; 86 | return ( 87 |
88 | 89 |
90 | 91 | 98 |
99 |
100 | ); 101 | }; 102 | }, 103 | }); 104 | 105 | export default Component; 106 | -------------------------------------------------------------------------------- /src/modules/stage/components/DialogPage/index.tsx: -------------------------------------------------------------------------------- 1 | import {ArrowLeftOutlined, CaretLeftOutlined, CaretRightOutlined, CloseOutlined, ReloadOutlined} from '@ant-design/icons-vue'; 2 | import {DocumentHead, Link} from '@elux/vue-web'; 3 | import {Tooltip} from 'ant-design-vue'; 4 | import {computed, defineComponent, ref} from 'vue'; 5 | import styles from './index.module.less'; 6 | 7 | export interface Props { 8 | class?: string; 9 | title?: string; 10 | subject?: string; 11 | showBrand?: boolean; 12 | showControls?: boolean; 13 | maskClosable?: boolean; 14 | mask?: boolean; 15 | size?: 'max' | 'auto'; 16 | minSize?: (number | string)[]; 17 | backOverflowRedirect?: string; 18 | } 19 | 20 | const Component = defineComponent({ 21 | name: styles.root, 22 | // eslint-disable-next-line vue/require-prop-types 23 | props: ['class', 'title', 'subject', 'showBrand', 'showControls', 'maskClosable', 'mask', 'size', 'minSize', 'backOverflowRedirect'] as any, 24 | setup(props, context) { 25 | const size = ref(props.size || 'auto'); 26 | 27 | const toggleSize = () => { 28 | size.value = size.value === 'auto' ? 'max' : 'auto'; 29 | }; 30 | 31 | const controls = computed(() => { 32 | const showControls = props.showControls !== undefined ? props.showControls : !props.showBrand; 33 | return showControls ? ( 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | ) : null; 52 | }); 53 | 54 | const resize = computed(() => { 55 | const minSize = props.minSize || []; 56 | const showResize = minSize[0]; 57 | return showResize ? ( 58 |
59 |
60 | 61 | 62 |
63 |
64 | ) : null; 65 | }); 66 | 67 | const style = computed(() => { 68 | const minSize = props.minSize || []; 69 | const style = {}; 70 | if (minSize[0] && size.value === 'auto') { 71 | style['width'] = typeof minSize[0] === 'number' ? minSize[0] + 'px' : minSize[0]; 72 | } 73 | if (minSize[1] && size.value === 'auto') { 74 | style['height'] = typeof minSize[1] === 'number' ? minSize[1] + 'px' : minSize[1]; 75 | } 76 | return style; 77 | }); 78 | 79 | return () => { 80 | const {class: className = '', title, subject, showBrand, maskClosable = true, mask, backOverflowRedirect} = props; 81 | return ( 82 | <> 83 |
84 | {title && } 85 | {resize.value} 86 | {controls.value} 87 | {showBrand && ( 88 |
89 | Elux-管理系统 V1.0 90 |
91 | )} 92 |
93 | {subject &&

{subject}

} 94 | {context.slots.default!()} 95 |
96 |
97 | 105 | 106 | ); 107 | }; 108 | }, 109 | }); 110 | 111 | export default Component; 112 | -------------------------------------------------------------------------------- /src/modules/stage/views/LoginForm/index.tsx: -------------------------------------------------------------------------------- 1 | import {AlipayCircleOutlined, AliwangwangFilled, DingtalkCircleFilled, LockOutlined, MobileFilled, UserOutlined} from '@ant-design/icons-vue'; 2 | import {Link, connectStore} from '@elux/vue-web'; 3 | import {Button, Checkbox, Form, FormInstance, Input} from 'ant-design-vue'; 4 | import {defineComponent, reactive, ref} from 'vue'; 5 | import {APPState, GetActions} from '@/Global'; 6 | import DialogPage from '../../components/DialogPage'; 7 | import {LoginParams} from '../../entity'; 8 | import {getFormDecorators} from '../../utils/tools'; 9 | import styles from './index.module.less'; 10 | 11 | type HFormData = Required; 12 | 13 | const {stage: stageActions} = GetActions('stage'); 14 | 15 | interface StoreProps { 16 | fromUrl?: string; 17 | } 18 | 19 | function mapStateToProps(appState: APPState): StoreProps { 20 | const {fromUrl} = appState.stage!; 21 | return { 22 | fromUrl, 23 | }; 24 | } 25 | 26 | const Component = defineComponent({ 27 | name: styles.root, 28 | setup() { 29 | const storeProps = connectStore(mapStateToProps); 30 | const formRef = ref(); 31 | const formState = reactive({ 32 | username: 'admin', 33 | password: '123456', 34 | keep: false, 35 | }); 36 | 37 | const fromDecorators = getFormDecorators({ 38 | username: {rules: [{required: true, message: '请输入用户名!', whitespace: true}]}, 39 | password: { 40 | rules: [{required: true, message: '请输入密码!', whitespace: true}], 41 | }, 42 | keep: {valuePropName: 'checked'}, 43 | }); 44 | const onSubmit = (values: HFormData) => { 45 | const result = storeProps.dispatch(stageActions.login(values)) as Promise; 46 | result.catch(({message}) => { 47 | //console.log(message); 48 | }); 49 | }; 50 | const onCancel = () => { 51 | storeProps.dispatch(stageActions.cancelLogin()); 52 | }; 53 | 54 | return () => { 55 | const {fromUrl} = storeProps; 56 | return ( 57 | 58 |
59 |
60 | 61 | } placeholder="请输入用户名" /> 62 | 63 | 64 | } placeholder="请输入密码" /> 65 | 66 | 67 | 68 | 记住登录 69 | 70 | 71 | 忘记密码? 72 | 73 | 74 | 75 |
76 | 79 | 82 |
83 |
84 |
85 | 102 |
103 |
104 | ); 105 | }; 106 | }, 107 | }); 108 | 109 | export default Component; 110 | -------------------------------------------------------------------------------- /src/modules/stage/components/MSearch/index.tsx: -------------------------------------------------------------------------------- 1 | import {DownOutlined, UpOutlined} from '@ant-design/icons-vue'; 2 | import {Button, Form} from 'ant-design-vue'; 3 | import {cloneVNode, computed, defineComponent, ref, shallowReactive} from 'vue'; 4 | import {SearchFromItems} from '../../utils/tools'; 5 | import styles from './index.module.less'; 6 | 7 | interface Props> { 8 | class?: string; 9 | items: SearchFromItems; 10 | values: TFormData; 11 | onSearch: (values: Partial) => void; 12 | fixedFields?: Partial; //固定搜索值 13 | senior?: number; //未展开时显示多少项 14 | cols?: number; //每行显示多少项 15 | expand?: boolean; 16 | } 17 | 18 | const Component = defineComponent({ 19 | name: styles.root, 20 | props: ['class', 'items', 'values', 'onSearch', 'fixedFields', 'senior', 'cols', 'expand'] as any, 21 | emits: ['search'], 22 | setup(props, {emit}) { 23 | const cols = computed(() => { 24 | const {cols = 4, items} = props; 25 | const cArr: number[] = []; 26 | let cur = 0; 27 | items.forEach((item) => { 28 | // eslint-disable-next-line no-control-regex 29 | const label = Math.ceil(item.label!.replace(/[^\x00-\xff]/g, 'aa').length / 2); 30 | const col = item.col || 1; 31 | if (cur + col > cols) { 32 | cur = 0; 33 | } 34 | item.cite = cur; 35 | if (label > (cArr[cur] || 0)) { 36 | cArr[cur] = label; 37 | } 38 | cur += col; 39 | }); 40 | return cArr; 41 | }); 42 | const colWidth = computed(() => { 43 | const {cols = 4} = props; 44 | return parseFloat((100 / cols).toFixed(2)); 45 | }); 46 | const expand = ref(!!props.expand); 47 | 48 | const config = computed(() => { 49 | const {senior = 4} = props; 50 | const shrink = expand.value ? props.items.length : senior; 51 | return shallowReactive({senior, shrink}); 52 | }); 53 | const formStateRef = computed(() => { 54 | const data = props.items.reduce((obj, item) => { 55 | obj[item.name] = props.values[item.name]; 56 | return obj; 57 | }, {}); 58 | return shallowReactive(data); 59 | }); 60 | const onClear = () => { 61 | emit('search', props.fixedFields || {}); 62 | }; 63 | const onFinish = (vals: Record) => { 64 | Object.assign(vals, props.fixedFields); 65 | emit('search', vals); 66 | }; 67 | const toggle = () => { 68 | expand.value = !expand.value; 69 | }; 70 | 71 | return () => { 72 | const {class: className = '', items, fixedFields} = props; 73 | const {shrink, senior} = config.value; 74 | const formState = formStateRef.value; 75 | return ( 76 |
77 |
78 | {items.map((item, index) => { 79 | return ( 80 | = shrink ? 'none' : 'flex', width: `${colWidth.value * (item.col || 1)}%`}} 84 | key={item.name} 85 | label={ 86 | 87 | {item.label} 88 | 89 | } 90 | > 91 | {cloneVNode(item.formItem, { 92 | disabled: !!(fixedFields && fixedFields[item.name]), 93 | value: formState[item.name], 94 | 'onUpdate:value': ($event: any) => (formState[item.name] = $event), 95 | })} 96 | 97 | ); 98 | })} 99 |
100 | 103 | 104 | {items.length > senior && ( 105 | 106 | {expand.value ? '收起' : '展开'} {expand.value ? : } 107 | 108 | )} 109 |
110 |
111 |
112 | ); 113 | }; 114 | }, 115 | }) as any; 116 | 117 | export default Component as (props: Props) => JSX.Element; 118 | -------------------------------------------------------------------------------- /mock/src/routes/article.ts: -------------------------------------------------------------------------------- 1 | import {Router} from 'express'; 2 | import {database} from '../database'; 3 | import type {IAlterItems, ICreateItem, IGetItem, IGetList, ListItem, Status} from '@elux-admin-antd/article/entity'; 4 | 5 | type Query = {[K in keyof T]: string}; 6 | 7 | const router = Router(); 8 | 9 | router.get('/', function ({query}: {query: Query}, res, next) { 10 | const {pageCurrent, pageSize, title, author, editor, status, sorterField, sorterOrder} = { 11 | pageCurrent: parseInt(query.pageCurrent || '1'), 12 | pageSize: parseInt(query.pageSize || '10'), 13 | title: query.title || '', 14 | author: (query.author || '').split(',', 1)[0], 15 | editor: (query.editor || '').split(',', 1)[0], 16 | status: query.status || '', 17 | sorterField: query.sorterField || '', 18 | sorterOrder: query.sorterOrder || '', 19 | }; 20 | 21 | const start = (pageCurrent - 1) * pageSize; 22 | const end = start + pageSize; 23 | 24 | const dataMap = database.articles; 25 | let listData = Object.keys(dataMap) 26 | .reverse() 27 | .map((id) => { 28 | return dataMap[id]; 29 | }); 30 | 31 | if (title) { 32 | listData = listData.filter((item) => item.title.includes(title)); 33 | } 34 | if (author) { 35 | listData = listData.filter((item) => item.author.split(',', 1)[0] === author); 36 | } 37 | if (editor) { 38 | listData = listData.filter( 39 | (item) => item.editors[0].split(',', 1)[0] === editor || (item.editors[1] && item.editors[1].split(',', 1)[0] === editor) 40 | ); 41 | } 42 | if (status) { 43 | listData = listData.filter((item) => item.status === status); 44 | } 45 | 46 | if (sorterField === 'createdTime') { 47 | if (sorterOrder === 'ascend') { 48 | listData.sort((a: ListItem, b: ListItem) => { 49 | return a.createdTime - b.createdTime; 50 | }); 51 | } else if (sorterOrder === 'descend') { 52 | listData.sort((a, b) => { 53 | return b.createdTime - a.createdTime; 54 | }); 55 | } 56 | } 57 | if (sorterField === 'author') { 58 | if (sorterOrder === 'ascend') { 59 | listData.sort((a: ListItem, b: ListItem) => { 60 | return a.author.split(',')[1].charCodeAt(0) - b.author.split(',')[1].charCodeAt(0); 61 | }); 62 | } else if (sorterOrder === 'descend') { 63 | listData.sort((a, b) => { 64 | return b.author.split(',')[1].charCodeAt(0) - a.author.split(',')[1].charCodeAt(0); 65 | }); 66 | } 67 | } 68 | 69 | const result: IGetList['Response'] = { 70 | listSummary: { 71 | pageCurrent, 72 | pageSize, 73 | totalItems: listData.length, 74 | totalPages: Math.ceil(listData.length / pageSize), 75 | }, 76 | list: listData.slice(start, end).map((item) => ({...item, summary: '', content: ''})), 77 | }; 78 | 79 | setTimeout(() => res.json(result), 500); 80 | }); 81 | 82 | router.get('/:id', function ({params}: {params: IGetItem['Request']}, res, next) { 83 | const {id} = params; 84 | const item = database.articles[id]; 85 | if (!item) { 86 | res.status(404).end(); 87 | } else { 88 | const result: IGetItem['Response'] = item; 89 | setTimeout(() => res.json(result), 500); 90 | } 91 | }); 92 | 93 | router.put('/:id', function ({params, body}: {params: {id: string}; body: IAlterItems['Request']['data']}, res, next) { 94 | const ids = params.id.split(','); 95 | ids.forEach((id) => { 96 | const item = database.articles[id]; 97 | if (item) { 98 | Object.assign(item, body); 99 | } 100 | }); 101 | setTimeout(() => res.json({}), 500); 102 | }); 103 | 104 | router.post('/', function ({body}: {body: ICreateItem['Request']}, res, next) { 105 | const id = (Object.keys(database.articles).length + 1).toString(); 106 | const members = database.members; 107 | const memberId = Object.keys(database.members).pop() as string; 108 | const author = members[memberId]; 109 | database.articles[id] = {...body, author: [author.id, author.name].join(','), createdTime: Date.now(), status: 'pending' as Status, id}; 110 | author.articles++; 111 | const result: ICreateItem['Response'] = {id}; 112 | setTimeout(() => res.json(result), 500); 113 | }); 114 | 115 | router.delete('/:id', function ({params}: {params: {id: string}}, res, next) { 116 | const ids = params.id.split(','); 117 | ids.forEach((id) => { 118 | delete database.articles[id]; 119 | }); 120 | setTimeout(() => res.json({}), 500); 121 | }); 122 | 123 | export default router; 124 | -------------------------------------------------------------------------------- /mock/src/routes/member.ts: -------------------------------------------------------------------------------- 1 | import {Router} from 'express'; 2 | import {database} from '../database'; 3 | import type {IAlterItems, ICreateItem, IGetItem, IGetList, ListItem} from '@elux-admin-antd/member/entity'; 4 | 5 | type Query = {[K in keyof T]: string}; 6 | 7 | const router = Router(); 8 | 9 | router.get('/', function ({query}: {query: Query}, res, next) { 10 | const {pageCurrent, pageSize, name, nickname, role, status, email, sorterField, sorterOrder} = { 11 | pageCurrent: parseInt(query.pageCurrent || '1'), 12 | pageSize: parseInt(query.pageSize || '10'), 13 | name: query.name || '', 14 | nickname: query.nickname || '', 15 | role: query.role || '', 16 | status: query.status || '', 17 | email: query.email || '', 18 | sorterField: query.sorterField || '', 19 | sorterOrder: query.sorterOrder || '', 20 | }; 21 | 22 | const start = (pageCurrent - 1) * pageSize; 23 | const end = start + pageSize; 24 | 25 | const dataMap = database.members; 26 | let listData = Object.keys(dataMap) 27 | .reverse() 28 | .map((id) => { 29 | return dataMap[id]; 30 | }); 31 | 32 | if (name) { 33 | listData = listData.filter((item) => item.name.includes(name)); 34 | } 35 | if (nickname) { 36 | listData = listData.filter((item) => item.nickname.includes(nickname)); 37 | } 38 | if (role) { 39 | listData = listData.filter((item) => item.role === role); 40 | } 41 | if (status) { 42 | listData = listData.filter((item) => item.status === status); 43 | } 44 | if (email) { 45 | listData = listData.filter((item) => item.email === email); 46 | } 47 | 48 | if (sorterField === 'createdTime') { 49 | if (sorterOrder === 'ascend') { 50 | listData.sort((a: ListItem, b: ListItem) => { 51 | return a.createdTime - b.createdTime; 52 | }); 53 | } else if (sorterOrder === 'descend') { 54 | listData.sort((a, b) => { 55 | return b.createdTime - a.createdTime; 56 | }); 57 | } 58 | } 59 | if (sorterField === 'name') { 60 | if (sorterOrder === 'ascend') { 61 | listData.sort((a: ListItem, b: ListItem) => { 62 | return a.name.charCodeAt(0) - b.name.charCodeAt(0); 63 | }); 64 | } else if (sorterOrder === 'descend') { 65 | listData.sort((a, b) => { 66 | return b.name.charCodeAt(0) - a.name.charCodeAt(0); 67 | }); 68 | } 69 | } 70 | if (sorterField === 'articles') { 71 | if (sorterOrder === 'ascend') { 72 | listData.sort((a: ListItem, b: ListItem) => { 73 | return a.articles - b.articles; 74 | }); 75 | } else if (sorterOrder === 'descend') { 76 | listData.sort((a, b) => { 77 | return b.articles - a.articles; 78 | }); 79 | } 80 | } 81 | 82 | const result: IGetList['Response'] = { 83 | listSummary: { 84 | pageCurrent, 85 | pageSize, 86 | totalItems: listData.length, 87 | totalPages: Math.ceil(listData.length / pageSize), 88 | }, 89 | list: listData.slice(start, end).map((item) => ({...item, content: ''})), 90 | }; 91 | 92 | setTimeout(() => res.json(result), 500); 93 | }); 94 | 95 | router.get('/:id', function ({params}: {params: IGetItem['Request']}, res, next) { 96 | const {id} = params; 97 | const item = database.members[id]; 98 | if (!item) { 99 | res.status(404).end(); 100 | } else { 101 | const result: IGetItem['Response'] = item; 102 | setTimeout(() => res.json(result), 500); 103 | } 104 | }); 105 | 106 | router.put('/:id', function ({params, body}: {params: {id: string}; body: IAlterItems['Request']['data']}, res, next) { 107 | const ids = params.id.split(','); 108 | ids.forEach((id) => { 109 | const item = database.members[id]; 110 | if (item) { 111 | Object.assign(item, body); 112 | } 113 | }); 114 | setTimeout(() => res.json({}), 500); 115 | }); 116 | 117 | router.post('/', function ({body}: {body: ICreateItem['Request']}, res, next) { 118 | const id = (Object.keys(database.members).length + 1).toString(); 119 | database.members[id] = {...body, articles: 0, createdTime: Date.now(), id}; 120 | const result: ICreateItem['Response'] = {id}; 121 | setTimeout(() => res.json(result), 500); 122 | }); 123 | 124 | router.delete('/:id', function ({params}: {params: {id: string}}, res, next) { 125 | const ids = params.id.split(','); 126 | ids.forEach((id) => { 127 | delete database.members[id]; 128 | }); 129 | setTimeout(() => res.json({}), 500); 130 | }); 131 | 132 | export default router; 133 | -------------------------------------------------------------------------------- /src/modules/stage/views/ForgotPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import {LockOutlined, MobileOutlined, NumberOutlined} from '@ant-design/icons-vue'; 2 | import {Link, connectStore} from '@elux/vue-web'; 3 | import {Button, Form, FormInstance, Input} from 'ant-design-vue'; 4 | import {defineComponent, onBeforeUnmount, reactive, ref} from 'vue'; 5 | import {GetActions} from '@/Global'; 6 | import DialogPage from '../../components/DialogPage'; 7 | import {ResetPasswordParams} from '../../entity'; 8 | import {getFormDecorators} from '../../utils/tools'; 9 | import styles from './index.module.less'; 10 | 11 | interface HFormData extends Required { 12 | confirm: string; 13 | } 14 | 15 | const {stage: stageActions} = GetActions('stage'); 16 | 17 | const Component = defineComponent({ 18 | name: styles.root, 19 | setup() { 20 | const storeProps = connectStore(); 21 | const formRef = ref(); 22 | const formState = reactive({ 23 | phone: '', 24 | password: '', 25 | confirm: '', 26 | captcha: '', 27 | }); 28 | const countDown = ref(0); 29 | onBeforeUnmount(() => (countDown.value = 0)); 30 | const checkCountDown = () => { 31 | if (countDown.value > 0) { 32 | countDown.value--; 33 | setTimeout(checkCountDown, 1000); 34 | } 35 | }; 36 | 37 | const sendCaptcha = async () => { 38 | const phone = formState.phone; 39 | if (!phone) { 40 | formRef.value?.validateFields('phone'); 41 | } else { 42 | await storeProps.dispatch(stageActions.sendCaptcha({phone})); 43 | countDown.value = 60; 44 | setTimeout(checkCountDown, 1000); 45 | } 46 | }; 47 | const onSubmit = (values: HFormData) => { 48 | storeProps.dispatch(stageActions.resetPassword(values)); 49 | }; 50 | const confirmValidator = (rules: any, value: string) => { 51 | if (!value || formState.password === value) { 52 | return Promise.resolve(); 53 | } 54 | return Promise.reject('2次密码输入不一致!'); 55 | }; 56 | const fromDecorators = getFormDecorators({ 57 | phone: {rules: [{required: true, message: '请输入注册手机号!', whitespace: true}]}, 58 | password: {rules: [{required: true, message: '请输入新密码!', whitespace: true}]}, 59 | captcha: {rules: [{required: true, message: '请输入短信验证码!', whitespace: true}]}, 60 | confirm: {rules: [{required: true, message: '请再次输入密码!', whitespace: true}, {validator: confirmValidator}]}, 61 | }); 62 | return () => { 63 | return ( 64 | 65 |
66 |
67 | 68 | } placeholder="注册手机" /> 69 | 70 | 71 | } 75 | placeholder="新密码" 76 | autocomplete="new-password" 77 | /> 78 | 79 | 80 | } 84 | placeholder="确认密码" 85 | autocomplete="new-password" 86 | /> 87 | 88 | 89 | 90 | } placeholder="短信验证码" style="width:250px" /> 91 | 92 | 95 | 96 | 97 |
98 | 101 | 102 | 103 | 104 |
105 |
106 |
107 |
108 |
109 | ); 110 | }; 111 | }, 112 | }); 113 | 114 | export default Component; 115 | -------------------------------------------------------------------------------- /src/modules/stage/views/RegistryForm/index.tsx: -------------------------------------------------------------------------------- 1 | import {AliwangwangFilled, LockOutlined, UserOutlined} from '@ant-design/icons-vue'; 2 | import {Link, connectStore} from '@elux/vue-web'; 3 | import {Button, Checkbox, Form, FormInstance, Input} from 'ant-design-vue'; 4 | import {defineComponent, reactive, ref} from 'vue'; 5 | import {APPState, GetActions} from '@/Global'; 6 | import DialogPage from '../../components/DialogPage'; 7 | import {RegisterParams} from '../../entity'; 8 | import {LoginUrl} from '../../utils/const'; 9 | import {getFormDecorators} from '../../utils/tools'; 10 | import styles from './index.module.less'; 11 | 12 | interface HFormData extends Required { 13 | confirm: string; 14 | agreement: boolean; 15 | } 16 | 17 | const agreementChecked = (rule: any, value: string) => { 18 | if (!value) { 19 | return Promise.reject('您必须同意注册协议!'); 20 | } 21 | return Promise.resolve(); 22 | }; 23 | 24 | const {stage: stageActions} = GetActions('stage'); 25 | 26 | interface StoreProps { 27 | fromUrl?: string; 28 | } 29 | 30 | function mapStateToProps(appState: APPState): StoreProps { 31 | const {fromUrl} = appState.stage!; 32 | return { 33 | fromUrl, 34 | }; 35 | } 36 | 37 | const Component = defineComponent({ 38 | name: styles.root, 39 | setup() { 40 | const storeProps = connectStore(mapStateToProps); 41 | const formRef = ref(); 42 | const formState = reactive({ 43 | username: '', 44 | password: '', 45 | confirm: '', 46 | agreement: false, 47 | }); 48 | const confirmValidator = (rules: any, value: string) => { 49 | if (!value || formState.password === value) { 50 | return Promise.resolve(); 51 | } 52 | return Promise.reject('2次密码输入不一致!'); 53 | }; 54 | const fromDecorators = getFormDecorators({ 55 | username: {rules: [{required: true, message: '请输入用户名!', whitespace: true}]}, 56 | password: {rules: [{required: true, message: '请输入密码!', whitespace: true}]}, 57 | agreement: {rules: [{validator: agreementChecked}]}, 58 | confirm: {rules: [{required: true, message: '请再次输入密码!', whitespace: true}, {validator: confirmValidator}]}, 59 | }); 60 | const onSubmit = (values: HFormData) => { 61 | storeProps.dispatch(stageActions.registry(values)); 62 | }; 63 | 64 | return () => { 65 | const {fromUrl} = storeProps; 66 | return ( 67 | 68 |
69 |
70 | 71 | } placeholder="用户名" /> 72 | 73 | 74 | } 78 | placeholder="密码" 79 | autocomplete="new-password" 80 | /> 81 | 82 | 83 | } 87 | placeholder="确认密码" 88 | autocomplete="new-password" 89 | /> 90 | 91 |
92 | 93 | 我已阅读并同意 94 | 95 | 96 | 注册协议 97 | 98 |
99 | 100 |
101 | 104 | 105 | 106 | 107 |
108 |
109 |
110 | 116 |
117 |
118 | ); 119 | }; 120 | }, 121 | }); 122 | 123 | export default Component; 124 | -------------------------------------------------------------------------------- /src/modules/stage/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import {Rule} from 'ant-design-vue/lib/form'; 2 | import {VNode, shallowReactive, watch} from 'vue'; 3 | 4 | export {message} from 'ant-design-vue'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 7 | export function usePropsState>(map: () => T) { 8 | const limit = shallowReactive({} as T); 9 | watch( 10 | map, 11 | (val) => { 12 | Object.assign(limit, val); 13 | }, 14 | {immediate: true} 15 | ); 16 | return limit; 17 | } 18 | 19 | export interface FormDecorator { 20 | label?: string; 21 | dependencies?: T[]; 22 | rules?: Rule[]; 23 | valuePropName?: string; 24 | } 25 | 26 | export interface SearchFormDecorator { 27 | name: T; 28 | formItem: VNode; 29 | label: string; 30 | rules?: Rule[]; 31 | col?: number; 32 | cite?: number; 33 | } 34 | 35 | export type SearchFromItems = Array>; 36 | 37 | export function getFormDecorators(items: {[key in keyof TFormData]: FormDecorator}): { 38 | [key in keyof TFormData]: FormDecorator & {name: string}; 39 | } { 40 | Object.entries(items).forEach(([key, item]: [string, any]) => { 41 | item.name = key; 42 | }); 43 | return items as any; 44 | } 45 | 46 | export function arrayToMap(arr: T[], key = 'id'): {[key: string]: T} { 47 | return arr.reduce((pre, cur) => { 48 | pre[cur[key]] = cur; 49 | return pre; 50 | }, {}); 51 | } 52 | 53 | export function loadingPlaceholder(val: string | undefined): string { 54 | return val || '...'; 55 | } 56 | 57 | export function splitIdName(str: string): {id: string; name: string} { 58 | if (str.includes(',')) { 59 | const [id] = str.split(',', 1); 60 | return {id, name: str.replace(id + ',', '')}; 61 | } else { 62 | return {id: str, name: str}; 63 | } 64 | } 65 | 66 | // export function urlPushQuery(url: string, query: {[key: string]: any} = {}): string { 67 | // const queryStr = stringify(query); 68 | // return queryStr ? `${url}${url.indexOf('?') > -1 ? '&' : '?'}${queryStr}` : url; 69 | // } 70 | 71 | function isMapObject(obj: any): Boolean { 72 | return typeof obj === 'object' && obj !== null && !Array.isArray(obj); 73 | } 74 | 75 | export function mergeDefaultParams(defaultParams: T, targetParams: {[key: string]: any}): T { 76 | return Object.keys(defaultParams).reduce((result, key) => { 77 | const defVal = defaultParams[key]; 78 | const tgtVal = targetParams[key]; 79 | if (tgtVal == undefined) { 80 | result[key] = defVal; 81 | } else if (isMapObject(defVal) && isMapObject(tgtVal)) { 82 | result[key] = mergeDefaultParams(defVal, tgtVal); 83 | } else { 84 | result[key] = tgtVal; 85 | } 86 | return result; 87 | }, {}) as any; 88 | } 89 | 90 | export function excludeDefaultParams(defaultParams: {[key: string]: any}, targetParams: {[key: string]: any}): {[key: string]: any} | undefined { 91 | const result: any = {}; 92 | let hasSub = false; 93 | Object.keys(targetParams).forEach((key) => { 94 | let tgtVal = targetParams[key]; 95 | const defVal = defaultParams[key]; 96 | if (tgtVal !== defVal) { 97 | if (isMapObject(defVal) && isMapObject(tgtVal)) { 98 | tgtVal = excludeDefaultParams(defVal, tgtVal); 99 | } 100 | if (tgtVal !== undefined) { 101 | hasSub = true; 102 | result[key] = tgtVal; 103 | } 104 | } 105 | }); 106 | return hasSub ? result : undefined; 107 | } 108 | 109 | // fork from 'fast-deep-equal' 110 | export function deepEqual(a: any, b: any): boolean { 111 | if (a === b) return true; 112 | 113 | if (a && b && typeof a == 'object' && typeof b == 'object') { 114 | if (a.constructor !== b.constructor) return false; 115 | 116 | let length: number, i: number; 117 | if (Array.isArray(a)) { 118 | length = a.length; 119 | if (length != b.length) return false; 120 | for (i = length; i-- !== 0; ) if (!deepEqual(a[i], b[i])) return false; 121 | return true; 122 | } 123 | 124 | if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; 125 | if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); 126 | if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); 127 | 128 | const keys = Object.keys(a); 129 | length = keys.length; 130 | if (length !== Object.keys(b).length) return false; 131 | 132 | for (i = length; i-- !== 0; ) if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; 133 | 134 | for (i = length; i-- !== 0; ) { 135 | const key = keys[i]; 136 | 137 | if (!deepEqual(a[key], b[key])) return false; 138 | } 139 | 140 | return true; 141 | } 142 | 143 | return a !== a && b !== b; 144 | } 145 | -------------------------------------------------------------------------------- /src/modules/stage/components/MSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import {CloseCircleFilled, CloseOutlined, FullscreenOutlined} from '@ant-design/icons-vue'; 2 | import {Form} from 'ant-design-vue'; 3 | import {computed, defineComponent} from 'vue'; 4 | import {useRouter} from '@/Global'; 5 | import {BaseLocationState} from '../../utils/base'; 6 | import {DialogPageClassname} from '../../utils/const'; 7 | import styles from './index.module.less'; 8 | 9 | interface Props> { 10 | class?: string; 11 | selectorPathname: string; 12 | showSearch?: boolean; 13 | fixedSearch?: Partial; 14 | limit?: number | [number, number]; 15 | placeholder?: string; 16 | rowKey?: string; 17 | rowName?: string; 18 | value?: string | string[]; 19 | returnArray?: boolean; 20 | onChange?: (value?: string | string[]) => void; 21 | } 22 | 23 | const Component = defineComponent({ 24 | name: styles.root, 25 | props: [ 26 | 'class', 27 | 'selectorPathname', 28 | 'showSearch', 29 | 'fixedSearch', 30 | 'limit', 31 | 'placeholder', 32 | 'rowKey', 33 | 'rowName', 34 | 'value', 35 | 'returnArray', 36 | 'onChange', 37 | ] as any, 38 | emits: ['update:value'], 39 | setup(props, {emit}) { 40 | const formItemContext = Form.useInjectFormItemContext(); 41 | const triggerChange = (changedValue: string | string[]) => { 42 | emit('update:value', changedValue); 43 | formItemContext.onFieldChange(); 44 | }; 45 | const selectedRows = computed(() => { 46 | const {value, rowKey = 'id', rowName = 'name'} = props; 47 | const arr = value ? (typeof value === 'string' ? [value] : value) : []; 48 | return arr.map((item) => { 49 | const [id, ...others] = item.split(','); 50 | const name = others.join(','); 51 | return {[rowKey]: id, [rowName]: name || id}; 52 | }); 53 | }); 54 | const removeItem = (index: number) => { 55 | const {rowKey = 'id', rowName = 'name', returnArray} = props; 56 | const rows = selectedRows.value 57 | .slice(0, index) 58 | .concat(selectedRows.value.slice(index + 1)) 59 | .map((row) => [row[rowKey], row[rowName]].join(',')); 60 | triggerChange(rows.length === 1 && !returnArray ? rows[0] : rows); 61 | }; 62 | const onSelectedSubmit = (selectedItems: Record[]) => { 63 | const {rowKey = 'id', rowName = 'name', returnArray} = props; 64 | const rows = selectedItems.map((item) => [item[rowKey], item[rowName]].filter(Boolean).join(',')); 65 | triggerChange(rows.length === 1 && !returnArray ? rows[0] : rows); 66 | }; 67 | const removeAll = () => { 68 | const {returnArray} = props; 69 | triggerChange(returnArray ? [] : ''); 70 | }; 71 | const router = useRouter(); 72 | 73 | const onSelect = () => { 74 | const {limit, selectorPathname, showSearch, fixedSearch} = props; 75 | const state: BaseLocationState = {selectLimit: limit, selectedRows: selectedRows.value, showSearch, fixedSearch, onSelectedSubmit}; 76 | router.push({pathname: selectorPathname, searchQuery: fixedSearch, classname: DialogPageClassname, state}, 'window'); 77 | }; 78 | 79 | const children = computed(() => { 80 | const {rowKey = 'id', rowName = 'name', placeholder} = props; 81 | return ( 82 | <> 83 |
84 |
85 | {selectedRows.value.map((row, index) => ( 86 |
87 | 88 | {row[rowName]} 89 | 100 | 101 |
102 | ))} 103 |
104 | {placeholder && selectedRows.value.length < 1 && {placeholder}} 105 |
106 | 109 | {selectedRows.value.length > 0 && ( 110 | 113 | )} 114 | 115 | ); 116 | }); 117 | 118 | return () => { 119 | const {class: className = ''} = props; 120 | return ( 121 |
{children.value}
122 | ); 123 | }; 124 | }, 125 | }) as any; 126 | 127 | export default Component as (props: Props) => JSX.Element; 128 | -------------------------------------------------------------------------------- /src/modules/article/views/ListTable.tsx: -------------------------------------------------------------------------------- 1 | import MTable, {MBatchActions, MColumns, MSelection} from '@elux-admin-antd/stage/components/MTable'; 2 | import {DialogPageClassname} from '@elux-admin-antd/stage/utils/const'; 3 | import {useSingleWindow, useTableChange, useTableSize} from '@elux-admin-antd/stage/utils/resource'; 4 | import {splitIdName} from '@elux-admin-antd/stage/utils/tools'; 5 | import {Link, LoadingState, connectStore} from '@elux/vue-web'; 6 | import {Tooltip} from 'ant-design-vue'; 7 | import {ColumnProps} from 'ant-design-vue/lib/table'; 8 | import {VNode, computed, defineComponent} from 'vue'; 9 | import {APPState} from '@/Global'; 10 | import {DStatus, ListItem, ListSearch, ListSummary, Status, defaultListSearch} from '../entity'; 11 | 12 | interface StoreProps { 13 | listSearch: ListSearch; 14 | list?: ListItem[]; 15 | listSummary?: ListSummary; 16 | listLoading?: LoadingState; 17 | } 18 | 19 | interface Props { 20 | listPathname: string; 21 | mergeColumns?: {[field: string]: MColumns}; 22 | actionColumns?: ColumnProps; 23 | commonActions?: VNode; 24 | batchActions?: MBatchActions; 25 | selectedRows?: Partial[]; 26 | selection?: MSelection; 27 | } 28 | 29 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 30 | const {listSearch, list, listSummary, listLoading} = state.article!; 31 | return {listSearch, list, listSummary, listLoading}; 32 | }; 33 | 34 | const Component = defineComponent({ 35 | props: ['listPathname', 'mergeColumns', 'actionColumns', 'commonActions', 'batchActions', 'selectedRows', 'selection'] as any, 36 | setup(props) { 37 | const storeProps = connectStore(mapStateToProps); 38 | const onTableChange = useTableChange(storeProps, props.listPathname, defaultListSearch); 39 | const singleWindow = useSingleWindow(); 40 | const tableSize = useTableSize(); 41 | const columns = computed[]>(() => { 42 | const {actionColumns, mergeColumns} = props; 43 | const cols: MColumns[] = [ 44 | { 45 | title: '标题', 46 | dataIndex: 'title', 47 | ellipsis: {showTitle: false}, 48 | customRender: ({value, record}: {value: string; record: {id: string}}) => ( 49 | 50 | 51 | {value} 52 | 53 | 54 | ), 55 | }, 56 | { 57 | title: '作者', 58 | dataIndex: 'author', 59 | width: '10%', 60 | sorter: true, 61 | customRender: ({value}: {value: string}) => { 62 | const {id, name} = splitIdName(value); 63 | return ( 64 | 65 | {name} 66 | 67 | ); 68 | }, 69 | }, 70 | { 71 | title: '责任编辑', 72 | dataIndex: 'editors', 73 | width: '20%', 74 | className: 'g-actions', 75 | customRender: ({value}: {value: string[]}) => 76 | value.map((editor) => { 77 | const {id, name} = splitIdName(editor); 78 | return ( 79 | 80 | {name} 81 | 82 | ); 83 | }), 84 | }, 85 | { 86 | title: '创建时间', 87 | dataIndex: 'createdTime', 88 | width: '200px', 89 | sorter: true, 90 | timestamp: true, 91 | }, 92 | { 93 | title: '状态', 94 | dataIndex: 'status', 95 | width: '100px', 96 | customRender: ({value}: {value: string}) => ( 97 | 98 | {DStatus.valueToLabel[value]} 99 | 100 | ), 101 | }, 102 | ]; 103 | 104 | if (actionColumns) { 105 | cols.push(actionColumns); 106 | } 107 | if (mergeColumns) { 108 | cols.forEach((col) => { 109 | const field = col.dataIndex as string; 110 | if (field && mergeColumns[field]) { 111 | Object.assign(col, mergeColumns[field]); 112 | } 113 | }); 114 | } 115 | return cols; 116 | }); 117 | 118 | return () => { 119 | const {commonActions, batchActions, selectedRows, selection} = props; 120 | const {listSearch, list, listSummary, listLoading} = storeProps; 121 | return ( 122 | 123 | size={tableSize} 124 | commonActions={commonActions} 125 | batchActions={batchActions} 126 | onChange={onTableChange} 127 | selectedRows={selectedRows} 128 | columns={columns.value} 129 | listSearch={listSearch} 130 | dataSource={list} 131 | listSummary={listSummary} 132 | selection={selection} 133 | loading={listLoading === 'Start' || listLoading === 'Depth'} 134 | /> 135 | ); 136 | }; 137 | }, 138 | }); 139 | 140 | export default Component; 141 | -------------------------------------------------------------------------------- /public/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 175 | 176 | 177 |
178 |
179 | 180 | 181 | -------------------------------------------------------------------------------- /src/modules/member/views/ListTable.tsx: -------------------------------------------------------------------------------- 1 | import MTable, {MBatchActions, MColumns, MSelection} from '@elux-admin-antd/stage/components/MTable'; 2 | import {DialogPageClassname} from '@elux-admin-antd/stage/utils/const'; 3 | import {useSingleWindow, useTableChange, useTableSize} from '@elux-admin-antd/stage/utils/resource'; 4 | import {Link, LoadingState, connectStore} from '@elux/vue-web'; 5 | import {Tooltip} from 'ant-design-vue'; 6 | import {ColumnProps} from 'ant-design-vue/lib/table'; 7 | import {VNode, computed, defineComponent} from 'vue'; 8 | import {APPState} from '@/Global'; 9 | import {DGender, DRole, DStatus, ListItem, ListSearch, ListSummary, defaultListSearch} from '../entity'; 10 | 11 | interface StoreProps { 12 | listSearch: ListSearch; 13 | list?: ListItem[]; 14 | listSummary?: ListSummary; 15 | listLoading?: LoadingState; 16 | } 17 | 18 | interface Props { 19 | listPathname: string; 20 | mergeColumns?: {[field: string]: MColumns}; 21 | actionColumns?: ColumnProps; 22 | commonActions?: VNode; 23 | batchActions?: MBatchActions; 24 | selectedRows?: Partial[]; 25 | selection?: MSelection; 26 | } 27 | 28 | const mapStateToProps: (state: APPState) => StoreProps = (state) => { 29 | const {listSearch, list, listSummary, listLoading} = state.member!; 30 | return {listSearch, list, listSummary, listLoading}; 31 | }; 32 | 33 | const Component = defineComponent({ 34 | props: ['listPathname', 'mergeColumns', 'actionColumns', 'commonActions', 'batchActions', 'selectedRows', 'selection'] as any, 35 | setup(props) { 36 | const storeProps = connectStore(mapStateToProps); 37 | const onTableChange = useTableChange(storeProps, props.listPathname, defaultListSearch); 38 | const singleWindow = useSingleWindow(); 39 | const tableSize = useTableSize(); 40 | const columns = computed[]>(() => { 41 | const {actionColumns, mergeColumns} = props; 42 | const cols: MColumns[] = [ 43 | { 44 | title: '用户名', 45 | dataIndex: 'name', 46 | width: '10%', 47 | sorter: true, 48 | customRender: ({value, record}: {value: string; record: {id: string}}) => ( 49 | 50 | {value} 51 | 52 | ), 53 | }, 54 | { 55 | title: '呢称', 56 | dataIndex: 'nickname', 57 | width: '10%', 58 | }, 59 | { 60 | title: '角色', 61 | dataIndex: 'role', 62 | width: '10%', 63 | customRender: ({value}: {value: string}) => DRole.valueToLabel[value], 64 | }, 65 | { 66 | title: '性别', 67 | dataIndex: 'gender', 68 | align: 'center', 69 | width: '100px', 70 | customRender: ({value}: {value: string}) => DGender.valueToLabel[value], 71 | }, 72 | { 73 | title: '文章数', 74 | dataIndex: 'articles', 75 | align: 'center', 76 | sorter: true, 77 | width: '120px', 78 | customRender: ({value, record}: {value: string; record: {id: string}}) => ( 79 | 80 | {value} 81 | 82 | ), 83 | }, 84 | { 85 | title: 'Email', 86 | dataIndex: 'email', 87 | ellipsis: {showTitle: false}, 88 | customRender: ({value}: {value: string}) => ( 89 | 90 | {value} 91 | 92 | ), 93 | }, 94 | { 95 | title: '注册时间', 96 | dataIndex: 'createdTime', 97 | width: '200px', 98 | sorter: true, 99 | timestamp: true, 100 | }, 101 | { 102 | title: '状态', 103 | dataIndex: 'status', 104 | width: '100px', 105 | customRender: ({value}: {value: string}) => {DStatus.valueToLabel[value]}, 106 | }, 107 | ]; 108 | if (actionColumns) { 109 | cols.push(actionColumns); 110 | } 111 | if (mergeColumns) { 112 | cols.forEach((col) => { 113 | const field = col.dataIndex as string; 114 | if (field && mergeColumns[field]) { 115 | Object.assign(col, mergeColumns[field]); 116 | } 117 | }); 118 | } 119 | return cols; 120 | }); 121 | 122 | return () => { 123 | const {commonActions, batchActions, selectedRows, selection} = props; 124 | const {listSearch, list, listSummary, listLoading} = storeProps; 125 | return ( 126 | 127 | size={tableSize} 128 | commonActions={commonActions} 129 | batchActions={batchActions} 130 | onChange={onTableChange} 131 | selectedRows={selectedRows} 132 | columns={columns.value} 133 | listSearch={listSearch} 134 | dataSource={list} 135 | listSummary={listSummary} 136 | selection={selection} 137 | loading={listLoading === 'Start' || listLoading === 'Depth'} 138 | /> 139 | ); 140 | }; 141 | }, 142 | }); 143 | 144 | export default Component; 145 | -------------------------------------------------------------------------------- /src/modules/admin/model.ts: -------------------------------------------------------------------------------- 1 | //定义本模块的业务模型 2 | import {BaseModel, effect, reducer} from '@elux/vue-web'; 3 | import {pathToRegexp} from 'path-to-regexp'; 4 | import {APPState} from '@/Global'; 5 | import api from './api'; 6 | import {MenuData, Notices, SubModule} from './entity'; 7 | import type {Tab, TabData} from './entity'; 8 | 9 | //定义本模块的状态结构 10 | export interface ModuleState { 11 | subModule?: SubModule; //该字段用来记录当前路由下展示哪个子Module 12 | notices?: Notices; //该字段用来记录实时通知信息 13 | siderCollapsed?: boolean; //左边菜单是否折叠 14 | tabEdit?: Tab; 15 | tabData: TabData; 16 | curTab: Tab; 17 | menuData: MenuData; 18 | menuSelected: {selected: string[]; open: string[]}; 19 | //该字段用来记录当前Page是否用Dialog模式展示(非全屏) 20 | //Dialog模式时将不渲染Layout 21 | dialogMode: boolean; 22 | } 23 | 24 | //定义路由中的本模块感兴趣的信息 25 | export interface RouteParams { 26 | subModule?: SubModule; 27 | } 28 | 29 | export class Model extends BaseModel { 30 | protected routeParams!: RouteParams; //保存从当前路由中提取的信息结果 31 | 32 | //因为要尽量避免使用public方法,所以构建this.privateActions来引用私有actions 33 | protected privateActions = this.getPrivateActions({}); 34 | 35 | //提取当前路由中的本模块感兴趣的信息 36 | protected getRouteParams(): RouteParams { 37 | const {pathname} = this.getRouter().location; 38 | const [, , subModuleStr = ''] = pathToRegexp('/:admin/:subModule', undefined, {end: false}).exec(pathname) || []; 39 | const subModule: SubModule | undefined = SubModule[subModuleStr] || undefined; 40 | return {subModule}; 41 | } 42 | 43 | //初始化或路由变化时都需要重新挂载Model 44 | //在此钩子中必需完成ModuleState的初始赋值,可以异步 45 | public onMount(env: 'init' | 'route' | 'update'): void | Promise { 46 | this.routeParams = this.getRouteParams(); 47 | const { 48 | location: {classname}, 49 | } = this.getRouter(); 50 | const {subModule} = this.routeParams; 51 | const curTab = this.getCurTab(); 52 | const dialogMode = classname.startsWith('_'); 53 | const prevState = this.getPrevState(); 54 | const initCallback = ([tabData, menuData]: [TabData, MenuData]) => { 55 | const menuSelected = this.matchMenu(menuData); 56 | const initState: ModuleState = { 57 | ...prevState, 58 | curTab, 59 | subModule, 60 | dialogMode, 61 | tabData, 62 | menuData, 63 | menuSelected, 64 | tabEdit: undefined, 65 | }; 66 | this.dispatch(this.privateActions._initState(initState)); 67 | }; 68 | if (prevState) { 69 | //复用之前的state 70 | return initCallback([prevState.tabData, prevState.menuData]); 71 | } else { 72 | return Promise.all([api.getTabData(), api.getMenuData()]).then(initCallback); 73 | } 74 | } 75 | 76 | @effect(null) 77 | public clickMenu(key: string): void { 78 | const link = this.state.menuData.keyToLink[key]; 79 | if (link) { 80 | if (link.startsWith('/')) { 81 | this.getRouter().push({url: link}, 'page'); 82 | } else { 83 | window.open(link); 84 | } 85 | } 86 | } 87 | 88 | @reducer 89 | public putSiderCollapsed(siderCollapsed: boolean): void { 90 | this.state.siderCollapsed = siderCollapsed; 91 | } 92 | 93 | @effect(null) 94 | public clickTab(id: string): void { 95 | if (id) { 96 | const item = this.state.tabData.maps[id]; 97 | if (id === this.state.curTab.id) { 98 | this.dispatch(this.privateActions._updateState('showTabEditor', {tabEdit: item})); 99 | } else { 100 | this.getRouter().push({url: item.url}, 'page'); 101 | } 102 | } else { 103 | this.dispatch(this.privateActions._updateState('showTabEditor', {tabEdit: {...this.state.curTab, title: document.title}})); 104 | } 105 | } 106 | 107 | @reducer 108 | public closeTabEditor(): void { 109 | this.state.tabEdit = undefined; 110 | } 111 | 112 | @effect(null) 113 | public async updateTab(item: Tab): Promise { 114 | let {list, maps} = this.state.tabData; 115 | if (maps[item.id]) { 116 | list = list.map((tab) => (tab.id === item.id ? item : tab)); 117 | } else { 118 | list = [item, ...list]; 119 | } 120 | maps = {...maps, [item.id]: item}; 121 | const tabData = {list, maps}; 122 | await api.updateTabData(tabData); 123 | this.dispatch(this.privateActions._updateState('updateTab', {tabData, tabEdit: undefined})); 124 | } 125 | 126 | @effect(null) 127 | public async deleteTab(id: string): Promise { 128 | let {list, maps} = this.state.tabData; 129 | if (maps[id]) { 130 | list = list.filter((tab) => tab.id !== id); 131 | maps = {...maps}; 132 | delete maps[id]; 133 | const tabData = {list, maps}; 134 | await api.updateTabData(tabData); 135 | this.dispatch(this.privateActions._updateState('updateTab', {tabData})); 136 | } 137 | } 138 | 139 | private getCurTab(): Tab { 140 | const {pathname, search, url} = this.getRouter().location; 141 | let id = pathname; 142 | if (search) { 143 | id += `?${search.split('&').sort().join('&')}`; 144 | } 145 | return {id, url, title: ''}; 146 | } 147 | 148 | private matchMenu({matchToKey, keyToParents}: MenuData): {selected: string[]; open: string[]} { 149 | const pathname = this.getRouter().location.pathname; 150 | for (const rule in matchToKey) { 151 | if (pathname.startsWith(rule)) { 152 | const selected = matchToKey[rule]; 153 | return {selected: [selected], open: [...keyToParents[selected]]}; 154 | } 155 | } 156 | return {selected: [], open: []}; 157 | } 158 | private hasLogin(): boolean { 159 | return this.getRootState().stage!.curUser.hasLogin; 160 | } 161 | 162 | private getNotices = () => { 163 | if (this.hasLogin()) { 164 | api.getNotices().then((notices) => { 165 | curNotices = notices; 166 | this.dispatch(this.privateActions._updateState('updataNotices', {notices})); 167 | }); 168 | } 169 | }; 170 | 171 | //页面被激活(变为显示页面)时调用 172 | onActive(): void { 173 | if (curNotices && this.state.notices !== curNotices) { 174 | this.dispatch(this.privateActions._updateState('updataNotices', {notices: curNotices})); 175 | } 176 | if (!curLoopTask) { 177 | curLoopTask = this.getNotices; 178 | curLoopTask(); 179 | } else { 180 | curLoopTask = this.getNotices; 181 | } 182 | } 183 | } 184 | 185 | let curLoopTask: (() => void) | undefined; 186 | let curNotices: Notices | undefined; 187 | setInterval(() => curLoopTask && curLoopTask(), 10000); 188 | -------------------------------------------------------------------------------- /src/modules/stage/model.ts: -------------------------------------------------------------------------------- 1 | //定义本模块的业务模型 2 | import {BaseModel, ErrorCodes, LoadingState, effect, reducer} from '@elux/vue-web'; 3 | import {pathToRegexp} from 'path-to-regexp'; 4 | import {APPState} from '@/Global'; 5 | import api, {guest} from './api'; 6 | import {CurView, SubModule} from './entity'; 7 | import {AdminHomeUrl, GuestHomeUrl, LoginUrl} from './utils/const'; 8 | import {CommonErrorCode, CustomError} from './utils/errors'; 9 | import {message} from './utils/tools'; 10 | import type {CurUser, LoginParams, RegisterParams, ResetPasswordParams, SendCaptchaParams} from './entity'; 11 | 12 | //定义本模块的状态结构 13 | export interface ModuleState { 14 | curUser: CurUser; 15 | subModule?: SubModule; //该字段用来记录当前路由下展示哪个子Module 16 | curView?: CurView; //该字段用来记录当前路由下展示本模块的哪个View 17 | fromUrl?: string; //登录或注册后返回的原来页面 18 | globalLoading?: LoadingState; //该字段用来记录一个全局的loading状态 19 | error?: string; //该字段用来记录启动错误,如果该字段有值,则不渲染其它UI 20 | } 21 | 22 | //定义路由中的本模块感兴趣的信息 23 | export interface RouteParams { 24 | pathname: string; 25 | subModule?: SubModule; 26 | curView?: CurView; 27 | fromUrl?: string; 28 | } 29 | 30 | //定义本模块的业务模型,必需继承BaseModel 31 | //模型中的属性和方法尽量使用非public 32 | export class Model extends BaseModel { 33 | protected routeParams!: RouteParams; //保存从当前路由中提取的信息结果 34 | 35 | //因为要尽量避免使用public方法,所以构建this.privateActions来引用私有actions 36 | protected privateActions = this.getPrivateActions({putCurUser: this.putCurUser}); 37 | 38 | //提取当前路由中的本模块感兴趣的信息 39 | protected getRouteParams(): RouteParams { 40 | const {pathname, searchQuery} = this.getRouter().location; 41 | const [, subModuleStr = '', curViewStr = ''] = pathToRegexp('/:subModule/:curView', undefined, {end: false}).exec(pathname) || []; 42 | const subModule: SubModule | undefined = SubModule[subModuleStr] || undefined; 43 | const curView: CurView | undefined = CurView[curViewStr] || undefined; 44 | const fromUrl: string | undefined = searchQuery.from; 45 | return {pathname, subModule, curView, fromUrl}; 46 | } 47 | 48 | //每次路由发生变化,都会引起Model重新挂载到Store 49 | //在此钩子中必需完成ModuleState的初始赋值,可以异步 50 | //在此钩子执行完成之前,本模块的View将不会Render 51 | //在此钩子中可以await数据请求,这样等所有数据拉取回来后,一次性Render 52 | //在此钩子中也可以await子模块的mount,这样等所有子模块都挂载好了,一次性Render 53 | //也可以不做任何await,直接Render,此时需要设计Loading界面 54 | //这样也形成了2种不同的路由风格: 55 | //一种是数据前置,路由后置(所有数据全部都准备好了再跳转、展示界面) 56 | //一种是路由前置,数据后置(路由先跳转,展示设计好的loading界面) 57 | //SSR时只能使用"数据前置"风格 58 | public async onMount(env: 'init' | 'route' | 'update'): Promise { 59 | this.routeParams = this.getRouteParams(); 60 | const {subModule, curView, fromUrl} = this.routeParams; 61 | //getPrevState()可以获取路由跳转前的状态 62 | //以下意思是:如果curUser已经存在(之前获取过了),就直接使用,不再调用API获取 63 | //你也可以利用这个方法,复用路由之前的任何有效状态,从而减少数据请求 64 | const {curUser: _curUser} = this.getPrevState() || {}; 65 | try { 66 | //如果用户信息不存在(第一次),等待获取当前用户信息 67 | const curUser = _curUser || (await api.getCurUser()); 68 | const initState: ModuleState = {curUser, subModule, curView, fromUrl}; 69 | //_initState是基类BaseModel中内置的一个reducer 70 | //this.dispatch是this.store.dispatch的快捷方式 71 | //以下语句等于this.store.dispatch({type: 'stage._initState', payload: initState}) 72 | this.dispatch(this.privateActions._initState(initState)); 73 | } catch (err: any) { 74 | //如果根模块初始化中出现错误,将错误放入ModuleState.error字段中,此时将展示该错误信息 75 | const initState: ModuleState = {curUser: {...guest}, subModule, curView, fromUrl, error: err.message || err.toString()}; 76 | this.dispatch(this.privateActions._initState(initState)); 77 | } 78 | } 79 | 80 | //定义一个reducer,用来更新当前用户状态 81 | //注意该render不希望对外输出,所以定义为protected 82 | @reducer 83 | protected putCurUser(curUser: CurUser): void { 84 | this.state.curUser = curUser; 85 | } 86 | 87 | //定义一个effect,用来执行登录逻辑 88 | //effect(参数),参数可以用来将该effect的执行进度注入ModuleState中,如effect('this.loginLoading') 89 | //effect()参数为空,默认等于effect('stage.globalLoading'),表示将执行进度注入stage模块的globalLoading状态中 90 | //如果不需要跟踪该effect的执行进度,请使用effect(null) 91 | @effect() 92 | public async login(args: LoginParams): Promise { 93 | const curUser = await api.login(args); 94 | this.dispatch(this.privateActions.putCurUser(curUser)); 95 | //用户登录后清空所有路由栈,并跳回原地 96 | this.getRouter().relaunch({url: this.state.fromUrl || AdminHomeUrl}, 'window'); 97 | } 98 | 99 | @effect() 100 | public async cancelLogin(): Promise { 101 | //在历史栈中找到第一条不需要登录的记录 102 | //如果简单的back(1),前一个页面需要登录时会引起循环 103 | this.getRouter().back((record) => { 104 | return !this.checkNeedsLogin(record.location.pathname); 105 | }, 'window'); 106 | } 107 | 108 | @effect() 109 | public async logout(): Promise { 110 | const curUser = await api.logout(); 111 | this.dispatch(this.privateActions.putCurUser(curUser)); 112 | //用户登出后清空所有路由栈,并跳首页 113 | this.getRouter().relaunch({url: GuestHomeUrl}, 'window'); 114 | } 115 | 116 | @effect() 117 | public async registry(args: RegisterParams): Promise { 118 | const curUser = await api.registry(args); 119 | this.dispatch(this.privateActions.putCurUser(curUser)); 120 | //用户登录后清空所有路由栈,并跳回原地 121 | this.getRouter().relaunch({url: this.state.fromUrl || AdminHomeUrl}, 'window'); 122 | } 123 | 124 | @effect() 125 | public async resetPassword(args: ResetPasswordParams): Promise { 126 | await api.resetPassword(args); 127 | message.success('您的密码已修改,请重新登录!'); 128 | this.getRouter().relaunch({url: LoginUrl(this.state.fromUrl)}, 'window'); 129 | } 130 | 131 | @effect() 132 | public async sendCaptcha(args: SendCaptchaParams): Promise { 133 | await api.sendCaptcha(args); 134 | message.success('短信验证码已发送,请查收!'); 135 | } 136 | 137 | //ActionHandler运行中的出现的任何错误都会自动派发'stage._error'的Action 138 | //可以通过effect来监听这个Action,用来处理错误, 139 | //如果继续抛出错误,则Action停止继续传播,Handler链条将终止执行 140 | //注意如果继续抛出,请抛出原错误,不要创建新的错误,以防止无穷递归 141 | @effect(null) 142 | protected async ['this._error'](error: CustomError): Promise { 143 | if (error.code === CommonErrorCode.unauthorized) { 144 | this.getRouter().push({url: LoginUrl(error.detail)}, 'window'); 145 | } else if (error.code === ErrorCodes.ROUTE_BACK_OVERFLOW) { 146 | //用户后退溢出时会触发这个错误 147 | const redirect: string = error.detail.redirect || (this.state.curUser.hasLogin ? AdminHomeUrl : GuestHomeUrl); 148 | if (this.getRouter().location.url === redirect && window.confirm('确定要退出本站吗?')) { 149 | //注意: back('')可以退出本站 150 | setTimeout(() => this.getRouter().back(''), 0); 151 | } else { 152 | setTimeout(() => this.getRouter().relaunch({url: redirect}, 'window'), 0); 153 | } 154 | } else if (!error.quiet) { 155 | message.error(error.message); 156 | } 157 | throw error; 158 | } 159 | 160 | private checkNeedsLogin(pathname: string): boolean { 161 | return ['/admin/'].some((prefix) => pathname.startsWith(prefix)); 162 | } 163 | 164 | //支持路由守卫 165 | //路由跳转前会自动派发'stage._testRouteChange'的Action 166 | //可以通过effect来监听这个Action,并决定是否阻止,如果想阻止跳转,可以抛出一个错误 167 | //注意:小程序中如果使用原生路由跳转是无法拦截的 168 | @effect(null) 169 | protected async ['this._testRouteChange']({url, pathname}: {url: string; pathname: string}): Promise { 170 | if (!this.state.curUser.hasLogin && this.checkNeedsLogin(pathname)) { 171 | throw new CustomError(CommonErrorCode.unauthorized, '请登录!', url, true); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/modules/stage/utils/resource.ts: -------------------------------------------------------------------------------- 1 | import {Action, BaseModel, Dispatch, RouteTarget, StoreState, effect, reducer} from '@elux/vue-web'; 2 | import {pathToRegexp} from 'path-to-regexp'; 3 | import {shallowRef, watch} from 'vue'; 4 | import {useRouter} from '@/Global'; 5 | import {BaseApi, BaseListSearch, DefineResource} from './base'; 6 | import {DialogPageClassname} from './const'; 7 | import {excludeDefaultParams, mergeDefaultParams, message} from './tools'; 8 | 9 | export abstract class BaseResource extends BaseModel< 10 | TDefineResource['ModuleState'], 11 | TStoreState 12 | > { 13 | protected abstract api: BaseApi; 14 | protected abstract defaultListSearch: TDefineResource['ListSearch']; 15 | protected routeParams!: TDefineResource['RouteParams']; //保存从当前路由中提取的信息结果 16 | //因为要尽量避免使用public方法,所以构建this.privateActions来引用私有actions 17 | protected privateActions = this.getPrivateActions({putList: this.putList, putCurrentItem: this.putCurrentItem}); 18 | 19 | protected parseListQuery(query: Record): Record { 20 | const data = {...query} as Record; 21 | if (query.pageCurrent) { 22 | data.pageCurrent = parseInt(query.pageCurrent) || undefined; 23 | } 24 | return data; 25 | } 26 | 27 | //提取当前路由中的本模块感兴趣的信息 28 | protected getRouteParams(): TDefineResource['RouteParams'] { 29 | const {pathname, searchQuery} = this.getRouter().location; 30 | const [, admin = '', subModule = '', curViewStr = '', curRenderStr = '', id = ''] = 31 | pathToRegexp('/:admin/:subModule/:curView/:curRender/:id?').exec(pathname) || []; 32 | const curView = curViewStr as TDefineResource['CurView']; 33 | const curRender = curRenderStr as TDefineResource['CurRender']; 34 | const prefixPathname = ['', admin, subModule].join('/'); 35 | const routeParams: TDefineResource['RouteParams'] = {prefixPathname, curView}; 36 | if (curView === 'list') { 37 | routeParams.curRender = curRender || 'maintain'; 38 | const listQuery = this.parseListQuery(searchQuery); 39 | routeParams.listSearch = mergeDefaultParams(this.defaultListSearch, listQuery); 40 | } else if (curView === 'item') { 41 | routeParams.curRender = curRender || 'detail'; 42 | routeParams.itemId = id; 43 | } 44 | return routeParams; 45 | } 46 | 47 | //每次路由发生变化,都会引起Model重新挂载到Store 48 | //在此钩子中必需完成ModuleState的初始赋值,可以异步 49 | //在此钩子执行完成之前,本模块的View将不会Render 50 | //在此钩子中可以await数据请求,等所有数据拉取回来后,一次性Render 51 | //在此钩子中也可以await子模块的mount,等所有子模块都挂载好了,一次性Render 52 | //也可以不做任何await,直接Render,此时需要设计Loading界面 53 | public onMount(env: 'init' | 'route' | 'update'): void { 54 | this.routeParams = this.getRouteParams(); 55 | const {prefixPathname, curView, curRender, listSearch, itemId} = this.routeParams; 56 | this.dispatch( 57 | this.privateActions._initState({ 58 | prefixPathname, 59 | curView, 60 | curRender, 61 | listSearch, 62 | itemId, 63 | } as TDefineResource['ModuleState']) 64 | ); 65 | if (curView === 'list') { 66 | this.dispatch(this.actions.fetchList(listSearch)); 67 | } else if (curView === 'item') { 68 | this.dispatch(this.actions.fetchItem(itemId || '')); 69 | } 70 | } 71 | 72 | @reducer 73 | public putList(listSearch: TDefineResource['ListSearch'], list: TDefineResource['ListItem'][], listSummary: TDefineResource['ListSummary']): void { 74 | Object.assign(this.state, {listSearch, list, listSummary}); 75 | } 76 | 77 | @effect('this.listLoading') 78 | public async fetchList(listSearchData?: TDefineResource['ListSearch']): Promise { 79 | const listSearch = listSearchData || this.state.listSearch || this.defaultListSearch; 80 | const {list, listSummary} = await this.api.getList!(listSearch); 81 | this.dispatch(this.privateActions.putList(listSearch, list, listSummary)); 82 | } 83 | 84 | @reducer 85 | public putCurrentItem(itemId = '', itemDetail: TDefineResource['ItemDetail']): void { 86 | Object.assign(this.state, {itemId, itemDetail}); 87 | } 88 | 89 | @effect() 90 | public async fetchItem(itemId: string): Promise { 91 | const item = await this.api.getItem!({id: itemId}); 92 | this.dispatch(this.actions.putCurrentItem(itemId, item)); 93 | } 94 | 95 | @effect() 96 | public async alterItems(id: string | string[], data: Partial): Promise { 97 | await this.api.alterItems!({id, data}); 98 | message.success('修改成功!'); 99 | this.state.curView === 'list' && this.dispatch(this.actions.fetchList()); 100 | } 101 | 102 | @effect() 103 | public async deleteItems(id: string | string[]): Promise { 104 | await this.api.deleteItems!({id}); 105 | message.success('删除成功!'); 106 | this.state.curView === 'list' && this.dispatch(this.actions.fetchList()); 107 | } 108 | 109 | @effect() 110 | public async updateItem(id: string, data: TDefineResource['UpdateItem']): Promise { 111 | await this.api.updateItem!({id, data}); 112 | await this.getRouter().back(1, 'window'); 113 | message.success('编辑成功!'); 114 | this.getRouter().back(0); 115 | } 116 | 117 | @effect() 118 | public async createItem(data: TDefineResource['CreateItem']): Promise { 119 | await this.api.createItem!(data); 120 | await this.getRouter().back(1, 'window'); 121 | message.success('创建成功!'); 122 | this.getRouter().back(0); 123 | } 124 | } 125 | 126 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 127 | export function useSearch(props: {listPathname: string}, defaultListSearch: Partial) { 128 | const router = useRouter(); 129 | const onSearch = (values: Partial) => { 130 | const searchQuery = excludeDefaultParams(defaultListSearch, {...values, pageCurrent: 1}); 131 | router.push({pathname: props.listPathname, searchQuery, state: router.location.state}, 'page'); 132 | }; 133 | 134 | return {onSearch}; 135 | } 136 | 137 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 138 | export function useShowDetail(props: {prefixPathname: string}) { 139 | const router = useRouter(); 140 | const onShowDetail = (id: string) => { 141 | router.push({url: `${props.prefixPathname}/item/detail/${id}`, classname: DialogPageClassname}, 'window'); 142 | }; 143 | const onShowEditor = (id: string, onSubmit: (id: string, data: Record) => Promise) => { 144 | router.push({url: `${props.prefixPathname}/item/edit/${id}`, classname: DialogPageClassname, state: {onSubmit}}, 'window'); 145 | }; 146 | 147 | return {onShowDetail, onShowEditor}; 148 | } 149 | 150 | //eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 151 | export function useAlter( 152 | props: { 153 | dispatch: Dispatch; 154 | selectedRows?: T[]; 155 | }, 156 | actions: { 157 | deleteItems?(id: string | string[]): Action; 158 | alterItems?(id: string | string[], data: Record): Action; 159 | updateItem?(id: string, data: Record): Action; 160 | createItem?(data: Record): Action; 161 | } 162 | ) { 163 | const selectedRows = shallowRef(props.selectedRows); 164 | watch( 165 | () => props.selectedRows, 166 | (val) => (selectedRows.value = val) 167 | ); 168 | 169 | const deleteItems = async (id: string | string[]) => { 170 | await props.dispatch(actions.deleteItems!(id)); 171 | selectedRows.value = []; 172 | }; 173 | 174 | const alterItems = async (id: string | string[], data: Record) => { 175 | await props.dispatch(actions.alterItems!(id, data)); 176 | selectedRows.value = []; 177 | }; 178 | 179 | const updateItem = async (id: string, data: Record) => { 180 | if (id) { 181 | await props.dispatch(actions.updateItem!(id, data)); 182 | } else { 183 | await props.dispatch(actions.createItem!(data)); 184 | } 185 | selectedRows.value = []; 186 | }; 187 | 188 | return {selectedRows, deleteItems, alterItems, updateItem}; 189 | } 190 | 191 | export function useSingleWindow(): RouteTarget { 192 | const router = useRouter(); 193 | return router.location.classname.startsWith('_') ? 'page' : 'window'; 194 | } 195 | export function useTableSize(): 'middle' | 'large' { 196 | const router = useRouter(); 197 | return router.location.classname.startsWith('_') ? 'middle' : 'large'; 198 | } 199 | //eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 200 | export function useTableChange(props: {listSearch?: T}, listPathname: string, defaultListSearch: T) { 201 | const router = useRouter(); 202 | return (pagination: any, filter: any, _sorter: any) => { 203 | const {listSearch} = props; 204 | const sorterStr = [listSearch?.sorterField, listSearch?.sorterOrder].join(''); 205 | const sorter = _sorter as {field: string; order: 'ascend' | 'descend' | undefined}; 206 | const {current, pageSize} = pagination as {current: number; pageSize: number}; 207 | const sorterField = (sorter.order && sorter.field) || undefined; 208 | const sorterOrder = sorter.order || undefined; 209 | const currentSorter = [sorterField, sorterOrder].join(''); 210 | const pageCurrent = currentSorter !== sorterStr ? 1 : current; 211 | 212 | const searchQuery = excludeDefaultParams(defaultListSearch, {...listSearch, pageCurrent, pageSize, sorterField, sorterOrder}); 213 | 214 | router.push({pathname: listPathname, searchQuery, state: router.location.state}, 'page'); 215 | }; 216 | } 217 | 218 | //也可以使用回调到方法创建和编辑,但使用await 路由跳转更简单 219 | // export function useUpdateItem( 220 | // id: string, 221 | // dispatch: Dispatch, 222 | // actions: {updateItem?(id: string, data: Record): Action; createItem?(data: Record): Action} 223 | // ) { 224 | // const loading = shallowRef(false); 225 | // const router = useRouter(); 226 | // const onFinish = (values: Record) => { 227 | // const {onSubmit} = (router.location.state || {}) as {onSubmit?: (id: string, data: Record) => Promise}; 228 | // let result: Promise; 229 | // loading.value = true; 230 | // if (onSubmit) { 231 | // result = onSubmit(id, values); 232 | // } else { 233 | // if (id) { 234 | // result = dispatch(actions.updateItem!(id, values)) as Promise; 235 | // } else { 236 | // result = dispatch(actions.createItem!(values)) as Promise; 237 | // } 238 | // } 239 | // result.finally(() => (loading.value = false)).then(() => router.back(1, 'window')); 240 | // }; 241 | 242 | // return {loading, onFinish}; 243 | // } 244 | --------------------------------------------------------------------------------