fix: 移动目录

This commit is contained in:
jackycheng
2024-12-26 16:14:44 +08:00
parent 09edc936b6
commit 91a97ffb93
56 changed files with 0 additions and 4 deletions

7
src/Icon.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,81 @@
import { customValueTypeMap } from '@/components';
import { ConfigProvider, App as TocoApp, theme } from '@df/toco-ui';
import { Router } from '@remix-run/router';
import enUS from 'antd/locale/en_US';
import zhCN from 'antd/locale/zh_CN';
import { useEffect, useMemo } from 'react';
import { IntlProvider } from 'react-intl';
import { Provider as StoreProvider } from 'react-redux';
import { RouterProvider } from 'react-router-dom';
import { Locale, Theme } from './enum';
import { useAppSelector } from './hooks';
import * as langs from './langs';
import globalMessage from './message';
import { store } from './store';
const { darkAlgorithm } = theme;
const AppInternal: React.FC<{ router: Router }> = (props) => {
const { router } = props;
const { message } = TocoApp.useApp();
const locale = useAppSelector((state) => state.common.locale);
const theme = useAppSelector((state) => state.common.theme);
const primaryColor = useAppSelector((state) => state.common.primaryColor);
const antdLocaleMap = {
[Locale.zh_CN]: zhCN,
[Locale.en_US]: enUS,
};
const langsMap = {
[Locale.zh_CN]: langs.zh_CN,
[Locale.en_US]: langs.en_US,
};
const themeConfig = useMemo(() => {
return {
algorithm: theme === Theme.DARK ? [darkAlgorithm] : [],
cssVar: true,
token: primaryColor
? {
colorPrimary: primaryColor,
}
: undefined,
};
}, [primaryColor, theme]);
useEffect(() => {
globalMessage.setMessage(message);
}, [message]);
useEffect(() => {
document.documentElement.setAttribute(
'data-color-scheme',
theme === Theme.DARK ? 'dark' : 'light',
);
}, [theme]);
return (
<IntlProvider locale={locale} messages={langsMap[locale]}>
<ConfigProvider
locale={antdLocaleMap[locale]}
theme={themeConfig}
valueTypeMap={customValueTypeMap}
>
<RouterProvider router={router} />
</ConfigProvider>
</IntlProvider>
);
};
const App: React.FC<{ router: Router }> = (props) => {
return (
<StoreProvider store={store}>
<TocoApp style={{ height: '100%' }}>
<AppInternal {...props} />
</TocoApp>
</StoreProvider>
);
};
export default App;

13
src/app/langs/en_US.ts Normal file
View 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
View 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
View 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
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;

75
src/app/request.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
export {};

9
src/global.d.ts vendored Normal file
View 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
View 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();

View File

@@ -0,0 +1,70 @@
.layout {
height: 100%;
--header-height: 63px;
--extra-height: 63px;
.logoContainer{
height: 100%;
background: var(--ant-color-bg-container);
}
.logo {
&:global(.dark) {
background: var(--ant-layout-sider-bg);
}
height: var(--header-height);
padding: 16px;
.icon {
display: flex;
}
}
.header {
display: flex;
padding: 0;
background: var(--ant-color-bg-container);
border-bottom: 1px solid var(--ant-color-border);
.headerMenu {
flex: 1;
min-width: 0;
border-bottom: none;
}
}
.sider {
border-right: 1px solid var(--ant-color-border);
:global(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
.siderContainer {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.siderMenu {
border-right: none;
}
}
}
.content {
padding: 24px;
background: var(--ant-color-bg-container);
}
.extra {
&:global(.dark) {
background: var(--ant-layout-sider-bg);
}
height: var(--extra-height);
display: flex;
align-items: center;
justify-content: center;
}
}

74
src/layout/index.tsx Normal file
View 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;

View File

@@ -0,0 +1,65 @@
import { Theme } from '@/app/enum';
import { routerConfig } from '@/router';
import { Layout, Menu, MenuProps } from '@df/toco-ui';
import { SelectInfo } from 'rc-menu/lib/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
import { LayoutProps } from '.';
import styles from '../index.module.css';
import { parseMenuItems, parseRouteMatchs } from './utils';
const { Header, Content } = Layout;
const HeaderOnly: React.FC<LayoutProps> = (props) => {
const { theme: themeProp, logo, extra, siderWidth } = props;
const matches = useMatches();
const navigate = useNavigate();
const [selectedKeys, setSelectedKeys] = useState<string[]>();
useEffect(() => {
const parts = parseRouteMatchs(matches);
setSelectedKeys(parts);
}, [matches]);
const items: MenuProps['items'] = useMemo(() => {
return parseMenuItems(routerConfig?.children || [], true);
}, []);
const onKeyChange = useCallback(
(info: SelectInfo) => {
const keys = info.keyPath;
setSelectedKeys(info.keyPath);
navigate(keys.reverse().join('/'));
},
[navigate],
);
const theme = useMemo(() => {
return themeProp === Theme.DARK ? 'dark' : 'light';
}, [themeProp]);
return (
<Layout className={styles.layout}>
<Header className={styles.header}>
<div className={styles.logoContainer} style={{ width: siderWidth }}>
{logo}
</div>
<Menu
className={styles.headerMenu}
theme={theme}
mode="horizontal"
items={items}
selectedKeys={selectedKeys}
onSelect={onKeyChange}
/>
{extra}
</Header>
<Layout className={styles.content}>
<Content>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default HeaderOnly;

View File

@@ -0,0 +1,104 @@
import { Theme } from '@/app/enum';
import { routerConfig } from '@/router';
import { Layout, Menu, MenuProps } from '@df/toco-ui';
import { SelectInfo } from 'rc-menu/lib/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
import { LayoutProps } from '.';
import styles from '../index.module.css';
import { parseMenuItems, parseRouteMatchs } from './utils';
const { Header, Content, Sider } = Layout;
const HeaderSider: React.FC<LayoutProps> = (props) => {
const { theme: themeProp, siderWidth, logo, extra } = props;
const matches = useMatches();
const navigate = useNavigate();
const [headerKey, setHeaderKey] = useState<string>();
const [siderSelectedKeys, setSiderSelectedKeys] = useState<string[]>();
const [siderExpandedKeys, setSiderExpandedKeys] = useState<string[]>();
useEffect(() => {
const parts = parseRouteMatchs(matches);
const [headerKey, ...siderKey] = parts;
setHeaderKey(headerKey);
setSiderSelectedKeys(siderKey);
setSiderExpandedKeys((prev) => {
const set = new Set<string>(prev);
siderKey.slice(0, -1).forEach((key) => {
set.add(key);
});
return Array.from(set);
});
}, [matches]);
const headerItems: MenuProps['items'] = useMemo(() => {
return parseMenuItems(routerConfig.children, false);
}, []);
const onHeaderKeyChange = useCallback(
(info: SelectInfo) => {
setHeaderKey(info.key);
navigate(info.key);
},
[navigate],
);
const siderItems: MenuProps['items'] = useMemo(() => {
const item = routerConfig.children.find((item) => item.path === headerKey);
return parseMenuItems(item?.children || [], true);
}, [headerKey]);
const onSiderKeyChange = useCallback(
(info: SelectInfo) => {
const keys = info.keyPath;
setSiderSelectedKeys(info.keyPath);
const parts = [headerKey, ...keys];
navigate(parts.join('/'));
},
[navigate, headerKey],
);
const theme = useMemo(() => {
return themeProp === Theme.DARK ? 'dark' : 'light';
}, [themeProp]);
return (
<Layout className={styles.layout}>
<Header className={styles.header}>
<div className={styles.logoContainer} style={{ width: siderWidth }}>
{logo}
</div>
<Menu
className={styles.headerMenu}
theme={theme}
mode="horizontal"
items={headerItems}
selectedKeys={headerKey ? [headerKey] : []}
onSelect={onHeaderKeyChange}
/>
{extra}
</Header>
<Layout>
<Sider className={styles.sider} theme={theme} width={siderWidth}>
<Menu
className={styles.siderMenu}
mode="inline"
theme={theme}
items={siderItems}
selectedKeys={siderSelectedKeys}
onSelect={onSiderKeyChange}
openKeys={siderExpandedKeys}
onOpenChange={setSiderExpandedKeys}
/>
</Sider>
<Layout className={styles.content}>
<Content>
<Outlet />
</Content>
</Layout>
</Layout>
</Layout>
);
};
export default HeaderSider;

View File

@@ -0,0 +1,75 @@
import { Theme } from '@/app/enum';
import { routerConfig } from '@/router';
import { Layout, Menu, MenuProps } from '@df/toco-ui';
import { SelectInfo } from 'rc-menu/lib/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useMatches, useNavigate } from 'react-router-dom';
import { LayoutProps } from '.';
import styles from '../index.module.css';
import { parseMenuItems, parseRouteMatchs } from './utils';
const { Content, Sider } = Layout;
const SiderOnly: React.FC<LayoutProps> = (props) => {
const { theme: themeProp, logo, extra, siderWidth } = props;
const matches = useMatches();
const navigate = useNavigate();
const [selectedKeys, setSelectedKeys] = useState<string[]>();
const [expandedKeys, setExpandedKeys] = useState<string[]>();
useEffect(() => {
const parts = parseRouteMatchs(matches);
setSelectedKeys(parts);
setExpandedKeys((prev) => {
const set = new Set<string>(prev);
parts.slice(0, -1).forEach((key) => {
set.add(key);
});
return Array.from(set);
});
}, [matches]);
const items: MenuProps['items'] = useMemo(() => {
return parseMenuItems(routerConfig?.children || [], true);
}, []);
const onKeyChange = useCallback(
(info: SelectInfo) => {
const keys = info.keyPath;
setSelectedKeys(info.keyPath);
navigate(keys.reverse().join('/'));
},
[navigate],
);
const theme = useMemo(() => {
return themeProp === Theme.DARK ? 'dark' : 'light';
}, [themeProp]);
return (
<Layout className={styles.layout}>
<Sider className={styles.sider} theme={theme} width={siderWidth}>
{logo}
<div className={styles.siderContainer}>
<Menu
className={styles.siderMenu}
theme={theme}
mode="inline"
items={items}
selectedKeys={selectedKeys}
onSelect={onKeyChange}
openKeys={expandedKeys}
onOpenChange={setExpandedKeys}
/>
{extra}
</div>
</Sider>
<Layout className={styles.content}>
<Content>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default SiderOnly;

View File

@@ -0,0 +1,13 @@
import { Theme } from '@/app/enum';
import React from 'react';
export { default as HeaderOnly } from './HeaderOnly';
export { default as HeaderSider } from './HeaderSider';
export { default as SiderOnly } from './SiderOnly';
export type LayoutProps = {
theme?: Theme;
logo?: React.ReactNode;
extra?: React.ReactNode;
siderWidth?: number;
};

View File

@@ -0,0 +1,49 @@
import { RootRoute } from '@/router';
import { MenuProps } from '@df/toco-ui';
import { parse } from 'path-to-regexp';
import { UIMatch } from 'react-router-dom';
const removeLeadingSlashes = (path: string) => {
return path.replace(/^\/+/, '');
};
export const parseMenuItems = (
routes: RootRoute['children'],
recursively: boolean,
): MenuProps['items'] => {
return routes
.filter((item) => typeof item.path === 'string')
.map((item) => {
const res = parse(item.path);
if (res.tokens.some((token) => token.type !== 'text') || !item.menu) {
// 不生成菜单项:
// 1. 路由中不全为text例如有参数 :id
// 2. 没有设置menu
return undefined;
}
const children =
recursively && item.children
? parseMenuItems(item.children, true)
: undefined;
return {
key: removeLeadingSlashes(item.path),
label: item.menu.title,
children: children && children.length > 0 ? children : 0,
};
})
.filter((p) => !!p);
};
export const parseRouteMatchs = (matches: UIMatch[]) => {
return matches.reduce<string[]>((acc, match, index, array) => {
const last = array[index - 1];
const part = last
? match.pathname.replace(last.pathname, '')
: match.pathname;
const path = removeLeadingSlashes(part);
if (path) {
acc.push(path);
}
return acc;
}, []);
};

View File

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

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

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

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;

23
src/layout/spin/index.tsx Normal file
View 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;

View File

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

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

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

15
src/reportWebVitals.ts Normal file
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;

9
src/router/config.tsx Normal file
View File

@@ -0,0 +1,9 @@
import Layout, { LayoutType } from '@/layout';
import { Navigate } from 'react-router-dom';
import { RootRoute } from './types';
export const routerConfig: RootRoute = {
path: '/',
element: <Layout type={LayoutType.{{layout}}} />,
children: [],
};

32
src/router/index.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom';
import { routerConfig } from './config';
import { RootRoute } from './types';
import withPage, { WithPageProps } from './withPage';
export { routerConfig };
export type { RootRoute, WithPageProps };
const recursivelyFormatRoute = (
config: RootRoute['children'],
): RouteObject[] => {
return config?.map((route) => {
const { menu, children, Component, ...rest } = route;
const ret = {
...rest,
children: children ? recursivelyFormatRoute(children) : undefined,
Component: Component ? withPage(Component) : undefined,
} as RouteObject;
return ret;
});
};
export const router = createBrowserRouter([
{
...routerConfig,
children: recursivelyFormatRoute(routerConfig.children),
} as RouteObject,
{
path: '*',
element: <Navigate to="/" replace />,
},
]);

23
src/router/types.ts Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom';
import { WithPageProps } from './withPage';
export type RootRoute = {
menu?: undefined;
children: (IndexRoute | Route)[];
Component?: React.ComponentType<WithPageProps>;
} & Omit<NonIndexRouteObject, 'children' | 'Component'>;
export type IndexRoute = {
index: true;
menu?: undefined;
path?: undefined;
Component?: React.ComponentType<WithPageProps>;
} & Omit<IndexRouteObject, 'path' | 'Component'>;
export type Route = {
menu?: { title: string };
path: string;
children?: (IndexRoute | Route)[];
Component?: React.ComponentType<WithPageProps>;
} & Omit<NonIndexRouteObject, 'children' | 'path' | 'Component'>;

49
src/router/withPage.tsx Normal file
View 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
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';

1
src/style/index.css Normal file
View File

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

19
src/style/reset.css Normal file
View 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;
}