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 Chip:8px 圆角 + 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;