feat: 后台 MUI 主题 MD3 化(与前台同源 token/大圆角/surface 边框)+ 深色模式修复

This commit is contained in:
2026-08-07 00:27:00 +08:00
parent a858439e19
commit 249ed8cd2c
3 changed files with 563 additions and 8 deletions
+515
View File
@@ -0,0 +1,515 @@
import { createTheme, alpha } from '@mui/material/styles';
/* ============================================================
* 后台 Material Design 3 主题
*
* - 与前台 public/css/style.css 的 MD3 token 保持同源:
* 默认主色 #6750a4 时直接复用前台 token 值(浅/深两套),
* 自定义主色时用 tonal ramp 近似生成整套 token。
* - 组件形态按 MD3 规范覆盖:pill 按钮、大圆角卡片/弹窗、
* surface 色阶、弱边框 + 低阴影(surface tint)。
* ============================================================ */
const DEFAULT_SEED = '#6750a4';
/* ---------- 颜色工具 ---------- */
function clamp(v) { return Math.min(255, Math.max(0, Math.round(v))); }
function hexToRgb(hex) {
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r, g, b) {
const to = (v) => clamp(v).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
function mix(a, b, w) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return rgbToHex(
ca[0] + (cb[0] - ca[0]) * w,
ca[1] + (cb[1] - ca[1]) * w,
ca[2] + (cb[2] - ca[2]) * w
);
}
function hexToHsl(hex) {
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, s, l];
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let rgb;
if (h < 60) rgb = [c, x, 0];
else if (h < 120) rgb = [x, c, 0];
else if (h < 180) rgb = [0, c, x];
else if (h < 240) rgb = [0, x, c];
else if (h < 300) rgb = [x, 0, c];
else rgb = [c, 0, x];
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
}
function grayscale(hex) {
const [r, g, b] = hexToRgb(hex);
const v = clamp(0.299 * r + 0.587 * g + 0.114 * b);
return rgbToHex(v, v, v);
}
/* MD3 tonal ramp 近似:tone t = 向白/黑按比例混合(t40 即 seed 本身) */
function tone(seed, t) {
if (t >= 40) return mix(seed, '#ffffff', (t - 40) / 60);
return mix(seed, '#000000', (40 - t) / 40);
}
/* 派生色:可旋转色相 / 降低饱和度后再套 tone */
function tonal(seed, t, { hue = 0, sat = 1 } = {}) {
let base = seed;
if (hue) {
const [h, s, l] = hexToHsl(base);
base = hslToHex(h + hue, s, l);
}
if (sat < 1) base = mix(base, grayscale(base), 1 - sat);
return tone(base, t);
}
/* ---------- 前台同源 token(默认主色 #6750a4,取自 public/css/style.css ---------- */
const LIGHT_TOKENS = {
primary: '#6750a4',
onPrimary: '#ffffff',
primaryContainer: '#eadfff',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#ffffff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#ffffff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_TOKENS = {
primary: '#d0bcff',
onPrimary: '#381e72',
primaryContainer: '#4f378b',
onPrimaryContainer: '#eadfff',
secondary: '#ccc2dc',
onSecondary: '#332d41',
secondaryContainer: '#4a4458',
onSecondaryContainer: '#e8def8',
tertiary: '#efb8c8',
onTertiary: '#492532',
tertiaryContainer: '#61343f',
onTertiaryContainer: '#ffd8e4',
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
function buildPalette(seed, dark) {
if (String(seed).toLowerCase() === DEFAULT_SEED) return dark ? DARK_TOKENS : LIGHT_TOKENS;
// 自定义主色:surface 色阶保持 MD3 基线,色相相关 token 由 tonal ramp 派生
const sat = 0.55; // secondary 适度去饱和,贴近 MD3 参考值
const hue = 32; // tertiary 色相偏移
if (dark) {
return {
primary: tone(seed, 80),
onPrimary: tone(seed, 20),
primaryContainer: tone(seed, 30),
onPrimaryContainer: tone(seed, 90),
secondary: tonal(seed, 80, { sat }),
onSecondary: tonal(seed, 20, { sat }),
secondaryContainer: tonal(seed, 30, { sat }),
onSecondaryContainer: tonal(seed, 90, { sat }),
tertiary: tonal(seed, 80, { hue }),
onTertiary: tonal(seed, 20, { hue }),
tertiaryContainer: tonal(seed, 30, { hue }),
onTertiaryContainer: tonal(seed, 90, { hue }),
...DARK_TOKENS_DERIVED,
};
}
return {
primary: tone(seed, 40),
onPrimary: '#ffffff',
primaryContainer: tone(seed, 90),
onPrimaryContainer: tone(seed, 10),
secondary: tonal(seed, 40, { sat }),
onSecondary: '#ffffff',
secondaryContainer: tonal(seed, 90, { sat }),
onSecondaryContainer: tonal(seed, 10, { sat }),
tertiary: tonal(seed, 40, { hue }),
onTertiary: '#ffffff',
tertiaryContainer: tonal(seed, 90, { hue }),
onTertiaryContainer: tonal(seed, 10, { hue }),
...LIGHT_TOKENS_DERIVED,
};
}
// 派生模式下 surface 相关与 error 沿用 MD3 基线
const LIGHT_TOKENS_DERIVED = {
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_TOKENS_DERIVED = {
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
/* ---------- MD3 柔和阴影(surface tint 而非硬投影) ---------- */
function md3Shadows() {
const shadows = ['none'];
for (let i = 1; i <= 24; i += 1) {
const a = Math.min(0.26, 0.05 + i * 0.008).toFixed(3);
const b = Math.min(0.15, 0.03 + i * 0.005).toFixed(3);
const y1 = Math.min(3, 1 + Math.floor(i / 8));
const y2 = Math.min(2, 1 + Math.floor(i / 12));
const b1 = Math.min(8, 2 + Math.floor(i / 3));
const b2 = Math.min(6, 1 + Math.floor(i / 5));
shadows.push(`0px ${y1}px ${b1}px rgba(0,0,0,${a}), 0px ${y2}px ${b2}px rgba(0,0,0,${b})`);
}
return shadows;
}
/* ---------- 主题工厂 ---------- */
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
const dark = mode === 'dark';
const p = buildPalette(primary, dark);
const shadows = md3Shadows();
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
// filled / tonal 按钮的 hover 色(MD3 hover = 相邻 tone
const hoverPrimary = dark ? tone(p.primary, 90) : tone(p.primary, 35);
return createTheme({
palette: {
mode,
primary: {
main: p.primary,
contrastText: p.onPrimary,
container: p.primaryContainer,
onContainer: p.onPrimaryContainer,
},
secondary: {
main: p.secondary,
contrastText: p.onSecondary,
container: p.secondaryContainer,
onContainer: p.onSecondaryContainer,
},
tertiary: {
main: p.tertiary,
contrastText: p.onTertiary,
container: p.tertiaryContainer,
onContainer: p.onTertiaryContainer,
},
error: { main: p.error, contrastText: p.onError },
background: { default: p.surface, paper: p.surfaceContainerLow },
text: {
primary: p.onSurface,
secondary: p.onSurfaceVariant,
disabled: alpha(p.onSurface, 0.38),
},
divider: p.outlineVariant,
action: {
hover: alpha(p.onSurface, 0.08),
selected: alpha(p.onSurface, 0.08),
disabled: alpha(p.onSurface, 0.38),
disabledBackground: alpha(p.onSurface, 0.12),
},
},
shape: { borderRadius: 8 },
typography: {
fontFamily,
h4: { fontSize: 28, fontWeight: 500, letterSpacing: 0 },
h5: { fontSize: 24, fontWeight: 500, letterSpacing: 0 },
h6: { fontSize: 22, fontWeight: 500, letterSpacing: 0.15 },
subtitle1: { fontSize: 16, fontWeight: 500, letterSpacing: 0.15 },
body1: { fontSize: 16, lineHeight: 1.5 },
body2: { fontSize: 14, lineHeight: 1.5 },
button: { fontSize: 14, fontWeight: 500, letterSpacing: 0.1 },
caption: { fontSize: 12, fontWeight: 400, letterSpacing: 0.4 },
},
shadows,
components: {
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
MuiAppBar: {
defaultProps: { color: 'inherit', elevation: 0 },
styleOverrides: {
root: {
backgroundColor: p.surfaceContainer,
color: p.onSurface,
boxShadow: 'none',
borderBottom: `1px solid ${p.outlineVariant}`,
backgroundImage: 'none',
},
},
},
/* MD3 导航抽屉:surfaceContainerLow + 右侧细边框 */
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: p.surfaceContainerLow,
borderRight: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
},
},
/* MD3 导航项:胶囊选中态(secondaryContainer */
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 24,
margin: '4px 10px',
paddingTop: 8,
paddingBottom: 8,
'& .MuiListItemIcon-root': { color: p.onSurfaceVariant, minWidth: 40 },
'& .MuiListItemText-primary': { fontWeight: 500, fontSize: 14 },
'&.Mui-selected': {
backgroundColor: p.secondaryContainer,
color: p.onSecondaryContainer,
'&:hover': { backgroundColor: p.secondaryContainer },
'& .MuiListItemIcon-root': { color: p.onSecondaryContainer },
},
},
},
},
/* MD3 按钮:pill 圆角、无阴影、文字不转大写 */
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
borderRadius: 20,
textTransform: 'none',
fontWeight: 500,
letterSpacing: '0.1px',
height: 40,
padding: '0 24px',
},
sizeSmall: { height: 32, padding: '0 16px', fontSize: 13 },
sizeLarge: { height: 48, padding: '0 28px', fontSize: 15 },
containedPrimary: {
backgroundColor: p.primary,
color: p.onPrimary,
'&:hover': { backgroundColor: hoverPrimary, boxShadow: shadows[1] },
},
containedError: { boxShadow: 'none' },
outlined: { borderColor: p.outline },
outlinedPrimary: {
borderColor: p.outline,
'&:hover': { borderColor: p.onSurface, backgroundColor: alpha(p.primary, 0.08) },
},
outlinedError: { color: p.error, borderColor: p.outline, '&:hover': { backgroundColor: alpha(p.error, 0.08) } },
textPrimary: { color: p.primary, '&:hover': { backgroundColor: alpha(p.primary, 0.08) } },
textError: { color: p.error },
},
},
/* MD3 卡片:surfaceContainerLow + 弱边框 + 12px 圆角 */
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
backgroundColor: p.surfaceContainerLow,
border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
},
},
/* 设置页的普通 Paper(默认 elevation=1)同样 MD3 化 */
MuiPaper: {
styleOverrides: {
rounded: { borderRadius: 12 },
elevation1: {
backgroundColor: p.surfaceContainerLow,
border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
outlined: { borderColor: p.outlineVariant },
},
},
/* MD3 弹窗:28px 大圆角 + surfaceContainerHigh */
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 28,
backgroundColor: p.surfaceContainerHigh,
border: `1px solid ${p.outlineVariant}`,
boxShadow: shadows[4],
backgroundImage: 'none',
},
},
},
MuiDialogTitle: {
styleOverrides: { root: { fontSize: 22, fontWeight: 500, padding: '24px 24px 8px' } },
},
MuiDialogContent: {
styleOverrides: { root: { padding: '8px 24px', color: p.onSurface } },
},
MuiDialogActions: {
styleOverrides: { root: { padding: '16px 24px 24px', gap: 8 } },
},
/* MD3 输入框:4px 圆角 + 中性 outline 边框 */
MuiOutlinedInput: {
styleOverrides: {
root: { borderRadius: 4 },
notchedOutline: { borderColor: p.outline },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: p.onSurface },
},
},
/* MD3 Chip8px 圆角 + outline 边框 */
MuiChip: {
styleOverrides: {
root: { borderRadius: 8, fontWeight: 500 },
outlined: { borderColor: p.outline },
},
},
/* MD3 表格:outline-variant 分隔线、紧凑密度 */
MuiTableCell: {
styleOverrides: {
root: { borderBottom: `1px solid ${p.outlineVariant}`, padding: '14px 16px', fontSize: 14 },
head: { color: p.onSurfaceVariant, fontWeight: 600, fontSize: 13 },
},
},
/* MD3 开关:开 = primary 轨道 + 白 thumb,关 = surfaceContainerHigh 轨道 */
MuiSwitch: {
styleOverrides: {
root: {},
switchBase: {
color: p.outline,
'&.Mui-checked': { color: p.primary },
'&.Mui-checked + .MuiSwitch-track': { backgroundColor: p.primary, opacity: 1 },
},
track: { backgroundColor: p.surfaceContainerHigh, opacity: 1 },
},
},
/* MD3 分段按钮:secondaryContainer 选中态 */
MuiToggleButton: {
styleOverrides: {
root: {
borderRadius: 20,
textTransform: 'none',
borderColor: p.outline,
'&.Mui-selected': {
backgroundColor: p.secondaryContainer,
color: p.onSecondaryContainer,
borderColor: p.outline,
},
},
},
},
/* MD3 头像:primaryContainer 底 + onPrimaryContainer 字 */
MuiAvatar: {
styleOverrides: {
root: { backgroundColor: p.primaryContainer, color: p.onPrimaryContainer },
},
},
/* MD3 提示:inverseSurface 底 */
MuiTooltip: {
styleOverrides: {
tooltip: {
backgroundColor: p.inverseSurface,
color: p.inverseOnSurface,
borderRadius: 4,
fontSize: 12,
padding: '6px 8px',
},
arrow: { color: p.inverseSurface },
},
},
/* 图标按钮去底色圆角适配 */
MuiIconButton: {
styleOverrides: {
root: { borderRadius: 999 },
},
},
},
});
}
export default createMD3Theme;