build: init

This commit is contained in:
dayjoy
2024-10-14 18:51:44 +08:00
commit 6f6f976d24
70 changed files with 13878 additions and 0 deletions

View File

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

44
template/.eslintrc.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
extends: [
'react-app',
'react-app/jest',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'prettier/prettier',
'plugin:prettier/recommended',
'plugin:react-hooks/recommended',
'plugin:json/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['react', '@typescript-eslint', 'simple-import-sort'],
rules: {
'prettier/prettier': ['error', { singleQuote: true }],
'@typescript-eslint/no-unused-vars': [
'off',
{ argsIgnorePattern: ['^_', 'tocoServices'] },
],
'simple-import-sort/exports': 'error',
'react-hooks/exhaustive-deps': 'error',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
overrides: [
{
files: ['**/*.json'],
rules: {
'prettier/prettier': ['error', { singleQuote: false }],
},
},
{
files: ['**/*.config.js', '**/setupProxy.js', 'script/**/*.js'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
],
globals: {
tocoRefs: true,
},
};

35
template/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
src/meta.js
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
#idea
/.idea
#vscode
/.vscode
# testing
coverage
# production
build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/package.zip
.iconfont
.env

View File

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

2
template/.npmrc Normal file
View File

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

2
template/.prettierignore Normal file
View File

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

8
template/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}

109
template/README.md Normal file
View File

@@ -0,0 +1,109 @@
# TOCO Project
## 开发
- install
```shell
yarn install
```
- dev
```shell
yarn start
```
- build
```shell
yarn build
```
## 路由和菜单
本工程采用配置化自动生成路由和菜单方案,在 `pages` 下的单个页面目录中配置 `meta.json` 文件,即可生成路由和菜单,具体配置如下:
```ts
interface Meta {
displayName?: string; // 展示在菜单上的名字
icon?: string; // 展示在菜单上的图标
headMenu?: // 是否展示在头部菜单 或 所属头部菜单的名字
| boolean
| {
key: string;
label: string;
icon?: string;
};
sideMenu?: // 是否展示在侧边菜单 或 所属侧边菜单的名字
| boolean
| {
key: string;
label: string;
icon?: string;
};
order?: number; // 菜单排列序号,数字越小越靠前
}
```
其中 `headMenu`、`sideMenu` 为 `true` 时,表示该页面是一个菜单页面,即点击对应 `displayName` 后直接跳转到该页面,参考 `demo` 页面。
如果 `headMenu`、`sideMenu` 是对象,则表示的是该页面 **所属** 的非页面菜单组,参考 `test` 页面。
## 常用方法和全局变量
在每个页面内,可以使用以下常用方法和全局变量:
```
navigate页面跳转方法用法navigate('/path')
props页面 props
tocoStore全局 store
tocoServices页面的请求
tocoRefs页面上所有带 id 组件的 refs
```
## 目录结构
```shell
src
├── App.tsx # 路由逻辑,一般不需要修改
├── app # store 主逻辑
│   └── store.ts # 如果写了新的 store需要在这里引入
├── components # 组件放这里
├── config # 可以放一些常量、配置
│   └── index.ts
├── enum # 枚举值定义
│   └── index.ts
├── global.d.ts
├── index.tsx
├── langs # 国际化配置
│   ├── en_US.ts
│   ├── index.ts
│   └── zh_CN.ts
├── layout # 布局
│   ├── HeaderAndFooter
│   ├── HeaderAndSider
│   └── index.tsx # 可以在这里切换布局方案
├── pages # 页面放这里
│   ├── demo # 一个页面一个文件夹
│   │   ├── Demo.module.css # 页面样式
│   │   ├── Demo.tsx # 页面逻辑
│   │   ├── demoAPI.ts # 页面请求
│   │   ├── demoSlice.ts # 页面 store
│   │   └── meta.json # 页面元数据,用于生成路由和菜单,没有这个文件视为普通组件
│   └── test
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupProxy.js
├── setupTests.ts
├── style # 全局样式
│   ├── index.css
│   └── reset.css
├── themes # 主题配置
│   ├── CSSVariablesStyle.tsx # 用于定义css主题变量不用修改
│   ├── dark.ts # 夜间主题
│   ├── default.ts # 日间主题
│   └── index.ts # 在这里定义自定义token在主题文件内配置具体值
└── utils # 帮助函数放这里
```

81
template/craco.config.js Normal file
View File

@@ -0,0 +1,81 @@
const CracoAlias = require('craco-alias');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
const GenerateMetaPlugin = require('./script/GenerateMetaPlugin');
module.exports = {
plugins: [
{
plugin: CracoAlias,
options: {
baseUrl: 'src',
source: 'tsconfig',
tsConfigPath: './tsconfig.json',
},
},
],
devServer: {
client: {
overlay: {
runtimeErrors: (error) => {
// 忽略ResizeObserver的错误防止阻断调试
return !(
error.message === 'ResizeObserver loop limit exceeded' ||
error.message ===
'ResizeObserver loop completed with undelivered notifications.'
);
},
},
},
},
webpack: {
configure: (webpackConfig, arg) => {
// webpack v4 2 v5 polyfill workaround
webpackConfig.resolve.fallback = {
path: false,
fs: false,
assert: false,
buffer: require.resolve('buffer'),
'process/browser': require.resolve('process/browser'),
os: require.resolve('os-browserify/browser'),
};
webpackConfig.plugins.push(
new GenerateMetaPlugin(),
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
);
// 处理typescript的warning
// Module not found: Error: Can't resolve 'perf_hooks' in '/node_modules/typescript/lib'
// Critical dependency: the request of a dependency is an expression
webpackConfig.module.noParse = /typescript\/lib\/typescript.js$/;
// 去掉一个warning
webpackConfig.ignoreWarnings = [/Failed to parse source map/];
// 去掉comments
if (arg.env === 'production') {
webpackConfig.optimization.minimize = true;
webpackConfig.optimization.minimizer = [
new TerserPlugin({
terserOptions: {
output: {
comments: false,
},
},
extractComments: false,
}),
];
}
return webpackConfig;
},
},
style: {
postcss: {
mode: 'file',
},
},
};

81
template/package.json Normal file
View File

@@ -0,0 +1,81 @@
{
"name": "toco-template",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "craco build",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"lint": "eslint src --ext .js,.jsx,.tsx,.ts,.json --fix --quiet",
"lint-staged": "lint-staged",
"start": "craco start",
"test": "craco test",
"prepare": "husky"
},
"resolutions": {
"@typescript-eslint/parser": "^6.19.0"
},
"dependencies": {
"@ant-design/icons": "^5.3.4",
"@df/toco-ui": "^0.1.11",
"@reduxjs/toolkit": "^1.9.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@types/react": "^18.2.60",
"@types/react-dom": "^18.2.19",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"os-browserify": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.4",
"react-intl": "^6.6.2",
"react-redux": "^8.1.3",
"react-router-dom": "^6.22.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@craco/craco": "^7.1.0",
"buffer": "^6.0.3",
"craco-alias": "^3.0.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-json": "^3.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"http-proxy-middleware": "^2.0.6",
"husky": "^9.1.0",
"lint-staged": "^15.2.2",
"postcss-mixins": "^10.0.0",
"postcss-nested": "^6.0.1",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-packagejson": "^2.4.12",
"process": "^0.11.10",
"source-map-explorer": "^2.5.3",
"terser-webpack-plugin": "^5.3.10"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"lint-staged": {
"src/**/*.{js,jsx,tsx,ts,json}": "eslint",
"*.js": "eslint"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,5 @@
const path = require('path');
module.exports = {
plugins: [require('postcss-nested')],
};

BIN
template/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
</head>
<body>
<script src="https://registry.npmmirror.com/prettier/2.6.0/files/standalone.js"></script>
<script src="https://registry.npmmirror.com/prettier/2.6.0/files/parser-babel.js"></script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
template/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
// 递归地构建嵌套结构
function buildNestedStructure(filePaths) {
const root = [];
filePaths.forEach((filePath) => {
const parts = filePath.split('/').slice(2, -1); // 去掉 'src/pages' 'meta.json'
let current = root;
parts.forEach((part, index) => {
const find = current.find((item) => item._path === part);
if (find) {
current = find;
} else {
current.push({
_path: part,
_children: [],
});
current = current[current.length - 1];
}
if (index === parts.length - 1) {
const content = fs.readFileSync(filePath, 'utf-8');
Object.assign(current, JSON.parse(content), {
_fullpath: parts.join('/'),
});
}
current = current._children;
});
});
return root;
}
// 自定义插件:用于生成 meta.js 文件
class GenerateMetaPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(
'GenerateMetaPlugin',
(compilation, callback) => {
const metaFiles = glob.sync('src/pages/**/meta.json');
const metaInfo = buildNestedStructure(metaFiles);
const output = `export const metaInfo = ${JSON.stringify(metaInfo, null, 2)};`;
const outputPath = path.resolve(__dirname, '../src/meta.js');
if (fs.existsSync(outputPath)) {
const existingContent = fs.readFileSync(outputPath, 'utf8');
if (existingContent === output) {
// 文件已存在且内容相同,跳过文件生成
return callback();
}
}
fs.writeFileSync(outputPath, output, 'utf-8');
console.log('meta.js has been generated successfully!');
callback();
},
);
}
}
module.exports = GenerateMetaPlugin;

45
template/src/App.tsx Normal file
View File

@@ -0,0 +1,45 @@
import Layout from '@/layout';
import { toPascalCase } from '@/utils';
import { lazy } from 'react';
import {
createBrowserRouter,
RouteObject,
RouterProvider,
} from 'react-router-dom';
import { metaInfo } from './meta.js';
// routes
const parseMeta2Routes = (meta: Meta[]) => {
const routes: RouteObject[] = [];
meta.forEach((item) => {
if (item._children?.length) {
// 是分组文件夹
routes.push(...parseMeta2Routes(item._children));
return;
}
const Element = lazy(
() => import(`@/pages/${item._fullpath}/${toPascalCase(item._path)}`),
);
const route: RouteObject = {
path: item._fullpath,
element: <Element />,
};
routes.push(route);
});
return routes;
};
const routes = parseMeta2Routes(metaInfo);
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: routes,
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;

View File

@@ -0,0 +1,73 @@
import { Locale, Theme } from '@/enum';
import { MenuItem } from '@/utils/menu';
import storageService from '@/utils/storage';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from './store';
type MenuKey = {
headKey: string;
sideKey?: string;
};
export interface CommonState {
locale: Locale;
theme: Theme;
primaryColor: string;
currentMenu: MenuKey;
headMenus: MenuItem[];
sideMenus: MenuItem[];
}
/* DO NOT EDIT initialState */
const initialState: CommonState = {
locale: storageService.getItem('locale', Locale.zh_CN),
theme: storageService.getItem('theme', Theme.DEFAULT),
primaryColor: storageService.getItem('primaryColor', '#1361B3'),
currentMenu: {
headKey: '',
sideKey: '',
},
headMenus: [],
sideMenus: [],
};
export const commonSlice = createSlice({
name: 'common',
initialState,
reducers: {
setLocale: (state, action) => {
state.locale = action.payload;
storageService.setItem('locale', action.payload);
},
setTheme: (state, action) => {
state.theme = action.payload;
storageService.setItem('theme', action.payload);
},
setPrimaryColor: (state, action) => {
state.primaryColor = action.payload;
storageService.setItem('primaryColor', action.payload);
},
setCurrentMenu: (state, action: PayloadAction<MenuKey>) => {
state.currentMenu = action.payload;
// 设置侧边菜单内容
const { headKey } = action.payload;
state.sideMenus =
state.headMenus.find((menu) => menu.key === headKey)?.children ?? [];
},
setHeadMenus: (state, action: PayloadAction<MenuItem[]>) => {
state.headMenus = action.payload ?? [];
},
},
});
export const {
setLocale,
setTheme,
setPrimaryColor,
setCurrentMenu,
setHeadMenus,
} = commonSlice.actions;
export const selectCommon = (state: RootState) => state.common;
export default commonSlice.reducer;

View File

@@ -0,0 +1,6 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -0,0 +1,75 @@
import message from '@/components/message';
import axios, { AxiosError } from 'axios';
import { AxiosResponse } from 'axios/index';
export type ResponseType<T = any> = {
code: number;
message: string;
data: T | null;
};
const axiosInstance = axios.create({
timeout: 10000,
});
// 请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
// 在这里添加请求头等等
return config;
},
(error) => {
return Promise.reject(error);
},
);
// 响应拦截器
axiosInstance.interceptors.response.use(
(response: AxiosResponse<ResponseType>) => {
const { errorHandler, errorMessage } = response?.config?.params ?? {};
if (errorHandler === false) return response;
if (response.data.code !== 200) {
handleGlobalError(
response.data.code,
errorMessage ?? response.data.message,
);
}
return response;
},
(error: AxiosError) => {
const { errorHandler, errorMessage } = error?.config?.params ?? {};
if (errorHandler === false) return Promise.reject(error);
if (error.response) {
handleGlobalError(error.response.status, errorMessage);
} else if (error.request) {
message.error('请求发送失败');
} else {
message.error('请求失败');
}
},
);
// 全局错误处理函数
const handleGlobalError = (code: number, messageStr?: string) => {
switch (code) {
case 401:
// TODO 登录跳转
message.error(messageStr ?? '未授权');
break;
case 403:
message.error(messageStr ?? '禁止访问');
break;
case 404:
message.error(messageStr ?? '资源未找到');
break;
case 500:
message.error(messageStr ?? '服务器内部错误');
break;
default:
message.error(messageStr ?? '发生错误');
}
};
export default axiosInstance;

19
template/src/app/store.ts Normal file
View File

@@ -0,0 +1,19 @@
import demoReducer from '@/pages/demo/demoSlice';
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
import commonReducer from './common';
export const store = configureStore({
reducer: {
common: commonReducer,
demo: demoReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;

View File

@@ -0,0 +1,89 @@
import { RootState } from '@/app/store';
import globalMessage from '@/components/message';
import { Locale, Theme } from '@/enum';
import * as langs from '@/langs';
import * as themes from '@/themes';
import { CSSVariablesStyle } from '@/themes/CSSVariablesStyle';
import { ConfigProvider } from '@df/toco-ui';
import { Store } from '@reduxjs/toolkit';
import { message as tocoMessage } from 'antd';
import enUS from 'antd/locale/en_US';
import zhCN from 'antd/locale/zh_CN';
import React, { useEffect, useMemo } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { IntlProvider } from 'react-intl';
import { Provider, useSelector } from 'react-redux';
const helmetContext = {};
type AppWrapperProps = {
children: React.ReactNode;
store: Store;
};
const AppWrapper = (props: AppWrapperProps) => {
const { children, store } = props;
return (
<Provider store={store}>
<Insider>{children}</Insider>
</Provider>
);
};
type InsiderProps = {
children: React.ReactNode;
};
const Insider = (props: InsiderProps) => {
const { children } = props;
const [message, messageContextHolder] = tocoMessage.useMessage();
const locale = useSelector((state: RootState) => state.common.locale);
const theme = useSelector((state: RootState) => state.common.theme);
const primaryColor = useSelector(
(state: RootState) => state.common.primaryColor,
);
const antdLocaleMap = {
[Locale.zh_CN]: zhCN,
[Locale.en_US]: enUS,
};
const langsMap = {
[Locale.zh_CN]: langs.zh_CN,
[Locale.en_US]: langs.en_US,
};
const themeConfig = useMemo(() => {
const themeMap = {
[Theme.DEFAULT]: themes.defaultTheme,
[Theme.DARK]: themes.darkTheme,
};
const config = themeMap[theme];
if (primaryColor) {
return {
...config,
token: {
...(config.token ?? {}),
colorPrimary: primaryColor,
},
};
}
return config;
}, [primaryColor, theme]);
useEffect(() => {
globalMessage.setMessage(message);
}, [message]);
return (
<HelmetProvider context={helmetContext}>
<IntlProvider locale={locale} messages={langsMap[locale]}>
<ConfigProvider locale={antdLocaleMap[locale]} theme={themeConfig}>
<CSSVariablesStyle />
{messageContextHolder}
{children}
</ConfigProvider>
</IntlProvider>
</HelmetProvider>
);
};
export default AppWrapper;

View File

@@ -0,0 +1,50 @@
/**
* 带主题等上下文的、可全局使用的message api
* toco-ui/antd 组件库的message无法直接全局使用主题上下文会丢失
*/
import { message as tocoMessage } from '@df/toco-ui';
type Message = ReturnType<typeof tocoMessage.useMessage>[0];
class GlobalMessage {
private static instance: GlobalMessage;
private message: Message;
private constructor() {
this.message = tocoMessage;
}
public static getInstance(): GlobalMessage {
if (!GlobalMessage.instance) {
GlobalMessage.instance = new GlobalMessage();
}
return GlobalMessage.instance;
}
public setMessage(message: Message) {
this.message = message;
}
public success(content: string, duration?: number, onClose?: () => void) {
this.message.success(content, duration, onClose);
}
public error(content: string, duration?: number, onClose?: () => void) {
this.message.error(content, duration, onClose);
}
public info(content: string, duration?: number, onClose?: () => void) {
this.message.info(content, duration, onClose);
}
public warning(content: string, duration?: number, onClose?: () => void) {
this.message.warning(content, duration, onClose);
}
public loading(content: string, duration?: number, onClose?: () => void) {
this.message.loading(content, duration, onClose);
}
}
const globalMessage = GlobalMessage.getInstance();
export default globalMessage;

View File

@@ -0,0 +1,15 @@
.color-select {
display: flex;
align-items: center;
gap: 8px;
.color-option {
width: 20px;
height: 20px;
color: #fff;
font-weight: 700;
text-align: center;
border-radius: 2px;
cursor: pointer;
}
}

View File

@@ -0,0 +1,57 @@
import { RootState } from '@/app/store';
import { Theme } from '@/enum';
import { CheckOutlined } from '@ant-design/icons';
import { useControllableValue } from 'ahooks';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import styles from './index.module.css';
export type ColorSelectOption = {
color: string;
theme?: Theme;
};
export type ColorSelectProps = {
className?: string;
options?: ColorSelectOption[];
defaultValue?: string;
value?: string;
onChange?: (color: string) => void;
};
export const ColorSelect: React.FC<ColorSelectProps> = (props) => {
const [value, setValue] = useControllableValue<string | undefined>(props);
const theme = useSelector((state: RootState) => state.common.theme);
const onChange = useCallback(
(option: ColorSelectOption) => {
setValue(option.color);
},
[setValue],
);
return (
<div className={classNames(styles['color-select'], props.className)}>
{props.options?.map((option) => {
if (!option.theme || option.theme === theme) {
return (
<div
key={option.color}
className={styles['color-option']}
style={{
backgroundColor: option.color,
}}
onClick={() => {
onChange(option);
}}
>
{option.color === value && <CheckOutlined />}
</div>
);
}
return null;
})}
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { Theme } from '@/enum';
import React from 'react';
import { ColorSelect } from './color-select';
export const changeColor = ['#DFC193', '#1361B3'];
const colorArray = [
{
theme: Theme.DARK,
color: '#DFC193',
},
{
theme: Theme.DEFAULT,
color: '#1361B3',
},
{
color: '#E57373',
},
{
color: '#F08F48',
},
{
color: '#59AF6C',
},
{
color: '#26ABB2',
},
{
color: '#AD81E5',
},
];
type ColorProps = {
color: string;
handleChange: (color: string) => void;
};
const Color: React.FC<ColorProps> = (props) => {
const { color, handleChange } = props;
return (
<ColorSelect options={colorArray} value={color} onChange={handleChange} />
);
};
export default Color;

View File

@@ -0,0 +1,71 @@
.container {
}
.item {
margin-bottom: 12px;
}
.title {
margin-bottom: 12px;
color: var(--toco-colorTextBase);
}
.content {
display: flex;
>div {
margin-right: 16px;
}
}
.light {
width: 44px;
height: 36px;
background-color: #F0F2F5;
border-radius: 4px;
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
position: relative;
cursor: pointer;
:global(.anticon) {
color: var(--toco-colorTextBase);
position: absolute;
right: 6px;
bottom: 6px;
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 33%;
height: 100%;
background-color: #fff;
content: "";
border-radius: 4px;
}
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 25%;
background-color: #fff;
content: "";
border-radius: 4px;
}
}
.dark {
background-color: #3A4D5B;
&::before {
background-color: #000;
}
&::after {
background-color: #000;
}
}

View File

@@ -0,0 +1,103 @@
import {
selectCommon,
setLocale,
setPrimaryColor,
setTheme,
} from '@/app/common';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { Locale, Theme } from '@/enum';
import { Radio, RadioChangeEvent, Tooltip } from '@df/toco-ui';
import React, { useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import ColorContainer, { changeColor } from './color';
import styles from './index.module.css';
import ThemeContainer from './theme';
const Settings: React.FC = () => {
const dispatch = useAppDispatch();
const { primaryColor, theme, locale } = useAppSelector(selectCommon);
const intl = useIntl();
const toggleTheme = (newTheme: Theme) => {
const index = changeColor?.findIndex(
(item: string) => item === primaryColor,
);
if (index >= 0) {
dispatch(
setPrimaryColor(index === 0 ? changeColor?.[1] : changeColor?.[0]),
);
}
dispatch(setTheme(newTheme));
};
const togglePrimaryColor = (newPrimaryColor: string) => {
dispatch(setPrimaryColor(newPrimaryColor));
};
const handleLocaleChange = useCallback(
(e: RadioChangeEvent) => {
dispatch(setLocale(e.target.value));
},
[dispatch],
);
return (
<div className={styles.container}>
<div className={styles.item}>
<div className={styles.title}>
<FormattedMessage id="style" />
</div>
<div className={styles.content}>
<Tooltip
placement="top"
title={intl.formatMessage({ id: 'theme_light' })}
>
<div
onClick={() => {
toggleTheme(Theme.DEFAULT);
}}
>
<ThemeContainer checked={theme === Theme.DEFAULT} />
</div>
</Tooltip>
<Tooltip
placement="top"
title={intl.formatMessage({ id: 'theme_dark' })}
>
<div
onClick={() => {
toggleTheme(Theme.DARK);
}}
>
<ThemeContainer theme="dark" checked={theme === Theme.DARK} />
</div>
</Tooltip>
</div>
</div>
<div className={styles.item}>
<div className={styles.title}>
<FormattedMessage id="primary_color" />
</div>
<div className={styles.content}>
<ColorContainer
color={primaryColor || ''}
handleChange={togglePrimaryColor}
/>
</div>
</div>
<div className={styles.item}>
<div className={styles.title}>
<FormattedMessage id="language" />
</div>
<div className={styles.content}>
<Radio.Group onChange={handleLocaleChange} value={locale}>
<Radio value={Locale.zh_CN}></Radio>
<Radio value={Locale.en_US}>English</Radio>
</Radio.Group>
</div>
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,25 @@
import { CheckOutlined } from '@ant-design/icons';
import classnames from 'classnames';
import React from 'react';
import styles from './index.module.css';
type ThemeProp = {
theme?: string;
checked: boolean;
};
const Theme: React.FC<ThemeProp> = (props) => {
const { theme, checked } = props;
return (
<div
className={classnames({
[styles.light]: true,
[styles.dark]: theme === 'dark',
})}
>
{checked && <CheckOutlined />}
</div>
);
};
export default Theme;

View File

@@ -0,0 +1,23 @@
import { Spin as AntSpin, SpinProps } from 'antd';
const Spin = (props: SpinProps) => {
const { size = 'default' } = props;
const sizeMap = {
small: { width: '40px', height: '58px' },
default: { width: '60px', height: '86px' },
large: { width: '80px', height: '114px' },
};
return (
<AntSpin
indicator={
<img
src={process.env.PUBLIC_URL + `/loading.webp`}
style={{ width: sizeMap[size].width, height: sizeMap[size].height }}
alt="loading"
/>
}
{...props}
/>
);
};
export default Spin;

View File

@@ -0,0 +1,7 @@
.loader {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,23 @@
import Spin from '@/components/spin';
import React, { Suspense } from 'react';
import styles from './index.module.css';
type SuspenseLayoutProps = {
children: React.ReactElement;
};
const SuspenseLayout = (props: SuspenseLayoutProps) => {
const { children } = props;
return (
<Suspense
fallback={
<div className={styles.loader}>
<Spin />
</div>
}
>
{children}
</Suspense>
);
};
export default SuspenseLayout;

View File

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

View File

@@ -0,0 +1,9 @@
export enum Theme {
DEFAULT = 'DEFAULT',
DARK = 'DARK',
}
export enum Locale {
'zh_CN' = 'zh-CN',
'en_US' = 'en-US',
}

28
template/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
declare global {
let tocoRefs: Record<string, any>;
}
interface Meta {
displayName?: string; // 展示在菜单上的名字, 可以用 {{language_id}} 包起来,用于国际化显示
icon?: string; // 展示在菜单上的图标
headMenu?: // 是否展示在头部菜单 或 所属头部菜单的名字
| boolean
| {
key: string;
label: string; // 可以用 {{language_id}} 包起来,用于国际化显示
icon?: string;
order?: number; // 菜单序号,数字越小越靠前
};
sideMenu?: // 是否展示在侧边菜单 或 所属侧边菜单的名字
| boolean
| {
key: string;
label: string; // 可以用 {{language_id}} 包起来,用于国际化显示
icon?: string;
order?: number; // 菜单序号,数字越小越靠前
};
order?: number; // 菜单序号,数字越小越靠前
_path: string; // 目录名
_fullpath?: string; // 完整目录结构
_children: Meta[];
}

29
template/src/index.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { store } from '@/app/store';
import AppWrapper from '@/components/app-wrapper';
import '@/style/index.css';
import { tocoGlobal } from '@df/toco-ui';
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
const container = document.getElementById('root')!;
const root = createRoot(container);
root.render(
<React.StrictMode>
<AppWrapper store={store}>
<App />
</AppWrapper>
</React.StrictMode>,
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
// global variables
(globalThis as any).tocoRefs = tocoGlobal.getRefs();
export { store };

View File

@@ -0,0 +1,16 @@
import { Language } from './index';
const en_US: Language = {
language: 'Language',
theme: 'Theme',
primary_color: 'Primary Color',
style: 'Style',
system_settings: 'System Settings',
theme_light: 'Light',
theme_dark: 'Dark',
demo: 'Demo',
demo2: 'Demo2',
test: 'Test',
};
export default en_US;

View File

@@ -0,0 +1,17 @@
import en_US from './en_US';
import zh_CN from './zh_CN';
export type Language = {
language: string;
theme: string;
primary_color: string;
style: string;
system_settings: string;
theme_light: string;
theme_dark: string;
demo: string;
demo2: string;
test: string;
};
export { en_US, zh_CN };

View File

@@ -0,0 +1,16 @@
import { Language } from './index';
const zh_CN: Language = {
language: '语言',
theme: '主题',
primary_color: '主题色',
style: '整体风格',
system_settings: '系统设置',
theme_light: '浅色模式',
theme_dark: '深色模式',
demo: '示例',
demo2: '示例2',
test: '测试',
};
export default zh_CN;

View File

@@ -0,0 +1,50 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--toco-colorBgContainer);
border-bottom: 1px solid var(--toco-colorBorder);
}
.left {
display: flex;
align-items: center;
flex: 1;
}
.settings {
cursor: pointer;
font-size: 14px;
color: var(--toco-colorTextBase);
}
.logo {
width: 125px;
padding-right: 10px;
}
.menu {
flex: 1;
min-width: 0;
border-bottom: transparent;
}
.content {
padding: 24px;
height: calc(100vh - 112px);
overflow-y: auto;
}
.breadcrumb {
margin: 16px 0;
}
.main {
padding: 24px;
background: var(--toco-colorBgBase);
border-radius: var(--toco-borderRadiusLG);
}
.footer {
text-align: center;
}

View File

@@ -0,0 +1,61 @@
import { selectCommon } from '@/app/common';
import { useAppSelector } from '@/app/hooks';
import Settings from '@/components/settings';
import { Theme } from '@/enum';
import { LayoutProps } from '@/layout';
import { SettingOutlined } from '@ant-design/icons';
import { Image, Layout, Menu, Popover } from '@df/toco-ui';
import { useIntl } from 'react-intl';
import styles from './index.module.css';
const { Header, Content, Footer } = Layout;
const HeaderAndFooterLayout = (props: LayoutProps) => {
const { children, onHeadMenuSelect } = props;
const { headMenus, currentMenu, theme } = useAppSelector(selectCommon);
const { headKey, sideKey = '' } = currentMenu;
const intl = useIntl();
return (
<Layout>
<Header className={styles.header}>
<div className={styles.left}>
<div className={styles.logo}>
<Image
preview={false}
src={
process.env.PUBLIC_URL +
(theme === Theme.DEFAULT ? '/logo.png' : '/logo_dark.png')
}
/>
</div>
<Menu
className={styles.menu}
mode="horizontal"
items={headMenus}
selectedKeys={[headKey, sideKey]}
onSelect={onHeadMenuSelect}
/>
</div>
<div className={styles.right}>
<Popover
title={intl.formatMessage({ id: 'system_settings' })}
content={<Settings />}
trigger="hover"
>
<SettingOutlined className={styles.settings} />
</Popover>
</div>
</Header>
<Content className={styles.content}>
<div className={styles.main}>{children}</div>
</Content>
<Footer className={styles.footer}>
TOCO Design ©{new Date().getFullYear()}
</Footer>
</Layout>
);
};
export default HeaderAndFooterLayout;

View File

@@ -0,0 +1,49 @@
.header {
display: flex;
align-items: center;
background: var(--toco-colorBgContainer);
border-bottom: 1px solid var(--toco-colorBorder);
}
.left {
display: flex;
align-items: center;
flex: 1;
}
.logo {
width: 125px;
margin-right: 32px;
}
.menu {
flex: 1;
min-width: 0;
border-bottom: none;
}
.sider {
width: 200px;
background: var(--toco-colorBgContainer);
}
.siderMenu {
height: 100%;
border-right: 0;
}
.contentWrapper {
padding: 24px;
}
.content {
padding: 24px;
margin: 0;
min-height: 280px;
background: var(--toco-colorBgBase);
border-radius: var(--toco-borderRadiusLG);
}
.breadcrumb {
margin: 16px 0;
}

View File

@@ -0,0 +1,79 @@
import { selectCommon } from '@/app/common';
import { useAppSelector } from '@/app/hooks';
import Settings from '@/components/settings';
import { Theme } from '@/enum';
import { LayoutProps } from '@/layout';
import { SettingOutlined } from '@ant-design/icons';
import { Image, Layout, Menu, Popover } from '@df/toco-ui';
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import styles from './index.module.css';
const { Header, Content, Sider } = Layout;
const HeaderAndFooterLayout = (props: LayoutProps) => {
const { children, onHeadMenuSelect, onSideMenuSelect } = props;
const { headMenus, sideMenus, currentMenu, theme } =
useAppSelector(selectCommon);
const { headKey, sideKey = '' } = currentMenu;
const intl = useIntl();
const flatHeadMenus = useMemo(
() =>
headMenus.map((m) => {
return { ...m, children: undefined };
}),
[headMenus],
);
return (
<Layout>
<Header className={styles.header}>
<div className={styles.left}>
<div className={styles.logo}>
<Image
preview={false}
src={
process.env.PUBLIC_URL +
(theme === Theme.DEFAULT ? '/logo.png' : '/logo_dark.png')
}
/>
</div>
<Menu
className={styles.menu}
mode="horizontal"
items={flatHeadMenus}
selectedKeys={[headKey]}
onSelect={onHeadMenuSelect}
/>
</div>
<div className={styles.right}>
<Popover
title={intl.formatMessage({ id: 'system_settings' })}
content={<Settings />}
trigger="hover"
>
<SettingOutlined className={styles.settings} />
</Popover>
</div>
</Header>
<Layout>
{sideMenus.length === 0 ? null : (
<Sider className={styles.sider}>
<Menu
className={styles.siderMenu}
mode="inline"
items={sideMenus}
selectedKeys={[sideKey]}
onSelect={onSideMenuSelect}
/>
</Sider>
)}
<Layout className={styles.contentWrapper}>
<Content className={styles.content}>{children}</Content>
</Layout>
</Layout>
</Layout>
);
};
export default HeaderAndFooterLayout;

View File

@@ -0,0 +1,96 @@
import { setCurrentMenu, setHeadMenus } from '@/app/common';
import { useAppDispatch } from '@/app/hooks';
import SuspenseLayout from '@/components/suspense-layout';
import {
findFirstSubMenu,
findHeadMenuByHeadKey,
findHeadMenuBySideKey,
menuItems,
} from '@/utils/menu';
import { MenuProps } from '@df/toco-ui';
import { useCallback, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import Layout from './HeaderAndSider';
// import Layout from './HeaderAndFooter';
export type LayoutProps = {
children?: React.ReactNode;
onHeadMenuSelect?: MenuProps['onSelect'];
onSideMenuSelect?: MenuProps['onSelect'];
};
const LayoutRoot = () => {
const dispatch = useAppDispatch();
const location = useLocation();
const navigate = useNavigate();
// 设置head菜单
useEffect(() => {
dispatch(setHeadMenus(menuItems));
}, [dispatch]);
// 监听路由变化
useEffect(() => {
const key = location.pathname.replace(/^\//, '').replace(/\/$/, '');
const headMenu = findHeadMenuByHeadKey(key);
if (headMenu) {
// 一级页面
dispatch(
setCurrentMenu({
headKey: headMenu.key,
}),
);
} else {
// 非一级页面
const headMenu = findHeadMenuBySideKey(key);
dispatch(
setCurrentMenu({
headKey: headMenu?.key ?? '',
sideKey: key,
}),
);
}
}, [dispatch, location]);
const handleHeadMenuSelect: MenuProps['onSelect'] = useCallback(
({ item, key, keyPath, domEvent }) => {
const headMenu = findHeadMenuByHeadKey(key);
if (!headMenu) {
// 顶部嵌套的菜单即实际点击的sideMenu
navigate(key);
return;
}
if (!headMenu.children?.length) {
// 一级页面
navigate(key);
} else {
// 非一级页面点击一级菜单,主动跳转第一个子页面
const subMenu = findFirstSubMenu(headMenu);
if (subMenu) {
navigate(subMenu.key);
}
}
},
[navigate],
);
const handleSideMenuSelect: MenuProps['onSelect'] = useCallback(
({ item, key, keyPath, domEvent }) => {
navigate(key);
},
[navigate],
);
return (
<Layout
onHeadMenuSelect={handleHeadMenuSelect}
onSideMenuSelect={handleSideMenuSelect}
>
<SuspenseLayout>
<Outlet />
</SuspenseLayout>
</Layout>
);
};
export default LayoutRoot;

View File

View File

@@ -0,0 +1,46 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { User } from '@/pages/demo/demoAPI';
import { fetchUsers, selectDemo } from '@/pages/demo/demoSlice';
import { EditTableColumnsType, Table } from '@df/toco-ui';
import { useEffect } from 'react';
const columns: EditTableColumnsType<User> = [
{
title: 'name',
dataIndex: 'name',
key: 'name',
render: (name) => `${name.first} ${name.last}`,
},
{
title: 'phone',
dataIndex: 'phone',
key: 'phone',
},
{
title: 'gender',
dataIndex: 'gender',
key: 'gender',
valueType: 'select',
fieldProps: {
options: [
{ label: '女', value: 'female' },
{ label: '男', value: 'male' },
],
},
},
];
const Demo = () => {
const { users } = useAppSelector(selectDemo);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
return (
<Table id="demoTable" value={users} columns={columns} editMode="none" />
);
};
export default Demo;

View File

@@ -0,0 +1,26 @@
import axiosInstance from '@/app/request';
import { AxiosResponse } from 'axios';
export interface User {
name: {
first: string;
last: string;
};
phone: string;
gender: 'female' | 'male';
}
export const getUsers = async (): Promise<User[]> => {
const response: AxiosResponse<{
info: any;
results: User[];
}> = await axiosInstance.get(
`https://randomuser.me/api/?results=8&seed=toco`,
{
params: {
errorHandler: false,
},
},
);
return response.data?.results || [];
};

View File

@@ -0,0 +1,33 @@
import { AppThunk, RootState } from '@/app/store';
import { getUsers, User } from '@/pages/demo/demoAPI';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface DemoState {
users: User[];
}
/* DO NOT EDIT initialState */
const initialState: DemoState = {
users: [],
};
export const demoSlice = createSlice({
name: 'demo',
initialState,
reducers: {
setUsers: (state, action: PayloadAction<User[]>) => {
state.users = action.payload;
},
},
});
export const { setUsers } = demoSlice.actions;
export const fetchUsers = (): AppThunk => async (dispatch, getState) => {
const data = await getUsers();
dispatch(setUsers(data));
};
export const selectDemo = (state: RootState) => state.demo;
export default demoSlice.reducer;

View File

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

View File

@@ -0,0 +1,32 @@
import { useAppSelector } from '@/app/hooks';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import * as tocoServices from './testAPI';
const Test = (props) => {
const tocoStore = useAppSelector((state) => state);
const navigate = useNavigate();
useEffect(() => {
// 一些常用变量或方法
console.log('navigate', navigate);
console.log('props', props);
console.log('store', tocoStore);
console.log('services', tocoServices);
console.log('refs', tocoRefs);
}, [navigate, props, tocoStore]);
return (
<div>
<h1>Store</h1>
<p>the primary color is: {tocoStore.common.primaryColor}</p>
<h1>Service</h1>
<h1>Refs</h1>
<div>
<p>children page</p>
</div>
</div>
);
};
export default Test;

View File

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

View File

@@ -0,0 +1,26 @@
import axiosInstance from '@/app/request';
import { AxiosResponse } from 'axios';
export interface User {
name: {
first: string;
last: string;
};
phone: string;
gender: 'female' | 'male';
}
export const getUsers = async (): Promise<User[]> => {
const response: AxiosResponse<{
info: any;
results: User[];
}> = await axiosInstance.get(
`https://randomuser.me/api/?results=8&seed=toco`,
{
params: {
errorHandler: false,
},
},
);
return response.data?.results || [];
};

1
template/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,11 @@
/* change dev-server proxy here */
// const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function (app) {
// app.use(
// '/api',
// createProxyMiddleware({
// target: 'https://my.test-api.com',
// changeOrigin: true,
// }),
// );
};

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View File

@@ -0,0 +1 @@
@import "./reset.css";

View File

@@ -0,0 +1,11 @@
html, body {
margin: 0;
padding: 0;
}
#root {
width: 100%;
height: 100vh;
min-height: 100vh;
max-height: 100vh;
}

View File

@@ -0,0 +1,45 @@
import { CSS_VARIABLE_PREFIX } from '@/config';
import { toCamelCase } from '@/utils';
import { GlobalToken, theme } from 'antd';
import { useMemo } from 'react';
const { useToken } = theme;
/**
* 这里把主题token注入css全局变量
*/
export function CSSVariablesStyle() {
const { token } = useToken();
const globalVars = useMemo(() => {
const varLines: string[] = [];
Object.keys(token).forEach((key) => {
const tokenValue = token[key as keyof GlobalToken];
const formatTokenValue = (v) =>
typeof v === 'number' && Number.isInteger(v) ? v + 'px' : v;
if (tokenValue) {
if (typeof tokenValue === 'object') {
// 组件token
Object.keys(tokenValue).forEach((name) => {
const cpTokenValue = tokenValue[name];
varLines.push(
`--${CSS_VARIABLE_PREFIX}-${toCamelCase(`${key.toLocaleLowerCase()}-${name}`)}: ${formatTokenValue(cpTokenValue)};`,
);
});
} else {
// 全局token
varLines.push(
`--${CSS_VARIABLE_PREFIX}-${toCamelCase(key)}: ${formatTokenValue(tokenValue)};`,
);
}
}
});
return `html {
${varLines.join('\n')}
}`;
}, [token]);
return <style>{globalVars}</style>;
}

View File

@@ -0,0 +1,23 @@
import { theme, ThemeConfig } from '@df/toco-ui';
import { SeedToken } from 'antd/es/theme/interface/seeds';
import { MapToken } from './index';
const { compactAlgorithm, darkAlgorithm } = theme;
const customAlgorithm: (token: SeedToken) => MapToken = (token) => {
const calcTokens = darkAlgorithm(token);
return {
...calcTokens,
// custom tokens
};
};
const darkTheme: ThemeConfig = {
token: {
// antd tokens
},
algorithm: [customAlgorithm, compactAlgorithm],
};
export default darkTheme;

View File

@@ -0,0 +1,23 @@
import { theme, ThemeConfig } from '@df/toco-ui';
import { SeedToken } from 'antd/es/theme/interface/seeds';
import { MapToken } from './index';
const { compactAlgorithm, defaultAlgorithm } = theme;
const customAlgorithm: (token: SeedToken) => MapToken = (token) => {
const calcTokens = defaultAlgorithm(token);
return {
...calcTokens,
// custom tokens
};
};
const defaultTheme: ThemeConfig = {
token: {
// antd tokens
},
algorithm: [customAlgorithm, compactAlgorithm],
};
export default defaultTheme;

View File

@@ -0,0 +1,33 @@
import { CommonMapToken } from 'antd/es/theme/interface/maps';
import { ColorMapToken } from 'antd/es/theme/interface/maps/colors';
import { FontMapToken } from 'antd/es/theme/interface/maps/font';
import {
HeightMapToken,
SizeMapToken,
} from 'antd/es/theme/interface/maps/size';
import { StyleMapToken } from 'antd/es/theme/interface/maps/style';
import {
ColorPalettes,
LegacyColorPalettes,
} from 'antd/es/theme/interface/presetColors';
import { SeedToken } from 'antd/es/theme/interface/seeds';
import darkTheme from './dark';
import defaultTheme from './default';
export interface MapToken
extends SeedToken,
ColorPalettes,
LegacyColorPalettes,
ColorMapToken,
SizeMapToken,
HeightMapToken,
StyleMapToken,
FontMapToken,
CommonMapToken,
CustomTokens {}
export type CustomTokens = {
// Define your custom tokens here
};
export { darkTheme, defaultTheme };

View File

@@ -0,0 +1,11 @@
// 将下划线或者中划线命名转换为驼峰命名
export const toCamelCase = (str) => {
return str
.replace(/[_-]([a-zA-Z])/g, (match, p1) => p1.toUpperCase())
.replace(/^[A-Z]/, (match) => match.toLowerCase());
};
// 将下划线命名转换为驼峰命名, 首字母大写
export const toPascalCase = (str) => {
return str.replace(/(^|[_-])([a-z])/g, (match, p1, p2) => p2.toUpperCase());
};

150
template/src/utils/menu.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { metaInfo } from '@/meta';
import React from 'react';
import { FormattedMessage } from 'react-intl';
export type MenuItem = {
key: string;
label: string | React.ReactElement;
disabled?: boolean;
icon?: React.ReactNode;
children?: MenuItem[];
_order?: number;
};
const formatDisplayName = (displayName: string) => {
// 用 {{xxx}} 包起来的字符串需要处理国际化
if (/^{{(.*)}}$/.test(displayName)) {
const key = displayName.match(/^{{(.*)}}$/)![1].trim();
return <FormattedMessage id={key} />;
}
return displayName;
};
const flatten = (info: Meta[]) => {
const result: Meta[] = [];
info.forEach((item) => {
if (item._children?.length) {
result.push(...flatten(item._children));
} else {
result.push(item);
}
});
return result;
};
const deeplySortMenu = (menus: MenuItem[]) => {
const newMenus = [...menus];
newMenus.sort((a, b) => a._order! - b._order!);
newMenus.forEach((menu) => {
if (menu.children) {
menu.children = deeplySortMenu(menu.children);
}
});
return newMenus;
};
const getMenuItems = (): MenuItem[] => {
const menus: MenuItem[] = [];
const flatMetaInfo = flatten(metaInfo);
// head
flatMetaInfo.forEach((item) => {
if (!item.headMenu) return;
// 非路由页面头部菜单
if (typeof item.headMenu !== 'boolean') {
const find = menus.find(
(menu) => menu.key === (item.headMenu as any)?.key,
);
if (!find) {
menus.push({
key: (item.headMenu as any)?.key,
label: formatDisplayName((item.headMenu as any)?.label),
_order: (item.headMenu as any)?.order ?? 0,
});
}
}
// 路由页面头部菜单
if (item.headMenu === true) {
menus.push({
key: item._fullpath ?? '',
label: formatDisplayName(item.displayName ?? ''),
_order: item.order ?? 0,
});
}
});
// side
flatMetaInfo.forEach((item) => {
if (!item.sideMenu) return;
const head = menus.find((menu) => menu.key === (item.headMenu as any)?.key);
if (!head) return;
if (!head.children) head.children = [];
// 非路由页面侧边菜单(分组)
if (typeof item.sideMenu !== 'boolean') {
const find = head.children.find(
(menu) => menu.key === (item.sideMenu as any)?.key,
);
if (!find) {
head.children.push({
key: (item.sideMenu as any)?.key,
label: formatDisplayName((item.sideMenu as any)?.label),
_order: (item.sideMenu as any)?.order,
children: [
{
key: item._fullpath ?? '',
label: formatDisplayName(item.displayName ?? ''),
_order: item.order ?? 0,
},
],
});
} else {
find.children!.push({
key: item._fullpath ?? '',
label: formatDisplayName(item.displayName ?? ''),
_order: item.order ?? 0,
});
}
}
// 路由页面侧边菜单
if (item.sideMenu === true) {
head.children.push({
key: item._fullpath ?? '',
label: formatDisplayName(item.displayName ?? ''),
_order: item.order ?? 0,
});
}
});
// sort
return deeplySortMenu(menus);
};
export const menuItems = getMenuItems();
export const findHeadMenuByHeadKey = (headKey) => {
return menuItems.find((menu) => menu.key === headKey);
};
export const findHeadMenuBySideKey = (
sideKey: string,
menus: MenuItem[] = menuItems,
): MenuItem | null => {
const find = menus.find((menu) =>
menu.children?.some(
(child) =>
child.key === sideKey ||
!!findHeadMenuBySideKey(sideKey, menu.children ?? []),
),
);
if (find) return find;
return null;
};
export const findFirstSubMenu = (menu: MenuItem): MenuItem | undefined => {
if (!menu?.children?.[0]?.children?.length) return menu?.children?.[0];
return findFirstSubMenu(menu.children[0]);
};

View File

@@ -0,0 +1,44 @@
import { STORAGE_KEY_PREFIX } from '@/config';
class StorageService {
setItem(key: string, value: any): void {
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(STORAGE_KEY_PREFIX + key, serializedValue);
} catch (error) {
console.error(`Error setting item ${key} to localStorage`, error);
}
}
getItem<T>(key: string, defaultValue: T): T {
try {
const serializedValue = localStorage.getItem(STORAGE_KEY_PREFIX + key);
if (serializedValue === null) {
return defaultValue;
}
return JSON.parse(serializedValue) as T;
} catch (error) {
console.error(`Error getting item ${key} from localStorage`, error);
return defaultValue;
}
}
removeItem(key: string): void {
try {
localStorage.removeItem(STORAGE_KEY_PREFIX + key);
} catch (error) {
console.error(`Error removing item ${key} from localStorage`, error);
}
}
clear(): void {
try {
localStorage.clear();
} catch (error) {
console.error('Error clearing localStorage', error);
}
}
}
const storageService = new StorageService();
export default storageService;

29
template/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"target": "esnext",
"importHelpers": true,
"sourceMap": false,
"noImplicitAny": false,
"useUnknownInCatchVariables": false,
"experimentalDecorators": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"]
}

11632
template/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

0
values.yml Normal file
View File