build: init
This commit is contained in:
2
template/.env.development
Normal file
2
template/.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=7777
|
||||||
44
template/.eslintrc.js
Normal file
44
template/.eslintrc.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'react-app',
|
||||||
|
'react-app/jest',
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier',
|
||||||
|
'prettier/prettier',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:json/recommended',
|
||||||
|
],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react', '@typescript-eslint', 'simple-import-sort'],
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': ['error', { singleQuote: true }],
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'off',
|
||||||
|
{ argsIgnorePattern: ['^_', 'tocoServices'] },
|
||||||
|
],
|
||||||
|
'simple-import-sort/exports': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'error',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['**/*.json'],
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': ['error', { singleQuote: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.config.js', '**/setupProxy.js', 'script/**/*.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-var-requires': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globals: {
|
||||||
|
tocoRefs: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
35
template/.gitignore
vendored
Normal file
35
template/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
src/meta.js
|
||||||
|
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
#idea
|
||||||
|
/.idea
|
||||||
|
|
||||||
|
#vscode
|
||||||
|
/.vscode
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
/package.zip
|
||||||
|
.iconfont
|
||||||
|
.env
|
||||||
1
template/.husky/pre-commit
Normal file
1
template/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
yarn lint-staged
|
||||||
2
template/.npmrc
Normal file
2
template/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
engine-strict=true
|
||||||
|
@df:registry=https://npm.byteawake.com
|
||||||
2
template/.prettierignore
Normal file
2
template/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
src/meta.js
|
||||||
8
template/.prettierrc
Normal file
8
template/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"proseWrap": "never",
|
||||||
|
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
|
||||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
|
||||||
|
}
|
||||||
109
template/README.md
Normal file
109
template/README.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# TOCO Project
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
- install
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
- dev
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
- build
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路由和菜单
|
||||||
|
|
||||||
|
本工程采用配置化自动生成路由和菜单方案,在 `pages` 下的单个页面目录中配置 `meta.json` 文件,即可生成路由和菜单,具体配置如下:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Meta {
|
||||||
|
displayName?: string; // 展示在菜单上的名字
|
||||||
|
icon?: string; // 展示在菜单上的图标
|
||||||
|
headMenu?: // 是否展示在头部菜单 或 所属头部菜单的名字
|
||||||
|
| boolean
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
};
|
||||||
|
sideMenu?: // 是否展示在侧边菜单 或 所属侧边菜单的名字
|
||||||
|
| boolean
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
};
|
||||||
|
order?: number; // 菜单排列序号,数字越小越靠前
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `headMenu`、`sideMenu` 为 `true` 时,表示该页面是一个菜单页面,即点击对应 `displayName` 后直接跳转到该页面,参考 `demo` 页面。
|
||||||
|
如果 `headMenu`、`sideMenu` 是对象,则表示的是该页面 **所属** 的非页面菜单组,参考 `test` 页面。
|
||||||
|
|
||||||
|
## 常用方法和全局变量
|
||||||
|
|
||||||
|
在每个页面内,可以使用以下常用方法和全局变量:
|
||||||
|
|
||||||
|
```
|
||||||
|
navigate:页面跳转方法,用法:navigate('/path')
|
||||||
|
props:页面 props
|
||||||
|
tocoStore:全局 store
|
||||||
|
tocoServices:页面的请求
|
||||||
|
tocoRefs:页面上所有带 id 组件的 refs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
|
||||||
|
```shell
|
||||||
|
src
|
||||||
|
├── App.tsx # 路由逻辑,一般不需要修改
|
||||||
|
├── app # store 主逻辑
|
||||||
|
│ └── store.ts # 如果写了新的 store,需要在这里引入
|
||||||
|
├── components # 组件放这里
|
||||||
|
├── config # 可以放一些常量、配置
|
||||||
|
│ └── index.ts
|
||||||
|
├── enum # 枚举值定义
|
||||||
|
│ └── index.ts
|
||||||
|
├── global.d.ts
|
||||||
|
├── index.tsx
|
||||||
|
├── langs # 国际化配置
|
||||||
|
│ ├── en_US.ts
|
||||||
|
│ ├── index.ts
|
||||||
|
│ └── zh_CN.ts
|
||||||
|
├── layout # 布局
|
||||||
|
│ ├── HeaderAndFooter
|
||||||
|
│ ├── HeaderAndSider
|
||||||
|
│ └── index.tsx # 可以在这里切换布局方案
|
||||||
|
├── pages # 页面放这里
|
||||||
|
│ ├── demo # 一个页面一个文件夹
|
||||||
|
│ │ ├── Demo.module.css # 页面样式
|
||||||
|
│ │ ├── Demo.tsx # 页面逻辑
|
||||||
|
│ │ ├── demoAPI.ts # 页面请求
|
||||||
|
│ │ ├── demoSlice.ts # 页面 store
|
||||||
|
│ │ └── meta.json # 页面元数据,用于生成路由和菜单,没有这个文件视为普通组件
|
||||||
|
│ └── test
|
||||||
|
├── react-app-env.d.ts
|
||||||
|
├── reportWebVitals.ts
|
||||||
|
├── setupProxy.js
|
||||||
|
├── setupTests.ts
|
||||||
|
├── style # 全局样式
|
||||||
|
│ ├── index.css
|
||||||
|
│ └── reset.css
|
||||||
|
├── themes # 主题配置
|
||||||
|
│ ├── CSSVariablesStyle.tsx # 用于定义css主题变量,不用修改
|
||||||
|
│ ├── dark.ts # 夜间主题
|
||||||
|
│ ├── default.ts # 日间主题
|
||||||
|
│ └── index.ts # 在这里定义自定义token,在主题文件内配置具体值
|
||||||
|
└── utils # 帮助函数放这里
|
||||||
|
```
|
||||||
|
|
||||||
81
template/craco.config.js
Normal file
81
template/craco.config.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const CracoAlias = require('craco-alias');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
|
const GenerateMetaPlugin = require('./script/GenerateMetaPlugin');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
plugin: CracoAlias,
|
||||||
|
options: {
|
||||||
|
baseUrl: 'src',
|
||||||
|
source: 'tsconfig',
|
||||||
|
tsConfigPath: './tsconfig.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
devServer: {
|
||||||
|
client: {
|
||||||
|
overlay: {
|
||||||
|
runtimeErrors: (error) => {
|
||||||
|
// 忽略ResizeObserver的错误防止阻断调试
|
||||||
|
return !(
|
||||||
|
error.message === 'ResizeObserver loop limit exceeded' ||
|
||||||
|
error.message ===
|
||||||
|
'ResizeObserver loop completed with undelivered notifications.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webpack: {
|
||||||
|
configure: (webpackConfig, arg) => {
|
||||||
|
// webpack v4 2 v5 polyfill workaround
|
||||||
|
webpackConfig.resolve.fallback = {
|
||||||
|
path: false,
|
||||||
|
fs: false,
|
||||||
|
assert: false,
|
||||||
|
buffer: require.resolve('buffer'),
|
||||||
|
'process/browser': require.resolve('process/browser'),
|
||||||
|
os: require.resolve('os-browserify/browser'),
|
||||||
|
};
|
||||||
|
webpackConfig.plugins.push(
|
||||||
|
new GenerateMetaPlugin(),
|
||||||
|
new webpack.ProvidePlugin({
|
||||||
|
process: 'process/browser',
|
||||||
|
Buffer: ['buffer', 'Buffer'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理typescript的warning:
|
||||||
|
// Module not found: Error: Can't resolve 'perf_hooks' in '/node_modules/typescript/lib'
|
||||||
|
// Critical dependency: the request of a dependency is an expression
|
||||||
|
webpackConfig.module.noParse = /typescript\/lib\/typescript.js$/;
|
||||||
|
|
||||||
|
// 去掉一个warning
|
||||||
|
webpackConfig.ignoreWarnings = [/Failed to parse source map/];
|
||||||
|
|
||||||
|
// 去掉comments
|
||||||
|
if (arg.env === 'production') {
|
||||||
|
webpackConfig.optimization.minimize = true;
|
||||||
|
webpackConfig.optimization.minimizer = [
|
||||||
|
new TerserPlugin({
|
||||||
|
terserOptions: {
|
||||||
|
output: {
|
||||||
|
comments: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extractComments: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return webpackConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
postcss: {
|
||||||
|
mode: 'file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
81
template/package.json
Normal file
81
template/package.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"name": "toco-template",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "craco build",
|
||||||
|
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||||
|
"lint": "eslint src --ext .js,.jsx,.tsx,.ts,.json --fix --quiet",
|
||||||
|
"lint-staged": "lint-staged",
|
||||||
|
"start": "craco start",
|
||||||
|
"test": "craco test",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@typescript-eslint/parser": "^6.19.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.3.4",
|
||||||
|
"@df/toco-ui": "^0.1.11",
|
||||||
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^17.0.45",
|
||||||
|
"@types/react": "^18.2.60",
|
||||||
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"os-browserify": "^0.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-helmet-async": "^2.0.4",
|
||||||
|
"react-intl": "^6.6.2",
|
||||||
|
"react-redux": "^8.1.3",
|
||||||
|
"react-router-dom": "^6.22.2",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@craco/craco": "^7.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"craco-alias": "^3.0.1",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-json": "^3.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
|
"husky": "^9.1.0",
|
||||||
|
"lint-staged": "^15.2.2",
|
||||||
|
"postcss-mixins": "^10.0.0",
|
||||||
|
"postcss-nested": "^6.0.1",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"prettier-plugin-organize-imports": "^3.2.4",
|
||||||
|
"prettier-plugin-packagejson": "^2.4.12",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"source-map-explorer": "^2.5.3",
|
||||||
|
"terser-webpack-plugin": "^5.3.10"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"src/**/*.{js,jsx,tsx,ts,json}": "eslint",
|
||||||
|
"*.js": "eslint"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
template/postcss.config.js
Normal file
5
template/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: [require('postcss-nested')],
|
||||||
|
};
|
||||||
BIN
template/public/favicon.ico
Normal file
BIN
template/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
44
template/public/index.html
Normal file
44
template/public/index.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="https://registry.npmmirror.com/prettier/2.6.0/files/standalone.js"></script>
|
||||||
|
<script src="https://registry.npmmirror.com/prettier/2.6.0/files/parser-babel.js"></script>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
template/public/loading.webp
Normal file
BIN
template/public/loading.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
BIN
template/public/logo.png
Normal file
BIN
template/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
template/public/logo_dark.png
Normal file
BIN
template/public/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
3
template/public/robots.txt
Normal file
3
template/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
70
template/script/GenerateMetaPlugin.js
Normal file
70
template/script/GenerateMetaPlugin.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const glob = require('glob');
|
||||||
|
|
||||||
|
// 递归地构建嵌套结构
|
||||||
|
function buildNestedStructure(filePaths) {
|
||||||
|
const root = [];
|
||||||
|
|
||||||
|
filePaths.forEach((filePath) => {
|
||||||
|
const parts = filePath.split('/').slice(2, -1); // 去掉 'src/pages' 'meta.json'
|
||||||
|
|
||||||
|
let current = root;
|
||||||
|
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
const find = current.find((item) => item._path === part);
|
||||||
|
if (find) {
|
||||||
|
current = find;
|
||||||
|
} else {
|
||||||
|
current.push({
|
||||||
|
_path: part,
|
||||||
|
_children: [],
|
||||||
|
});
|
||||||
|
current = current[current.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
Object.assign(current, JSON.parse(content), {
|
||||||
|
_fullpath: parts.join('/'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = current._children;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义插件:用于生成 meta.js 文件
|
||||||
|
class GenerateMetaPlugin {
|
||||||
|
apply(compiler) {
|
||||||
|
compiler.hooks.emit.tapAsync(
|
||||||
|
'GenerateMetaPlugin',
|
||||||
|
(compilation, callback) => {
|
||||||
|
const metaFiles = glob.sync('src/pages/**/meta.json');
|
||||||
|
|
||||||
|
const metaInfo = buildNestedStructure(metaFiles);
|
||||||
|
|
||||||
|
const output = `export const metaInfo = ${JSON.stringify(metaInfo, null, 2)};`;
|
||||||
|
|
||||||
|
const outputPath = path.resolve(__dirname, '../src/meta.js');
|
||||||
|
|
||||||
|
if (fs.existsSync(outputPath)) {
|
||||||
|
const existingContent = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
if (existingContent === output) {
|
||||||
|
// 文件已存在且内容相同,跳过文件生成
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, output, 'utf-8');
|
||||||
|
|
||||||
|
console.log('meta.js has been generated successfully!');
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GenerateMetaPlugin;
|
||||||
45
template/src/App.tsx
Normal file
45
template/src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Layout from '@/layout';
|
||||||
|
import { toPascalCase } from '@/utils';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
RouteObject,
|
||||||
|
RouterProvider,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import { metaInfo } from './meta.js';
|
||||||
|
|
||||||
|
// routes
|
||||||
|
const parseMeta2Routes = (meta: Meta[]) => {
|
||||||
|
const routes: RouteObject[] = [];
|
||||||
|
|
||||||
|
meta.forEach((item) => {
|
||||||
|
if (item._children?.length) {
|
||||||
|
// 是分组文件夹
|
||||||
|
routes.push(...parseMeta2Routes(item._children));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Element = lazy(
|
||||||
|
() => import(`@/pages/${item._fullpath}/${toPascalCase(item._path)}`),
|
||||||
|
);
|
||||||
|
const route: RouteObject = {
|
||||||
|
path: item._fullpath,
|
||||||
|
element: <Element />,
|
||||||
|
};
|
||||||
|
routes.push(route);
|
||||||
|
});
|
||||||
|
return routes;
|
||||||
|
};
|
||||||
|
const routes = parseMeta2Routes(metaInfo);
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Layout />,
|
||||||
|
children: routes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
73
template/src/app/common.ts
Normal file
73
template/src/app/common.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Locale, Theme } from '@/enum';
|
||||||
|
import { MenuItem } from '@/utils/menu';
|
||||||
|
import storageService from '@/utils/storage';
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { RootState } from './store';
|
||||||
|
|
||||||
|
type MenuKey = {
|
||||||
|
headKey: string;
|
||||||
|
sideKey?: string;
|
||||||
|
};
|
||||||
|
export interface CommonState {
|
||||||
|
locale: Locale;
|
||||||
|
theme: Theme;
|
||||||
|
primaryColor: string;
|
||||||
|
currentMenu: MenuKey;
|
||||||
|
headMenus: MenuItem[];
|
||||||
|
sideMenus: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DO NOT EDIT initialState */
|
||||||
|
const initialState: CommonState = {
|
||||||
|
locale: storageService.getItem('locale', Locale.zh_CN),
|
||||||
|
theme: storageService.getItem('theme', Theme.DEFAULT),
|
||||||
|
primaryColor: storageService.getItem('primaryColor', '#1361B3'),
|
||||||
|
currentMenu: {
|
||||||
|
headKey: '',
|
||||||
|
sideKey: '',
|
||||||
|
},
|
||||||
|
headMenus: [],
|
||||||
|
sideMenus: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonSlice = createSlice({
|
||||||
|
name: 'common',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLocale: (state, action) => {
|
||||||
|
state.locale = action.payload;
|
||||||
|
storageService.setItem('locale', action.payload);
|
||||||
|
},
|
||||||
|
setTheme: (state, action) => {
|
||||||
|
state.theme = action.payload;
|
||||||
|
storageService.setItem('theme', action.payload);
|
||||||
|
},
|
||||||
|
setPrimaryColor: (state, action) => {
|
||||||
|
state.primaryColor = action.payload;
|
||||||
|
storageService.setItem('primaryColor', action.payload);
|
||||||
|
},
|
||||||
|
setCurrentMenu: (state, action: PayloadAction<MenuKey>) => {
|
||||||
|
state.currentMenu = action.payload;
|
||||||
|
|
||||||
|
// 设置侧边菜单内容
|
||||||
|
const { headKey } = action.payload;
|
||||||
|
state.sideMenus =
|
||||||
|
state.headMenus.find((menu) => menu.key === headKey)?.children ?? [];
|
||||||
|
},
|
||||||
|
setHeadMenus: (state, action: PayloadAction<MenuItem[]>) => {
|
||||||
|
state.headMenus = action.payload ?? [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setLocale,
|
||||||
|
setTheme,
|
||||||
|
setPrimaryColor,
|
||||||
|
setCurrentMenu,
|
||||||
|
setHeadMenus,
|
||||||
|
} = commonSlice.actions;
|
||||||
|
|
||||||
|
export const selectCommon = (state: RootState) => state.common;
|
||||||
|
|
||||||
|
export default commonSlice.reducer;
|
||||||
6
template/src/app/hooks.ts
Normal file
6
template/src/app/hooks.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||||
|
import type { AppDispatch, RootState } from './store';
|
||||||
|
|
||||||
|
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
75
template/src/app/request.tsx
Normal file
75
template/src/app/request.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import message from '@/components/message';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { AxiosResponse } from 'axios/index';
|
||||||
|
|
||||||
|
export type ResponseType<T = any> = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 在这里添加请求头等等
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse<ResponseType>) => {
|
||||||
|
const { errorHandler, errorMessage } = response?.config?.params ?? {};
|
||||||
|
if (errorHandler === false) return response;
|
||||||
|
|
||||||
|
if (response.data.code !== 200) {
|
||||||
|
handleGlobalError(
|
||||||
|
response.data.code,
|
||||||
|
errorMessage ?? response.data.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const { errorHandler, errorMessage } = error?.config?.params ?? {};
|
||||||
|
if (errorHandler === false) return Promise.reject(error);
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
handleGlobalError(error.response.status, errorMessage);
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('请求发送失败');
|
||||||
|
} else {
|
||||||
|
message.error('请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全局错误处理函数
|
||||||
|
const handleGlobalError = (code: number, messageStr?: string) => {
|
||||||
|
switch (code) {
|
||||||
|
case 401:
|
||||||
|
// TODO 登录跳转
|
||||||
|
message.error(messageStr ?? '未授权');
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message.error(messageStr ?? '禁止访问');
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
message.error(messageStr ?? '资源未找到');
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
message.error(messageStr ?? '服务器内部错误');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.error(messageStr ?? '发生错误');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default axiosInstance;
|
||||||
19
template/src/app/store.ts
Normal file
19
template/src/app/store.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import demoReducer from '@/pages/demo/demoSlice';
|
||||||
|
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
|
||||||
|
import commonReducer from './common';
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
common: commonReducer,
|
||||||
|
demo: demoReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||||
|
ReturnType,
|
||||||
|
RootState,
|
||||||
|
unknown,
|
||||||
|
Action<string>
|
||||||
|
>;
|
||||||
89
template/src/components/app-wrapper/index.tsx
Normal file
89
template/src/components/app-wrapper/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import globalMessage from '@/components/message';
|
||||||
|
import { Locale, Theme } from '@/enum';
|
||||||
|
import * as langs from '@/langs';
|
||||||
|
import * as themes from '@/themes';
|
||||||
|
import { CSSVariablesStyle } from '@/themes/CSSVariablesStyle';
|
||||||
|
import { ConfigProvider } from '@df/toco-ui';
|
||||||
|
import { Store } from '@reduxjs/toolkit';
|
||||||
|
import { message as tocoMessage } from 'antd';
|
||||||
|
import enUS from 'antd/locale/en_US';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
|
import { Provider, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
const helmetContext = {};
|
||||||
|
|
||||||
|
type AppWrapperProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
store: Store;
|
||||||
|
};
|
||||||
|
const AppWrapper = (props: AppWrapperProps) => {
|
||||||
|
const { children, store } = props;
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<Insider>{children}</Insider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type InsiderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
const Insider = (props: InsiderProps) => {
|
||||||
|
const { children } = props;
|
||||||
|
const [message, messageContextHolder] = tocoMessage.useMessage();
|
||||||
|
|
||||||
|
const locale = useSelector((state: RootState) => state.common.locale);
|
||||||
|
const theme = useSelector((state: RootState) => state.common.theme);
|
||||||
|
const primaryColor = useSelector(
|
||||||
|
(state: RootState) => state.common.primaryColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const antdLocaleMap = {
|
||||||
|
[Locale.zh_CN]: zhCN,
|
||||||
|
[Locale.en_US]: enUS,
|
||||||
|
};
|
||||||
|
const langsMap = {
|
||||||
|
[Locale.zh_CN]: langs.zh_CN,
|
||||||
|
[Locale.en_US]: langs.en_US,
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeConfig = useMemo(() => {
|
||||||
|
const themeMap = {
|
||||||
|
[Theme.DEFAULT]: themes.defaultTheme,
|
||||||
|
[Theme.DARK]: themes.darkTheme,
|
||||||
|
};
|
||||||
|
const config = themeMap[theme];
|
||||||
|
if (primaryColor) {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
token: {
|
||||||
|
...(config.token ?? {}),
|
||||||
|
colorPrimary: primaryColor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}, [primaryColor, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
globalMessage.setMessage(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HelmetProvider context={helmetContext}>
|
||||||
|
<IntlProvider locale={locale} messages={langsMap[locale]}>
|
||||||
|
<ConfigProvider locale={antdLocaleMap[locale]} theme={themeConfig}>
|
||||||
|
<CSSVariablesStyle />
|
||||||
|
{messageContextHolder}
|
||||||
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
|
</IntlProvider>
|
||||||
|
</HelmetProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppWrapper;
|
||||||
50
template/src/components/message/index.ts
Normal file
50
template/src/components/message/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 带主题等上下文的、可全局使用的message api
|
||||||
|
* toco-ui/antd 组件库的message无法直接全局使用,主题上下文会丢失
|
||||||
|
*/
|
||||||
|
import { message as tocoMessage } from '@df/toco-ui';
|
||||||
|
|
||||||
|
type Message = ReturnType<typeof tocoMessage.useMessage>[0];
|
||||||
|
|
||||||
|
class GlobalMessage {
|
||||||
|
private static instance: GlobalMessage;
|
||||||
|
private message: Message;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.message = tocoMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): GlobalMessage {
|
||||||
|
if (!GlobalMessage.instance) {
|
||||||
|
GlobalMessage.instance = new GlobalMessage();
|
||||||
|
}
|
||||||
|
return GlobalMessage.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMessage(message: Message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public success(content: string, duration?: number, onClose?: () => void) {
|
||||||
|
this.message.success(content, duration, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(content: string, duration?: number, onClose?: () => void) {
|
||||||
|
this.message.error(content, duration, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(content: string, duration?: number, onClose?: () => void) {
|
||||||
|
this.message.info(content, duration, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warning(content: string, duration?: number, onClose?: () => void) {
|
||||||
|
this.message.warning(content, duration, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loading(content: string, duration?: number, onClose?: () => void) {
|
||||||
|
this.message.loading(content, duration, onClose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalMessage = GlobalMessage.getInstance();
|
||||||
|
export default globalMessage;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.color-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
template/src/components/settings/color-select/index.tsx
Normal file
57
template/src/components/settings/color-select/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { Theme } from '@/enum';
|
||||||
|
import { CheckOutlined } from '@ant-design/icons';
|
||||||
|
import { useControllableValue } from 'ahooks';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
export type ColorSelectOption = {
|
||||||
|
color: string;
|
||||||
|
theme?: Theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorSelectProps = {
|
||||||
|
className?: string;
|
||||||
|
options?: ColorSelectOption[];
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (color: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorSelect: React.FC<ColorSelectProps> = (props) => {
|
||||||
|
const [value, setValue] = useControllableValue<string | undefined>(props);
|
||||||
|
const theme = useSelector((state: RootState) => state.common.theme);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(option: ColorSelectOption) => {
|
||||||
|
setValue(option.color);
|
||||||
|
},
|
||||||
|
[setValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles['color-select'], props.className)}>
|
||||||
|
{props.options?.map((option) => {
|
||||||
|
if (!option.theme || option.theme === theme) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.color}
|
||||||
|
className={styles['color-option']}
|
||||||
|
style={{
|
||||||
|
backgroundColor: option.color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.color === value && <CheckOutlined />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
template/src/components/settings/color.tsx
Normal file
44
template/src/components/settings/color.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Theme } from '@/enum';
|
||||||
|
import React from 'react';
|
||||||
|
import { ColorSelect } from './color-select';
|
||||||
|
|
||||||
|
export const changeColor = ['#DFC193', '#1361B3'];
|
||||||
|
|
||||||
|
const colorArray = [
|
||||||
|
{
|
||||||
|
theme: Theme.DARK,
|
||||||
|
color: '#DFC193',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: Theme.DEFAULT,
|
||||||
|
color: '#1361B3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#E57373',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#F08F48',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#59AF6C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#26ABB2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#AD81E5',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type ColorProps = {
|
||||||
|
color: string;
|
||||||
|
handleChange: (color: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Color: React.FC<ColorProps> = (props) => {
|
||||||
|
const { color, handleChange } = props;
|
||||||
|
return (
|
||||||
|
<ColorSelect options={colorArray} value={color} onChange={handleChange} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Color;
|
||||||
71
template/src/components/settings/index.module.css
Normal file
71
template/src/components/settings/index.module.css
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
.container {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--toco-colorTextBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
background-color: #F0F2F5;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:global(.anticon) {
|
||||||
|
color: var(--toco-colorTextBase);
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 33%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
content: "";
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 25%;
|
||||||
|
background-color: #fff;
|
||||||
|
content: "";
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
background-color: #3A4D5B;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
template/src/components/settings/index.tsx
Normal file
103
template/src/components/settings/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
selectCommon,
|
||||||
|
setLocale,
|
||||||
|
setPrimaryColor,
|
||||||
|
setTheme,
|
||||||
|
} from '@/app/common';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/app/hooks';
|
||||||
|
import { Locale, Theme } from '@/enum';
|
||||||
|
import { Radio, RadioChangeEvent, Tooltip } from '@df/toco-ui';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import ColorContainer, { changeColor } from './color';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
import ThemeContainer from './theme';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { primaryColor, theme, locale } = useAppSelector(selectCommon);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const toggleTheme = (newTheme: Theme) => {
|
||||||
|
const index = changeColor?.findIndex(
|
||||||
|
(item: string) => item === primaryColor,
|
||||||
|
);
|
||||||
|
if (index >= 0) {
|
||||||
|
dispatch(
|
||||||
|
setPrimaryColor(index === 0 ? changeColor?.[1] : changeColor?.[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dispatch(setTheme(newTheme));
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePrimaryColor = (newPrimaryColor: string) => {
|
||||||
|
dispatch(setPrimaryColor(newPrimaryColor));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocaleChange = useCallback(
|
||||||
|
(e: RadioChangeEvent) => {
|
||||||
|
dispatch(setLocale(e.target.value));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.item}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<FormattedMessage id="style" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={intl.formatMessage({ id: 'theme_light' })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
toggleTheme(Theme.DEFAULT);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeContainer checked={theme === Theme.DEFAULT} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={intl.formatMessage({ id: 'theme_dark' })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
toggleTheme(Theme.DARK);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeContainer theme="dark" checked={theme === Theme.DARK} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.item}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<FormattedMessage id="primary_color" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<ColorContainer
|
||||||
|
color={primaryColor || ''}
|
||||||
|
handleChange={togglePrimaryColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.item}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<FormattedMessage id="language" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Radio.Group onChange={handleLocaleChange} value={locale}>
|
||||||
|
<Radio value={Locale.zh_CN}>简体中文</Radio>
|
||||||
|
<Radio value={Locale.en_US}>English</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
25
template/src/components/settings/theme.tsx
Normal file
25
template/src/components/settings/theme.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { CheckOutlined } from '@ant-design/icons';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
type ThemeProp = {
|
||||||
|
theme?: string;
|
||||||
|
checked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Theme: React.FC<ThemeProp> = (props) => {
|
||||||
|
const { theme, checked } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classnames({
|
||||||
|
[styles.light]: true,
|
||||||
|
[styles.dark]: theme === 'dark',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{checked && <CheckOutlined />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Theme;
|
||||||
23
template/src/components/spin/index.tsx
Normal file
23
template/src/components/spin/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Spin as AntSpin, SpinProps } from 'antd';
|
||||||
|
const Spin = (props: SpinProps) => {
|
||||||
|
const { size = 'default' } = props;
|
||||||
|
const sizeMap = {
|
||||||
|
small: { width: '40px', height: '58px' },
|
||||||
|
default: { width: '60px', height: '86px' },
|
||||||
|
large: { width: '80px', height: '114px' },
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<AntSpin
|
||||||
|
indicator={
|
||||||
|
<img
|
||||||
|
src={process.env.PUBLIC_URL + `/loading.webp`}
|
||||||
|
style={{ width: sizeMap[size].width, height: sizeMap[size].height }}
|
||||||
|
alt="loading"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Spin;
|
||||||
7
template/src/components/suspense-layout/index.module.css
Normal file
7
template/src/components/suspense-layout/index.module.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.loader {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
23
template/src/components/suspense-layout/index.tsx
Normal file
23
template/src/components/suspense-layout/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Spin from '@/components/spin';
|
||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
type SuspenseLayoutProps = {
|
||||||
|
children: React.ReactElement;
|
||||||
|
};
|
||||||
|
const SuspenseLayout = (props: SuspenseLayoutProps) => {
|
||||||
|
const { children } = props;
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className={styles.loader}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuspenseLayout;
|
||||||
3
template/src/config/index.ts
Normal file
3
template/src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const CSS_VARIABLE_PREFIX = 'toco';
|
||||||
|
|
||||||
|
export const STORAGE_KEY_PREFIX = 'toco_storage_';
|
||||||
9
template/src/enum/index.ts
Normal file
9
template/src/enum/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum Theme {
|
||||||
|
DEFAULT = 'DEFAULT',
|
||||||
|
DARK = 'DARK',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Locale {
|
||||||
|
'zh_CN' = 'zh-CN',
|
||||||
|
'en_US' = 'en-US',
|
||||||
|
}
|
||||||
28
template/src/global.d.ts
vendored
Normal file
28
template/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
declare global {
|
||||||
|
let tocoRefs: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Meta {
|
||||||
|
displayName?: string; // 展示在菜单上的名字, 可以用 {{language_id}} 包起来,用于国际化显示
|
||||||
|
icon?: string; // 展示在菜单上的图标
|
||||||
|
headMenu?: // 是否展示在头部菜单 或 所属头部菜单的名字
|
||||||
|
| boolean
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string; // 可以用 {{language_id}} 包起来,用于国际化显示
|
||||||
|
icon?: string;
|
||||||
|
order?: number; // 菜单序号,数字越小越靠前
|
||||||
|
};
|
||||||
|
sideMenu?: // 是否展示在侧边菜单 或 所属侧边菜单的名字
|
||||||
|
| boolean
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string; // 可以用 {{language_id}} 包起来,用于国际化显示
|
||||||
|
icon?: string;
|
||||||
|
order?: number; // 菜单序号,数字越小越靠前
|
||||||
|
};
|
||||||
|
order?: number; // 菜单序号,数字越小越靠前
|
||||||
|
_path: string; // 目录名
|
||||||
|
_fullpath?: string; // 完整目录结构
|
||||||
|
_children: Meta[];
|
||||||
|
}
|
||||||
29
template/src/index.tsx
Normal file
29
template/src/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { store } from '@/app/store';
|
||||||
|
import AppWrapper from '@/components/app-wrapper';
|
||||||
|
import '@/style/index.css';
|
||||||
|
import { tocoGlobal } from '@df/toco-ui';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const container = document.getElementById('root')!;
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AppWrapper store={store}>
|
||||||
|
<App />
|
||||||
|
</AppWrapper>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
|
|
||||||
|
// global variables
|
||||||
|
(globalThis as any).tocoRefs = tocoGlobal.getRefs();
|
||||||
|
|
||||||
|
export { store };
|
||||||
16
template/src/langs/en_US.ts
Normal file
16
template/src/langs/en_US.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Language } from './index';
|
||||||
|
|
||||||
|
const en_US: Language = {
|
||||||
|
language: 'Language',
|
||||||
|
theme: 'Theme',
|
||||||
|
primary_color: 'Primary Color',
|
||||||
|
style: 'Style',
|
||||||
|
system_settings: 'System Settings',
|
||||||
|
theme_light: 'Light',
|
||||||
|
theme_dark: 'Dark',
|
||||||
|
demo: 'Demo',
|
||||||
|
demo2: 'Demo2',
|
||||||
|
test: 'Test',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en_US;
|
||||||
17
template/src/langs/index.ts
Normal file
17
template/src/langs/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import en_US from './en_US';
|
||||||
|
import zh_CN from './zh_CN';
|
||||||
|
|
||||||
|
export type Language = {
|
||||||
|
language: string;
|
||||||
|
theme: string;
|
||||||
|
primary_color: string;
|
||||||
|
style: string;
|
||||||
|
system_settings: string;
|
||||||
|
theme_light: string;
|
||||||
|
theme_dark: string;
|
||||||
|
demo: string;
|
||||||
|
demo2: string;
|
||||||
|
test: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { en_US, zh_CN };
|
||||||
16
template/src/langs/zh_CN.ts
Normal file
16
template/src/langs/zh_CN.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Language } from './index';
|
||||||
|
|
||||||
|
const zh_CN: Language = {
|
||||||
|
language: '语言',
|
||||||
|
theme: '主题',
|
||||||
|
primary_color: '主题色',
|
||||||
|
style: '整体风格',
|
||||||
|
system_settings: '系统设置',
|
||||||
|
theme_light: '浅色模式',
|
||||||
|
theme_dark: '深色模式',
|
||||||
|
demo: '示例',
|
||||||
|
demo2: '示例2',
|
||||||
|
test: '测试',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default zh_CN;
|
||||||
50
template/src/layout/HeaderAndFooter/index.module.css
Normal file
50
template/src/layout/HeaderAndFooter/index.module.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--toco-colorBgContainer);
|
||||||
|
border-bottom: 1px solid var(--toco-colorBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--toco-colorTextBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 125px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px;
|
||||||
|
height: calc(100vh - 112px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--toco-colorBgBase);
|
||||||
|
border-radius: var(--toco-borderRadiusLG);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
61
template/src/layout/HeaderAndFooter/index.tsx
Normal file
61
template/src/layout/HeaderAndFooter/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { selectCommon } from '@/app/common';
|
||||||
|
import { useAppSelector } from '@/app/hooks';
|
||||||
|
import Settings from '@/components/settings';
|
||||||
|
import { Theme } from '@/enum';
|
||||||
|
import { LayoutProps } from '@/layout';
|
||||||
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
|
import { Image, Layout, Menu, Popover } from '@df/toco-ui';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
const { Header, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
const HeaderAndFooterLayout = (props: LayoutProps) => {
|
||||||
|
const { children, onHeadMenuSelect } = props;
|
||||||
|
const { headMenus, currentMenu, theme } = useAppSelector(selectCommon);
|
||||||
|
const { headKey, sideKey = '' } = currentMenu;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header className={styles.header}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<Image
|
||||||
|
preview={false}
|
||||||
|
src={
|
||||||
|
process.env.PUBLIC_URL +
|
||||||
|
(theme === Theme.DEFAULT ? '/logo.png' : '/logo_dark.png')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
className={styles.menu}
|
||||||
|
mode="horizontal"
|
||||||
|
items={headMenus}
|
||||||
|
selectedKeys={[headKey, sideKey]}
|
||||||
|
onSelect={onHeadMenuSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.right}>
|
||||||
|
<Popover
|
||||||
|
title={intl.formatMessage({ id: 'system_settings' })}
|
||||||
|
content={<Settings />}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<SettingOutlined className={styles.settings} />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content className={styles.content}>
|
||||||
|
<div className={styles.main}>{children}</div>
|
||||||
|
</Content>
|
||||||
|
<Footer className={styles.footer}>
|
||||||
|
TOCO Design ©{new Date().getFullYear()}
|
||||||
|
</Footer>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderAndFooterLayout;
|
||||||
49
template/src/layout/HeaderAndSider/index.module.css
Normal file
49
template/src/layout/HeaderAndSider/index.module.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--toco-colorBgContainer);
|
||||||
|
border-bottom: 1px solid var(--toco-colorBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 125px;
|
||||||
|
margin-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider {
|
||||||
|
width: 200px;
|
||||||
|
background: var(--toco-colorBgContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.siderMenu {
|
||||||
|
height: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 280px;
|
||||||
|
background: var(--toco-colorBgBase);
|
||||||
|
border-radius: var(--toco-borderRadiusLG);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
79
template/src/layout/HeaderAndSider/index.tsx
Normal file
79
template/src/layout/HeaderAndSider/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { selectCommon } from '@/app/common';
|
||||||
|
import { useAppSelector } from '@/app/hooks';
|
||||||
|
import Settings from '@/components/settings';
|
||||||
|
import { Theme } from '@/enum';
|
||||||
|
import { LayoutProps } from '@/layout';
|
||||||
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
|
import { Image, Layout, Menu, Popover } from '@df/toco-ui';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
|
const HeaderAndFooterLayout = (props: LayoutProps) => {
|
||||||
|
const { children, onHeadMenuSelect, onSideMenuSelect } = props;
|
||||||
|
const { headMenus, sideMenus, currentMenu, theme } =
|
||||||
|
useAppSelector(selectCommon);
|
||||||
|
const { headKey, sideKey = '' } = currentMenu;
|
||||||
|
const intl = useIntl();
|
||||||
|
const flatHeadMenus = useMemo(
|
||||||
|
() =>
|
||||||
|
headMenus.map((m) => {
|
||||||
|
return { ...m, children: undefined };
|
||||||
|
}),
|
||||||
|
[headMenus],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Header className={styles.header}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<Image
|
||||||
|
preview={false}
|
||||||
|
src={
|
||||||
|
process.env.PUBLIC_URL +
|
||||||
|
(theme === Theme.DEFAULT ? '/logo.png' : '/logo_dark.png')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
className={styles.menu}
|
||||||
|
mode="horizontal"
|
||||||
|
items={flatHeadMenus}
|
||||||
|
selectedKeys={[headKey]}
|
||||||
|
onSelect={onHeadMenuSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.right}>
|
||||||
|
<Popover
|
||||||
|
title={intl.formatMessage({ id: 'system_settings' })}
|
||||||
|
content={<Settings />}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<SettingOutlined className={styles.settings} />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
<Layout>
|
||||||
|
{sideMenus.length === 0 ? null : (
|
||||||
|
<Sider className={styles.sider}>
|
||||||
|
<Menu
|
||||||
|
className={styles.siderMenu}
|
||||||
|
mode="inline"
|
||||||
|
items={sideMenus}
|
||||||
|
selectedKeys={[sideKey]}
|
||||||
|
onSelect={onSideMenuSelect}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
<Layout className={styles.contentWrapper}>
|
||||||
|
<Content className={styles.content}>{children}</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderAndFooterLayout;
|
||||||
96
template/src/layout/index.tsx
Normal file
96
template/src/layout/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { setCurrentMenu, setHeadMenus } from '@/app/common';
|
||||||
|
import { useAppDispatch } from '@/app/hooks';
|
||||||
|
import SuspenseLayout from '@/components/suspense-layout';
|
||||||
|
import {
|
||||||
|
findFirstSubMenu,
|
||||||
|
findHeadMenuByHeadKey,
|
||||||
|
findHeadMenuBySideKey,
|
||||||
|
menuItems,
|
||||||
|
} from '@/utils/menu';
|
||||||
|
import { MenuProps } from '@df/toco-ui';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from './HeaderAndSider';
|
||||||
|
// import Layout from './HeaderAndFooter';
|
||||||
|
|
||||||
|
export type LayoutProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onHeadMenuSelect?: MenuProps['onSelect'];
|
||||||
|
onSideMenuSelect?: MenuProps['onSelect'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const LayoutRoot = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 设置head菜单
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setHeadMenus(menuItems));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 监听路由变化
|
||||||
|
useEffect(() => {
|
||||||
|
const key = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
const headMenu = findHeadMenuByHeadKey(key);
|
||||||
|
if (headMenu) {
|
||||||
|
// 一级页面
|
||||||
|
dispatch(
|
||||||
|
setCurrentMenu({
|
||||||
|
headKey: headMenu.key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 非一级页面
|
||||||
|
const headMenu = findHeadMenuBySideKey(key);
|
||||||
|
dispatch(
|
||||||
|
setCurrentMenu({
|
||||||
|
headKey: headMenu?.key ?? '',
|
||||||
|
sideKey: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatch, location]);
|
||||||
|
|
||||||
|
const handleHeadMenuSelect: MenuProps['onSelect'] = useCallback(
|
||||||
|
({ item, key, keyPath, domEvent }) => {
|
||||||
|
const headMenu = findHeadMenuByHeadKey(key);
|
||||||
|
|
||||||
|
if (!headMenu) {
|
||||||
|
// 顶部嵌套的菜单(即实际点击的sideMenu)
|
||||||
|
navigate(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headMenu.children?.length) {
|
||||||
|
// 一级页面
|
||||||
|
navigate(key);
|
||||||
|
} else {
|
||||||
|
// 非一级页面点击一级菜单,主动跳转第一个子页面
|
||||||
|
const subMenu = findFirstSubMenu(headMenu);
|
||||||
|
if (subMenu) {
|
||||||
|
navigate(subMenu.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
const handleSideMenuSelect: MenuProps['onSelect'] = useCallback(
|
||||||
|
({ item, key, keyPath, domEvent }) => {
|
||||||
|
navigate(key);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
onHeadMenuSelect={handleHeadMenuSelect}
|
||||||
|
onSideMenuSelect={handleSideMenuSelect}
|
||||||
|
>
|
||||||
|
<SuspenseLayout>
|
||||||
|
<Outlet />
|
||||||
|
</SuspenseLayout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default LayoutRoot;
|
||||||
0
template/src/pages/demo/Demo.module.css
Normal file
0
template/src/pages/demo/Demo.module.css
Normal file
46
template/src/pages/demo/Demo.tsx
Normal file
46
template/src/pages/demo/Demo.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '@/app/hooks';
|
||||||
|
import { User } from '@/pages/demo/demoAPI';
|
||||||
|
import { fetchUsers, selectDemo } from '@/pages/demo/demoSlice';
|
||||||
|
import { EditTableColumnsType, Table } from '@df/toco-ui';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const columns: EditTableColumnsType<User> = [
|
||||||
|
{
|
||||||
|
title: 'name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name) => `${name.first} ${name.last}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'phone',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
key: 'phone',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'gender',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
key: 'gender',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '女', value: 'female' },
|
||||||
|
{ label: '男', value: 'male' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Demo = () => {
|
||||||
|
const { users } = useAppSelector(selectDemo);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchUsers());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table id="demoTable" value={users} columns={columns} editMode="none" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Demo;
|
||||||
26
template/src/pages/demo/demoAPI.ts
Normal file
26
template/src/pages/demo/demoAPI.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import axiosInstance from '@/app/request';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
name: {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
};
|
||||||
|
phone: string;
|
||||||
|
gender: 'female' | 'male';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUsers = async (): Promise<User[]> => {
|
||||||
|
const response: AxiosResponse<{
|
||||||
|
info: any;
|
||||||
|
results: User[];
|
||||||
|
}> = await axiosInstance.get(
|
||||||
|
`https://randomuser.me/api/?results=8&seed=toco`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
errorHandler: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data?.results || [];
|
||||||
|
};
|
||||||
33
template/src/pages/demo/demoSlice.ts
Normal file
33
template/src/pages/demo/demoSlice.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { AppThunk, RootState } from '@/app/store';
|
||||||
|
import { getUsers, User } from '@/pages/demo/demoAPI';
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface DemoState {
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DO NOT EDIT initialState */
|
||||||
|
const initialState: DemoState = {
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const demoSlice = createSlice({
|
||||||
|
name: 'demo',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setUsers: (state, action: PayloadAction<User[]>) => {
|
||||||
|
state.users = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setUsers } = demoSlice.actions;
|
||||||
|
|
||||||
|
export const fetchUsers = (): AppThunk => async (dispatch, getState) => {
|
||||||
|
const data = await getUsers();
|
||||||
|
dispatch(setUsers(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectDemo = (state: RootState) => state.demo;
|
||||||
|
|
||||||
|
export default demoSlice.reducer;
|
||||||
6
template/src/pages/demo/meta.json
Normal file
6
template/src/pages/demo/meta.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"displayName": "{{demo}}",
|
||||||
|
"icon": "CrownOutlined",
|
||||||
|
"headMenu": true,
|
||||||
|
"order": 0
|
||||||
|
}
|
||||||
32
template/src/pages/test/Test.js
Normal file
32
template/src/pages/test/Test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useAppSelector } from '@/app/hooks';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import * as tocoServices from './testAPI';
|
||||||
|
|
||||||
|
const Test = (props) => {
|
||||||
|
const tocoStore = useAppSelector((state) => state);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 一些常用变量或方法
|
||||||
|
console.log('navigate', navigate);
|
||||||
|
console.log('props', props);
|
||||||
|
console.log('store', tocoStore);
|
||||||
|
console.log('services', tocoServices);
|
||||||
|
console.log('refs', tocoRefs);
|
||||||
|
}, [navigate, props, tocoStore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Store</h1>
|
||||||
|
<p>the primary color is: {tocoStore.common.primaryColor}</p>
|
||||||
|
<h1>Service</h1>
|
||||||
|
<h1>Refs</h1>
|
||||||
|
<div>
|
||||||
|
<p>children page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
10
template/src/pages/test/meta.json
Normal file
10
template/src/pages/test/meta.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"displayName": "{{test}}",
|
||||||
|
"icon": "RobotOutlined",
|
||||||
|
"headMenu": {
|
||||||
|
"key": "demo2",
|
||||||
|
"label": "{{demo2}}"
|
||||||
|
},
|
||||||
|
"sideMenu": true,
|
||||||
|
"order": 0
|
||||||
|
}
|
||||||
26
template/src/pages/test/testAPI.ts
Normal file
26
template/src/pages/test/testAPI.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import axiosInstance from '@/app/request';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
name: {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
};
|
||||||
|
phone: string;
|
||||||
|
gender: 'female' | 'male';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUsers = async (): Promise<User[]> => {
|
||||||
|
const response: AxiosResponse<{
|
||||||
|
info: any;
|
||||||
|
results: User[];
|
||||||
|
}> = await axiosInstance.get(
|
||||||
|
`https://randomuser.me/api/?results=8&seed=toco`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
errorHandler: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data?.results || [];
|
||||||
|
};
|
||||||
1
template/src/react-app-env.d.ts
vendored
Normal file
1
template/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
15
template/src/reportWebVitals.ts
Normal file
15
template/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
11
template/src/setupProxy.js
Normal file
11
template/src/setupProxy.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/* change dev-server proxy here */
|
||||||
|
// const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
module.exports = function (app) {
|
||||||
|
// app.use(
|
||||||
|
// '/api',
|
||||||
|
// createProxyMiddleware({
|
||||||
|
// target: 'https://my.test-api.com',
|
||||||
|
// changeOrigin: true,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
};
|
||||||
5
template/src/setupTests.ts
Normal file
5
template/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
1
template/src/style/index.css
Normal file
1
template/src/style/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "./reset.css";
|
||||||
11
template/src/style/reset.css
Normal file
11
template/src/style/reset.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
45
template/src/themes/CSSVariablesStyle.tsx
Normal file
45
template/src/themes/CSSVariablesStyle.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { CSS_VARIABLE_PREFIX } from '@/config';
|
||||||
|
import { toCamelCase } from '@/utils';
|
||||||
|
import { GlobalToken, theme } from 'antd';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这里把主题token注入css全局变量
|
||||||
|
*/
|
||||||
|
export function CSSVariablesStyle() {
|
||||||
|
const { token } = useToken();
|
||||||
|
|
||||||
|
const globalVars = useMemo(() => {
|
||||||
|
const varLines: string[] = [];
|
||||||
|
|
||||||
|
Object.keys(token).forEach((key) => {
|
||||||
|
const tokenValue = token[key as keyof GlobalToken];
|
||||||
|
const formatTokenValue = (v) =>
|
||||||
|
typeof v === 'number' && Number.isInteger(v) ? v + 'px' : v;
|
||||||
|
if (tokenValue) {
|
||||||
|
if (typeof tokenValue === 'object') {
|
||||||
|
// 组件token
|
||||||
|
Object.keys(tokenValue).forEach((name) => {
|
||||||
|
const cpTokenValue = tokenValue[name];
|
||||||
|
varLines.push(
|
||||||
|
`--${CSS_VARIABLE_PREFIX}-${toCamelCase(`${key.toLocaleLowerCase()}-${name}`)}: ${formatTokenValue(cpTokenValue)};`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 全局token
|
||||||
|
varLines.push(
|
||||||
|
`--${CSS_VARIABLE_PREFIX}-${toCamelCase(key)}: ${formatTokenValue(tokenValue)};`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `html {
|
||||||
|
${varLines.join('\n')}
|
||||||
|
}`;
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return <style>{globalVars}</style>;
|
||||||
|
}
|
||||||
23
template/src/themes/dark.ts
Normal file
23
template/src/themes/dark.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { theme, ThemeConfig } from '@df/toco-ui';
|
||||||
|
import { SeedToken } from 'antd/es/theme/interface/seeds';
|
||||||
|
import { MapToken } from './index';
|
||||||
|
|
||||||
|
const { compactAlgorithm, darkAlgorithm } = theme;
|
||||||
|
|
||||||
|
const customAlgorithm: (token: SeedToken) => MapToken = (token) => {
|
||||||
|
const calcTokens = darkAlgorithm(token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...calcTokens,
|
||||||
|
// custom tokens
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkTheme: ThemeConfig = {
|
||||||
|
token: {
|
||||||
|
// antd tokens
|
||||||
|
},
|
||||||
|
algorithm: [customAlgorithm, compactAlgorithm],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default darkTheme;
|
||||||
23
template/src/themes/default.ts
Normal file
23
template/src/themes/default.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { theme, ThemeConfig } from '@df/toco-ui';
|
||||||
|
import { SeedToken } from 'antd/es/theme/interface/seeds';
|
||||||
|
import { MapToken } from './index';
|
||||||
|
|
||||||
|
const { compactAlgorithm, defaultAlgorithm } = theme;
|
||||||
|
|
||||||
|
const customAlgorithm: (token: SeedToken) => MapToken = (token) => {
|
||||||
|
const calcTokens = defaultAlgorithm(token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...calcTokens,
|
||||||
|
// custom tokens
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTheme: ThemeConfig = {
|
||||||
|
token: {
|
||||||
|
// antd tokens
|
||||||
|
},
|
||||||
|
algorithm: [customAlgorithm, compactAlgorithm],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defaultTheme;
|
||||||
33
template/src/themes/index.ts
Normal file
33
template/src/themes/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { CommonMapToken } from 'antd/es/theme/interface/maps';
|
||||||
|
import { ColorMapToken } from 'antd/es/theme/interface/maps/colors';
|
||||||
|
import { FontMapToken } from 'antd/es/theme/interface/maps/font';
|
||||||
|
import {
|
||||||
|
HeightMapToken,
|
||||||
|
SizeMapToken,
|
||||||
|
} from 'antd/es/theme/interface/maps/size';
|
||||||
|
import { StyleMapToken } from 'antd/es/theme/interface/maps/style';
|
||||||
|
import {
|
||||||
|
ColorPalettes,
|
||||||
|
LegacyColorPalettes,
|
||||||
|
} from 'antd/es/theme/interface/presetColors';
|
||||||
|
import { SeedToken } from 'antd/es/theme/interface/seeds';
|
||||||
|
import darkTheme from './dark';
|
||||||
|
import defaultTheme from './default';
|
||||||
|
|
||||||
|
export interface MapToken
|
||||||
|
extends SeedToken,
|
||||||
|
ColorPalettes,
|
||||||
|
LegacyColorPalettes,
|
||||||
|
ColorMapToken,
|
||||||
|
SizeMapToken,
|
||||||
|
HeightMapToken,
|
||||||
|
StyleMapToken,
|
||||||
|
FontMapToken,
|
||||||
|
CommonMapToken,
|
||||||
|
CustomTokens {}
|
||||||
|
|
||||||
|
export type CustomTokens = {
|
||||||
|
// Define your custom tokens here
|
||||||
|
};
|
||||||
|
|
||||||
|
export { darkTheme, defaultTheme };
|
||||||
11
template/src/utils/index.ts
Normal file
11
template/src/utils/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// 将下划线或者中划线命名转换为驼峰命名
|
||||||
|
export const toCamelCase = (str) => {
|
||||||
|
return str
|
||||||
|
.replace(/[_-]([a-zA-Z])/g, (match, p1) => p1.toUpperCase())
|
||||||
|
.replace(/^[A-Z]/, (match) => match.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将下划线命名转换为驼峰命名, 首字母大写
|
||||||
|
export const toPascalCase = (str) => {
|
||||||
|
return str.replace(/(^|[_-])([a-z])/g, (match, p1, p2) => p2.toUpperCase());
|
||||||
|
};
|
||||||
150
template/src/utils/menu.tsx
Normal file
150
template/src/utils/menu.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { metaInfo } from '@/meta';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
export type MenuItem = {
|
||||||
|
key: string;
|
||||||
|
label: string | React.ReactElement;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children?: MenuItem[];
|
||||||
|
_order?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDisplayName = (displayName: string) => {
|
||||||
|
// 用 {{xxx}} 包起来的字符串需要处理国际化
|
||||||
|
if (/^{{(.*)}}$/.test(displayName)) {
|
||||||
|
const key = displayName.match(/^{{(.*)}}$/)![1].trim();
|
||||||
|
return <FormattedMessage id={key} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatten = (info: Meta[]) => {
|
||||||
|
const result: Meta[] = [];
|
||||||
|
info.forEach((item) => {
|
||||||
|
if (item._children?.length) {
|
||||||
|
result.push(...flatten(item._children));
|
||||||
|
} else {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deeplySortMenu = (menus: MenuItem[]) => {
|
||||||
|
const newMenus = [...menus];
|
||||||
|
newMenus.sort((a, b) => a._order! - b._order!);
|
||||||
|
newMenus.forEach((menu) => {
|
||||||
|
if (menu.children) {
|
||||||
|
menu.children = deeplySortMenu(menu.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newMenus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuItems = (): MenuItem[] => {
|
||||||
|
const menus: MenuItem[] = [];
|
||||||
|
const flatMetaInfo = flatten(metaInfo);
|
||||||
|
|
||||||
|
// head
|
||||||
|
flatMetaInfo.forEach((item) => {
|
||||||
|
if (!item.headMenu) return;
|
||||||
|
// 非路由页面头部菜单
|
||||||
|
if (typeof item.headMenu !== 'boolean') {
|
||||||
|
const find = menus.find(
|
||||||
|
(menu) => menu.key === (item.headMenu as any)?.key,
|
||||||
|
);
|
||||||
|
if (!find) {
|
||||||
|
menus.push({
|
||||||
|
key: (item.headMenu as any)?.key,
|
||||||
|
label: formatDisplayName((item.headMenu as any)?.label),
|
||||||
|
_order: (item.headMenu as any)?.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由页面头部菜单
|
||||||
|
if (item.headMenu === true) {
|
||||||
|
menus.push({
|
||||||
|
key: item._fullpath ?? '',
|
||||||
|
label: formatDisplayName(item.displayName ?? ''),
|
||||||
|
_order: item.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// side
|
||||||
|
flatMetaInfo.forEach((item) => {
|
||||||
|
if (!item.sideMenu) return;
|
||||||
|
|
||||||
|
const head = menus.find((menu) => menu.key === (item.headMenu as any)?.key);
|
||||||
|
if (!head) return;
|
||||||
|
if (!head.children) head.children = [];
|
||||||
|
|
||||||
|
// 非路由页面侧边菜单(分组)
|
||||||
|
if (typeof item.sideMenu !== 'boolean') {
|
||||||
|
const find = head.children.find(
|
||||||
|
(menu) => menu.key === (item.sideMenu as any)?.key,
|
||||||
|
);
|
||||||
|
if (!find) {
|
||||||
|
head.children.push({
|
||||||
|
key: (item.sideMenu as any)?.key,
|
||||||
|
label: formatDisplayName((item.sideMenu as any)?.label),
|
||||||
|
_order: (item.sideMenu as any)?.order,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: item._fullpath ?? '',
|
||||||
|
label: formatDisplayName(item.displayName ?? ''),
|
||||||
|
_order: item.order ?? 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
find.children!.push({
|
||||||
|
key: item._fullpath ?? '',
|
||||||
|
label: formatDisplayName(item.displayName ?? ''),
|
||||||
|
_order: item.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 路由页面侧边菜单
|
||||||
|
if (item.sideMenu === true) {
|
||||||
|
head.children.push({
|
||||||
|
key: item._fullpath ?? '',
|
||||||
|
label: formatDisplayName(item.displayName ?? ''),
|
||||||
|
_order: item.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// sort
|
||||||
|
return deeplySortMenu(menus);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const menuItems = getMenuItems();
|
||||||
|
|
||||||
|
export const findHeadMenuByHeadKey = (headKey) => {
|
||||||
|
return menuItems.find((menu) => menu.key === headKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findHeadMenuBySideKey = (
|
||||||
|
sideKey: string,
|
||||||
|
menus: MenuItem[] = menuItems,
|
||||||
|
): MenuItem | null => {
|
||||||
|
const find = menus.find((menu) =>
|
||||||
|
menu.children?.some(
|
||||||
|
(child) =>
|
||||||
|
child.key === sideKey ||
|
||||||
|
!!findHeadMenuBySideKey(sideKey, menu.children ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (find) return find;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findFirstSubMenu = (menu: MenuItem): MenuItem | undefined => {
|
||||||
|
if (!menu?.children?.[0]?.children?.length) return menu?.children?.[0];
|
||||||
|
return findFirstSubMenu(menu.children[0]);
|
||||||
|
};
|
||||||
44
template/src/utils/storage.ts
Normal file
44
template/src/utils/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { STORAGE_KEY_PREFIX } from '@/config';
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
setItem(key: string, value: any): void {
|
||||||
|
try {
|
||||||
|
const serializedValue = JSON.stringify(value);
|
||||||
|
localStorage.setItem(STORAGE_KEY_PREFIX + key, serializedValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting item ${key} to localStorage`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem<T>(key: string, defaultValue: T): T {
|
||||||
|
try {
|
||||||
|
const serializedValue = localStorage.getItem(STORAGE_KEY_PREFIX + key);
|
||||||
|
if (serializedValue === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return JSON.parse(serializedValue) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting item ${key} from localStorage`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_PREFIX + key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing item ${key} from localStorage`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing localStorage', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageService = new StorageService();
|
||||||
|
export default storageService;
|
||||||
29
template/tsconfig.json
Normal file
29
template/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "esnext",
|
||||||
|
"importHelpers": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"useUnknownInCatchVariables": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
11632
template/yarn.lock
Normal file
11632
template/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
0
values.yml
Normal file
0
values.yml
Normal file
Reference in New Issue
Block a user