fix: 移动目录
This commit is contained in:
7
src/Icon.tsx
Normal file
7
src/Icon.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
const Icon = createFromIconfontCN({
|
||||
scriptUrl: {{iconFontSrc}},
|
||||
});
|
||||
|
||||
export default Icon;
|
||||
7
src/Root.tsx
Normal file
7
src/Root.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const Root: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => {
|
||||
return <div {...props} />;
|
||||
};
|
||||
|
||||
export default Root;
|
||||
41
src/app/common.ts
Normal file
41
src/app/common.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { Locale, Theme } from './enum';
|
||||
import storageService from './storage';
|
||||
import { RootState } from './store';
|
||||
|
||||
export interface CommonState {
|
||||
locale: Locale;
|
||||
theme: Theme;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
const initialState: CommonState = {
|
||||
locale: storageService.getItem('locale', Locale.zh_CN),
|
||||
theme: storageService.getItem('theme', Theme.DEFAULT),
|
||||
primaryColor: storageService.getItem('primaryColor', '#1361B3'),
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLocale, setTheme, setPrimaryColor } = commonSlice.actions;
|
||||
|
||||
export const selectCommon = (state: RootState) => state.common;
|
||||
|
||||
export default commonSlice.reducer;
|
||||
9
src/app/enum.ts
Normal file
9
src/app/enum.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum Theme {
|
||||
DEFAULT = 'DEFAULT',
|
||||
DARK = 'DARK',
|
||||
}
|
||||
|
||||
export enum Locale {
|
||||
'zh_CN' = 'zh-CN',
|
||||
'en_US' = 'en-US',
|
||||
}
|
||||
5
src/app/hooks.ts
Normal file
5
src/app/hooks.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { AppDispatch, RootState } from './store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
81
src/app/index.tsx
Normal file
81
src/app/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { customValueTypeMap } from '@/components';
|
||||
import { ConfigProvider, App as TocoApp, theme } from '@df/toco-ui';
|
||||
import { Router } from '@remix-run/router';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider as StoreProvider } from 'react-redux';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Locale, Theme } from './enum';
|
||||
import { useAppSelector } from './hooks';
|
||||
import * as langs from './langs';
|
||||
import globalMessage from './message';
|
||||
import { store } from './store';
|
||||
|
||||
const { darkAlgorithm } = theme;
|
||||
|
||||
const AppInternal: React.FC<{ router: Router }> = (props) => {
|
||||
const { router } = props;
|
||||
const { message } = TocoApp.useApp();
|
||||
|
||||
const locale = useAppSelector((state) => state.common.locale);
|
||||
const theme = useAppSelector((state) => state.common.theme);
|
||||
const primaryColor = useAppSelector((state) => state.common.primaryColor);
|
||||
|
||||
const antdLocaleMap = {
|
||||
[Locale.zh_CN]: zhCN,
|
||||
[Locale.en_US]: enUS,
|
||||
};
|
||||
const langsMap = {
|
||||
[Locale.zh_CN]: langs.zh_CN,
|
||||
[Locale.en_US]: langs.en_US,
|
||||
};
|
||||
|
||||
const themeConfig = useMemo(() => {
|
||||
return {
|
||||
algorithm: theme === Theme.DARK ? [darkAlgorithm] : [],
|
||||
cssVar: true,
|
||||
token: primaryColor
|
||||
? {
|
||||
colorPrimary: primaryColor,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}, [primaryColor, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
globalMessage.setMessage(message);
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-color-scheme',
|
||||
theme === Theme.DARK ? 'dark' : 'light',
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={langsMap[locale]}>
|
||||
<ConfigProvider
|
||||
locale={antdLocaleMap[locale]}
|
||||
theme={themeConfig}
|
||||
valueTypeMap={customValueTypeMap}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC<{ router: Router }> = (props) => {
|
||||
return (
|
||||
<StoreProvider store={store}>
|
||||
<TocoApp style={{ height: '100%' }}>
|
||||
<AppInternal {...props} />
|
||||
</TocoApp>
|
||||
</StoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
src/app/langs/en_US.ts
Normal file
13
src/app/langs/en_US.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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',
|
||||
};
|
||||
|
||||
export default en_US;
|
||||
14
src/app/langs/index.ts
Normal file
14
src/app/langs/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export { en_US, zh_CN };
|
||||
13
src/app/langs/zh_CN.ts
Normal file
13
src/app/langs/zh_CN.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Language } from './index';
|
||||
|
||||
const zh_CN: Language = {
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
primary_color: '主题色',
|
||||
style: '整体风格',
|
||||
system_settings: '系统设置',
|
||||
theme_light: '浅色模式',
|
||||
theme_dark: '深色模式',
|
||||
};
|
||||
|
||||
export default zh_CN;
|
||||
50
src/app/message.ts
Normal file
50
src/app/message.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 带主题等上下文的、可全局使用的message api
|
||||
* toco-ui/antd 组件库的message无法直接全局使用,主题上下文会丢失
|
||||
*/
|
||||
import { message as tocoMessage } from '@df/toco-ui';
|
||||
|
||||
type Message = ReturnType<typeof tocoMessage.useMessage>[0];
|
||||
|
||||
class GlobalMessage {
|
||||
private static instance: GlobalMessage;
|
||||
private message: Message;
|
||||
|
||||
private constructor() {
|
||||
this.message = tocoMessage;
|
||||
}
|
||||
|
||||
public static getInstance(): GlobalMessage {
|
||||
if (!GlobalMessage.instance) {
|
||||
GlobalMessage.instance = new GlobalMessage();
|
||||
}
|
||||
return GlobalMessage.instance;
|
||||
}
|
||||
|
||||
public setMessage(message: Message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public success(content: string, duration?: number, onClose?: () => void) {
|
||||
this.message.success(content, duration, onClose);
|
||||
}
|
||||
|
||||
public error(content: string, duration?: number, onClose?: () => void) {
|
||||
this.message.error(content, duration, onClose);
|
||||
}
|
||||
|
||||
public info(content: string, duration?: number, onClose?: () => void) {
|
||||
this.message.info(content, duration, onClose);
|
||||
}
|
||||
|
||||
public warning(content: string, duration?: number, onClose?: () => void) {
|
||||
this.message.warning(content, duration, onClose);
|
||||
}
|
||||
|
||||
public loading(content: string, duration?: number, onClose?: () => void) {
|
||||
this.message.loading(content, duration, onClose);
|
||||
}
|
||||
}
|
||||
|
||||
const globalMessage = GlobalMessage.getInstance();
|
||||
export default globalMessage;
|
||||
75
src/app/request.ts
Normal file
75
src/app/request.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { AxiosResponse } from 'axios/index';
|
||||
import message from './message';
|
||||
|
||||
export type ResponseType<T = unknown> = {
|
||||
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;
|
||||
44
src/app/storage.ts
Normal file
44
src/app/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const STORAGE_KEY_PREFIX = 'toco_storage_';
|
||||
|
||||
class StorageService {
|
||||
setItem<T>(key: string, value: T): 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;
|
||||
17
src/app/store.ts
Normal file
17
src/app/store.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
|
||||
import commonReducer from './common';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
common: commonReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
RootState,
|
||||
unknown,
|
||||
Action<string>
|
||||
>;
|
||||
1
src/components/index.ts
Normal file
1
src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
9
src/global.d.ts
vendored
Normal file
9
src/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-var */
|
||||
|
||||
declare global {
|
||||
var tocoRefs: Record<string, any>;
|
||||
var tocoModals: Record<string, any>;
|
||||
}
|
||||
|
||||
export {};
|
||||
25
src/index.tsx
Normal file
25
src/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
import { router } from './router';
|
||||
|
||||
const container = document.getElementById('root')!;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App router={router} />
|
||||
</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.tocoRefs = tocoGlobal.getRefs();
|
||||
globalThis.tocoModals = tocoGlobal.getModals();
|
||||
70
src/layout/index.module.css
Normal file
70
src/layout/index.module.css
Normal file
@@ -0,0 +1,70 @@
|
||||
.layout {
|
||||
height: 100%;
|
||||
--header-height: 63px;
|
||||
--extra-height: 63px;
|
||||
|
||||
.logoContainer{
|
||||
height: 100%;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.logo {
|
||||
&:global(.dark) {
|
||||
background: var(--ant-layout-sider-bg);
|
||||
}
|
||||
height: var(--header-height);
|
||||
padding: 16px;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
background: var(--ant-color-bg-container);
|
||||
border-bottom: 1px solid var(--ant-color-border);
|
||||
|
||||
.headerMenu {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sider {
|
||||
border-right: 1px solid var(--ant-color-border);
|
||||
|
||||
:global(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.siderContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.siderMenu {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.extra {
|
||||
&:global(.dark) {
|
||||
background: var(--ant-layout-sider-bg);
|
||||
}
|
||||
height: var(--extra-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
74
src/layout/index.tsx
Normal file
74
src/layout/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
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 enum LayoutType {
|
||||
Default = 0,
|
||||
Header = 1 << 1,
|
||||
Sider = 1 << 2,
|
||||
}
|
||||
|
||||
type LayoutProps = {
|
||||
type?: LayoutType;
|
||||
};
|
||||
|
||||
const Layout: React.FC<LayoutProps> = (props) => {
|
||||
const { type: typeProp } = props;
|
||||
const theme = useAppSelector((state) => state.common.theme);
|
||||
|
||||
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;
|
||||
}
|
||||
return HeaderSider;
|
||||
}, [typeProp]);
|
||||
|
||||
return (
|
||||
<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 Layout;
|
||||
65
src/layout/layouts/HeaderOnly.tsx
Normal file
65
src/layout/layouts/HeaderOnly.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
const HeaderOnly: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, logo, extra, siderWidth } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
setSelectedKeys(parts);
|
||||
}, [matches]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig?.children || [], true);
|
||||
}, []);
|
||||
|
||||
const onKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSelectedKeys(info.keyPath);
|
||||
navigate(keys.reverse().join('/'));
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.logoContainer} style={{ width: siderWidth }}>
|
||||
{logo}
|
||||
</div>
|
||||
<Menu
|
||||
className={styles.headerMenu}
|
||||
theme={theme}
|
||||
mode="horizontal"
|
||||
items={items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={onKeyChange}
|
||||
/>
|
||||
{extra}
|
||||
</Header>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default HeaderOnly;
|
||||
104
src/layout/layouts/HeaderSider.tsx
Normal file
104
src/layout/layouts/HeaderSider.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const HeaderSider: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, siderWidth, logo, extra } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [headerKey, setHeaderKey] = useState<string>();
|
||||
const [siderSelectedKeys, setSiderSelectedKeys] = useState<string[]>();
|
||||
const [siderExpandedKeys, setSiderExpandedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
const [headerKey, ...siderKey] = parts;
|
||||
setHeaderKey(headerKey);
|
||||
setSiderSelectedKeys(siderKey);
|
||||
setSiderExpandedKeys((prev) => {
|
||||
const set = new Set<string>(prev);
|
||||
siderKey.slice(0, -1).forEach((key) => {
|
||||
set.add(key);
|
||||
});
|
||||
return Array.from(set);
|
||||
});
|
||||
}, [matches]);
|
||||
|
||||
const headerItems: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig.children, false);
|
||||
}, []);
|
||||
|
||||
const onHeaderKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
setHeaderKey(info.key);
|
||||
navigate(info.key);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const siderItems: MenuProps['items'] = useMemo(() => {
|
||||
const item = routerConfig.children.find((item) => item.path === headerKey);
|
||||
return parseMenuItems(item?.children || [], true);
|
||||
}, [headerKey]);
|
||||
|
||||
const onSiderKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSiderSelectedKeys(info.keyPath);
|
||||
const parts = [headerKey, ...keys];
|
||||
navigate(parts.join('/'));
|
||||
},
|
||||
[navigate, headerKey],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.logoContainer} style={{ width: siderWidth }}>
|
||||
{logo}
|
||||
</div>
|
||||
<Menu
|
||||
className={styles.headerMenu}
|
||||
theme={theme}
|
||||
mode="horizontal"
|
||||
items={headerItems}
|
||||
selectedKeys={headerKey ? [headerKey] : []}
|
||||
onSelect={onHeaderKeyChange}
|
||||
/>
|
||||
{extra}
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider className={styles.sider} theme={theme} width={siderWidth}>
|
||||
<Menu
|
||||
className={styles.siderMenu}
|
||||
mode="inline"
|
||||
theme={theme}
|
||||
items={siderItems}
|
||||
selectedKeys={siderSelectedKeys}
|
||||
onSelect={onSiderKeyChange}
|
||||
openKeys={siderExpandedKeys}
|
||||
onOpenChange={setSiderExpandedKeys}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default HeaderSider;
|
||||
75
src/layout/layouts/SiderOnly.tsx
Normal file
75
src/layout/layouts/SiderOnly.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import { routerConfig } from '@/router';
|
||||
import { Layout, Menu, MenuProps } from '@df/toco-ui';
|
||||
import { SelectInfo } from 'rc-menu/lib/interface';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
|
||||
import { LayoutProps } from '.';
|
||||
import styles from '../index.module.css';
|
||||
import { parseMenuItems, parseRouteMatchs } from './utils';
|
||||
|
||||
const { Content, Sider } = Layout;
|
||||
|
||||
const SiderOnly: React.FC<LayoutProps> = (props) => {
|
||||
const { theme: themeProp, logo, extra, siderWidth } = props;
|
||||
const matches = useMatches();
|
||||
const navigate = useNavigate();
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>();
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
const parts = parseRouteMatchs(matches);
|
||||
setSelectedKeys(parts);
|
||||
setExpandedKeys((prev) => {
|
||||
const set = new Set<string>(prev);
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
set.add(key);
|
||||
});
|
||||
return Array.from(set);
|
||||
});
|
||||
}, [matches]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
return parseMenuItems(routerConfig?.children || [], true);
|
||||
}, []);
|
||||
|
||||
const onKeyChange = useCallback(
|
||||
(info: SelectInfo) => {
|
||||
const keys = info.keyPath;
|
||||
setSelectedKeys(info.keyPath);
|
||||
navigate(keys.reverse().join('/'));
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeProp === Theme.DARK ? 'dark' : 'light';
|
||||
}, [themeProp]);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Sider className={styles.sider} theme={theme} width={siderWidth}>
|
||||
{logo}
|
||||
<div className={styles.siderContainer}>
|
||||
<Menu
|
||||
className={styles.siderMenu}
|
||||
theme={theme}
|
||||
mode="inline"
|
||||
items={items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={onKeyChange}
|
||||
openKeys={expandedKeys}
|
||||
onOpenChange={setExpandedKeys}
|
||||
/>
|
||||
{extra}
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout className={styles.content}>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
export default SiderOnly;
|
||||
13
src/layout/layouts/index.ts
Normal file
13
src/layout/layouts/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import React from 'react';
|
||||
|
||||
export { default as HeaderOnly } from './HeaderOnly';
|
||||
export { default as HeaderSider } from './HeaderSider';
|
||||
export { default as SiderOnly } from './SiderOnly';
|
||||
|
||||
export type LayoutProps = {
|
||||
theme?: Theme;
|
||||
logo?: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
siderWidth?: number;
|
||||
};
|
||||
49
src/layout/layouts/utils.ts
Normal file
49
src/layout/layouts/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RootRoute } from '@/router';
|
||||
import { MenuProps } from '@df/toco-ui';
|
||||
import { parse } from 'path-to-regexp';
|
||||
import { UIMatch } from 'react-router-dom';
|
||||
|
||||
const removeLeadingSlashes = (path: string) => {
|
||||
return path.replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
export const parseMenuItems = (
|
||||
routes: RootRoute['children'],
|
||||
recursively: boolean,
|
||||
): MenuProps['items'] => {
|
||||
return routes
|
||||
.filter((item) => typeof item.path === 'string')
|
||||
.map((item) => {
|
||||
const res = parse(item.path);
|
||||
if (res.tokens.some((token) => token.type !== 'text') || !item.menu) {
|
||||
// 不生成菜单项:
|
||||
// 1. 路由中不全为text,例如有参数 :id
|
||||
// 2. 没有设置menu
|
||||
return undefined;
|
||||
}
|
||||
const children =
|
||||
recursively && item.children
|
||||
? parseMenuItems(item.children, true)
|
||||
: undefined;
|
||||
return {
|
||||
key: removeLeadingSlashes(item.path),
|
||||
label: item.menu.title,
|
||||
children: children && children.length > 0 ? children : 0,
|
||||
};
|
||||
})
|
||||
.filter((p) => !!p);
|
||||
};
|
||||
|
||||
export const parseRouteMatchs = (matches: UIMatch[]) => {
|
||||
return matches.reduce<string[]>((acc, match, index, array) => {
|
||||
const last = array[index - 1];
|
||||
const part = last
|
||||
? match.pathname.replace(last.pathname, '')
|
||||
: match.pathname;
|
||||
const path = removeLeadingSlashes(part);
|
||||
if (path) {
|
||||
acc.push(path);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
15
src/layout/settings/color-select/index.module.css
Normal file
15
src/layout/settings/color-select/index.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/layout/settings/color-select/index.tsx
Normal file
56
src/layout/settings/color-select/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 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 = useAppSelector((state) => state.common.theme);
|
||||
|
||||
const onChange = useCallback(
|
||||
(option: ColorSelectOption) => {
|
||||
setValue(option.color);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['color-select'], props.className)}>
|
||||
{props.options?.map((option) => {
|
||||
if (!option.theme || option.theme === theme) {
|
||||
return (
|
||||
<div
|
||||
key={option.color}
|
||||
className={styles['color-option']}
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
}}
|
||||
>
|
||||
{option.color === value && <CheckOutlined />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/layout/settings/color.tsx
Normal file
44
src/layout/settings/color.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Theme } from '@/app/enum';
|
||||
import React from 'react';
|
||||
import { ColorSelect } from './color-select';
|
||||
|
||||
export const changeColor = ['#DFC193', '#1361B3'];
|
||||
|
||||
const colorArray = [
|
||||
{
|
||||
theme: Theme.DARK,
|
||||
color: '#DFC193',
|
||||
},
|
||||
{
|
||||
theme: Theme.DEFAULT,
|
||||
color: '#1361B3',
|
||||
},
|
||||
{
|
||||
color: '#E57373',
|
||||
},
|
||||
{
|
||||
color: '#F08F48',
|
||||
},
|
||||
{
|
||||
color: '#59AF6C',
|
||||
},
|
||||
{
|
||||
color: '#26ABB2',
|
||||
},
|
||||
{
|
||||
color: '#AD81E5',
|
||||
},
|
||||
];
|
||||
|
||||
type ColorProps = {
|
||||
color: string;
|
||||
handleChange: (color: string) => void;
|
||||
};
|
||||
|
||||
const Color: React.FC<ColorProps> = (props) => {
|
||||
const { color, handleChange } = props;
|
||||
return (
|
||||
<ColorSelect options={colorArray} value={color} onChange={handleChange} />
|
||||
);
|
||||
};
|
||||
export default Color;
|
||||
71
src/layout/settings/index.module.css
Normal file
71
src/layout/settings/index.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.container {
|
||||
|
||||
}
|
||||
|
||||
.item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 12px;
|
||||
color: var(--toco-colorTextBase);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
|
||||
>div {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 44px;
|
||||
height: 36px;
|
||||
background-color: #F0F2F5;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
:global(.anticon) {
|
||||
color: var(--toco-colorTextBase);
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 33%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
content: "";
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 25%;
|
||||
background-color: #fff;
|
||||
content: "";
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
background-color: #3A4D5B;
|
||||
|
||||
&::before {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
103
src/layout/settings/index.tsx
Normal file
103
src/layout/settings/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
selectCommon,
|
||||
setLocale,
|
||||
setPrimaryColor,
|
||||
setTheme,
|
||||
} from '@/app/common';
|
||||
import { Locale, Theme } from '@/app/enum';
|
||||
import { useAppDispatch, useAppSelector } from '@/app/hooks';
|
||||
import { Radio, RadioChangeEvent, Tooltip } from '@df/toco-ui';
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import ColorContainer, { changeColor } from './color';
|
||||
import styles from './index.module.css';
|
||||
import ThemeContainer from './theme';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { primaryColor, theme, locale } = useAppSelector(selectCommon);
|
||||
const intl = useIntl();
|
||||
|
||||
const toggleTheme = (newTheme: Theme) => {
|
||||
const index = changeColor?.findIndex(
|
||||
(item: string) => item === primaryColor,
|
||||
);
|
||||
if (index >= 0) {
|
||||
dispatch(
|
||||
setPrimaryColor(index === 0 ? changeColor?.[1] : changeColor?.[0]),
|
||||
);
|
||||
}
|
||||
dispatch(setTheme(newTheme));
|
||||
};
|
||||
|
||||
const togglePrimaryColor = (newPrimaryColor: string) => {
|
||||
dispatch(setPrimaryColor(newPrimaryColor));
|
||||
};
|
||||
|
||||
const handleLocaleChange = useCallback(
|
||||
(e: RadioChangeEvent) => {
|
||||
dispatch(setLocale(e.target.value));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.title}>
|
||||
<FormattedMessage id="style" />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={intl.formatMessage({ id: 'theme_light' })}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
toggleTheme(Theme.DEFAULT);
|
||||
}}
|
||||
>
|
||||
<ThemeContainer checked={theme === Theme.DEFAULT} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={intl.formatMessage({ id: 'theme_dark' })}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
toggleTheme(Theme.DARK);
|
||||
}}
|
||||
>
|
||||
<ThemeContainer theme="dark" checked={theme === Theme.DARK} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.title}>
|
||||
<FormattedMessage id="primary_color" />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<ColorContainer
|
||||
color={primaryColor || ''}
|
||||
handleChange={togglePrimaryColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.title}>
|
||||
<FormattedMessage id="language" />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<Radio.Group onChange={handleLocaleChange} value={locale}>
|
||||
<Radio value={Locale.zh_CN}>简体中文</Radio>
|
||||
<Radio value={Locale.en_US}>English</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
25
src/layout/settings/theme.tsx
Normal file
25
src/layout/settings/theme.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
import styles from './index.module.css';
|
||||
|
||||
type ThemeProp = {
|
||||
theme?: string;
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
const Theme: React.FC<ThemeProp> = (props) => {
|
||||
const { theme, checked } = props;
|
||||
return (
|
||||
<div
|
||||
className={classnames({
|
||||
[styles.light]: true,
|
||||
[styles.dark]: theme === 'dark',
|
||||
})}
|
||||
>
|
||||
{checked && <CheckOutlined />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Theme;
|
||||
23
src/layout/spin/index.tsx
Normal file
23
src/layout/spin/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Spin as AntSpin, SpinProps } from '@df/toco-ui';
|
||||
const Spin = (props: SpinProps) => {
|
||||
const { size = 'default' } = props;
|
||||
const sizeMap = {
|
||||
small: { width: '40px', height: '58px' },
|
||||
default: { width: '60px', height: '86px' },
|
||||
large: { width: '80px', height: '114px' },
|
||||
};
|
||||
return (
|
||||
<AntSpin
|
||||
indicator={
|
||||
<img
|
||||
src={process.env.PUBLIC_URL + `/loading.webp`}
|
||||
style={{ width: sizeMap[size].width, height: sizeMap[size].height }}
|
||||
alt="loading"
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spin;
|
||||
7
src/layout/suspense-layout/index.module.css
Normal file
7
src/layout/suspense-layout/index.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.loader {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
20
src/layout/suspense-layout/index.tsx
Normal file
20
src/layout/suspense-layout/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import Spin from '../spin';
|
||||
import styles from './index.module.css';
|
||||
|
||||
const SuspenseLayout: React.FC<React.PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={styles.loader}>
|
||||
<Spin />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuspenseLayout;
|
||||
1
src/react-app-env.d.ts
vendored
Normal file
1
src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
src/reportWebVitals.ts
Normal file
15
src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
9
src/router/config.tsx
Normal file
9
src/router/config.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Layout, { LayoutType } from '@/layout';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { RootRoute } from './types';
|
||||
|
||||
export const routerConfig: RootRoute = {
|
||||
path: '/',
|
||||
element: <Layout type={LayoutType.{{layout}}} />,
|
||||
children: [],
|
||||
};
|
||||
32
src/router/index.tsx
Normal file
32
src/router/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom';
|
||||
import { routerConfig } from './config';
|
||||
import { RootRoute } from './types';
|
||||
import withPage, { WithPageProps } from './withPage';
|
||||
|
||||
export { routerConfig };
|
||||
export type { RootRoute, WithPageProps };
|
||||
|
||||
const recursivelyFormatRoute = (
|
||||
config: RootRoute['children'],
|
||||
): RouteObject[] => {
|
||||
return config?.map((route) => {
|
||||
const { menu, children, Component, ...rest } = route;
|
||||
const ret = {
|
||||
...rest,
|
||||
children: children ? recursivelyFormatRoute(children) : undefined,
|
||||
Component: Component ? withPage(Component) : undefined,
|
||||
} as RouteObject;
|
||||
return ret;
|
||||
});
|
||||
};
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
...routerConfig,
|
||||
children: recursivelyFormatRoute(routerConfig.children),
|
||||
} as RouteObject,
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
]);
|
||||
23
src/router/types.ts
Normal file
23
src/router/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
|
||||
import { WithPageProps } from './withPage';
|
||||
|
||||
export type RootRoute = {
|
||||
menu?: undefined;
|
||||
children: (IndexRoute | Route)[];
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<NonIndexRouteObject, 'children' | 'Component'>;
|
||||
|
||||
export type IndexRoute = {
|
||||
index: true;
|
||||
menu?: undefined;
|
||||
path?: undefined;
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<IndexRouteObject, 'path' | 'Component'>;
|
||||
|
||||
export type Route = {
|
||||
menu?: { title: string };
|
||||
path: string;
|
||||
children?: (IndexRoute | Route)[];
|
||||
Component?: React.ComponentType<WithPageProps>;
|
||||
} & Omit<NonIndexRouteObject, 'children' | 'path' | 'Component'>;
|
||||
49
src/router/withPage.tsx
Normal file
49
src/router/withPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Location,
|
||||
NavigateFunction,
|
||||
Params,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
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: Record<string, string> = {};
|
||||
for (const [key, value] of searchParamsOri.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}, [searchParamsOri]);
|
||||
|
||||
return (
|
||||
<BaseComponent
|
||||
{...props}
|
||||
location={location}
|
||||
params={params}
|
||||
search={searchParams}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
WithPageComponent.displayName =
|
||||
BaseComponent.displayName ?? 'WithPageComponent';
|
||||
|
||||
return WithPageComponent;
|
||||
}
|
||||
|
||||
export default withPage;
|
||||
5
src/setupTests.ts
Normal file
5
src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
1
src/style/index.css
Normal file
1
src/style/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "./reset.css";
|
||||
19
src/style/reset.css
Normal file
19
src/style/reset.css
Normal file
@@ -0,0 +1,19 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-color-scheme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
Reference in New Issue
Block a user