@zl-ui-kit/theme 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,305 +1,305 @@
1
- /**
2
- * @zl-ui-kit/theme 运行时 API(需求 §7.2.3,D7)
3
- * L2:defineTheme 运行时定义主题 + setTheme/getTheme/useTheme 切换(可选持久化)
4
- * 自定义主题与内置主题变量完全同构(--zl-* + --el-*),components 无差别渲染
5
- */
6
-
7
- import themesDataRaw from '../generated/themes-data.json'
8
- import { generateElShades, mix } from './colors'
9
- import type { ComputedRef } from 'vue'
10
- import { computed, readonly, ref } from 'vue'
11
-
12
- /** 各主题解析后的 token 值表(构建期由 themegen 生成) */
13
- export const themesData = themesDataRaw as Record<string, Record<string, string>>
14
-
15
- /** 内置主题清单(数据源与 generated/constants.ts 一致) */
16
- export const THEMES = Object.keys(themesData) as ThemeName[]
17
-
18
- export type ThemeName = string
19
-
20
- export interface DefineThemeColors {
21
- /** 主色(EP primary + --zl-primary-6 联动派生) */
22
- primary?: string
23
- success?: string
24
- warning?: string
25
- danger?: string
26
- info?: string
27
- /** 页面底色(覆盖 --zl-bg-1..5 / --el-bg-*) */
28
- bg?: string
29
- }
30
-
31
- export interface DefineThemeOptions {
32
- /** 主题名(切换时使用) */
33
- name: string
34
- /** 以哪套内置主题为基线(默认 'light'),未指定的 token 全部继承 */
35
- base?: string
36
- /** 少量语义色定制,其余自动派生 */
37
- colors?: DefineThemeColors
38
- /** 任意 token 覆盖(键为原始 token 名,如 color-text-1 / color-modo-6) */
39
- tokens?: Record<string, string>
40
- /** 是否立即切换到该主题 */
41
- apply?: boolean
42
- }
43
-
44
- const THEME_STORAGE_KEY = 'zl-theme'
45
- const customThemes = new Map<string, { cssText: string }>()
46
- const currentTheme = ref<string>(initThemeFromStorage())
47
- const persistenceEnabled = ref(false)
48
- const changeListeners = new Set<(theme: string) => void>()
49
-
50
- function initThemeFromStorage(): string {
51
- try {
52
- return localStorage.getItem(THEME_STORAGE_KEY) || 'light'
53
- } catch {
54
- return 'light'
55
- }
56
- }
57
-
58
- /** 生成某主题完整变量声明文本(--zl-* + --el-* + 布局资产变量) */
59
- function buildCssVars(resolved: Record<string, string>, themeName: string, baseName = themeName): string {
60
- const isDark = themeName.includes('dark') || baseName.includes('dark')
61
- const get = (k: string, fallback = '') => resolved[`color-${k}`] ?? fallback
62
- const getLevel = (name: string, level: number) => resolved[`color-${name}-${level}`] ?? ''
63
-
64
- const decls: string[] = []
65
-
66
- // --zl-* 业务层
67
- for (const [key, value] of Object.entries(resolved)) {
68
- decls.push(`--zl-${key.replace(/^color-/, '')}: ${value};`)
69
- }
70
- for (let i = 1; i <= 10; i++) {
71
- decls.push(`--zl-primary-${i}: ${getLevel('modo', i)};`)
72
- decls.push(`--zl-link-${i}: ${getLevel('link', i)};`)
73
- }
74
-
75
- // --el-* EP 层
76
- const semantic: Array<[string, string]> = [
77
- ['primary', getLevel('modo', 6)],
78
- ['success', getLevel('green', 6)],
79
- ['warning', getLevel('orange', 6)],
80
- ['danger', getLevel('red', 6)],
81
- // EP 2.14 起 error 是规范名(el-tag--error / el-message--error 走它),
82
- // 只给 danger 会让这些组件回落到 EP 默认浅色底,深色主题下尤其刺眼
83
- ['error', getLevel('red', 6)],
84
- ['info', getLevel('gray', 6)],
85
- ]
86
- for (const [name, hex] of semantic) {
87
- decls.push(`--el-color-${name}: ${hex};`)
88
- for (const [shade, value] of Object.entries(generateElShades(hex))) {
89
- decls.push(`--el-color-${name}-${shade}: ${value};`)
90
- }
91
- }
92
-
93
- const textMap: Record<string, string> = {
94
- primary: 'text-1',
95
- regular: 'text-2',
96
- secondary: 'text-3',
97
- placeholder: 'text-4',
98
- // 没有 text-5:留空会产出 `--el-text-color-disabled: ;` 这种非法声明,
99
- // 浏览器整条丢弃,EP 内部又会回落默认值 —— 直接复用 placeholder
100
- disabled: 'text-4',
101
- }
102
- for (const [el, token] of Object.entries(textMap)) decls.push(`--el-text-color-${el}: ${get(token)};`)
103
-
104
- const borderMap: Array<[string, string]> = [
105
- ['extra-light', 'border-1'],
106
- ['lighter', 'border-2'],
107
- ['light', 'border-3'],
108
- ['', 'border-4'],
109
- ['dark', 'border-4'],
110
- ['darker', 'border-4'],
111
- ]
112
- for (const [suffix, token] of borderMap)
113
- decls.push(`--el-border-color${suffix ? `-${suffix}` : ''}: ${get(token)};`)
114
-
115
- const fillMap: Array<[string, string]> = [
116
- ['extra-light', 'fill-1'],
117
- ['lighter', 'fill-2'],
118
- ['light', 'fill-3'],
119
- ['', 'fill-4'],
120
- ['dark', 'fill-4'],
121
- ]
122
- for (const [suffix, token] of fillMap)
123
- decls.push(`--el-fill-color${suffix ? `-${suffix}` : ''}: ${get(token)};`)
124
- decls.push(`--el-fill-color-blank: ${isDark ? get('bg-2') : '#ffffff'};`)
125
-
126
- decls.push(`--el-bg-color: ${get('bg-2')};`)
127
- decls.push(`--el-bg-color-page: ${get('bg-1')};`)
128
- decls.push(`--el-bg-color-overlay: ${get('bg-3')};`)
129
- decls.push(`--el-color-white: ${isDark ? '#141414' : '#ffffff'};`)
130
- decls.push(`--el-color-black: ${isDark ? '#ffffff' : '#000000'};`)
131
- decls.push(`--el-mask-color: rgb(0, 0, 0, ${isDark ? '0.8' : '0.5'});`)
132
- decls.push(`color-scheme: ${isDark ? 'dark' : 'light'};`)
133
- // EP 圆角 ← 形状 token(与内置主题产物同构):`defineTheme({ tokens: { 'radius-base': ... } })`
134
- // 即可同时驱动 kit 组件与 Element Plus 控件,宿主不必再用 CSS 选择器抢优先级。
135
- // 基线 radius-base 4px / radius-small 2px 与 EP 默认值相同,内置主题零视觉变化。
136
- decls.push(`--el-border-radius-base: ${get('radius-base')};`)
137
- decls.push(`--el-border-radius-small: ${get('radius-small')};`)
138
-
139
- // 布局 / 菜单资产变量:内置主题由 scripts/themegen.mjs 产出,运行时主题必须同构,
140
- // 否则 defineTheme 造出的主题会把侧栏与菜单底色整块丢掉(退化成透明)。
141
- // 浅色/深色基线用弱对比底色;以彩色主题(red-light/blue…)为基线时侧栏是深色渐变,
142
- // 菜单文字必须转白 —— 所以这里要看基线名,不能只看自定义主题名。
143
- const isPlainBase = baseName === 'light' || baseName === 'dark'
144
- decls.push(`--zl-menu-hover-bg: ${isPlainBase ? 'var(--zl-fill-2)' : 'rgba(255, 255, 255, 0.2)'};`)
145
- decls.push(`--zl-menu-text-color: ${isPlainBase ? 'var(--zl-text-1)' : 'var(--zl-white)'};`)
146
- decls.push(`--zl-menu-trigger-arrow-color: ${isDark ? '#253044' : '#f6fafc'};`)
147
- decls.push(`--zl-menu-trigger-bg: linear-gradient(135deg, var(--zl-modo-5), var(--zl-modo-7));`)
148
- decls.push(
149
- `--zl-layout-vertical-aside-bg: linear-gradient(180deg, var(--zl-modo-7) 0%, var(--zl-modo-9) 100%);`,
150
- )
151
- decls.push(
152
- `--zl-layout-horizontal-header-bg: linear-gradient(90deg, var(--zl-modo-7) 0%, var(--zl-modo-6) 60%, var(--zl-modo-5) 100%);`,
153
- )
154
-
155
- return decls.join('\n ')
156
- }
157
-
158
- function injectStyle(name: string, cssText: string) {
159
- const styleId = `zl-theme-style-${name}`
160
- let style = document.getElementById(styleId) as HTMLStyleElement | null
161
- if (!style) {
162
- style = document.createElement('style')
163
- style.id = styleId
164
- document.head.appendChild(style)
165
- }
166
- style.textContent = cssText
167
- }
168
-
169
- /**
170
- * 运行时定义主题(L2)
171
- * 以某内置主题为基线,覆盖语义色后自动派生 EP 色阶与业务变量并注入样式
172
- */
173
- export function defineTheme(options: DefineThemeOptions): void {
174
- const { name, base = 'light', colors = {}, tokens = {}, apply = false } = options
175
- if (THEMES.includes(name) && !customThemes.has(name)) {
176
- throw new Error(`主题 ${name} 为内置主题,不可覆盖`)
177
- }
178
-
179
- // 基线数据克隆
180
- const resolved: Record<string, string> = { ...(themesData[base] ?? themesData.light) }
181
- for (const [key, value] of Object.entries(tokens)) {
182
- resolved[`color-${key.replace(/^color-/, '')}`] = value
183
- }
184
-
185
- // 形状 / 字体 token:与内置主题同构产出(值表与 scripts/themegen.mjs 的 SHAPE_TOKENS 一致,
186
- // 两边漂移会让 `defineTheme` 造出的主题与内置主题观感不同)。
187
- // 组件里写的是 `var(--zl-radius-large, 6px)` 这类带兜底的形式,此前这些 token 从未产出,
188
- // 等于「值被兜底写死、宿主改不了」。用 `??=` 播种:`tokens: { 'radius-large': '10px' }` 这类覆盖仍然生效。
189
- const shapeTokens: Record<string, string> = {
190
- 'radius-small': '2px',
191
- 'radius-base': '4px',
192
- 'radius-medium': '4px',
193
- 'radius-large': '6px',
194
- 'radius-xl': '12px',
195
- 'radius-round': '99px',
196
- 'font-family-code': "Consolas, 'SFMono-Regular', Menlo, monospace",
197
- 'slogan-font-family': "'PingFang SC', 'Microsoft YaHei', sans-serif",
198
- }
199
- for (const [key, value] of Object.entries(shapeTokens)) {
200
- resolved[`color-${key}`] ??= value
201
- }
202
-
203
- const colorEntries: Array<[keyof DefineThemeColors, string, string]> = [
204
- ['primary', 'modo', '6'],
205
- ['success', 'green', '6'],
206
- ['warning', 'orange', '6'],
207
- ['danger', 'red', '6'],
208
- ['info', 'gray', '6'],
209
- ]
210
- for (const [semantic, palette, level] of colorEntries) {
211
- const hex = colors[semantic]
212
- if (hex) {
213
- resolved[`color-${palette}-${level}`] = hex
214
- // 主色同时派生整组色板(浅→白、深→黑,各 10 级),保证 --zl-{palette}-N 可用
215
- const seedLevel = Number(level)
216
- for (let i = 1; i <= 10; i++) {
217
- if (i === seedLevel) continue
218
- const weight = (Math.abs(i - seedLevel) / 5) * 50
219
- resolved[`color-${palette}-${i}`] =
220
- i < seedLevel ? mix('#ffffff', hex, 100 - weight) : mix('#000000', hex, 100 - weight)
221
- }
222
- }
223
- }
224
-
225
- if (colors.bg) {
226
- resolved['color-bg-1'] = colors.bg
227
- resolved['color-bg-2'] = colors.bg
228
- }
229
-
230
- const cssText = `body[data-theme='${name}'] {\n ${buildCssVars(resolved, name, base)}\n}`
231
- injectStyle(name, cssText)
232
- customThemes.set(name, { cssText })
233
- if (!THEMES.includes(name)) {
234
- THEMES.push(name)
235
- }
236
-
237
- if (apply) {
238
- setTheme(name)
239
- }
240
- }
241
-
242
- export interface SetThemeOptions {
243
- /** 本次切换是否持久化(需已启用持久化或显式指定) */
244
- persist?: boolean
245
- }
246
-
247
- /** 切换主题(内置/自定义均可;light 作用于 body 本身) */
248
- export function setTheme(name: string, options: SetThemeOptions = {}): void {
249
- currentTheme.value = name
250
- if (name === 'light') {
251
- document.body.removeAttribute('data-theme')
252
- } else {
253
- document.body.setAttribute('data-theme', name)
254
- }
255
-
256
- if (persistenceEnabled.value || options.persist) {
257
- try {
258
- localStorage.setItem(THEME_STORAGE_KEY, name)
259
- } catch {
260
- // storage 不可用时忽略
261
- }
262
- }
263
-
264
- changeListeners.forEach((fn) => fn(name))
265
- }
266
-
267
- /** 当前主题名 */
268
- export function getTheme(): string {
269
- return currentTheme.value
270
- }
271
-
272
- /** 全部可用主题(内置 + 运行时自定义) */
273
- export function getThemes(): string[] {
274
- return [...THEMES]
275
- }
276
-
277
- /** 开启主题持久化(同一浏览器记住所选主题) */
278
- export function enableThemePersistence() {
279
- persistenceEnabled.value = true
280
- setTheme(currentTheme.value, { persist: true })
281
- }
282
-
283
- /** 主题变化监听 */
284
- export function onThemeChange(callback: (theme: string) => void): () => void {
285
- changeListeners.add(callback)
286
- return () => changeListeners.delete(callback)
287
- }
288
-
289
- export interface UseThemeReturn {
290
- /** 当前主题(只读 ref) */
291
- theme: ComputedRef<string>
292
- /** 可用主题列表(只读) */
293
- themes: ComputedRef<string[]>
294
- /** 切换主题 */
295
- setTheme: (name: string, options?: SetThemeOptions) => void
296
- }
297
-
298
- /** 主题 composable(配合 defineTheme 实现运行时换肤) */
299
- export function useTheme(): UseThemeReturn {
300
- return {
301
- theme: readonly(currentTheme) as unknown as ComputedRef<string>,
302
- themes: computed(() => getThemes()),
303
- setTheme,
304
- }
305
- }
1
+ /**
2
+ * @zl-ui-kit/theme 运行时 API(需求 §7.2.3,D7)
3
+ * L2:defineTheme 运行时定义主题 + setTheme/getTheme/useTheme 切换(可选持久化)
4
+ * 自定义主题与内置主题变量完全同构(--zl-* + --el-*),components 无差别渲染
5
+ */
6
+
7
+ import themesDataRaw from '../generated/themes-data.json'
8
+ import { generateElShades, mix } from './colors'
9
+ import type { ComputedRef } from 'vue'
10
+ import { computed, readonly, ref } from 'vue'
11
+
12
+ /** 各主题解析后的 token 值表(构建期由 themegen 生成) */
13
+ export const themesData = themesDataRaw as Record<string, Record<string, string>>
14
+
15
+ /** 内置主题清单(数据源与 generated/constants.ts 一致) */
16
+ export const THEMES = Object.keys(themesData) as ThemeName[]
17
+
18
+ export type ThemeName = string
19
+
20
+ export interface DefineThemeColors {
21
+ /** 主色(EP primary + --zl-primary-6 联动派生) */
22
+ primary?: string
23
+ success?: string
24
+ warning?: string
25
+ danger?: string
26
+ info?: string
27
+ /** 页面底色(覆盖 --zl-bg-1..5 / --el-bg-*) */
28
+ bg?: string
29
+ }
30
+
31
+ export interface DefineThemeOptions {
32
+ /** 主题名(切换时使用) */
33
+ name: string
34
+ /** 以哪套内置主题为基线(默认 'light'),未指定的 token 全部继承 */
35
+ base?: string
36
+ /** 少量语义色定制,其余自动派生 */
37
+ colors?: DefineThemeColors
38
+ /** 任意 token 覆盖(键为原始 token 名,如 color-text-1 / color-modo-6) */
39
+ tokens?: Record<string, string>
40
+ /** 是否立即切换到该主题 */
41
+ apply?: boolean
42
+ }
43
+
44
+ const THEME_STORAGE_KEY = 'zl-theme'
45
+ const customThemes = new Map<string, { cssText: string }>()
46
+ const currentTheme = ref<string>(initThemeFromStorage())
47
+ const persistenceEnabled = ref(false)
48
+ const changeListeners = new Set<(theme: string) => void>()
49
+
50
+ function initThemeFromStorage(): string {
51
+ try {
52
+ return localStorage.getItem(THEME_STORAGE_KEY) || 'light'
53
+ } catch {
54
+ return 'light'
55
+ }
56
+ }
57
+
58
+ /** 生成某主题完整变量声明文本(--zl-* + --el-* + 布局资产变量) */
59
+ function buildCssVars(resolved: Record<string, string>, themeName: string, baseName = themeName): string {
60
+ const isDark = themeName.includes('dark') || baseName.includes('dark')
61
+ const get = (k: string, fallback = '') => resolved[`color-${k}`] ?? fallback
62
+ const getLevel = (name: string, level: number) => resolved[`color-${name}-${level}`] ?? ''
63
+
64
+ const decls: string[] = []
65
+
66
+ // --zl-* 业务层
67
+ for (const [key, value] of Object.entries(resolved)) {
68
+ decls.push(`--zl-${key.replace(/^color-/, '')}: ${value};`)
69
+ }
70
+ for (let i = 1; i <= 10; i++) {
71
+ decls.push(`--zl-primary-${i}: ${getLevel('modo', i)};`)
72
+ decls.push(`--zl-link-${i}: ${getLevel('link', i)};`)
73
+ }
74
+
75
+ // --el-* EP 层
76
+ const semantic: Array<[string, string]> = [
77
+ ['primary', getLevel('modo', 6)],
78
+ ['success', getLevel('green', 6)],
79
+ ['warning', getLevel('orange', 6)],
80
+ ['danger', getLevel('red', 6)],
81
+ // EP 2.14 起 error 是规范名(el-tag--error / el-message--error 走它),
82
+ // 只给 danger 会让这些组件回落到 EP 默认浅色底,深色主题下尤其刺眼
83
+ ['error', getLevel('red', 6)],
84
+ ['info', getLevel('gray', 6)],
85
+ ]
86
+ for (const [name, hex] of semantic) {
87
+ decls.push(`--el-color-${name}: ${hex};`)
88
+ for (const [shade, value] of Object.entries(generateElShades(hex))) {
89
+ decls.push(`--el-color-${name}-${shade}: ${value};`)
90
+ }
91
+ }
92
+
93
+ const textMap: Record<string, string> = {
94
+ primary: 'text-1',
95
+ regular: 'text-2',
96
+ secondary: 'text-3',
97
+ placeholder: 'text-4',
98
+ // 没有 text-5:留空会产出 `--el-text-color-disabled: ;` 这种非法声明,
99
+ // 浏览器整条丢弃,EP 内部又会回落默认值 —— 直接复用 placeholder
100
+ disabled: 'text-4',
101
+ }
102
+ for (const [el, token] of Object.entries(textMap)) decls.push(`--el-text-color-${el}: ${get(token)};`)
103
+
104
+ const borderMap: Array<[string, string]> = [
105
+ ['extra-light', 'border-1'],
106
+ ['lighter', 'border-2'],
107
+ ['light', 'border-3'],
108
+ ['', 'border-4'],
109
+ ['dark', 'border-4'],
110
+ ['darker', 'border-4'],
111
+ ]
112
+ for (const [suffix, token] of borderMap)
113
+ decls.push(`--el-border-color${suffix ? `-${suffix}` : ''}: ${get(token)};`)
114
+
115
+ const fillMap: Array<[string, string]> = [
116
+ ['extra-light', 'fill-1'],
117
+ ['lighter', 'fill-2'],
118
+ ['light', 'fill-3'],
119
+ ['', 'fill-4'],
120
+ ['dark', 'fill-4'],
121
+ ]
122
+ for (const [suffix, token] of fillMap)
123
+ decls.push(`--el-fill-color${suffix ? `-${suffix}` : ''}: ${get(token)};`)
124
+ decls.push(`--el-fill-color-blank: ${isDark ? get('bg-2') : '#ffffff'};`)
125
+
126
+ decls.push(`--el-bg-color: ${get('bg-2')};`)
127
+ decls.push(`--el-bg-color-page: ${get('bg-1')};`)
128
+ decls.push(`--el-bg-color-overlay: ${get('bg-3')};`)
129
+ decls.push(`--el-color-white: ${isDark ? '#141414' : '#ffffff'};`)
130
+ decls.push(`--el-color-black: ${isDark ? '#ffffff' : '#000000'};`)
131
+ decls.push(`--el-mask-color: rgb(0, 0, 0, ${isDark ? '0.8' : '0.5'});`)
132
+ decls.push(`color-scheme: ${isDark ? 'dark' : 'light'};`)
133
+ // EP 圆角 ← 形状 token(与内置主题产物同构):`defineTheme({ tokens: { 'radius-base': ... } })`
134
+ // 即可同时驱动 kit 组件与 Element Plus 控件,宿主不必再用 CSS 选择器抢优先级。
135
+ // 基线 radius-base 4px / radius-small 2px 与 EP 默认值相同,内置主题零视觉变化。
136
+ decls.push(`--el-border-radius-base: ${get('radius-base')};`)
137
+ decls.push(`--el-border-radius-small: ${get('radius-small')};`)
138
+
139
+ // 布局 / 菜单资产变量:内置主题由 scripts/themegen.mjs 产出,运行时主题必须同构,
140
+ // 否则 defineTheme 造出的主题会把侧栏与菜单底色整块丢掉(退化成透明)。
141
+ // 浅色/深色基线用弱对比底色;以彩色主题(red-light/blue…)为基线时侧栏是深色渐变,
142
+ // 菜单文字必须转白 —— 所以这里要看基线名,不能只看自定义主题名。
143
+ const isPlainBase = baseName === 'light' || baseName === 'dark'
144
+ decls.push(`--zl-menu-hover-bg: ${isPlainBase ? 'var(--zl-fill-2)' : 'rgba(255, 255, 255, 0.2)'};`)
145
+ decls.push(`--zl-menu-text-color: ${isPlainBase ? 'var(--zl-text-1)' : 'var(--zl-white)'};`)
146
+ decls.push(`--zl-menu-trigger-arrow-color: ${isDark ? '#253044' : '#f6fafc'};`)
147
+ decls.push(`--zl-menu-trigger-bg: linear-gradient(135deg, var(--zl-modo-5), var(--zl-modo-7));`)
148
+ decls.push(
149
+ `--zl-layout-vertical-aside-bg: linear-gradient(180deg, var(--zl-modo-7) 0%, var(--zl-modo-9) 100%);`,
150
+ )
151
+ decls.push(
152
+ `--zl-layout-horizontal-header-bg: linear-gradient(90deg, var(--zl-modo-7) 0%, var(--zl-modo-6) 60%, var(--zl-modo-5) 100%);`,
153
+ )
154
+
155
+ return decls.join('\n ')
156
+ }
157
+
158
+ function injectStyle(name: string, cssText: string) {
159
+ const styleId = `zl-theme-style-${name}`
160
+ let style = document.getElementById(styleId) as HTMLStyleElement | null
161
+ if (!style) {
162
+ style = document.createElement('style')
163
+ style.id = styleId
164
+ document.head.appendChild(style)
165
+ }
166
+ style.textContent = cssText
167
+ }
168
+
169
+ /**
170
+ * 运行时定义主题(L2)
171
+ * 以某内置主题为基线,覆盖语义色后自动派生 EP 色阶与业务变量并注入样式
172
+ */
173
+ export function defineTheme(options: DefineThemeOptions): void {
174
+ const { name, base = 'light', colors = {}, tokens = {}, apply = false } = options
175
+ if (THEMES.includes(name) && !customThemes.has(name)) {
176
+ throw new Error(`主题 ${name} 为内置主题,不可覆盖`)
177
+ }
178
+
179
+ // 基线数据克隆
180
+ const resolved: Record<string, string> = { ...(themesData[base] ?? themesData.light) }
181
+ for (const [key, value] of Object.entries(tokens)) {
182
+ resolved[`color-${key.replace(/^color-/, '')}`] = value
183
+ }
184
+
185
+ // 形状 / 字体 token:与内置主题同构产出(值表与 scripts/themegen.mjs 的 SHAPE_TOKENS 一致,
186
+ // 两边漂移会让 `defineTheme` 造出的主题与内置主题观感不同)。
187
+ // 组件里写的是 `var(--zl-radius-large, 6px)` 这类带兜底的形式,此前这些 token 从未产出,
188
+ // 等于「值被兜底写死、宿主改不了」。用 `??=` 播种:`tokens: { 'radius-large': '10px' }` 这类覆盖仍然生效。
189
+ const shapeTokens: Record<string, string> = {
190
+ 'radius-small': '2px',
191
+ 'radius-base': '4px',
192
+ 'radius-medium': '4px',
193
+ 'radius-large': '6px',
194
+ 'radius-xl': '12px',
195
+ 'radius-round': '99px',
196
+ 'font-family-code': "Consolas, 'SFMono-Regular', Menlo, monospace",
197
+ 'slogan-font-family': "'PingFang SC', 'Microsoft YaHei', sans-serif",
198
+ }
199
+ for (const [key, value] of Object.entries(shapeTokens)) {
200
+ resolved[`color-${key}`] ??= value
201
+ }
202
+
203
+ const colorEntries: Array<[keyof DefineThemeColors, string, string]> = [
204
+ ['primary', 'modo', '6'],
205
+ ['success', 'green', '6'],
206
+ ['warning', 'orange', '6'],
207
+ ['danger', 'red', '6'],
208
+ ['info', 'gray', '6'],
209
+ ]
210
+ for (const [semantic, palette, level] of colorEntries) {
211
+ const hex = colors[semantic]
212
+ if (hex) {
213
+ resolved[`color-${palette}-${level}`] = hex
214
+ // 主色同时派生整组色板(浅→白、深→黑,各 10 级),保证 --zl-{palette}-N 可用
215
+ const seedLevel = Number(level)
216
+ for (let i = 1; i <= 10; i++) {
217
+ if (i === seedLevel) continue
218
+ const weight = (Math.abs(i - seedLevel) / 5) * 50
219
+ resolved[`color-${palette}-${i}`] =
220
+ i < seedLevel ? mix('#ffffff', hex, 100 - weight) : mix('#000000', hex, 100 - weight)
221
+ }
222
+ }
223
+ }
224
+
225
+ if (colors.bg) {
226
+ resolved['color-bg-1'] = colors.bg
227
+ resolved['color-bg-2'] = colors.bg
228
+ }
229
+
230
+ const cssText = `body[data-theme='${name}'] {\n ${buildCssVars(resolved, name, base)}\n}`
231
+ injectStyle(name, cssText)
232
+ customThemes.set(name, { cssText })
233
+ if (!THEMES.includes(name)) {
234
+ THEMES.push(name)
235
+ }
236
+
237
+ if (apply) {
238
+ setTheme(name)
239
+ }
240
+ }
241
+
242
+ export interface SetThemeOptions {
243
+ /** 本次切换是否持久化(需已启用持久化或显式指定) */
244
+ persist?: boolean
245
+ }
246
+
247
+ /** 切换主题(内置/自定义均可;light 作用于 body 本身) */
248
+ export function setTheme(name: string, options: SetThemeOptions = {}): void {
249
+ currentTheme.value = name
250
+ if (name === 'light') {
251
+ document.body.removeAttribute('data-theme')
252
+ } else {
253
+ document.body.setAttribute('data-theme', name)
254
+ }
255
+
256
+ if (persistenceEnabled.value || options.persist) {
257
+ try {
258
+ localStorage.setItem(THEME_STORAGE_KEY, name)
259
+ } catch {
260
+ // storage 不可用时忽略
261
+ }
262
+ }
263
+
264
+ changeListeners.forEach((fn) => fn(name))
265
+ }
266
+
267
+ /** 当前主题名 */
268
+ export function getTheme(): string {
269
+ return currentTheme.value
270
+ }
271
+
272
+ /** 全部可用主题(内置 + 运行时自定义) */
273
+ export function getThemes(): string[] {
274
+ return [...THEMES]
275
+ }
276
+
277
+ /** 开启主题持久化(同一浏览器记住所选主题) */
278
+ export function enableThemePersistence() {
279
+ persistenceEnabled.value = true
280
+ setTheme(currentTheme.value, { persist: true })
281
+ }
282
+
283
+ /** 主题变化监听 */
284
+ export function onThemeChange(callback: (theme: string) => void): () => void {
285
+ changeListeners.add(callback)
286
+ return () => changeListeners.delete(callback)
287
+ }
288
+
289
+ export interface UseThemeReturn {
290
+ /** 当前主题(只读 ref) */
291
+ theme: ComputedRef<string>
292
+ /** 可用主题列表(只读) */
293
+ themes: ComputedRef<string[]>
294
+ /** 切换主题 */
295
+ setTheme: (name: string, options?: SetThemeOptions) => void
296
+ }
297
+
298
+ /** 主题 composable(配合 defineTheme 实现运行时换肤) */
299
+ export function useTheme(): UseThemeReturn {
300
+ return {
301
+ theme: readonly(currentTheme) as unknown as ComputedRef<string>,
302
+ themes: computed(() => getThemes()),
303
+ setTheme,
304
+ }
305
+ }