Files
rainblogweb/frontend/src/admin/theme.js
T

516 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;