skinsuite 0.1.0 → 0.2.0

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.
Files changed (82) hide show
  1. package/README.md +28 -6
  2. package/dist/constants.d.ts +44 -7
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +44 -7
  5. package/dist/constants.js.map +1 -1
  6. package/dist/index.d.ts +32 -6
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +25 -4
  9. package/dist/index.js.map +1 -1
  10. package/dist/manifest.d.ts +83 -0
  11. package/dist/manifest.d.ts.map +1 -0
  12. package/dist/manifest.js +90 -0
  13. package/dist/manifest.js.map +1 -0
  14. package/dist/registry.d.ts +73 -0
  15. package/dist/registry.d.ts.map +1 -0
  16. package/dist/registry.js +299 -0
  17. package/dist/registry.js.map +1 -0
  18. package/dist/remote-config.d.ts +25 -0
  19. package/dist/remote-config.d.ts.map +1 -0
  20. package/dist/remote-config.js +43 -0
  21. package/dist/remote-config.js.map +1 -0
  22. package/dist/scheme.d.ts +36 -0
  23. package/dist/scheme.d.ts.map +1 -0
  24. package/dist/scheme.js +47 -0
  25. package/dist/scheme.js.map +1 -0
  26. package/dist/types.d.ts +17 -1
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/views/device-registry.d.ts +45 -0
  29. package/dist/views/device-registry.d.ts.map +1 -0
  30. package/dist/views/device-registry.js +55 -0
  31. package/dist/views/device-registry.js.map +1 -0
  32. package/dist/views/device.d.ts +63 -0
  33. package/dist/views/device.d.ts.map +1 -0
  34. package/dist/views/device.js +59 -0
  35. package/dist/views/device.js.map +1 -0
  36. package/dist/views/params.d.ts +24 -21
  37. package/dist/views/params.d.ts.map +1 -1
  38. package/dist/views/params.js +11 -11
  39. package/dist/views/params.js.map +1 -1
  40. package/docs/onboarding.md +472 -0
  41. package/package.json +22 -1
  42. package/src/constants.ts +141 -100
  43. package/src/index.ts +128 -65
  44. package/src/manifest.ts +131 -0
  45. package/src/registry.ts +400 -0
  46. package/src/scheme.ts +94 -0
  47. package/src/types.ts +19 -1
  48. package/src/views/device-registry.ts +96 -0
  49. package/src/views/device.ts +107 -0
  50. package/src/views/params.ts +26 -23
  51. package/src/vite/dir.ts +85 -0
  52. package/src/vite/index.ts +41 -0
  53. package/src/vite/plugin.ts +409 -0
  54. package/src/vite/theme.d.ts +49 -0
  55. package/src/vue/ThemeDebugPanel.vue +36 -0
  56. package/src/vue/ThemeDebugPanelBody.vue +505 -0
  57. package/src/vue/ThemeRoot.vue +59 -0
  58. package/src/vue/ThemeView.vue +92 -0
  59. package/src/vue/create-theme-state.ts +116 -0
  60. package/src/vue/define-theme-device.ts +157 -0
  61. package/src/vue/device-containers.ts +73 -0
  62. package/src/vue/device-domain.ts +149 -0
  63. package/src/vue/index.ts +98 -0
  64. package/src/vue/panel.ts +16 -0
  65. package/src/vue/plugin.ts +35 -0
  66. package/src/vue/remote-fetch.ts +74 -0
  67. package/src/vue/theme-intent.ts +157 -0
  68. package/src/vue/theme-persistence.ts +136 -0
  69. package/src/vue/theme-runtime.ts +163 -0
  70. package/src/vue/theme-scope.ts +148 -0
  71. package/src/vue/theme-store.ts +58 -0
  72. package/src/vue/theme-views.ts +39 -0
  73. package/src/vue/token-runtime.ts +81 -0
  74. package/src/vue/types.ts +67 -0
  75. package/src/vue/useParentThemeSync.ts +99 -0
  76. package/src/vue/useThemeDebugPanel.ts +192 -0
  77. package/src/vue/useThemeSources.ts +74 -0
  78. package/src/vue/view-page.ts +99 -0
  79. package/src/vue/view-registry.ts +215 -0
  80. package/src/vue/viewport.ts +54 -0
  81. package/src/mode.ts +0 -38
  82. package/src/tone.ts +0 -56
@@ -0,0 +1,505 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 主题 / 版式调试面板**主体**(真正的 UI + 数据 + 动作)
4
+ *
5
+ * === 为什么拆成「门卫 + 主体」两个文件 ===
6
+ * 面板要满足「`off` 档 / `url` 档无参数时**既不下载也不执行**」:`ThemeDebugPanel.vue`
7
+ * 是随主包的**轻量门卫**(只算 `visible`,`v-if` 决定要不要渲染本组件),本文件是真正的
8
+ * 面板重 chunk —— 只有 `visible` 为 true 时才被 `defineAsyncComponent(() => import(...))`
9
+ * 触发下载。于是 `off` 档连 chunk 都不进产物,`url` 档无参数时也不下载。
10
+ *
11
+ * 数据与动作由库内 `useThemeDebugPanel()` 自动组装(从 store / 设备域 / 页面域 / 清单 /
12
+ * 内核注册表单例 / 路由取),宿主 App.vue 里一行 `<ThemeDebugPanel />` 即用,**零接线**。
13
+ *
14
+ * === 为什么不限定只在开发构建里出现 ===
15
+ * 真机上没有 DevTools(本项目的反复痛点)。线上排查「某个租户看到的颜色不对」时,
16
+ * 能直接在手机上切主题看效果是刚需。面板内容只有主题名与色值 —— 它们本来就在前端
17
+ * 产物里,不构成信息泄露。
18
+ *
19
+ * === 样式刻意不跟随宿主主题 ===
20
+ * 面板是拿来做**对比**的:固定深色外观、**完全不透明**(半透明会让页面文字透上来叠字)。
21
+ * 样式用 scoped CSS 而**不是 Tailwind**:库不能假定宿主装了 Tailwind。
22
+ *
23
+ * === 颜色与明暗是**两个正交维度** ===
24
+ * 点颜色 = 纯换一套配色,**不碰明暗**;明暗只由「明暗」那一行控制,但有**单向联动**
25
+ * (切明暗会把配色切到对应系别)。这条联动是宿主的逻辑,面板只发出 `pickMode`。
26
+ *
27
+ * 结构:
28
+ * ```
29
+ * ┌ 版式 ──┐ 当前页的可选版式,点即切
30
+ * ├ 颜色 ──┤ **当前版式**支持的颜色(切版式后清单会变)
31
+ * ├ 明暗 ──┤ 浅色 / 深色 / 跟随系统
32
+ * └ 恢复默认┘
33
+ * ```
34
+ *
35
+ * @author yijiu2025
36
+ * @since 2026-09-23(2026-10-18 无 host 化;再拆「门卫 + 主体」,可见性判断移出门卫)
37
+ */
38
+ import { computed, ref } from 'vue';
39
+ import { MODE_LABELS, TONE_LABELS } from '../scheme';
40
+ import { useThemeDebugPanel } from './useThemeDebugPanel';
41
+ import type { ThemeDebugColor } from './types';
42
+
43
+ const TITLE = '主题调试';
44
+ const HINT = '?debug=theme';
45
+
46
+ const { model, actions } = useThemeDebugPanel();
47
+
48
+ /** 本次会话内手动关掉(宿主仍按 URL 参数控制可见性,刷新会回来) */
49
+ const dismissed = ref(false);
50
+
51
+ /** 折叠态:默认按视口高度判(弹窗场景真正的约束是高度,不是宽度) */
52
+ const COMPACT_MAX_HEIGHT = 560;
53
+ const collapsed = ref(
54
+ typeof window !== 'undefined' && window.innerHeight < COMPACT_MAX_HEIGHT
55
+ );
56
+
57
+ const deviceLabel = (device: string): string => model.value.deviceLabels?.[device] ?? device;
58
+
59
+ /** 面板头部状态串(配色 · 系别 · 明暗,联调时一眼确认联动是否生效) */
60
+ const headerState = computed(() => {
61
+ const m = model.value;
62
+ const name = m.colors.find(c => c.id === m.color)?.name ?? m.color;
63
+ const tone = m.colors.find(c => c.id === m.color)?.tone ?? 'light';
64
+ const parts = [name, TONE_LABELS[tone], MODE_LABELS[m.mode]];
65
+ const selected = m.selectedColor;
66
+ if (selected && selected !== m.color) {
67
+ const selName = m.colors.find(c => c.id === selected)?.name ?? selected;
68
+ parts.push(`选择 ${selName} → 生效 ${name}`);
69
+ }
70
+ return parts.join(' · ');
71
+ });
72
+
73
+ /** 该页还有别的版式挂了颜色(提示"切过去颜色清单会变") */
74
+ const otherViewsWithColors = computed(() =>
75
+ (model.value.viewsWithColors ?? []).filter(v => v !== model.value.view)
76
+ );
77
+
78
+ /** 把 #rrggbb 解析成三元组;非法值返回 null */
79
+ function parseHex(hex: string): [number, number, number] | null {
80
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
81
+ if (!m) return null;
82
+ const n = parseInt(m[1], 16);
83
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
84
+ }
85
+
86
+ /** 色卡底色:取色卡的 preview;取不到时给中性灰兜底(不透明,避免穿帮) */
87
+ function swatchBg(item: ThemeDebugColor): string {
88
+ return item.preview ?? '#334155';
89
+ }
90
+
91
+ /** 色块上的文字色:按底色亮度选黑/白,保证任何色卡上都读得清 */
92
+ function swatchFg(item: ThemeDebugColor): string {
93
+ const rgb = item.preview ? parseHex(item.preview) : null;
94
+ if (!rgb) return '#e2e8f0';
95
+ // 相对亮度(sRGB 感知近似:0.299R + 0.587G + 0.114B)
96
+ const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
97
+ return lum > 150 ? '#0f172a' : '#f8fafc';
98
+ }
99
+
100
+ const MODES = ['system', 'light', 'dark'] as const;
101
+ </script>
102
+
103
+ <template>
104
+ <div
105
+ v-if="!dismissed"
106
+ data-mauth-debug="theme"
107
+ class="tdp-root"
108
+ role="region"
109
+ :aria-label="TITLE"
110
+ >
111
+ <!-- 头部:标题 + 当前状态 + 折叠/关闭 -->
112
+ <div class="tdp-header">
113
+ <button type="button" class="tdp-toggle" @click="collapsed = !collapsed">
114
+ <span class="tdp-caret">{{ collapsed ? '▸' : '▾' }}</span>
115
+ <span class="tdp-title">{{ TITLE }}</span>
116
+ <span class="tdp-state">{{ headerState }}</span>
117
+ </button>
118
+ <button
119
+ type="button"
120
+ class="tdp-close"
121
+ title="收起面板(URL 参数仍在,刷新会回来)"
122
+ @click="dismissed = true"
123
+ >
124
+ ×
125
+ </button>
126
+ </div>
127
+
128
+ <div v-if="!collapsed" class="tdp-body">
129
+ <!-- 设备:同一台电脑上预览另一端(不必真去开移动端路由) -->
130
+ <div class="tdp-row">
131
+ <span class="tdp-label">设备</span>
132
+ <div class="tdp-seg">
133
+ <button
134
+ type="button"
135
+ class="tdp-seg-item"
136
+ :class="{ 'is-on': model.deviceOverride === null }"
137
+ data-device="auto"
138
+ :title="`跟随页面(当前 ${deviceLabel(model.device)})`"
139
+ @click="actions.pickDevice(null)"
140
+ >
141
+ 跟随
142
+ </button>
143
+ <button
144
+ v-for="d in model.devices"
145
+ :key="d"
146
+ type="button"
147
+ class="tdp-seg-item"
148
+ :class="{ 'is-on': model.deviceOverride === d }"
149
+ :data-device="d"
150
+ @click="actions.pickDevice(d)"
151
+ >
152
+ {{ deviceLabel(d) }}
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- ① 版式(当前页) -->
158
+ <div v-if="model.views.length" class="tdp-block">
159
+ <div class="tdp-block-head">
160
+ <span class="tdp-label">版式</span>
161
+ <span class="tdp-note">
162
+ {{ model.page }}{{ model.views.length > 1 ? '' : '(仅此一种版式)' }}
163
+ </span>
164
+ </div>
165
+ <div class="tdp-chips">
166
+ <button
167
+ v-for="id in model.views"
168
+ :key="id"
169
+ type="button"
170
+ class="tdp-chip"
171
+ :class="{ 'is-on': model.view === id }"
172
+ :data-view-id="id"
173
+ :title="`切到版式 ${id}`"
174
+ @click="actions.pickView(id)"
175
+ >
176
+ {{ id }}
177
+ </button>
178
+ </div>
179
+ <p v-if="otherViewsWithColors.length" class="tdp-hint">
180
+ 另有版式 {{ otherViewsWithColors.join('、') }} 可选
181
+ </p>
182
+ </div>
183
+
184
+ <!-- ② 颜色(**当前版式**支持的那几种) -->
185
+ <div class="tdp-block">
186
+ <div class="tdp-block-head">
187
+ <span class="tdp-label">颜色</span>
188
+ <span class="tdp-note">版式 {{ model.view }} · {{ model.colors.length }} 种</span>
189
+ </div>
190
+ <div v-if="model.colors.length" class="tdp-swatches">
191
+ <button
192
+ v-for="c in model.colors"
193
+ :key="c.id"
194
+ type="button"
195
+ class="tdp-swatch"
196
+ :class="{ 'is-on': model.color === c.id }"
197
+ :data-color-pick="c.id"
198
+ :data-color-active="model.color === c.id ? c.id : undefined"
199
+ :data-tone="c.tone"
200
+ :title="`${c.name}(${c.id} · ${TONE_LABELS[c.tone]})`"
201
+ @click="actions.pickColor(c.id)"
202
+ >
203
+ <span class="tdp-swatch-chip" :style="{ background: swatchBg(c), color: swatchFg(c) }">
204
+ {{ c.name }}
205
+ </span>
206
+ <span class="tdp-swatch-id">{{ c.id }}·{{ TONE_LABELS[c.tone] }}</span>
207
+ </button>
208
+ </div>
209
+ <p v-else class="tdp-hint">这个版式还没有配色目录(`themes/…/{{ model.page }}/…/colors/`)</p>
210
+ </div>
211
+
212
+ <!-- ③ 明暗(切明暗会**单向**联动配色系别;点颜色不改明暗) -->
213
+ <div class="tdp-mode">
214
+ <span class="tdp-label">明暗</span>
215
+ <div class="tdp-seg">
216
+ <button
217
+ v-for="m in MODES"
218
+ :key="m"
219
+ type="button"
220
+ class="tdp-seg-item"
221
+ :class="{ 'is-on': model.mode === m }"
222
+ :data-mode="m"
223
+ @click="actions.pickMode(m)"
224
+ >
225
+ {{ MODE_LABELS[m] }}
226
+ </button>
227
+ </div>
228
+ </div>
229
+
230
+ <div class="tdp-foot">
231
+ <button type="button" class="tdp-chip" @click="actions.reset()">恢复默认</button>
232
+ <span class="tdp-note">{{ HINT }}</span>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </template>
237
+
238
+ <style scoped>
239
+ /*
240
+ * 固定深色、不透明:面板是"参照物",跟着主题变色就失去意义。
241
+ * 颜色全部写死在这里(不取宿主 token),也不依赖任何 CSS 框架。
242
+ */
243
+ .tdp-root {
244
+ position: fixed;
245
+ right: 12px;
246
+ bottom: 12px;
247
+ z-index: 9999;
248
+ display: flex;
249
+ flex-direction: column;
250
+ width: 264px;
251
+ max-width: calc(100vw - 24px);
252
+ max-height: calc(100vh - 24px);
253
+ overflow: hidden;
254
+ border: 1px solid #334155;
255
+ border-radius: 12px;
256
+ background: #0f172a;
257
+ color: #e2e8f0;
258
+ box-shadow: 0 20px 40px rgb(0 0 0 / 45%);
259
+ font-family:
260
+ system-ui,
261
+ -apple-system,
262
+ 'Segoe UI',
263
+ 'PingFang SC',
264
+ 'Microsoft YaHei',
265
+ sans-serif;
266
+ font-size: 12px;
267
+ line-height: 1.4;
268
+ }
269
+
270
+ .tdp-header {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 4px;
274
+ padding: 8px 10px;
275
+ border-bottom: 1px solid rgb(51 65 85 / 70%);
276
+ }
277
+
278
+ .tdp-toggle {
279
+ display: flex;
280
+ flex: 1;
281
+ min-width: 0;
282
+ align-items: center;
283
+ gap: 6px;
284
+ overflow: hidden;
285
+ padding: 0;
286
+ border: 0;
287
+ background: none;
288
+ color: inherit;
289
+ font: inherit;
290
+ text-align: left;
291
+ cursor: pointer;
292
+ }
293
+
294
+ .tdp-caret,
295
+ .tdp-title {
296
+ flex-shrink: 0;
297
+ }
298
+
299
+ .tdp-caret {
300
+ color: #94a3b8;
301
+ }
302
+
303
+ .tdp-title {
304
+ font-size: 12px;
305
+ font-weight: 500;
306
+ color: #f1f5f9;
307
+ }
308
+
309
+ .tdp-state {
310
+ overflow: hidden;
311
+ color: #94a3b8;
312
+ font-size: 11px;
313
+ text-overflow: ellipsis;
314
+ white-space: nowrap;
315
+ }
316
+
317
+ .tdp-close {
318
+ flex-shrink: 0;
319
+ padding: 0 4px;
320
+ border: 0;
321
+ background: none;
322
+ color: #64748b;
323
+ font-size: 14px;
324
+ line-height: 1;
325
+ cursor: pointer;
326
+ }
327
+
328
+ .tdp-close:hover {
329
+ color: #e2e8f0;
330
+ }
331
+
332
+ .tdp-body {
333
+ flex: 1;
334
+ overflow-y: auto;
335
+ padding: 8px 10px 10px;
336
+ }
337
+
338
+ .tdp-row {
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: space-between;
342
+ margin-bottom: 6px;
343
+ }
344
+
345
+ .tdp-block {
346
+ margin-top: 10px;
347
+ }
348
+
349
+ .tdp-block-head {
350
+ display: flex;
351
+ align-items: baseline;
352
+ justify-content: space-between;
353
+ margin-bottom: 4px;
354
+ }
355
+
356
+ .tdp-label {
357
+ color: #94a3b8;
358
+ font-size: 11px;
359
+ }
360
+
361
+ .tdp-note {
362
+ overflow: hidden;
363
+ padding-left: 8px;
364
+ color: #64748b;
365
+ font-size: 10px;
366
+ text-overflow: ellipsis;
367
+ white-space: nowrap;
368
+ }
369
+
370
+ .tdp-hint {
371
+ margin: 4px 0 0;
372
+ color: #64748b;
373
+ font-size: 10px;
374
+ line-height: 1.5;
375
+ }
376
+
377
+ /* 分段控件(设备 / 明暗) */
378
+ .tdp-seg {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 2px;
382
+ }
383
+
384
+ .tdp-seg-item {
385
+ padding: 2px 6px;
386
+ border: 0;
387
+ border-radius: 4px;
388
+ background: none;
389
+ color: #64748b;
390
+ font: inherit;
391
+ font-size: 10px;
392
+ cursor: pointer;
393
+ transition: background-color 0.15s, color 0.15s;
394
+ }
395
+
396
+ .tdp-seg-item:hover {
397
+ color: #cbd5e1;
398
+ }
399
+
400
+ .tdp-seg-item.is-on {
401
+ background: rgb(56 189 248 / 20%);
402
+ color: #f1f5f9;
403
+ }
404
+
405
+ /* 版式 chip */
406
+ .tdp-chips {
407
+ display: flex;
408
+ flex-wrap: wrap;
409
+ gap: 4px;
410
+ }
411
+
412
+ .tdp-chip {
413
+ padding: 2px 8px;
414
+ border: 1px solid #334155;
415
+ border-radius: 999px;
416
+ background: none;
417
+ color: #94a3b8;
418
+ font: inherit;
419
+ font-size: 11px;
420
+ cursor: pointer;
421
+ transition: background-color 0.15s, border-color 0.15s, color 0.15s;
422
+ }
423
+
424
+ .tdp-chip:hover {
425
+ background: rgb(241 245 249 / 10%);
426
+ }
427
+
428
+ .tdp-chip.is-on {
429
+ border-color: rgb(56 189 248 / 70%);
430
+ background: rgb(56 189 248 / 15%);
431
+ color: #f1f5f9;
432
+ }
433
+
434
+ /* 色卡 */
435
+ .tdp-swatches {
436
+ display: flex;
437
+ flex-wrap: wrap;
438
+ gap: 4px;
439
+ }
440
+
441
+ .tdp-swatch {
442
+ display: flex;
443
+ width: 46px;
444
+ flex-direction: column;
445
+ align-items: center;
446
+ gap: 2px;
447
+ padding: 2px 0;
448
+ border: 1px solid #334155;
449
+ border-radius: 6px;
450
+ background: none;
451
+ font: inherit;
452
+ cursor: pointer;
453
+ transition: border-color 0.15s, box-shadow 0.15s;
454
+ }
455
+
456
+ .tdp-swatch:hover {
457
+ border-color: #64748b;
458
+ }
459
+
460
+ .tdp-swatch.is-on {
461
+ border-color: rgb(56 189 248 / 80%);
462
+ box-shadow: 0 0 0 1px rgb(56 189 248 / 60%);
463
+ }
464
+
465
+ .tdp-swatch-chip {
466
+ display: flex;
467
+ width: 38px;
468
+ height: 26px;
469
+ align-items: center;
470
+ justify-content: center;
471
+ border-radius: 4px;
472
+ font-size: 11px;
473
+ font-weight: 500;
474
+ }
475
+
476
+ .tdp-swatch-id {
477
+ max-width: 42px;
478
+ overflow: hidden;
479
+ color: #64748b;
480
+ font-size: 9px;
481
+ line-height: 1;
482
+ text-overflow: ellipsis;
483
+ white-space: nowrap;
484
+ }
485
+
486
+ /* 明暗 */
487
+ .tdp-mode {
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: space-between;
491
+ margin-top: 10px;
492
+ padding: 4px 8px;
493
+ border: 1px solid rgb(51 65 85 / 60%);
494
+ border-radius: 8px;
495
+ }
496
+
497
+ .tdp-foot {
498
+ display: flex;
499
+ align-items: center;
500
+ justify-content: space-between;
501
+ margin-top: 10px;
502
+ padding-top: 8px;
503
+ border-top: 1px solid rgb(51 65 85 / 70%);
504
+ }
505
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 主题外壳(**纯外观壳**)—— 只管「长什么样」,不碰「主题从哪来」
4
+ *
5
+ * 职责(两件事):
6
+ * 1. 挂 `.dark` 类(Tailwind `dark:` 变体与基线 SCSS 的开关)
7
+ * 2. body 容器用 **token** 决定底色与文字色(并平滑过渡)
8
+ *
9
+ * 「主题从哪来」(父应用同步 / 后端配色 / URL / 用户操作)是**主题运行时**的职责
10
+ * (见 `useThemeSources`),不在本组件 —— 外壳只读 `themeStore.isDark` 决定挂不挂 dark。
11
+ * 调试面板已独立为 `<ThemeDebugPanel/>` 组件,由宿主在 App 层单独挂载。
12
+ *
13
+ * === 为什么底色用 token 而不是 Tailwind 色阶 ===
14
+ * 早先这里是 `bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-50`,
15
+ * 与 `--mauth-bg` / `--mauth-text` 形成**双重轨道**:选 black 主题时 Tailwind 给出
16
+ * slate-950、token 给出 #000000,结果"选了黑但页面底不是纯黑"。
17
+ * 现在统一到 token:黑/白配色文件里的 `--mauth-bg` / `--mauth-text` 完全决定底色。
18
+ * `html.dark` 类仍保留 —— Tailwind 的 `dark:bg-slate-xxx` 类仍依赖这个开关,不能删。
19
+ *
20
+ * @author yijiu2025
21
+ * @since 2026-10-17(2026-10-18 退化为纯外观壳:删 host / 父同步 / 后端拉取 / 调试面板)
22
+ */
23
+ import { computed, provide } from 'vue';
24
+ import { themeStoreKey } from './theme-store';
25
+ import type { ThemeStoreLike } from './theme-store';
26
+
27
+ const props = defineProps<{
28
+ /** 主题 store(结构类型,宿主 Pinia store 结构兼容即可传;`provide` 给 <ThemeView>/<ThemeDebugPanel>) */
29
+ themeStore: ThemeStoreLike;
30
+ }>();
31
+
32
+ // 提供主题 store 给子树(<ThemeView> / <ThemeDebugPanel> inject)
33
+ provide(themeStoreKey, props.themeStore);
34
+
35
+ /** 当前是否深色(驱动 `html.dark` 类与 body 底色过渡) */
36
+ const isDark = computed(() => props.themeStore.isDark);
37
+
38
+ /**
39
+ * body 容器的内联样式(token 通道)
40
+ *
41
+ * 过渡(0.3s)保留:Tailwind dark 切换的瞬间仍可能抖动,300ms 让它与
42
+ * token 切换同步平滑。
43
+ */
44
+ const shellStyle =
45
+ 'background-color: var(--mauth-bg); color: var(--mauth-text); ' +
46
+ 'transition: background-color 0.3s, color 0.3s;';
47
+ </script>
48
+
49
+ <template>
50
+ <div :class="{ dark: isDark }">
51
+ <!--
52
+ body 容器:底色/文字色由 token 决定,与组件 scoped token 同源。
53
+ min-h-screen + bg-mauth / text-mauth / font-sans 保持既有排版基线。
54
+ -->
55
+ <div class="min-h-screen bg-mauth text-mauth font-sans" :style="shellStyle">
56
+ <slot />
57
+ </div>
58
+ </div>
59
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<ThemeView>` —— 渲染某个页面「当前生效的版式组件」
4
+ *
5
+ * 把每个业务容器里复制粘贴的 ~40 行「版式加载 + epoch 防抖 + 回退」样板收进库。容器
6
+ * 只剩业务逻辑(表单、校验、ctx 组装),模板里一行 `<ThemeView page="login" device="mobile" :ctx="ctx" />`。
7
+ *
8
+ * 内部自动做三件事(开发者不再手写):
9
+ * 1. `viewId = page.pick({ url, theme, pkg, device })`(URL > 声明 > env > 包名)
10
+ * 2. `page.views.load(id, pkg, device)` → epoch 防抖 → `markRaw(loaded) ?? builtin`
11
+ * 3. `setActivePageView(page, viewId)`(immediate,配色挂在版式下)
12
+ *
13
+ * === 依赖从哪来(库不 import 宿主 store/router)===
14
+ * · `page`(`pageOf(page)`)→ 页面域自动注册(`defineThemePage`)
15
+ * · `themeStore`(`inject`)→ `<ThemeRoot :themeStore>` provide
16
+ * · `url`(`?th` / `?th_<设备缩写>`)→ 设备域(`app.use(theme, { router })` 传入的 router)
17
+ * · `builtin`(首帧零请求回退)→ 版式注册表 `views.builtin(device)`(eager 静态 import)
18
+ *
19
+ * @author yijiu2025
20
+ * @since 2026-10-18
21
+ */
22
+ import { computed, inject, markRaw, shallowRef, watch } from 'vue';
23
+ import type { Component } from 'vue';
24
+ import { readDeviceParam } from '../views/params';
25
+ import { pageOf } from './view-page';
26
+ import { currentQuery } from './device-domain';
27
+ import { urlKeyOf } from './define-theme-device';
28
+ import { themeStoreKey } from './theme-store';
29
+ import type { ThemeStoreLike } from './theme-store';
30
+
31
+ const props = defineProps<{
32
+ /** 页面名(`'login'`),内部从页面域取 `loginPage`(与主题包搭配) */
33
+ page: string;
34
+ /** 设备名(容器身份 = 文件位置即身份) */
35
+ device: string;
36
+ /** 业务上下文(传进版式组件的 props) */
37
+ ctx: object;
38
+ }>();
39
+
40
+ const store = inject<ThemeStoreLike>(themeStoreKey);
41
+ if (!store) {
42
+ throw new Error('[ThemeView] 未注入主题 store —— 请通过 <ThemeRoot :themeStore> 提供');
43
+ }
44
+
45
+ const pageObj = pageOf(props.page);
46
+ if (!pageObj) {
47
+ throw new Error(`[ThemeView] 页面「${props.page}」未声明 —— 请先 defineThemePage({ page: '${props.page}', ... })`);
48
+ }
49
+
50
+ /**
51
+ * 用哪套版式:`?th[_<设备缩写>]` > 主题包声明(views.<page>)> VITE_<PAGE>_VIEW > 当前包名。
52
+ * 做成 computed(而非 setup 取一次)是因为主题**运行时**才定:后端换配色配置可能晚于本页
53
+ * setup 到达,取一次会漏掉「主题包声明了版式」这种情况;依赖 `packageId` 让它在换包时自动重算。
54
+ */
55
+ const viewId = computed(() =>
56
+ pageObj.pick({
57
+ url: readDeviceParam(currentQuery(), 'th', urlKeyOf(props.device)),
58
+ theme: store.viewFor(props.page, props.device),
59
+ pkg: store.packageId,
60
+ device: props.device
61
+ })
62
+ );
63
+
64
+ /** 告知 store「当前是哪一页、哪套版式」(配色挂在版式下,token 注入依赖它) */
65
+ watch(viewId, id => store.setActivePageView(props.page, id), { immediate: true });
66
+
67
+ /**
68
+ * 当前渲染的版式组件。初始值 = 内置包版式(eager 静态 import,首帧零请求直接正确);
69
+ * 非内置包版式是动态 chunk,加载完成后接管。`viewEpoch` 丢弃过期结果(A→B→A 连续切换时
70
+ * 先发出的 A 可能后返回)。🔴 必须同时盯住 `packageId`:换了包但解析出的 id 字符串没变
71
+ * (两边都是 `base`)时渲染的组件其实换了一整套。
72
+ */
73
+ const builtin = pageObj.views.builtin(props.device);
74
+ const activeView = shallowRef<Component | null>(builtin);
75
+ let viewEpoch = 0;
76
+ watch(
77
+ [viewId, () => store.packageId],
78
+ ([id, pkg]) => {
79
+ const epoch = ++viewEpoch;
80
+ void pageObj.views.load(id, pkg, props.device).then(loaded => {
81
+ if (epoch !== viewEpoch) return;
82
+ // null = 用内置包版式(首屏零请求那条路径)
83
+ activeView.value = loaded ? markRaw(loaded) : builtin;
84
+ });
85
+ },
86
+ { immediate: true }
87
+ );
88
+ </script>
89
+
90
+ <template>
91
+ <component :is="activeView" v-if="activeView" :ctx="ctx" />
92
+ </template>