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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 页面工厂 —— 「这一页接入可换 UI 机制」的**唯一写法**(内核版)
|
|
3
|
+
*
|
|
4
|
+
* === 它收掉的是什么 ===
|
|
5
|
+
* 三个页面文件原先各自抄了一遍同样的机制:声明 glob → `createViewRegistry` →
|
|
6
|
+
* `createViewPicker` → 两个转发函数。机制逐字相同,改一条口径要改三处。现在机制只在
|
|
7
|
+
* 这里写一次,页面文件退化成「一份契约 + 一次声明」。
|
|
8
|
+
*
|
|
9
|
+
* === 版式组件从哪来 ===
|
|
10
|
+
* `themeViews` 来自 `virtual:theme` 插件(主题目录 = `VITE_THEME_DIR`,构建期扫
|
|
11
|
+
* `themes/<包>/<设备>/<页面>/index.vue` 按页面分组)。页面文件**不自己 glob、也不 import
|
|
12
|
+
* `virtual:theme`**:`loaders` 在取名(渲染期)由 `themeViewsFor(page)` 惰性解析 ——
|
|
13
|
+
* 注册时零依赖,`virtual:theme` 反过来副作用 import 页面文件时才不会踩循环。
|
|
14
|
+
*
|
|
15
|
+
* === 页面名从哪来 ===
|
|
16
|
+
* 页面名同时是:目录名、`views/<页面>.ts` 的文件名、路由 path 的末段。三处必须字面一致。
|
|
17
|
+
*
|
|
18
|
+
* === 页面身份与路由的关系(2026-10-18 解耦)===
|
|
19
|
+
* 页面清单是**宿主事实**(router 固定声明 `/<page>` 等主路由),本工厂只注册
|
|
20
|
+
* 「页面名 → 版式」,**不驱动路由、不声明标题**(`document.title` 归路由)。容器里
|
|
21
|
+
* `<ThemeView page="...">` 的 `page` 与路由 path 靠字面一致对齐。
|
|
22
|
+
*
|
|
23
|
+
* @author yijiu2025
|
|
24
|
+
* @since 2026-10-17(从 oauth21/src/theme/views/page.ts 下沉;2026-10-18 惰性 loader 解析)
|
|
25
|
+
*/
|
|
26
|
+
import type { ViewRegistry, ViewPickerSource } from './view-registry';
|
|
27
|
+
import { createViewRegistry, createViewPicker } from './view-registry';
|
|
28
|
+
import { themeViewsFor } from './theme-views';
|
|
29
|
+
|
|
30
|
+
/** 页面对象的运行时标记(`Symbol.for` = 跨模块实例也同一个键) */
|
|
31
|
+
const PAGE_BRAND = Symbol.for('mauth.themePage');
|
|
32
|
+
|
|
33
|
+
export interface ThemePageOptions {
|
|
34
|
+
/** 页面名(目录名 / 文件名 / 路由末段三处一致) */
|
|
35
|
+
page: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 一个页面接入版式机制后的全部能力(页面文件的公开面就长这样) */
|
|
39
|
+
export interface ThemePage {
|
|
40
|
+
readonly page: string;
|
|
41
|
+
readonly views: ViewRegistry;
|
|
42
|
+
pick(source?: ViewPickerSource): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 已自动注册的页面(页面域;按页面名取 `ThemePage`) */
|
|
46
|
+
const registeredPages = new Map<string, ThemePage>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 声明一个页面
|
|
50
|
+
*
|
|
51
|
+
* 用法(页面文件里就这么多):
|
|
52
|
+
* ```ts
|
|
53
|
+
* defineThemePage({ page: 'login' });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* 无 `device` 字段:设备归一化由设备域自动提供。
|
|
57
|
+
* 无 `envView` 字段:部署级默认版式(`VITE_<PAGE>_VIEW`)机制已取消。
|
|
58
|
+
* 无 `title` 字段:标题(`document.title`)是路由职责,由宿主路由表声明(router 与主题分层)。
|
|
59
|
+
* 无 `loaders` 字段:版式组件清单由 `themeViewsFor(page)` 惰性解析(`virtual:theme` 装单例)。
|
|
60
|
+
*/
|
|
61
|
+
export function defineThemePage(options: ThemePageOptions): ThemePage {
|
|
62
|
+
const { page } = options;
|
|
63
|
+
const views = createViewRegistry(() => themeViewsFor(page), { page });
|
|
64
|
+
const picker = createViewPicker({ registry: views });
|
|
65
|
+
|
|
66
|
+
const result: ThemePage = {
|
|
67
|
+
page,
|
|
68
|
+
views,
|
|
69
|
+
pick: (source: ViewPickerSource = {}) => picker.pick(source)
|
|
70
|
+
};
|
|
71
|
+
// 标记不可枚举:它只是个身份戳,不该出现在日志 / 序列化里
|
|
72
|
+
Object.defineProperty(result, PAGE_BRAND, { value: true, enumerable: false });
|
|
73
|
+
registeredPages.set(page, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 这个值是不是 `defineThemePage` 的产物 */
|
|
78
|
+
export function isThemePage(value: unknown): value is ThemePage {
|
|
79
|
+
return (
|
|
80
|
+
!!value &&
|
|
81
|
+
typeof value === 'object' &&
|
|
82
|
+
(value as Record<PropertyKey, unknown>)[PAGE_BRAND] === true
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 按页面名取已注册的页面(`<ThemeView>` 内部用);未注册返回 undefined */
|
|
87
|
+
export function pageOf(name: string): ThemePage | undefined {
|
|
88
|
+
return registeredPages.get(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 已注册的页面名清单(调试面板用) */
|
|
92
|
+
export function registeredPageNames(): readonly string[] {
|
|
93
|
+
return [...registeredPages.keys()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 重置模块级页面域(单测 / HMR 用) */
|
|
97
|
+
export function resetPages(): void {
|
|
98
|
+
registeredPages.clear();
|
|
99
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版式(view)注册表 + 选择器 —— 「业务容器 + 可换 UI」的通用机制(内核版)
|
|
3
|
+
*
|
|
4
|
+
* === 这一层解决什么 ===
|
|
5
|
+
* 页面拆成两半:
|
|
6
|
+
* · **业务容器** —— 状态、校验、请求、路由跳转。只此一份。
|
|
7
|
+
* · **版式(UI)** —— 只负责「长什么样」,通过 props 拿到容器给的 `ctx` 来渲染与回调。
|
|
8
|
+
* 于是「换一套 UI」= 加一个目录,不用碰业务代码,也不会出现两份校验逻辑各自漂移。
|
|
9
|
+
*
|
|
10
|
+
* === 版式跟随主题包与设备 ===
|
|
11
|
+
* 版式实现住在主题包里、且按设备分区(`themes/<包>/<设备>/<page>/index.vue`),
|
|
12
|
+
* 查找范围就是**当前主题包 × 当前设备**。契约与注册表是**每页一份**的页面文件
|
|
13
|
+
* (`views/<page>.ts`),留在宿主架构层,所有包共用一份。
|
|
14
|
+
*
|
|
15
|
+
* === 选择优先级(高 → 低)===
|
|
16
|
+
* 1. URL `?th[_<设备缩写>]` 2. 主题包/配色声明的 `views.<page>`
|
|
17
|
+
* 3. 当前包名(一个主题包 = 一种版式 ⇒ 版式 id ≡ 包名)
|
|
18
|
+
* 🔴 第 1 档命中后不再回退;非法值一律落当前包名。
|
|
19
|
+
*
|
|
20
|
+
* @author yijiu2025
|
|
21
|
+
* @since 2026-10-17(从 oauth21 下沉;2026-10-17 末合并原 view-picker.ts,共 180 行)
|
|
22
|
+
*/
|
|
23
|
+
import type { Component } from 'vue';
|
|
24
|
+
import { registeredDevices } from './define-theme-device';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 版式组件加载表形态:键 → 加载器
|
|
28
|
+
*
|
|
29
|
+
* 内置包(`default`)的加载器由 `virtual:theme` 插件**同步**返回(eager 静态 import,
|
|
30
|
+
* 零请求首屏);非内置包返回 `Promise`(`import()` 惰性 chunk)。`await` 对两者都成立,
|
|
31
|
+
* 但只有同步形态能支撑「首帧同步拿到内置包版式」的 `builtin()` 路径。
|
|
32
|
+
*/
|
|
33
|
+
export type ViewModuleLoader = () => { default: Component } | Promise<{ default: Component }>;
|
|
34
|
+
export type ViewModuleMap = Record<string, ViewModuleLoader>;
|
|
35
|
+
/**
|
|
36
|
+
* 版式加载表的**惰性提供者**:`() => ViewModuleMap`。
|
|
37
|
+
*
|
|
38
|
+
* 页面契约文件在注册时(`defineThemePage`)可能早于 `virtual:theme` 把 `themeViews`
|
|
39
|
+
* 装入单例,所以建注册表时**不立即读**加载表,而是传一个 provider,等首次查询
|
|
40
|
+
* (渲染期)再取 —— 既打破「页面契约 → virtual:theme」的循环,又保证取到的是终态。
|
|
41
|
+
*/
|
|
42
|
+
export type ViewModuleProvider = () => ViewModuleMap;
|
|
43
|
+
|
|
44
|
+
/** 某个主题包在**某设备**该页登记了哪些版式 */
|
|
45
|
+
export interface PackageViews {
|
|
46
|
+
pkg: string;
|
|
47
|
+
device: string;
|
|
48
|
+
ids: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ViewRegistry {
|
|
52
|
+
readonly baseId: string;
|
|
53
|
+
readonly builtinPackage: string;
|
|
54
|
+
packages(): PackageViews[];
|
|
55
|
+
has(raw: unknown, pkg: string, device: string): boolean;
|
|
56
|
+
resolve(raw: unknown, pkg: string, device: string): string | null;
|
|
57
|
+
load(id: string | null, pkg: string, device: string): Promise<Component | null>;
|
|
58
|
+
/** 内置包(`default`)在**某设备**该页的版式组件(同步,eager 静态 import;无则 null) */
|
|
59
|
+
builtin(device: string): Component | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ViewRegistryOptions {
|
|
63
|
+
page: string;
|
|
64
|
+
baseId?: string;
|
|
65
|
+
builtinPackage?: string;
|
|
66
|
+
idRe?: RegExp;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 选择器的输入:四个都是外部值,全部未校验 */
|
|
70
|
+
export interface ViewPickerSource {
|
|
71
|
+
url?: unknown;
|
|
72
|
+
theme?: unknown;
|
|
73
|
+
pkg?: unknown;
|
|
74
|
+
device?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ViewPicker {
|
|
78
|
+
pick(source?: ViewPickerSource): string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ViewPickerOptions {
|
|
82
|
+
registry: ViewRegistry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DEFAULT_VIEW_ID_RE = /^[a-z0-9-]+$/;
|
|
86
|
+
|
|
87
|
+
function asText(value: unknown): string | null {
|
|
88
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 设备名是否在设备域白名单里(`defineThemeDevice` 自动注册) */
|
|
92
|
+
function isKnownDevice(raw: unknown): raw is string {
|
|
93
|
+
return typeof raw === 'string' && registeredDevices().some(d => d.id === raw);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 归一化设备名:不在白名单返回空串(建表时据此跳过非法设备目录) */
|
|
97
|
+
function normalizeDevice(raw: string): string {
|
|
98
|
+
return isKnownDevice(raw) ? raw : '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createViewRegistry(
|
|
102
|
+
loaders: ViewModuleMap | ViewModuleProvider,
|
|
103
|
+
options: ViewRegistryOptions
|
|
104
|
+
): ViewRegistry {
|
|
105
|
+
const baseId = options.baseId ?? 'base';
|
|
106
|
+
const builtinPackage = options.builtinPackage ?? 'default';
|
|
107
|
+
const idRe = options.idRe ?? DEFAULT_VIEW_ID_RE;
|
|
108
|
+
const { page } = options;
|
|
109
|
+
if (!idRe.test(page)) {
|
|
110
|
+
throw new Error(`[view] page 名「${page}」不合规:只允许小写字母/数字/短横线`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 惰性提供者:注册时可能还拿不到 `themeViews`(`virtual:theme` 还没装单例),
|
|
114
|
+
// 所以这里只记「怎么取」,首次查询(渲染期)才真正建 `bases` 表。
|
|
115
|
+
const resolveLoaders = typeof loaders === 'function' ? loaders : () => loaders;
|
|
116
|
+
const scopeKey = (pkg: string, device: string): string => `${pkg}/${device}`;
|
|
117
|
+
|
|
118
|
+
// 🔴 键形与 `virtual:theme` 的 `themeViews` 一致:`./<包>/<设备>/<页面>/index.vue`
|
|
119
|
+
// (相对 `themes/` 目录,`./` 开头)—— 不再是 `import.meta.glob('/src/theme/themes/...')`
|
|
120
|
+
// 那种 `/src/...` 根绝对路径键。改键形(2026-10-18)后这里必须同步,否则建空表。
|
|
121
|
+
const baseKeyRe = new RegExp(`^\\./([^/]+)/([^/]+)/${page}/index\\.vue$`);
|
|
122
|
+
|
|
123
|
+
let bases: Map<string, ViewModuleLoader> | null = null;
|
|
124
|
+
const ensure = (): Map<string, ViewModuleLoader> => {
|
|
125
|
+
if (bases) return bases;
|
|
126
|
+
bases = new Map();
|
|
127
|
+
for (const [key, loader] of Object.entries(resolveLoaders())) {
|
|
128
|
+
const m = baseKeyRe.exec(key);
|
|
129
|
+
const pkg = m?.[1];
|
|
130
|
+
const device = m?.[2];
|
|
131
|
+
if (!pkg || !device) continue;
|
|
132
|
+
if (!idRe.test(pkg)) continue;
|
|
133
|
+
if (normalizeDevice(device) !== device) continue;
|
|
134
|
+
bases.set(scopeKey(pkg, device), loader);
|
|
135
|
+
}
|
|
136
|
+
return bases;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const resolve = (raw: unknown, pkg: string, device: string): string | null => {
|
|
140
|
+
if (typeof raw !== 'string') return null;
|
|
141
|
+
const id = raw.trim().toLowerCase();
|
|
142
|
+
if (!id || !idRe.test(id)) return null;
|
|
143
|
+
if (id === baseId) return pkg;
|
|
144
|
+
const b = ensure();
|
|
145
|
+
if (b.has(scopeKey(id, device))) return id;
|
|
146
|
+
if (id === pkg && b.has(scopeKey(pkg, device))) return pkg;
|
|
147
|
+
return null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
baseId,
|
|
152
|
+
builtinPackage,
|
|
153
|
+
packages() {
|
|
154
|
+
const b = ensure();
|
|
155
|
+
return [...b.keys()]
|
|
156
|
+
.map(k => {
|
|
157
|
+
const cut = k.indexOf('/');
|
|
158
|
+
return { pkg: k.slice(0, cut), device: k.slice(cut + 1) };
|
|
159
|
+
})
|
|
160
|
+
.sort((a, b2) => `${a.pkg}/${a.device}`.localeCompare(`${b2.pkg}/${b2.device}`))
|
|
161
|
+
.map(({ pkg, device }) => ({ pkg, device, ids: [pkg] }));
|
|
162
|
+
},
|
|
163
|
+
has: (raw, pkg, device) => resolve(raw, pkg, device) !== null,
|
|
164
|
+
resolve,
|
|
165
|
+
async load(id, pkg, device) {
|
|
166
|
+
if (!id || !idRe.test(pkg)) return null;
|
|
167
|
+
const b = ensure();
|
|
168
|
+
const target = id !== pkg && b.has(scopeKey(id, device)) ? id : pkg;
|
|
169
|
+
const loader = b.get(scopeKey(target, device));
|
|
170
|
+
if (!loader) return null;
|
|
171
|
+
try {
|
|
172
|
+
return (await loader()).default ?? null;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.warn(`[view] 主题包「${target}」${device} 端的版式加载失败,已回退`, err);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
builtin(device) {
|
|
179
|
+
const loader = ensure().get(scopeKey(builtinPackage, device));
|
|
180
|
+
if (!loader) return null;
|
|
181
|
+
const mod = loader();
|
|
182
|
+
// 内置包是 eager 静态 import → 同步返回模块命名空间;非内置包才是 Promise
|
|
183
|
+
return mod instanceof Promise ? null : (mod.default ?? null);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 选择器工厂:从外部输入挑出版式 id(永远返回可用 id,最差是当前包名) */
|
|
189
|
+
export function createViewPicker(options: ViewPickerOptions): ViewPicker {
|
|
190
|
+
const { registry } = options;
|
|
191
|
+
|
|
192
|
+
function asPackage(value: unknown): string {
|
|
193
|
+
return asText(value) ?? registry.builtinPackage;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function asDevice(value: unknown): string {
|
|
197
|
+
// 设备名归一化:白名单命中取原值,否则兜底到末条 fallback 设备
|
|
198
|
+
if (isKnownDevice(value)) return value;
|
|
199
|
+
const defs = registeredDevices();
|
|
200
|
+
return defs[defs.length - 1]?.id ?? '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pick(source: ViewPickerSource = {}): string {
|
|
204
|
+
const pkg = asPackage(source.pkg);
|
|
205
|
+
const device = asDevice(source.device);
|
|
206
|
+
const url = asText(source.url);
|
|
207
|
+
// 第 1 档:URL 显式意图 —— 非法值也**不回退**到下面的档
|
|
208
|
+
if (url) return registry.resolve(url, pkg, device) ?? pkg;
|
|
209
|
+
|
|
210
|
+
// 第 2 档 → 第 3 档:主题包/配色声明 → 当前包(一个主题包 = 一种版式)
|
|
211
|
+
return registry.resolve(source.theme, pkg, device) ?? pkg;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { pick };
|
|
215
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|
package/src/mode.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 明暗意图(mode)的取值、循环顺序与展示文案
|
|
3
|
-
*
|
|
4
|
-
* 🔴 明暗 ≡ 色系(2026-09-25 定):「明暗切换就是切换白色和黑色这个色系,不是另一套规则」。
|
|
5
|
-
* mode 不是独立于配色的第二个维度,而是「切到哪一系别」的意图:
|
|
6
|
-
* light —— 明 = 白系
|
|
7
|
-
* dark —— 暗 = 黑系
|
|
8
|
-
* system —— 跟随系统偏好(系统暗→黑系、亮→白系)
|
|
9
|
-
* 最终明暗由**当前配色的 tone** 决定(见 `oauth21/src/stores/theme.ts` 的 `isDark`)。
|
|
10
|
-
* 点色卡同步 mode(点黑卡=dark、点白卡=light),切 mode 联动配色 —— 永远一致。
|
|
11
|
-
*
|
|
12
|
-
* @author yijiu2025
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** 明暗三态:'system' 显式跟随系统(旧版只有布尔值,切过就回不来) */
|
|
16
|
-
export type ThemeMode = 'system' | 'light' | 'dark';
|
|
17
|
-
|
|
18
|
-
/** 切换按钮的循环顺序:跟随系统 → 浅色 → 深色 → 跟随系统 */
|
|
19
|
-
export const MODE_CYCLE: readonly ThemeMode[] = ['system', 'light', 'dark'];
|
|
20
|
-
|
|
21
|
-
/** 界面与无障碍标签用的中文文案(明=白系 / 暗=黑系 / 跟随系统) */
|
|
22
|
-
export const MODE_LABELS: Record<ThemeMode, string> = {
|
|
23
|
-
system: '跟随系统',
|
|
24
|
-
light: '明',
|
|
25
|
-
dark: '暗'
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** 是否是合法的明暗取值(用于校验 URL 参数、后端下发这类不可信输入) */
|
|
29
|
-
export function isThemeMode(value: unknown): value is ThemeMode {
|
|
30
|
-
return value === 'system' || value === 'light' || value === 'dark';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 归一化外部输入:大小写与首尾空白容忍,非法返回 null(由调用方决定回退) */
|
|
34
|
-
export function normalizeMode(raw: unknown): ThemeMode | null {
|
|
35
|
-
if (typeof raw !== 'string') return null;
|
|
36
|
-
const v = raw.trim().toLowerCase();
|
|
37
|
-
return isThemeMode(v) ? v : null;
|
|
38
|
-
}
|
package/src/tone.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配色的「明暗系别」(tone)
|
|
3
|
-
*
|
|
4
|
-
* 与 `mode`(明暗偏好)是两个不同的维度,本模块负责把它们**关联起来做联动**:
|
|
5
|
-
* • tone —— 一套配色**自带**的属性:它的底是浅色还是深色
|
|
6
|
-
* 'light' 白系:浅底深字(white / blue / cyan / rainbow)
|
|
7
|
-
* 'dark' 黑系:深底浅字(black)
|
|
8
|
-
* • mode —— 用户的明暗偏好('system' | 'light' | 'dark',见 ./mode)
|
|
9
|
-
*
|
|
10
|
-
* === 联动规则(用户 2026-09-25 定)===
|
|
11
|
-
* 🔴 明暗 ≡ 色系:「明暗切换就是切换白色和黑色这个色系,不是另一套规则」。
|
|
12
|
-
* 切「暗」→ 落到黑系配色、切「明」→ 落到白系配色,两侧各自记住上一次的选择,
|
|
13
|
-
* 切回来即恢复。例如:白系用 blue,切「暗」→ 自动变 black,切「明」→ 回到 blue。
|
|
14
|
-
* 实现见 `oauth21/src/stores/theme.ts` 的「双侧记忆槽」。
|
|
15
|
-
*
|
|
16
|
-
* ⚠️ 它**不是 CSS 的一部分**:不是 token,不会写成 `--mauth-*` 变量,
|
|
17
|
-
* 也不参与渲染。唯一用途是让 store 判断"当前配色与目标明暗是否一致"。
|
|
18
|
-
*
|
|
19
|
-
* ⚠️ 与 tokens 的分档无关:自 2026-09-25 起 `tokens` 是一组扁平值,
|
|
20
|
-
* 一套配色内部不再分深浅两档 —— tone 描述的是**配色之间**的关系,
|
|
21
|
-
* 不是"一套配色内部的明暗两档"。别再把它理解成旧版的 `light`/`dark` 两块。
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ 明暗与配色**保持一致**:点色卡同步把明暗设成该配色的系别(点黑卡=暗、
|
|
24
|
-
* 点白卡=明),切明暗联动把配色切到目标系别 —— 两条路径互相咬合,不撕裂。
|
|
25
|
-
*
|
|
26
|
-
* @author yijiu2025
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/** 明暗系别:配色自带的底色深浅 */
|
|
30
|
-
export type ThemeTone = 'light' | 'dark';
|
|
31
|
-
|
|
32
|
-
/** 全部系别(遍历 / 校验用;顺序与界面展示一致) */
|
|
33
|
-
export const THEME_TONES: readonly ThemeTone[] = ['light', 'dark'];
|
|
34
|
-
|
|
35
|
-
/** 界面与日志用的中文文案 */
|
|
36
|
-
export const TONE_LABELS: Record<ThemeTone, string> = {
|
|
37
|
-
light: '白系',
|
|
38
|
-
dark: '黑系'
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** 是否是合法的系别取值(用于校验外部输入这一类不可信来源) */
|
|
42
|
-
export function isThemeTone(value: unknown): value is ThemeTone {
|
|
43
|
-
return value === 'light' || value === 'dark';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 归一化外部输入:容忍大小写与首尾空白,非法返回 null(由调用方决定回退)
|
|
48
|
-
*
|
|
49
|
-
* ⚠️ 与 `mode` 不同,这里**没有** `'system'` —— 系别是配色自带的属性,
|
|
50
|
-
* 不存在"跟随系统"这回事。
|
|
51
|
-
*/
|
|
52
|
-
export function normalizeTone(raw: unknown): ThemeTone | null {
|
|
53
|
-
if (typeof raw !== 'string') return null;
|
|
54
|
-
const v = raw.trim().toLowerCase();
|
|
55
|
-
return isThemeTone(v) ? v : null;
|
|
56
|
-
}
|