feat: 初始化项目

This commit is contained in:
gary 2022-06-07 13:44:06 +08:00
parent d08c850f29
commit b9898ba85c
82 changed files with 20326 additions and 1 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

8
.eslintignore Normal file
View File

@ -0,0 +1,8 @@
/lambda/
/scripts
/config
.history
public
dist
.umi
mock

21
.eslintrc.js Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
extends: [require.resolve('@umijs/fabric/dist/eslint')],
rules: {
'no-console': 'off',
'no-underscore-dangle': 'off',
'@typescript-eslint/no-unused-vars': 0,
'@typescript-eslint/no-shadow': 1,
'no-param-reassign': 1,
'array-callback-return': 1,
'no-plusplus': 'off',
'@typescript-eslint/no-unused-expressions': 1,
'@typescript-eslint/consistent-type-imports': 0,
'for-direction': 0,
'@typescript-eslint/no-unused-expressions': 0,
},
globals: {
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: true,
page: true,
REACT_APP_ENV: true,
},
};

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules
# roadhog-api-doc ignore
/src/utils/request-temp.js
_roadhog-api-doc
# production
/dist
# misc
.DS_Store
npm-debug.log*
yarn-error.log
/coverage
.idea
# yarn.lock
package-lock.json
pnpm-lock.yaml
*bak
# visual studio code
.history
*.log
functions/*
.temp/**
# umi
.umi
.umi-production
# screenshot
screenshot
.firebase
.eslintcache
build

23
.prettierignore Normal file
View File

@ -0,0 +1,23 @@
**/*.svg
package.json
.umi
.umi-production
/dist
.dockerignore
.DS_Store
.eslintignore
*.png
*.toml
docker
.editorconfig
Dockerfile*
.gitignore
.prettierignore
LICENSE
.eslintcache
*.lock
yarn-error.log
.history
CNAME
/build
/public

5
.prettierrc.js Normal file
View File

@ -0,0 +1,5 @@
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
};

5
.stylelintrc.js Normal file
View File

@ -0,0 +1,5 @@
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.stylelint,
};

8
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"wangzy.sneak-mark"
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"editor.formatOnSave": true,
"prettier.requireConfig": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

View File

@ -1,2 +1,57 @@
# frontend-template
# Ant Design Pro
This project is initialized with [Ant Design Pro](https://pro.ant.design). Follow is the quick guide for how to use.
## Environment Prepare
Install `node_modules`:
```bash
npm install
```
or
```bash
yarn
```
## Provided Scripts
Ant Design Pro provides some useful script to help you quick start and build with web project, code style check and test.
Scripts provided in `package.json`. It's safe to modify or add additional script:
### Start project
```bash
npm start
```
### Build project
```bash
npm run build
```
### Check code style
```bash
npm run lint
```
You can also use script to auto fix some lint error:
```bash
npm run lint:fix
```
### Test code
```bash
npm test
```
## More
You can view full document on our [official website](https://pro.ant.design). And welcome any feedback in our [github](https://github.com/ant-design/ant-design-pro).

53
build.js Normal file
View File

@ -0,0 +1,53 @@
const shell = require('child_process').execSync;
const outputRoot = process.env.npm_config_outputRoot || './dist';
// const apptype = process.env.npm_config_apptype
const branch = 'master'; //process.env.npm_config_branch
// const env = process.env.npm_config_env
// if (!env) {
// console.error('请配置项目环境envnpm config set env=test')
// return
// }
// if (!branch) {
// console.error(
// '请配置发布目录的分支名称branchnpm config set branch=BRANCH_V1.0_FEATURE_20201106_HUISHAN'
// )
// return
// }
if (!outputRoot) {
console.log(
`未配置h5导出目录outputRoot,会使用默认导出相对路径npm config set outputRoot=${outputRoot}`,
);
}
try {
// 更新分支
// console.log('---1 更新项目代码 git pull start---')
// shell(`git pull`)
// console.log('---git pull success---')
// console.log('---2 更新依赖npm i')
// console.log(shell(`npm i`).toString())
// console.log(`---3 切分支 git checkout -q ${branch}`)
shell(`cd ${outputRoot} && git checkout -q ${branch}`);
// console.log(`---4 更新发布目录代码 git pull ${outputRoot}---`)
shell(`cd ${outputRoot}&&git pull`);
console.log(`---git pull success ${outputRoot}---`);
console.log(`--- 开始编译`);
console.log(shell(`npm run build`).toString());
console.log('---npm run build success---');
console.log(`---6 提交代码---`);
shell(`cd ${outputRoot} && git add .`);
shell(`cd ${outputRoot} && git commit -m "feat: 编译发布 ${new Date()}"`);
shell(`cd ${outputRoot} && git push origin ${branch}:${branch}`);
// console.log(`---7 提交代码成功!---`)
// console.log(
// `---发布正式测试环境请点击https://jenkins-dev.yzone01.com/jenkins/job/saas-deploy-dtest-pageframework/build?delay=0sec`
// )
// console.log(
// `---发布备用测试环境请点击http://172.20.208.10:29010/jenkins/job/saas-dtest-deploy/build?delay=0sec`
// )
// console.log(
// `---发布远程测试环境请点击https://jenkins-dev.yzone01.com/jenkins/job/saas-deploy-devweb/build?delay=0sec`
// )
} catch (e) {
console.log('build error ---', e);
}

15
config/config.dev.ts Normal file
View File

@ -0,0 +1,15 @@
// https://umijs.org/config/
import { defineConfig } from 'umi';
export default defineConfig({
plugins: [
// https://github.com/zthxxx/react-dev-inspector
'react-dev-inspector/plugins/umi/react-inspector',
],
// https://github.com/zthxxx/react-dev-inspector#inspector-loader-props
inspectorConfig: {
exclude: [],
babelPlugins: [],
babelOptions: {},
},
});

77
config/config.ts Normal file
View File

@ -0,0 +1,77 @@
// https://umijs.org/config/
import { defineConfig } from 'umi';
import { join } from 'path';
import defaultSettings from './defaultSettings';
import theme from './theme';
import proxy from './proxy';
import routes from '../src/routes';
const { REACT_APP_ENV } = process.env;
export default defineConfig({
hash: true,
antd: {},
dva: {
hmr: true,
},
devServer: {
port: 8003,
},
history: {
type: 'hash',
},
layout: {
// https://umijs.org/zh-CN/plugins/plugin-layout
// locale: true,
siderWidth: 208,
...defaultSettings,
},
// https://umijs.org/zh-CN/plugins/plugin-locale
locale: {
// default zh-CN
default: 'zh-CN',
antd: true,
// default true, when it is true, will use `navigator.language` overwrite default
baseNavigator: true,
},
dynamicImport: {
loading: '@ant-design/pro-layout/es/PageLoading',
},
targets: {
ie: 11,
},
// umi routes: https://umijs.org/docs/routing
routes,
// Theme for antd: https://ant.design/docs/react/customize-theme-cn
theme: {
...theme,
'root-entry-name': 'default',
},
// esbuild is father build tools
// https://umijs.org/plugins/plugin-esbuild
esbuild: {},
title: false,
ignoreMomentLocale: true,
proxy: proxy[REACT_APP_ENV || 'dev'],
manifest: {
basePath: '/',
},
extraBabelPlugins: [
['babel-plugin-import', { libraryName: 'antd', libraryDirectory: 'es', style: true }, 'antd'],
[
'babel-plugin-import',
{ libraryName: '@formily/antd', libraryDirectory: 'esm', style: true },
'@formily/antd',
],
],
cssLoader: {
localsConvention: 'camelCase',
},
outputPath: './dist',
nodeModulesTransform: { type: 'none' },
mfsu: {},
webpack5: {},
exportStatic: {},
});

21
config/defaultSettings.ts Normal file
View File

@ -0,0 +1,21 @@
import { Settings as LayoutSettings } from '@ant-design/pro-layout';
const Settings: LayoutSettings & {
pwa?: boolean;
logo?: string;
} = {
navTheme: 'light',
// 拂晓蓝
primaryColor: '#1890ff',
layout: 'mix',
contentWidth: 'Fluid',
fixedHeader: false,
fixSiderbar: true,
colorWeak: false,
title: 'Pro',
pwa: false,
logo: '/pro_icon.svg',
iconfontUrl: '',
};
export default Settings;

20
config/proxy.ts Normal file
View File

@ -0,0 +1,20 @@
/**
*
* -------------------------------
* The agent cannot take effect in the production environment
* so there is no configuration of the production environment
* For details, please see
* https://pro.ant.design/docs/deploy
*/
export default {
dev: {
// localhost:8000/api/** -> https://preview.pro.ant.design/api/**
'/admin/': {
// 要代理的地址
target: 'http://106.55.157.116:8086',
// 配置了这个可以从 http 代理到 https
// 依赖 origin 的功能可能需要这个,比如 cookie
changeOrigin: true,
},
},
};

18
config/theme/index.ts Normal file
View File

@ -0,0 +1,18 @@
export default {
'primary-color': '#04A17E',
// 'secondary-color': '#87dba2',
// 'primary-1': 'rgba(@primary-color,0.2)',
'success-color': '#04A17E',
'error-color': '#FD6353',
'warning-color': '#F8991D',
'disabled-color': 'rgba(#000,0.25)',
'text-color': '#334458',
// 'text-color-secondary': 'rgba(@text-color,0.6)',
// 'text-color-third': 'rgba(@text-color,0.8)',
// 'border-color-base': '#D5DEE7',
// 'border-color-split': '#ECF1F5',
// 'background-color-base': '#F8FAFB',
// 'background-color-dark': '#FAFBFC',
// 'steps-icon-size': '28px',
// 'steps-title-line-height': '@steps-icon-size',
};

11
jsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

116
package.json Normal file
View File

@ -0,0 +1,116 @@
{
"name": "ant-design-pro",
"version": "5.0.0",
"private": true,
"description": "An out-of-box UI solution for enterprise applications",
"scripts": {
"analyze": "cross-env ANALYZE=1 umi build",
"build": "umi build",
"b": "node build",
"deploy": "npm run build && npm run gh-pages",
"dev": "npm run start:dev",
"gh-pages": "gh-pages -d dist",
"i18n-remove": "pro i18n-remove --locale=zh-CN --write",
"postinstall": "umi g tmp",
"lint": "umi g tmp && npm run lint:js && npm run lint:style && npm run lint:prettier && npm run tsc",
"lint-staged": "lint-staged",
"lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx ",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src && npm run lint:style",
"lint:js": "eslint --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src",
"lint:prettier": "prettier -c --write \"src/**/*\" --end-of-line auto",
"lint:style": "stylelint --fix \"src/**/*.less\" --syntax less",
"openapi": "umi openapi",
"precommit": "lint-staged",
"prettier": "prettier -c --write \"src/**/*\"",
"start": "cross-env UMI_ENV=dev umi dev",
"start:dev": "cross-env REACT_APP_ENV=dev MOCK=none UMI_ENV=dev umi dev",
"start:no-mock": "cross-env MOCK=none UMI_ENV=dev umi dev",
"start:no-ui": "cross-env UMI_UI=none UMI_ENV=dev umi dev",
"start:pre": "cross-env REACT_APP_ENV=pre UMI_ENV=dev umi dev",
"start:test": "cross-env REACT_APP_ENV=test MOCK=none UMI_ENV=dev umi dev",
"pretest": "node ./tests/beforeTest",
"test": "umi test",
"test:all": "node ./tests/run-tests.js",
"test:component": "umi test ./src/components",
"serve": "umi-serve",
"tsc": "tsc --noEmit"
},
"lint-staged": {
"**/*.less": "stylelint --syntax less",
"**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js",
"**/*.{js,jsx,tsx,ts,less,md,json}": [
"prettier --write"
]
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 10"
],
"dependencies": {
"@ant-design/icons": "^4.5.0",
"@ant-design/pro-descriptions": "^1.9.0",
"@ant-design/pro-form": "^1.43.0",
"@ant-design/pro-layout": "^6.26.0",
"@ant-design/pro-table": "^2.56.0",
"@formily/antd": "^2.0.7",
"@formily/core": "^2.0.7",
"@formily/react": "^2.0.7",
"@umijs/route-utils": "^2.0.3",
"ahooks": "^2.10.14",
"antd": "^4.17.2",
"axios": "^0.24.0",
"braft-editor": "^2.3.9",
"classnames": "^2.2.6",
"lodash": "^4.17.11",
"moment": "^2.25.3",
"omit.js": "^2.0.2",
"rc-menu": "^9.0.13",
"rc-util": "^5.14.0",
"react": "^17.0.0",
"react-dev-inspector": "^1.1.1",
"react-dom": "^17.0.0",
"react-helmet-async": "^1.0.4",
"umi": "^3.5.0",
"umi-serve": "^1.9.10"
},
"devDependencies": {
"@ant-design/pro-cli": "^2.0.2",
"@types/express": "^4.17.0",
"@types/history": "^4.7.2",
"@types/jest": "^26.0.0",
"@types/lodash": "^4.14.144",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-helmet": "^6.1.0",
"@umijs/fabric": "^2.6.2",
"@umijs/openapi": "^1.3.0",
"@umijs/plugin-blocks": "^2.0.5",
"@umijs/plugin-esbuild": "^1.0.1",
"@umijs/plugin-openapi": "^1.2.0",
"@umijs/preset-ant-design-pro": "^1.2.0",
"@umijs/preset-dumi": "^1.1.7",
"@umijs/preset-react": "^1.8.17",
"@umijs/yorkie": "^2.0.3",
"babel-plugin-import": "^1.13.3",
"carlo": "^0.9.46",
"cross-env": "^7.0.0",
"cross-port-killer": "^1.1.1",
"detect-installer": "^1.0.1",
"enzyme": "^3.11.0",
"eslint": "^7.1.0",
"express": "^4.17.1",
"gh-pages": "^3.0.0",
"jsdom-global": "^3.0.2",
"lint-staged": "^10.0.0",
"mockjs": "^1.0.1-beta3",
"prettier": "^2.3.2",
"puppeteer-core": "^8.0.0",
"stylelint": "^13.0.0",
"swagger-ui-react": "^3.52.3",
"typescript": "^4.2.2"
},
"engines": {
"node": ">=10.0.0"
}
}

1
public/CNAME Normal file
View File

@ -0,0 +1 @@
preview.pro.ant.design

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

1
public/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" version="1.1" viewBox="0 0 200 200"><title>Group 28 Copy 5</title><desc>Created with Sketch.</desc><defs><linearGradient id="linearGradient-1" x1="62.102%" x2="108.197%" y1="0%" y2="37.864%"><stop offset="0%" stop-color="#4285EB"/><stop offset="100%" stop-color="#2EC7FF"/></linearGradient><linearGradient id="linearGradient-2" x1="69.644%" x2="54.043%" y1="0%" y2="108.457%"><stop offset="0%" stop-color="#29CDFF"/><stop offset="37.86%" stop-color="#148EFF"/><stop offset="100%" stop-color="#0A60FF"/></linearGradient><linearGradient id="linearGradient-3" x1="69.691%" x2="16.723%" y1="-12.974%" y2="117.391%"><stop offset="0%" stop-color="#FA816E"/><stop offset="41.473%" stop-color="#F74A5C"/><stop offset="100%" stop-color="#F51D2C"/></linearGradient><linearGradient id="linearGradient-4" x1="68.128%" x2="30.44%" y1="-35.691%" y2="114.943%"><stop offset="0%" stop-color="#FA8E7D"/><stop offset="51.264%" stop-color="#F74A5C"/><stop offset="100%" stop-color="#F51D2C"/></linearGradient></defs><g id="Page-1" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="logo" transform="translate(-20.000000, -20.000000)"><g id="Group-28-Copy-5" transform="translate(20.000000, 20.000000)"><g id="Group-27-Copy-3"><g id="Group-25" fill-rule="nonzero"><g id="2"><path id="Shape" fill="url(#linearGradient-1)" d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C99.2571609,26.9692191 101.032305,26.9692191 102.20193,28.1378823 L129.985225,55.8983314 C134.193707,60.1033528 141.017005,60.1033528 145.225487,55.8983314 C149.433969,51.69331 149.433969,44.8756232 145.225487,40.6706018 L108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z"/><path id="Shape" fill="url(#linearGradient-2)" d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C100.999864,25.6271836 105.751642,20.541824 112.729652,19.3524487 C117.915585,18.4685261 123.585219,20.4140239 129.738554,25.1889424 C125.624663,21.0784292 118.571995,14.0340304 108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z"/></g><path id="Shape" fill="url(#linearGradient-3)" d="M153.685633,135.854579 C157.894115,140.0596 164.717412,140.0596 168.925894,135.854579 L195.959977,108.842726 C200.659183,104.147384 200.659183,96.5636133 195.960527,91.8688194 L168.690777,64.7181159 C164.472332,60.5180858 157.646868,60.5241425 153.435895,64.7316526 C149.227413,68.936674 149.227413,75.7543607 153.435895,79.9593821 L171.854035,98.3623765 C173.02366,99.5310396 173.02366,101.304724 171.854035,102.473387 L153.685633,120.626849 C149.47715,124.83187 149.47715,131.649557 153.685633,135.854579 Z"/></g><ellipse id="Combined-Shape" cx="100.519" cy="100.437" fill="url(#linearGradient-4)" rx="23.6" ry="23.581"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

5
public/pro_icon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="42" height="42" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="#070707" d="m6.717392,13.773912l5.6,0c2.8,0 4.7,1.9 4.7,4.7c0,2.8 -2,4.7 -4.9,4.7l-2.5,0l0,4.3l-2.9,0l0,-13.7zm2.9,2.2l0,4.9l1.9,0c1.6,0 2.6,-0.9 2.6,-2.4c0,-1.6 -0.9,-2.4 -2.6,-2.4l-1.9,0l0,-0.1zm8.9,11.5l2.7,0l0,-5.7c0,-1.4 0.8,-2.3 2.2,-2.3c0.4,0 0.8,0.1 1,0.2l0,-2.4c-0.2,-0.1 -0.5,-0.1 -0.8,-0.1c-1.2,0 -2.1,0.7 -2.4,2l-0.1,0l0,-1.9l-2.7,0l0,10.2l0.1,0zm11.7,0.1c-3.1,0 -5,-2 -5,-5.3c0,-3.3 2,-5.3 5,-5.3s5,2 5,5.3c0,3.4 -1.9,5.3 -5,5.3zm0,-2.1c1.4,0 2.2,-1.1 2.2,-3.2c0,-2 -0.8,-3.2 -2.2,-3.2c-1.4,0 -2.2,1.2 -2.2,3.2c0,2.1 0.8,3.2 2.2,3.2z" class="st0" id="Ant-Design-Pro"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 677 B

9
src/access.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* @see https://umijs.org/zh-CN/plugins/plugin-access
* */
export default function access(initialState: { currentUser?: API.CurrentUser | undefined }) {
const { currentUser } = initialState || {};
return {
canAdmin: currentUser && currentUser.access === 'admin',
};
}

29
src/app.tsx Normal file
View File

@ -0,0 +1,29 @@
import { PageContainer } from '@ant-design/pro-layout';
import type { RunTimeLayoutConfig } from 'umi';
import { history } from 'umi';
import RightContent from '@/components/RightContent';
import RoutePath from '@/routes/routePath';
import { CACHE_TOKEN } from '@/constants/cacheKey';
// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
return {
rightContentRender: () => <RightContent />,
disableContentMargin: false,
onPageChange: () => {
if (!localStorage.getItem(CACHE_TOKEN)) {
history.push(RoutePath.LOGIN);
}
},
menuHeaderRender: undefined,
// 自定义 403 页面
// unAccessible: <div>unAccessible</div>,
// 增加一个 loading 的状态
childrenRender: (children) => {
const pathname = location.hash.replace('#', '');
return pathname === RoutePath.LOGIN ? children : <PageContainer>{children}</PageContainer>;
},
...initialState?.settings,
};
};

View File

@ -0,0 +1,75 @@
import type { MenuTheme } from 'antd/lib/menu/MenuContext';
export type ContentWidth = 'Fluid' | 'Fixed';
export type RenderSetting = {
headerRender?: false;
footerRender?: false;
menuRender?: false;
menuHeaderRender?: false;
};
export type PureSettings = {
/** @name theme for nav menu */
navTheme?: MenuTheme | 'realDark' | undefined;
/** @name 顶部菜单的颜色mix 模式下生效 */
headerTheme?: MenuTheme;
/** @name nav menu position: `side` or `top` */
headerHeight?: number;
/** @name customize header height */
layout?: 'side' | 'top' | 'mix';
/** @name layout of content: `Fluid` or `Fixed`, only works when layout is top */
contentWidth?: ContentWidth;
/** @name sticky header */
fixedHeader?: boolean;
/** @name sticky siderbar */
fixSiderbar?: boolean;
/** @name menu 相关的一些配置 */
menu?: {
locale?: boolean;
defaultOpenAll?: boolean;
loading?: boolean;
type?: 'sub' | 'group';
autoClose?: false;
};
/**
* false layout pageName pageName - title
*
* @name Layout title
*/
title: string | false;
/**
* Your custom iconfont Symbol script Url eg//at.alicdn.com/t/font_1039637_btcrd5co4w.js
* Iconfont Usage: https://github.com/ant-design/ant-design-pro/pull/3517
*/
iconfontUrl?: string;
/** @name 主色,需要配合 umi 使用 */
primaryColor?: string;
/** @name 全局增加滤镜 */
colorWeak?: boolean;
/**
* mix
*
* @name
*/
splitMenus?: boolean;
};
export type ProSettings = PureSettings & RenderSetting;
const defaultSettings: ProSettings = {
navTheme: 'dark',
layout: 'side',
contentWidth: 'Fluid',
fixedHeader: false,
fixSiderbar: false,
menu: {
locale: true,
},
headerHeight: 48,
title: 'pro',
iconfontUrl: '',
primaryColor: 'daybreak',
splitMenus: false,
};
export default defaultSettings;

View File

@ -0,0 +1,137 @@
import type H from 'history';
import type { BreadcrumbProps as AntdBreadcrumbProps } from 'antd';
import React from 'react';
import pathToRegexp from 'path-to-regexp';
import { isBrowser } from '@ant-design/pro-utils';
import type { ProSettings } from './defaultSettings';
import type { MenuDataItem, MessageDescriptor, WithFalse } from './typings';
import { urlToList } from './pathTools';
export type BreadcrumbProps = {
breadcrumbList?: { title: string; href: string }[];
home?: string;
location?:
| H.Location
| {
pathname?: string;
};
menu?: ProSettings['menu'];
breadcrumbMap?: Map<string, MenuDataItem>;
formatMessage?: (message: MessageDescriptor) => string;
breadcrumbRender?: WithFalse<
(routers: AntdBreadcrumbProps['routes']) => AntdBreadcrumbProps['routes']
>;
itemRender?: AntdBreadcrumbProps['itemRender'];
};
// 渲染Breadcrumb 子节点
// Render the Breadcrumb child node
const defaultItemRender: AntdBreadcrumbProps['itemRender'] = ({ breadcrumbName, path }) => (
<a href={path}>{breadcrumbName}</a>
);
const renderItemLocal = (item: MenuDataItem, props: BreadcrumbProps): string => {
const { formatMessage, menu } = props;
if (item.locale && formatMessage && menu?.locale !== false) {
return formatMessage({ id: item.locale, defaultMessage: item.name });
}
return item.name as string;
};
export const getBreadcrumb = (
breadcrumbMap: Map<string, MenuDataItem>,
url: string,
): MenuDataItem => {
let breadcrumbItem = breadcrumbMap.get(url);
if (!breadcrumbItem) {
// Find the first matching path in the order defined by route config
// 按照 route config 定义的顺序找到第一个匹配的路径
const targetPath = [...breadcrumbMap.keys()].find((path) =>
// remove ? ,不然会重复
pathToRegexp(path.replace('?', '')).test(url),
);
if (targetPath) breadcrumbItem = breadcrumbMap.get(targetPath);
}
return breadcrumbItem || { path: '' };
};
export const getBreadcrumbFromProps = (
props: BreadcrumbProps,
): {
location: BreadcrumbProps['location'];
breadcrumbMap: BreadcrumbProps['breadcrumbMap'];
} => {
const { location, breadcrumbMap } = props;
return {
location,
breadcrumbMap,
};
};
export const conversionFromLocation = (
routerLocation: BreadcrumbProps['location'],
breadcrumbMap: Map<string, MenuDataItem>,
props: BreadcrumbProps,
): AntdBreadcrumbProps['routes'] => {
// Convertor the url to an array
const pathSnippets = urlToList(routerLocation?.pathname);
// Loop data mosaic routing
const extraBreadcrumbItems: AntdBreadcrumbProps['routes'] = pathSnippets
.map((url) => {
// For application that has configured router base
// @ts-ignore
const { routerBase = '/' } = isBrowser() ? window : {};
const realPath = routerBase === '/' ? url : `${routerBase}${url}`;
const currentBreadcrumb = getBreadcrumb(breadcrumbMap, url);
const name = renderItemLocal(currentBreadcrumb, props);
const { hideInBreadcrumb } = currentBreadcrumb;
return name && !hideInBreadcrumb
? {
path: realPath,
breadcrumbName: name,
component: currentBreadcrumb.component,
meta: currentBreadcrumb.meta || {},
}
: { path: '', breadcrumbName: '' };
})
.filter((item) => item && item.path);
return extraBreadcrumbItems;
};
export type BreadcrumbListReturn = Pick<
AntdBreadcrumbProps,
Extract<keyof AntdBreadcrumbProps, 'routes' | 'itemRender'>
>;
/** 将参数转化为面包屑 Convert parameters into breadcrumbs */
export const genBreadcrumbProps = (props: BreadcrumbProps): AntdBreadcrumbProps['routes'] => {
const { location, breadcrumbMap } = getBreadcrumbFromProps(props);
// 根据 location 生成 面包屑
// Generate breadcrumbs based on location
if (location && location.pathname && breadcrumbMap) {
return conversionFromLocation(location, breadcrumbMap, props);
}
return [];
};
// use breadcrumbRender to change routes
export const getBreadcrumbProps = (props: BreadcrumbProps): BreadcrumbListReturn => {
const { breadcrumbRender, itemRender: propsItemRender } = props;
const routesArray = genBreadcrumbProps(props);
const itemRender = propsItemRender || defaultItemRender;
let routes = routesArray;
// if routes.length =1, don't show it
if (breadcrumbRender) {
routes = breadcrumbRender(routes) || [];
}
if ((routes && routes.length < 1) || breadcrumbRender === false) {
routes = undefined;
}
return {
routes,
itemRender,
};
};

View File

@ -0,0 +1,43 @@
import { transformRoute } from '@umijs/route-utils';
import type { MenuDataItem, Route, MessageDescriptor } from './typings';
function fromEntries(iterable: any) {
return [...iterable].reduce((obj: Record<string, MenuDataItem>, [key, val]) => {
// eslint-disable-next-line no-param-reassign
obj[key] = val;
return obj;
}, {});
}
export default (
routes: Route[],
menu?: { locale?: boolean },
formatMessage?: (message: MessageDescriptor) => string,
menuDataRender?: (menuData: MenuDataItem[]) => MenuDataItem[],
) => {
const { menuData, breadcrumb } = transformRoute(
routes,
menu?.locale || false,
formatMessage,
true,
);
if (!menuDataRender) {
return {
breadcrumb: fromEntries(breadcrumb),
breadcrumbMap: breadcrumb,
menuData,
};
}
const renderData = transformRoute(
menuDataRender(menuData),
menu?.locale || false,
formatMessage,
true,
);
return {
breadcrumb: fromEntries(renderData.breadcrumb),
breadcrumbMap: renderData.breadcrumb,
menuData: renderData.menuData,
};
};

View File

@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import { Breadcrumb, Button, Row } from 'antd';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
import { getBreadcrumbProps, conversionFromLocation } from './getBreadcrumbProps';
import type { MenuDataItem } from './typings';
import { getMenuData } from '@ant-design/pro-layout';
import { Prompt, useLocation, history } from 'umi';
import qs from 'qs';
export const BreadcrumbContext = React.createContext<any>([]);
const PageComponent = (props: any) => {
const [menuInfoData, setMenuInfoData] = useMergedState<{
breadcrumb?: Record<string, MenuDataItem>;
breadcrumbMap?: Map<string, MenuDataItem>;
menuData?: MenuDataItem[];
}>(() => getMenuData(props.route?.routes || []));
const location = useLocation();
const { breadcrumbMap } = menuInfoData;
const itemRender = (route, params, routes, paths) => {
const last = routes.indexOf(route) === routes.length - 1;
return last || !route.component ? (
<span>{route.breadcrumbName}</span>
) : (
<a
onClick={() => {
breadClick(route);
}}
>
{route.breadcrumbName}
</a>
);
};
const breadcrumbProps = getBreadcrumbProps({ ...props, breadcrumbMap, itemRender });
useEffect(() => {
const infoData = getMenuData(props.route?.routes || []);
// 稍微慢一点 render不然会造成性能问题看起来像是菜单的卡顿
const animationFrameId = requestAnimationFrame(() => {
setMenuInfoData(infoData);
});
return () => window.cancelAnimationFrame && window.cancelAnimationFrame(animationFrameId);
}, [props.route, props.location?.pathname]);
useEffect(() => {
if (props.onChange) {
props.onChange(breadcrumbProps.routes);
}
}, [props.location.pathname]);
const handleBeforeLeave = (e, action) => {
// 当前 history 跳转的 action有 PUSH、REPLACE 和 POP 三种类型
if (action !== 'PUSH' || e.pass) {
// @ts-ignore
document.getElementById('pdmRightZone').scrollTop = 0;
return true;
}
handleLink(e, location.query);
return false;
};
const handleLink = (value: any, query = {}, actionType = 'push') => {
const valueParams = { ...value };
const beforeQuery = getBackQueryParams(value.pathname, query);
valueParams.query = { ...beforeQuery, ...valueParams.query };
valueParams.search = `?${qs.stringify(valueParams.query)}`;
// @ts-ignore
history[actionType]({
...valueParams,
pass: true, // 代表不拦截
});
};
const breadClick = (route) => {
const routeValue = {
pathname: route.path,
};
handleLink(routeValue, location.query || {}, 'push');
};
// 获取回填的参数,所需的参数放在meta里面
const getBackQueryParams = (pathname: string, beforeQuery = {}) => {
const routeList = conversionFromLocation(
{
pathname,
},
breadcrumbMap,
{},
);
let metaParams: string[] = [];
routeList?.map((item: any) => {
item.meta.params && (metaParams = metaParams.concat(item.meta.params));
});
const routeQuery = {};
[...new Set(metaParams)].map((item) => {
routeQuery[item] = beforeQuery[item];
});
return routeQuery;
};
return (
<>
<Prompt when={true} message={handleBeforeLeave} />
<Row align="middle" justify="space-between">
<Breadcrumb {...breadcrumbProps}></Breadcrumb>
</Row>
</>
);
};
export default PageComponent;

View File

@ -0,0 +1,9 @@
// /userInfo/2144/id => ['/userInfo','/userInfo/2144,'/userInfo/2144/id']
// eslint-disable-next-line import/prefer-default-export
export function urlToList(url?: string): string[] {
if (!url || url === '/') {
return ['/'];
}
const urlList = url.split('/').filter((i) => i);
return urlList.map((urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`);
}

View File

@ -0,0 +1,61 @@
import type * as H from 'history';
import type { RouteComponentProps as BasicRouteProps, match } from 'react-router-dom';
import type React from 'react';
export type LinkProps = {
to: H.LocationDescriptor;
replace?: boolean;
innerRef?: React.Ref<HTMLAnchorElement>;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
export type MenuDataItem = {
/** @name 子菜单 */
children?: MenuDataItem[];
/** @name 在菜单中隐藏子节点 */
hideChildrenInMenu?: boolean;
/** @name 在菜单中隐藏自己和子节点 */
hideInMenu?: boolean;
/** @name 菜单的icon */
icon?: React.ReactNode;
/** @name 自定义菜单的国际化 key */
locale?: string | false;
/** @name 菜单的名字 */
name?: string;
/** @name 用于标定选中的值,默认是 path */
key?: string;
/** @name disable 菜单选项 */
disabled?: boolean;
/** @name 路径,可以设定为网页链接 */
path?: string;
/**
* parentKeys
*
* @name
*/
parentKeys?: string[];
/** @name 隐藏自己,并且将子节点提升到与自己平级 */
flatMenu?: boolean;
/** @name 指定外链打开形式同a标签 */
target?: string;
[key: string]: any;
};
export type Route = {
routes?: Route[];
} & MenuDataItem;
export type WithFalse<T> = T | false;
export type RouterTypes<P> = {
computedMatch?: match<P>;
route?: Route;
location: BasicRouteProps['location'] | { pathname?: string };
} & Omit<BasicRouteProps, 'location'>;
export type MessageDescriptor = {
id: any;
description?: string;
defaultMessage?: string;
};

View File

@ -0,0 +1,4 @@
.container {
padding: 40px 20px;
background: #fff;
}

View File

@ -0,0 +1,8 @@
import React from 'react';
import styles from './index.less';
const DetailPageContainer: React.FC = ({ children }) => {
return <div className={styles.container}>{children}</div>;
};
export default DetailPageContainer;

View File

@ -0,0 +1,66 @@
import React, { useRef, ReactNode } from 'react';
import { Modal } from 'antd';
import classnames from 'classnames';
export interface IProps {
value: string; // 预览html代码段
visible: boolean; // 是否可见
title?: string; // 预览弹框名称
width?: number; // 预览弹框高度
renderHeader?: () => ReactNode | ReactNode; // 渲染头部
renderFooter?: () => ReactNode | ReactNode; // 渲染底部
onCancel?: () => void; // 预览弹框关闭
prefixCls?: string;
className?: string;
style?: React.CSSProperties;
}
const EditorPreview: React.FC<IProps> = (props) => {
const {
value,
visible,
title,
width,
renderHeader,
renderFooter,
onCancel,
prefixCls,
className,
style,
} = props;
const previewRef = useRef<HTMLInputElement>(null);
setTimeout(() => {
if (previewRef.current) {
previewRef.current.innerHTML = value;
}
}, 0);
return (
<Modal
className={classnames(className, `${prefixCls}-wrapper`)}
style={style}
destroyOnClose
maskClosable={false}
title={title}
visible={visible}
width={width}
onCancel={onCancel}
footer={null}
>
{typeof renderHeader === 'function' ? renderHeader() : renderHeader}
<div className="eidtor-content" ref={previewRef} />
{typeof renderFooter === 'function' ? renderFooter() : renderFooter}
</Modal>
);
};
EditorPreview.defaultProps = {
visible: false,
title: '图文详情预览',
value: '',
width: 375,
onCancel: () => {},
className: '',
style: {},
};
export default EditorPreview;

View File

@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import BraftEditor, { EditorState, ExtendControlType, MediaType } from 'braft-editor';
import classnames from 'classnames';
import EditorPreview from './EditorPreview';
import 'braft-editor/dist/index.css';
export interface IEditorProps {
/**
* html代码段
*/
value: string;
/**
*
*/
request?: any;
/**
*
*/
action?: string;
/**
* order
*/
data?: {
[propName: string]: any;
};
/**
*
*/
prefixCls?: string;
/**
*
*/
className?: string;
/**
*
*/
style?: React.CSSProperties;
[propName: string]: any;
}
// 上传到服务端的排序号
let order = 0;
const Editor: React.FC<IEditorProps> = (props) => {
const { value, request, action, data, prefixCls, className, style, onChange, ...rest } = props;
// 富文本数据是否初始化
const [isInit, setIsInit] = useState(false);
// 富文本
const [editorState, setEditorState] = useState<EditorState>(BraftEditor.createEditorState(value));
// 富文本预览
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
// onChange
const handleEditorChange = (newEditorState: EditorState) => {
if (isInit || (!isInit && value === '<p></p>')) {
setIsInit(true);
setEditorState(newEditorState);
const changeValue = newEditorState.toHTML() === '<p></p>' ? '' : newEditorState.toHTML();
onChange(changeValue);
}
};
// 富文本预览
const extendControls: ExtendControlType[] = [
{
key: 'preview-button',
type: 'button',
text: '预览',
onClick: () => setPreviewVisible(true),
},
];
// 自定义媒体
const media: MediaType = {
uploadFn: async ({ file, progress, error, success }) => {
// 自定义上传
const baseRequestForm = request.requestForm.bind(request);
order += 1;
baseRequestForm(
action,
{ file, order, ...data },
{
onUploadProgress: ({ total, loaded }: { total: number; loaded: number }) => {
progress((loaded / total) * 100);
},
},
)
.then((res: any) => {
const newRes = res.value || res.valueObj || {};
const { imageUrl = '', attachUrl = '', attachId = '' } = newRes;
res.url = imageUrl || attachUrl;
res.uid = attachId;
res.width = '100%';
res.height = 'auto';
success(res);
})
.catch(error);
},
};
useEffect(() => {
if (!isInit && value !== '<p></p>') {
setEditorState(BraftEditor.createEditorState(value));
setIsInit(true);
}
}, [value]);
return (
<div>
<BraftEditor
className={classnames(className, `${prefixCls}-wrapper`)}
style={style}
contentStyle={{ boxShadow: 'inset 0 1px 3px rgba(0,0,0,.1)' }}
contentClassName="editor-content"
extendControls={extendControls}
// media={media}
value={editorState}
onChange={handleEditorChange}
textBackgroundColor={false}
stripPastedStyles
{...rest}
/>
<EditorPreview
width={600}
value={editorState.toHTML()}
visible={previewVisible}
onCancel={() => setPreviewVisible(false)}
/>
</div>
);
};
Editor.defaultProps = {
value: '<p></p>',
data: {},
className: '',
style: {},
};
export default Editor;

View File

@ -0,0 +1,16 @@
@import '~antd/es/style/themes/default.less';
.container > * {
background-color: @popover-bg;
border-radius: 4px;
box-shadow: @shadow-1-down;
}
@media screen and (max-width: @screen-xs) {
.container {
width: 100% !important;
}
.container > * {
border-radius: 0 !important;
}
}

View File

@ -0,0 +1,17 @@
import type { DropDownProps } from 'antd/es/dropdown';
import { Dropdown } from 'antd';
import React from 'react';
import classNames from 'classnames';
import styles from './index.less';
export type HeaderDropdownProps = {
overlayClassName?: string;
overlay: React.ReactNode | (() => React.ReactNode) | any;
placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight' | 'bottomCenter';
} & Omit<DropDownProps, 'overlay'>;
const HeaderDropdown: React.FC<HeaderDropdownProps> = ({ overlayClassName: cls, ...restProps }) => (
<Dropdown overlayClassName={classNames(styles.container, cls)} {...restProps} />
);
export default HeaderDropdown;

View File

@ -0,0 +1,7 @@
import React from 'react';
import { Modal as AntModal, ModalProps } from 'antd';
const Modal: React.FC<ModalProps> = ({ okText = '确认', ...rest }) => {
return <AntModal {...{ ...rest, okText }}></AntModal>;
};
export type { ModalProps };
export default Modal;

View File

@ -0,0 +1,126 @@
import { BellOutlined } from '@ant-design/icons';
import { Badge, Spin, Tabs } from 'antd';
import useMergedState from 'rc-util/es/hooks/useMergedState';
import React from 'react';
import classNames from 'classnames';
import type { NoticeIconTabProps } from './NoticeList';
import NoticeList from './NoticeList';
import HeaderDropdown from '../HeaderDropdown';
import styles from './index.less';
const { TabPane } = Tabs;
export type NoticeIconProps = {
count?: number;
bell?: React.ReactNode;
className?: string;
loading?: boolean;
onClear?: (tabName: string, tabKey: string) => void;
onItemClick?: (item: API.NoticeIconItem, tabProps: NoticeIconTabProps) => void;
onViewMore?: (tabProps: NoticeIconTabProps, e: MouseEvent) => void;
onTabChange?: (tabTile: string) => void;
style?: React.CSSProperties;
onPopupVisibleChange?: (visible: boolean) => void;
popupVisible?: boolean;
clearText?: string;
viewMoreText?: string;
clearClose?: boolean;
emptyImage?: string;
children?: React.ReactElement<NoticeIconTabProps>[];
};
const NoticeIcon: React.FC<NoticeIconProps> & {
Tab: typeof NoticeList;
} = (props) => {
const getNotificationBox = (): React.ReactNode => {
const {
children,
loading,
onClear,
onTabChange,
onItemClick,
onViewMore,
clearText,
viewMoreText,
} = props;
if (!children) {
return null;
}
const panes: React.ReactNode[] = [];
React.Children.forEach(children, (child: React.ReactElement<NoticeIconTabProps>): void => {
if (!child) {
return;
}
const { list, title, count, tabKey, showClear, showViewMore } = child.props;
const len = list && list.length ? list.length : 0;
const msgCount = count || count === 0 ? count : len;
const tabTitle: string = msgCount > 0 ? `${title} (${msgCount})` : title;
panes.push(
<TabPane tab={tabTitle} key={tabKey}>
<NoticeList
clearText={clearText}
viewMoreText={viewMoreText}
list={list}
tabKey={tabKey}
onClear={(): void => onClear && onClear(title, tabKey)}
onClick={(item): void => onItemClick && onItemClick(item, child.props)}
onViewMore={(event): void => onViewMore && onViewMore(child.props, event)}
showClear={showClear}
showViewMore={showViewMore}
title={title}
/>
</TabPane>,
);
});
return (
<>
<Spin spinning={loading} delay={300}>
<Tabs className={styles.tabs} onChange={onTabChange}>
{panes}
</Tabs>
</Spin>
</>
);
};
const { className, count, bell } = props;
const [visible, setVisible] = useMergedState<boolean>(false, {
value: props.popupVisible,
onChange: props.onPopupVisibleChange,
});
const noticeButtonClass = classNames(className, styles.noticeButton);
const notificationBox = getNotificationBox();
const NoticeBellIcon = bell || <BellOutlined className={styles.icon} />;
const trigger = (
<span className={classNames(noticeButtonClass, { opened: visible })}>
<Badge count={count} style={{ boxShadow: 'none' }} className={styles.badge}>
{NoticeBellIcon}
</Badge>
</span>
);
if (!notificationBox) {
return trigger;
}
return (
<HeaderDropdown
placement="bottomRight"
overlay={notificationBox}
overlayClassName={styles.popover}
trigger={['click']}
visible={visible}
onVisibleChange={setVisible}
>
{trigger}
</HeaderDropdown>
);
};
NoticeIcon.defaultProps = {
emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg',
};
NoticeIcon.Tab = NoticeList;
export default NoticeIcon;

View File

@ -0,0 +1,103 @@
@import '~antd/es/style/themes/default.less';
.list {
max-height: 400px;
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
.item {
padding-right: 24px;
padding-left: 24px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
.meta {
width: 100%;
}
.avatar {
margin-top: 4px;
background: @component-background;
}
.iconElement {
font-size: 32px;
}
&.read {
opacity: 0.4;
}
&:last-child {
border-bottom: 0;
}
&:hover {
background: @primary-1;
}
.title {
margin-bottom: 8px;
font-weight: normal;
}
.description {
font-size: 12px;
line-height: @line-height-base;
}
.datetime {
margin-top: 4px;
font-size: 12px;
line-height: @line-height-base;
}
.extra {
float: right;
margin-top: -1.5px;
margin-right: 0;
color: @text-color-secondary;
font-weight: normal;
}
}
.loadMore {
padding: 8px 0;
color: @primary-6;
text-align: center;
cursor: pointer;
&.loadedAll {
color: rgba(0, 0, 0, 0.25);
cursor: unset;
}
}
}
.notFound {
padding: 73px 0 88px;
color: @text-color-secondary;
text-align: center;
img {
display: inline-block;
height: 76px;
margin-bottom: 16px;
}
}
.bottomBar {
height: 46px;
color: @text-color;
line-height: 46px;
text-align: center;
border-top: 1px solid @border-color-split;
border-radius: 0 0 @border-radius-base @border-radius-base;
transition: all 0.3s;
div {
display: inline-block;
width: 50%;
cursor: pointer;
transition: all 0.3s;
user-select: none;
&:only-child {
width: 100%;
}
&:not(:only-child):last-child {
border-left: 1px solid @border-color-split;
}
}
}

View File

@ -0,0 +1,113 @@
import { Avatar, List } from 'antd';
import React from 'react';
import classNames from 'classnames';
import styles from './NoticeList.less';
export type NoticeIconTabProps = {
count?: number;
showClear?: boolean;
showViewMore?: boolean;
style?: React.CSSProperties;
title: string;
tabKey: API.NoticeIconItemType;
onClick?: (item: API.NoticeIconItem) => void;
onClear?: () => void;
emptyText?: string;
clearText?: string;
viewMoreText?: string;
list: API.NoticeIconItem[];
onViewMore?: (e: any) => void;
};
const NoticeList: React.FC<NoticeIconTabProps> = ({
list = [],
onClick,
onClear,
title,
onViewMore,
emptyText,
showClear = true,
clearText,
viewMoreText,
showViewMore = false,
}) => {
if (!list || list.length === 0) {
return (
<div className={styles.notFound}>
<img
src="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg"
alt="not found"
/>
<div>{emptyText}</div>
</div>
);
}
return (
<div>
<List<API.NoticeIconItem>
className={styles.list}
dataSource={list}
renderItem={(item, i) => {
const itemCls = classNames(styles.item, {
[styles.read]: item.read,
});
// eslint-disable-next-line no-nested-ternary
const leftIcon = item.avatar ? (
typeof item.avatar === 'string' ? (
<Avatar className={styles.avatar} src={item.avatar} />
) : (
<span className={styles.iconElement}>{item.avatar}</span>
)
) : null;
return (
<List.Item
className={itemCls}
key={item.key || i}
onClick={() => {
onClick?.(item);
}}
>
<List.Item.Meta
className={styles.meta}
avatar={leftIcon}
title={
<div className={styles.title}>
{item.title}
<div className={styles.extra}>{item.extra}</div>
</div>
}
description={
<div>
<div className={styles.description}>{item.description}</div>
<div className={styles.datetime}>{item.datetime}</div>
</div>
}
/>
</List.Item>
);
}}
/>
<div className={styles.bottomBar}>
{showClear ? (
<div onClick={onClear}>
{clearText} {title}
</div>
) : null}
{showViewMore ? (
<div
onClick={(e) => {
if (onViewMore) {
onViewMore(e);
}
}}
>
{viewMoreText}
</div>
) : null}
</div>
</div>
);
};
export default NoticeList;

View File

@ -0,0 +1,35 @@
@import '~antd/es/style/themes/default.less';
.popover {
position: relative;
width: 336px;
}
.noticeButton {
display: inline-block;
cursor: pointer;
transition: all 0.3s;
}
.icon {
padding: 4px;
vertical-align: middle;
}
.badge {
font-size: 16px;
}
.tabs {
:global {
.ant-tabs-nav-list {
margin: auto;
}
.ant-tabs-nav-scroll {
text-align: center;
}
.ant-tabs-bar {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { Tag, message } from 'antd';
import { groupBy } from 'lodash';
import moment from 'moment';
import { useModel, useRequest } from 'umi';
import { getNotices } from '@/services/ant-design-pro/api';
import NoticeIcon from './NoticeIcon';
import styles from './index.less';
export type GlobalHeaderRightProps = {
fetchingNotices?: boolean;
onNoticeVisibleChange?: (visible: boolean) => void;
onNoticeClear?: (tabName?: string) => void;
};
const getNoticeData = (notices: API.NoticeIconItem[]): Record<string, API.NoticeIconItem[]> => {
if (!notices || notices.length === 0 || !Array.isArray(notices)) {
return {};
}
const newNotices = notices.map((notice) => {
const newNotice = { ...notice };
if (newNotice.datetime) {
newNotice.datetime = moment(notice.datetime as string).fromNow();
}
if (newNotice.id) {
newNotice.key = newNotice.id;
}
if (newNotice.extra && newNotice.status) {
const color = {
todo: '',
processing: 'blue',
urgent: 'red',
doing: 'gold',
}[newNotice.status];
newNotice.extra = (
<Tag
color={color}
style={{
marginRight: 0,
}}
>
{newNotice.extra}
</Tag>
) as any;
}
return newNotice;
});
return groupBy(newNotices, 'type');
};
const getUnreadData = (noticeData: Record<string, API.NoticeIconItem[]>) => {
const unreadMsg: Record<string, number> = {};
Object.keys(noticeData).forEach((key) => {
const value = noticeData[key];
if (!unreadMsg[key]) {
unreadMsg[key] = 0;
}
if (Array.isArray(value)) {
unreadMsg[key] = value.filter((item) => !item.read).length;
}
});
return unreadMsg;
};
const NoticeIconView: React.FC = () => {
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
const [notices, setNotices] = useState<API.NoticeIconItem[]>([]);
const { data } = useRequest(getNotices);
useEffect(() => {
setNotices(data || []);
}, [data]);
const noticeData = getNoticeData(notices);
const unreadMsg = getUnreadData(noticeData || {});
const changeReadState = (id: string) => {
setNotices(
notices.map((item) => {
const notice = { ...item };
if (notice.id === id) {
notice.read = true;
}
return notice;
}),
);
};
const clearReadState = (title: string, key: string) => {
setNotices(
notices.map((item) => {
const notice = { ...item };
if (notice.type === key) {
notice.read = true;
}
return notice;
}),
);
message.success(`${'清空了'} ${title}`);
};
return (
<NoticeIcon
className={styles.action}
count={currentUser && currentUser.unreadCount}
onItemClick={(item) => {
changeReadState(item.id!);
}}
onClear={(title: string, key: string) => clearReadState(title, key)}
loading={false}
clearText="清空"
viewMoreText="查看更多"
onViewMore={() => message.info('Click on view more')}
clearClose
>
<NoticeIcon.Tab
tabKey="notification"
count={unreadMsg.notification}
list={noticeData.notification}
title="通知"
emptyText="你已查看所有通知"
showViewMore
/>
<NoticeIcon.Tab
tabKey="message"
count={unreadMsg.message}
list={noticeData.message}
title="消息"
emptyText="您已读完所有消息"
showViewMore
/>
<NoticeIcon.Tab
tabKey="event"
title="待办"
emptyText="你已完成所有待办"
count={unreadMsg.event}
list={noticeData.event}
showViewMore
/>
</NoticeIcon>
);
};
export default NoticeIconView;

View File

@ -0,0 +1,97 @@
import React, { useCallback } from 'react';
import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
import { Avatar, Menu, Spin } from 'antd';
import { history } from 'umi';
import { stringify } from 'querystring';
import HeaderDropdown from '../HeaderDropdown';
import styles from './index.less';
import type { MenuInfo } from 'rc-menu/lib/interface';
import RoutePath from '@/routes/routePath';
import { CACHE_TOKEN } from '@/constants/cacheKey';
export type GlobalHeaderRightProps = {
menu?: boolean;
};
/**
* 退 url
*/
const loginOut = async () => {
const { query = {}, search, pathname } = history.location;
const { redirect } = query;
// Note: There may be security issues, please note
if (window.location.pathname !== RoutePath.LOGIN && !redirect) {
localStorage.removeItem(CACHE_TOKEN);
history.replace({
pathname: RoutePath.LOGIN,
search: stringify({
redirect: pathname + search,
}),
});
}
};
const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({ menu }) => {
const onMenuClick = useCallback((event: MenuInfo) => {
const { key } = event;
if (key === 'logout') {
loginOut();
return;
}
history.push(`/account/${key}`);
}, []);
const loading = (
<span className={`${styles.action} ${styles.account}`}>
<Spin
size="small"
style={{
marginLeft: 8,
marginRight: 8,
}}
/>
</span>
);
const currentUser = {
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
name: 'admin',
};
if (!currentUser || !currentUser.name) {
return loading;
}
const menuHeaderDropdown = (
<Menu className={styles.menu} selectedKeys={[]} onClick={onMenuClick}>
{menu && (
<Menu.Item key="center">
<UserOutlined />
</Menu.Item>
)}
{menu && (
<Menu.Item key="settings">
<SettingOutlined />
</Menu.Item>
)}
{menu && <Menu.Divider />}
<Menu.Item key="logout">
<LogoutOutlined />
退
</Menu.Item>
</Menu>
);
return (
<HeaderDropdown overlay={menuHeaderDropdown}>
<span className={`${styles.action} ${styles.account}`}>
<Avatar size="small" className={styles.avatar} src={currentUser.avatar} alt="avatar" />
<span className={`${styles.name} anticon`}>{currentUser.name}</span>
</span>
</HeaderDropdown>
);
};
export default AvatarDropdown;

View File

@ -0,0 +1,84 @@
@import '~antd/es/style/themes/default.less';
@pro-header-hover-bg: rgba(0, 0, 0, 0.025);
.menu {
:global(.anticon) {
margin-right: 8px;
}
:global(.ant-dropdown-menu-item) {
min-width: 160px;
}
}
.right {
display: flex;
float: right;
height: 48px;
margin-left: auto;
overflow: hidden;
.action {
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
cursor: pointer;
transition: all 0.3s;
> span {
vertical-align: middle;
}
&:hover {
background: @pro-header-hover-bg;
}
&:global(.opened) {
background: @pro-header-hover-bg;
}
}
.search {
padding: 0 12px;
&:hover {
background: transparent;
}
}
.account {
.avatar {
margin-right: 8px;
color: @primary-color;
vertical-align: top;
background: rgba(255, 255, 255, 0.85);
}
}
}
.dark {
.action {
&:hover {
background: #252a3d;
}
&:global(.opened) {
background: #252a3d;
}
}
}
@media only screen and (max-width: @screen-md) {
:global(.ant-divider-vertical) {
vertical-align: unset;
}
.name {
display: none;
}
.right {
position: absolute;
top: 0;
right: 12px;
.account {
.avatar {
margin-right: 0;
}
}
.search {
display: none;
}
}
}

View File

@ -0,0 +1,25 @@
import { Space } from 'antd';
import React from 'react';
import Avatar from './AvatarDropdown';
import styles from './index.less';
export type SiderTheme = 'light' | 'dark';
const GlobalHeaderRight: React.FC = () => {
// if (!initialState || !initialState.settings) {
// return null;
// }
// const { navTheme, layout } = initialState.settings;
const className = styles.right;
// if ((navTheme === 'dark' && layout === 'top') || layout === 'mix') {
// className = `${styles.right} ${styles.dark}`;
// }
return (
<Space className={className}>
<Avatar />
</Space>
);
};
export default GlobalHeaderRight;

View File

@ -0,0 +1,13 @@
.delete-btn {
color: @error-color;
&:hover {
color: @error-color !important;
}
}
.disabled {
color: @disabled-color;
cursor: not-allowed;
&:hover {
color: @disabled-color !important;
}
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Popconfirm } from 'antd';
import styles from './index.less';
import classNames from 'classnames';
interface PropsType {
onDelete: () => void;
disabled?: boolean;
}
const DeleteButton: React.FC<PropsType> = ({ onDelete, disabled = false }) => {
return (
<Popconfirm
title="是否确认删除当前项?"
disabled={disabled}
onConfirm={onDelete}
okText="是"
cancelText="否"
>
<a className={classNames(styles.deleteBtn, disabled ? styles.disabled : '')}></a>
</Popconfirm>
);
};
export default DeleteButton;

View File

@ -0,0 +1,261 @@
import React, { useMemo, useCallback, useState, useRef } from 'react';
import {
Button,
message,
Modal,
Space,
Divider,
AlertProps,
Alert,
Typography,
Tooltip,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import ProTable, { ProColumns, ActionType, ProTableProps } from '@ant-design/pro-table';
export interface toolBarActionsItem {
type: 'add' | 'batchDelete';
text?: string;
onConfirm: (val?: string[]) => void;
}
export interface PropsType extends ProTableProps<any, any> {
toolBarActions?: toolBarActionsItem[];
alertProps?: AlertProps;
indexColumn?: boolean | ProColumns<any, 'text'>;
}
type Record<K extends keyof any, T> = {
[P in K]: T;
};
const Table = <T extends Record<string, any>>(props: PropsType) => {
const {
columns: columnsProps = [],
search: searchProps = {},
pagination: paginationProps,
options = false,
toolBarRender: toolBarRenderProps,
toolBarActions,
actionRef: actionRefProps,
rowSelection: rowSelectionProps,
toolbar: toolbarProps,
alertProps,
indexColumn,
} = props;
const defaultRef = useRef<ActionType>();
const actionRef: any = actionRefProps || defaultRef;
const [selectedRows, setSelectedRows] = useState<React.Key[]>([]);
// 暂时有批量删除的话替换rowSelection后续有发现问题再改
const rowSelection = useMemo(() => {
if (toolBarActions && toolBarActions.findIndex((item) => item.type === 'batchDelete') > -1) {
return {
selectedRowKeys: selectedRows,
onChange: setSelectedRows,
preserveSelectedRowKeys: true,
};
}
return rowSelectionProps
? {
preserveSelectedRowKeys: true,
...rowSelectionProps,
}
: false;
}, [rowSelectionProps, toolBarActions, setSelectedRows, selectedRows]);
const indexColumnValue = useMemo(() => {
if (!indexColumn) return [];
const rankItem = [
{
title: '序号',
dataIndex: 'index',
width: 80,
align: 'center',
search: false,
render: (_: any, item: any, index: number) => {
// @ts-ignore
const { current, pageSize } = actionRef.current?.pageInfo;
return index + 1 + (current - 1) * pageSize;
},
...(typeof indexColumn === 'object' ? indexColumn : {}),
},
];
return rankItem;
}, [indexColumn, actionRef]);
const columns = useMemo(() => {
const newColumns = columnsProps.map((item) => {
const optionRender = (_: any, val: any, ...rest: any) => {
return (
<Space size={0} split={<Divider type="vertical" />}>
{(item.render && (item.render(_, val, ...rest) as []))?.map((item2) => item2)}
</Space>
);
};
let { render } = item;
if (item.valueType === 'option') {
render = optionRender;
}
return {
...item,
fieldProps:
item.valueType === 'select'
? {
...item.fieldProps,
showSearch: (item.fieldProps as any)?.options?.length > 8,
}
: item.fieldProps,
render,
};
});
return indexColumnValue.concat(newColumns);
}, [columnsProps, indexColumnValue]);
// search属性
const search = useMemo(() => {
if (searchProps === false) {
return searchProps;
}
return {
...searchProps,
optionRender: ({ searchText, resetText }, { form }) => [
<Button
key="search"
type="primary"
onClick={() => {
form?.submit();
}}
>
{searchText}
</Button>,
<Button
key="rest"
onClick={() => {
form?.resetFields();
form?.submit();
props.onReset && props.onReset();
}}
>
{resetText}
</Button>,
],
};
}, [searchProps, columnsProps]);
const handleBatchDelete = (action: any) => {
if (selectedRows.length === 0) {
message.warn('请选择要操作的项');
return;
}
Modal.confirm({
title: '提示',
content: '是否确认删除?',
okText: '确认',
cancelText: '取消',
onOk: () => {
action(selectedRows);
},
});
};
// 渲染toolbarActions自定义按钮
const toolBarActionsRender = useCallback(() => {
const buttonList: any = [];
toolBarActions &&
toolBarActions.forEach((item) => {
if (item.type === 'add') {
buttonList.push(
<Button
key="add"
type="primary"
onClick={() => {
item.onConfirm();
}}
>
<PlusOutlined />
{item.text || '新建'}
</Button>,
);
} else if (item.type === 'batchDelete') {
buttonList.push(
<Button
key="del"
danger
onClick={() => {
handleBatchDelete(item.onConfirm);
}}
>
{item.text || '批量删除'}
</Button>,
);
}
});
return buttonList;
}, [toolBarActions, selectedRows]);
// 渲染toolbar
const toolBarRender = useCallback(
(
action,
rows: {
selectedRowKeys?: (string | number)[];
selectedRows?: T[];
},
) => {
let toolRender = toolBarRenderProps ? toolBarRenderProps(action, rows) : toolBarRenderProps;
if (toolBarActions) {
const buttonList: any = toolBarActionsRender();
toolRender = toolRender ? toolRender.concat(buttonList) : buttonList;
}
return toolRender;
},
[toolBarRenderProps, toolBarActions, toolBarActionsRender],
);
// 分页统一为10条
const pagination = useMemo(() => {
if (paginationProps === undefined) {
return { defaultCurrent: 1, defaultPageSize: 10, pageSize: 10, current: 1 };
}
return paginationProps;
}, [paginationProps]);
// 渲染tip用toolbar的filter
const toolbar = useMemo(() => {
if (alertProps) {
return {
...toolbarProps,
multipleLine: true,
filter: <Alert {...alertProps} />,
};
}
return toolbarProps;
}, [alertProps, toolbarProps]);
return (
<div className="ls-table">
<ProTable<T>
{...{
...props,
search,
pagination,
columns,
rowSelection,
options,
toolbar,
toolBarRender: toolBarRenderProps || toolBarActions ? toolBarRender : toolBarRenderProps,
actionRef,
onLoad: (res) => {
if (res.length === 0 && actionRef?.current.pageInfo.current !== 1) {
actionRef?.current.setPageInfo({
...actionRef?.current.pageInfo,
current: actionRef?.current.pageInfo.current - 1,
});
}
},
}}
/>
</div>
);
};
export type { ProColumns, ActionType, ProTableProps };
export default Table;

View File

@ -0,0 +1,23 @@
import React from 'react';
import Text from 'antd/es/typography/Text';
import moment, { Moment } from 'moment';
const DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export interface TimeTextProps {
value?: Moment;
format?: string;
}
/**
*
*
*/
const TimeText: React.FC<TimeTextProps> = (props) => {
const { value, format } = props;
const txtFormat = format ? format : DEFAULT_FORMAT;
const txt = value ? moment(value).format(txtFormat) : '-';
return <Text>{txt}</Text>;
};
export default TimeText;

View File

@ -0,0 +1,14 @@
.upload {
:global {
.ant-upload-list-picture-card .ant-upload-list-item-info::before {
left: 0;
}
}
&.hide-button {
:global {
.ant-upload {
display: none;
}
}
}
}

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Upload as AntdUpload } from '@formily/antd';
import { UploadProps as AntdUploadProps, Modal } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { UploadChangeParam, UploadFile, UploadListType } from 'antd/lib/upload/interface';
import { baseRequestForm as request } from '@/utils/request';
import classNames from 'classnames';
import { UploadBizType } from '@/constants/enum/uploadBizType';
import { FileType } from '@/constants/enum/fileType';
import { getFileType } from '@/utils/getFileType';
import { CACHE_TOKEN } from '@/constants/cacheKey';
import styles from './index.less';
const UPLOAD_URL = '/admin/common/upload';
interface UploadPropsType extends AntdUploadProps {
value?: UploadFile<any>[];
bizType: UploadBizType;
}
const Upload: React.FC<UploadPropsType> = ({
listType = 'picture-card',
accept = 'image/*',
value = [],
maxCount = 1,
bizType,
onChange,
...rest
}) => {
// 图片预览
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewImage, setPreviewImage] = useState<string | undefined>('');
const [previewTitle, setPreviewTitle] = useState<string>('预览');
const handlePreview = async (file: UploadFile) => {
// // 文件上传失败时没有url
// if (
// (file.url && !imageExpendRegExp.test(file.url)) ||
// (!file.url && !imageExpendRegExp.test(file.name))
// )
// return;
// !file.url &&
// !file.preview &&
// (file.preview = await getBase64(file.originFileObj));
const { ...rest1 } = file;
setPreviewImage(file.url || file.preview);
setPreviewVisible(true);
setPreviewTitle(file.name || '预览');
};
// onChange
const handleChange = (fileList) => {
fileList = fileList.slice(-maxCount);
fileList.forEach((item) => {
if (item.response && item.response.data) {
const resData = item.response.data;
resData.url && (item.url = resData.url);
resData.path && (item.path = resData.path);
}
});
onChange && onChange(fileList);
};
const props = {
listType,
accept,
onPreview: handlePreview,
maxCount,
onChange: handleChange,
// customRequest: handleCustomRequest,
fileList: value,
action: `${UPLOAD_URL}?type=${bizType}`,
headers: { token: localStorage.getItem(CACHE_TOKEN) },
...rest,
};
const className = {
[props.className!]: true,
[styles.upload]: true,
[styles.hideButton]: value.length >= maxCount,
};
return (
<div>
<AntdUpload {...{ ...props, className: classNames(className) }} />
<Modal
visible={previewVisible}
title={previewTitle}
footer={null}
width={600}
onCancel={() => setPreviewVisible(false)}
>
{getFileType(previewImage) === FileType.VIDEO ? (
<video controls style={{ width: '100%' }} src={previewImage} />
) : (
<img alt="预览" style={{ width: '100%' }} src={previewImage} />
)}
</Modal>
</div>
);
};
export default Upload;

View File

@ -0,0 +1 @@
export const CACHE_TOKEN = 'token';

View File

@ -0,0 +1,4 @@
export enum BlindboxStatusType {
PUBLISHED = 'published',
NO_PUBLISH = 'no_publish',
}

View File

@ -0,0 +1,5 @@
export enum FileType {
IMAGE = 'image',
VIDEO = 'video',
}
export default FileType;

View File

@ -0,0 +1,11 @@
/**
*
* @author gary
*/
export enum UploadBizType {
WORK = 'work', // 应用信息相关
AVATAR = 'avatar', // 头像
AUTHOR = 'author', // 作者
BLIND_BOX = 'blind_box', // 盲盒
COLLECT = 'collect', // 集卡
}

View File

@ -0,0 +1,13 @@
export enum WorkType {
VIDEO = 'video',
AUDIO = 'audio',
PICTURE = 'picture',
}
export enum WorkRarityType {
UR = 'UR',
SSR = 'SSR',
SR = 'SR',
R = 'R',
N = 'N',
}

61
src/global.less Normal file
View File

@ -0,0 +1,61 @@
@import '~antd/es/style/themes/default.less';
@import './styles/antdGlobal.less';
* {
margin: 0;
}
html,
body,
#root {
height: 100%;
}
.colorWeak {
filter: invert(80%);
}
.ant-layout {
min-height: 100vh;
}
.ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed {
left: unset;
}
canvas {
display: block;
}
body {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
ul,
ol {
list-style: none;
}
@media (max-width: @screen-xs) {
.ant-table {
width: 100%;
overflow-x: auto;
&-thead > tr,
&-tbody > tr {
> th,
> td {
white-space: pre;
> span {
display: block;
}
}
}
}
}
// Compatible with IE11
@media screen and(-ms-high-contrast: active), (-ms-high-contrast: none) {
body .ant-design-pro > .ant-layout {
min-height: 100vh;
}
}

91
src/global.tsx Normal file
View File

@ -0,0 +1,91 @@
import { Button, message, notification } from 'antd';
import { useIntl } from 'umi';
import defaultSettings from '../config/defaultSettings';
const { pwa } = defaultSettings;
const isHttps = document.location.protocol === 'https:';
const clearCache = () => {
// remove all caches
if (window.caches) {
caches
.keys()
.then((keys) => {
keys.forEach((key) => {
caches.delete(key);
});
})
.catch((e) => console.log(e));
}
};
// if pwa is true
if (pwa) {
// Notify user if offline now
window.addEventListener('sw.offline', () => {
message.warning(useIntl().formatMessage({ id: 'app.pwa.offline' }));
});
// Pop up a prompt on the page asking the user if they want to use the latest version
window.addEventListener('sw.updated', (event: Event) => {
const e = event as CustomEvent;
const reloadSW = async () => {
// Check if there is sw whose state is waiting in ServiceWorkerRegistration
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
const worker = e.detail && e.detail.waiting;
if (!worker) {
return true;
}
// Send skip-waiting event to waiting SW with MessageChannel
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (msgEvent) => {
if (msgEvent.data.error) {
reject(msgEvent.data.error);
} else {
resolve(msgEvent.data);
}
};
worker.postMessage({ type: 'skip-waiting' }, [channel.port2]);
});
clearCache();
window.location.reload();
return true;
};
const key = `open${Date.now()}`;
const btn = (
<Button
type="primary"
onClick={() => {
notification.close(key);
reloadSW();
}}
>
{useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated.ok' })}
</Button>
);
notification.open({
message: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated' }),
description: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated.hint' }),
btn,
key,
onClose: async () => null,
});
});
} else if ('serviceWorker' in navigator && isHttps) {
// unregister service worker
const { serviceWorker } = navigator;
if (serviceWorker.getRegistrations) {
serviceWorker.getRegistrations().then((sws) => {
sws.forEach((sw) => {
sw.unregister();
});
});
}
serviceWorker.getRegistration().then((sw) => {
if (sw) sw.unregister();
});
clearCache();
}

22
src/manifest.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "Ant Design Pro",
"short_name": "Ant Design Pro",
"display": "standalone",
"start_url": "./?utm_source=homescreen",
"theme_color": "#002140",
"background_color": "#001529",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512"
}
]
}

18
src/pages/404.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Button, Result } from 'antd';
import React from 'react';
import { history } from 'umi';
const NoFoundPage: React.FC = () => (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Button type="primary" onClick={() => history.push('/')}>
Back Home
</Button>
}
/>
);
export default NoFoundPage;

View File

@ -0,0 +1,55 @@
@import '~antd/es/style/themes/default.less';
.container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: auto;
background: @layout-body-background;
:global {
.ant-pro-form-login-header {
margin-bottom: 40px;
}
}
}
.lang {
width: 100%;
height: 40px;
line-height: 44px;
text-align: right;
:global(.ant-dropdown-trigger) {
margin-right: 24px;
}
}
.content {
flex: 1;
padding: 32px 0;
}
@media (min-width: @screen-md-min) {
.container {
background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg');
background-repeat: no-repeat;
background-position: center 110px;
background-size: 100%;
}
.content {
padding: 100px 0 24px;
}
}
.icon {
margin-left: 8px;
color: rgba(0, 0, 0, 0.2);
font-size: 24px;
vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: @primary-color;
}
}

87
src/pages/Login/index.tsx Normal file
View File

@ -0,0 +1,87 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { ProFormText, LoginForm } from '@ant-design/pro-form';
import { useIntl, history, FormattedMessage, useModel } from 'umi';
import { login } from '@/services/login';
import defaultSettings from '../../../config/defaultSettings';
import styles from './index.less';
import { CACHE_TOKEN } from '@/constants/cacheKey';
const Login: React.FC = () => {
const intl = useIntl();
const handleSubmit = async (values: API.LoginParams) => {
// 登录
const res: any = await login({ ...values });
localStorage.setItem(CACHE_TOKEN, res.token);
/** 此方法会跳转到 redirect 参数所在的位置 */
if (!history) return;
const { query } = history.location;
const { redirect } = query as { redirect: string };
history.push(redirect || '/');
};
return (
<div className={styles.container}>
<div className={styles.content}>
<LoginForm
logo={<img alt="logo" src="/logo.svg" />}
title={defaultSettings.title as string}
initialValues={{
autoLogin: true,
}}
onFinish={async (values) => {
await handleSubmit(values as API.LoginParams);
}}
>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined className={styles.prefixIcon} />,
}}
placeholder={intl.formatMessage({
id: 'pages.login.username.placeholder',
defaultMessage: '用户名',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="pages.login.username.required"
defaultMessage="请输入用户名!"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined className={styles.prefixIcon} />,
}}
placeholder={intl.formatMessage({
id: 'pages.login.password.placeholder',
defaultMessage: '密码',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="pages.login.password.required"
defaultMessage="请输入密码!"
/>
),
},
]}
/>
</LoginForm>
</div>
</div>
);
};
export default Login;

View File

@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
// import { queryAuthorList } from '@/services/author';
import { Select } from 'antd';
import _debounce from 'lodash/debounce';
export interface Options {
label: string;
value: string;
}
interface AuthorSelectPropsType {
params?: any;
value?: string | number;
defaultOptions: Options[];
}
const AuthorSelect: React.FC<AuthorSelectPropsType> = ({
params = {},
value,
defaultOptions,
...rest
}) => {
const [options, setOptions] = useState<Options[]>([]);
const [isFirst, setIsFirst] = useState(true);
useEffect(() => {
if (isFirst) {
if (defaultOptions.length > 0 && defaultOptions.findIndex((item) => !item.label) === -1) {
setOptions(defaultOptions);
setIsFirst(false);
}
}
}, [defaultOptions, isFirst]);
const handleSearch = async (name: string) => {
// const { data } = await queryAuthorList({
// page: 1,
// num: 10,
// name,
// });
// const optionsList = data?.map((item) => ({
// label: `${item.first_name} ${item.last_name}`,
// value: item.id,
// }));
// setOptions(optionsList);
};
const handleFocus = () => {
handleSearch('');
};
return (
<Select
{...rest}
options={options}
value={value}
allowClear
showSearch
onFocus={handleFocus}
optionFilterProp="label"
onSearch={_debounce(handleSearch, 500)}
/>
);
};
export default AuthorSelect;

View File

@ -0,0 +1,289 @@
import React, { useState, useEffect } from 'react';
import { createForm } from '@formily/core';
import { createSchemaField } from '@formily/react';
import {
Form,
FormItem,
Input,
Select,
Submit,
FormGrid,
ArrayItems,
Editable,
Switch,
ArrayTable,
FormButtonGroup,
NumberPicker,
} from '@formily/antd';
import { observable } from '@formily/reactive';
import Upload from '@/components/Upload';
import { WorkType } from '@/constants/enum/work';
import { message, Button, Spin } from 'antd';
import Editor from '@/components/Editor';
import DetailPageContainer from '@/components/DetailPageContainer';
import { addWork, updateWork, queryWorkDetail } from '@/services/work';
import { useLocation } from 'umi';
import { UploadBizType } from '@/constants/enum/uploadBizType';
import AuthorSelect, { Options } from './components/AuthorSelector';
const SchemaField = createSchemaField({
components: {
FormItem,
FormGrid,
Input,
Select,
ArrayTable,
Upload,
Switch,
ArrayItems,
NumberPicker,
Editable,
Editor,
AuthorSelect,
},
});
const obs: { authorDefaultOption: Options[] } = observable({
authorDefaultOption: [],
});
const workTypeList = [
{
label: '视频',
value: WorkType.VIDEO,
},
{
label: '音频',
value: WorkType.AUDIO,
},
{
label: '图片',
value: WorkType.PICTURE,
},
];
const form = createForm({});
const WorkEdit = () => {
const { id } = useLocation().query;
const [loading, setLoading] = useState(false);
const getWorkDetail = async () => {
const res: any = await queryWorkDetail({ id });
const resourseList = res.resource_arr.map((item) => ({
...item,
img: [{ uid: item.path, path: item.path, url: item.url }],
}));
const newValue = { ...res, resource_arr: resourseList, author_id: res.author?.id };
obs.authorDefaultOption = [
{ label: `${res.author?.first_name} ${res.author?.last_name}`, value: res.author?.id },
];
form.setValues(newValue);
};
const handleSubmit = async (val) => {
const params = { ...val };
if (params.resource_arr.filter((item) => item.cover).length > 1) {
message.warning('只能设置一张图片为封面');
return;
}
if (params.resource_arr.filter((item) => item.cover).length === 0) {
message.warning('请设置一张图片为封面');
return;
}
if (params.resource_arr.filter((item) => item.avatar).length > 1) {
message.warning('只能设置一个为nft资源');
return;
}
if (params.resource_arr.filter((item) => item.avatar).length === 0) {
message.warning('请设置一个为nft资源');
return;
}
params.resource_arr.forEach((item, index) => {
item.path = item.img[0].path;
item.order_num = index;
});
setLoading(true);
try {
if (id) {
await updateWork(params);
} else {
await addWork(params);
}
message.success('操作成功');
setLoading(false);
history.back();
} catch (e) {
setLoading(false);
}
};
const handleAddResoruce = () => {
const resourceList = form.getFieldState('resource_arr').value;
if (resourceList.length >= 5) {
message.warning('只能添加5个资源');
return;
}
form.setFieldState('resource_arr', (state) => {
state.value = [...resourceList, {}];
});
};
useEffect(() => {
id && getWorkDetail();
return () => {
form.setValues({}, 'overwrite');
obs.authorDefaultOption = [];
};
}, []);
return (
<DetailPageContainer>
<Spin spinning={loading}>
<Form form={form} labelCol={4} wrapperCol={18} onAutoSubmit={handleSubmit}>
<SchemaField>
<SchemaField.String
name="title"
title="标题"
required
x-decorator="FormItem"
x-component="Input"
/>
<SchemaField.String
name="sub_title"
title="副标题"
required
x-component-props={{ maxLength: 100, showCount: true }}
x-decorator="FormItem"
x-component="Input.TextArea"
/>
<SchemaField.String
name="series"
title="系列"
required
x-decorator="FormItem"
x-component="Input"
/>
<SchemaField.Number
name="rarity"
title="等级"
required
description="rarity < 0 UR,
rarity >= 0 && rarity < 100 SSR,
rarity > =100 && rarity < 1000 SR,
rarity >= 1000 && rarity < 10000 R,
rarity >= 10000 N"
x-validator="number"
x-decorator="FormItem"
x-component="NumberPicker"
/>
<SchemaField.String
name="author_id"
title="作者"
required
x-component-props={{
defaultOptions: obs.authorDefaultOption,
}}
x-reactions={(field) => {
field.component[1].defaultOptions = obs.authorDefaultOption;
}}
x-decorator="FormItem"
x-component="AuthorSelect"
/>
<SchemaField.String
name="type"
title="类型"
required
x-component-props={{
options: workTypeList,
}}
x-decorator="FormItem"
x-component="Select"
/>
<SchemaField.String
name="introduction"
title="描述"
required
x-decorator="FormItem"
x-component="Editor"
/>
<SchemaField.Array
name="resource_arr"
title="资源"
required
// description="资源推荐尺寸420x325"
x-decorator="FormItem"
x-component="ArrayTable"
x-component-props={{
pagination: { pageSize: 10 },
}}
>
<SchemaField.Object>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{ width: 60, title: '拖动排序', align: 'center' }}
>
<SchemaField.Void
x-decorator="FormItem"
required
x-component="ArrayTable.SortHandle"
/>
</SchemaField.Void>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{ width: 80, title: '序号', align: 'center' }}
>
<SchemaField.String x-decorator="FormItem" x-component="ArrayTable.Index" />
</SchemaField.Void>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{ width: 200, title: '资源', align: 'center' }}
>
<SchemaField.String
required
x-decorator="FormItem"
name="img"
x-component-props={{ accept: 'image/*,video/mp4', bizType: UploadBizType.WORK }}
x-component="Upload"
/>
</SchemaField.Void>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{ width: 200, title: '设置为封面', align: 'center' }}
>
<SchemaField.String x-decorator="FormItem" name="cover" x-component="Switch" />
</SchemaField.Void>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{ width: 200, title: '设置为NFT资源', align: 'center' }}
>
<SchemaField.String x-decorator="FormItem" name="avatar" x-component="Switch" />
</SchemaField.Void>
<SchemaField.Void
x-component="ArrayTable.Column"
x-component-props={{
title: '操作',
dataIndex: 'operations',
width: 200,
fixed: 'right',
}}
>
<SchemaField.Void x-component="FormItem">
<SchemaField.Void x-component="ArrayTable.Remove" />
</SchemaField.Void>
</SchemaField.Void>
</SchemaField.Object>
</SchemaField.Array>
</SchemaField>
<FormButtonGroup.FormItem style={{ marginBottom: 20 }}>
<Button type="dashed" onClick={handleAddResoruce} block>
</Button>
</FormButtonGroup.FormItem>
<FormButtonGroup.FormItem>
<Submit block size="large">
</Submit>
</FormButtonGroup.FormItem>
</Form>
</Spin>
</DetailPageContainer>
);
};
export default WorkEdit;

View File

@ -0,0 +1,97 @@
import React, { useRef } from 'react';
import Table, { ProColumns, ActionType } from '@/components/Table';
import { Image } from 'antd';
import DeleteButton from '@/components/Table/DeleteButton';
import { history, Link } from 'umi';
import RoutePath from '@/routes/routePath';
import { queryWorkList, deleteWork } from '@/services/work';
import { fetchTableData } from '@/utils/table';
import TimeText from '@/components/Typography/TimeText';
const WorkList = () => {
const tableRef = useRef<ActionType>();
const handleDelete = async (id) => {
await deleteWork({ id });
tableRef.current?.reload();
};
const columns: ProColumns<any>[] = [
{
title: 'ID',
dataIndex: 'id',
width: '10%',
hideInSearch: true,
},
{
title: '封面',
dataIndex: 'url',
valueType: 'image',
hideInSearch: true,
width: '20%',
render: (_, row) => {
return <Image src={row.cover_resource?.url} height={100} alt="" />;
},
},
{
title: '标题',
dataIndex: 'title',
ellipsis: true,
},
{
title: '系列',
dataIndex: 'series',
width: '10%',
hideInSearch: true,
},
{
title: '等级',
dataIndex: 'rarity_name',
width: '10%',
hideInSearch: true,
},
{
title: '创建时间',
dataIndex: 'created_at',
valueType: 'dateRange',
width: '20%',
hideInSearch: true,
render: (_, row) => {
return <TimeText value={row.created_at} />;
},
},
{
title: '操作',
valueType: 'option',
width: 150,
render: (_, row) => [
<Link to={`${RoutePath.WORK.EDIT}?id=${row.id}`}></Link>,
<DeleteButton
onDelete={() => {
handleDelete(row.id);
}}
/>,
],
},
];
return (
<Table
columns={columns}
// indexColumn
rowKey="id"
actionRef={tableRef}
toolBarActions={[
{
type: 'add',
onConfirm: () => {
history.push(RoutePath.WORK.EDIT);
},
},
]}
request={async (params) => {
return fetchTableData(queryWorkList, params);
}}
/>
);
};
export default WorkList;

235
src/pages/document.ejs Normal file
View File

@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="keywords"
content="antd,umi,umijs,ant design,Scaffolding, layout, Ant Design, project, Pro, admin, console, homepage, out-of-the-box, middle and back office, solution, component library"
/>
<meta
name="description"
content="
An out-of-box UI solution for enterprise applications as a React boilerplate."
/>
<meta
name="description"
content="
Out-of-the-box mid-stage front-end/design solution."
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>Pro</title>
<link rel="icon" href="<%= context.config.publicPath +'logo.png'%>" type="image/png" />
</head>
<body>
<noscript>
<div class="noscript-container">
Hi there! Please
<div class="noscript-enableJS">
<a href="https://www.enablejavascript.io/en" target="_blank" rel="noopener noreferrer">
<b>enable Javascript</b>
</a>
</div>
in your browser to use Ant Design, Out-of-the-box mid-stage front/design solution!
</div>
</noscript>
<div id="root">
<style>
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
#root {
background-repeat: no-repeat;
background-size: 100% auto;
}
.noscript-container {
display: flex;
align-content: center;
justify-content: center;
margin-top: 90px;
font-size: 20px;
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode',
Geneva, Verdana, sans-serif;
}
.noscript-enableJS {
padding-right: 3px;
padding-left: 3px;
}
.page-loading-warp {
display: flex;
align-items: center;
justify-content: center;
padding: 98px;
}
.ant-spin {
position: absolute;
display: none;
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.65);
color: #1890ff;
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
text-align: center;
list-style: none;
opacity: 0;
-webkit-transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
-webkit-font-feature-settings: 'tnum';
font-feature-settings: 'tnum';
}
.ant-spin-spinning {
position: static;
display: inline-block;
opacity: 1;
}
.ant-spin-dot {
position: relative;
display: inline-block;
width: 20px;
height: 20px;
font-size: 20px;
}
.ant-spin-dot-item {
position: absolute;
display: block;
width: 9px;
height: 9px;
background-color: #1890ff;
border-radius: 100%;
-webkit-transform: scale(0.75);
-ms-transform: scale(0.75);
transform: scale(0.75);
-webkit-transform-origin: 50% 50%;
-ms-transform-origin: 50% 50%;
transform-origin: 50% 50%;
opacity: 0.3;
-webkit-animation: antspinmove 1s infinite linear alternate;
animation: antSpinMove 1s infinite linear alternate;
}
.ant-spin-dot-item:nth-child(1) {
top: 0;
left: 0;
}
.ant-spin-dot-item:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.ant-spin-dot-item:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.ant-spin-dot-item:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
.ant-spin-dot-spin {
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
-webkit-animation: antrotate 1.2s infinite linear;
animation: antRotate 1.2s infinite linear;
}
.ant-spin-lg .ant-spin-dot {
width: 32px;
height: 32px;
font-size: 32px;
}
.ant-spin-lg .ant-spin-dot i {
width: 14px;
height: 14px;
}
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.ant-spin-blur {
background: #fff;
opacity: 0.5;
}
}
@-webkit-keyframes antSpinMove {
to {
opacity: 1;
}
}
@keyframes antSpinMove {
to {
opacity: 1;
}
}
@-webkit-keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
</style>
<div
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 420px;
"
>
<!-- <img src="<%= context.config.publicPath +'logo.svg'%>" alt="logo" width="256" /> -->
<div class="page-loading-warp">
<div class="ant-spin ant-spin-lg ant-spin-spinning">
<span class="ant-spin-dot ant-spin-dot-spin"
><i class="ant-spin-dot-item"></i><i class="ant-spin-dot-item"></i
><i class="ant-spin-dot-item"></i><i class="ant-spin-dot-item"></i
></span>
</div>
</div>
<div style="display: flex; align-items: center; justify-content: center">
<img
src="<%= context.config.publicPath +'logo.svg'%>"
width="32"
style="margin-right: 8px"
/>
Pro
</div>
</div>
</div>
</body>
</html>

44
src/routes/index.ts Normal file
View File

@ -0,0 +1,44 @@
import RoutePath from './routePath';
/*
https://umijs.org/zh-CN/plugins/plugin-layout
*/
export default [
{
path: '/login',
layout: false,
component: './Login',
},
{
name: '设置',
path: RoutePath.SETTING,
routes: [
{
path: RoutePath.SETTING,
redirect: RoutePath.WORK.LIST,
hideInMenu: true,
},
{
name: '作品',
path: RoutePath.WORK.LIST,
component: './Work/List',
},
{
name: '作品详情',
path: RoutePath.WORK.EDIT,
hideInMenu: true,
component: './Work/Edit',
},
],
},
{
path: '/',
redirect: RoutePath.WORK.LIST,
},
{
component: './404',
},
];

11
src/routes/routePath.ts Normal file
View File

@ -0,0 +1,11 @@
const SETTING = '/setting';
const RoutePath = {
LOGIN: '/login',
SETTING: SETTING,
WORK: {
LIST: `${SETTING}/work`,
EDIT: `${SETTING}/work/edit`,
},
};
export default RoutePath;

65
src/service-worker.js Normal file
View File

@ -0,0 +1,65 @@
/* eslint-disable no-restricted-globals */
/* eslint-disable no-underscore-dangle */
/* globals workbox */
workbox.core.setCacheNameDetails({
prefix: 'antd-pro',
suffix: 'v5',
});
// Control all opened tabs ASAP
workbox.clientsClaim();
/**
* Use precaching list generated by workbox in build process.
* https://developers.google.com/web/tools/workbox/reference-docs/latest/workbox.precaching
*/
workbox.precaching.precacheAndRoute(self.__precacheManifest || []);
/**
* Register a navigation route.
* https://developers.google.com/web/tools/workbox/modules/workbox-routing#how_to_register_a_navigation_route
*/
workbox.routing.registerNavigationRoute('/index.html');
/**
* Use runtime cache:
* https://developers.google.com/web/tools/workbox/reference-docs/latest/workbox.routing#.registerRoute
*
* Workbox provides all common caching strategies including CacheFirst, NetworkFirst etc.
* https://developers.google.com/web/tools/workbox/reference-docs/latest/workbox.strategies
*/
/** Handle API requests */
workbox.routing.registerRoute(/\/api\//, workbox.strategies.networkFirst());
/** Handle third party requests */
workbox.routing.registerRoute(
/^https:\/\/gw\.alipayobjects\.com\//,
workbox.strategies.networkFirst(),
);
workbox.routing.registerRoute(
/^https:\/\/cdnjs\.cloudflare\.com\//,
workbox.strategies.networkFirst(),
);
workbox.routing.registerRoute(/\/color.less/, workbox.strategies.networkFirst());
/** Response to client after skipping waiting with MessageChannel */
addEventListener('message', (event) => {
const replyPort = event.ports[0];
const message = event.data;
if (replyPort && message && message.type === 'skip-waiting') {
event.waitUntil(
self.skipWaiting().then(
() => {
replyPort.postMessage({
error: null,
});
},
(error) => {
replyPort.postMessage({
error,
});
},
),
);
}
});

9
src/services/login.ts Normal file
View File

@ -0,0 +1,9 @@
import request from '@/utils/request';
export const login = (data) => {
return request.request({
url: '/admin/login',
method: 'post',
data,
});
};

41
src/services/work.ts Normal file
View File

@ -0,0 +1,41 @@
import request from '@/utils/request';
export const queryWorkList = (params) => {
return request.request({
url: '/admin/work/page',
method: 'get',
params,
});
};
export const addWork = (data) => {
return request.request({
url: '/admin/work',
method: 'post',
data,
});
};
export const updateWork = (data) => {
return request.request({
url: '/admin/work',
method: 'put',
data,
});
};
export const deleteWork = (params) => {
return request.request({
url: '/admin/work',
method: 'delete',
params,
});
};
export const queryWorkDetail = (params: { id: string }) => {
return request.request({
url: '/admin/work',
method: 'get',
params,
});
};

324
src/styles/antdGlobal.less Normal file
View File

@ -0,0 +1,324 @@
.table-btn() {
.delete-btn {
color: @error-color !important;
}
td:not(:last-child) {
a {
color: #035cc1;
&:hover {
color: #035cc1;
}
}
}
}
.ant-table() {
.ant-table {
.table-btn();
&-tbody > tr:not(.ant-table-measure-row) > td {
padding: 12px;
color: @text-color;
border-right: 0;
}
&-container {
border: 0;
}
&-thead > tr > th {
padding: 12px;
color: @text-color;
white-space: nowrap;
background: #f5f7fa;
border: 0;
&::after {
position: absolute;
top: 10px;
right: 0;
bottom: 10px;
width: 1px;
background: #e4ebf1;
content: '';
}
&.ant-table-selection-column {
text-align: left;
}
}
&-thead > tr > th:nth-child(1),
&-tbody > tr > td:nth-child(1) {
padding-left: 20px;
}
&-tbody > tr {
&:nth-child(2n) {
td {
background: #fafbfc;
}
}
td.ant-table-selection-column {
text-align: left;
}
&:not(.ant-table-placeholder):hover {
td {
background: #effdf6;
}
}
}
&-column-sorters {
padding: 0;
}
&.ant-table-bordered
> .ant-table-container
> .ant-table-content
> table
> tbody
> tr
> .ant-table-cell-fix-right-first::after {
border-right: 0;
}
}
}
.ant-modal() {
.ant-modal {
&-header {
padding: 19px 20px;
}
&-title {
font-size: 16px;
line-height: 18px;
}
&-close-x {
height: 56px;
color: @text-color;
line-height: 56px;
}
&-content {
max-width: 1200px;
.ant-modal-body {
max-height: 600px;
overflow: auto;
}
.ant-pro-table {
.ant-pro-table-search {
margin-bottom: 0;
padding: 0;
}
.ant-card-body {
padding: 0;
}
}
.ls-pagination {
padding-right: 0;
padding-left: 0;
}
}
&-body {
padding: 20px;
}
}
}
.at-pagegation () {
.ant-pagination {
&-item {
margin-right: 8px;
border-radius: 6px;
}
&-item-active {
background: @primary-color;
border-color: @primary-color;
a {
color: #fff;
}
}
.ant-pagination-item-link,
.ant-select-selector,
&-options-quick-jumper input {
border-radius: 6px;
}
}
.ant-pagination.mini {
.ant-pagination-item {
margin-right: 8px;
border-radius: 4px;
}
.ant-select-selector,
.ant-pagination-options-quick-jumper input {
border-radius: 4px;
}
}
}
.ant-btn() {
.ant-btn {
&-lg {
height: 36px;
font-size: 14px;
}
}
}
.ant-pro-table() {
.ant-pro-table {
word-break: break-all;
.table-btn();
&.tool-bar-padding-top-0 {
.ant-pro-table-list-toolbar-container {
padding-top: 0;
}
}
&.no-tool-bar {
.ant-card {
padding-top: 20px;
}
}
.ant-pro-table-list-toolbar {
.ant-alert {
flex: 1;
}
}
.ant-pro-table-list-toolbar-container {
flex-direction: column;
justify-content: flex-start;
padding: 0 0 20px 0;
.ant-pro-table-list-toolbar-left {
.ant-space-item {
margin-bottom: 16px;
}
}
.ant-pro-table-list-toolbar-right {
justify-content: start;
}
}
.ant-pro-table-search {
margin-bottom: 20px;
padding-top: 20px;
padding-right: 20px;
padding-left: 20px;
.ant-form-item {
margin-bottom: 20px;
}
}
.ant-card {
padding-top: 20px;
&-body {
padding-right: 20px;
padding-left: 20px;
}
}
.ant-pro-table-alert {
.ant-alert-info {
padding: 8px 16px;
a {
color: @info-color;
}
}
}
.ant-pro-form-collapse-button {
display: flex;
align-items: center;
svg {
display: none;
}
span.anticon {
position: relative;
&::after {
display: inline-block;
width: 0;
height: 0;
margin-top: 6px;
border-color: @primary-color transparent transparent transparent;
border-style: solid;
border-width: 6px 5px;
transform-origin: top;
content: '';
}
}
}
.ant-input-number {
width: 100%;
}
}
}
.ant-tabs() {
.ant-tabs {
&.ant-tabs-top {
background: #fff;
&.padding {
.ant-tabs-content {
padding: 20px;
}
}
.ant-tabs-nav {
&:hover {
color: @text-color;
}
margin-bottom: 0;
padding: 0;
background: #f4f6f9;
.ant-tabs-tab {
margin: 0;
padding: 10px 24px;
border-right: 1px solid #e6edf2;
&.ant-tabs-tab-active {
color: @text-color;
font-weight: bold;
background: #fff;
.ant-tabs-tab-btn {
color: @primary-color;
}
}
}
}
}
.ls-pagination {
padding-right: 0;
padding-left: 0;
}
.ant-pro-table {
.ant-pro-table-search {
padding: 0;
}
.ant-card-body {
padding: 0;
}
}
}
}
.ant-pro-page-container() {
.ant-pro-page-container {
.ant-pro-page-container-warp {
background: transparent;
.ant-page-header {
padding: 0 0 14px;
}
}
}
}
.ant-breadcrumb() {
.ant-breadcrumb > span:last-child {
color: @text-color;
font-weight: 500;
font-size: 16px;
}
}
body {
.ant-table() !important;
.ant-modal();
.at-pagegation () !important;
.ant-btn();
.ant-pro-table();
.ant-pro-page-container-children-content {
margin: 20px;
}
.ant-layout {
min-height: 100vh;
}
.ant-form-item-label > label {
color: rgba(@text-color, 0.8);
}
.ant-breadcrumb();
.ant-formily-array-base-sort-handle {
color: #888 !important;
cursor: move;
}
}

28
src/typings.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
import { Location } from 'history';
declare module 'slash2';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare module '*.sass';
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.bmp';
declare module '*.tiff';
declare module 'omit.js';
declare module 'numeral';
declare module '@antv/data-set';
declare module 'mockjs';
declare module 'react-fittext';
declare module 'bizcharts-plugin-slider';
// preview.pro.ant.design only do not use in your production ;
// preview.pro.ant.design Dedicated environment variable, please do not use it in your project.
declare let ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: 'site' | undefined;
declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false;
export interface RouterLocation<S> extends Location<S> {
query: Record<string, string>; /// nope
}

17
src/utils/getFileType.ts Normal file
View File

@ -0,0 +1,17 @@
import { FileType } from '@/constants/enum/fileType';
const imgTypeList = ['png', 'jpg', 'gif', 'jpeg', 'nmp'];
const videoTypeList = ['mp4'];
export const getFileType = (url: string = '') => {
// 获取最后一个.的位置
const index = url.lastIndexOf('.');
// 获取后缀
const ext = url.substr(index + 1);
if (imgTypeList.indexOf(ext) > -1) {
return FileType.IMAGE;
} else if (videoTypeList.indexOf(ext) > -1) {
return FileType.VIDEO;
}
return null;
};
export default getFileType;

67
src/utils/request.ts Normal file
View File

@ -0,0 +1,67 @@
import axios from 'axios';
import { notification } from 'antd';
import RoutePath from '@/routes/routePath';
import { history } from 'umi';
import { CACHE_TOKEN } from '@/constants/cacheKey';
// create an axios instance
const request = axios.create({
baseURL: '', //
timeout: 10000, // request timeout
});
// request interceptor
request.interceptors.request.use(
(memo: any) => {
memo.headers.token = localStorage.getItem(CACHE_TOKEN);
return memo;
},
(error) => {
return Promise.reject(error);
},
);
request.interceptors.response.use(
(response) => {
const res: any = response.data;
if (res.code !== 200) {
if (res.code === 401) {
notification.error({
message: '错误信息',
description: '登录失效',
});
history.replace(RoutePath.LOGIN);
localStorage.removeItem(CACHE_TOKEN);
return Promise.reject(new Error('Login expiration'));
}
notification.error({
message: '错误信息',
description: res.msg,
});
return Promise.reject(res.msg || 'Error');
}
return res.data;
},
(error) => {
return Promise.reject(error);
},
);
export const baseRequestForm = (url: string, data: object, ...rest: any) => {
const form = new FormData();
Object.keys(data).map((key) => {
form.append(key, data[key]);
return key;
});
return request({
url,
method: 'post',
body: form,
...rest,
});
};
export const baseRequestGet = (url: string, params) => request({ url, method: 'get', params });
export const baseRequestPost = (url: string, data) => request({ url, method: 'post', data });
export default request;

24
src/utils/table.ts Normal file
View File

@ -0,0 +1,24 @@
// 格式化表格获取数据接口的返回
export const fetchTableData = async (
fetch: (params: any) => Promise<any>,
params: any,
formatObj: any = {},
) => {
params.page = params.current;
params.num = params.pageSize;
delete params.current;
delete params.pageSize;
const res = (await fetch(params)) || {};
const data = res.data;
data?.forEach((n: any) => {
for (const key in formatObj) {
n[key] = n[formatObj[key]];
}
});
return {
success: true,
data: data,
total: res.total,
};
};

View File

@ -0,0 +1,93 @@
// 选择作品弹窗
import React, { useRef, useState, useEffect } from 'react';
import { Image } from 'antd';
import Table, { ProColumns, ActionType } from '@/components/Table';
import Modal, { ModalProps } from '@/components/Modal';
import { queryWorkList } from '@/services/work';
import { fetchTableData } from '@/utils/table';
interface WorkSelectModalPropsType extends ModalProps {
value: any[];
onOk: (val: any) => void;
type?: 'checkbox' | 'radio';
}
const WorkSelectModal = ({
value = [],
onOk,
type = 'checkbox',
...rest
}: WorkSelectModalPropsType) => {
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const actionRef = useRef<ActionType>();
const columns: ProColumns<any>[] = [
{
title: 'ID',
dataIndex: 'work_id',
width: '10%',
hideInSearch: true,
},
{
title: '封面',
dataIndex: 'url',
valueType: 'image',
hideInSearch: true,
width: '30%',
render: (_, row) => {
return <Image src={row.cover_resource?.url} height={100} alt="" />;
},
},
{
title: '标题',
dataIndex: 'title',
},
{
title: '等级',
dataIndex: 'rarity_name',
width: '10%',
hideInSearch: true,
},
];
const handleOk = () => {
onOk && onOk(selectedRows);
};
const handleMerge = (keys, selectedInfos) => {
const newInfo: any = [];
const valueMap = {};
const selectedInfosMap = {};
value.forEach((item) => (valueMap[item.work_id] = item));
selectedInfos.filter((item) => item).forEach((item) => (selectedInfosMap[item.work_id] = item));
keys.forEach((item) => {
newInfo.push(selectedInfosMap[item] || valueMap[item]);
});
setSelectedRows(newInfo);
};
useEffect(() => {
setSelectedRows(value);
}, [value]);
return (
<Modal title="选择作品" onOk={handleOk} width={800} {...rest}>
<Table
columns={columns}
actionRef={actionRef}
rowKey="work_id"
rowSelection={{
type,
selectedRowKeys: selectedRows?.map((item) => item.work_id) || [],
onChange: (keys, info) => {
handleMerge(keys, info);
},
}}
request={async (params) => {
const res = await fetchTableData(queryWorkList, params);
res.data?.forEach((item) => {
item.work_id = item.id;
delete item.id;
});
return res;
}}
/>
</Modal>
);
};
export default WorkSelectModal;

42
tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"outDir": "build/dist",
"module": "esnext",
"target": "esnext",
"lib": ["esnext", "dom"],
"sourceMap": true,
"baseUrl": ".",
"jsx": "react-jsx",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"allowJs": true,
"noImplicitAny": false,
"skipLibCheck": true,
"experimentalDecorators": true,
"strict": true,
"paths": {
"@/*": ["./src/*"],
"@@/*": ["./src/.umi/*"]
}
},
"include": [
"mock/**/*",
"src/**/*",
"tests/**/*",
"test/**/*",
"__test__/**/*",
"typings/**/*",
"config/**/*",
".eslintrc.js",
".stylelintrc.js",
".prettierrc.js",
"jest.config.js",
"mock/*"
],
"exclude": ["node_modules", "build", "dist", "scripts", "src/.umi/*", "webpack", "jest"]
}

16094
yarn.lock Normal file

File diff suppressed because it is too large Load Diff