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.
- package/README.md +28 -6
- package/dist/constants.d.ts +44 -7
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +44 -7
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +32 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +83 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +90 -0
- package/dist/manifest.js.map +1 -0
- package/dist/registry.d.ts +73 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +299 -0
- package/dist/registry.js.map +1 -0
- package/dist/remote-config.d.ts +25 -0
- package/dist/remote-config.d.ts.map +1 -0
- package/dist/remote-config.js +43 -0
- package/dist/remote-config.js.map +1 -0
- package/dist/scheme.d.ts +36 -0
- package/dist/scheme.d.ts.map +1 -0
- package/dist/scheme.js +47 -0
- package/dist/scheme.js.map +1 -0
- package/dist/types.d.ts +17 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/device-registry.d.ts +45 -0
- package/dist/views/device-registry.d.ts.map +1 -0
- package/dist/views/device-registry.js +55 -0
- package/dist/views/device-registry.js.map +1 -0
- package/dist/views/device.d.ts +63 -0
- package/dist/views/device.d.ts.map +1 -0
- package/dist/views/device.js +59 -0
- package/dist/views/device.js.map +1 -0
- package/dist/views/params.d.ts +24 -21
- package/dist/views/params.d.ts.map +1 -1
- package/dist/views/params.js +11 -11
- package/dist/views/params.js.map +1 -1
- package/docs/onboarding.md +472 -0
- package/package.json +22 -1
- package/src/constants.ts +141 -100
- package/src/index.ts +128 -65
- package/src/manifest.ts +131 -0
- package/src/registry.ts +400 -0
- package/src/scheme.ts +94 -0
- package/src/types.ts +19 -1
- package/src/views/device-registry.ts +96 -0
- package/src/views/device.ts +107 -0
- package/src/views/params.ts +26 -23
- package/src/vite/dir.ts +85 -0
- package/src/vite/index.ts +41 -0
- package/src/vite/plugin.ts +409 -0
- package/src/vite/theme.d.ts +49 -0
- package/src/vue/ThemeDebugPanel.vue +36 -0
- package/src/vue/ThemeDebugPanelBody.vue +505 -0
- package/src/vue/ThemeRoot.vue +59 -0
- package/src/vue/ThemeView.vue +92 -0
- package/src/vue/create-theme-state.ts +116 -0
- package/src/vue/define-theme-device.ts +157 -0
- package/src/vue/device-containers.ts +73 -0
- package/src/vue/device-domain.ts +149 -0
- package/src/vue/index.ts +98 -0
- package/src/vue/panel.ts +16 -0
- package/src/vue/plugin.ts +35 -0
- package/src/vue/remote-fetch.ts +74 -0
- package/src/vue/theme-intent.ts +157 -0
- package/src/vue/theme-persistence.ts +136 -0
- package/src/vue/theme-runtime.ts +163 -0
- package/src/vue/theme-scope.ts +148 -0
- package/src/vue/theme-store.ts +58 -0
- package/src/vue/theme-views.ts +39 -0
- package/src/vue/token-runtime.ts +81 -0
- package/src/vue/types.ts +67 -0
- package/src/vue/useParentThemeSync.ts +99 -0
- package/src/vue/useThemeDebugPanel.ts +192 -0
- package/src/vue/useThemeSources.ts +74 -0
- package/src/vue/view-page.ts +99 -0
- package/src/vue/view-registry.ts +215 -0
- package/src/vue/viewport.ts +54 -0
- package/src/mode.ts +0 -38
- package/src/tone.ts +0 -56
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题 store 的**结构类型**(库侧约定)+ provide / inject 键
|
|
3
|
+
*
|
|
4
|
+
* 库(skinsuite/vue)不能 import 宿主的 Pinia store(边界),所以只约定一个**结构类型**:
|
|
5
|
+
* 宿主的 `useThemeStore()` 只要结构兼容这几项,就能直接传给 `<ThemeRoot :themeStore>`。
|
|
6
|
+
* `<ThemeRoot>` 再 `provide` 出去,`<ThemeView>` / `<ThemeDebugPanel>` 用 `inject` 拿到 ——
|
|
7
|
+
* 这是「库不依赖宿主、但宿主事实要注入」的标准手法。
|
|
8
|
+
*
|
|
9
|
+
* === 为什么是这些字段 ===
|
|
10
|
+
* · `isDark` → ThemeRoot 挂 `.dark` 类(纯外观)
|
|
11
|
+
* · `packageId` → ThemeView / 面板选版式的「在哪个包里找」
|
|
12
|
+
* · `viewFor(page)` → 主题包声明档(`views.<page>`)这一档
|
|
13
|
+
* · `setActivePageView` → ThemeView 告知 store「当前页 + 版式」(配色挂在版式下)
|
|
14
|
+
* · `activePage` → 面板展示当前页
|
|
15
|
+
* · `themeId` / `mode` → 面板展示 / 切换配色与明暗
|
|
16
|
+
* · `themeRecordFor` → 面板算「生效配色」(含 URL 设备覆盖,device 参数化)
|
|
17
|
+
* · `setTheme` / `setMode` → 面板写侧(点色卡 / 切明暗)
|
|
18
|
+
*
|
|
19
|
+
* 字段用**只读标量**(非 ref):宿主 Pinia store 返回的 ref/computed 会被 Pinia 自动解包,
|
|
20
|
+
* 在 store 实例上直接读值即可,且读进 computed/watch 时仍保持响应式追踪。
|
|
21
|
+
* 方法用**方法签名**(method shorthand)而非函数类型属性:这样 TS 对参数做双变检查,
|
|
22
|
+
* 宿主的窄类型(如 `ThemeDevice`)能直接兼容库声明的宽类型(`string`)。
|
|
23
|
+
*
|
|
24
|
+
* @author yijiu2025
|
|
25
|
+
* @since 2026-10-18
|
|
26
|
+
*/
|
|
27
|
+
import type { InjectionKey } from 'vue';
|
|
28
|
+
import type { ThemeMode } from '../scheme';
|
|
29
|
+
|
|
30
|
+
/** 主题 store 的结构契约(宿主 store 结构兼容即可传) */
|
|
31
|
+
export interface ThemeStoreLike {
|
|
32
|
+
/** 当前生效主题包名(= 版式 id,一个包一种版式) */
|
|
33
|
+
readonly packageId: string;
|
|
34
|
+
/** 当前是否深色(= 生效配色的系别) */
|
|
35
|
+
readonly isDark: boolean;
|
|
36
|
+
/** 当前生效页面名 */
|
|
37
|
+
readonly activePage: string;
|
|
38
|
+
/** 全局配色意图(用户显式选过的 id) */
|
|
39
|
+
readonly themeId: string;
|
|
40
|
+
/** 明暗意图 */
|
|
41
|
+
readonly mode: ThemeMode;
|
|
42
|
+
/** 某页当前声明式版式 id(主题包 `views.<page>` 声明;无声明返回 undefined) */
|
|
43
|
+
viewFor(page: string, device?: string): string | undefined;
|
|
44
|
+
/** 告知 store「当前是哪一页、哪套版式」(配色挂在版式下,token 注入依赖它) */
|
|
45
|
+
setActivePageView(page: string, viewId: string): void;
|
|
46
|
+
/** 按包/设备/页解析生效配色记录(含 URL 设备覆盖;面板只要 `meta.id` 与 `tone`) */
|
|
47
|
+
themeRecordFor(pkg?: string, device?: string, page?: string): {
|
|
48
|
+
meta: { id: string };
|
|
49
|
+
tone?: string;
|
|
50
|
+
};
|
|
51
|
+
/** 设置配色(落盘;面板点色卡用) */
|
|
52
|
+
setTheme(id: string): void;
|
|
53
|
+
/** 设置明暗意图(面板切明暗用) */
|
|
54
|
+
setMode(mode: ThemeMode): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `provide`/`inject` 用的键(`<ThemeRoot>` provide,`<ThemeView>` / `<ThemeDebugPanel>` inject) */
|
|
58
|
+
export const themeStoreKey: InjectionKey<ThemeStoreLike> = Symbol('mauth.themeStore');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版式加载表(`themeViews`)的**运行时单例** —— 构建产物 → 运行时的手递手收口点
|
|
3
|
+
*
|
|
4
|
+
* === 它解决什么 ===
|
|
5
|
+
* 页面契约文件(`views/<page>.ts`)需要「本页有哪些版式组件」才能建注册表,而这份清单
|
|
6
|
+
* 来自构建期插件扫出的虚拟模块 `virtual:theme` 的 `themeViews`。若页面文件直接
|
|
7
|
+
* `import { themeViews } from 'virtual:theme'`,则「页面契约 → virtual:theme」这一条依赖
|
|
8
|
+
* 会在 `virtual:theme` 反过来副作用 import 页面契约时形成循环(页面文件在 `themeViews`
|
|
9
|
+
* 求值前读到 undefined)。
|
|
10
|
+
*
|
|
11
|
+
* 所以这里采用与配色注册表(`installThemeRegistry`)、清单(`installThemeManifest`)同一
|
|
12
|
+
* 范式:`virtual:theme` 模块体把 `themeViews` **装入本单例**,`defineThemePage` 只在**取名
|
|
13
|
+
* 的那一刻**(渲染期)从这里取 loader —— 注册时零依赖、永不踩循环。
|
|
14
|
+
*
|
|
15
|
+
* === 为什么不在纯内核 `skinsuite`,而在 `skinsuite/vue` ===
|
|
16
|
+
* `themeViews` 的装载器返回 Vue `Component`,纯内核零 Vue 依赖,装不下这份类型。
|
|
17
|
+
*
|
|
18
|
+
* @author yijiu2025
|
|
19
|
+
* @since 2026-10-18
|
|
20
|
+
*/
|
|
21
|
+
import type { ViewModuleMap } from './view-registry';
|
|
22
|
+
|
|
23
|
+
/** 模块级版式加载表单例(`virtual:theme` 装入;未装入时为空表,查询走空兜底) */
|
|
24
|
+
let themeViewsSingleton: Record<string, ViewModuleMap> = {};
|
|
25
|
+
|
|
26
|
+
/** 装入版式加载表(`virtual:theme` 自动装配时调一次) */
|
|
27
|
+
export function installThemeViews(views: Record<string, ViewModuleMap>): void {
|
|
28
|
+
themeViewsSingleton = views;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 按页面名取该页的版式加载表;未装入 / 未登记返回空表(建空注册表,不抛错) */
|
|
32
|
+
export function themeViewsFor(page: string): ViewModuleMap {
|
|
33
|
+
return themeViewsSingleton[page] ?? {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 重置模块级单例(单测 / HMR 用) */
|
|
37
|
+
export function resetThemeViews(): void {
|
|
38
|
+
themeViewsSingleton = {};
|
|
39
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题 token 运行时(**DOM 注入半边**)—— 把分层 token 校验后注入为 CSS 变量(内核版)
|
|
3
|
+
*
|
|
4
|
+
* === 分层与优先级(低 → 高)===
|
|
5
|
+
* 1. SCSS 基线 :root / html.dark / 断点块
|
|
6
|
+
* 2. 主题包 tokens getThemeRecord(...).tokens
|
|
7
|
+
* 3. 外部覆写 tokens 后端下发 / 父应用同步
|
|
8
|
+
* 第 2、3 层都写在根节点的 inline style 上,层间顺序靠**写入顺序**保证。
|
|
9
|
+
*
|
|
10
|
+
* === 为什么校验(细节见 `skinsuite` 的 `tokens.ts`)===
|
|
11
|
+
* CSS 自定义属性能直接改变渲染,未校验就 `setProperty` 等于把 CSS 注入的口子交给
|
|
12
|
+
* 后端或父页面。所以走白名单(`sanitizeOverrides`,纯函数已在内核 `skinsuite`)。
|
|
13
|
+
*
|
|
14
|
+
* === 为什么本文件在 Vue 绑定层、而非纯内核 ===
|
|
15
|
+
* 它操作 `document.documentElement`(DOM),纯内核 `skinsuite` 刻意零 DOM。
|
|
16
|
+
* 校验函数(`sanitizeOverrides`)已在纯内核,这里只保留「往 DOM 写」这个动作本身。
|
|
17
|
+
*
|
|
18
|
+
* @author yijiu2025
|
|
19
|
+
* @since 2026-10-17(从 oauth21/src/theme/runtime.ts 下沉)
|
|
20
|
+
*/
|
|
21
|
+
import { sanitizeOverrides } from '../tokens';
|
|
22
|
+
import type { ThemeTokenLayers, ThemeTokenOverrides } from '../tokens';
|
|
23
|
+
|
|
24
|
+
/** 上一次注入到 inline style 的变量名,用于下次注入前清理 */
|
|
25
|
+
let injectedNames: string[] = [];
|
|
26
|
+
|
|
27
|
+
/** 清掉上一次注入的所有主题变量 */
|
|
28
|
+
export function clearThemeTokens(): void {
|
|
29
|
+
const root = document.documentElement;
|
|
30
|
+
for (const name of injectedNames) root.style.removeProperty(name);
|
|
31
|
+
injectedNames = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 应用分层 token(主题包 + 外部覆写)
|
|
36
|
+
*
|
|
37
|
+
* 每次调用都是「把两层重算一遍,再与上一轮的变量名做差集清理」:
|
|
38
|
+
* · 换配色时不会残留上一轮的值
|
|
39
|
+
* · 只动自己写过的变量名,不碰别人留在 html 上的 inline style
|
|
40
|
+
* 层内顺序:先 theme 后 external,同名后者胜(「后端覆写压过主题默认」)。
|
|
41
|
+
*/
|
|
42
|
+
export function applyThemeLayers(layers: ThemeTokenLayers): string[] {
|
|
43
|
+
const theme = sanitizeOverrides(layers.theme);
|
|
44
|
+
const external = sanitizeOverrides(layers.external);
|
|
45
|
+
|
|
46
|
+
const root = document.documentElement;
|
|
47
|
+
const nextNames = new Set([...Object.keys(theme.tokens), ...Object.keys(external.tokens)]);
|
|
48
|
+
for (const name of injectedNames) {
|
|
49
|
+
if (!nextNames.has(name)) root.style.removeProperty(name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 🔴 去重:theme 层与 external 层**同名** token 时(external 压过 theme),
|
|
53
|
+
// 同一变量会被 setProperty 两次(后者胜是设计行为),但注入名单里不该有重复名。
|
|
54
|
+
const applied = new Set<string>();
|
|
55
|
+
for (const [name, value] of Object.entries(theme.tokens)) {
|
|
56
|
+
root.style.setProperty(name, value);
|
|
57
|
+
applied.add(name);
|
|
58
|
+
}
|
|
59
|
+
for (const [name, value] of Object.entries(external.tokens)) {
|
|
60
|
+
root.style.setProperty(name, value);
|
|
61
|
+
applied.add(name);
|
|
62
|
+
}
|
|
63
|
+
injectedNames = [...applied];
|
|
64
|
+
|
|
65
|
+
return [...theme.rejected, ...external.rejected];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 只应用外部覆写(等价于 applyThemeLayers({ external })) */
|
|
69
|
+
export function applyThemeTokens(overrides: ThemeTokenOverrides | null): string[] {
|
|
70
|
+
return applyThemeLayers({ external: overrides });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 当前已注入的变量名(供验收脚本与单测断言注入是否真的发生) */
|
|
74
|
+
export function getInjectedTokenNames(): readonly string[] {
|
|
75
|
+
return injectedNames;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 重置模块级状态(HMR / 单测用) */
|
|
79
|
+
export function resetThemeRuntime(): void {
|
|
80
|
+
clearThemeTokens();
|
|
81
|
+
}
|
package/src/vue/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调试面板的**数据契约**
|
|
3
|
+
*
|
|
4
|
+
* 面板被抽进主题库后,就不能再自己 `useRoute()` / `useThemeStore()` 了 ——
|
|
5
|
+
* 那是宿主的框架与业务。现在它只认这份契约:**宿主算好模型、给出动作**,
|
|
6
|
+
* 面板负责渲染与交互。于是:
|
|
7
|
+
* • 任何前端(乃至别的框架包壳)都能复用同一个面板;
|
|
8
|
+
* • 面板的行为可以在单测里用一组假数据完整覆盖,不需要起真路由。
|
|
9
|
+
*
|
|
10
|
+
* @author yijiu2025
|
|
11
|
+
* @since 2026-10-17
|
|
12
|
+
*/
|
|
13
|
+
import type { ThemeMode, ThemeTone } from '../scheme';
|
|
14
|
+
|
|
15
|
+
/** 一张色卡 */
|
|
16
|
+
export interface ThemeDebugColor {
|
|
17
|
+
/** 配色 id(目录名) */
|
|
18
|
+
id: string;
|
|
19
|
+
/** 中文显示名 */
|
|
20
|
+
name: string;
|
|
21
|
+
/** 系别(白系 / 黑系) */
|
|
22
|
+
tone: ThemeTone;
|
|
23
|
+
/** 色卡底色(`#rrggbb`);缺省用中性灰兜底 */
|
|
24
|
+
preview?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 面板要渲染的**全部**状态 */
|
|
28
|
+
export interface ThemeDebugModel {
|
|
29
|
+
/** 当前页面名;该页未接入版式机制时留空(面板不显示版式行) */
|
|
30
|
+
page?: string;
|
|
31
|
+
/** 可选设备清单(宿主声明) */
|
|
32
|
+
devices: readonly string[];
|
|
33
|
+
/** 设备显示名;缺省直接用 id */
|
|
34
|
+
deviceLabels?: Readonly<Record<string, string>>;
|
|
35
|
+
/** 当前**生效**设备(渲染形态决定的那个) */
|
|
36
|
+
device: string;
|
|
37
|
+
/** 面板手动覆盖的设备;`null` = 跟随页面 */
|
|
38
|
+
deviceOverride: string | null;
|
|
39
|
+
/** 当前页可选版式(= 可选主题包) */
|
|
40
|
+
views: string[];
|
|
41
|
+
/** 当前生效版式 */
|
|
42
|
+
view: string;
|
|
43
|
+
/** 该页还有哪些版式挂了配色(用于提示"切过去颜色会变") */
|
|
44
|
+
viewsWithColors?: string[];
|
|
45
|
+
/** 当前版式支持的配色 */
|
|
46
|
+
colors: ThemeDebugColor[];
|
|
47
|
+
/** 当前**生效**配色 id */
|
|
48
|
+
color: string;
|
|
49
|
+
/** 用户显式选过、但当前作用域落不到它时用于「选择 → 生效」提示 */
|
|
50
|
+
selectedColor?: string;
|
|
51
|
+
/** 明暗意图 */
|
|
52
|
+
mode: ThemeMode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 面板能触发的**全部**动作 */
|
|
56
|
+
export interface ThemeDebugActions {
|
|
57
|
+
/** 覆盖设备;传 `null` 表示跟随页面 */
|
|
58
|
+
pickDevice(device: string | null): void;
|
|
59
|
+
/** 切换版式(宿主负责改 URL,面板不碰路由) */
|
|
60
|
+
pickView(id: string): void;
|
|
61
|
+
/** 切换配色 */
|
|
62
|
+
pickColor(id: string): void;
|
|
63
|
+
/** 切换明暗意图 */
|
|
64
|
+
pickMode(mode: ThemeMode): void;
|
|
65
|
+
/** 恢复默认 */
|
|
66
|
+
reset(): void;
|
|
67
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 父应用主题同步(iframe 嵌入场景)
|
|
3
|
+
*
|
|
4
|
+
* 父应用(posecraft / firewall)切主题时,通过 `postMessage` 通知子应用
|
|
5
|
+
* (被嵌在 iframe 里的登录页)同步切换,解决 iframe 隔离导致的父子主题不同步。
|
|
6
|
+
*
|
|
7
|
+
* === 消息协议 ===
|
|
8
|
+
* ```
|
|
9
|
+
* { type: 'THEME_CHANGE', isDark: boolean } // 旧版:只同步明暗
|
|
10
|
+
* { type: 'THEME_CHANGE', isDark?, theme?, skin?, tokens? } // 新版:可同时换主题
|
|
11
|
+
* ```
|
|
12
|
+
* `theme` 是配色 id;`skin` 是它的旧字段名(两者等价)。
|
|
13
|
+
* `tokens` 是一组**扁平**的 CSS 变量覆写表,实现「父应用给子页面定制每个组件」。
|
|
14
|
+
* 旧的 `{ light, dark }` 两档结构不再接受 —— 配色自带完整底色,明暗档已取消。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ `tokens` 的每一项都会过白名单校验(token 名 + 取值形态),不合规的丢弃并告警。
|
|
17
|
+
* 父页面虽然是受信来源,但 postMessage 的载荷在浏览器里终究是外部输入,
|
|
18
|
+
* 未校验就写进 CSS 变量等于开了 CSS 注入的口子(`url()` 一律被拒)。
|
|
19
|
+
* 校验发生在宿主的 store / runtime 层,本模块只做**协议解析与分发**。
|
|
20
|
+
*
|
|
21
|
+
* === 为什么 origin 白名单是宿主的参数 ===
|
|
22
|
+
* 「哪些父应用可信」是部署事实(域名、端口、是否 https),库无从知道。
|
|
23
|
+
* 所以这里只调用宿主给的 `isAllowedOrigin`;库负责的是「什么时候问、问什么」。
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* useParentThemeSync({
|
|
28
|
+
* isAllowedOrigin: isAllowedParentOrigin,
|
|
29
|
+
* apply: config => themeStore.applyThemeConfig(config),
|
|
30
|
+
* applyDark: dark => themeStore.applyTheme(dark)
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @author yijiu2025
|
|
35
|
+
* @since 2026-08-30
|
|
36
|
+
*/
|
|
37
|
+
import type { RemoteThemeConfig } from '../scheme';
|
|
38
|
+
|
|
39
|
+
/** 宿主适配器 */
|
|
40
|
+
export interface ParentThemeSyncOptions {
|
|
41
|
+
/** 该 origin 是否可信(宿主维护白名单) */
|
|
42
|
+
isAllowedOrigin(origin: string): boolean;
|
|
43
|
+
/** 收到配色 / tokens 变更 */
|
|
44
|
+
apply(config: RemoteThemeConfig): void;
|
|
45
|
+
/**
|
|
46
|
+
* 收到明暗变更(旧协议兼容)
|
|
47
|
+
*
|
|
48
|
+
* 不传则忽略旧协议的 `isDark` 字段 —— 宿主若已把明暗纳入 `apply({mode})`,
|
|
49
|
+
* 可以只实现一个入口。
|
|
50
|
+
*/
|
|
51
|
+
applyDark?(dark: boolean): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 监听父应用的主题同步消息(**立即注册**,返回 cleanup)
|
|
56
|
+
*
|
|
57
|
+
* 仅在 iframe 嵌入时生效(`handleMessage` 内判断顶层窗口无父则忽略)。
|
|
58
|
+
* 不依赖组件生命周期(`onMounted`/`onUnmounted`):来源同步已归主题运行时
|
|
59
|
+
* (宿主 store 的 runtime),那里没有组件实例,改用「立即 addEventListener +
|
|
60
|
+
* 返回 cleanup」,由宿主在 store 的 dispose 里调 cleanup。
|
|
61
|
+
*/
|
|
62
|
+
export function useParentThemeSync(options: ParentThemeSyncOptions): () => void {
|
|
63
|
+
function handleMessage(event: MessageEvent): void {
|
|
64
|
+
// 顶层窗口无父,忽略(非 iframe 场景)
|
|
65
|
+
if (!(window.parent && window.parent !== window)) return;
|
|
66
|
+
|
|
67
|
+
// origin 校验:只接受白名单父应用的消息(防恶意嵌入伪造主题切换)
|
|
68
|
+
if (!options.isAllowedOrigin(event.origin)) return;
|
|
69
|
+
|
|
70
|
+
const { type, isDark, theme, skin, tokens } = (event.data ?? {}) as {
|
|
71
|
+
type?: unknown;
|
|
72
|
+
isDark?: unknown;
|
|
73
|
+
theme?: unknown;
|
|
74
|
+
skin?: unknown;
|
|
75
|
+
tokens?: unknown;
|
|
76
|
+
};
|
|
77
|
+
if (type !== 'THEME_CHANGE') return;
|
|
78
|
+
|
|
79
|
+
// 明暗:新旧协议都支持
|
|
80
|
+
if (typeof isDark === 'boolean' && options.applyDark) {
|
|
81
|
+
options.applyDark(isDark);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 主题与组件级覆写(新协议)。`skin` 是 `theme` 的旧字段名,历史父应用仍能生效。
|
|
85
|
+
const wanted = typeof theme === 'string' ? theme : typeof skin === 'string' ? skin : undefined;
|
|
86
|
+
if (wanted !== undefined || (tokens && typeof tokens === 'object')) {
|
|
87
|
+
options.apply({
|
|
88
|
+
theme: wanted,
|
|
89
|
+
tokens:
|
|
90
|
+
tokens && typeof tokens === 'object'
|
|
91
|
+
? (tokens as RemoteThemeConfig['tokens'])
|
|
92
|
+
: undefined
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
window.addEventListener('message', handleMessage);
|
|
98
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
99
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调试面板的**适配器**(无参)—— 把应用状态翻译成面板的 `model` / `actions`
|
|
3
|
+
*
|
|
4
|
+
* === 无 host:一切从「域」自动取 ===
|
|
5
|
+
* 面板主体(`ThemeDebugPanelBody.vue`)是纯展示:不读路由、不读 store。但「当前是哪一页、
|
|
6
|
+
* 有哪些版式、点了之后改哪个 URL」需要宿主的 store / 路由 / 页面机制。早先这些事实靠
|
|
7
|
+
* 宿主写一个 `host` 对象注入;现在它们都已收进库内「域」,本文件**无参**地从域取:
|
|
8
|
+
*
|
|
9
|
+
* · store → `<ThemeRoot :themeStore>` provide,`inject`(`ThemeStoreLike`)
|
|
10
|
+
* · 设备 → 设备域 `registeredDevices()` / `currentDevice()`(`defineThemeDevice` 自动注册)
|
|
11
|
+
* · 页面 → 页面域 `pageOf()`(`defineThemePage` 自动注册)+ store `activePage`(ThemeView 维护)
|
|
12
|
+
* · 配色查询 → 内核注册表单例(`listColorsOf` / `toneOfColor` / `listColorViews`)
|
|
13
|
+
* · 路由 → 设备域(`app.use(theme, { router })` 传入):`currentQuery()` / `replaceQuery()`
|
|
14
|
+
*
|
|
15
|
+
* 于是宿主**零接线**:App.vue 里一行 `<ThemeDebugPanel />`,其余全在库内自动完成。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 可见性判断(清单 `debug` 开关 + `?debug=theme`)**不在本文件** —— 已移到门卫
|
|
18
|
+
* `ThemeDebugPanel.vue`(它算 `visible` 再异步加载面板主体)。本文件只负责「显示什么」。
|
|
19
|
+
*
|
|
20
|
+
* === 为什么「生效配色」走 `store.themeRecordFor` 而不是内核 registry ===
|
|
21
|
+
* `themeRecordFor` 的语义包含「URL 设备覆盖 + 用户显式操作清除覆盖」,这是宿主的运行时
|
|
22
|
+
* 状态(`deviceTheme` 初始自 URL、之后 `setTheme` 会清掉),不是实时读 URL 能还原的。
|
|
23
|
+
* 内核 registry 只能查「给定 id 有没有、什么系别」,算不出「当前生效哪套」,所以这一处
|
|
24
|
+
* 从 store 读(其余配色清单 / 系别都走内核 registry)。
|
|
25
|
+
*
|
|
26
|
+
* @author yijiu2025
|
|
27
|
+
* @since 2026-10-17(2026-10-18 无 host 化:删 host 注入,改从域自动取)
|
|
28
|
+
*/
|
|
29
|
+
import { computed, inject, ref } from 'vue';
|
|
30
|
+
import type { ComputedRef } from 'vue';
|
|
31
|
+
import { listColorViews, listColorsOf, toneOfColor } from '../registry';
|
|
32
|
+
import { readDeviceParam } from '../views/params';
|
|
33
|
+
import type { ThemeDebugActions, ThemeDebugColor, ThemeDebugModel } from './types';
|
|
34
|
+
import { currentDevice, currentQuery, replaceQuery } from './device-domain';
|
|
35
|
+
import { pageOf } from './view-page';
|
|
36
|
+
import { registeredDevices, urlKeyOf } from './define-theme-device';
|
|
37
|
+
import { themeStoreKey } from './theme-store';
|
|
38
|
+
import type { ThemeStoreLike } from './theme-store';
|
|
39
|
+
|
|
40
|
+
/** 只把「非空字符串」当有效参数 —— query 值可能是数组 */
|
|
41
|
+
function asText(value: unknown): string {
|
|
42
|
+
return typeof value === 'string' ? value : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useThemeDebugPanel(): {
|
|
46
|
+
model: ComputedRef<ThemeDebugModel>;
|
|
47
|
+
actions: ThemeDebugActions;
|
|
48
|
+
} {
|
|
49
|
+
const store = inject<ThemeStoreLike>(themeStoreKey);
|
|
50
|
+
if (!store) {
|
|
51
|
+
throw new Error('[ThemeDebugPanel] 未注入主题 store —— 请通过 <ThemeRoot :themeStore> 提供');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 面板上手动指定的设备(覆盖页面判定),用于在**同一台电脑**预览另一端的配色 */
|
|
55
|
+
const deviceOverride = ref<string | null>(null);
|
|
56
|
+
|
|
57
|
+
/** 可选设备清单 + 显示名(来自设备域 `defineThemeDevice` 自动注册) */
|
|
58
|
+
const devices = registeredDevices().map(d => d.id);
|
|
59
|
+
const deviceLabels: Record<string, string> = Object.fromEntries(
|
|
60
|
+
registeredDevices().map(d => [d.id, d.label])
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** 当前生效设备:面板覆盖优先,否则用设备域判定的 */
|
|
64
|
+
const device = computed<string>(() => deviceOverride.value ?? currentDevice().value);
|
|
65
|
+
|
|
66
|
+
/** 当前生效页面:由 `<ThemeView>` 的 `setActivePageView` 维护(不再从路径反推) */
|
|
67
|
+
const activePage = computed(() => store.activePage);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 当前生效的**版式 id**
|
|
71
|
+
*
|
|
72
|
+
* 🔴 这个 computed 必须定义在 `viewOptions` 之前:后者求值时读它,顺序颠倒会在
|
|
73
|
+
* 首次求值时踩 TDZ(现象是面板整个不渲染)。fallback 是 `store.packageId`
|
|
74
|
+
* 而不是 `'base'`:新版式架构下 `view ≡ pkg`,`'base'` 已不是合法 view 名。
|
|
75
|
+
*/
|
|
76
|
+
const activeViewId = computed(() => {
|
|
77
|
+
const registry = activePage.value ? pageOf(activePage.value)?.views : undefined;
|
|
78
|
+
if (!registry) return store.packageId;
|
|
79
|
+
const pkg = store.packageId;
|
|
80
|
+
const dev = device.value;
|
|
81
|
+
// 设备维度参数:`th_<urlKey>` 优先于 `th`(与容器 pick 同一套读取规则)
|
|
82
|
+
const fromUrl = asText(readDeviceParam(currentQuery(), 'th', urlKeyOf(dev)));
|
|
83
|
+
if (fromUrl && fromUrl !== 'base') {
|
|
84
|
+
const inCurrent = registry.resolve(fromUrl, pkg, dev);
|
|
85
|
+
if (inCurrent) return inCurrent;
|
|
86
|
+
if (registry.has(fromUrl, fromUrl, dev)) return fromUrl;
|
|
87
|
+
return pkg;
|
|
88
|
+
}
|
|
89
|
+
const declared = activePage.value ? store.viewFor(activePage.value, dev) : undefined;
|
|
90
|
+
return (declared ? registry.resolve(declared, pkg, dev) : null) ?? pkg;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 当前页的**可选版式**(= 可选主题包)。一个主题包 = 一种版式,所以版式清单
|
|
95
|
+
* 就是主题包清单:枚举注册表里当前设备的每个包,包名即版式 id。
|
|
96
|
+
*/
|
|
97
|
+
const viewOptions = computed<string[]>(() => {
|
|
98
|
+
const registry = activePage.value ? pageOf(activePage.value)?.views : undefined;
|
|
99
|
+
if (!registry || !activePage.value) return [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
for (const { pkg, device: pkgDevice } of registry.packages()) {
|
|
102
|
+
if (pkgDevice !== device.value) continue;
|
|
103
|
+
seen.add(pkg);
|
|
104
|
+
}
|
|
105
|
+
// 当前生效 view 一定在列(即使该范围内没扫到它,例如外部强制写了未知 id)
|
|
106
|
+
if (!seen.has(activeViewId.value)) seen.add(activeViewId.value);
|
|
107
|
+
return [...seen].sort();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** **当前版式**支持的颜色 —— 切版式后这份清单会变,正是"每版式各自选色"的落点 */
|
|
111
|
+
const colors = computed<ThemeDebugColor[]>(() => {
|
|
112
|
+
const active = activePage.value;
|
|
113
|
+
if (!active) return [];
|
|
114
|
+
return listColorsOf(device.value, active, activeViewId.value).map(meta => ({
|
|
115
|
+
id: meta.id,
|
|
116
|
+
name: meta.name,
|
|
117
|
+
tone: toneOfColor(meta.id, device.value, active, activeViewId.value) ?? 'light',
|
|
118
|
+
preview: meta.preview?.primary
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** 该页在该设备下挂了配色的版式清单(提示"这页还有哪些版式有颜色") */
|
|
123
|
+
const viewsWithColors = computed<string[]>(() =>
|
|
124
|
+
activePage.value ? listColorViews(device.value, activePage.value) : []
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 当前**生效**的配色 id(含 URL 设备覆盖 + 用户显式清除覆盖的语义)——
|
|
129
|
+
* 🔴 传 `activeViewId` 作为 pkg:view ≡ pkg,否则会把 compact 包点的 blue 又落回 default 包。
|
|
130
|
+
*/
|
|
131
|
+
const activeColorId = computed(
|
|
132
|
+
() => store.themeRecordFor(activeViewId.value, device.value, activePage.value).meta.id
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const model = computed<ThemeDebugModel>(() => ({
|
|
136
|
+
page: activePage.value,
|
|
137
|
+
devices,
|
|
138
|
+
deviceLabels,
|
|
139
|
+
device: device.value,
|
|
140
|
+
deviceOverride: deviceOverride.value,
|
|
141
|
+
views: viewOptions.value,
|
|
142
|
+
view: activeViewId.value,
|
|
143
|
+
viewsWithColors: viewsWithColors.value,
|
|
144
|
+
colors: colors.value,
|
|
145
|
+
color: activeColorId.value,
|
|
146
|
+
selectedColor: store.themeId,
|
|
147
|
+
mode: store.mode
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
const actions: ThemeDebugActions = {
|
|
151
|
+
pickDevice(id) {
|
|
152
|
+
// 面板发来的是 `devices` 里的值,这里再收一次口:越界值按"跟随页面"处理。
|
|
153
|
+
deviceOverride.value = id !== null && devices.includes(id) ? id : null;
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 切换版式(**只改 URL 的版式键,path 不动**)
|
|
158
|
+
*
|
|
159
|
+
* 🔴 设备维度参数:版式键可能是 `th` 也可能是 `th_<urlKey>`(设备专属优先)。
|
|
160
|
+
* 必须改**当前设备实际由哪条键决定**的那一条,否则带 `?th_std=compact` 时
|
|
161
|
+
* 点面板会写 `th=xxx` 而被设备专属键盖住 —— 表现是"点了没反应"。
|
|
162
|
+
* 选回当前生效版式时把参数**删掉**(URL 干净,与容器「无参数即默认 view」一致)。
|
|
163
|
+
*/
|
|
164
|
+
pickView(id) {
|
|
165
|
+
const scopedKey = `th_${urlKeyOf(device.value)}`;
|
|
166
|
+
const key = scopedKey in currentQuery() ? scopedKey : 'th';
|
|
167
|
+
if (id === activeViewId.value) replaceQuery({ [key]: undefined });
|
|
168
|
+
else replaceQuery({ [key]: id });
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 选颜色:**只换色,不碰明暗**(两者正交)。不改 URL:配色是运行时状态
|
|
173
|
+
* (store + localStorage),面板改的是当前会话观感。
|
|
174
|
+
*/
|
|
175
|
+
pickColor(id) {
|
|
176
|
+
store.setTheme(id);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
pickMode(mode) {
|
|
180
|
+
store.setMode(mode);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/** 一键回默认:清掉落盘的配色与明暗偏好 */
|
|
184
|
+
reset() {
|
|
185
|
+
store.setMode('system');
|
|
186
|
+
const first = colors.value[0];
|
|
187
|
+
if (first) store.setTheme(first.id);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return { model, actions };
|
|
192
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题意图**来源同步** —— 拉后端配色 + 父应用主题同步(都读 env,宿主零传参)
|
|
3
|
+
*
|
|
4
|
+
* === 为什么归这里(而非 ThemeRoot)===
|
|
5
|
+
* 后端下发与父应用 postMessage 都是**主题意图来源**(与 URL / 用户操作 / localStorage
|
|
6
|
+
* 同类,见 6.2 来源优先级),不是「外观外壳」的职责。ThemeRoot 退化为纯外观壳后,
|
|
7
|
+
* 这两条「来源」由主题运行时(宿主的 `useThemeRuntime`)调用本 composable 接入,
|
|
8
|
+
* 落地动作(`applyThemeConfig` / `applyTheme`)由宿主 store 注入 —— 库不 import 宿主 store。
|
|
9
|
+
*
|
|
10
|
+
* === 为什么读 env ===
|
|
11
|
+
* 端点(`VITE_THEME_ENDPOINT`)与父 origin 白名单(`VITE_ALLOWED_PARENT_ORIGINS`)都是
|
|
12
|
+
* 主题域的部署事实,库直接读这两个约定好的 env 变量 —— 宿主配好 env 即生效,不再传参。
|
|
13
|
+
*
|
|
14
|
+
* === 为什么不依赖组件生命周期 ===
|
|
15
|
+
* 主题运行时(store)没有组件实例,`onMounted`/`onUnmounted` 在那里会失效(静默 bug)。
|
|
16
|
+
* 所以这里「立即注册父同步 + 立即发起后端拉取」,返回 cleanup,由宿主在 store 的
|
|
17
|
+
* dispose 里调用 cleanup(父同步 removeEventListener)。
|
|
18
|
+
*
|
|
19
|
+
* @author yijiu2025
|
|
20
|
+
* @since 2026-10-18
|
|
21
|
+
*/
|
|
22
|
+
import { useParentThemeSync } from './useParentThemeSync';
|
|
23
|
+
import { fetchRemoteThemeConfig } from './remote-fetch';
|
|
24
|
+
import type { RemoteThemeConfig } from '../scheme';
|
|
25
|
+
|
|
26
|
+
export interface ThemeSourcesOptions {
|
|
27
|
+
/** 把外部配置落到宿主 store(含 null = 撤销外部覆写) */
|
|
28
|
+
applyThemeConfig(config: RemoteThemeConfig | null): void;
|
|
29
|
+
/** 把明暗落到宿主 store(父应用旧协议 `isDark`) */
|
|
30
|
+
applyTheme(dark: boolean): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 从 env 读父 origin 白名单(逗号分隔);空 = 无父同步 */
|
|
34
|
+
function allowedParentOrigins(): string[] {
|
|
35
|
+
const raw = import.meta.env.VITE_ALLOWED_PARENT_ORIGINS as string | undefined;
|
|
36
|
+
return typeof raw === 'string' ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从 env 读后端配色端点;空 = 不拉远程 */
|
|
40
|
+
function remoteEndpoint(): string {
|
|
41
|
+
return (import.meta.env.VITE_THEME_ENDPOINT as string | undefined) ?? '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 接入两条主题意图来源(父同步 + 后端配色),返回 cleanup
|
|
46
|
+
*
|
|
47
|
+
* 宿主 `useThemeRuntime` 里调一次:`const disposeSources = useThemeSources({ applyThemeConfig, applyTheme })`,
|
|
48
|
+
* 并在 store 的 dispose 里调 `disposeSources()`。
|
|
49
|
+
*/
|
|
50
|
+
export function useThemeSources(options: ThemeSourcesOptions): () => void {
|
|
51
|
+
const cleanups: Array<() => void> = [];
|
|
52
|
+
|
|
53
|
+
// 父主题同步(iframe 嵌入时父切主题子跟着切)
|
|
54
|
+
const allowed = allowedParentOrigins();
|
|
55
|
+
if (allowed.length > 0) {
|
|
56
|
+
cleanups.push(
|
|
57
|
+
useParentThemeSync({
|
|
58
|
+
isAllowedOrigin: origin => allowed.includes(origin),
|
|
59
|
+
apply: config => options.applyThemeConfig(config),
|
|
60
|
+
applyDark: dark => options.applyTheme(dark)
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 拉后端配色(异步发起,失败静默,不阻塞首屏)
|
|
66
|
+
const endpoint = remoteEndpoint();
|
|
67
|
+
void (async () => {
|
|
68
|
+
const clientId = new URLSearchParams(window.location.search).get('client_id') || undefined;
|
|
69
|
+
const config = await fetchRemoteThemeConfig(clientId, { endpoint });
|
|
70
|
+
if (config) options.applyThemeConfig(config);
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
return () => cleanups.forEach(fn => fn());
|
|
74
|
+
}
|