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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题状态机装配入口 —— `createThemeState(options)`(宿主一行 `defineStore` 包起来)
|
|
3
|
+
*
|
|
4
|
+
* 从 `oauth21/src/stores/theme.ts` 的编排逻辑下沉(2026-10-19)。
|
|
5
|
+
*
|
|
6
|
+
* === 为什么下沉编排、但**不**下沉 pinia ===
|
|
7
|
+
* 状态机的三块 composable(scope / intent / runtime)+ 持久化层都不依赖 pinia(只用
|
|
8
|
+
* `ref`/`computed`/`watch`),所以「下沉状态机」不需要把 pinia 传给包 —— 只要把**编排**
|
|
9
|
+
* 收成一个纯函数,宿主用一行 `defineStore('theme', () => createThemeState())` 包成 store。
|
|
10
|
+
* pinia 只是把 composable 注册成 store 实例的一层壳,不值得为它给库加 peer 依赖。
|
|
11
|
+
*
|
|
12
|
+
* === 宿主策略怎么收口(`ThemeStateOptions`)===
|
|
13
|
+
* 状态机里唯一可能因宿主而异的,是**持久化契约**:localStorage 键名、URL 配色参数前缀。
|
|
14
|
+
* 这两样下沉后作为默认值(对齐主题机制约定),宿主与之冲突时通过 options 覆盖。
|
|
15
|
+
* 后端配色端点 / 父应用 origin 白名单已通过 env 注入(`useThemeSources` 读
|
|
16
|
+
* `VITE_THEME_ENDPOINT` / `VITE_ALLOWED_PARENT_ORIGINS`),不进 options。
|
|
17
|
+
*
|
|
18
|
+
* === 调试探针 ===
|
|
19
|
+
* dev + `?debug=theme` 时把只读快照挂到 `window.__MAUTH_STORE__`(getter 形态,随 store 实时)。
|
|
20
|
+
* 探针需要访问 scope/intent 的内部 ref,所以随编排一起下沉(而不是留在宿主壳里)。
|
|
21
|
+
*
|
|
22
|
+
* @author yijiu2025
|
|
23
|
+
* @since 2026-10-19(从 oauth21/src/stores/theme.ts 下沉编排,pinia 留在宿主)
|
|
24
|
+
*/
|
|
25
|
+
import { useThemeScope } from './theme-scope';
|
|
26
|
+
import { useThemeIntent } from './theme-intent';
|
|
27
|
+
import { useThemeRuntime } from './theme-runtime';
|
|
28
|
+
import { DEFAULT_COLOR_PARAM, DEFAULT_STORAGE_KEYS } from './theme-persistence';
|
|
29
|
+
import type { ThemeStorageKeys } from './theme-persistence';
|
|
30
|
+
|
|
31
|
+
/** 宿主策略收口点(全可选,默认 = 主题机制约定,oauth21 即默认值本身) */
|
|
32
|
+
export interface ThemeStateOptions {
|
|
33
|
+
/** localStorage 键名(与宿主已有存储冲突时可覆盖) */
|
|
34
|
+
storageKeys?: Partial<ThemeStorageKeys>;
|
|
35
|
+
/** URL 配色意图参数前缀(配合设备 urlKey 组成 `th_color_<urlKey>`) */
|
|
36
|
+
colorParam?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 装配主题状态机,返回与「主题 store」一致的公开面。
|
|
41
|
+
*
|
|
42
|
+
* 用法(宿主就这么多):
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { defineStore } from 'pinia';
|
|
45
|
+
* import { createThemeState } from 'skinsuite/vue';
|
|
46
|
+
* import 'virtual:theme'; // 副作用:装版式加载表 + 注册表 + 清单 + 拉齐 views/*.ts 声明
|
|
47
|
+
*
|
|
48
|
+
* export const useThemeStore = defineStore('theme', () => createThemeState());
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* 返回对象里的 ref 会被 pinia 自动解包(与 `defineStore(() => ({ ...refs }))` 同形)。
|
|
52
|
+
*/
|
|
53
|
+
export function createThemeState(options: ThemeStateOptions = {}) {
|
|
54
|
+
const keys: ThemeStorageKeys = { ...DEFAULT_STORAGE_KEYS, ...options.storageKeys };
|
|
55
|
+
const colorParam = options.colorParam ?? DEFAULT_COLOR_PARAM;
|
|
56
|
+
|
|
57
|
+
// ① 作用域(共享基础:activeDevice / activePage / activeView / themeRecordFor / isDark 等)
|
|
58
|
+
const scope = useThemeScope(keys, colorParam);
|
|
59
|
+
// ② 意图(明暗三态 + 记忆槽 + 色系联动 + 用户操作入口)
|
|
60
|
+
const intent = useThemeIntent(scope, keys);
|
|
61
|
+
// ③ 运行时(token 注入 + 附加样式 + 明暗类 + 外部配置);必须最后创建(内部 watch immediate 依赖前两步)
|
|
62
|
+
const runtime = useThemeRuntime(scope, intent);
|
|
63
|
+
|
|
64
|
+
// -------------------------------------------------------------------------
|
|
65
|
+
// 调试探针(仅 dev + ?debug=theme):getter 形态随 store 实时取最新值
|
|
66
|
+
// -------------------------------------------------------------------------
|
|
67
|
+
if (import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(location.search).get('debug') === 'theme') {
|
|
68
|
+
(window as unknown as Record<string, unknown>).__MAUTH_STORE__ = {
|
|
69
|
+
get themeId() { return scope.themeId.value; },
|
|
70
|
+
get deviceTheme() { return { ...scope.deviceTheme.value }; },
|
|
71
|
+
get mode() { return intent.mode.value; },
|
|
72
|
+
get packageId() { return scope.packageId.value; },
|
|
73
|
+
get activeDevice() { return scope.activeDevice.value; },
|
|
74
|
+
get activePage() { return scope.activePage.value; },
|
|
75
|
+
get activeView() { return scope.activeView.value; },
|
|
76
|
+
get activeThemeId() { return scope.themeRecordFor().meta.id; },
|
|
77
|
+
get activeTone() { return scope.themeRecordFor().tone; }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
// ---- 来自 scope ----
|
|
83
|
+
mode: intent.mode,
|
|
84
|
+
isDark: scope.isDark,
|
|
85
|
+
themeId: scope.themeId,
|
|
86
|
+
packageId: scope.packageId,
|
|
87
|
+
device: scope.device,
|
|
88
|
+
activeDevice: scope.activeDevice,
|
|
89
|
+
activePage: scope.activePage,
|
|
90
|
+
activeView: scope.activeView,
|
|
91
|
+
activeTone: scope.activeTone,
|
|
92
|
+
themes: scope.themes,
|
|
93
|
+
systemDark: intent.systemDark,
|
|
94
|
+
themeTokens: runtime.themeTokens,
|
|
95
|
+
externalTokens: runtime.externalTokens,
|
|
96
|
+
lightColor: intent.lightColor,
|
|
97
|
+
darkColor: intent.darkColor,
|
|
98
|
+
themeRecordFor: scope.themeRecordFor,
|
|
99
|
+
setActiveDevice: scope.setActiveDevice,
|
|
100
|
+
setActivePageView: scope.setActivePageView,
|
|
101
|
+
// ---- 来自 intent ----
|
|
102
|
+
setMode: intent.setMode,
|
|
103
|
+
cycleMode: intent.cycleMode,
|
|
104
|
+
toggleTheme: intent.toggleTheme,
|
|
105
|
+
setTheme: intent.setTheme,
|
|
106
|
+
viewFor: scope.viewFor,
|
|
107
|
+
// ---- 来自 runtime ----
|
|
108
|
+
applyThemeConfig: runtime.applyThemeConfig,
|
|
109
|
+
applyTheme: intent.applyTheme,
|
|
110
|
+
// 销毁:intent 的系统偏好监听 + runtime 的来源同步(父同步 removeEventListener)
|
|
111
|
+
dispose: () => {
|
|
112
|
+
intent.dispose();
|
|
113
|
+
runtime.disposeSources();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备声明工厂 —— `defineThemeDevice`(声明式,自动注册进设备域)
|
|
3
|
+
*
|
|
4
|
+
* 宿主只写一条声明(`id` / `label` / `urlKey` / `containers` 三参数 / `routePath` /
|
|
5
|
+
* 声明式 `match`),机制全在库内:`pageContainers` 建容器表、声明式 `match` → 判定函数、
|
|
6
|
+
* 自动注册进设备域。宿主不再写 `createDeviceRegistry` 装配、不再手动组数组、不再转发
|
|
7
|
+
* `asKnownDevice`。
|
|
8
|
+
*
|
|
9
|
+
* === 声明式 match 的优先级规则(库内唯一实现)===
|
|
10
|
+
* 字段间 OR;`explicitMobile` 是**全局最高优先信号** —— 一旦 `ctx.explicitMobile=true`,
|
|
11
|
+
* 带 `from`/`fromLogin`/`pathSegment` 的条目直接跳过,落到 `explicitMobile: true` 的条目
|
|
12
|
+
* (mobile)。宿主不再手写「排除显式手机意图」的 `!explicitMobile`。
|
|
13
|
+
*
|
|
14
|
+
* @author yijiu2025
|
|
15
|
+
* @since 2026-10-18(从 oauth21/src/theme/views/devices.ts 的声明下沉)
|
|
16
|
+
*/
|
|
17
|
+
import { pickDeviceId as pickDeviceIdOf } from '../views/device';
|
|
18
|
+
import type { DeviceDef, DeviceMatchContext } from '../views/device';
|
|
19
|
+
import { pageContainers, resolveContainerOf as resolveContainerOfWith } from './device-containers';
|
|
20
|
+
import type { DeviceContainerLoader } from './device-containers';
|
|
21
|
+
|
|
22
|
+
/** 声明式 match:字段间 OR;`fallback` 与其它互斥 */
|
|
23
|
+
export interface DeclarativeMatch {
|
|
24
|
+
/** `?isMobile=true` 显式手机意图(全局最高优先信号) */
|
|
25
|
+
explicitMobile?: boolean;
|
|
26
|
+
/** 视口/UA 判为手机 */
|
|
27
|
+
viewportMobile?: boolean;
|
|
28
|
+
/** `?from=` 命中任一值 */
|
|
29
|
+
from?: string[];
|
|
30
|
+
/** `?fromLogin=` 命中任一值 */
|
|
31
|
+
fromLogin?: string[];
|
|
32
|
+
/** 路径含某整段/前缀 */
|
|
33
|
+
pathSegment?: string;
|
|
34
|
+
/** 兜底(无条件命中;必须是清单末条) */
|
|
35
|
+
fallback?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `defineThemeDevice` 的输入(声明式) */
|
|
39
|
+
export interface ThemeDeviceOptions {
|
|
40
|
+
/** 设备 id = 主题包内目录名,只允许 `[a-z0-9-]` */
|
|
41
|
+
id: string;
|
|
42
|
+
/** 中文名(调试面板 / 日志用) */
|
|
43
|
+
label: string;
|
|
44
|
+
/** 缩写名(URL 专属参数后缀用) */
|
|
45
|
+
urlKey: string;
|
|
46
|
+
/** 容器三参数:工厂内部自动执行 `pageContainers(glob, fileRe, idRe)` */
|
|
47
|
+
containers: {
|
|
48
|
+
/** `import.meta.glob(...)` 的返回值(必须字面量调用) */
|
|
49
|
+
glob: Record<string, DeviceContainerLoader>;
|
|
50
|
+
/** 文件名匹配正则(哪个文件算容器入口) */
|
|
51
|
+
fileRe: RegExp;
|
|
52
|
+
/** 页面名白名单(统一 `THEME_ID_RE`) */
|
|
53
|
+
idRe: RegExp;
|
|
54
|
+
};
|
|
55
|
+
/** 该设备在路由表占的 path(`{page}` 由页面名替换) */
|
|
56
|
+
routePath(page: string): string;
|
|
57
|
+
/** 声明式 match(不再手写闭包) */
|
|
58
|
+
match: DeclarativeMatch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 路径是否含某整段/前缀(`mini` 匹配 `/mini/login` 或旧 `mini-login`) */
|
|
62
|
+
function pathHasDeviceSegment(path: string, prefix: string): boolean {
|
|
63
|
+
return path
|
|
64
|
+
.split('/')
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.some(seg => seg === prefix || seg.startsWith(`${prefix}-`));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 声明式 match → 判定函数
|
|
71
|
+
*
|
|
72
|
+
* 字段间 OR;`explicitMobile` 是全局最高优先信号。
|
|
73
|
+
*/
|
|
74
|
+
function normalizeMatch(m: DeclarativeMatch): (ctx: DeviceMatchContext) => boolean {
|
|
75
|
+
if (m.fallback) return () => true;
|
|
76
|
+
return (ctx: DeviceMatchContext) => {
|
|
77
|
+
// 显式手机意图下,带 from/fromLogin/pathSegment 的条目跳过(落到 explicitMobile 条目)
|
|
78
|
+
if (ctx.explicitMobile && (m.from || m.fromLogin || m.pathSegment)) return false;
|
|
79
|
+
return (
|
|
80
|
+
(m.explicitMobile === true && ctx.explicitMobile) ||
|
|
81
|
+
(m.viewportMobile === true && ctx.viewportIsMobile) ||
|
|
82
|
+
(m.from !== undefined && m.from.includes(ctx.from)) ||
|
|
83
|
+
(m.fromLogin !== undefined && m.fromLogin.includes(ctx.fromLogin)) ||
|
|
84
|
+
(m.pathSegment !== undefined && pathHasDeviceSegment(ctx.path, m.pathSegment))
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 已自动注册的设备清单(顺序 = 优先级,末条必须 fallback 兜底) */
|
|
90
|
+
const registeredDefs: DeviceDef<DeviceContainerLoader>[] = [];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 声明一台设备并自动注册进设备域
|
|
94
|
+
*
|
|
95
|
+
* 内部做三件事:`pageContainers(glob, fileRe, idRe)` 建容器表、声明式 `match` → 判定函数、
|
|
96
|
+
* 注册进设备域。
|
|
97
|
+
*/
|
|
98
|
+
export function defineThemeDevice(options: ThemeDeviceOptions): DeviceDef<DeviceContainerLoader> {
|
|
99
|
+
const def: DeviceDef<DeviceContainerLoader> = {
|
|
100
|
+
id: options.id,
|
|
101
|
+
label: options.label,
|
|
102
|
+
urlKey: options.urlKey,
|
|
103
|
+
containers: pageContainers(
|
|
104
|
+
options.containers.glob,
|
|
105
|
+
options.containers.fileRe,
|
|
106
|
+
options.containers.idRe
|
|
107
|
+
),
|
|
108
|
+
routePath: options.routePath,
|
|
109
|
+
match: normalizeMatch(options.match)
|
|
110
|
+
};
|
|
111
|
+
registeredDefs.push(def);
|
|
112
|
+
return def;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 已注册的设备清单(设备域运行时 / URL 参数解析用) */
|
|
116
|
+
export function registeredDevices(): readonly DeviceDef<DeviceContainerLoader>[] {
|
|
117
|
+
return registeredDefs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 外部传入的设备名是否在设备域白名单里(归一化校验用;替代旧 `asThemeDevice` 转发) */
|
|
121
|
+
export function isKnownDevice(raw: unknown): raw is string {
|
|
122
|
+
return typeof raw === 'string' && registeredDefs.some(d => d.id === raw);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 设备 id → 缩写名(urlKey,URL 专属参数后缀用);未声明缩写时回落 id */
|
|
126
|
+
export function urlKeyOf(deviceId: string): string {
|
|
127
|
+
return registeredDefs.find(d => d.id === deviceId)?.urlKey ?? deviceId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------------------------------------------------------------------------
|
|
131
|
+
* 用设备域单例的查询 / 派生(替代宿主「传 registeredDefs」的转发层)
|
|
132
|
+
* ------------------------------------------------------------------------- */
|
|
133
|
+
|
|
134
|
+
/** 设备 id 清单(顺序即优先级,从已注册清单派生,不手写字面量) */
|
|
135
|
+
export function deviceIds(): string[] {
|
|
136
|
+
return registeredDefs.map(d => d.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 兜底设备 = 末条 fallback 声明的 id(顺序即优先级,末条必须 fallback);未声明设备时返回空串 */
|
|
140
|
+
export function fallbackDevice(): string {
|
|
141
|
+
return registeredDefs[registeredDefs.length - 1]?.id ?? '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 从外部输入判定「本次访问属于哪台设备」(用设备域单例 + 兜底设备) */
|
|
145
|
+
export function pickDeviceId(ctx: DeviceMatchContext): string {
|
|
146
|
+
return pickDeviceIdOf(registeredDefs, ctx, fallbackDevice());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 取某设备在某页的容器加载器,**没写时兜底到 standard 设备**(用设备域单例) */
|
|
150
|
+
export function resolveContainerOf(id: string, page: string): DeviceContainerLoader {
|
|
151
|
+
return resolveContainerOfWith(registeredDefs, id, page);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 重置模块级注册表(单测 / HMR 用) */
|
|
155
|
+
export function resetDevices(): void {
|
|
156
|
+
registeredDefs.length = 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备容器机制(**组件半边**)—— 「设备 id → 该页容器组件」的加载(内核版)
|
|
3
|
+
*
|
|
4
|
+
* 纯逻辑半边(设备判定 / 归一化 / 取容器表)在 `skinsuite` 的 `views/device.ts`;
|
|
5
|
+
* 本文件补上**组件**相关的一半:从 glob 结果建「页面 → 加载器」表、按设备取容器加载器。
|
|
6
|
+
*
|
|
7
|
+
* 之所以拆成两半:`import type { Component } from 'vue'` 让本文件只能住在 Vue 绑定层
|
|
8
|
+
* (`skinsuite/vue`),而判定逻辑是纯函数、值得被内核 tsc 独立检查、也能被非 Vue 前端复用。
|
|
9
|
+
*
|
|
10
|
+
* @author yijiu2025
|
|
11
|
+
* @since 2026-10-17(从 oauth21/src/theme/devices.ts 下沉)
|
|
12
|
+
*/
|
|
13
|
+
import type { Component } from 'vue';
|
|
14
|
+
import type { DeviceDef } from '../views/device';
|
|
15
|
+
import { containersOf } from '../views/device';
|
|
16
|
+
|
|
17
|
+
/** 设备容器的惰性加载器(与 `import.meta.glob` 的取值形态一致) */
|
|
18
|
+
export type DeviceContainerLoader = () => Promise<{ default: Component }>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 从「一批容器文件」建「页面 → 加载器」表
|
|
22
|
+
*
|
|
23
|
+
* 位置约定:`<...>/<页面名>/<文件名>.vue` —— **页面名 = 文件所在目录名**。
|
|
24
|
+
* 各设备用自己的 glob + 文件名规则调它,于是「容器放在哪、叫什么」是该设备自己的声明。
|
|
25
|
+
*/
|
|
26
|
+
export function pageContainers(
|
|
27
|
+
mods: Record<string, DeviceContainerLoader>,
|
|
28
|
+
fileRe: RegExp,
|
|
29
|
+
idRe: RegExp
|
|
30
|
+
): ReadonlyMap<string, DeviceContainerLoader> {
|
|
31
|
+
const map = new Map<string, DeviceContainerLoader>();
|
|
32
|
+
for (const [key, loader] of Object.entries(mods)) {
|
|
33
|
+
const parts = key.split('/');
|
|
34
|
+
const file = parts[parts.length - 1];
|
|
35
|
+
// 页面名 = 文件上一级目录名(`/src/view/app/login/index.vue` → `login`)
|
|
36
|
+
const page = parts[parts.length - 2];
|
|
37
|
+
if (!page || !file || !fileRe.test(file)) continue;
|
|
38
|
+
// 页面名同样进 data-* / 路由,走同一份白名单;不合规目录静默跳过
|
|
39
|
+
if (!idRe.test(page)) continue;
|
|
40
|
+
if (!map.has(page)) map.set(page, loader);
|
|
41
|
+
}
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 取某设备在某页的容器加载器;该设备没写这一页时返回 undefined */
|
|
46
|
+
export function containerOf(
|
|
47
|
+
defs: readonly DeviceDef<DeviceContainerLoader>[],
|
|
48
|
+
id: string,
|
|
49
|
+
page: string
|
|
50
|
+
): DeviceContainerLoader | undefined {
|
|
51
|
+
return containersOf(defs, id).get(page);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 取某设备在某页的容器加载器,**没写时兜底到 standard 设备**
|
|
56
|
+
*
|
|
57
|
+
* 分发器用它做「设备 id → 该页容器」的数据化映射:加一种设备时,只要给它写了容器,
|
|
58
|
+
* 分发器就能渲染;没写则该设备退到桌面容器 —— 不再写死「mobile ? X : mini ? Y : Standard」。
|
|
59
|
+
*/
|
|
60
|
+
export function resolveContainerOf(
|
|
61
|
+
defs: readonly DeviceDef<DeviceContainerLoader>[],
|
|
62
|
+
id: string,
|
|
63
|
+
page: string
|
|
64
|
+
): DeviceContainerLoader {
|
|
65
|
+
return (
|
|
66
|
+
containerOf(defs, id, page) ??
|
|
67
|
+
containerOf(defs, 'standard', page) ??
|
|
68
|
+
(() =>
|
|
69
|
+
Promise.reject(
|
|
70
|
+
new Error(`[theme] 设备「${id}」在页面「${page}」没有容器,也没有 standard 兜底`)
|
|
71
|
+
))
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备域运行时 —— 当前生效设备 `activeDevice` + matchMedia 视口判定 + 路由监听 → 判定
|
|
3
|
+
*
|
|
4
|
+
* 与 `define-theme-device`(声明工厂)配对:宿主在 `defineThemeDevice` 里声明设备清单
|
|
5
|
+
* (自动注册),本文件维护「当前属于哪台设备」这一运行时状态,供宿主 store 订阅、供
|
|
6
|
+
* token 注入 / 版式查找取用。
|
|
7
|
+
*
|
|
8
|
+
* === 信号来源 ===
|
|
9
|
+
* 设备判定只有「设备清单 `match`」一个来源,输入是 path / query(从 router 拿)+ 视口
|
|
10
|
+
* (`matchMedia`,阈值可配)。**不再有 `meta.device`** 这个冗余基线。
|
|
11
|
+
*
|
|
12
|
+
* === 为什么库只认 router 的最小结构 ===
|
|
13
|
+
* 库不 import vue-router(应用世界观),只认 `currentRoute.value.{ query, path, fullPath }`
|
|
14
|
+
* 这三个字段 —— 宿主把 router 传进来(`app.use(theme, { router })`)。
|
|
15
|
+
*
|
|
16
|
+
* @author yijiu2025
|
|
17
|
+
* @since 2026-10-18
|
|
18
|
+
*/
|
|
19
|
+
import { ref, watch } from 'vue';
|
|
20
|
+
import { deviceContext, pickDeviceId } from '../views/device';
|
|
21
|
+
import { registeredDevices } from './define-theme-device';
|
|
22
|
+
import { DESKTOP_MIN_WIDTH, MOBILE_BREAKPOINT, detectMobileUA } from './viewport';
|
|
23
|
+
|
|
24
|
+
/** 路由的最小结构(库不 import vue-router,只认这些字段) */
|
|
25
|
+
export interface DeviceDomainRouter {
|
|
26
|
+
currentRoute: {
|
|
27
|
+
value: {
|
|
28
|
+
query: Record<string, unknown>;
|
|
29
|
+
path: string;
|
|
30
|
+
fullPath: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** 替换当前路由(保留 path,只改 query;调试面板换版式 / 配色用) */
|
|
34
|
+
replace?(to: { query: Record<string, string | string[] | undefined> }): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InstallThemeDomainOptions {
|
|
38
|
+
router: DeviceDomainRouter;
|
|
39
|
+
/**
|
|
40
|
+
* 宿主注入的「设备覆盖」解析:从当前 query 返回要**锁定**的设备 id,`null` = 不覆盖。
|
|
41
|
+
*
|
|
42
|
+
* 用于宿主自己的 URL 约定(如 `?device=mobile` 锁定手机端)——参数名是宿主词汇,
|
|
43
|
+
* 库不认、也不写死,只提供一个「显式覆盖自动判定」的通用注入点。命中合法设备时
|
|
44
|
+
* 优先于 `?isMobile` / `?from` / 视口;返回 null 或非法 id 则走自动判定。
|
|
45
|
+
*/
|
|
46
|
+
resolveDeviceOverride?: (query: Record<string, unknown>) => string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 当前生效设备(响应式;宿主 store 从这里订阅) */
|
|
50
|
+
const activeDevice = ref<string>('');
|
|
51
|
+
|
|
52
|
+
/** 视口是否判为手机(响应式;「1024 优先 + 768 窄 + UA 兜底」口径,见 viewport.ts) */
|
|
53
|
+
const viewportMobile = ref(false);
|
|
54
|
+
|
|
55
|
+
let routerRef: DeviceDomainRouter | null = null;
|
|
56
|
+
let resolveDeviceOverride: InstallThemeDomainOptions['resolveDeviceOverride'] = undefined;
|
|
57
|
+
|
|
58
|
+
/** 判定一次设备并写入 activeDevice */
|
|
59
|
+
function judgeDevice(): void {
|
|
60
|
+
const defs = registeredDevices();
|
|
61
|
+
if (!routerRef || defs.length === 0) return;
|
|
62
|
+
const route = routerRef.currentRoute.value;
|
|
63
|
+
|
|
64
|
+
// 宿主注入的设备覆盖(如 ?device=):命中合法设备直接锁定,压过自动判定
|
|
65
|
+
const override = resolveDeviceOverride?.(route.query) ?? null;
|
|
66
|
+
if (override && defs.some(d => d.id === override)) {
|
|
67
|
+
if (activeDevice.value !== override) activeDevice.value = override;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ctx = deviceContext(route.query, route.path, viewportMobile.value);
|
|
72
|
+
// 兜底 = 末条 fallback 设备的 id(默认设备的指定先不在这里定)
|
|
73
|
+
const fallback = defs[defs.length - 1].id;
|
|
74
|
+
const next = pickDeviceId(defs, ctx, fallback);
|
|
75
|
+
if (activeDevice.value !== next) activeDevice.value = next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 装配设备域运行时(`app.use(theme, { router })` 时调用一次)
|
|
80
|
+
*
|
|
81
|
+
* 建立两条响应链:链接变动(`fullPath`)→ 判定;视口变化(两个 matchMedia + UA)→ 判定。
|
|
82
|
+
* 视口口径与宿主 `useDeviceDetect` 一致(宽视口优先 + 窄视口 + UA 兜底),保证设备域
|
|
83
|
+
* 判出的 `currentDevice` 与分发器渲染的形态同源。
|
|
84
|
+
*/
|
|
85
|
+
export function installThemeDomain(options: InstallThemeDomainOptions): void {
|
|
86
|
+
routerRef = options.router;
|
|
87
|
+
resolveDeviceOverride = options.resolveDeviceOverride;
|
|
88
|
+
|
|
89
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
90
|
+
const desktopMq = window.matchMedia(`(min-width: ${DESKTOP_MIN_WIDTH}px)`);
|
|
91
|
+
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
92
|
+
const syncViewport = (): void => {
|
|
93
|
+
// 宽视口优先:放得下桌面布局就一律电脑版,UA 说什么都不例外
|
|
94
|
+
if (desktopMq.matches || window.innerWidth >= DESKTOP_MIN_WIDTH) {
|
|
95
|
+
viewportMobile.value = false;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// 窄视口直接移动端;中间段(768–1023)交给 UA 兜底(该区间可能是被缩放的桌面浏览器)
|
|
99
|
+
const narrow = mobileMq.matches || window.innerWidth < MOBILE_BREAKPOINT;
|
|
100
|
+
viewportMobile.value = narrow ? true : detectMobileUA();
|
|
101
|
+
};
|
|
102
|
+
syncViewport();
|
|
103
|
+
desktopMq.addEventListener('change', syncViewport);
|
|
104
|
+
mobileMq.addEventListener('change', syncViewport);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 🔴 watch fullPath 而非整个路由对象:同路由 query/hash 变化不触发重判
|
|
108
|
+
watch(
|
|
109
|
+
() => options.router.currentRoute.value.fullPath,
|
|
110
|
+
() => judgeDevice(),
|
|
111
|
+
{ immediate: true }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// 视口变化 → 重判
|
|
115
|
+
watch(viewportMobile, () => judgeDevice());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 当前生效设备(只读响应式,宿主 store 订阅它) */
|
|
119
|
+
export function currentDevice(): Readonly<typeof activeDevice> {
|
|
120
|
+
return activeDevice;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 当前路由 query(`<ThemeView>` 读 URL 版式/配色参数用);未装配 router 时返回空对象 */
|
|
124
|
+
export function currentQuery(): Record<string, unknown> {
|
|
125
|
+
return routerRef?.currentRoute.value.query ?? {};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 替换当前路由的 query(**保留 `client_id` 等授权上下文**,只增删目标项)
|
|
130
|
+
*
|
|
131
|
+
* 与宿主旧接线壳 `replaceQuery` 同一语义:复制当前 query、应用 patch、`router.replace`。
|
|
132
|
+
* 库不 import vue-router,只认 `router.replace({ query })` 这个最小结构。
|
|
133
|
+
*/
|
|
134
|
+
export function replaceQuery(patch: Record<string, string | undefined>): void {
|
|
135
|
+
if (!routerRef?.replace) return;
|
|
136
|
+
const current = routerRef.currentRoute.value.query;
|
|
137
|
+
const query: Record<string, string | string[]> = {};
|
|
138
|
+
for (const [key, value] of Object.entries(current)) {
|
|
139
|
+
if (typeof value === 'string') query[key] = value;
|
|
140
|
+
else if (Array.isArray(value)) {
|
|
141
|
+
query[key] = value.filter((x): x is string => typeof x === 'string');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
145
|
+
if (value === undefined) delete query[key];
|
|
146
|
+
else query[key] = value;
|
|
147
|
+
}
|
|
148
|
+
void routerRef.replace({ query });
|
|
149
|
+
}
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skinsuite/vue` —— **Vue 绑定层**的唯一公开出口
|
|
3
|
+
*
|
|
4
|
+
* 与 `skinsuite`(零依赖纯内核)分开成两个入口的理由:
|
|
5
|
+
* • 只要纯机制的消费者(Node 脚本、另一套框架、单测)**不该被迫装 Vue**;
|
|
6
|
+
* • `.vue` 与 `vue` 运行时只有这一个入口碰,依赖方向一眼可查。
|
|
7
|
+
*
|
|
8
|
+
* 这里只装「**机制**」,不装「**策略**」:
|
|
9
|
+
* ✅ 面板长什么样、点一下发什么事件
|
|
10
|
+
* ✅ postMessage 协议怎么解析
|
|
11
|
+
* ❌ 路由怎么改、store 怎么实现、哪些 origin 可信 —— 那些是宿主参数
|
|
12
|
+
*
|
|
13
|
+
* @author yijiu2025
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { default as ThemeDebugPanel } from './ThemeDebugPanel.vue';
|
|
17
|
+
export { default as ThemeRoot } from './ThemeRoot.vue';
|
|
18
|
+
export { default as ThemeView } from './ThemeView.vue';
|
|
19
|
+
export { themeStoreKey } from './theme-store';
|
|
20
|
+
export type { ThemeStoreLike } from './theme-store';
|
|
21
|
+
export { useThemeSources } from './useThemeSources';
|
|
22
|
+
export type { ThemeSourcesOptions } from './useThemeSources';
|
|
23
|
+
export { useParentThemeSync } from './useParentThemeSync';
|
|
24
|
+
export type { ParentThemeSyncOptions } from './useParentThemeSync';
|
|
25
|
+
export { useThemeDebugPanel } from './useThemeDebugPanel';
|
|
26
|
+
export type { ThemeDebugActions, ThemeDebugColor, ThemeDebugModel } from './types';
|
|
27
|
+
|
|
28
|
+
// ---- 版式机制(「主题包 ⇄ Vue 组件」的桥接,2026-10-17 下沉)----
|
|
29
|
+
// 🔴 公开面收敛(2026-10-17 末):原 view-picker 并入 view-registry、view-pages 并入
|
|
30
|
+
// view-page;对外只留两个工厂。宿主只需 defineThemePage。
|
|
31
|
+
export { createViewRegistry, createViewPicker } from './view-registry';
|
|
32
|
+
export type {
|
|
33
|
+
PackageViews,
|
|
34
|
+
ViewModuleLoader,
|
|
35
|
+
ViewModuleMap,
|
|
36
|
+
ViewModuleProvider,
|
|
37
|
+
ViewRegistry,
|
|
38
|
+
ViewRegistryOptions,
|
|
39
|
+
ViewPicker,
|
|
40
|
+
ViewPickerOptions,
|
|
41
|
+
ViewPickerSource
|
|
42
|
+
} from './view-registry';
|
|
43
|
+
export { defineThemePage, isThemePage, pageOf, registeredPageNames, resetPages } from './view-page';
|
|
44
|
+
export type { ThemePage, ThemePageOptions } from './view-page';
|
|
45
|
+
export { installThemeViews, resetThemeViews, themeViewsFor } from './theme-views';
|
|
46
|
+
|
|
47
|
+
// ---- 设备容器机制(「设备 id → 容器组件」的组件半边,2026-10-17 下沉)----
|
|
48
|
+
// 🔴 containerOf / resolveContainerOf 的「传 defs」低层版不对外导出 —— 对外只留
|
|
49
|
+
// pageContainers(建容器表)与设备域单例版(见下),宿主不再写「传 registeredDefs」转发。
|
|
50
|
+
export { pageContainers } from './device-containers';
|
|
51
|
+
export type { DeviceContainerLoader } from './device-containers';
|
|
52
|
+
|
|
53
|
+
// ---- 设备域(声明工厂 + 查询派生 + 运行时,2026-10-18 下沉)----
|
|
54
|
+
export {
|
|
55
|
+
defineThemeDevice,
|
|
56
|
+
deviceIds,
|
|
57
|
+
fallbackDevice,
|
|
58
|
+
isKnownDevice,
|
|
59
|
+
pickDeviceId,
|
|
60
|
+
registeredDevices,
|
|
61
|
+
resetDevices,
|
|
62
|
+
resolveContainerOf,
|
|
63
|
+
urlKeyOf
|
|
64
|
+
} from './define-theme-device';
|
|
65
|
+
export type { DeclarativeMatch, ThemeDeviceOptions } from './define-theme-device';
|
|
66
|
+
export { currentDevice, currentQuery, installThemeDomain, replaceQuery } from './device-domain';
|
|
67
|
+
export type { DeviceDomainRouter, InstallThemeDomainOptions } from './device-domain';
|
|
68
|
+
export { theme } from './plugin';
|
|
69
|
+
export type { ThemePluginOptions } from './plugin';
|
|
70
|
+
|
|
71
|
+
// ---- 视口 / UA 设备形态判定(2026-10-19 下沉,供设备域与宿主守卫/请求共用)----
|
|
72
|
+
export {
|
|
73
|
+
DESKTOP_MIN_WIDTH,
|
|
74
|
+
MOBILE_BREAKPOINT,
|
|
75
|
+
detectMobileUA,
|
|
76
|
+
isDesktopViewport,
|
|
77
|
+
isMobileViewport
|
|
78
|
+
} from './viewport';
|
|
79
|
+
|
|
80
|
+
// ---- 主题状态机(scope/intent/runtime + 持久化,2026-10-19 下沉)----
|
|
81
|
+
// 🔴 只导出 `createThemeState`(编排入口)—— scope/intent/runtime/persistence 是内部实现,
|
|
82
|
+
// 对外只承诺「宿主一行 defineStore 包起来」。pinia **不进库**:编排返回的是纯 Vue 响应式,
|
|
83
|
+
// 宿主自己决定是否用 defineStore 包装。
|
|
84
|
+
export { createThemeState } from './create-theme-state';
|
|
85
|
+
export type { ThemeStateOptions } from './create-theme-state';
|
|
86
|
+
|
|
87
|
+
// ---- token 运行时(「分层 token → DOM」的注入半边,2026-10-17 下沉)----
|
|
88
|
+
export {
|
|
89
|
+
applyThemeLayers,
|
|
90
|
+
applyThemeTokens,
|
|
91
|
+
clearThemeTokens,
|
|
92
|
+
getInjectedTokenNames,
|
|
93
|
+
resetThemeRuntime
|
|
94
|
+
} from './token-runtime';
|
|
95
|
+
|
|
96
|
+
// ---- 后端主题配置取数(「fetch + 超时 + 归一」骨架,2026-10-17 下沉)----
|
|
97
|
+
export { fetchRemoteThemeConfig, isRemoteThemeEnabled } from './remote-fetch';
|
|
98
|
+
export type { RemoteThemeFetchOptions } from './remote-fetch';
|
package/src/vue/panel.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板的**独立入口** —— 供宿主做异步加载用
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独开一条子路径:`skinsuite/vue` 里同时有同步的 composable 与懒加载的组件。
|
|
5
|
+
* 宿主若从同一个入口 `import()` 面板,打包器会把该入口的所有导出视为"已被静态引入",
|
|
6
|
+
* 动态导入**不会**切出独立 chunk(Vite 报 `INEFFECTIVE_DYNAMIC_IMPORT`),
|
|
7
|
+
* 面板连同它的样式就回到了主包 —— 恰好破坏了"不带调试参数时不下载"的约定。
|
|
8
|
+
*
|
|
9
|
+
* 于是显式分成两条:
|
|
10
|
+
* `skinsuite/vue` → 同步(useParentThemeSync 等)
|
|
11
|
+
* `skinsuite/vue/panel` → 只导出面板组件,专供 `defineAsyncComponent`
|
|
12
|
+
*
|
|
13
|
+
* @author yijiu2025
|
|
14
|
+
*/
|
|
15
|
+
export { default as ThemeDebugPanel } from './ThemeDebugPanel.vue';
|
|
16
|
+
export { default } from './ThemeDebugPanel.vue';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题域的 Vue 插件 —— `app.use(theme, { router })` 一行启动设备域
|
|
3
|
+
*
|
|
4
|
+
* 与 Pinia / Router / i18n 同一套接入方式。`install` 在 `app.mount()` 之前**同步**执行,
|
|
5
|
+
* 设备域据此建立「链接变动 + 视口(matchMedia)→ 判定设备 → `activeDevice`」两条响应链,
|
|
6
|
+
* 首帧即终态(不闪基线色)。
|
|
7
|
+
*
|
|
8
|
+
* === 为什么只传 router ===
|
|
9
|
+
* 设备来源(path / query)从 router 拿、视口用库默认 `matchMedia`、判定结果存在设备域
|
|
10
|
+
* (宿主 store 订阅)——三件事都收进库,宿主只传一个 router,不写 `deviceSource`、不标
|
|
11
|
+
* `meta.device`、不手动 `setActiveDevice`。
|
|
12
|
+
*
|
|
13
|
+
* @author yijiu2025
|
|
14
|
+
* @since 2026-10-18
|
|
15
|
+
*/
|
|
16
|
+
import type { App, Plugin } from 'vue';
|
|
17
|
+
import { installThemeDomain } from './device-domain';
|
|
18
|
+
import type { DeviceDomainRouter, InstallThemeDomainOptions } from './device-domain';
|
|
19
|
+
|
|
20
|
+
export interface ThemePluginOptions {
|
|
21
|
+
/** 宿主路由(库只认 `currentRoute.value.{ query, path, fullPath }` 最小结构) */
|
|
22
|
+
router: DeviceDomainRouter;
|
|
23
|
+
/** 宿主注入的设备覆盖解析(`?device=` 等宿主 URL 词汇),透传给设备域 */
|
|
24
|
+
resolveDeviceOverride?: InstallThemeDomainOptions['resolveDeviceOverride'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Vue 插件:`app.use(theme, { router, resolveDeviceOverride? })` */
|
|
28
|
+
export const theme: Plugin<[ThemePluginOptions]> = {
|
|
29
|
+
install(_app: App, options: ThemePluginOptions) {
|
|
30
|
+
installThemeDomain({
|
|
31
|
+
router: options.router,
|
|
32
|
+
resolveDeviceOverride: options.resolveDeviceOverride
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
};
|