Compare commits
2 Commits
38cfa091c6
...
09edc936b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09edc936b6 | ||
|
|
de8f3b3921 |
@@ -1,2 +0,0 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=7777
|
||||
@@ -22,13 +22,13 @@ module.exports = {
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
rules: {
|
||||
'react/prop-types': 'off',
|
||||
'react/no-children-prop': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
11
template/.gitignore
vendored
11
template/.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
src/meta.js
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -23,15 +22,9 @@ build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
.env.*
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
/package.zip
|
||||
.iconfont
|
||||
.env
|
||||
@@ -1 +0,0 @@
|
||||
yarn lint-staged
|
||||
@@ -1,2 +0,0 @@
|
||||
engine-strict=true
|
||||
@df:registry=https://npm.byteawake.com
|
||||
@@ -1,2 +1 @@
|
||||
node_modules
|
||||
src/meta.js
|
||||
|
||||
@@ -20,37 +20,28 @@
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 路由和菜单
|
||||
## 路由、布局、菜单
|
||||
|
||||
本工程采用配置化自动生成路由和菜单方案,在 `pages` 下的单个页面目录中配置 `meta.json` 文件,即可生成路由和菜单,具体配置如下:
|
||||
- 路由
|
||||
- 路由配置位于`src/router/config.tsx`文件
|
||||
- 配置形式类似于`ReactRouter 6`
|
||||
- `menu`字段会用于菜单项显示
|
||||
|
||||
```ts
|
||||
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; // 菜单序号,数字越小越靠前
|
||||
route?: string; // 当前页面路由名字,支持 ':id' 等路由参数,不同则默认等于目录名
|
||||
}
|
||||
```
|
||||
- 布局
|
||||
- 通过修改根路由节点的`type`属性实现`顶栏+侧栏`、`仅顶栏`、`仅侧栏`的布局模式
|
||||
|
||||
其中 `headMenu`、`sideMenu` 为 `true` 时,表示该页面是一个菜单页面,即点击对应 `displayName` 后直接跳转到该页面,参考 `demo` 页面。
|
||||
如果 `headMenu`、`sideMenu` 是对象,则表示的是该页面 **所属** 的非页面菜单组,参考 `test` 页面。
|
||||
```tsx
|
||||
export const routerConfig: RootRoute = {
|
||||
path: '/',
|
||||
element: <Layout type={LayoutType.Default} />,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- 菜单会根据`路由`和`布局`自动生成
|
||||
- 顶栏+侧栏:一级路由放置在`顶栏`,其余层级放置在`侧栏`
|
||||
- 仅侧栏/仅顶栏:所有菜单项均在分布`侧栏/顶栏`菜单中
|
||||
|
||||
## 常用方法和全局变量
|
||||
|
||||
@@ -69,48 +60,29 @@ interface Meta {
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
```shell
|
||||
src
|
||||
├── App.tsx # 路由逻辑,一般不需要修改
|
||||
├── Root.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 # 一个页面一个文件夹
|
||||
│ │ ├── index.module.css # 页面样式
|
||||
│ │ ├── index.tsx # 页面逻辑
|
||||
│ │ ├── api.ts # 页面请求
|
||||
│ │ ├── slice.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 # 帮助函数放这里
|
||||
```
|
||||
```shell
|
||||
src
|
||||
├── Icon.tsx # Icon组件,对应Toco前端工程的iconfont配置项
|
||||
├── Root.tsx # 页面根
|
||||
├── app # 应用
|
||||
│ ├── common.ts # 应用配置store
|
||||
│ ├── enum.ts # 枚举值
|
||||
│ ├── hooks.ts # store的hook
|
||||
│ ├── index.tsx # 应用入口
|
||||
│ ├── langs # 多语言配置
|
||||
│ ├── message.ts # 全局消息
|
||||
│ ├── request.ts # 请求实例
|
||||
│ ├── storage.ts # localStorage
|
||||
│ └── store.ts # store实例
|
||||
├── components # 组件放这里
|
||||
├── index.tsx # 前端入口
|
||||
├── layout # 布局
|
||||
├── pages # 页面放这里
|
||||
├── router # 路由
|
||||
│ ├── config.tsx # 配置路由、菜单项文本、布局类型
|
||||
│ ├── index.tsx # 路由导出
|
||||
└── style # 全局样式
|
||||
├── index.css
|
||||
└── reset.css
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
const CracoAlias = require('craco-alias');
|
||||
const webpack = require('webpack');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const GenerateMetaPlugin = require('./script/GenerateMetaPlugin');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
@@ -15,67 +12,11 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
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',
|
||||
},
|
||||
proxy: [
|
||||
// {
|
||||
// context: ['/api'],
|
||||
// target: 'https://my.test-api.com',
|
||||
// },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"name": "toco-template",
|
||||
"name": "{{projectName}}",
|
||||
"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"
|
||||
"test": "craco test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
@@ -21,43 +15,32 @@
|
||||
"@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",
|
||||
"path-to-regexp": "^8.2.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",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/react": "^18.2.60",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"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"
|
||||
"typescript": "^5"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -71,10 +54,6 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,tsx,ts,json}": "eslint",
|
||||
"*.js": "eslint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: [require('postcss-nested')],
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 138 KiB |
@@ -1,77 +0,0 @@
|
||||
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;
|
||||
let parentFullRoute = '';
|
||||
|
||||
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');
|
||||
const contentJSON = JSON.parse(content);
|
||||
Object.assign(current, contentJSON, {
|
||||
_fullpath: parts.join('/'),
|
||||
_fullroute: parentFullRoute
|
||||
? `${parentFullRoute}/${contentJSON.route || part}`
|
||||
: contentJSON.route || part,
|
||||
});
|
||||
} else {
|
||||
parentFullRoute = current._fullroute || parentFullRoute;
|
||||
}
|
||||
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;
|
||||
@@ -1,44 +0,0 @@
|
||||
import Layout from '@/layout';
|
||||
import withPage from '@/utils/withPage';
|
||||
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}`));
|
||||
const PageElement = withPage(Element);
|
||||
const route: RouteObject = {
|
||||
path: item._fullroute,
|
||||
element: <PageElement />,
|
||||
};
|
||||
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;
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const Root: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||
return <div {...props} />;
|
||||
};
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
import { Locale, Theme } from '@/enum';
|
||||
import { MenuItem } from '@/utils/menu';
|
||||
import storageService from '@/utils/storage';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { Locale, Theme } from './enum';
|
||||
import storageService from './storage';
|
||||
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({
|
||||
@@ -46,27 +31,10 @@ export const commonSlice = createSlice({
|
||||
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 { setLocale, setTheme, setPrimaryColor } = commonSlice.actions;
|
||||
|
||||
export const selectCommon = (state: RootState) => state.common;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
|
||||
81
template/src/app/index.tsx
Normal file
81
template/src/app/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { customValueTypeMap } from '@/components';
|
||||
import { ConfigProvider, App as TocoApp, theme } from '@df/toco-ui';
|
||||
import { Router } from '@remix-run/router';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider as StoreProvider } from 'react-redux';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Locale, Theme } from './enum';
|
||||
import { useAppSelector } from './hooks';
|
||||
import * as langs from './langs';
|
||||
import globalMessage from './message';
|
||||
import { store } from './store';
|
||||
|
||||
const { darkAlgorithm } = theme;
|
||||
|
||||
const AppInternal: React.FC<{ router: Router }> = (props) => {
|
||||
const { router } = props;
|
||||
const { message } = TocoApp.useApp();
|
||||
|
||||
const locale = useAppSelector((state) => state.common.locale);
|
||||
const theme = useAppSelector((state) => state.common.theme);
|
||||
const primaryColor = useAppSelector((state) => 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(() => {
|
||||
return {
|
||||
algorithm: theme === Theme.DARK ? [darkAlgorithm] : [],
|
||||
cssVar: true,
|
||||
token: primaryColor
|
||||
? {
|
||||
colorPrimary: primaryColor,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}, [primaryColor, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
globalMessage.setMessage(message);
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-color-scheme',
|
||||
theme === Theme.DARK ? 'dark' : 'light',
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={langsMap[locale]}>
|
||||
<ConfigProvider
|
||||
locale={antdLocaleMap[locale]}
|
||||
theme={themeConfig}
|
||||
valueTypeMap={customValueTypeMap}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC<{ router: Router }> = (props) => {
|
||||
return (
|
||||
<StoreProvider store={store}>
|
||||
<TocoApp style={{ height: '100%' }}>
|
||||
<AppInternal {...props} />
|
||||
</TocoApp>
|
||||
</StoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -8,9 +8,6 @@ const en_US: Language = {
|
||||
system_settings: 'System Settings',
|
||||
theme_light: 'Light',
|
||||
theme_dark: 'Dark',
|
||||
demo: 'Demo',
|
||||
demo2: 'Demo2',
|
||||
test: 'Test',
|
||||
};
|
||||
|
||||
export default en_US;
|
||||
@@ -9,9 +9,6 @@ export type Language = {
|
||||
system_settings: string;
|
||||
theme_light: string;
|
||||
theme_dark: string;
|
||||
demo: string;
|
||||
demo2: string;
|
||||
test: string;
|
||||
};
|
||||
|
||||
export { en_US, zh_CN };
|
||||
@@ -8,9 +8,6 @@ const zh_CN: Language = {
|
||||
system_settings: '系统设置',
|
||||
theme_light: '浅色模式',
|
||||
theme_dark: '深色模式',
|
||||
demo: '示例',
|
||||
demo2: '示例2',
|
||||
test: '测试',
|
||||
};
|
||||
|
||||
export default zh_CN;
|
||||
@@ -1,8 +1,8 @@
|
||||
import message from '@/components/message';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { AxiosResponse } from 'axios/index';
|
||||
import message from './message';
|
||||
|
||||
export type ResponseType<T = any> = {
|
||||
export type ResponseType<T = unknown> = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T | null;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { STORAGE_KEY_PREFIX } from '@/config';
|
||||
const STORAGE_KEY_PREFIX = 'toco_storage_';
|
||||
|
||||
class StorageService {
|
||||
setItem(key: string, value: any): void {
|
||||
setItem<T>(key: string, value: T): void {
|
||||
try {
|
||||
const serializedValue = JSON.stringify(value);
|
||||
localStorage.setItem(STORAGE_KEY_PREFIX + key, serializedValue);
|
||||
@@ -1,11 +1,9 @@
|
||||
import demoReducer from '@/pages/demo/slice';
|
||||
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
|
||||
import commonReducer from './common';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
common: commonReducer,
|
||||
demo: demoReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { RootState } from '@/app/store';
|
||||
import { customValueTypeMap } from '@/components';
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-color-scheme',
|
||||
theme === Theme.DARK ? 'dark' : 'light',
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<HelmetProvider context={helmetContext}>
|
||||
<IntlProvider locale={locale} messages={langsMap[locale]}>
|
||||
<ConfigProvider
|
||||
locale={antdLocaleMap[locale]}
|
||||
theme={themeConfig}
|
||||
valueTypeMap={customValueTypeMap}
|
||||
>
|
||||
<CSSVariablesStyle />
|
||||
{messageContextHolder}
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</IntlProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppWrapper;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Input, RenderFieldPropsType } from '@df/toco-ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
type NameType = {
|
||||
first?: string;
|
||||
last?: string;
|
||||
title?: string;
|
||||
};
|
||||
type AddressProps = {
|
||||
defaultValue?: NameType;
|
||||
value?: NameType;
|
||||
onChange?: (value: NameType) => void;
|
||||
};
|
||||
|
||||
const Fullname = (props: AddressProps) => {
|
||||
const { value, onChange } = props;
|
||||
const [currentValue, setCurrentValue] = useState<NameType | undefined>(value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<span>first</span>
|
||||
<Input
|
||||
defaultValue={value?.first}
|
||||
onChange={(e) =>
|
||||
onChange?.({
|
||||
...currentValue,
|
||||
first: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>last</span>
|
||||
<Input
|
||||
defaultValue={value?.last}
|
||||
onChange={(e) =>
|
||||
onChange?.({
|
||||
...currentValue,
|
||||
last: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>title</span>
|
||||
<Input
|
||||
defaultValue={value?.title}
|
||||
onChange={(e) =>
|
||||
onChange?.({
|
||||
...currentValue,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const valueTypeOptions: {
|
||||
name: string;
|
||||
renderFieldProps: RenderFieldPropsType;
|
||||
} = {
|
||||
name: 'fullname',
|
||||
renderFieldProps: {
|
||||
render: (value, props) => {
|
||||
return (
|
||||
<span>
|
||||
{value?.title} {value?.first} {value?.last}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
renderFormItem: (_, props) => <Fullname {...props.fieldProps} />,
|
||||
},
|
||||
};
|
||||
|
||||
export default Fullname;
|
||||
@@ -1,6 +1 @@
|
||||
import { RenderFieldPropsType } from '@df/toco-ui';
|
||||
import { valueTypeOptions as fullnameValueTypeOptions } from './fullname';
|
||||
|
||||
export const customValueTypeMap: Record<string, RenderFieldPropsType> = {
|
||||
[fullnameValueTypeOptions.name]: fullnameValueTypeOptions.renderFieldProps,
|
||||
};
|
||||
export {};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const CSS_VARIABLE_PREFIX = 'toco';
|
||||
|
||||
export const STORAGE_KEY_PREFIX = 'toco_storage_';
|
||||
34
template/src/global.d.ts
vendored
34
template/src/global.d.ts
vendored
@@ -1,31 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-var */
|
||||
|
||||
declare global {
|
||||
let tocoRefs: Record<string, any>;
|
||||
let tocoModals: Record<string, any>;
|
||||
var tocoRefs: Record<string, any>;
|
||||
var tocoModals: 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; // 菜单序号,数字越小越靠前
|
||||
route?: string; // 当前页面路由名字,支持 ':id' 等路由参数,不同则默认等于目录名
|
||||
_fullroute?: string; // 完整路由,最终路由
|
||||
_path: string; // 目录名
|
||||
_fullpath?: string; // 完整目录结构
|
||||
_children: Meta[];
|
||||
}
|
||||
export {};
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
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 App from './app';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { router } from './router';
|
||||
|
||||
const container = document.getElementById('root')!;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AppWrapper store={store}>
|
||||
<App />
|
||||
</AppWrapper>
|
||||
<App router={router} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -24,7 +21,5 @@ root.render(
|
||||
reportWebVitals();
|
||||
|
||||
// global variables
|
||||
(globalThis as any).tocoRefs = tocoGlobal.getRefs();
|
||||
(globalThis as any).tocoModals = tocoGlobal.getModals();
|
||||
|
||||
export { store };
|
||||
globalThis.tocoRefs = tocoGlobal.getRefs();
|
||||
globalThis.tocoModals = tocoGlobal.getModals();
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
.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-colorBgContainer);
|
||||
border-radius: var(--toco-borderRadiusLG);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
.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-colorBgContainer);
|
||||
border-radius: var(--toco-borderRadiusLG);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin: 16px 0;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
70
template/src/layout/index.module.css
Normal file
70
template/src/layout/index.module.css
Normal file
@@ -0,0 +1,70 @@
|
||||
.layout {
|
||||
height: 100%;
|
||||
--header-height: 63px;
|
||||
--extra-height: 63px;
|
||||
|
||||
.logoContainer{
|
||||
height: 100%;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.logo {
|
||||
&:global(.dark) {
|
||||
background: var(--ant-layout-sider-bg);
|
||||
}
|
||||
height: var(--header-height);
|
||||
padding: 16px;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
background: var(--ant-color-bg-container);
|
||||
border-bottom: 1px solid var(--ant-color-border);
|
||||
|
||||
.headerMenu {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sider {
|
||||
border-right: 1px solid var(--ant-color-border);
|
||||
|
||||
:global(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.siderContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.siderMenu {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.extra {
|
||||
&:global(.dark) {
|
||||
background: var(--ant-layout-sider-bg);
|
||||
}
|
||||
height: var(--extra-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,74 @@
|
||||
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';
|
||||
import { Theme } from '@/app/enum';
|
||||
import { useAppSelector } from '@/app/hooks';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { Button, Image, Popover } from '@df/toco-ui';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import styles from './index.module.css';
|
||||
import { HeaderOnly, HeaderSider, SiderOnly } from './layouts';
|
||||
import Settings from './settings';
|
||||
import SuspenseLayout from './suspense-layout';
|
||||
|
||||
export type LayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
onHeadMenuSelect?: MenuProps['onSelect'];
|
||||
onSideMenuSelect?: MenuProps['onSelect'];
|
||||
export enum LayoutType {
|
||||
Default = 0,
|
||||
Header = 1 << 1,
|
||||
Sider = 1 << 2,
|
||||
}
|
||||
|
||||
type LayoutProps = {
|
||||
type?: LayoutType;
|
||||
};
|
||||
|
||||
const LayoutRoot = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const Layout: React.FC<LayoutProps> = (props) => {
|
||||
const { type: typeProp } = props;
|
||||
const theme = useAppSelector((state) => state.common.theme);
|
||||
|
||||
// 设置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,
|
||||
}),
|
||||
);
|
||||
const Component = useMemo(() => {
|
||||
const type = typeProp ?? LayoutType.Default;
|
||||
const hasHeader = type & LayoutType.Header;
|
||||
const hasSider = type & LayoutType.Sider;
|
||||
if (hasHeader && hasSider) {
|
||||
return HeaderSider;
|
||||
} else if (hasHeader) {
|
||||
return HeaderOnly;
|
||||
} else if (hasSider) {
|
||||
return SiderOnly;
|
||||
}
|
||||
}, [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 HeaderSider;
|
||||
}, [typeProp]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
onHeadMenuSelect={handleHeadMenuSelect}
|
||||
onSideMenuSelect={handleSideMenuSelect}
|
||||
>
|
||||
<SuspenseLayout>
|
||||
<Outlet />
|
||||
</SuspenseLayout>
|
||||
</Layout>
|
||||
<SuspenseLayout>
|
||||
<Component
|
||||
theme={theme}
|
||||
siderWidth={250}
|
||||
logo={
|
||||
<div
|
||||
className={classNames(styles.logo, { dark: theme === Theme.DARK })}
|
||||
>
|
||||
<Image
|
||||
className={styles.icon}
|
||||
preview={false}
|
||||
src={
|
||||
process.env.PUBLIC_URL +
|
||||
(theme === Theme.DEFAULT ? '/logo.png' : '/logo_dark.png')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<div
|
||||
className={classNames(styles.extra, { dark: theme === Theme.DARK })}
|
||||
>
|
||||
<Popover content={<Settings />}>
|
||||
<Button type="text">
|
||||
<SettingOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SuspenseLayout>
|
||||
);
|
||||
};
|
||||
export default LayoutRoot;
|
||||
export default Layout;
|
||||
|
||||
65
template/src/layout/layouts/HeaderOnly.tsx
Normal file
65
template/src/layout/layouts/HeaderOnly.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
const HeaderOnly: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, logo, extra, siderWidth } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
setSelectedKeys(parts);
|
||||
}, [matches]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig?.children || [], true);
|
||||
}, []);
|
||||
|
||||
const onKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSelectedKeys(info.keyPath);
|
||||
navigate(keys.reverse().join('/'));
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.logoContainer} style={{ width: siderWidth }}>
|
||||
{logo}
|
||||
</div>
|
||||
<Menu
|
||||
className={styles.headerMenu}
|
||||
theme={theme}
|
||||
mode="horizontal"
|
||||
items={items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={onKeyChange}
|
||||
/>
|
||||
{extra}
|
||||
</Header>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default HeaderOnly;
|
||||
104
template/src/layout/layouts/HeaderSider.tsx
Normal file
104
template/src/layout/layouts/HeaderSider.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const HeaderSider: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, siderWidth, logo, extra } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [headerKey, setHeaderKey] = useState<string>();
|
||||
const [siderSelectedKeys, setSiderSelectedKeys] = useState<string[]>();
|
||||
const [siderExpandedKeys, setSiderExpandedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
const [headerKey, ...siderKey] = parts;
|
||||
setHeaderKey(headerKey);
|
||||
setSiderSelectedKeys(siderKey);
|
||||
setSiderExpandedKeys((prev) => {
|
||||
const set = new Set<string>(prev);
|
||||
siderKey.slice(0, -1).forEach((key) => {
|
||||
set.add(key);
|
||||
});
|
||||
return Array.from(set);
|
||||
});
|
||||
}, [matches]);
|
||||
|
||||
const headerItems: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig.children, false);
|
||||
}, []);
|
||||
|
||||
const onHeaderKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
setHeaderKey(info.key);
|
||||
navigate(info.key);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const siderItems: MenuProps['items'] = useMemo(() => {
|
||||
const item = routerConfig.children.find((item) => item.path === headerKey);
|
||||
return parseMenuItems(item?.children || [], true);
|
||||
}, [headerKey]);
|
||||
|
||||
const onSiderKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSiderSelectedKeys(info.keyPath);
|
||||
const parts = [headerKey, ...keys];
|
||||
navigate(parts.join('/'));
|
||||
},
|
||||
[navigate, headerKey],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.logoContainer} style={{ width: siderWidth }}>
|
||||
{logo}
|
||||
</div>
|
||||
<Menu
|
||||
className={styles.headerMenu}
|
||||
theme={theme}
|
||||
mode="horizontal"
|
||||
items={headerItems}
|
||||
selectedKeys={headerKey ? [headerKey] : []}
|
||||
onSelect={onHeaderKeyChange}
|
||||
/>
|
||||
{extra}
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider className={styles.sider} theme={theme} width={siderWidth}>
|
||||
<Menu
|
||||
className={styles.siderMenu}
|
||||
mode="inline"
|
||||
theme={theme}
|
||||
items={siderItems}
|
||||
selectedKeys={siderSelectedKeys}
|
||||
onSelect={onSiderKeyChange}
|
||||
openKeys={siderExpandedKeys}
|
||||
onOpenChange={setSiderExpandedKeys}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default HeaderSider;
|
||||
75
template/src/layout/layouts/SiderOnly.tsx
Normal file
75
template/src/layout/layouts/SiderOnly.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Content, Sider } = Layout;
|
||||
|
||||
const SiderOnly: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, logo, extra, siderWidth } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>();
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
setSelectedKeys(parts);
|
||||
setExpandedKeys((prev) => {
|
||||
const set = new Set<string>(prev);
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
set.add(key);
|
||||
});
|
||||
return Array.from(set);
|
||||
});
|
||||
}, [matches]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig?.children || [], true);
|
||||
}, []);
|
||||
|
||||
const onKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSelectedKeys(info.keyPath);
|
||||
navigate(keys.reverse().join('/'));
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Sider className={styles.sider} theme={theme} width={siderWidth}>
|
||||
{logo}
|
||||
<div className={styles.siderContainer}>
|
||||
<Menu
|
||||
className={styles.siderMenu}
|
||||
theme={theme}
|
||||
mode="inline"
|
||||
items={items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={onKeyChange}
|
||||
openKeys={expandedKeys}
|
||||
onOpenChange={setExpandedKeys}
|
||||
/>
|
||||
{extra}
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default SiderOnly;
|
||||
13
template/src/layout/layouts/index.ts
Normal file
13
template/src/layout/layouts/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import React from 'react';
|
||||
|
||||
export { default as HeaderOnly } from './HeaderOnly';
|
||||
export { default as HeaderSider } from './HeaderSider';
|
||||
export { default as SiderOnly } from './SiderOnly';
|
||||
|
||||
export type LayoutProps = {
|
||||
theme?: Theme;
|
||||
logo?: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
siderWidth?: number;
|
||||
};
|
||||
49
template/src/layout/layouts/utils.ts
Normal file
49
template/src/layout/layouts/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RootRoute } from '@/router';
|
||||
import { MenuProps } from '@df/toco-ui';
|
||||
import { parse } from 'path-to-regexp';
|
||||
import { UIMatch } from 'react-router-dom';
|
||||
|
||||
const removeLeadingSlashes = (path: string) => {
|
||||
return path.replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
export const parseMenuItems = (
|
||||
routes: RootRoute['children'],
|
||||
recursively: boolean,
|
||||
): MenuProps['items'] => {
|
||||
return routes
|
||||
.filter((item) => typeof item.path === 'string')
|
||||
.map((item) => {
|
||||
const res = parse(item.path);
|
||||
if (res.tokens.some((token) => token.type !== 'text') || !item.menu) {
|
||||
// 不生成菜单项:
|
||||
// 1. 路由中不全为text,例如有参数 :id
|
||||
// 2. 没有设置menu
|
||||
return undefined;
|
||||
}
|
||||
const children =
|
||||
recursively && item.children
|
||||
? parseMenuItems(item.children, true)
|
||||
: undefined;
|
||||
return {
|
||||
key: removeLeadingSlashes(item.path),
|
||||
label: item.menu.title,
|
||||
children: children && children.length > 0 ? children : 0,
|
||||
};
|
||||
})
|
||||
.filter((p) => !!p);
|
||||
};
|
||||
|
||||
export const parseRouteMatchs = (matches: UIMatch[]) => {
|
||||
return matches.reduce<string[]>((acc, match, index, array) => {
|
||||
const last = array[index - 1];
|
||||
const part = last
|
||||
? match.pathname.replace(last.pathname, '')
|
||||
: match.pathname;
|
||||
const path = removeLeadingSlashes(part);
|
||||
if (path) {
|
||||
acc.push(path);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { RootState } from '@/app/store';
|
||||
import { Theme } from '@/enum';
|
||||
import { Theme } from '@/app/enum';
|
||||
import { useAppSelector } from '@/app/hooks';
|
||||
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 = {
|
||||
@@ -22,7 +21,7 @@ export type ColorSelectProps = {
|
||||
|
||||
export const ColorSelect: React.FC<ColorSelectProps> = (props) => {
|
||||
const [value, setValue] = useControllableValue<string | undefined>(props);
|
||||
const theme = useSelector((state: RootState) => state.common.theme);
|
||||
const theme = useAppSelector((state) => state.common.theme);
|
||||
|
||||
const onChange = useCallback(
|
||||
(option: ColorSelectOption) => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Theme } from '@/enum';
|
||||
import { Theme } from '@/app/enum';
|
||||
import React from 'react';
|
||||
import { ColorSelect } from './color-select';
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
setPrimaryColor,
|
||||
setTheme,
|
||||
} from '@/app/common';
|
||||
import { Locale, Theme } from '@/app/enum';
|
||||
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';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Spin as AntSpin, SpinProps } from 'antd';
|
||||
import { Spin as AntSpin, SpinProps } from '@df/toco-ui';
|
||||
const Spin = (props: SpinProps) => {
|
||||
const { size = 'default' } = props;
|
||||
const sizeMap = {
|
||||
@@ -1,11 +1,8 @@
|
||||
import Spin from '@/components/spin';
|
||||
import React, { Suspense } from 'react';
|
||||
import Spin from '../spin';
|
||||
import styles from './index.module.css';
|
||||
|
||||
type SuspenseLayoutProps = {
|
||||
children: React.ReactElement;
|
||||
};
|
||||
const SuspenseLayout = (props: SuspenseLayoutProps) => {
|
||||
const SuspenseLayout: React.FC<React.PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<Suspense
|
||||
@@ -1,27 +0,0 @@
|
||||
import axiosInstance from '@/app/request';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export interface User {
|
||||
name: {
|
||||
first: string;
|
||||
last: string;
|
||||
title: 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,56 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/app/hooks';
|
||||
import { customValueTypeMap } from '@/components';
|
||||
import { User } from '@/pages/demo/api';
|
||||
import { fetchUsers, selectDemo } from '@/pages/demo/slice';
|
||||
import Root from '@/Root';
|
||||
import { EditTableColumnsType, Table } from '@df/toco-ui';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const columns: EditTableColumnsType<User, keyof typeof customValueTypeMap> = [
|
||||
{
|
||||
title: 'name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
valueType: 'fullname',
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Root>
|
||||
<Table
|
||||
id="demoTable"
|
||||
rowKey="email"
|
||||
value={users}
|
||||
columns={columns}
|
||||
editMode="single"
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Demo;
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"displayName": "{{demo}}",
|
||||
"icon": "CrownOutlined",
|
||||
"headMenu": true,
|
||||
"order": 0
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AppThunk, RootState } from '@/app/store';
|
||||
import { getUsers, User } from '@/pages/demo/api';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface DemoState {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
/* DO NOT EDIT initialState */
|
||||
const initialState: DemoState = {
|
||||
users: [],
|
||||
};
|
||||
|
||||
export const slice = createSlice({
|
||||
name: 'demo',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUsers: (state, action: PayloadAction<User[]>) => {
|
||||
state.users = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUsers } = slice.actions;
|
||||
|
||||
export const fetchUsers = (): AppThunk => async (dispatch, getState) => {
|
||||
const data = await getUsers();
|
||||
dispatch(setUsers(data));
|
||||
};
|
||||
|
||||
export const selectDemo = (state: RootState) => state.demo;
|
||||
|
||||
export default slice.reducer;
|
||||
@@ -1,26 +0,0 @@
|
||||
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,31 +0,0 @@
|
||||
import { useAppSelector } from '@/app/hooks';
|
||||
import Root from '@/Root';
|
||||
import { useEffect } from 'react';
|
||||
import * as tocoServices from './api';
|
||||
|
||||
const Test = (props) => {
|
||||
const tocoStore = useAppSelector((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
// 一些常用变量或方法
|
||||
console.log('props', props);
|
||||
console.log('store', tocoStore);
|
||||
console.log('services', tocoServices);
|
||||
console.log('refs', tocoRefs);
|
||||
console.log('modals', tocoModals);
|
||||
}, [props, tocoStore]);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<h1>Store</h1>
|
||||
<p>the primary color is: {tocoStore.common.primaryColor}</p>
|
||||
<h1>Service</h1>
|
||||
<h1>Refs</h1>
|
||||
<div>
|
||||
<p>children page</p>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Test;
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"displayName": "{{test}}",
|
||||
"icon": "RobotOutlined",
|
||||
"headMenu": {
|
||||
"key": "demo2",
|
||||
"label": "{{demo2}}"
|
||||
},
|
||||
"sideMenu": true,
|
||||
"order": 0
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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,25 +0,0 @@
|
||||
import { useAppSelector } from '@/app/hooks';
|
||||
import Root from '@/Root';
|
||||
import { useEffect } from 'react';
|
||||
import * as tocoServices from './api';
|
||||
|
||||
const Xxx = (props) => {
|
||||
const tocoStore = useAppSelector((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
// 一些常用变量或方法
|
||||
console.log('props', props);
|
||||
console.log('services', tocoServices);
|
||||
console.log('refs', tocoRefs);
|
||||
console.log('modals', tocoModals);
|
||||
console.log('store', tocoStore);
|
||||
}, [props, tocoStore]);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<h1>xxx</h1>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Xxx;
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"displayName": "xxx",
|
||||
"icon": "RobotOutlined",
|
||||
"headMenu": false,
|
||||
"sideMenu": false,
|
||||
"order": 0,
|
||||
"route": "xx/:oo"
|
||||
}
|
||||
9
template/src/router/config.tsx
Normal file
9
template/src/router/config.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Layout, { LayoutType } from '@/layout';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { RootRoute } from './types';
|
||||
|
||||
export const routerConfig: RootRoute = {
|
||||
path: '/',
|
||||
element: <Layout type={LayoutType.{{layout}}} />,
|
||||
children: [],
|
||||
};
|
||||
32
template/src/router/index.tsx
Normal file
32
template/src/router/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom';
|
||||
import { routerConfig } from './config';
|
||||
import { RootRoute } from './types';
|
||||
import withPage, { WithPageProps } from './withPage';
|
||||
|
||||
export { routerConfig };
|
||||
export type { RootRoute, WithPageProps };
|
||||
|
||||
const recursivelyFormatRoute = (
|
||||
config: RootRoute['children'],
|
||||
): RouteObject[] => {
|
||||
return config?.map((route) => {
|
||||
const { menu, children, Component, ...rest } = route;
|
||||
const ret = {
|
||||
...rest,
|
||||
children: children ? recursivelyFormatRoute(children) : undefined,
|
||||
Component: Component ? withPage(Component) : undefined,
|
||||
} as RouteObject;
|
||||
return ret;
|
||||
});
|
||||
};
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
...routerConfig,
|
||||
children: recursivelyFormatRoute(routerConfig.children),
|
||||
} as RouteObject,
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
]);
|
||||
23
template/src/router/types.ts
Normal file
23
template/src/router/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
|
||||
import { WithPageProps } from './withPage';
|
||||
|
||||
export type RootRoute = {
|
||||
menu?: undefined;
|
||||
children: (IndexRoute | Route)[];
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<NonIndexRouteObject, 'children' | 'Component'>;
|
||||
|
||||
export type IndexRoute = {
|
||||
index: true;
|
||||
menu?: undefined;
|
||||
path?: undefined;
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<IndexRouteObject, 'path' | 'Component'>;
|
||||
|
||||
export type Route = {
|
||||
menu?: { title: string };
|
||||
path: string;
|
||||
children?: (IndexRoute | Route)[];
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<NonIndexRouteObject, 'children' | 'path' | 'Component'>;
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Location,
|
||||
NavigateFunction,
|
||||
Params,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
const withPage = (BaseComponent: React.ComponentType) => {
|
||||
const WithPageComponent = (props: any) => {
|
||||
export type WithPageProps<T = Record<string, unknown>> = T & {
|
||||
location: Location;
|
||||
params: Readonly<Params<string>>;
|
||||
search: Record<string, string>;
|
||||
navigate: NavigateFunction;
|
||||
};
|
||||
|
||||
function withPage<P>(BaseComponent: React.ComponentType<WithPageProps<P>>) {
|
||||
const WithPageComponent = (props: P) => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const [searchParamsOri] = useSearchParams();
|
||||
const searchParams = useMemo(() => {
|
||||
const params = {};
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParamsOri.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
@@ -21,21 +31,19 @@ const withPage = (BaseComponent: React.ComponentType) => {
|
||||
}, [searchParamsOri]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseComponent
|
||||
{...props}
|
||||
location={location}
|
||||
params={params}
|
||||
search={searchParams}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</>
|
||||
<BaseComponent
|
||||
{...props}
|
||||
location={location}
|
||||
params={params}
|
||||
search={searchParams}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
WithPageComponent.displayName =
|
||||
BaseComponent.displayName ?? 'WithPageComponent';
|
||||
|
||||
return WithPageComponent;
|
||||
};
|
||||
}
|
||||
|
||||
export default withPage;
|
||||
@@ -1,11 +0,0 @@
|
||||
/* 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,
|
||||
// }),
|
||||
// );
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
@@ -1,33 +0,0 @@
|
||||
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 };
|
||||
@@ -1,11 +0,0 @@
|
||||
// 将下划线或者中划线命名转换为驼峰命名
|
||||
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());
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
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]);
|
||||
};
|
||||
@@ -1,25 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"strict": 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": {
|
||||
"@/*": ["./*"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
projectName: toco
|
||||
uiVersion: 0.1.36
|
||||
iconFontSrc: ""
|
||||
iconFontSrc: ""
|
||||
layout: Default
|
||||
Reference in New Issue
Block a user