skinsuite 0.2.3 → 0.3.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.
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/views/device.d.ts +19 -34
- package/dist/views/device.d.ts.map +1 -1
- package/dist/views/device.js +19 -35
- package/dist/views/device.js.map +1 -1
- package/docs/onboarding.md +72 -23
- package/package.json +4 -2
- package/src/index.ts +6 -13
- package/src/views/device.ts +19 -61
- package/src/vue/define-theme-device.ts +22 -69
- package/src/vue/device-containers.ts +8 -4
- package/src/vue/device-domain.ts +54 -56
- package/src/vue/index.ts +16 -12
- package/src/vue/plugin.ts +10 -4
- package/src/vue/theme-scope.ts +2 -1
- package/src/views/device-registry.ts +0 -96
- package/src/vue/viewport.ts +0 -54
package/dist/index.d.ts
CHANGED
|
@@ -42,14 +42,14 @@ export type { ThemeAssetModules } from './registry.js';
|
|
|
42
42
|
export { listThemes, listColorsOf, toneOfColor, toneOfAnyScope, listColorIdsOfTone, resolveThemeId, getThemeRecord, getDefaultThemeId, isKnownTheme, listColorViews } from './registry.js';
|
|
43
43
|
export { DEVICE_SCOPED_PARAMS, readDeviceParam } from './views/params.js';
|
|
44
44
|
export type { DeviceScopedParam } from './views/params.js';
|
|
45
|
-
export { asDeviceId, containersOf, deviceContext, deviceLabel, pickDeviceId } from './views/device.js';
|
|
46
|
-
export type { DeviceDef, DeviceMatchContext } from './views/device.js';
|
|
47
45
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* 设备清单机制(只有事实与查询,**判定不在这里**)
|
|
47
|
+
*
|
|
48
|
+
* 「当前属于哪台设备」由宿主注入 `resolveDevice`(`skinsuite/vue`),库不认识
|
|
49
|
+
* 视口阈值 / UA / `?isMobile` / `?from=` 这些词汇。
|
|
50
50
|
*/
|
|
51
|
-
export {
|
|
52
|
-
export type {
|
|
51
|
+
export { asDeviceId, containersOf, deviceLabel } from './views/device.js';
|
|
52
|
+
export type { DeviceDef } from './views/device.js';
|
|
53
53
|
/**
|
|
54
54
|
* 外部下发的主题配置:**只归一形状,不碰网络**
|
|
55
55
|
*
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACtB,gBAAgB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACnF,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAClF,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEzF,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACzE,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EACL,UAAU,EACR,YAAY,EACd,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACf,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAC1E,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACtB,gBAAgB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACnF,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAClF,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEzF,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACzE,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EACL,UAAU,EACR,YAAY,EACd,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACf,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAC1E,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAC1E,YAAY,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEnD;;;;GAIG;AACH,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AACzD,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD;;;;;GAKG;AACH,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,aAAa,EACd,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,cAAc,EACd,aAAa,EACd,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -36,12 +36,13 @@ export { isSafeTokenName, isSafeTokenValue, isSafeTokenEntry, sanitizeOverrides
|
|
|
36
36
|
export { buildThemeRegistry, installThemeRegistry } from './registry.js';
|
|
37
37
|
export { listThemes, listColorsOf, toneOfColor, toneOfAnyScope, listColorIdsOfTone, resolveThemeId, getThemeRecord, getDefaultThemeId, isKnownTheme, listColorViews } from './registry.js';
|
|
38
38
|
export { DEVICE_SCOPED_PARAMS, readDeviceParam } from './views/params.js';
|
|
39
|
-
export { asDeviceId, containersOf, deviceContext, deviceLabel, pickDeviceId } from './views/device.js';
|
|
40
39
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* 设备清单机制(只有事实与查询,**判定不在这里**)
|
|
41
|
+
*
|
|
42
|
+
* 「当前属于哪台设备」由宿主注入 `resolveDevice`(`skinsuite/vue`),库不认识
|
|
43
|
+
* 视口阈值 / UA / `?isMobile` / `?from=` 这些词汇。
|
|
43
44
|
*/
|
|
44
|
-
export {
|
|
45
|
+
export { asDeviceId, containersOf, deviceLabel } from './views/device.js';
|
|
45
46
|
/**
|
|
46
47
|
* 外部下发的主题配置:**只归一形状,不碰网络**
|
|
47
48
|
*
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACtB,gBAAgB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGnF,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGlF,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAEzE,OAAO,EACL,UAAU,EACR,YAAY,EACd,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACf,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAG1E,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACtB,gBAAgB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGnF,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAGlF,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAEzE,OAAO,EACL,UAAU,EACR,YAAY,EACd,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACf,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAG1E;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG1E;;;;GAIG;AACH,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAGzD;;;;;GAKG;AACH,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,aAAa,EACd,MAAM,eAAe,CAAC"}
|
package/dist/views/device.d.ts
CHANGED
|
@@ -1,40 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设备清单机制(**纯逻辑半边**)——
|
|
2
|
+
* 设备清单机制(**纯逻辑半边**)—— 只描述「有哪些设备、占哪条路由、容器在哪」
|
|
3
3
|
*
|
|
4
|
-
* ===
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* === 🔴 判定不在这里(2026-10-19 收口)===
|
|
5
|
+
* 「当前属于哪台设备」是**宿主的业务判定**,不是通用机制:
|
|
6
|
+
* · 视口阈值(768 / 1024)、UA 兜底口径 —— 换个产品就变
|
|
7
|
+
* · 宿主自己的 URL 词汇(`?isMobile` / `?from=mini` / `?device=` 锁定)—— 更是宿主专有
|
|
8
|
+
* 一旦下沉,库就得认识这些词汇,别的宿主复用机制时被迫沿用同一套约定;而本库对外
|
|
9
|
+
* 承诺的只有「设备域」这件机制本身。
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 那里才 import Vue 的 `Component`。
|
|
11
|
+
* 所以判定改成**注入点**:宿主给 `app.use(theme, { router, resolveDevice })` 传一个
|
|
12
|
+
* `(ctx) => deviceId | null`,库把 router 的 `query / path / fullPath` **原样**递过去
|
|
13
|
+
* (只递事实,不带任何词汇),由宿主自己决定。库只负责:注册清单、记住当前设备、
|
|
14
|
+
* 通知订阅方、按设备取容器。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* 它是兜底设备。宿主关卡会核这一条。
|
|
16
|
+
* 由此「加一种设备」= 宿主加一条声明 + 一条判定分支,**库零改动**。
|
|
17
17
|
*
|
|
18
|
-
* ===
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* === 为什么这里只有「设备」、没有「组件」 ===
|
|
19
|
+
* 本文件是**纯逻辑**(不含 `Component` 类型),能被内核 tsc 独立检查、也能被任何前端
|
|
20
|
+
* 复用。`containers` 用泛型 `T` 表达(宿主的「设备容器加载器」是 `T`),组件加载的
|
|
21
|
+
* **具体机制**(`pageContainers` / `resolveContainerOf`)在 `skinsuite/vue` 的
|
|
22
|
+
* `device-containers`,那里才 import Vue 的 `Component`。
|
|
21
23
|
*
|
|
22
24
|
* @author yijiu2025
|
|
23
25
|
* @since 2026-10-17(从 oauth21/src/theme/devices.ts 下沉)
|
|
26
|
+
* @since 2026-10-19(判定层撤出:`match` / `DeviceMatchContext` / `pickDeviceId` /
|
|
27
|
+
* `deviceContext` 全删,改为宿主注入 `resolveDevice`)
|
|
24
28
|
*/
|
|
25
|
-
/** 判定上下文 —— 判定所需的**全部**外部信息(与页面无关的原始事实) */
|
|
26
|
-
export interface DeviceMatchContext {
|
|
27
|
-
/** `?isMobile=true` —— 显式意图,优先级最高 */
|
|
28
|
-
explicitMobile: boolean;
|
|
29
|
-
/** 路由 path(不含 query / hash) */
|
|
30
|
-
path: string;
|
|
31
|
-
/** `?from=` 的取值(iframe 来源标记,空串表示没给) */
|
|
32
|
-
from: string;
|
|
33
|
-
/** `?fromLogin=` 的取值(历史键:从 mini 登录页跳来) */
|
|
34
|
-
fromLogin: string;
|
|
35
|
-
/** 视口/UA 判定结果 */
|
|
36
|
-
viewportIsMobile: boolean;
|
|
37
|
-
}
|
|
38
29
|
/** 一条设备声明(`containers` 是泛型:纯逻辑侧不关心它到底是什么,只透传) */
|
|
39
30
|
export interface DeviceDef<T = unknown> {
|
|
40
31
|
/** 设备 id = 主题包内的目录名,只允许 `[a-z0-9-]` */
|
|
@@ -47,17 +38,11 @@ export interface DeviceDef<T = unknown> {
|
|
|
47
38
|
containers: ReadonlyMap<string, T>;
|
|
48
39
|
/** 该设备在路由表里占的 path(相对根,`{page}` 由页面名替换) */
|
|
49
40
|
routePath(page: string): string;
|
|
50
|
-
/** 本次访问是否属于这台设备(清单顺序 = 优先级,先命中先用) */
|
|
51
|
-
match(ctx: DeviceMatchContext): boolean;
|
|
52
41
|
}
|
|
53
42
|
/** 归一化外部传入的设备名;不认识的取值返回 null(**不是**落默认设备) */
|
|
54
43
|
export declare function asDeviceId<T extends string>(known: readonly string[], raw: unknown): T | null;
|
|
55
44
|
/** 设备 id → 中文名(调试面板用;加设备不必再改面板) */
|
|
56
45
|
export declare function deviceLabel<T>(defs: readonly DeviceDef<T>[], id: string): string;
|
|
57
|
-
/** 从外部输入判定「本次访问属于哪台设备」;清单顺序 = 优先级,返回值永远非空 */
|
|
58
|
-
export declare function pickDeviceId<T>(defs: readonly DeviceDef<T>[], ctx: DeviceMatchContext, defaultDevice: string): string;
|
|
59
46
|
/** 取某设备的容器表;未知设备返回空表(调用方退到兜底设备) */
|
|
60
47
|
export declare function containersOf<T>(defs: readonly DeviceDef<T>[], id: string): ReadonlyMap<string, T>;
|
|
61
|
-
/** 从当前访问信息构造判定上下文 */
|
|
62
|
-
export declare function deviceContext(query: Record<string, unknown> | undefined, path: string, viewportIsMobile: boolean): DeviceMatchContext;
|
|
63
48
|
//# sourceMappingURL=device.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../src/views/device.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../../src/views/device.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,iDAAiD;AACjD,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACpC,uCAAuC;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACnC,2CAA2C;IAC3C,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED,6CAA6C;AAC7C,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EACzC,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,GAAG,EAAE,OAAO,GACX,CAAC,GAAG,IAAI,CAIV;AAED,mCAAmC;AACnC,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhF;AAED,mCAAmC;AACnC,wBAAgB,YAAY,CAAC,CAAC,EAC5B,IAAI,EAAE,SAAS,SAAS,CAAC,CAAC,CAAC,EAAE,EAC7B,EAAE,EAAE,MAAM,GACT,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,CAExB"}
|
package/dist/views/device.js
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设备清单机制(**纯逻辑半边**)——
|
|
2
|
+
* 设备清单机制(**纯逻辑半边**)—— 只描述「有哪些设备、占哪条路由、容器在哪」
|
|
3
3
|
*
|
|
4
|
-
* ===
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* === 🔴 判定不在这里(2026-10-19 收口)===
|
|
5
|
+
* 「当前属于哪台设备」是**宿主的业务判定**,不是通用机制:
|
|
6
|
+
* · 视口阈值(768 / 1024)、UA 兜底口径 —— 换个产品就变
|
|
7
|
+
* · 宿主自己的 URL 词汇(`?isMobile` / `?from=mini` / `?device=` 锁定)—— 更是宿主专有
|
|
8
|
+
* 一旦下沉,库就得认识这些词汇,别的宿主复用机制时被迫沿用同一套约定;而本库对外
|
|
9
|
+
* 承诺的只有「设备域」这件机制本身。
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 那里才 import Vue 的 `Component`。
|
|
11
|
+
* 所以判定改成**注入点**:宿主给 `app.use(theme, { router, resolveDevice })` 传一个
|
|
12
|
+
* `(ctx) => deviceId | null`,库把 router 的 `query / path / fullPath` **原样**递过去
|
|
13
|
+
* (只递事实,不带任何词汇),由宿主自己决定。库只负责:注册清单、记住当前设备、
|
|
14
|
+
* 通知订阅方、按设备取容器。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* 它是兜底设备。宿主关卡会核这一条。
|
|
16
|
+
* 由此「加一种设备」= 宿主加一条声明 + 一条判定分支,**库零改动**。
|
|
17
17
|
*
|
|
18
|
-
* ===
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* === 为什么这里只有「设备」、没有「组件」 ===
|
|
19
|
+
* 本文件是**纯逻辑**(不含 `Component` 类型),能被内核 tsc 独立检查、也能被任何前端
|
|
20
|
+
* 复用。`containers` 用泛型 `T` 表达(宿主的「设备容器加载器」是 `T`),组件加载的
|
|
21
|
+
* **具体机制**(`pageContainers` / `resolveContainerOf`)在 `skinsuite/vue` 的
|
|
22
|
+
* `device-containers`,那里才 import Vue 的 `Component`。
|
|
21
23
|
*
|
|
22
24
|
* @author yijiu2025
|
|
23
25
|
* @since 2026-10-17(从 oauth21/src/theme/devices.ts 下沉)
|
|
26
|
+
* @since 2026-10-19(判定层撤出:`match` / `DeviceMatchContext` / `pickDeviceId` /
|
|
27
|
+
* `deviceContext` 全删,改为宿主注入 `resolveDevice`)
|
|
24
28
|
*/
|
|
25
29
|
/** 归一化外部传入的设备名;不认识的取值返回 null(**不是**落默认设备) */
|
|
26
30
|
export function asDeviceId(known, raw) {
|
|
@@ -32,28 +36,8 @@ export function asDeviceId(known, raw) {
|
|
|
32
36
|
export function deviceLabel(defs, id) {
|
|
33
37
|
return defs.find(def => def.id === id)?.label ?? id;
|
|
34
38
|
}
|
|
35
|
-
/** 从外部输入判定「本次访问属于哪台设备」;清单顺序 = 优先级,返回值永远非空 */
|
|
36
|
-
export function pickDeviceId(defs, ctx, defaultDevice) {
|
|
37
|
-
for (const def of defs) {
|
|
38
|
-
if (def.match(ctx))
|
|
39
|
-
return def.id;
|
|
40
|
-
}
|
|
41
|
-
// 清单被改坏(末条不再无条件成立)时的保底
|
|
42
|
-
return defaultDevice;
|
|
43
|
-
}
|
|
44
39
|
/** 取某设备的容器表;未知设备返回空表(调用方退到兜底设备) */
|
|
45
40
|
export function containersOf(defs, id) {
|
|
46
41
|
return defs.find(def => def.id === id)?.containers ?? new Map();
|
|
47
42
|
}
|
|
48
|
-
/** 从当前访问信息构造判定上下文 */
|
|
49
|
-
export function deviceContext(query, path, viewportIsMobile) {
|
|
50
|
-
const text = (key) => typeof query?.[key] === 'string' ? query[key] : '';
|
|
51
|
-
return {
|
|
52
|
-
explicitMobile: text('isMobile') === 'true',
|
|
53
|
-
path: path.split(/[?#]/)[0],
|
|
54
|
-
from: text('from'),
|
|
55
|
-
fromLogin: text('fromLogin'),
|
|
56
|
-
viewportIsMobile
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
43
|
//# sourceMappingURL=device.js.map
|
package/dist/views/device.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"device.js","sourceRoot":"","sources":["../../src/views/device.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"device.js","sourceRoot":"","sources":["../../src/views/device.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAgBH,6CAA6C;AAC7C,MAAM,UAAU,UAAU,CACxB,KAAwB,EACxB,GAAY;IAEZ,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAK,KAA2B,CAAC,QAAQ,CAAC,GAAG,CAAC;QAC1E,CAAC,CAAE,GAAS;QACZ,CAAC,CAAC,IAAI,CAAC;AACX,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,WAAW,CAAI,IAA6B,EAAE,EAAU;IACtE,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;AACtD,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,YAAY,CAC1B,IAA6B,EAC7B,EAAU;IAEV,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,IAAI,IAAI,GAAG,EAAE,CAAC;AAClE,CAAC"}
|
package/docs/onboarding.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|---|---|---|
|
|
18
18
|
| 多配色(明暗 + 色系,换 CSS 变量) | `skinsuite` + `skinsuite/vue` | 必选 |
|
|
19
19
|
| 多版式(同一页换 DOM / 交互组织) | `skinsuite/vue` | 可选(只有一个版式就只建一个主题包) |
|
|
20
|
-
| 多设备(手机 / 桌面 / 平板各自布局) | `skinsuite/vue` |
|
|
20
|
+
| 多设备(手机 / 桌面 / 平板各自布局) | `skinsuite/vue` | 可选(单端就只声明一台设备) |
|
|
21
21
|
| 调试面板 | `skinsuite/vue` | 可选(`theme.config.ts` 里 `debug: { enabled: 'url' }`) |
|
|
22
22
|
|
|
23
23
|
**最小接入 = 多配色**:只建一个主题包、一个设备、一个页面,也能完整跑起来。
|
|
@@ -131,7 +131,7 @@ import type { Component } from 'vue';
|
|
|
131
131
|
import { THEME_ID_RE } from 'skinsuite';
|
|
132
132
|
import { defineThemeDevice } from 'skinsuite/vue';
|
|
133
133
|
|
|
134
|
-
//
|
|
134
|
+
// 声明顺序 = 优先级;**最后一条是兜底设备**(库只认顺序,不认设备名)
|
|
135
135
|
defineThemeDevice({
|
|
136
136
|
id: 'mobile',
|
|
137
137
|
label: '手机端',
|
|
@@ -141,8 +141,7 @@ defineThemeDevice({
|
|
|
141
141
|
fileRe: /^index\.vue$/,
|
|
142
142
|
idRe: THEME_ID_RE
|
|
143
143
|
},
|
|
144
|
-
routePath: page => `m/${page}
|
|
145
|
-
match: { explicitMobile: true, viewportMobile: true }
|
|
144
|
+
routePath: page => `m/${page}`
|
|
146
145
|
});
|
|
147
146
|
|
|
148
147
|
defineThemeDevice({
|
|
@@ -154,14 +153,61 @@ defineThemeDevice({
|
|
|
154
153
|
fileRe: /^Standard/,
|
|
155
154
|
idRe: THEME_ID_RE
|
|
156
155
|
},
|
|
157
|
-
routePath: page => page
|
|
158
|
-
match: { fallback: true }
|
|
156
|
+
routePath: page => page
|
|
159
157
|
});
|
|
160
158
|
```
|
|
161
159
|
|
|
162
|
-
`
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
`defineThemeDevice` 只登记**事实**(id / label / urlKey / 容器在哪 / 占哪条路由),自动建容器表、
|
|
161
|
+
自动注册进设备域,**不要再手动写任何转发函数**。
|
|
162
|
+
|
|
163
|
+
🔴 **这里没有 `match`** —— 判定不归库,见 §4.1b。
|
|
164
|
+
|
|
165
|
+
### 4.1b 🔴 设备判定写在哪:宿主(不是库)
|
|
166
|
+
|
|
167
|
+
「当前属于哪台设备」是**业务口径**:视口阈值取 768 还是别的、UA 命中算不算、
|
|
168
|
+
`?isMobile=true` / `?from=mini` / `?device=` 这些 URL 词汇,换个产品就变。
|
|
169
|
+
所以库只留**注入点**,判定由宿主实现并通过 `app.use(theme, { resolveDevice })` 传进来:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// src/utils/device-resolve.ts(宿主侧)
|
|
173
|
+
//
|
|
174
|
+
// 🔴 别把这段放进 `src/theme/views/`:那是主题域的**纯声明目录**
|
|
175
|
+
// (defineThemePage / defineThemeDevice),会被 virtual:theme 当声明副作用 import,
|
|
176
|
+
// 判定代码会跟着主题域一起进包;而且「theme/ 整包搬到另一个前端」就不成立了。
|
|
177
|
+
// theme/views/devices.ts 只留设备声明(有哪些设备、容器在哪、占哪条路由)。
|
|
178
|
+
import type { DeviceDomainContext } from 'skinsuite/vue';
|
|
179
|
+
import { fallbackDevice, isKnownDevice } from 'skinsuite/vue';
|
|
180
|
+
import { isMobileViewport } from '@/utils/device';
|
|
181
|
+
|
|
182
|
+
export function resolveCurrentDevice(ctx: DeviceDomainContext): string | null {
|
|
183
|
+
if (isKnownDevice(ctx.query.device)) return ctx.query.device as string; // 一次性锁定
|
|
184
|
+
if (ctx.query.isMobile === 'true') return 'mobile'; // 显式意图
|
|
185
|
+
if (ctx.query.from === 'mini') return 'mini'; // 来源标记
|
|
186
|
+
if (isMobileViewport()) return 'mobile'; // 视口 + UA
|
|
187
|
+
return fallbackDevice(); // 兜底(末条)
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`DeviceDomainContext` 只给**事实** —— `{ query, path, fullPath }`,没有任何词汇。
|
|
192
|
+
库不认识「手机」是什么、也不认识 768 这个数。
|
|
193
|
+
|
|
194
|
+
**视口变化怎么办**:库不订阅 `matchMedia`(「什么算变了」也是宿主口径)。
|
|
195
|
+
宿主自己订阅,然后叫库重判:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { refreshDevice } from 'skinsuite/vue';
|
|
199
|
+
import { watchViewport } from '@/utils/device';
|
|
200
|
+
|
|
201
|
+
watchViewport(() => refreshDevice());
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**加一种设备**要动的四处:`theme/views/devices.ts` 一条 `defineThemeDevice` 声明 /
|
|
205
|
+
`utils/device-resolve.ts` 一条判定分支 / 容器组件 / 主题包里加设备目录。库零改动。
|
|
206
|
+
|
|
207
|
+
⚠️ 判定函数依赖「设备域已注册的清单」(`isKnownDevice` / `fallbackDevice`),
|
|
208
|
+
所以 `bootstrap.ts` 要在 `app.use(theme)` **之前** import 一次声明模块
|
|
209
|
+
(`import './theme/views/devices'`)—— 通常 `virtual:theme` 会拉齐,但显式 import
|
|
210
|
+
能让顺序确定,不依赖插件产物。
|
|
165
211
|
|
|
166
212
|
### 4.2 页面契约 `views/<page>.ts`
|
|
167
213
|
|
|
@@ -272,21 +318,22 @@ app.use(createPinia());
|
|
|
272
318
|
app.use(router);
|
|
273
319
|
app.use(i18n);
|
|
274
320
|
|
|
275
|
-
// 设备域启动:传 router +
|
|
321
|
+
// 设备域启动:传 router + 宿主自己的判定函数。
|
|
276
322
|
// 库只认 router 的最小结构(currentRoute.value.{ query, path, fullPath }),
|
|
277
|
-
// 不 import vue-router
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
323
|
+
// 不 import vue-router,**也不认识任何 URL 词汇或视口阈值** —— 判定全在
|
|
324
|
+
// `theme/views/devices.ts` 的 resolveCurrentDevice 里(含 ?device= 锁定)。
|
|
325
|
+
app.use(theme, { router, resolveDevice: resolveCurrentDevice });
|
|
326
|
+
|
|
327
|
+
// 视口变化 → 让设备域重判(库不订阅 matchMedia)
|
|
328
|
+
watchViewport(() => refreshDevice());
|
|
283
329
|
|
|
284
330
|
router.isReady().then(() => app.mount('#app'));
|
|
285
331
|
```
|
|
286
332
|
|
|
287
|
-
**`
|
|
288
|
-
|
|
289
|
-
|
|
333
|
+
**`resolveDevice` 的语义**:`(ctx) => deviceId | null`,**唯一的判定入口**。`ctx` 只有
|
|
334
|
+
`{ query, path, fullPath }` 三个事实字段。返回合法设备 id 即采用;返回 `null` 或未知 id
|
|
335
|
+
走兜底设备(清单末条)。宿主在里面怎么写都行 —— `?device=` 锁定、`?isMobile`、UA、视口,
|
|
336
|
+
都是宿主自己的事。**不传就全部落兜底设备**(单端应用可以只用一句话的判定了)。
|
|
290
337
|
|
|
291
338
|
---
|
|
292
339
|
|
|
@@ -417,7 +464,7 @@ const ctx = computed<LoginViewContext>(() => ({
|
|
|
417
464
|
| 构建 | `vite build` | 成功,内置包 eager、非内置包独立 chunk |
|
|
418
465
|
| 手测 | `?th=<包>` / `?th_<设备缩写>=<包>` | 版式切换 |
|
|
419
466
|
| 手测 | `?debug=theme` | 调试面板出现,可切配色 |
|
|
420
|
-
| 手测 | 缩放窗口 / 换 UA | 手机 /
|
|
467
|
+
| 手测 | 缩放窗口 / 换 UA | 手机 / 桌面自动切换(由宿主的 `resolveDevice` 决定) |
|
|
421
468
|
| 手测 | `?device=mobile`(若注入 override) | 锁定手机端,刷新后失效 |
|
|
422
469
|
|
|
423
470
|
---
|
|
@@ -439,8 +486,10 @@ const ctx = computed<LoginViewContext>(() => ({
|
|
|
439
486
|
5. **在容器里手写 `setActiveDevice` / `setActivePageView`** → 冗余且会被 `<ThemeView>` 覆盖。
|
|
440
487
|
全删,交给 `<ThemeView>`。
|
|
441
488
|
|
|
442
|
-
6.
|
|
443
|
-
|
|
489
|
+
6. **设备判定(视口阈值 / UA / `?device=` / `?isMobile` / `?from=`)写进库里** → 边界错。
|
|
490
|
+
库只提供注入点 `resolveDevice`(`ctx` 里只有 `query / path / fullPath` 三个事实),
|
|
491
|
+
判定与它的词汇全部写在宿主(见 §4.1b)。同理别指望库订阅 `matchMedia` ——
|
|
492
|
+
视口变化由宿主订阅后调 `refreshDevice()`。
|
|
444
493
|
|
|
445
494
|
7. **容器摊平在 `view/web/` 下**(`web/StandardLogin.vue`)→ 页面名被算成 `web`,该页在这一设备上
|
|
446
495
|
等于「没写容器」。`standard` / `mini` 必须各自待在 `view/web/<页面>/` 里,见 §9.2。
|
|
@@ -456,7 +505,7 @@ my-app/
|
|
|
456
505
|
├── vite.config.ts # themeAssets({ dir: resolveThemeDir(mode) }) + vue()
|
|
457
506
|
├── .env # VITE_THEME_DIR=src/theme(可省)
|
|
458
507
|
└── src/
|
|
459
|
-
├── bootstrap.ts # app.use(theme, { router,
|
|
508
|
+
├── bootstrap.ts # app.use(theme, { router, resolveDevice }) + watchViewport(refreshDevice)
|
|
460
509
|
├── App.vue # <ThemeRoot :theme-store> + <ThemeDebugPanel>
|
|
461
510
|
├── stores/theme.ts # defineStore('theme', () => createThemeState()) + import 'virtual:theme'
|
|
462
511
|
├── router/index.ts # 固定声明 /<page>、/m/<page>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skinsuite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "多主题 / 多版式机制的纯内核(零运行时依赖):目录名与默认值的白名单常量、主题包契约类型、明暗 mode 与色系 tone、CSS 变量 token 的安全校验、设备维度 URL 参数读取。不碰 DOM / 构建器 / 路由,可在任意前端(Vue / React / Node / SSR)复用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"
|
|
78
|
+
"@types/node": "^22.0.0",
|
|
79
|
+
"typescript": "^6.0.3",
|
|
80
|
+
"vite": "^7.0.0"
|
|
79
81
|
}
|
|
80
82
|
}
|
package/src/index.ts
CHANGED
|
@@ -83,21 +83,14 @@ export {
|
|
|
83
83
|
export { DEVICE_SCOPED_PARAMS, readDeviceParam } from './views/params.js';
|
|
84
84
|
export type { DeviceScopedParam } from './views/params.js';
|
|
85
85
|
|
|
86
|
-
export {
|
|
87
|
-
asDeviceId,
|
|
88
|
-
containersOf,
|
|
89
|
-
deviceContext,
|
|
90
|
-
deviceLabel,
|
|
91
|
-
pickDeviceId
|
|
92
|
-
} from './views/device.js';
|
|
93
|
-
export type { DeviceDef, DeviceMatchContext } from './views/device.js';
|
|
94
|
-
|
|
95
86
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
87
|
+
* 设备清单机制(只有事实与查询,**判定不在这里**)
|
|
88
|
+
*
|
|
89
|
+
* 「当前属于哪台设备」由宿主注入 `resolveDevice`(`skinsuite/vue`),库不认识
|
|
90
|
+
* 视口阈值 / UA / `?isMobile` / `?from=` 这些词汇。
|
|
98
91
|
*/
|
|
99
|
-
export {
|
|
100
|
-
export type {
|
|
92
|
+
export { asDeviceId, containersOf, deviceLabel } from './views/device.js';
|
|
93
|
+
export type { DeviceDef } from './views/device.js';
|
|
101
94
|
|
|
102
95
|
/**
|
|
103
96
|
* 外部下发的主题配置:**只归一形状,不碰网络**
|
package/src/views/device.ts
CHANGED
|
@@ -1,42 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设备清单机制(**纯逻辑半边**)——
|
|
2
|
+
* 设备清单机制(**纯逻辑半边**)—— 只描述「有哪些设备、占哪条路由、容器在哪」
|
|
3
3
|
*
|
|
4
|
-
* ===
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* === 🔴 判定不在这里(2026-10-19 收口)===
|
|
5
|
+
* 「当前属于哪台设备」是**宿主的业务判定**,不是通用机制:
|
|
6
|
+
* · 视口阈值(768 / 1024)、UA 兜底口径 —— 换个产品就变
|
|
7
|
+
* · 宿主自己的 URL 词汇(`?isMobile` / `?from=mini` / `?device=` 锁定)—— 更是宿主专有
|
|
8
|
+
* 一旦下沉,库就得认识这些词汇,别的宿主复用机制时被迫沿用同一套约定;而本库对外
|
|
9
|
+
* 承诺的只有「设备域」这件机制本身。
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 那里才 import Vue 的 `Component`。
|
|
11
|
+
* 所以判定改成**注入点**:宿主给 `app.use(theme, { router, resolveDevice })` 传一个
|
|
12
|
+
* `(ctx) => deviceId | null`,库把 router 的 `query / path / fullPath` **原样**递过去
|
|
13
|
+
* (只递事实,不带任何词汇),由宿主自己决定。库只负责:注册清单、记住当前设备、
|
|
14
|
+
* 通知订阅方、按设备取容器。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* 它是兜底设备。宿主关卡会核这一条。
|
|
16
|
+
* 由此「加一种设备」= 宿主加一条声明 + 一条判定分支,**库零改动**。
|
|
17
17
|
*
|
|
18
|
-
* ===
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* === 为什么这里只有「设备」、没有「组件」 ===
|
|
19
|
+
* 本文件是**纯逻辑**(不含 `Component` 类型),能被内核 tsc 独立检查、也能被任何前端
|
|
20
|
+
* 复用。`containers` 用泛型 `T` 表达(宿主的「设备容器加载器」是 `T`),组件加载的
|
|
21
|
+
* **具体机制**(`pageContainers` / `resolveContainerOf`)在 `skinsuite/vue` 的
|
|
22
|
+
* `device-containers`,那里才 import Vue 的 `Component`。
|
|
21
23
|
*
|
|
22
24
|
* @author yijiu2025
|
|
23
25
|
* @since 2026-10-17(从 oauth21/src/theme/devices.ts 下沉)
|
|
26
|
+
* @since 2026-10-19(判定层撤出:`match` / `DeviceMatchContext` / `pickDeviceId` /
|
|
27
|
+
* `deviceContext` 全删,改为宿主注入 `resolveDevice`)
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
/** 判定上下文 —— 判定所需的**全部**外部信息(与页面无关的原始事实) */
|
|
27
|
-
export interface DeviceMatchContext {
|
|
28
|
-
/** `?isMobile=true` —— 显式意图,优先级最高 */
|
|
29
|
-
explicitMobile: boolean;
|
|
30
|
-
/** 路由 path(不含 query / hash) */
|
|
31
|
-
path: string;
|
|
32
|
-
/** `?from=` 的取值(iframe 来源标记,空串表示没给) */
|
|
33
|
-
from: string;
|
|
34
|
-
/** `?fromLogin=` 的取值(历史键:从 mini 登录页跳来) */
|
|
35
|
-
fromLogin: string;
|
|
36
|
-
/** 视口/UA 判定结果 */
|
|
37
|
-
viewportIsMobile: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
30
|
/** 一条设备声明(`containers` 是泛型:纯逻辑侧不关心它到底是什么,只透传) */
|
|
41
31
|
export interface DeviceDef<T = unknown> {
|
|
42
32
|
/** 设备 id = 主题包内的目录名,只允许 `[a-z0-9-]` */
|
|
@@ -49,8 +39,6 @@ export interface DeviceDef<T = unknown> {
|
|
|
49
39
|
containers: ReadonlyMap<string, T>;
|
|
50
40
|
/** 该设备在路由表里占的 path(相对根,`{page}` 由页面名替换) */
|
|
51
41
|
routePath(page: string): string;
|
|
52
|
-
/** 本次访问是否属于这台设备(清单顺序 = 优先级,先命中先用) */
|
|
53
|
-
match(ctx: DeviceMatchContext): boolean;
|
|
54
42
|
}
|
|
55
43
|
|
|
56
44
|
/** 归一化外部传入的设备名;不认识的取值返回 null(**不是**落默认设备) */
|
|
@@ -68,19 +56,6 @@ export function deviceLabel<T>(defs: readonly DeviceDef<T>[], id: string): strin
|
|
|
68
56
|
return defs.find(def => def.id === id)?.label ?? id;
|
|
69
57
|
}
|
|
70
58
|
|
|
71
|
-
/** 从外部输入判定「本次访问属于哪台设备」;清单顺序 = 优先级,返回值永远非空 */
|
|
72
|
-
export function pickDeviceId<T>(
|
|
73
|
-
defs: readonly DeviceDef<T>[],
|
|
74
|
-
ctx: DeviceMatchContext,
|
|
75
|
-
defaultDevice: string
|
|
76
|
-
): string {
|
|
77
|
-
for (const def of defs) {
|
|
78
|
-
if (def.match(ctx)) return def.id;
|
|
79
|
-
}
|
|
80
|
-
// 清单被改坏(末条不再无条件成立)时的保底
|
|
81
|
-
return defaultDevice;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
59
|
/** 取某设备的容器表;未知设备返回空表(调用方退到兜底设备) */
|
|
85
60
|
export function containersOf<T>(
|
|
86
61
|
defs: readonly DeviceDef<T>[],
|
|
@@ -88,20 +63,3 @@ export function containersOf<T>(
|
|
|
88
63
|
): ReadonlyMap<string, T> {
|
|
89
64
|
return defs.find(def => def.id === id)?.containers ?? new Map();
|
|
90
65
|
}
|
|
91
|
-
|
|
92
|
-
/** 从当前访问信息构造判定上下文 */
|
|
93
|
-
export function deviceContext(
|
|
94
|
-
query: Record<string, unknown> | undefined,
|
|
95
|
-
path: string,
|
|
96
|
-
viewportIsMobile: boolean
|
|
97
|
-
): DeviceMatchContext {
|
|
98
|
-
const text = (key: string): string =>
|
|
99
|
-
typeof query?.[key] === 'string' ? (query[key] as string) : '';
|
|
100
|
-
return {
|
|
101
|
-
explicitMobile: text('isMobile') === 'true',
|
|
102
|
-
path: path.split(/[?#]/)[0],
|
|
103
|
-
from: text('from'),
|
|
104
|
-
fromLogin: text('fromLogin'),
|
|
105
|
-
viewportIsMobile
|
|
106
|
-
};
|
|
107
|
-
}
|
|
@@ -1,41 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 设备声明工厂 —— `defineThemeDevice`(声明式,自动注册进设备域)
|
|
3
3
|
*
|
|
4
|
-
* 宿主只写一条声明(`id` / `label` / `urlKey` / `containers` 三参数 / `routePath
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `asKnownDevice`。
|
|
4
|
+
* 宿主只写一条声明(`id` / `label` / `urlKey` / `containers` 三参数 / `routePath`),
|
|
5
|
+
* 机制全在库内:`pageContainers` 建容器表、按声明顺序注册进设备域。宿主不再写
|
|
6
|
+
* `createDeviceRegistry` 装配、不再手动组数组、不再转发 `asKnownDevice`。
|
|
8
7
|
*
|
|
9
|
-
* ===
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* === 🔴 这里没有 `match`(2026-10-19 收口)===
|
|
9
|
+
* 早先这里带一个「声明式 match」(`explicitMobile` / `from` / `fromLogin` /
|
|
10
|
+
* `pathSegment` / `viewportMobile` / `fallback`),并由库解释成判定函数 ——
|
|
11
|
+
* 那等于把**宿主的判定词汇**(`?isMobile`、`?from=mini`、视口阈值)写进了库。
|
|
12
|
+
* 现在判定归宿主:`app.use(theme, { resolveDevice })` 传一个函数,库把 router 的
|
|
13
|
+
* `query / path / fullPath` 原样递过去。声明里只剩「这台设备叫什么、容器在哪」。
|
|
14
|
+
*
|
|
15
|
+
* === 声明顺序 = 优先级,末条是兜底 ===
|
|
16
|
+
* `fallbackDevice()` 取末条、`resolveContainerOf()` 兜底也走末条 —— 库不认识
|
|
17
|
+
* `standard` 这类具体名字,只认顺序。所以**末条必须是兜底设备**(宿主关卡盯着)。
|
|
13
18
|
*
|
|
14
19
|
* @author yijiu2025
|
|
15
20
|
* @since 2026-10-18(从 oauth21/src/theme/views/devices.ts 的声明下沉)
|
|
21
|
+
* @since 2026-10-19(`match` 撤出,判定改由宿主注入 `resolveDevice`)
|
|
16
22
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import type { DeviceDef, DeviceMatchContext } from '../views/device';
|
|
23
|
+
import type { DeviceDef } from '../views/device';
|
|
19
24
|
import { pageContainers, resolveContainerOf as resolveContainerOfWith } from './device-containers';
|
|
20
25
|
import type { DeviceContainerLoader } from './device-containers';
|
|
21
26
|
|
|
22
|
-
/**
|
|
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` 的输入(声明式) */
|
|
27
|
+
/** `defineThemeDevice` 的输入(只描述设备事实,不含任何判定) */
|
|
39
28
|
export interface ThemeDeviceOptions {
|
|
40
29
|
/** 设备 id = 主题包内目录名,只允许 `[a-z0-9-]` */
|
|
41
30
|
id: string;
|
|
@@ -54,46 +43,16 @@ export interface ThemeDeviceOptions {
|
|
|
54
43
|
};
|
|
55
44
|
/** 该设备在路由表占的 path(`{page}` 由页面名替换) */
|
|
56
45
|
routePath(page: string): string;
|
|
57
|
-
/** 声明式 match(不再手写闭包) */
|
|
58
|
-
match: DeclarativeMatch;
|
|
59
46
|
}
|
|
60
47
|
|
|
61
|
-
/**
|
|
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 兜底) */
|
|
48
|
+
/** 已自动注册的设备清单(顺序 = 优先级,末条是兜底设备) */
|
|
90
49
|
const registeredDefs: DeviceDef<DeviceContainerLoader>[] = [];
|
|
91
50
|
|
|
92
51
|
/**
|
|
93
52
|
* 声明一台设备并自动注册进设备域
|
|
94
53
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
54
|
+
* 内部只做两件事:`pageContainers(glob, fileRe, idRe)` 建容器表、按声明顺序注册。
|
|
55
|
+
* 「怎么判定当前是哪台设备」不在这里 —— 由宿主 `resolveDevice` 负责。
|
|
97
56
|
*/
|
|
98
57
|
export function defineThemeDevice(options: ThemeDeviceOptions): DeviceDef<DeviceContainerLoader> {
|
|
99
58
|
const def: DeviceDef<DeviceContainerLoader> = {
|
|
@@ -105,8 +64,7 @@ export function defineThemeDevice(options: ThemeDeviceOptions): DeviceDef<Device
|
|
|
105
64
|
options.containers.fileRe,
|
|
106
65
|
options.containers.idRe
|
|
107
66
|
),
|
|
108
|
-
routePath: options.routePath
|
|
109
|
-
match: normalizeMatch(options.match)
|
|
67
|
+
routePath: options.routePath
|
|
110
68
|
};
|
|
111
69
|
registeredDefs.push(def);
|
|
112
70
|
return def;
|
|
@@ -136,17 +94,12 @@ export function deviceIds(): string[] {
|
|
|
136
94
|
return registeredDefs.map(d => d.id);
|
|
137
95
|
}
|
|
138
96
|
|
|
139
|
-
/** 兜底设备 =
|
|
97
|
+
/** 兜底设备 = 末条声明的 id(声明顺序即优先级);未声明设备时返回空串 */
|
|
140
98
|
export function fallbackDevice(): string {
|
|
141
99
|
return registeredDefs[registeredDefs.length - 1]?.id ?? '';
|
|
142
100
|
}
|
|
143
101
|
|
|
144
|
-
/**
|
|
145
|
-
export function pickDeviceId(ctx: DeviceMatchContext): string {
|
|
146
|
-
return pickDeviceIdOf(registeredDefs, ctx, fallbackDevice());
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** 取某设备在某页的容器加载器,**没写时兜底到 standard 设备**(用设备域单例) */
|
|
102
|
+
/** 取某设备在某页的容器加载器,**没写时兜底到末条设备**(用设备域单例) */
|
|
150
103
|
export function resolveContainerOf(id: string, page: string): DeviceContainerLoader {
|
|
151
104
|
return resolveContainerOfWith(registeredDefs, id, page);
|
|
152
105
|
}
|
|
@@ -52,22 +52,26 @@ export function containerOf(
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* 取某设备在某页的容器加载器,**没写时兜底到末条设备**(声明顺序即优先级)
|
|
56
56
|
*
|
|
57
57
|
* 分发器用它做「设备 id → 该页容器」的数据化映射:加一种设备时,只要给它写了容器,
|
|
58
|
-
*
|
|
58
|
+
* 分发器就能渲染;没写则该设备退到兜底设备 —— 不再写死「mobile ? X : mini ? Y : Standard」。
|
|
59
|
+
*
|
|
60
|
+
* 🔴 兜底目标取**末条声明**,不写死 `'standard'`(2026-10-19):具体设备名是宿主的词汇,
|
|
61
|
+
* 库只认「声明顺序,末条兜底」这条机制。宿主把兜底设备放末条即可。
|
|
59
62
|
*/
|
|
60
63
|
export function resolveContainerOf(
|
|
61
64
|
defs: readonly DeviceDef<DeviceContainerLoader>[],
|
|
62
65
|
id: string,
|
|
63
66
|
page: string
|
|
64
67
|
): DeviceContainerLoader {
|
|
68
|
+
const fallback = defs[defs.length - 1]?.id;
|
|
65
69
|
return (
|
|
66
70
|
containerOf(defs, id, page) ??
|
|
67
|
-
containerOf(defs,
|
|
71
|
+
(fallback === undefined ? undefined : containerOf(defs, fallback, page)) ??
|
|
68
72
|
(() =>
|
|
69
73
|
Promise.reject(
|
|
70
|
-
new Error(`[theme] 设备「${id}」在页面「${page}
|
|
74
|
+
new Error(`[theme] 设备「${id}」在页面「${page}」没有容器,也没有兜底设备`)
|
|
71
75
|
))
|
|
72
76
|
);
|
|
73
77
|
}
|
package/src/vue/device-domain.ts
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 设备域运行时 ——
|
|
2
|
+
* 设备域运行时 —— 维护「当前生效设备」这一状态,判定交给宿主
|
|
3
3
|
*
|
|
4
|
-
* 与 `define-theme-device
|
|
5
|
-
*
|
|
6
|
-
* token 注入 / 版式查找取用。
|
|
4
|
+
* 与 `define-theme-device`(声明工厂)配对:宿主声明设备清单(自动注册),本文件维护
|
|
5
|
+
* 「当前属于哪台设备」,供宿主 store 订阅、供 token 注入 / 版式查找取用。
|
|
7
6
|
*
|
|
8
|
-
* ===
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* === 🔴 判定归宿主(2026-10-19 收口)===
|
|
8
|
+
* 本文件**不认识**视口阈值、UA、`?isMobile`、`?from=mini`、`?device=` 这些词汇 ——
|
|
9
|
+
* 它们都是宿主的产品约定。库只把 router 上的事实(`query / path / fullPath`)原样
|
|
10
|
+
* 交给宿主注入的 `resolveDevice`,拿回一个设备 id;不合法/返回 null 就落到兜底设备
|
|
11
|
+
* (清单末条)。
|
|
12
|
+
*
|
|
13
|
+
* 宿主自己知道「什么时候该重判」(视口变了、UA 变了、自己的开关变了),所以本文件
|
|
14
|
+
* 额外暴露 `refreshDevice()` 让宿主主动触发 —— 库**不订阅** `matchMedia`,也就不会
|
|
15
|
+
* 和宿主的响应式口径打架。
|
|
11
16
|
*
|
|
12
17
|
* === 为什么库只认 router 的最小结构 ===
|
|
13
18
|
* 库不 import vue-router(应用世界观),只认 `currentRoute.value.{ query, path, fullPath }`
|
|
14
|
-
* 这三个字段 —— 宿主把 router 传进来(`app.use(theme, { router })`)。
|
|
19
|
+
* 这三个字段 —— 宿主把 router 传进来(`app.use(theme, { router, resolveDevice })`)。
|
|
15
20
|
*
|
|
16
21
|
* @author yijiu2025
|
|
17
22
|
* @since 2026-10-18
|
|
23
|
+
* @since 2026-10-19(`matchMedia` / 视口 / `resolveDeviceOverride` 撤出,改 `resolveDevice` + `refreshDevice`)
|
|
18
24
|
*/
|
|
19
25
|
import { ref, watch } from 'vue';
|
|
20
|
-
import { deviceContext, pickDeviceId } from '../views/device';
|
|
21
26
|
import { registeredDevices } from './define-theme-device';
|
|
22
|
-
import { DESKTOP_MIN_WIDTH, MOBILE_BREAKPOINT, detectMobileUA } from './viewport';
|
|
23
27
|
|
|
24
28
|
/** 路由的最小结构(库不 import vue-router,只认这些字段) */
|
|
25
29
|
export interface DeviceDomainRouter {
|
|
@@ -34,75 +38,63 @@ export interface DeviceDomainRouter {
|
|
|
34
38
|
replace?(to: { query: Record<string, string | string[] | undefined> }): unknown;
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 交给宿主的**判定输入** —— 只有事实,没有任何词汇
|
|
43
|
+
*
|
|
44
|
+
* 宿主在这里自己决定:视口宽窄、UA、`?isMobile`、`?from=mini`、`?device=` 锁定……
|
|
45
|
+
* 都是宿主的事;库不参与、也不缓存这些概念。
|
|
46
|
+
*/
|
|
47
|
+
export interface DeviceDomainContext {
|
|
48
|
+
/** 当前路由 query */
|
|
49
|
+
query: Record<string, unknown>;
|
|
50
|
+
/** 路由 path(不含 query / hash) */
|
|
51
|
+
path: string;
|
|
52
|
+
/** 完整路径(宿主可用于自己的来源判断) */
|
|
53
|
+
fullPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
export interface InstallThemeDomainOptions {
|
|
38
57
|
router: DeviceDomainRouter;
|
|
39
58
|
/**
|
|
40
|
-
*
|
|
59
|
+
* 宿主判定「本次访问属于哪台设备」:返回设备 id,`null` = 交给兜底设备。
|
|
41
60
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* 优先于 `?isMobile` / `?from` / 视口;返回 null 或非法 id 则走自动判定。
|
|
61
|
+
* 返回未知 id 时按 `null` 处理(不信任宿主手滑写错的名字)。
|
|
62
|
+
* 🔴 这是**唯一的判定入口** —— 库内没有任何默认判定实现。
|
|
45
63
|
*/
|
|
46
|
-
|
|
64
|
+
resolveDevice?: (ctx: DeviceDomainContext) => string | null;
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
/** 当前生效设备(响应式;宿主 store 从这里订阅) */
|
|
50
68
|
const activeDevice = ref<string>('');
|
|
51
69
|
|
|
52
|
-
/** 视口是否判为手机(响应式;「1024 优先 + 768 窄 + UA 兜底」口径,见 viewport.ts) */
|
|
53
|
-
const viewportMobile = ref(false);
|
|
54
|
-
|
|
55
70
|
let routerRef: DeviceDomainRouter | null = null;
|
|
56
|
-
let
|
|
71
|
+
let resolveDevice: InstallThemeDomainOptions['resolveDevice'];
|
|
57
72
|
|
|
58
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* 判定一次设备并写入 `activeDevice`
|
|
75
|
+
*
|
|
76
|
+
* 优先宿主 `resolveDevice` 的结果;返回 null / 未知 id → 清单末条(兜底设备)。
|
|
77
|
+
*/
|
|
59
78
|
function judgeDevice(): void {
|
|
60
79
|
const defs = registeredDevices();
|
|
61
80
|
if (!routerRef || defs.length === 0) return;
|
|
62
81
|
const route = routerRef.currentRoute.value;
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
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);
|
|
83
|
+
const picked = resolveDevice?.({ query: route.query, path: route.path, fullPath: route.fullPath });
|
|
84
|
+
const known = picked !== null && picked !== undefined && defs.some(d => d.id === picked);
|
|
85
|
+
const next = known ? (picked as string) : defs[defs.length - 1].id;
|
|
75
86
|
if (activeDevice.value !== next) activeDevice.value = next;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
/**
|
|
79
|
-
* 装配设备域运行时(`app.use(theme, { router })` 时调用一次)
|
|
90
|
+
* 装配设备域运行时(`app.use(theme, { router, resolveDevice })` 时调用一次)
|
|
80
91
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* 判出的 `currentDevice` 与分发器渲染的形态同源。
|
|
92
|
+
* 只建一条响应链:路由 `fullPath` 变动 → 判定。
|
|
93
|
+
* 视口 / UA / 宿主其它信号变化时,宿主自己调 `refreshDevice()`。
|
|
84
94
|
*/
|
|
85
95
|
export function installThemeDomain(options: InstallThemeDomainOptions): void {
|
|
86
96
|
routerRef = options.router;
|
|
87
|
-
|
|
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
|
-
}
|
|
97
|
+
resolveDevice = options.resolveDevice;
|
|
106
98
|
|
|
107
99
|
// 🔴 watch fullPath 而非整个路由对象:同路由 query/hash 变化不触发重判
|
|
108
100
|
watch(
|
|
@@ -110,9 +102,15 @@ export function installThemeDomain(options: InstallThemeDomainOptions): void {
|
|
|
110
102
|
() => judgeDevice(),
|
|
111
103
|
{ immediate: true }
|
|
112
104
|
);
|
|
105
|
+
}
|
|
113
106
|
|
|
114
|
-
|
|
115
|
-
|
|
107
|
+
/**
|
|
108
|
+
* 让宿主在**自己的信号变化时**主动重判(视口、UA、自己存的开关……)
|
|
109
|
+
*
|
|
110
|
+
* 幂等、廉价;未装配 router 时是空操作。
|
|
111
|
+
*/
|
|
112
|
+
export function refreshDevice(): void {
|
|
113
|
+
judgeDevice();
|
|
116
114
|
}
|
|
117
115
|
|
|
118
116
|
/** 当前生效设备(只读响应式,宿主 store 订阅它) */
|
package/src/vue/index.ts
CHANGED
|
@@ -56,26 +56,30 @@ export {
|
|
|
56
56
|
deviceIds,
|
|
57
57
|
fallbackDevice,
|
|
58
58
|
isKnownDevice,
|
|
59
|
-
pickDeviceId,
|
|
60
59
|
registeredDevices,
|
|
61
60
|
resetDevices,
|
|
62
61
|
resolveContainerOf,
|
|
63
62
|
urlKeyOf
|
|
64
63
|
} from './define-theme-device';
|
|
65
|
-
export type {
|
|
66
|
-
export {
|
|
67
|
-
|
|
64
|
+
export type { ThemeDeviceOptions } from './define-theme-device';
|
|
65
|
+
export {
|
|
66
|
+
currentDevice,
|
|
67
|
+
currentQuery,
|
|
68
|
+
installThemeDomain,
|
|
69
|
+
refreshDevice,
|
|
70
|
+
replaceQuery
|
|
71
|
+
} from './device-domain';
|
|
72
|
+
export type {
|
|
73
|
+
DeviceDomainContext,
|
|
74
|
+
DeviceDomainRouter,
|
|
75
|
+
InstallThemeDomainOptions
|
|
76
|
+
} from './device-domain';
|
|
68
77
|
export { theme } from './plugin';
|
|
69
78
|
export type { ThemePluginOptions } from './plugin';
|
|
70
79
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
MOBILE_BREAKPOINT,
|
|
75
|
-
detectMobileUA,
|
|
76
|
-
isDesktopViewport,
|
|
77
|
-
isMobileViewport
|
|
78
|
-
} from './viewport';
|
|
80
|
+
// ── 视口 / UA 判定**不在这里**(2026-10-19 收口)──────────────────────────────
|
|
81
|
+
// 「手机还是电脑」是宿主的业务口径(768 / 1024 阈值、UA 兜底、`?isMobile` 等),
|
|
82
|
+
// 换个产品就变。库只提供注入点 `resolveDevice`(见 device-domain),宿主自己判定。
|
|
79
83
|
|
|
80
84
|
// ---- 主题状态机(scope/intent/runtime + 持久化,2026-10-19 下沉)----
|
|
81
85
|
// 🔴 只导出 `createThemeState`(编排入口)—— scope/intent/runtime/persistence 是内部实现,
|
package/src/vue/plugin.ts
CHANGED
|
@@ -20,16 +20,22 @@ import type { DeviceDomainRouter, InstallThemeDomainOptions } from './device-dom
|
|
|
20
20
|
export interface ThemePluginOptions {
|
|
21
21
|
/** 宿主路由(库只认 `currentRoute.value.{ query, path, fullPath }` 最小结构) */
|
|
22
22
|
router: DeviceDomainRouter;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 宿主判定「当前属于哪台设备」—— **唯一的判定入口**。
|
|
25
|
+
*
|
|
26
|
+
* 库把 `{ query, path, fullPath }` 原样递过去,宿主自己用视口 / UA / 自己的 URL 词汇
|
|
27
|
+
* (`?isMobile`、`?from=mini`、`?device=` 锁定……)决定返回哪个设备 id;返回 `null`
|
|
28
|
+
* 走兜底设备(清单末条)。
|
|
29
|
+
*/
|
|
30
|
+
resolveDevice?: InstallThemeDomainOptions['resolveDevice'];
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
/** Vue 插件:`app.use(theme, { router,
|
|
33
|
+
/** Vue 插件:`app.use(theme, { router, resolveDevice })` */
|
|
28
34
|
export const theme: Plugin<[ThemePluginOptions]> = {
|
|
29
35
|
install(_app: App, options: ThemePluginOptions) {
|
|
30
36
|
installThemeDomain({
|
|
31
37
|
router: options.router,
|
|
32
|
-
|
|
38
|
+
resolveDevice: options.resolveDevice
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
41
|
};
|
package/src/vue/theme-scope.ts
CHANGED
|
@@ -62,7 +62,8 @@ export function useThemeScope(keys: ThemeStorageKeys, colorParam: string) {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* 从**设备域**订阅当前生效设备(设备判定已收进库内设备域,这里只读)。
|
|
65
|
-
* `app.use(theme, { router })` 里设备域监听路由 +
|
|
65
|
+
* `app.use(theme, { router, resolveDevice })` 里设备域监听路由 + 宿主主动
|
|
66
|
+
* `refreshDevice()` → 宿主 `resolveDevice` 判定 → `activeDevice`,
|
|
66
67
|
* 这里把它同步进作用域(`immediate` 覆盖 store 实例化时设备域已判好的值)。
|
|
67
68
|
*/
|
|
68
69
|
watch(
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 设备注册表工厂 —— 「一份设备清单 → 全套判定机制」的**唯一实现**(内核版)
|
|
3
|
-
*
|
|
4
|
-
* === 为什么要有它 ===
|
|
5
|
-
* 宿主(oauth21 等)曾经在 `views/devices.ts` 里既要写清单(`DeviceDef[]`),又要逐个
|
|
6
|
-
* re-export 内核的判定函数(`deviceLabel` / `pickDeviceId` / `containersOf` / …),
|
|
7
|
-
* 每个新项目都要把这一串转发函数抄一遍 —— 那是纯样板。本工厂把「清单 → 机制」收成
|
|
8
|
-
* 一步:宿主只写一份 `DeviceDef[]`,调一次 `createDeviceRegistry(defs)`,全套判定函数
|
|
9
|
-
* 就位,且都绑定了这份清单、参数顺序按宿主直觉收敛(不再要求每个调用点传 defs)。
|
|
10
|
-
*
|
|
11
|
-
* === 与 `views/device.ts` 的关系 ===
|
|
12
|
-
* `device.ts` 是**无状态纯函数**(`pickDeviceId(defs, ctx, …)` 每次传清单),供内核
|
|
13
|
-
* 内部与「清单还没装进注册表」的场景用;本文件是它的**装配层**——把清单闭包进去,
|
|
14
|
-
* 产出「已绑定清单」的面向宿主的 API。组件半边(`containerOf` / `resolveContainerOf`)
|
|
15
|
-
* 住在 `skinsuite/vue` 的 `device-containers.ts`,本文件的工厂也收那一半。
|
|
16
|
-
*
|
|
17
|
-
* === 为什么设备是宽 `string` + 白名单参数 ===
|
|
18
|
-
* 内核不认识宿主的设备词汇表(哪些目录名算合法设备是宿主的部署事实),所以:
|
|
19
|
-
* · 判定函数对外都收 `string`、返回 `string`;
|
|
20
|
-
* · 「合法取值集合」由清单派生(`DeviceRegistry.devices`);
|
|
21
|
-
* · 「归一化外部输入」用清单派生的白名单(`asKnownDevice`)——不再要求宿主另传。
|
|
22
|
-
*
|
|
23
|
-
* @author yijiu2025
|
|
24
|
-
* @since 2026-10-17(从 oauth21/src/theme/views/devices.ts 的转发函数收编)
|
|
25
|
-
*/
|
|
26
|
-
import { containersOf } from './device.js';
|
|
27
|
-
import type { DeviceDef, DeviceMatchContext } from './device.js';
|
|
28
|
-
import { asDeviceId, deviceContext, deviceLabel, pickDeviceId } from './device.js';
|
|
29
|
-
|
|
30
|
-
export type { DeviceDef, DeviceMatchContext } from './device.js';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 一套设备注册表 —— 宿主「设备这件事」的**全部**能力
|
|
34
|
-
*
|
|
35
|
-
* 所有函数都已绑定传入的清单,调用方不再需要每次传 `defs`。
|
|
36
|
-
* `T` 是容器加载器的类型(纯逻辑侧透传、不关心它到底是什么)。
|
|
37
|
-
*/
|
|
38
|
-
export interface DeviceRegistry<T = unknown> {
|
|
39
|
-
/** 设备 id 清单(顺序即优先级)—— 词汇表的**唯一来源** */
|
|
40
|
-
readonly defs: readonly DeviceDef<T>[];
|
|
41
|
-
/** 设备 id 数组(顺序即优先级) */
|
|
42
|
-
readonly devices: readonly string[];
|
|
43
|
-
/** 兜底设备 id */
|
|
44
|
-
readonly defaultDevice: string;
|
|
45
|
-
/** 设备 id → 中文名(调试面板 / 日志用;未知 id 原样返回) */
|
|
46
|
-
deviceLabel(id: string): string;
|
|
47
|
-
/** 归一化外部传入的设备名;不认识的取值返回 null(**不是**落默认设备) */
|
|
48
|
-
asKnownDevice(raw: unknown): string | null;
|
|
49
|
-
/** 从外部输入判定「本次访问属于哪台设备」;返回值永远非空(落 defaultDevice) */
|
|
50
|
-
pickDeviceId(ctx: DeviceMatchContext): string;
|
|
51
|
-
/** 取某设备的容器表(`containers` 是泛型 T,原样透传) */
|
|
52
|
-
containersOf(id: string): ReadonlyMap<string, T>;
|
|
53
|
-
/** 从当前访问信息构造判定上下文 */
|
|
54
|
-
deviceContext(
|
|
55
|
-
query: Record<string, unknown> | undefined,
|
|
56
|
-
path: string,
|
|
57
|
-
viewportIsMobile: boolean
|
|
58
|
-
): DeviceMatchContext;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface DeviceRegistryOptions {
|
|
62
|
-
/** 兜底设备 id(判定全 miss 时返回);缺省取清单最后一条的 id */
|
|
63
|
-
defaultDevice?: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 从一份设备清单装配出整套设备机制
|
|
68
|
-
*
|
|
69
|
-
* @param defs 设备清单(顺序即优先级,末条建议无条件命中作兜底)
|
|
70
|
-
* @param options 兜底设备等可选项
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* ```ts
|
|
74
|
-
* const registry = createDeviceRegistry(THEME_DEVICE_DEFS, { defaultDevice: 'mobile' });
|
|
75
|
-
* registry.pickDeviceId(registry.deviceContext(query, path, isMobile));
|
|
76
|
-
* registry.asKnownDevice('mini'); // → 'mini' | null
|
|
77
|
-
* ```
|
|
78
|
-
*/
|
|
79
|
-
export function createDeviceRegistry<T>(
|
|
80
|
-
defs: readonly DeviceDef<T>[],
|
|
81
|
-
options: DeviceRegistryOptions = {}
|
|
82
|
-
): DeviceRegistry<T> {
|
|
83
|
-
const defaultDevice = options.defaultDevice ?? defs[defs.length - 1]?.id ?? '';
|
|
84
|
-
const devices = defs.map(def => def.id);
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
defs,
|
|
88
|
-
devices,
|
|
89
|
-
defaultDevice,
|
|
90
|
-
deviceLabel: (id: string) => deviceLabel(defs, id),
|
|
91
|
-
asKnownDevice: (raw: unknown) => asDeviceId(devices, raw),
|
|
92
|
-
pickDeviceId: (ctx: DeviceMatchContext) => pickDeviceId(defs, ctx, defaultDevice),
|
|
93
|
-
containersOf: (id: string) => containersOf(defs, id),
|
|
94
|
-
deviceContext
|
|
95
|
-
};
|
|
96
|
-
}
|
package/src/vue/viewport.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 视口 / UA 设备形态判定(**纯浏览器判定**,SSR 安全)
|
|
3
|
-
*
|
|
4
|
-
* 从 `oauth21/src/utils/device.ts` 下沉(2026-10-19)。判定口径只有这一份:
|
|
5
|
-
* 设备域(`device-domain`)用它的响应式版判「当前设备」,宿主守卫 / 请求拦截等
|
|
6
|
-
* 用它的纯函数版判「跳哪个登录页」—— 两边同源,不再漂移。
|
|
7
|
-
*
|
|
8
|
-
* === 判定口径(三级,宽视口优先于 UA)===
|
|
9
|
-
* 1. 视口 ≥ 1024px(DESKTOP_MIN_WIDTH)→ 电脑版(UA 说什么都不例外)
|
|
10
|
-
* 2. 视口 < 768px(MOBILE_BREAKPOINT)→ 移动端
|
|
11
|
-
* 3. 中间段(768–1023)→ UA 命中真机才算移动端(该区间可能是被缩放的桌面浏览器)
|
|
12
|
-
*
|
|
13
|
-
* === 为什么宽视口优先于 UA(2026-09-21 线上问题)===
|
|
14
|
-
* 平板横屏 / 折叠屏展开 / 手机开「桌面版网站」时 UA 仍报手机、视口却有 1024px+,
|
|
15
|
-
* 恒定按 UA 判移动端会把移动端版式铺满整屏。宽度比 UA 更贴近「这块屏放不放得下
|
|
16
|
-
* 桌面布局」这个真问题。
|
|
17
|
-
*
|
|
18
|
-
* @author yijiu2025
|
|
19
|
-
* @since 2026-10-19(从 oauth21/src/utils/device.ts 下沉,供设备域与宿主共用)
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** 移动端判定阈值(px):窄于它说明视口放不下桌面双栏布局 */
|
|
23
|
-
export const MOBILE_BREAKPOINT = 768;
|
|
24
|
-
|
|
25
|
-
/** 桌面端判定阈值(px):宽于等于它说明屏幕放得下完整桌面布局(对齐 Tailwind lg 断点) */
|
|
26
|
-
export const DESKTOP_MIN_WIDTH = 1024;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* UA 兜底检测:视口宽度会被窗口缩放 / 分屏 / DevTools 模拟影响,
|
|
30
|
-
* UA 能识别「真手机/平板」(新版 iPad UA 已不含 iPad,走 maxTouchPoints 兜底)。
|
|
31
|
-
*/
|
|
32
|
-
export function detectMobileUA(): boolean {
|
|
33
|
-
if (typeof navigator === 'undefined') return false;
|
|
34
|
-
const ua = navigator.userAgent || '';
|
|
35
|
-
const uaMobile = /Android|iPhone|iPod|Windows Phone|IEMobile|BlackBerry|Opera Mini/i.test(ua);
|
|
36
|
-
const iPadLike =
|
|
37
|
-
/iPad/i.test(ua) ||
|
|
38
|
-
(/Macintosh/i.test(ua) && typeof navigator.maxTouchPoints === 'number' && navigator.maxTouchPoints > 1);
|
|
39
|
-
return uaMobile || iPadLike;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** 视口是否已宽到该用电脑版(只看宽度,不看 UA) */
|
|
43
|
-
export function isDesktopViewport(): boolean {
|
|
44
|
-
if (typeof window === 'undefined') return false;
|
|
45
|
-
return window.innerWidth >= DESKTOP_MIN_WIDTH;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 当前是否应按移动端处理(一次性判定;SSR / 无 window 环境返回 false = 桌面默认) */
|
|
49
|
-
export function isMobileViewport(): boolean {
|
|
50
|
-
if (typeof window === 'undefined') return false;
|
|
51
|
-
if (isDesktopViewport()) return false;
|
|
52
|
-
if (detectMobileUA()) return true;
|
|
53
|
-
return window.innerWidth < MOBILE_BREAKPOINT;
|
|
54
|
-
}
|