diff --git a/apps/admin/config/config.ts b/apps/admin/config/config.ts index 7dbd75c..1bee553 100644 --- a/apps/admin/config/config.ts +++ b/apps/admin/config/config.ts @@ -71,6 +71,7 @@ export default defineConfig({ * @description 一个不错的热更新组件,更新时可以保留 state */ fastRefresh: true, + mfsu: false, /** * @name 路由预加载 * @description 预加载路由资源,提升页面切换速度 diff --git a/apps/admin/src/app.tsx b/apps/admin/src/app.tsx index 0a1db66..d603d44 100644 --- a/apps/admin/src/app.tsx +++ b/apps/admin/src/app.tsx @@ -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'; @@ -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'; @@ -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: () => [], + actionsRender: () => [ + , + , + ], menuItemRender: (item, dom) => { if (item.path) { return ( @@ -216,28 +214,6 @@ export const layout: RunTimeLayoutConfig = ({ }, bgLayoutImgList: [], menuHeaderRender: undefined, - childrenRender: (children) => ( - <> - {children} - { - saveThemePreference({ - ...loadThemePreference(), - ...settings, - }); - setInitialState?.((state: any) => ({ - ...state, - settings, - })); - }} - /> - - ), // 无权限页面 unAccessible: , ...initialState?.settings, diff --git a/apps/admin/src/components/RightContent/index.tsx b/apps/admin/src/components/RightContent/index.tsx index f1a038f..5e941a1 100644 --- a/apps/admin/src/components/RightContent/index.tsx +++ b/apps/admin/src/components/RightContent/index.tsx @@ -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', @@ -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 }, }, })); @@ -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 = { + light: , + dark: , + system: , +}; + +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 ( + 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 ? ( + + ) : undefined, + })), + }} + trigger={['click']} + > + + {triggerIcon} + + + ); +}; + export type SiderTheme = 'light' | 'dark'; diff --git a/apps/admin/src/components/StatusTag.tsx b/apps/admin/src/components/StatusTag.tsx new file mode 100644 index 0000000..bb9bea4 --- /dev/null +++ b/apps/admin/src/components/StatusTag.tsx @@ -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 { + tone?: StatusTagTone; +} + +const StatusTag: React.FC = ({ + tone = 'neutral', + className, + children, + ...props +}) => ( + + {children} + +); + +export default StatusTag; diff --git a/apps/admin/src/components/index.ts b/apps/admin/src/components/index.ts index 1597df7..fde89d9 100644 --- a/apps/admin/src/components/index.ts +++ b/apps/admin/src/components/index.ts @@ -6,7 +6,7 @@ * 布局组件 */ import Footer from './Footer'; -import { SelectLang } from './RightContent'; +import { SelectLang, ThemeSwitcher } from './RightContent'; import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown'; /** @@ -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 }; diff --git a/apps/admin/src/global.less b/apps/admin/src/global.less index 4d6c9c5..150d103 100644 --- a/apps/admin/src/global.less +++ b/apps/admin/src/global.less @@ -152,6 +152,214 @@ body, box-shadow: none; } +html.examora-dark { + .ant-btn { + box-shadow: none; + } + + .ant-btn:focus-visible { + outline: 2px solid rgba(244, 244, 245, 0.28); + outline-offset: 2px; + box-shadow: none; + } + + .ant-btn-default:not(.ant-btn-primary) { + color: #e4e4e7; + background: #18181b; + border-color: #3f3f46; + box-shadow: none; + + &:hover, + &:focus { + color: #fafafa; + background: #27272a; + border-color: #52525b; + } + + &:active { + color: #fafafa; + background: #303030; + border-color: #525252; + } + } + + .ant-btn-primary { + color: #18181b; + background: #f4f4f5; + border-color: #f4f4f5; + box-shadow: none; + + &:hover, + &:focus { + color: #18181b; + background: #e4e4e7; + border-color: #e4e4e7; + } + + &:active { + color: #18181b; + background: #d4d4d8; + border-color: #d4d4d8; + } + } + + .ant-pro-query-filter-collapse-button { + color: #a1a1aa; + transition: color 150ms ease; + + &:hover, + &:focus { + color: #d4d4d8; + } + + .anticon { + color: currentColor; + } + } + + .ant-btn-link:not(.ant-btn-dangerous), + .ant-table a:not(.ant-btn), + .ant-pro-table a:not(.ant-btn) { + color: #d4d4d8; + + &:hover, + &:focus { + color: #fafafa; + } + } + + .ant-pro-page-container-detail, + .ant-typography-secondary { + color: #a1a1aa; + } +} + +.examora-status-tag.ant-tag { + height: 22px; + margin: 0; + padding: 0 9px; + font-size: 12px; + font-weight: 600; + line-height: 20px; + border-radius: 6px; +} + +.examora-status-tag-neutral.ant-tag { + color: #3f3f46; + background: #f4f4f5; + border-color: #e4e4e7; +} + +.examora-status-tag-info.ant-tag { + color: #1d4ed8; + background: #eff6ff; + border-color: #bfdbfe; +} + +.examora-status-tag-success.ant-tag { + color: #15803d; + background: #f0fdf4; + border-color: #bbf7d0; +} + +.examora-status-tag-warning.ant-tag { + color: #a16207; + background: #fffbeb; + border-color: #fde68a; +} + +.examora-status-tag-danger.ant-tag { + color: #b91c1c; + background: #fef2f2; + border-color: #fecaca; +} + +html.examora-dark { + .examora-status-tag-neutral.ant-tag { + color: #d4d4d8; + background: rgba(63, 63, 70, 0.3); + border-color: #3f3f46; + } + + .examora-status-tag-info.ant-tag { + color: #93c5fd; + background: rgba(30, 64, 175, 0.24); + border-color: rgba(59, 130, 246, 0.28); + } + + .examora-status-tag-success.ant-tag { + color: #86efac; + background: rgba(20, 83, 45, 0.28); + border-color: rgba(34, 197, 94, 0.26); + } + + .examora-status-tag-warning.ant-tag { + color: #fde68a; + background: rgba(113, 63, 18, 0.28); + border-color: rgba(234, 179, 8, 0.24); + } + + .examora-status-tag-danger.ant-tag { + color: #fca5a5; + background: rgba(127, 29, 29, 0.3); + border-color: rgba(248, 113, 113, 0.26); + } +} + +html.examora-dark .account-settings-main { + background: #18181b; + border-color: #27272a; +} + +html.examora-dark .account-settings-menu, +html.examora-dark .account-settings-content { + background: #18181b; +} + +html.examora-dark .account-settings-menu { + border-right-color: #27272a; +} + +html.examora-dark .account-settings-menu .ant-menu { + background: transparent; +} + +html.examora-dark .account-settings-menu .ant-menu-item { + color: #a1a1aa; +} + +html.examora-dark .account-settings-menu .ant-menu-item:hover { + color: #e4e4e7; + background: rgba(63, 63, 70, 0.28); +} + +html.examora-dark .account-settings-menu .ant-menu-item-selected { + color: #e4e4e7 !important; + background: rgba(63, 63, 70, 0.42) !important; + box-shadow: inset 2px 0 0 #52525b; +} + +html.examora-dark .account-settings-menu .ant-menu-item:focus-visible { + outline: 1px solid #52525b; + outline-offset: 1px; +} + +html.examora-dark .account-settings-main .ant-list-split .ant-list-item { + border-block-end-color: #27272a; +} + +html.examora-dark .account-settings-main .ant-list-item-meta-description { + color: #a1a1aa !important; +} + +html.examora-dark .account-settings-main .ant-list-item-action a { + color: #a1a1aa; +} + +html.examora-dark .account-settings-main .ant-list-item-action a:hover { + color: #e4e4e7; +} + .ant-pro-global-header-logo img { height: 32px; } diff --git a/apps/admin/src/locales/en-US/pages.ts b/apps/admin/src/locales/en-US/pages.ts index c6a040d..fda72d8 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -70,7 +70,7 @@ export default { // Users page 'pages.users.title': 'User List', 'pages.users.description': - 'Create and manage platform user accounts, supporting admin, teacher, and student roles, as well as active, inactive, and suspended statuses.', + 'Maintain platform users, roles, and account state.', 'pages.users.listTitle': 'User List', 'pages.users.create': 'Add User', 'pages.users.fetchError': 'Failed to fetch user list', @@ -139,9 +139,9 @@ export default { // Questions page 'pages.questions.title': 'Question Management', 'pages.questions.description': - 'Create and manage exam questions, answers, and programming test cases.', + 'Maintain questions, answers, and programming test cases.', 'pages.programming.description': - 'Manage programming questions, starter code, and test cases for online judging.', + 'Maintain programming questions, starter code, and test cases.', 'pages.programming.listTitle': 'Programming Question List', 'pages.programming.create': 'Create Programming Question', 'pages.questions.listTitle': 'Question List', @@ -306,7 +306,7 @@ export default { 'pages.questions.statusUpdateError': 'Failed to update status', // Papers page 'pages.papers.description': - 'Create and maintain exam papers, configure question order and score.', + 'Maintain paper structure, question order, and scores.', 'pages.papers.listTitle': 'Paper List', 'pages.papers.create': 'New Paper', 'pages.papers.fetchError': 'Failed to fetch paper list', @@ -386,7 +386,7 @@ export default { // Exam pages 'pages.exams.title': 'Exam Management', 'pages.exams.description': - 'Create and manage exams, set time range and duration, and manage exam participants.', + 'Manage exam schedules, assignees, and publication state.', 'pages.exams.listTitle': 'Exam List', 'pages.exams.create': 'Create Exam', 'pages.exams.more': 'More', @@ -671,7 +671,7 @@ export default { 'Select users to add to the current group', // User groups 'pages.userGroups.description': - 'Maintain reusable user scopes. Exams can be assigned by user or user group.', + 'Maintain reusable scopes for exam assignment.', 'pages.userGroups.listTitle': 'User Group List', 'pages.userGroups.total': 'Total {total} records', 'pages.userGroups.keyword': 'Keyword', @@ -726,7 +726,7 @@ export default { 'pages.userGroups.detail.usersLoadError': 'Failed to load users', // Results 'pages.results.description': - 'Review candidate submissions, total scores, and per-question grading status by exam.', + 'View submissions, scores, and question grading state.', 'pages.results.examsLoadError': 'Failed to load exams', 'pages.results.detailLoadError': 'Failed to load result detail', 'pages.results.fetchError': 'Failed to load submission records', @@ -739,7 +739,7 @@ export default { 'pages.results.columns.submittedAt': 'Submitted At', // Judge tasks 'pages.judgeTasks.description': - 'Review async judge tasks, retries, runtime duration, and sandbox summaries.', + 'View judge queue, retry counts, and sandbox summaries.', 'pages.judgeTasks.fetchError': 'Failed to load judge tasks', 'pages.judgeTasks.detailLoadError': 'Failed to load judge task detail', 'pages.judgeTasks.detailTitle': 'Judge Task Detail', @@ -757,4 +757,8 @@ export default { 'pages.forbidden.relogin': 'Sign In Again', 'navbar.settings': 'Settings', 'navbar.logout': 'Logout', + 'navbar.theme': 'Theme', + 'navbar.theme.light': 'Light', + 'navbar.theme.dark': 'Dark', + 'navbar.theme.system': 'System', }; diff --git a/apps/admin/src/locales/zh-CN/pages.ts b/apps/admin/src/locales/zh-CN/pages.ts index a134c6d..c745b90 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -61,8 +61,7 @@ export default { 'pages.login.brand.signal3.desc': '管理后台角色与受保护操作流程。', // Users page 'pages.users.title': '用户列表', - 'pages.users.description': - '创建和管理平台用户账号,支持设置管理员、教师、学生等角色,以及启用、停用、封禁等账号状态。', + 'pages.users.description': '维护平台用户、角色与账号状态。', 'pages.users.listTitle': '用户列表', 'pages.users.create': '添加用户', 'pages.users.fetchError': '获取用户列表失败', @@ -128,9 +127,8 @@ export default { 'pages.users.form.status.placeholder': '选择状态', // Questions page 'pages.questions.title': '题目管理', - 'pages.questions.description': '创建和管理考试题目、答案与编程用例。', - 'pages.programming.description': - '集中维护编程题、代码模板与测试用例,用于在线评测。', + 'pages.questions.description': '维护题目、答案与编程测试用例。', + 'pages.programming.description': '维护编程题、代码模板与测试用例。', 'pages.programming.listTitle': '编程题列表', 'pages.programming.create': '新建编程题', 'pages.questions.listTitle': '题目列表', @@ -282,7 +280,7 @@ export default { 'pages.questions.statusUpdateSuccess': '状态已更新为「{status}」', 'pages.questions.statusUpdateError': '状态更新失败', // Papers page - 'pages.papers.description': '创建和维护考试试卷,配置试题顺序与分值。', + 'pages.papers.description': '维护试卷结构、题目顺序与分值。', 'pages.papers.listTitle': '试卷列表', 'pages.papers.create': '新建试卷', 'pages.papers.fetchError': '获取试卷列表失败', @@ -359,8 +357,7 @@ export default { 'pages.papers.detail.form.statusRequired': '请选择状态', // Exam pages 'pages.exams.title': '考试管理', - 'pages.exams.description': - '创建和管理考试,设置考试时间、时长和参与用户,支持线上监考。', + 'pages.exams.description': '管理考试安排、参与用户与发布状态。', 'pages.exams.listTitle': '考试列表', 'pages.exams.create': '创建考试', 'pages.exams.more': '更多', @@ -636,8 +633,7 @@ export default { 'pages.candidates.groupDescription': '说明', 'pages.candidates.memberPlaceholder': '选择要加入当前用户组的用户', // User groups - 'pages.userGroups.description': - '维护可复用的用户范围,考试可按用户或用户组分配。', + 'pages.userGroups.description': '维护可复用的考试分配范围。', 'pages.userGroups.listTitle': '用户组列表', 'pages.userGroups.total': '共 {total} 条', 'pages.userGroups.keyword': '关键词', @@ -691,7 +687,7 @@ export default { 'pages.userGroups.detail.examSearchPlaceholder': '搜索考试 ID', 'pages.userGroups.detail.usersLoadError': '加载用户失败', // Results - 'pages.results.description': '按考试查看用户提交、总分和题目判分状态。', + 'pages.results.description': '查看提交、成绩与题目判分状态。', 'pages.results.examsLoadError': '加载考试列表失败', 'pages.results.detailLoadError': '加载结果详情失败', 'pages.results.fetchError': '加载提交记录失败', @@ -703,8 +699,7 @@ export default { 'pages.results.columns.score': '成绩', 'pages.results.columns.submittedAt': '提交时间', // Judge tasks - 'pages.judgeTasks.description': - '查看异步判题任务、重试次数、运行耗时和沙箱摘要。', + 'pages.judgeTasks.description': '查看判题队列、重试次数与沙箱摘要。', 'pages.judgeTasks.fetchError': '加载判题任务失败', 'pages.judgeTasks.detailLoadError': '加载判题任务详情失败', 'pages.judgeTasks.detailTitle': '判题任务详情', @@ -722,4 +717,8 @@ export default { 'pages.forbidden.relogin': '重新登录', 'navbar.settings': '设置', 'navbar.logout': '退出登录', + 'navbar.theme': '主题', + 'navbar.theme.light': '亮色', + 'navbar.theme.dark': '暗色', + 'navbar.theme.system': '跟随系统', }; diff --git a/apps/admin/src/pages/Account/Settings/components/base.tsx b/apps/admin/src/pages/Account/Settings/components/base.tsx index e7a9447..b4a794e 100644 --- a/apps/admin/src/pages/Account/Settings/components/base.tsx +++ b/apps/admin/src/pages/Account/Settings/components/base.tsx @@ -1,7 +1,7 @@ import { ProCard, ProDescriptions } from '@ant-design/pro-components'; import { useIntl, useModel } from '@umijs/max'; -import { Tag } from 'antd'; import React from 'react'; +import { StatusTag } from '@/components'; import useStyles from '../style'; const BaseView: React.FC = () => { @@ -59,7 +59,9 @@ const BaseView: React.FC = () => { render: (_: unknown, entity: Record) => { const roles = entity.roles as string[]; return roles?.length - ? roles.map((role) => {role}) + ? roles.map((role) => ( + {role} + )) : '-'; }, }, diff --git a/apps/admin/src/pages/Account/Settings/index.tsx b/apps/admin/src/pages/Account/Settings/index.tsx index 23c3ff6..73326a5 100644 --- a/apps/admin/src/pages/Account/Settings/index.tsx +++ b/apps/admin/src/pages/Account/Settings/index.tsx @@ -100,14 +100,14 @@ const Settings: React.FC = () => { return (
{ if (ref) { dom.current = ref; } }} > -
+
{ items={getMenu()} />
-
-
{menuMap[initConfig.selectKey]}
+
+
+ {menuMap[initConfig.selectKey]} +
diff --git a/apps/admin/src/pages/Account/Settings/style.ts b/apps/admin/src/pages/Account/Settings/style.ts index 3b8e4d8..b77c965 100644 --- a/apps/admin/src/pages/Account/Settings/style.ts +++ b/apps/admin/src/pages/Account/Settings/style.ts @@ -6,36 +6,76 @@ const useStyles = createStyles(({ token }) => { display: 'flex', width: '100%', height: '100%', - paddingTop: '16px', - paddingBottom: '16px', + minHeight: '520px', + overflow: 'hidden', backgroundColor: token.colorBgContainer, + border: `${token.lineWidth}px solid ${token.colorBorderSecondary}`, + borderRadius: token.borderRadiusLG, '.ant-list-split .ant-list-item:last-child': { borderBottom: `1px solid ${token.colorSplit}`, }, - '.ant-list-item': { paddingTop: '14px', paddingBottom: '14px' }, + '.ant-list-item': { paddingTop: '16px', paddingBottom: '16px' }, + '.ant-list-item-meta-title': { + color: token.colorTextHeading, + fontWeight: 600, + }, + '.ant-list-item-meta-description': { + color: token.colorTextTertiary, + }, + '.ant-list-item-action a': { + color: token.colorTextSecondary, + }, + '.ant-list-item-action a:hover': { + color: token.colorText, + }, [`@media screen and (max-width: ${token.screenMD}px)`]: { flexDirection: 'column', }, }, leftMenu: { width: '224px', - borderRight: `${token.lineWidth}px solid ${token.colorSplit}`, - '.ant-menu-inline': { border: 'none !important' }, - '.ant-menu-horizontal': { fontWeight: 'bold' }, + padding: '16px 12px', + backgroundColor: token.colorBgContainer, + borderRight: `${token.lineWidth}px solid ${token.colorBorderSecondary}`, + '.ant-menu': { + background: 'transparent', + border: 'none !important', + }, + '.ant-menu-item': { + height: '40px', + marginInline: 0, + marginBlock: '4px', + borderRadius: token.borderRadius, + color: token.colorTextSecondary, + fontWeight: 500, + }, + '.ant-menu-item:hover': { + color: token.colorText, + backgroundColor: token.colorFillTertiary, + }, + '.ant-menu-item-selected': { + color: token.colorText, + backgroundColor: token.colorFillSecondary, + boxShadow: `inset 2px 0 0 ${token.colorBorder}`, + }, + '.ant-menu-horizontal': { fontWeight: 600 }, [`@media screen and (max-width: ${token.screenMD}px)`]: { width: '100%', - border: 'none', + borderRight: 'none', + borderBottom: `${token.lineWidth}px solid ${token.colorBorderSecondary}`, }, }, right: { flex: '1', - padding: '8px 40px', + minWidth: 0, + padding: '28px 40px 40px', + backgroundColor: token.colorBgContainer, [`@media screen and (max-width: ${token.screenMD}px)`]: { - padding: '40px', + padding: '24px', }, }, title: { - marginBottom: '12px', + marginBottom: '20px', color: token.colorTextHeading, fontWeight: '500', fontSize: '20px', @@ -43,7 +83,6 @@ const useStyles = createStyles(({ token }) => { }, baseView: { display: 'flex', - paddingTop: '12px', '.ant-legacy-form-item .ant-legacy-form-item-control-wrapper': { width: '100%', }, @@ -52,6 +91,44 @@ const useStyles = createStyles(({ token }) => { 'font.strong': { color: token.colorSuccess }, 'font.medium': { color: token.colorWarning }, 'font.weak': { color: token.colorError }, + 'html.examora-dark .account-settings-main': { + backgroundColor: '#18181b', + borderColor: '#27272a', + }, + 'html.examora-dark .account-settings-menu': { + backgroundColor: '#18181b', + borderRightColor: '#27272a', + }, + 'html.examora-dark .account-settings-content': { + backgroundColor: '#18181b', + }, + 'html.examora-dark .account-settings-main .ant-list-split .ant-list-item': + { + borderBlockEndColor: '#27272a', + }, + 'html.examora-dark .account-settings-main .ant-list-item-meta-description': + { + color: '#a1a1aa', + }, + 'html.examora-dark .account-settings-main .ant-list-item-action a': { + color: '#a1a1aa', + }, + 'html.examora-dark .account-settings-main .ant-list-item-action a:hover': + { + color: '#e4e4e7', + }, + 'html.examora-dark .account-settings-menu .ant-menu-item': { + color: '#a1a1aa', + }, + 'html.examora-dark .account-settings-menu .ant-menu-item:hover': { + color: '#e4e4e7', + backgroundColor: 'rgba(63, 63, 70, 0.28)', + }, + 'html.examora-dark .account-settings-menu .ant-menu-item-selected': { + color: '#e4e4e7', + backgroundColor: 'rgba(63, 63, 70, 0.42)', + boxShadow: 'inset 2px 0 0 #52525b', + }, }, }; }); diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx index b385637..91ff79e 100644 --- a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx @@ -16,11 +16,11 @@ import { Drawer, Space, Spin, - Tag, Typography, } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useState } from 'react'; +import { StatusTag, statusToneFromAntdColor } from '@/components'; import { fetchEnvelope } from '@/utils/apiEnvelope'; import { requestErrorMessage } from '@/utils/request'; import { @@ -128,7 +128,11 @@ const JudgeTasksContent: React.FC = () => { width: 150, search: false, render: (_, record) => ( - {record.status} + + {record.status} + ), }, { @@ -230,9 +234,13 @@ const JudgeTasksContent: React.FC = () => { {detail.language} - + {detail.status} - + {detail.retry_count}/{detail.max_retry_count} diff --git a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx index 02b686c..9f16129 100644 --- a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx @@ -24,11 +24,11 @@ import { Space, Spin, Table, - Tag, Typography, } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; +import { StatusTag, statusToneFromAntdColor } from '@/components'; import { fetchEnvelope } from '@/utils/apiEnvelope'; import { requestErrorMessage } from '@/utils/request'; import { @@ -157,7 +157,11 @@ const SubmissionsContent: React.FC = () => { width: 140, search: false, render: (_, record) => ( - {record.status} + + {record.status} + ), }, { @@ -284,9 +288,13 @@ const SubmissionsContent: React.FC = () => { {detail.user_id} - + {detail.status} - + {formatScore(detail.score, detail.max_score)} @@ -304,7 +312,7 @@ const SubmissionsContent: React.FC = () => { title: 'Status', dataIndex: 'status', width: 160, - render: (status) => {status}, + render: (status) => {status}, }, { title: 'Score', diff --git a/apps/admin/src/pages/Content/Library/Questions/Detail.tsx b/apps/admin/src/pages/Content/Library/Questions/Detail.tsx index bb64cb6..6b04ce1 100644 --- a/apps/admin/src/pages/Content/Library/Questions/Detail.tsx +++ b/apps/admin/src/pages/Content/Library/Questions/Detail.tsx @@ -46,10 +46,10 @@ import { Row, Select, Space, - Tag, } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; +import { StatusTag } from '@/components'; import { requestErrorMessage } from '@/utils/request'; import { SectionTitle } from './components/SectionTitle'; import { SortableItemWrapper } from './components/SortableItemWrapper'; @@ -272,12 +272,6 @@ interface QuestionEnvelope { const HiddenFormValue: React.FC = () => null; -const DIFFICULTY_TAGS: Record = { - EASY: 'qdiff-easy', - MEDIUM: 'qdiff-medium', - HARD: 'qdiff-hard', -}; - /* ============================================================ Sub-components ============================================================ */ @@ -1433,15 +1427,19 @@ const QuestionsDetailContent: React.FC = () => { question && !isNew ? ( #{question.id} - {typeLabelMap[question.type]} + {typeLabelMap[question.type]} {question.difficulty && ( - {difficultyLabelMap[question.difficulty] || question.difficulty} - + )} = { - EASY: 'question-diff-tag-easy', - MEDIUM: 'question-diff-tag-medium', - HARD: 'question-diff-tag-hard', -}; - const questionTypeIcon = (type: QuestionType) => { if (type === 'PROGRAMMING') return ; if (type === 'MULTIPLE_CHOICE' || type === 'TRUE_FALSE') @@ -504,9 +498,7 @@ export const QuestionsPageContent: React.FC = ({ valueType: 'select', valueEnum: typeValueEnum, render: (_: unknown, question: AdminQuestion) => ( - - {typeLabelMap[question.type] || question.type} - + {typeLabelMap[question.type] || question.type} ), }, { @@ -521,13 +513,17 @@ export const QuestionsPageContent: React.FC = ({ valueEnum: difficultyValueEnum, render: (_: unknown, question: AdminQuestion) => question.difficulty ? ( - {difficultyLabelMap[question.difficulty] || question.difficulty} - + ) : ( {notSetLabel} ), diff --git a/apps/admin/src/pages/Content/Papers/index.less b/apps/admin/src/pages/Content/Papers/index.less index a429a92..8ab97c1 100644 --- a/apps/admin/src/pages/Content/Papers/index.less +++ b/apps/admin/src/pages/Content/Papers/index.less @@ -27,6 +27,14 @@ color: var(--ant-color-primary-hover); } +html.examora-dark .paper-title-button { + color: #fafafa; +} + +html.examora-dark .paper-title-button:hover { + color: #d4d4d8; +} + .paper-title-desc { margin-top: 2px; overflow: hidden; @@ -37,20 +45,8 @@ text-overflow: ellipsis; } -.paper-status-tag { - border-radius: 4px; -} - -.paper-status-draft { - color: #7a5d00; - background: #fff7d6; - border-color: #ffe58f; -} - -.paper-status-published { - color: #0f6b3d; - background: #e8f7ef; - border-color: #b7ebc6; +html.examora-dark .paper-title-desc { + color: #a1a1aa; } .paper-date { diff --git a/apps/admin/src/pages/Content/Papers/index.tsx b/apps/admin/src/pages/Content/Papers/index.tsx index a64ed72..d71037b 100644 --- a/apps/admin/src/pages/Content/Papers/index.tsx +++ b/apps/admin/src/pages/Content/Papers/index.tsx @@ -13,9 +13,10 @@ import { } from '@ant-design/pro-components'; import { API_PATHS } from '@examora/types'; import { history, request, useIntl } from '@umijs/max'; -import { App as AntdApp, Button, Dropdown, Space, Tag, Tooltip } from 'antd'; +import { App as AntdApp, Button, Dropdown, Space, Tooltip } from 'antd'; import dayjs from 'dayjs'; import React, { useMemo, useRef, useState } from 'react'; +import { StatusTag } from '@/components'; import { type BatchActionResult, proTableSortParams, @@ -270,11 +271,9 @@ const PapersPageContent: React.FC = () => { valueType: 'select', valueEnum: statusValueEnum, render: (_: unknown, paper: Paper) => ( - + {statusLabelMap[paper.status] || paper.status} - + ), }, { diff --git a/apps/admin/src/pages/Examination/ExamDetail/index.tsx b/apps/admin/src/pages/Examination/ExamDetail/index.tsx index db9770b..944ebbf 100644 --- a/apps/admin/src/pages/Examination/ExamDetail/index.tsx +++ b/apps/admin/src/pages/Examination/ExamDetail/index.tsx @@ -42,13 +42,13 @@ import { Spin, Table, Tabs, - Tag, Tree, Typography, } from 'antd'; import type { DataNode } from 'antd/es/tree'; import dayjs from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; +import { StatusTag, statusToneFromAntdColor } from '@/components'; import { formatScore, resultStatusTone, @@ -431,7 +431,11 @@ const ExamDetailContent: React.FC = () => { }), dataIndex: 'status', render: (_, record) => ( - {record.status} + + {record.status} + ), }, { @@ -507,7 +511,11 @@ const ExamDetailContent: React.FC = () => { }), dataIndex: 'status', render: (_, record) => ( - {record.status} + + {record.status} + ), }, { @@ -623,7 +631,13 @@ const ExamDetailContent: React.FC = () => { defaultMessage: '状态', })} > - {exam.status} + + {exam.status} + { defaultMessage: '状态', })} > - + {resultDetail.status} - + { { title: 'Status', dataIndex: 'status', - render: (status) => {status}, + render: (status) => {status}, }, { title: 'Score', diff --git a/apps/admin/src/pages/Examination/ExamForm/index.tsx b/apps/admin/src/pages/Examination/ExamForm/index.tsx index 1be1502..31def5c 100644 --- a/apps/admin/src/pages/Examination/ExamForm/index.tsx +++ b/apps/admin/src/pages/Examination/ExamForm/index.tsx @@ -18,9 +18,9 @@ import { Select, Space, Spin, - Tag, } from 'antd'; import React, { useEffect, useMemo, useState } from 'react'; +import { StatusTag } from '@/components'; import { requestErrorMessage } from '@/utils/request'; import { buildExamPayload, @@ -156,7 +156,9 @@ const ExamFormContent: React.FC = () => { })} extra={ exam ? ( - {exam.status} + + {exam.status} + ) : null } > diff --git a/apps/admin/src/pages/Examination/ExamList/index.tsx b/apps/admin/src/pages/Examination/ExamList/index.tsx index c996107..235e1ab 100644 --- a/apps/admin/src/pages/Examination/ExamList/index.tsx +++ b/apps/admin/src/pages/Examination/ExamList/index.tsx @@ -19,10 +19,10 @@ import { Modal, Select, Space, - Tag, } from 'antd'; import dayjs from 'dayjs'; import React, { useMemo, useRef, useState } from 'react'; +import { StatusTag, type StatusTagTone } from '@/components'; import type { BatchActionResult } from '@/utils/request'; import { requestErrorMessage } from '@/utils/request'; import { @@ -52,12 +52,12 @@ const EXAM_STATUS_KEYS = [ 'ARCHIVED', ] as const; -const statusColors: Record = { - DRAFT: 'default', - PUBLISHED: 'green', - RUNNING: 'blue', - CLOSED: 'red', - ARCHIVED: 'gray', +const statusTones: Record = { + DRAFT: 'neutral', + PUBLISHED: 'info', + RUNNING: 'success', + CLOSED: 'warning', + ARCHIVED: 'neutral', }; const ExamListContent: React.FC = () => { @@ -311,9 +311,9 @@ const ExamListContent: React.FC = () => { width: 120, search: false, render: (_: unknown, record) => ( - + {statusLabelMap[record.status] || record.status} - + ), }, { diff --git a/apps/admin/src/pages/Examination/ExamPublish/index.tsx b/apps/admin/src/pages/Examination/ExamPublish/index.tsx index b643a91..c24a763 100644 --- a/apps/admin/src/pages/Examination/ExamPublish/index.tsx +++ b/apps/admin/src/pages/Examination/ExamPublish/index.tsx @@ -11,10 +11,10 @@ import { Form, InputNumber, Space, - Tag, } from 'antd'; import type dayjs from 'dayjs'; import React, { useEffect, useState } from 'react'; +import { StatusTag } from '@/components'; import { requestErrorMessage } from '@/utils/request'; interface Exam { @@ -143,7 +143,7 @@ const ExamPublishContent: React.FC = () => { id: 'pages.exams.columns.status', defaultMessage: '状态', }), - children: {exam.status}, + children: {exam.status}, }, { key: 'paper', diff --git a/apps/admin/src/pages/System/Settings/UserGroups/Detail/index.tsx b/apps/admin/src/pages/System/Settings/UserGroups/Detail/index.tsx index ad6c8bd..c6edb96 100644 --- a/apps/admin/src/pages/System/Settings/UserGroups/Detail/index.tsx +++ b/apps/admin/src/pages/System/Settings/UserGroups/Detail/index.tsx @@ -25,11 +25,11 @@ import { Input, Modal, Tabs, - Tag, Typography, } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useRef, useState } from 'react'; +import { StatusTag } from '@/components'; import { fetchEnvelope } from '@/utils/apiEnvelope'; import { requestErrorMessage } from '@/utils/request'; import '../index.less'; @@ -72,6 +72,13 @@ const UserGroupDetailContent: React.FC = () => { const [assignmentKeyword, setAssignmentKeyword] = useState(''); const [memberModalOpen, setMemberModalOpen] = useState(false); const [selectedUserIDs, setSelectedUserIDs] = useState([]); + const sourceLabel = (source?: string) => + source === 'LOCAL' || !source + ? intl.formatMessage({ + id: 'pages.userGroups.sourceLocal', + defaultMessage: '本地', + }) + : source; const loadGroup = React.useCallback(async () => { try { @@ -273,7 +280,7 @@ const UserGroupDetailContent: React.FC = () => { dataIndex: ['user', 'role'], width: 110, search: false, - render: (_, item) => {item.user.role}, + render: (_, item) => {item.user.role}, }, { title: intl.formatMessage({ @@ -285,19 +292,19 @@ const UserGroupDetailContent: React.FC = () => { search: false, render: (_, item) => item.direct ? ( - + {intl.formatMessage({ id: 'pages.userGroups.detail.directMember', defaultMessage: '直接成员', })} - + ) : ( - + {intl.formatMessage({ id: 'pages.userGroups.detail.inheritedMember', defaultMessage: '继承成员', })} - + ), }, { @@ -378,7 +385,7 @@ const UserGroupDetailContent: React.FC = () => { dataIndex: 'role', width: 120, search: false, - render: (_, user) => {user.role}, + render: (_, user) => {user.role}, }, ]; @@ -406,12 +413,12 @@ const UserGroupDetailContent: React.FC = () => { }), dataIndex: 'target_type', render: () => ( - + {intl.formatMessage({ id: 'pages.userGroups.name', defaultMessage: '用户组', })} - + ), }, { @@ -443,11 +450,11 @@ const UserGroupDetailContent: React.FC = () => { content={ group ? (
- - {group.source} - + + {sourceLabel(group.source)} + {group.source !== 'LOCAL' && ( - + {intl.formatMessage( { id: 'pages.userGroups.detail.syncMode', @@ -455,9 +462,9 @@ const UserGroupDetailContent: React.FC = () => { }, { mode: group.sync_mode }, )} - + )} - + {intl.formatMessage( { id: 'pages.userGroups.detail.memberCount', @@ -465,8 +472,8 @@ const UserGroupDetailContent: React.FC = () => { }, { count: group.member_count || 0 }, )} - - + + {intl.formatMessage( { id: 'pages.userGroups.detail.examCount', @@ -474,7 +481,7 @@ const UserGroupDetailContent: React.FC = () => { }, { count: assignments.length }, )} - +
) : null } diff --git a/apps/admin/src/pages/System/Settings/UserGroups/index.tsx b/apps/admin/src/pages/System/Settings/UserGroups/index.tsx index ce25c96..c487802 100644 --- a/apps/admin/src/pages/System/Settings/UserGroups/index.tsx +++ b/apps/admin/src/pages/System/Settings/UserGroups/index.tsx @@ -16,10 +16,10 @@ import { Input, Modal, Space, - Tag, } from 'antd'; import dayjs from 'dayjs'; import React, { useRef, useState } from 'react'; +import { StatusTag, type StatusTagTone } from '@/components'; import { fetchEnvelope } from '@/utils/apiEnvelope'; import { requestErrorMessage } from '@/utils/request'; import './index.less'; @@ -29,11 +29,9 @@ interface GroupFormValues { description?: string; } -const sourceColor = (source?: string) => { - if (!source || source === 'LOCAL') return 'default'; - if (source === 'LOGTO') return 'blue'; - if (source === 'SCIM') return 'purple'; - return 'cyan'; +const sourceTone = (source?: string): StatusTagTone => { + if (!source || source === 'LOCAL') return 'neutral'; + return 'info'; }; const UserGroupsContent: React.FC = () => { @@ -44,6 +42,14 @@ const UserGroupsContent: React.FC = () => { const [modalOpen, setModalOpen] = useState(false); const [saving, setSaving] = useState(false); + const sourceLabel = (source?: string) => + source === 'LOCAL' || !source + ? intl.formatMessage({ + id: 'pages.userGroups.sourceLocal', + defaultMessage: '本地', + }) + : source; + const openCreate = () => { form.resetFields(); setModalOpen(true); @@ -198,7 +204,9 @@ const UserGroupsContent: React.FC = () => { width: 110, search: false, render: (_, group) => ( - {group.source} + + {sourceLabel(group.source)} + ), }, { diff --git a/apps/admin/src/pages/System/Settings/Users/index.less b/apps/admin/src/pages/System/Settings/Users/index.less index 67a5977..a17596f 100644 --- a/apps/admin/src/pages/System/Settings/Users/index.less +++ b/apps/admin/src/pages/System/Settings/Users/index.less @@ -57,66 +57,6 @@ html.examora-dark .user-name-sub { color: #a1a1aa; } -.user-role-tag.ant-tag { - height: 22px; - margin: 0; - padding: 0 9px; - color: #111827; - font-size: 12px; - font-weight: 600; - line-height: 20px; - background: linear-gradient(180deg, #f1f3f5 0%, #e7eaee 100%); - border: 1px solid #d9dde3; - border-radius: 6px; -} - -html.examora-dark .user-role-tag.ant-tag { - color: #e4e4e7; - background: linear-gradient(180deg, #27272a 0%, #1c1c1c 100%); - border-color: #3f3f46; -} - -.user-status-tag.ant-tag { - height: 22px; - margin: 0; - padding: 0 9px; - font-size: 12px; - font-weight: 600; - line-height: 20px; - border: 0; - border-radius: 4px; -} - -.user-status-active { - color: #15803d; - background: #dcfce7; -} - -.user-status-inactive { - color: #6b7280; - background: #f3f4f6; -} - -.user-status-suspended { - color: #b91c1c; - background: #fee2e2; -} - -html.examora-dark .user-status-active { - color: #86efac; - background: rgba(20, 83, 45, 0.52); -} - -html.examora-dark .user-status-inactive { - color: #a1a1aa; - background: rgba(63, 63, 70, 0.52); -} - -html.examora-dark .user-status-suspended { - color: #fca5a5; - background: rgba(127, 29, 29, 0.52); -} - .user-date { font-size: 13px; line-height: 18px; diff --git a/apps/admin/src/pages/System/Settings/Users/index.tsx b/apps/admin/src/pages/System/Settings/Users/index.tsx index 96f3584..8481099 100644 --- a/apps/admin/src/pages/System/Settings/Users/index.tsx +++ b/apps/admin/src/pages/System/Settings/Users/index.tsx @@ -29,11 +29,11 @@ import { Row, Select, Space, - Tag, Tooltip, } from 'antd'; import dayjs from 'dayjs'; import React, { useMemo, useRef, useState } from 'react'; +import { StatusTag } from '@/components'; import './index.less'; interface User { @@ -144,6 +144,14 @@ const UserListContent: React.FC = () => { [statusLabelMap], ); + const sourceLabel = (source?: string) => + source === 'LOCAL' || !source + ? intl.formatMessage({ + id: 'pages.users.source.LOCAL', + defaultMessage: '本地', + }) + : source; + // valueEnums for ProTable const roleValueEnum = useMemo( () => @@ -400,11 +408,7 @@ const UserListContent: React.FC = () => { valueEnum: roleValueEnum, render: (_, user) => { const roleKey = normalizeRole(user.role); - return ( - - {roleLabelMap[roleKey] || user.role} - - ); + return {roleLabelMap[roleKey] || user.role}; }, }, { @@ -420,11 +424,17 @@ const UserListContent: React.FC = () => { render: (_, user) => { const statusKey = normalizeStatus(user.status); return ( - {statusLabelMap[statusKey] || user.status} - + ); }, }, @@ -447,7 +457,13 @@ const UserListContent: React.FC = () => { OIDC: { text: 'OIDC' }, SCIM: { text: 'SCIM' }, }, - render: (_, user) => {user.source || 'LOCAL'}, + render: (_, user) => ( + + {sourceLabel(user.source)} + + ), }, { title: intl.formatMessage({ diff --git a/apps/admin/src/pages/Welcome/index.tsx b/apps/admin/src/pages/Welcome/index.tsx index 73834a9..af20e6c 100644 --- a/apps/admin/src/pages/Welcome/index.tsx +++ b/apps/admin/src/pages/Welcome/index.tsx @@ -28,11 +28,11 @@ import { Row, Space, Statistic, - Tag, Timeline, Typography, } from 'antd'; import React from 'react'; +import { StatusTag } from '@/components'; import TrendLineChart from './TrendLineChart'; import './welcome.less'; @@ -42,6 +42,11 @@ const Welcome: React.FC = () => { const intl = useIntl(); const f = (id: string, values?: Record) => intl.formatMessage({ id }, values); + const metricTone = (tone: string) => { + if (tone === 'amber') return 'warning'; + if (tone === 'green') return 'success'; + return 'info'; + }; const weekDays = [ f('pages.dashboard.week.mon'), f('pages.dashboard.week.tue'), @@ -313,9 +318,9 @@ const Welcome: React.FC = () => { {f('pages.dashboard.hero.kicker')} - + Examora Admin - +
@@ -359,7 +364,9 @@ const Welcome: React.FC = () => { >
{stat.icon} - {stat.trend} + + {stat.trend} +
{
- {exam.status} - - + {exam.risk} - + { } extra={ - + {f('pages.dashboard.risk.watchCount')} - + } >
diff --git a/apps/admin/src/pages/Welcome/welcome.less b/apps/admin/src/pages/Welcome/welcome.less index 106a693..cf8eda2 100644 --- a/apps/admin/src/pages/Welcome/welcome.less +++ b/apps/admin/src/pages/Welcome/welcome.less @@ -166,9 +166,17 @@ html.examora-dark .dashboard-container { flex-shrink: 0; .ant-btn-primary { + color: var(--dashboard-bg); background: var(--dashboard-primary); border-color: var(--dashboard-primary); box-shadow: none; + + &:hover, + &:focus { + color: var(--dashboard-bg); + background: var(--dashboard-text); + border-color: var(--dashboard-text); + } } .ant-btn:not(.ant-btn-primary) { diff --git a/apps/admin/src/theme/preference.test.ts b/apps/admin/src/theme/preference.test.ts new file mode 100644 index 0000000..4770469 --- /dev/null +++ b/apps/admin/src/theme/preference.test.ts @@ -0,0 +1,88 @@ +import { + getEffectiveThemeMode, + loadThemePreference, + normalizeThemePreference, + saveThemePreference, + subscribe, +} from './preference'; + +describe('theme preference', () => { + const storage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: storage, + }); + }); + + it('normalizes legacy navTheme values', () => { + expect(normalizeThemePreference({ navTheme: 'realDark' })).toMatchObject({ + themeMode: 'dark', + }); + expect(normalizeThemePreference({ navTheme: 'light' })).toMatchObject({ + themeMode: 'light', + }); + }); + + it('defaults to system mode when no preference exists', () => { + storage.getItem.mockReturnValue(null); + + expect(loadThemePreference()).toEqual({ + themeMode: 'system', + colorPrimary: '#262626', + }); + }); + + it('falls back to light when system preference is unavailable', () => { + expect(getEffectiveThemeMode('system', false)).toBe('light'); + expect(getEffectiveThemeMode('system', true)).toBe('dark'); + expect(getEffectiveThemeMode('dark', false)).toBe('dark'); + }); + + it('loads persisted theme mode', () => { + storage.getItem.mockReturnValue( + JSON.stringify({ + themeMode: 'system', + colorPrimary: '#1677ff', + }), + ); + + expect(loadThemePreference()).toEqual({ + themeMode: 'system', + colorPrimary: '#1677ff', + }); + }); + + it('notifies subscribers after saving', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + + saveThemePreference({ + themeMode: 'dark', + colorPrimary: '#262626', + }); + + expect(storage.setItem).toHaveBeenCalledWith( + 'examora-theme-preference', + JSON.stringify({ + themeMode: 'dark', + colorPrimary: '#262626', + }), + ); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + saveThemePreference({ + themeMode: 'light', + colorPrimary: '#262626', + }); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/admin/src/theme/preference.ts b/apps/admin/src/theme/preference.ts index 5074951..52207e1 100644 --- a/apps/admin/src/theme/preference.ts +++ b/apps/admin/src/theme/preference.ts @@ -1,22 +1,58 @@ import type { Settings as LayoutSettings } from '@ant-design/pro-components'; const STORAGE_KEY = 'examora-theme-preference'; +export const SYSTEM_DARK_QUERY = '(prefers-color-scheme: dark)'; + +export type ThemeMode = 'light' | 'dark' | 'system'; +export type EffectiveThemeMode = 'light' | 'dark'; export interface ThemePreference { - navTheme: 'light' | 'realDark'; + themeMode: ThemeMode; colorPrimary: string; } const DEFAULT: ThemePreference = { - navTheme: 'light', + themeMode: 'system', colorPrimary: '#262626', }; +type LegacyThemePreference = Partial & { + navTheme?: 'light' | 'realDark'; +}; + +function normalizeThemeMode(value: unknown): ThemeMode { + if (value === 'dark' || value === 'light' || value === 'system') { + return value; + } + return DEFAULT.themeMode; +} + +export function normalizeThemePreference( + value: LegacyThemePreference | null | undefined, +): ThemePreference { + if (!value) return { ...DEFAULT }; + const { navTheme, themeMode, ...rest } = value; + const legacyMode = navTheme === 'realDark' ? 'dark' : navTheme; + return { + ...DEFAULT, + ...rest, + themeMode: normalizeThemeMode(themeMode || legacyMode), + }; +} + +export function getEffectiveThemeMode( + mode: ThemeMode, + systemPrefersDark: boolean, +): EffectiveThemeMode { + if (mode === 'system') return systemPrefersDark ? 'dark' : 'light'; + return mode; +} + export function loadThemePreference(): ThemePreference { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { - return { ...DEFAULT, ...JSON.parse(raw) }; + return normalizeThemePreference(JSON.parse(raw)); } } catch { // ignore @@ -25,7 +61,11 @@ export function loadThemePreference(): ThemePreference { } export function saveThemePreference(pref: ThemePreference): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(pref)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(normalizeThemePreference(pref)), + ); + notifyThemeChange(); } const listeners = new Set<() => void>(); @@ -44,9 +84,10 @@ export function notifyThemeChange(): void { } export function toLayoutSettings(pref: ThemePreference): LayoutSettings { + const normalized = normalizeThemePreference(pref); return { - navTheme: pref.navTheme, - colorPrimary: pref.colorPrimary, + navTheme: normalized.themeMode === 'dark' ? 'realDark' : 'light', + colorPrimary: normalized.colorPrimary, layout: 'mix', contentWidth: 'Fluid', fixedHeader: true, diff --git a/apps/admin/src/theme/shadcnTheme.ts b/apps/admin/src/theme/shadcnTheme.ts index 90af579..533099f 100644 --- a/apps/admin/src/theme/shadcnTheme.ts +++ b/apps/admin/src/theme/shadcnTheme.ts @@ -1,10 +1,20 @@ import type { ConfigProviderProps, ThemeConfig } from 'antd'; import { theme } from 'antd'; import { useEffect, useMemo, useState } from 'react'; -import { loadThemePreference, subscribe } from './preference'; +import { + getEffectiveThemeMode, + loadThemePreference, + SYSTEM_DARK_QUERY, + subscribe, +} from './preference'; const DARK_CLASS = 'examora-dark'; +function getSystemPrefersDark(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) return false; + return window.matchMedia(SYSTEM_DARK_QUERY).matches; +} + // ---- Light mode token values ---- const lightToken: ThemeConfig['token'] = { @@ -275,6 +285,8 @@ const darkComponentsOverrides: ThemeConfig['components'] = { const useShadcnTheme = (): ConfigProviderProps => { const [preference, setPreference] = useState(() => loadThemePreference()); + const [systemPrefersDark, setSystemPrefersDark] = + useState(getSystemPrefersDark); useEffect(() => { const syncPreference = () => setPreference(loadThemePreference()); @@ -286,7 +298,22 @@ const useShadcnTheme = (): ConfigProviderProps => { }; }, []); - const isDark = preference.navTheme === 'realDark'; + 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 effectiveThemeMode = getEffectiveThemeMode( + preference.themeMode, + systemPrefersDark, + ); + const isDark = effectiveThemeMode === 'dark'; useEffect(() => { const root = document.documentElement;