前端代码

This commit is contained in:
2026-08-13 22:35:13 +08:00
parent 9044bafdff
commit f1a0b17bb0
412 changed files with 36769 additions and 6617 deletions
+52
View File
@@ -0,0 +1,52 @@
import { autoDarkColor } from '../color'
const HEADER_SHADOWS = {
light: {
none: 'none',
sm: '0 1px 0 rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04)',
md: '0 1px 0 rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08)',
lg: '0 1px 0 rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.12)'
},
dark: {
none: 'none',
sm: '0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35)',
md: '0 1px 0 rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.45)',
lg: '0 1px 0 rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.55)'
}
}
const TAB_SHADOWS = {
light: {
none: 'none',
sm: '0 1px 2px rgba(0, 21, 41, 0.08)',
md: '0 2px 8px rgba(0, 21, 41, 0.12)',
lg: '0 6px 18px rgba(0, 21, 41, 0.16)'
},
dark: {
none: 'none',
sm: '0 1px 3px rgba(0, 0, 0, 0.35)',
md: '0 2px 8px rgba(0, 0, 0, 0.45)',
lg: '0 6px 18px rgba(0, 0, 0, 0.55)'
}
}
const setOrClear = (style, key, value) => {
if (value === undefined || value === null || value === '') style.removeProperty(key)
else style.setProperty(key, value)
}
/**
* 应用顶栏 / 标签栏的背景与阴影到根节点 CSS 变量。
* @param {{ settings: object, isDark: boolean }} context
*/
export const applyChromeTheme = ({ settings, isDark }) => {
const style = document.documentElement.style
const mode = isDark ? 'dark' : 'light'
const headerBg = isDark ? autoDarkColor(settings.header.bg) : settings.header.bg
const tabBg = isDark ? autoDarkColor(settings.tab.bg) : settings.tab.bg
setOrClear(style, '--kra-header-bg', headerBg)
setOrClear(style, '--kra-tabs-bg', tabBg)
setOrClear(style, '--kra-header-shadow', HEADER_SHADOWS[mode][settings.header.shadow])
setOrClear(style, '--kra-tabs-shadow', TAB_SHADOWS[mode][settings.tab.shadow])
}
+47
View File
@@ -0,0 +1,47 @@
import { setElementPlusColor, setElementPlusPrimaryColor } from '../color'
export const BASE_ELCONF = {
zIndex: 2000,
button: { autoInsertSpace: false },
message: {
grouping: false,
plain: true,
}
}
/**
* 解析注入 `<el-config-provider>` 的配置。
*
* 注意:Provider 只能下发组件「行为」配置(size / zIndex / button / message 等),
* 无法覆盖颜色、圆角这类 CSS 变量——后者由 applyElementPlusTheme 直接写 --el-* 变量。
* 因此这里不再保留独立的 elConf 覆盖通道,size 直接取自 settings,其余为应用级常量。
*/
export const resolveElementPlusConfig = (settings) => ({
size: settings.size,
zIndex: BASE_ELCONF.zIndex,
button: { ...BASE_ELCONF.button },
message: { ...BASE_ELCONF.message }
})
/**
* 把主题色与圆角写入 Element Plus 的 CSS 变量(--el-color-* / 圆角)。
* @param {{ themeColors: object, settings: object, isDark: boolean }} context
*/
export const applyElementPlusTheme = ({ themeColors: colors, settings, isDark }) => {
const mode = isDark ? 'dark' : 'light'
const root = document.documentElement.style
const radius = `${settings.themeRadius}rem`
// Prism 把品牌色钉在 500(明暗两态一致),故传入的原始种子色即各语义色的 500 步,
// 与 UnoCSS 的 bg-primary / bg-success 同源,直接写入 --el-* 即可保持一致;mode 仅用于生成明暗变体。
setElementPlusPrimaryColor(colors.primary, mode)
setElementPlusColor('success', colors.success, mode)
setElementPlusColor('warning', colors.warning, mode)
setElementPlusColor('danger', colors.error, mode)
setElementPlusColor('error', colors.error, mode)
setElementPlusColor('info', colors.info, mode)
root.setProperty('--kra-radius', radius)
root.setProperty('--el-border-radius-base', radius)
root.setProperty('--el-card-border-radius', radius)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 运行时主题派发层。
*
* themeStore 算出主题上下文后,统一派发给下列「运行时适配器」。每个适配器接收
* 同一份 context({ settings, themeColors, isDark }),各自把主题落到对应目标:
* - applyCssVarsTheme -> 注入 :root / html.dark 的 CSS 变量(供 UnoCSS / 业务样式消费)
* - applyElementPlusTheme -> 写 Element Plus 的 --el-* 变量与圆角
* - applyChromeTheme -> 顶栏 / 标签栏背景与阴影
* - applyStructureTheme -> 菜单风格 / 卡片模式的 <html> class
*
* 接入新的 UI 体系(如 shadcn-vue)时,只需新增一个 `(context) => void` 适配器
* 并追加到 themeRuntimeAdapters,无需改动 themeStore。
*/
import { addThemeVarsToGlobal } from '../shared'
import { createThemeToken } from '../token'
import { applyChromeTheme } from './chrome'
import { applyElementPlusTheme } from './element-plus'
import { applyStructureTheme } from './structure'
const applyCssVarsTheme = ({ settings, themeColors }) => {
const { themeTokens, darkThemeTokens } = createThemeToken(themeColors, settings.tokens)
addThemeVarsToGlobal(themeTokens, darkThemeTokens)
}
export const themeRuntimeAdapters = [
applyCssVarsTheme,
applyElementPlusTheme,
applyChromeTheme,
applyStructureTheme
]
/**
* 把主题上下文派发给全部运行时适配器。
* @param {{ settings: object, themeColors: object, isDark: boolean }} context
*/
export const dispatchTheme = (context) => {
themeRuntimeAdapters.forEach((adapter) => adapter(context))
}
+11
View File
@@ -0,0 +1,11 @@
/**
* 通过 <html> class 切换卡片模式。
* (菜单风格已由 <g-menu> 的语义 token 驱动,不再需要 kra-menu--* 类。)
* @param {{ settings: object }} context
*/
export const applyStructureTheme = ({ settings }) => {
const html = document.documentElement
html.classList.remove('kra-card--border', 'kra-card--shadow')
html.classList.add(`kra-card--${settings.card.mode}`)
}
+3
View File
@@ -0,0 +1,3 @@
import { themeVars } from '../vars'
export const unoTheme = themeVars
+165
View File
@@ -0,0 +1,165 @@
const clamp = (value, min = 0, max = 255) =>
Math.min(max, Math.max(min, value))
export const colorToRgb = (color) => {
if (!color || typeof color !== 'string') return null
const value = color.trim()
if (value.startsWith('#')) {
let hex = value.slice(1)
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('')
if (hex.length !== 6 && hex.length !== 8) return null
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
}
}
const rgb = value.match(/rgba?\(([^)]+)\)/i)
if (rgb) {
const parts = rgb[1].split(',').map((item) => parseFloat(item.trim()))
if (parts.length < 3 || parts.some((item) => Number.isNaN(item))) return null
return {
r: parts[0],
g: parts[1],
b: parts[2],
a: parts[3] === undefined ? 1 : parts[3]
}
}
return null
}
export const rgbToHex = (r, g, b) => {
const parts = [r, g, b].map((item) => {
const value = clamp(Math.round(item)).toString(16)
return value.length === 1 ? `0${value}` : value
})
return `#${parts.join('')}`
}
export const colorToRgbChannels = (color) => {
const rgb = colorToRgb(color)
return rgb ? `${rgb.r} ${rgb.g} ${rgb.b}` : '0 0 0'
}
// 仅供本文件内 Element Plus 明暗变体(generateLightColor / generateDarkColor)使用;
// 主题色阶已由 Prism 生成,不再对外暴露此原语。
const mixColor = (color, target, amount) => {
const sourceRgb = colorToRgb(color)
const targetRgb = colorToRgb(target)
if (!sourceRgb || !targetRgb) return color
return rgbToHex(
sourceRgb.r * (1 - amount) + targetRgb.r * amount,
sourceRgb.g * (1 - amount) + targetRgb.g * amount,
sourceRgb.b * (1 - amount) + targetRgb.b * amount
)
}
export const addOpacityToColor = (color, opacity) => {
const rgb = colorToRgb(color)
if (!rgb) return color
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`
}
const rgbToHsl = (r, g, b) => {
r /= 255
g /= 255
b /= 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]
}
const hslToRgb = (h, s, l) => {
h /= 360
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
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(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255)
]
}
export const autoDarkColor = (color) => {
const rgb = colorToRgb(color)
if (!rgb) return color
const [h, s, l] = rgbToHsl(rgb.r, rgb.g, rgb.b)
const nextLightness = Math.min(0.45, Math.max(0.06, (1 - l) * 0.2 + 0.06))
const nextSaturation = Math.min(s, 0.6)
const [r, g, b] = hslToRgb(h, nextSaturation, nextLightness)
return `rgba(${r}, ${g}, ${b}, ${rgb.a})`
}
// Element Plus 明暗变体(--el-color-*-light-N / -dark-N):按官方算法向纯白/纯黑混合,
// 不再使用带蓝调的魔法锚点色(原 #f0f8ff / #0a0a1e)。
const generateDarkColor = (color, amount) => mixColor(color, '#000000', amount)
const generateLightColor = (color, amount) => mixColor(color, '#ffffff', amount)
/**
* Keep the current KRA/Element Plus palette behavior while moving ownership
* into the theme package. The adapter can later swap this implementation for
* a richer 50-950 color palette without touching callers.
*/
export const setElementPlusColor = (name, color, mode) => {
const createColor = mode === 'light' ? generateLightColor : generateDarkColor
const root = document.documentElement.style
root.setProperty(`--el-color-${name}`, color)
for (let times = 1; times <= 2; times++) {
root.setProperty(
`--el-color-${name}-dark-${times}`,
createColor(color, times / 10)
)
}
for (let times = 1; times <= 10; times++) {
root.setProperty(
`--el-color-${name}-light-${times}`,
createColor(color, times / 10)
)
}
}
export const setElementPlusPrimaryColor = (primaryColor, mode) => {
setElementPlusColor('primary', primaryColor, mode)
const root = document.documentElement.style
root.setProperty('--el-color-primary-bg', addOpacityToColor(primaryColor, 0.4))
root.setProperty('--el-menu-hover-bg-color', addOpacityToColor(primaryColor, 0.2))
}
+65
View File
@@ -0,0 +1,65 @@
import {
normalizeThemePreset
} from './shared'
const STORAGE_KEY = 'kra-theme-presets'
export {
buildThemePreset,
normalizeThemePreset
} from './shared'
export {
cloneThemeSettings
} from './settings'
export {
cacheThemeSettings,
clearCachedThemeSettings,
readCachedThemeSettings
} from './shared'
export {
resolveElementPlusConfig
} from './adapters/element-plus'
export {
KRA_MAIN_VERSION,
compareMainVersion,
isPresetCompatible
} from './version'
// Built-in presets are normalized at module boundary so the rest of the app
// consumes a normalized nested `theme` shape.
const presetModules = import.meta.glob('./preset/*.json', { eager: true })
export const BUILTIN_PRESETS = Object.values(presetModules)
.map((module) => normalizeThemePreset(module.default || module))
.filter(Boolean)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
export const loadCustomPresets = () => {
try {
const list = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
return Array.isArray(list) ? list.map(normalizeThemePreset).filter(Boolean) : []
} catch {
return []
}
}
const saveCustomPresets = (list) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
}
export const addCustomPreset = (preset) => {
const normalized = normalizeThemePreset(preset)
const list = loadCustomPresets().filter((item) => item.name !== normalized.name)
list.push(normalized)
saveCustomPresets(list)
return list
}
export const removeCustomPreset = (name) => {
const list = loadCustomPresets().filter((preset) => preset.name !== name)
saveCustomPresets(list)
return list
}
export const serializePreset = (preset) => JSON.stringify(normalizeThemePreset(preset), null, 2)
export const parsePreset = (text) => normalizeThemePreset(JSON.parse(text))
+62
View File
@@ -0,0 +1,62 @@
{
"order": 3,
"version": 3,
"minMainVersion": "2.9.0",
"name": "Azir-清新蓝",
"builtin": true,
"theme": {
"themeScheme": "auto",
"grayscale": false,
"colourWeakness": false,
"themeColor": "#2264f2",
"themeRadius": 0.5,
"size": "default",
"otherColor": {
"info": "#909399",
"success": "#60C041",
"warning": "#F9901F",
"error": "#F56C6C"
},
"layout": {
"mode": "vertical",
"sideWidth": 256,
"sideCollapsedWidth": 80,
"sideItemHeight": 48
},
"page": {
"transition": "slide"
},
"header": {
"breadcrumb": {
"visible": true,
"showIcon": true
},
"refresh": {
"visible": true
},
"search": {
"visible": true
},
"collapseButton": {
"visible": true
},
"bg": "rgba(255, 255, 255, 0)",
"shadow": "none"
},
"tab": {
"visible": true,
"shadow": "none",
"mode": "button",
"bg": "rgba(255, 255, 255, 0)"
},
"menu": {
"theme": "design"
},
"card": {
"mode": "border"
},
"watermark": {
"visible": false
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"order": 4,
"version": 3,
"minMainVersion": "2.9.0",
"name": "暗夜深色",
"builtin": true,
"theme": {
"themeScheme": "dark",
"themeColor": "#2264f2",
"themeRadius": 0.5,
"otherColor": {
"info": "#909399",
"success": "#60C041",
"warning": "#F9901F",
"error": "#F56C6C"
},
"menu": {
"theme": "dark"
},
"card": {
"mode": "shadow"
}
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"order": 2,
"version": 3,
"minMainVersion": "2.9.0",
"name": "KRA 经典蓝",
"builtin": true,
"theme": {
"themeScheme": "auto",
"grayscale": false,
"colourWeakness": false,
"themeColor": "#3b82f6",
"themeRadius": 0.25,
"size": "default",
"otherColor": {
"info": "#909399",
"success": "#67c23a",
"warning": "#e6a23c",
"error": "#f56c6c"
},
"layout": {
"mode": "normal",
"sideWidth": 256,
"sideCollapsedWidth": 80,
"sideItemHeight": 48
},
"page": {
"transition": "slide"
},
"header": {
"breadcrumb": {
"visible": true,
"showIcon": false
},
"refresh": {
"visible": true
},
"search": {
"visible": true
},
"collapseButton": {
"visible": true
},
"bg": "",
"shadow": "sm"
},
"tab": {
"visible": true,
"bg": ""
},
"menu": {
"theme": "light"
},
"card": {
"mode": "border"
},
"watermark": {
"visible": false
}
}
}
+104
View File
@@ -0,0 +1,104 @@
{
"order": 1,
"version": 1,
"minMainVersion": "2.9.0",
"name": "KRA-科技蓝",
"builtin": true,
"theme": {
"themeScheme": "auto",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#2264f2",
"themeRadius": 0.5,
"size": "default",
"otherColor": {
"info": "#909399",
"success": "#60c041",
"warning": "#f9901f",
"error": "#f56c6c"
},
"isInfoFollowPrimary": false,
"layout": {
"mode": "normal",
"sideWidth": 256,
"sideCollapsedWidth": 80,
"sideItemHeight": 48
},
"page": {
"transition": "slide"
},
"header": {
"breadcrumb": {
"visible": true,
"showIcon": true
},
"refresh": {
"visible": true
},
"search": {
"visible": true
},
"collapseButton": {
"visible": true
},
"bg": "",
"shadow": "sm"
},
"tab": {
"visible": true,
"bg": "",
"shadow": "sm",
"mode": "chrome",
"showIcon": true
},
"menu": {
"theme": "light",
"darkSider": false
},
"card": {
"mode": "border"
},
"watermark": {
"visible": false
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(15, 23, 42)",
"base-text": "rgb(31, 41, 55)",
"border": "rgb(229, 231, 235)",
"muted": "rgb(248, 250, 252)",
"muted-foreground": "rgb(100, 116, 139)",
"control-track": "rgb(209, 213, 219)"
},
"boxShadow": {
"header": "0 1px 0 rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04)",
"sider": "2px 0 8px 0 rgba(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgba(0, 21, 41, 0.08)",
"card": "0 1px 2px rgba(0, 0, 0, 0.04), 0 6px 18px rgba(0, 0, 0, 0.06)"
}
},
"dark": {
"colors": {
"container": "rgb(15, 23, 42)",
"layout": "rgb(30, 41, 59)",
"inverted": "rgb(255, 255, 255)",
"base-text": "rgb(226, 232, 240)",
"border": "rgb(51, 65, 85)",
"muted": "rgb(30, 41, 59)",
"muted-foreground": "rgb(148, 163, 184)",
"control-track": "rgb(71, 85, 105)"
},
"boxShadow": {
"header": "0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35)",
"sider": "2px 0 8px 0 rgba(0, 0, 0, 0.35)",
"tab": "0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35)",
"card": "0 1px 2px rgba(0, 0, 0, 0.28), 0 6px 18px rgba(0, 0, 0, 0.32)"
}
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Centralized theme settings.
*
* settings is the only durable theme source. Component libraries and runtime
* CSS variables are derived from it through adapters.
*/
/**
* 主题 settings 的 schema 版本。本次为大版本重构,不兼容旧的扁平主题数据。
* 持久化(本地缓存 / 后端 originSetting)统一写成 `{ version, settings }` 信封,
* 读取时只接受当前版本,版本不匹配或旧格式一律回落默认值。
* 未来 settings 结构再做破坏性调整时,递增此版本即可让旧数据自动失效。
*/
export const THEME_SETTINGS_VERSION = 1
export const themeSettings = {
themeScheme: 'auto',
grayscale: false,
colourWeakness: false,
themeColor: '#2264f2',
themeRadius: 0.5,
size: 'default',
otherColor: {
info: '#909399',
success: '#60c041',
warning: '#f9901f',
error: '#f56c6c'
},
isInfoFollowPrimary: false,
layout: {
mode: 'normal',
sideWidth: 256,
sideCollapsedWidth: 80,
sideItemHeight: 48
},
page: {
transition: 'slide'
},
header: {
breadcrumb: {
visible: true,
showIcon: true
},
refresh: {
visible: true
},
search: {
visible: true
},
collapseButton: {
visible: true
},
bg: '',
shadow: 'sm',
},
tab: {
visible: true,
bg: '',
// 标签栏阴影档位:none / sm / md / lg(与顶栏阴影同机制)
shadow: 'sm',
// 标签栏风格:button(描边胶囊) / chrome(浏览器标签) / slider(底部指示条)
mode: 'chrome',
// 是否在标签上展示路由图标
showIcon: true
},
menu: {
theme: 'light',
darkSider: false
},
card: {
mode: 'border'
},
watermark: {
visible: false
},
// 中性/表面色不再在此硬编码:container / layout / border / muted / base-text 等全部由
// Prism 的 neutral 色阶在 token.js 中派生(详见 createThemeToken)。此处只保留与颜色无关的阴影档位。
tokens: {
light: {
boxShadow: {
header: '0 1px 0 rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04)',
sider: '2px 0 8px 0 rgba(29, 35, 41, 0.05)',
tab: '0 1px 2px rgba(0, 21, 41, 0.08)',
card: '0 1px 2px rgba(0, 0, 0, 0.04), 0 6px 18px rgba(0, 0, 0, 0.06)'
}
},
dark: {
boxShadow: {
header: '0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35)',
sider: '2px 0 8px 0 rgba(0, 0, 0, 0.35)',
tab: '0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.35)',
card: '0 1px 2px rgba(0, 0, 0, 0.28), 0 6px 18px rgba(0, 0, 0, 0.32)'
}
}
}
}
export const cloneThemeSettings = () => structuredClone(themeSettings)
+207
View File
@@ -0,0 +1,207 @@
import { colorToRgbChannels } from './color'
import { cloneThemeSettings, themeSettings, THEME_SETTINGS_VERSION } from './settings'
import { KRA_MAIN_VERSION, PRESET_FALLBACK_MIN_VERSION } from './version'
import { themeVars } from './vars'
import { defu, isObject, liteClone } from 'arcdash'
export const THEME_SETTINGS_STORAGE_KEY = 'kra-theme-settings'
// 颜色统一小写:项目内部所有颜色字符串以小写为单一形态,避免默认值「#2264f2」
// 与预设「#2264f2」因大小写不等而出现选中态丢失。任何外部数据进入运行态前都过一遍。
export const normalizeColor = (value) =>
typeof value === 'string' ? value.toLowerCase() : value
// 把一份 settings 里的主题色字段统一小写(就地改写后返回同一对象)。
const normalizeThemeColors = (settings) => {
settings.themeColor = normalizeColor(settings.themeColor)
if (isObject(settings.otherColor)) {
Object.keys(settings.otherColor).forEach((key) => {
settings.otherColor[key] = normalizeColor(settings.otherColor[key])
})
}
return settings
}
// 把 source 深合并进 target(就地,供 themeStore 直接改写响应式 settings)。
// target / source 都来自完整 schema(默认值或 normalizeThemeSettings 输出),
// 未知字段的过滤已在 normalize 阶段由 pickKnownKeys 完成,这里无需再做白名单。
export const mergeThemeSettings = (target, source) => {
Object.entries(source || {}).forEach(([key, value]) => {
if (isObject(value) && isObject(target[key])) {
mergeThemeSettings(target[key], value)
return
}
target[key] = value
})
}
// 递归按默认 schema 过滤未知字段:schema 中不存在的键一律丢弃,
// 避免外部数据污染运行态、拖累 adapter 扩展。
const pickKnownKeys = (source, schema) => {
if (!isObject(source) || !isObject(schema)) return source
const result = {}
Object.keys(schema).forEach((key) => {
if (key in source) result[key] = pickKnownKeys(source[key], schema[key])
})
return result
}
const isThemeSettingsLike = (source) =>
isObject(source) && Boolean(
source.themeScheme ||
source.themeColor ||
source.otherColor ||
source.layout ||
source.page ||
source.header ||
source.tab ||
source.menu ||
source.card ||
source.watermark ||
source.tokens
)
/**
* 把任意外部主题数据(后端 originSetting、导入预设的内层 theme、本地缓存)规整成
* 一份完整、可信的 settings。兼容策略(大版本,不迁移旧值,只做防御式回落):
* - `{ version, settings }` 信封:版本不等于当前版本 → 回落默认值;否则取 settings 继续。
* - 旧的扁平主题数据(不含新结构字段)→ isThemeSettingsLike 为 false → 回落默认值。
* - 合法的新结构 → 先按 schema 过滤未知键(pickKnownKeys),再用 defu 覆盖到默认值之上
* (source 优先,缺失 / undefined 自动回落默认;未知键已被剔除,不会进入运行态)。
*/
// 值级迁移:老版本把「深色」当成一种菜单风格(menu.theme==='dark')。新版本里深色是与风格
// 正交的独立开关(menu.darkSider),这里把老值翻译过去,保住老用户外观。
const migrateLegacyDarkMenu = (settings) => {
if (settings?.menu?.theme === 'dark') {
settings.menu.theme = 'design'
settings.menu.darkSider = true
}
return settings
}
export const normalizeThemeSettings = (input) => {
// 带 version 字段的信封:只接受当前大版本,否则视为不兼容数据,回落默认。
if (isObject(input) && 'version' in input && input.version !== THEME_SETTINGS_VERSION) {
return cloneThemeSettings()
}
const source = input?.theme || input?.settings || input
if (!isThemeSettingsLike(source)) return cloneThemeSettings()
return migrateLegacyDarkMenu(
normalizeThemeColors(defu(pickKnownKeys(source, themeSettings), cloneThemeSettings()))
)
}
export const normalizeThemePreset = (preset) => {
if (!preset) return null
const source = preset.theme || preset.settings
if (!source) return null
// 丢弃旧预设里的 ElConf/elConf 与原始 settings 键,统一输出规范化的内层 theme。
const rest = { ...preset }
delete rest.theme
delete rest.settings
delete rest.ElConf
delete rest.elConf
return {
...rest,
// version 是预设自身的发布迭代号,沿用作者声明的值,缺省记为 1。
version: preset.version ?? 1,
// minMainVersion 才是与 KRA 主题生态的兼容判据;旧预设未声明时兜底为始终兼容。
minMainVersion: preset.minMainVersion ?? PRESET_FALLBACK_MIN_VERSION,
theme: normalizeThemeSettings(source)
}
}
export const buildThemePreset = (settings, name) => ({
// 用户导出当前配置 = 一个全新预设,发布号从 1 起;
// minMainVersion 记为导出时的运行版本,即「能用该预设的最低 KRA 版本」。
version: 1,
minMainVersion: KRA_MAIN_VERSION,
name,
builtin: false,
theme: liteClone(settings)
})
/** 主题持久化信封:本地缓存与后端 originSetting 共用同一形态,便于版本校验。 */
export const buildThemeEnvelope = (settings) => ({
version: THEME_SETTINGS_VERSION,
settings: liteClone(settings)
})
export const readCachedThemeSettings = () => {
try {
const raw = localStorage.getItem(THEME_SETTINGS_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
// 严格按版本信封读取:版本不匹配(含旧的无版本缓存)一律视为失效,交由调用方回落默认。
if (parsed?.version !== THEME_SETTINGS_VERSION) return null
return normalizeThemeSettings(parsed.settings)
} catch {
return null
}
}
export const cacheThemeSettings = (settings) => {
localStorage.setItem(
THEME_SETTINGS_STORAGE_KEY,
JSON.stringify(buildThemeEnvelope(settings))
)
}
export const clearCachedThemeSettings = () => {
localStorage.removeItem(THEME_SETTINGS_STORAGE_KEY)
}
const getCssVarByTokens = (tokens) => {
const styles = []
Object.entries(themeVars).forEach(([group, vars]) => {
Object.entries(vars).forEach(([tokenKey, tokenValue]) => {
let cssVarKey = tokenValue.replace('var(', '').replace(')', '')
let cssValue = tokens[group]?.[tokenKey]
if (cssValue === undefined) return
if (group === 'colors') {
cssVarKey = cssVarKey.replace('rgb(', '').replace(')', '')
cssValue = colorToRgbChannels(cssValue)
}
styles.push(`${cssVarKey}: ${cssValue}`)
})
})
return styles.join(';')
}
export const addThemeVarsToGlobal = (tokens, darkTokens) => {
const css = `:root { ${getCssVarByTokens(tokens)} }`
const darkCss = `html.dark { ${getCssVarByTokens(darkTokens)} }`
// 作用域暗色:浅色主题下给侧栏容器加 .kra-sider-dark,即让其子树的语义 token 翻成暗色。
// 与 html.dark 共用同一套 darkTokens,保证「浅色 + 深色侧栏」与「全局暗色侧栏」观感一致。
const scopedDarkCss = `.kra-sider-dark { ${getCssVarByTokens(darkTokens)} }`
const styleId = 'theme-vars'
const style = document.querySelector(`#${styleId}`) || document.createElement('style')
style.id = styleId
style.textContent = css + darkCss + scopedDarkCss
document.head.appendChild(style)
}
export const toggleCssDarkMode = (isDark) => {
document.documentElement.classList.toggle('dark', isDark)
document.documentElement.classList.toggle('light', !isDark)
}
export const toggleAuxiliaryColorModes = (grayscale, colourWeakness) => {
document.documentElement.classList.toggle('html-grey', grayscale)
document.documentElement.classList.toggle('html-weakenss', colourWeakness)
}
+100
View File
@@ -0,0 +1,100 @@
import { generateTheme } from '@simple-prism/core'
const colorKeys = ['primary', 'info', 'success', 'warning', 'error']
const paletteNumbers = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
export const getThemeColors = (settings) => ({
primary: settings.themeColor,
info: settings.isInfoFollowPrimary
? settings.themeColor
: settings.otherColor.info,
success: settings.otherColor.success,
warning: settings.otherColor.warning,
error: settings.otherColor.error
})
// 全部颜色交给 Prism(@simple-prism/core)生成:
// - 语义色板 primary/info/success/warning/error 各取 11 阶;
// - 中性/表面色 container/layout/border/muted/base-text 等取自 neutral 色阶(见 pickSurface)。
// 唯一的字面色是亮色 container 的纯白纸面——neutral 最浅阶仍偏灰,而抬升表面(卡片/侧栏/浮层)需纯白。
// generateTheme 一次算好 light+dark,这里只取 hex 喂回既有 token 管线,由 shared.js 统一
// 转成 `--x-color: r g b` 裸通道。
//
// dispatchTheme 可能因布局等非颜色设置变化被多次触发,故按 5 个基色的组合缓存,避免同色重复求解。
let paletteCache = { key: '', value: null }
// 中性/表面 token 取自 Prism neutral 色阶。neutral 是镜像色阶,同一阶号在明暗两态各给出正确色。
// 抬升表面 container 与页面画布 layout 的关系并非简单镜像:亮色下卡片是纯白、页面是最浅灰;
// 暗色下卡片是最深面、页面抬升一阶——故这两者按模式取值,其余明暗共用同一阶。
const pickSurface = (neutral, isLight) => {
const at = (step) => neutral.steps[step].hex
return {
container: isLight ? '#ffffff' : at(50), // 卡片/侧栏/浮层:亮=纸白,暗=最深面
layout: at(isLight ? 50 : 100), // 页面画布:位于容器之下一层
muted: at(100), // 弱化区块底色
border: at(200), // 描边
'control-track': at(300), // 开关/滑块未激活轨道
'base-text': neutral.textContrast.hex, // 主文本(APCA 高对比求解)
'muted-foreground': neutral.text.hex // 次文本(APCA 低对比求解)
}
}
const buildPalettes = (colors) => {
const key = colorKeys.map((name) => colors[name]).join('|')
if (paletteCache.key === key && paletteCache.value) return paletteCache.value
const theme = generateTheme({
primary: colors.primary,
info: colors.info,
success: colors.success,
warning: colors.warning,
error: colors.error
})
const pick = (mode) => {
const result = {}
colorKeys.forEach((name) => {
const scale = theme.scales[name][mode]
result[name] = scale.steps[500].hex
paletteNumbers.forEach((number) => {
result[`${name}-${number}`] = scale.steps[number].hex
})
})
Object.assign(result, pickSurface(theme.scales.neutral[mode], mode === 'light'))
return result
}
const value = { light: pick('light'), dark: pick('dark') }
paletteCache = { key, value }
return value
}
export const createThemeToken = (colors, tokens) => {
const palettes = buildPalettes(colors)
// 颜色(语义色板 + 中性/表面)已由 Prism 明暗两套色阶完整给出,直接整体铺开即可;
// settings.tokens 现在只提供与颜色无关的阴影档位。
const themeTokens = {
colors: {
...palettes.light,
nprogress: palettes.light.primary
},
boxShadow: { ...(tokens?.light?.boxShadow || {}) }
}
const darkThemeTokens = {
colors: {
...palettes.dark,
nprogress: palettes.dark.primary
},
boxShadow: {
...themeTokens.boxShadow,
...(tokens?.dark?.boxShadow || {})
}
}
return {
themeTokens,
darkThemeTokens
}
}
+36
View File
@@ -0,0 +1,36 @@
const colorNames = ['primary', 'info', 'success', 'warning', 'error']
const paletteNumbers = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
const createPaletteVars = () => {
const colors = {}
colorNames.forEach((name) => {
colors[name] = `rgb(var(--${name}-color))`
paletteNumbers.forEach((number) => {
colors[`${name}-${number}`] = `rgb(var(--${name}-${number}-color))`
})
})
return colors
}
export const themeVars = {
colors: {
...createPaletteVars(),
nprogress: 'rgb(var(--nprogress-color))',
container: 'rgb(var(--container-bg-color))',
layout: 'rgb(var(--layout-bg-color))',
'base-text': 'rgb(var(--base-text-color))',
border: 'rgb(var(--border-color))',
muted: 'rgb(var(--muted-color))',
'muted-foreground': 'rgb(var(--muted-foreground-color))',
// 控件「未激活轨道」表面:开关关闭态、滑块未填充轨道共用(亮/暗各自适配,替代裸 gray 色阶)
'control-track': 'rgb(var(--control-track-color))'
},
boxShadow: {
header: 'var(--header-box-shadow)',
sider: 'var(--sider-box-shadow)',
tab: 'var(--tab-box-shadow)',
card: 'var(--card-box-shadow)'
}
}
+40
View File
@@ -0,0 +1,40 @@
import { version as pkgVersion } from '../../package.json'
/**
* 当前 KRA 主版本号,取自 package.json,作为「主题生态」唯一事实来源。
*
* 注意区分两类版本:
* - 预设自身的 `version`:预设的发布迭代号,由预设作者维护,同一 KRA 版本下可反复
* 发布到几十、几百,与主题生态无关,仅用于展示 / 去重。
* - 预设的 `minMainVersion`:该预设适配的最低 KRA 主版本,用来判断它能否被当前
* KRA 应用——这才是跨版本兼容的判据。
*/
export const KRA_MAIN_VERSION = pkgVersion
/** 旧预设未声明 minMainVersion 时的兜底:视为始终兼容,避免误伤历史导出文件。 */
export const PRESET_FALLBACK_MIN_VERSION = '0.0.0'
const toSegments = (value) =>
String(value ?? '')
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
/** 语义化主版本比较:a < b → -1,a === b → 0,a > b → 1。缺位段按 0 处理。 */
export const compareMainVersion = (a, b) => {
const left = toSegments(a)
const right = toSegments(b)
const len = Math.max(left.length, right.length)
for (let i = 0; i < len; i++) {
const diff = (left[i] || 0) - (right[i] || 0)
if (diff !== 0) return diff > 0 ? 1 : -1
}
return 0
}
/** 预设是否适配当前 KRA:当前主版本 >= 预设要求的最低主版本。 */
export const isPresetCompatible = (preset) => {
const required = preset?.minMainVersion || PRESET_FALLBACK_MIN_VERSION
return compareMainVersion(KRA_MAIN_VERSION, required) >= 0
}