Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default defineConfig({
* @description 一个不错的热更新组件,更新时可以保留 state
*/
fastRefresh: true,
mfsu: false,
/**
* @name 路由预加载
* @description 预加载路由资源,提升页面切换速度
Expand Down
48 changes: 12 additions & 36 deletions apps/admin/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
type Settings as LayoutSettings,
SettingDrawer,
} from '@ant-design/pro-components';
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import { API_PATHS } from '@examora/types';
import type { RunTimeLayoutConfig } from '@umijs/max';
import { history, Link, useIntl } from '@umijs/max';
Expand All @@ -15,12 +12,13 @@ import {
getAccessToken,
setLocalProfile,
} from '@/auth/token';
import { AvatarDropdown, Footer, SelectLang } from '@/components';
import {
loadThemePreference,
saveThemePreference,
toLayoutSettings,
} from '@/theme/preference';
AvatarDropdown,
Footer,
SelectLang,
ThemeSwitcher,
} from '@/components';
import { loadThemePreference, toLayoutSettings } from '@/theme/preference';
import useShadcnTheme from '@/theme/shadcnTheme';
import defaultSettings from '../config/defaultSettings';
import { errorConfig } from './request';
Expand Down Expand Up @@ -160,12 +158,12 @@ export async function getInitialState(): Promise<{
}

// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({
initialState,
setInitialState,
}) => {
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
return {
actionsRender: () => [<SelectLang key="SelectLang" />],
actionsRender: () => [
<ThemeSwitcher key="ThemeSwitcher" />,
<SelectLang key="SelectLang" />,
],
menuItemRender: (item, dom) => {
if (item.path) {
return (
Expand Down Expand Up @@ -216,28 +214,6 @@ export const layout: RunTimeLayoutConfig = ({
},
bgLayoutImgList: [],
menuHeaderRender: undefined,
childrenRender: (children) => (
<>
{children}
<SettingDrawer
enableDarkTheme
hideCopyButton
hideHintAlert
disableUrlParams
settings={initialState?.settings}
onSettingChange={(settings) => {
saveThemePreference({
...loadThemePreference(),
...settings,
});
setInitialState?.((state: any) => ({
...state,
settings,
}));
}}
/>
</>
),
// 无权限页面
unAccessible: <ForbiddenPage />,
...initialState?.settings,
Expand Down
119 changes: 115 additions & 4 deletions apps/admin/src/components/RightContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@ import {
getLocale,
setLocale,
} from '@@/plugin-locale/localeExports';
import { GlobalOutlined } from '@ant-design/icons';
import {
CheckOutlined,
DesktopOutlined,
GlobalOutlined,
MoonOutlined,
SunOutlined,
} from '@ant-design/icons';
import { useIntl, useModel } from '@umijs/max';
import { Dropdown } from 'antd';
import { createStyles } from 'antd-style';
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
getEffectiveThemeMode,
loadThemePreference,
SYSTEM_DARK_QUERY,
saveThemePreference,
subscribe,
type ThemeMode,
toLayoutSettings,
} from '@/theme/preference';

const useStyles = createStyles(() => ({
const useStyles = createStyles(({ token }) => ({
trigger: {
display: 'inline-flex',
alignItems: 'center',
Expand All @@ -19,7 +35,7 @@ const useStyles = createStyles(() => ({
color: 'inherit',
transition: 'background 0.2s',
borderRadius: 8,
'&:hover': { background: 'rgba(0,0,0,0.04)' },
'&:hover': { background: token.colorBgTextHover },
},
}));

Expand Down Expand Up @@ -50,4 +66,99 @@ export const SelectLang: React.FC = () => {
);
};

function getSystemPrefersDark(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(SYSTEM_DARK_QUERY).matches;
}

const themeModeIcons: Record<ThemeMode, React.ReactNode> = {
light: <SunOutlined />,
dark: <MoonOutlined />,
system: <DesktopOutlined />,
};

export const ThemeSwitcher: React.FC = () => {
const intl = useIntl();
const { styles } = useStyles();
const { setInitialState } = useModel('@@initialState');
const [preference, setPreference] = useState(() => loadThemePreference());
const [systemPrefersDark, setSystemPrefersDark] =
useState(getSystemPrefersDark);

useEffect(() => {
const syncPreference = () => setPreference(loadThemePreference());
const unsubscribe = subscribe(syncPreference);
window.addEventListener('storage', syncPreference);
return () => {
unsubscribe();
window.removeEventListener('storage', syncPreference);
};
}, []);

useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mediaQuery = window.matchMedia(SYSTEM_DARK_QUERY);
const syncSystemTheme = () => setSystemPrefersDark(mediaQuery.matches);
syncSystemTheme();
mediaQuery.addEventListener?.('change', syncSystemTheme);
return () => {
mediaQuery.removeEventListener?.('change', syncSystemTheme);
};
}, []);

const updateThemeMode = (themeMode: ThemeMode) => {
const next = { ...loadThemePreference(), themeMode };
saveThemePreference(next);
setPreference(next);
setInitialState?.((state: any) => ({
...state,
settings: {
...state?.settings,
...toLayoutSettings(next),
},
}));
};

const effectiveThemeMode = getEffectiveThemeMode(
preference.themeMode,
systemPrefersDark,
);
const triggerIcon =
preference.themeMode === 'system'
? themeModeIcons.system
: themeModeIcons[effectiveThemeMode];

return (
<Dropdown
menu={{
selectedKeys: [preference.themeMode],
onClick: ({ key }) => updateThemeMode(key as ThemeMode),
items: (['light', 'dark', 'system'] as ThemeMode[]).map((mode) => ({
key: mode,
icon: themeModeIcons[mode],
label: intl.formatMessage({
id: `navbar.theme.${mode}`,
defaultMessage: mode,
}),
extra:
preference.themeMode === mode ? (
<CheckOutlined aria-hidden />
) : undefined,
})),
}}
trigger={['click']}
>
<span
className={styles.trigger}
title={intl.formatMessage({
id: 'navbar.theme',
defaultMessage: 'Theme',
})}
>
{triggerIcon}
</span>
</Dropdown>
);
};

export type SiderTheme = 'light' | 'dark';
43 changes: 43 additions & 0 deletions apps/admin/src/components/StatusTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Tag, type TagProps } from 'antd';
import React from 'react';

export type StatusTagTone =
| 'neutral'
| 'info'
| 'success'
| 'warning'
| 'danger';

export function statusToneFromAntdColor(color?: string): StatusTagTone {
if (color === 'success' || color === 'green') return 'success';
if (color === 'processing' || color === 'blue' || color === 'cyan') {
return 'info';
}
if (color === 'warning' || color === 'orange' || color === 'gold') {
return 'warning';
}
if (color === 'error' || color === 'red') return 'danger';
return 'neutral';
}

interface StatusTagProps extends Omit<TagProps, 'color'> {
tone?: StatusTagTone;
}

const StatusTag: React.FC<StatusTagProps> = ({
tone = 'neutral',
className,
children,
...props
}) => (
<Tag
{...props}
className={['examora-status-tag', `examora-status-tag-${tone}`, className]
.filter(Boolean)
.join(' ')}
>
{children}
</Tag>
);

export default StatusTag;
6 changes: 4 additions & 2 deletions apps/admin/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 布局组件
*/
import Footer from './Footer';
import { SelectLang } from './RightContent';
import { SelectLang, ThemeSwitcher } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';

/**
Expand All @@ -15,6 +15,8 @@ import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { default as ArticleListContent } from './ArticleListContent';
export { default as AvatarList } from './AvatarList';
export { default as StandardFormRow } from './StandardFormRow';
export type { StatusTagTone } from './StatusTag';
export { default as StatusTag, statusToneFromAntdColor } from './StatusTag';
export { default as TagSelect } from './TagSelect';

export { AvatarDropdown, AvatarName, Footer, SelectLang };
export { AvatarDropdown, AvatarName, Footer, SelectLang, ThemeSwitcher };
Loading
Loading