feat: 后台 MUI 主题 MD3 化(与前台同源 token/大圆角/surface 边框)+ 深色模式修复
This commit is contained in:
@@ -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 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;
|
||||
Reference in New Issue
Block a user