20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
1 parent b6f9a4c912
commit 72993ecdcd
179 files changed
+20050 -633

No files matched your search

-196
View File
@@ -1,196 +0,0 @@
// 约定:对外一律 hex('#rrggbb'),内部算法用 RGB 数组 [r, g, b]。
// ---------- 配置 ----------
export const STORAGE_KEYS = {
mode: 'fms-theme',
color: 'fms-theme-color',
}
export const DEFAULT_MODE = 'light'
export const DEFAULT_COLOR = '#18181b'
export const PRESETS = [
{ name: 'Shadcn 中性', value: '#18181b' },
{ name: '蓝色', value: '#2563eb' },
{ name: '靛蓝', value: '#4f46e5' },
{ name: '紫色', value: '#7c3aed' },
{ name: '青色', value: '#0d9488' },
{ name: '绿色', value: '#16a34a' },
{ name: '橙色', value: '#ea580c' },
{ name: '红色', value: '#dc2626' },
{ name: '粉色', value: '#db2777' },
]
export const ALGORITHM = {
minContrast: 3, // 主色与背景最小对比度
hoverDelta: 0.06, // hover 明度偏移
activeDelta: 0.14, // active 明度偏移
borderMixRatio: 0.55, // 边框与背景混合比例
}
// 背景参照色:用于主色对比度兜底(浅色=纯白、深色=zinc-900,与原始实现保持一致)。
export const SURFACES = {
light: '#ffffff',
dark: '#18181b',
}
// 主色上文字的前景候选(白 / 近黑),取对比度更大者。
export const FOREGROUND_CANDIDATES = ['#ffffff', '#18181b']
// ---------- 颜色数学 ----------
export function normalizeHex(value) {
const color = String(value ?? '').trim().toLowerCase()
if (/^#[0-9a-f]{6}$/.test(color)) return color
if (/^#[0-9a-f]{3}$/.test(color)) {
const [red, green, blue] = color.slice(1)
return `#${red}${red}${green}${green}${blue}${blue}`
}
return null
}
export function hexToRgb(hex) {
return [
Number.parseInt(hex.slice(1, 3), 16),
Number.parseInt(hex.slice(3, 5), 16),
Number.parseInt(hex.slice(5, 7), 16),
]
}
export function rgbToHex([red, green, blue]) {
return `#${[red, green, blue]
.map((channel) => Math.round(channel).toString(16).padStart(2, '0'))
.join('')}`
}
export function rgbToHsl([red, green, blue]) {
const r = red / 255
const g = green / 255
const b = blue / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const lightness = (max + min) / 2
if (max === min) return { h: 0, s: 0, l: lightness }
const delta = max - min
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
let hue
if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0)
else if (max === g) hue = (b - r) / delta + 2
else hue = (r - g) / delta + 4
return { h: hue / 6, s: saturation, l: lightness }
}
function hueToRgb(p, q, t) {
let hue = t
if (hue < 0) hue += 1
if (hue > 1) hue -= 1
if (hue < 1 / 6) return p + (q - p) * 6 * hue
if (hue < 1 / 2) return q
if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6
return p
}
export function hslToRgb({ h, s, l }) {
if (s === 0) {
const value = Math.round(l * 255)
return [value, value, value]
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
return [
Math.round(hueToRgb(p, q, h + 1 / 3) * 255),
Math.round(hueToRgb(p, q, h) * 255),
Math.round(hueToRgb(p, q, h - 1 / 3) * 255),
]
}
export function adjustLightness(rgb, delta) {
const hsl = rgbToHsl(rgb)
return hslToRgb({ ...hsl, l: Math.min(1, Math.max(0, hsl.l + delta)) })
}
export function mixRgb(firstRgb, secondRgb, secondRatio) {
const firstRatio = 1 - secondRatio
return firstRgb.map((channel, index) =>
Math.round(channel * firstRatio + secondRgb[index] * secondRatio),
)
}
export function getRelativeLuminance(rgb) {
const [red, green, blue] = rgb.map((channel) => {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return red * 0.2126 + green * 0.7152 + blue * 0.0722
}
export function getContrastRatio(firstRgb, secondRgb) {
const firstLuminance = getRelativeLuminance(firstRgb)
const secondLuminance = getRelativeLuminance(secondRgb)
const lighter = Math.max(firstLuminance, secondLuminance)
const darker = Math.min(firstLuminance, secondLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
// ---------- 主色派生 ----------
function getForegroundRgb(themeRgb) {
const [light, dark] = FOREGROUND_CANDIDATES.map(hexToRgb)
const lightContrast = getContrastRatio(themeRgb, light)
const darkContrast = getContrastRatio(themeRgb, dark)
return lightContrast >= darkContrast ? light : dark
}
function ensurePrimaryContrast(rgb, backgroundRgb) {
if (getContrastRatio(rgb, backgroundRgb) >= ALGORITHM.minContrast) return rgb
const sourceLightness = rgbToHsl(rgb).l
for (let step = 1; step <= 100; step += 1) {
const delta = step / 100
const candidates = [adjustLightness(rgb, -delta), adjustLightness(rgb, delta)]
const candidate = candidates.find(
(item) => getContrastRatio(item, backgroundRgb) >= ALGORITHM.minContrast,
)
if (candidate) return candidate
}
return sourceLightness >= 0.5 ? adjustLightness(rgb, -0.5) : adjustLightness(rgb, 0.5)
}
export function createPrimaryTokens(color, mode) {
const sourceRgb = hexToRgb(color)
const backgroundRgb = hexToRgb(SURFACES[mode])
const primaryRgb = ensurePrimaryContrast(sourceRgb, backgroundRgb)
const primaryLightness = rgbToHsl(primaryRgb).l
const direction =
mode === 'dark' ? (primaryLightness > 0.85 ? -1 : 1) : primaryLightness < 0.15 ? 1 : -1
const hoverRgb = ensurePrimaryContrast(
adjustLightness(primaryRgb, direction * ALGORITHM.hoverDelta),
backgroundRgb,
)
const activeRgb = ensurePrimaryContrast(
adjustLightness(primaryRgb, direction * ALGORITHM.activeDelta),
backgroundRgb,
)
const primaryBorderRgb = mixRgb(primaryRgb, backgroundRgb, ALGORITHM.borderMixRatio)
return {
primary: rgbToHex(primaryRgb),
'primary-foreground': rgbToHex(getForegroundRgb(primaryRgb)),
'primary-hover': rgbToHex(hoverRgb),
'primary-active': rgbToHex(activeRgb),
'primary-border': rgbToHex(primaryBorderRgb),
ring: rgbToHex(primaryRgb),
}
}
+15
View File
@@ -0,0 +1,15 @@
// 主题应用纯函数 —— 操作 <html> 上的 class 与 CSS 变量
const root = () => document.documentElement
// 写入主色变量
export function applyPrimaryColor(color) {
if (color) {
root().style.setProperty('--fms-primary', color)
}
}
// 切换深浅色 class
export function applyDarkMode(isDark) {
root().classList.toggle('dark', !!isDark)
}
+15
View File
@@ -0,0 +1,15 @@
// 预设主色色板 —— 颜色值单源
export const PRESETS = [
{ name: '墨蓝', color: '#0f172b' },
{ name: '拂晓蓝', color: '#1677ff' },
{ name: '青碧', color: '#06b6d4' },
{ name: '翠绿', color: '#16a34a' },
{ name: '琥珀', color: '#f59e0b' },
{ name: '朱砂', color: '#ef4444' },
{ name: '暮紫', color: '#8b5cf6' },
{ name: '玫红', color: '#ec4899' },
{ name: '墨青', color: '#0f766e' },
]
// 默认主色(与 store 初始值一致)
export const DEFAULT_PRIMARY = PRESETS[0].color
+151
View File
@@ -0,0 +1,151 @@
/* ============================================================
* Design Tokens —— CSS 变量唯一运行时真相
* 浅色定义于 :root,深色通过 .dark 覆盖同一批变量。
* 主色 --fms-primary 由 JS 写入,衍生色用 color-mix() 派生。
* ============================================================ */
:root {
/* 中性色:背景 / 卡片 / 文字 / 次级 / 边框 */
--fms-background: #f5f6f8;
--fms-card: #ffffff;
--fms-text: #1f2329;
--fms-secondary: #e5e6eb;
--fms-secondary-hover: #f1f5f9;
--fms-text-secondary: #86909c;
--fms-border: #e5e6eb;
/* 禁用态(disabled) */
--fms-disabled-bg: #f2f3f5;
--fms-disabled-text: #c9cdd4;
--fms-disabled-border: #e5e6eb;
/* 固定语义色 */
--fms-success: #16a34a;
--fms-success-text: #ffffff;
--fms-warning: #f59e0b;
--fms-warning-text: #1f2329;
--fms-danger: #ef4444;
--fms-danger-text: #ffffff;
/* 主色系列:--fms-primary 由 JS 写入,其余派生 */
--fms-primary: #0f172b;
--fms-primary-text: #ffffff;
--fms-primary-hover: color-mix(in srgb, var(--fms-primary) 88%, #000);
--fms-primary-active: color-mix(in srgb, var(--fms-primary) 76%, #000);
/* 字体 */
--fms-font-size: 14px;
--fms-line-height: 20px;
/* 控件 */
--fms-control-height: 32px;
--fms-control-padding-x: 10px;
--fms-control-radius: 6px;
--fms-control-size: 16px;
/* 开关(switch)尺寸(不随主题变化) */
--fms-switch-width: 32px;
--fms-switch-height: 18px;
--fms-switch-thumb-size: 14px;
--fms-switch-thumb-offset: 2px;
/* 下拉(select)浮层列表最大高度(不随主题变化) */
--fms-select-dropdown-max-height: 256px;
/* 下拉菜单(dropdown)尺寸(不随主题变化) */
--fms-dropdown-min-width: 160px;
--fms-dropdown-max-height: 256px;
--fms-dropdown-item-padding-y: 5px;
--fms-dropdown-item-padding-x: 8px;
--fms-dropdown-item-radius: 4px;
--fms-dropdown-divider-margin: 4px 0;
/* 标签(tag)尺寸(不随主题变化) */
--fms-tag-height: 20px;
--fms-tag-font-size: 12px;
--fms-tag-padding-x: 8px;
--fms-tag-gap: 4px;
/* 加载(spin)尺寸(不随主题变化) */
--fms-spin-size: 20px;
--fms-spin-description-gap: 8px;
/* 表单(form):label 宽度 / 表单项间距 / 辅助提示字号(不随主题变化) */
--fms-form-label-width: 88px;
--fms-form-item-margin-bottom: 18px;
--fms-form-help-font-size: 12px;
/* 阴影 */
--fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
/* 浮层(popover / tooltip 等) */
--fms-popover-padding: 8px;
--fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
--fms-z-popover: 1000;
/* 轻提示 / 通知(message / notification) */
--fms-feedback-icon-size: 18px;
--fms-feedback-offset: 24px;
--fms-z-message: 2000;
--fms-z-notification: 2000;
/* 弹窗(modal / drawer) */
--fms-mask-bg: rgba(0, 0, 0, 0.45);
--fms-z-modal: 3000;
--fms-modal-padding: 16px 20px;
--fms-drawer-header-padding: 12px 20px;
--fms-drawer-body-padding: 12px 20px;
--fms-drawer-footer-padding: 8px 20px;
/* 分割(splitter):命中区 / 视觉条 / hover 线宽 / 中央手柄(不随主题变化) */
/* --fms-splitter-bar-size-hover 同时被 modal / drawer 缩放手柄的拖拽线引用 */
--fms-splitter-trigger-size: 6px;
/* 栅格(grid):默认水平/垂直间距(不随主题变化) */
--fms-grid-gutter: 0px;
--fms-splitter-bar-size: 2px;
--fms-splitter-bar-size-hover: 4px;
--fms-splitter-handle-size: 20px;
/* 树(tree):节点高度 / 缩进 / 图标 / 拖拽指示线(不随主题变化) */
--fms-tree-node-height: 32px;
--fms-tree-indent: 18px;
--fms-tree-icon-size: 16px;
--fms-tree-switcher-size: 22px;
--fms-tree-gutter-x: 4px;
--fms-tree-title-padding-x: 6px;
--fms-tree-grip-size: 16px;
--fms-tree-drop-line-size: 2px;
}
.dark {
/* 中性色 */
--fms-background: #0f1115;
--fms-card: #1a1d24;
--fms-text: #e6e8eb;
--fms-secondary: #262a33;
--fms-secondary-hover: #262a33;
--fms-text-secondary: #9aa3af;
--fms-border: #2a2f3a;
/* 禁用态 */
--fms-disabled-bg: #262a33;
--fms-disabled-text: #5b6472;
--fms-disabled-border: #2a2f3a;
/* 固定语义色(深色下提亮) */
--fms-success: #22c55e;
--fms-success-text: #0f1115;
--fms-warning: #fbbf24;
--fms-warning-text: #1f2329;
--fms-danger: #f87171;
--fms-danger-text: #0f1115;
/* 主色派生方向反转:深色下 hover 提亮 */
--fms-primary-hover: color-mix(in srgb, var(--fms-primary) 88%, #fff);
--fms-primary-active: color-mix(in srgb, var(--fms-primary) 76%, #fff);
/* 阴影(深色下加重投影) */
--fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.5);
}
-72
View File
@@ -1,72 +0,0 @@
import { ref } from 'vue'
import {
createPrimaryTokens,
DEFAULT_COLOR,
DEFAULT_MODE,
normalizeHex,
STORAGE_KEYS,
} from './color'
const theme = ref(DEFAULT_MODE)
const themeColor = ref(DEFAULT_COLOR)
function applyTheme() {
const root = document.documentElement
const tokens = createPrimaryTokens(themeColor.value, theme.value)
root.dataset.theme = theme.value
root.style.colorScheme = theme.value
for (const [name, value] of Object.entries(tokens)) {
root.style.setProperty(`--${name}`, value)
}
}
function readStorage(key) {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
function writeStorage(key, value) {
try {
window.localStorage.setItem(key, value)
} catch {
// storage 不可用时主题仍生效。
}
}
export function initTheme() {
const storedMode = readStorage(STORAGE_KEYS.mode)
const storedColor = normalizeHex(readStorage(STORAGE_KEYS.color))
theme.value = storedMode === 'dark' ? 'dark' : 'light'
themeColor.value = storedColor ?? DEFAULT_COLOR
applyTheme()
}
function setTheme(value) {
theme.value = value === 'dark' ? 'dark' : 'light'
applyTheme()
writeStorage(STORAGE_KEYS.mode, theme.value)
}
function setThemeColor(value) {
const color = normalizeHex(value)
if (!color) return false
themeColor.value = color
applyTheme()
writeStorage(STORAGE_KEYS.color, color)
return true
}
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
export function useTheme() {
return { theme, themeColor, setTheme, setThemeColor, toggleTheme }
}