Compare commits

...

2 Commits

Author SHA1 Message Date
jackycheng
09edc936b6 fix: 添加layout参数 2024-12-24 14:27:56 +08:00
jackycheng
de8f3b3921 fix: 更新模板 2024-12-24 14:25:06 +08:00
77 changed files with 704 additions and 1492 deletions

View File

@@ -1,2 +0,0 @@
HOST=0.0.0.0
PORT=7777

View File

@@ -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
View File

@@ -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

View File

@@ -1 +0,0 @@
yarn lint-staged

View File

@@ -1,2 +0,0 @@
engine-strict=true
@df:registry=https://npm.byteawake.com

View File

@@ -1,2 +1 @@
node_modules
src/meta.js

View File

@@ -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
```

View File

@@ -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',
// },
],
},
};

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,3 +1,5 @@
import React from 'react';
const Root: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => {
return <div {...props} />;
};

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -8,9 +8,6 @@ const zh_CN: Language = {
system_settings: '系统设置',
theme_light: '浅色模式',
theme_dark: '深色模式',
demo: '示例',
demo2: '示例2',
test: '测试',
};
export default zh_CN;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {};

View File

@@ -1,3 +0,0 @@
export const CSS_VARIABLE_PREFIX = 'toco';
export const STORAGE_KEY_PREFIX = 'toco_storage_';

View File

@@ -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 {};

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View 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;
}
}

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;
};

View 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;
}, []);
};

View File

@@ -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) => {

View File

@@ -1,4 +1,4 @@
import { Theme } from '@/enum';
import { Theme } from '@/app/enum';
import React from 'react';
import { ColorSelect } from './color-select';

View File

@@ -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';

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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 || [];
};

View File

@@ -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;

View File

@@ -1,6 +0,0 @@
{
"displayName": "{{demo}}",
"icon": "CrownOutlined",
"headMenu": true,
"order": 0
}

View File

@@ -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;

View File

@@ -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 || [];
};

View File

@@ -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;

View File

@@ -1,10 +0,0 @@
{
"displayName": "{{test}}",
"icon": "RobotOutlined",
"headMenu": {
"key": "demo2",
"label": "{{demo2}}"
},
"sideMenu": true,
"order": 0
}

View File

@@ -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 || [];
};

View File

@@ -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;

View File

@@ -1,8 +0,0 @@
{
"displayName": "xxx",
"icon": "RobotOutlined",
"headMenu": false,
"sideMenu": false,
"order": 0,
"route": "xx/:oo"
}

View 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: [],
};

View 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 />,
},
]);

View 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'>;

View File

@@ -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;

View File

@@ -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,
// }),
// );
};

View File

@@ -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>;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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());
};

View File

@@ -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]);
};

View File

@@ -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": {
"@/*": ["./*"]

View File

@@ -1,3 +1,4 @@
projectName: toco
uiVersion: 0.1.36
iconFontSrc: ""
iconFontSrc: ""
layout: Default