516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
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;
|