前端代码
This commit is contained in:
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { themeVars } from '../vars'
|
||||
|
||||
export const unoTheme = themeVars
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user