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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后端主题配置取数(**参数化内核版**)—— 拉取后端下发的主题配置并归一形状
|
|
3
|
+
*
|
|
4
|
+
* === 为什么取数也能下沉 ===
|
|
5
|
+
* 「fetch + 超时 + 失败静默 + 归一形状」这个骨架是通用的;只有「端点 / 超时上限 /
|
|
6
|
+
* cookie 策略」是宿主的部署事实。所以把它们参数化,内核提供取数骨架,宿主编好参数即可。
|
|
7
|
+
*
|
|
8
|
+
* === 形状归一见 `skinsuite` 的 `normalizeRemoteThemeConfig` ===
|
|
9
|
+
* 「哪些字段算数、`skin` 是不是旧名字、tokens 为什么必须扁平」是契约,已收进纯内核;
|
|
10
|
+
* 这里只负责「怎么拿到 JSON 再交给它归一」。
|
|
11
|
+
*
|
|
12
|
+
* === 失败策略 ===
|
|
13
|
+
* 超时 / 非 200 / 结构不符 一律静默返回 null(用内置配色继续)。主题是锦上添花,
|
|
14
|
+
* 绝不能因为拉不到配色而挡住登录。
|
|
15
|
+
*
|
|
16
|
+
* @author yijiu2025
|
|
17
|
+
* @since 2026-10-17(从 oauth21/src/theme/remote.ts 下沉)
|
|
18
|
+
*/
|
|
19
|
+
import { normalizeRemoteThemeConfig } from '../scheme';
|
|
20
|
+
import type { RemoteThemeConfig } from '../scheme';
|
|
21
|
+
|
|
22
|
+
export interface RemoteThemeFetchOptions {
|
|
23
|
+
/** 端点 URL(宿主 `VITE_THEME_ENDPOINT`);空串 = 机制关闭,直接返回 null */
|
|
24
|
+
endpoint: string;
|
|
25
|
+
/** 超时上限(毫秒) */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** 是否不带 cookie(默认 true,避免把登录态发到第三方域名) */
|
|
28
|
+
omitCredentials?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 后端换配色是否启用(端点非空) */
|
|
32
|
+
export function isRemoteThemeEnabled(endpoint: string | undefined): boolean {
|
|
33
|
+
return typeof endpoint === 'string' && endpoint.trim().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 拉取后端主题配置
|
|
38
|
+
*
|
|
39
|
+
* @param clientId 接入应用的 client_id(决定用哪套品牌配色),可为空
|
|
40
|
+
* @param options 见 `RemoteThemeFetchOptions`
|
|
41
|
+
* @returns 配置对象;未启用 / 失败 / 无有效内容时返回 null
|
|
42
|
+
*/
|
|
43
|
+
export async function fetchRemoteThemeConfig(
|
|
44
|
+
clientId: string | undefined,
|
|
45
|
+
options: RemoteThemeFetchOptions
|
|
46
|
+
): Promise<RemoteThemeConfig | null> {
|
|
47
|
+
const endpoint = options.endpoint;
|
|
48
|
+
if (!isRemoteThemeEnabled(endpoint)) return null;
|
|
49
|
+
|
|
50
|
+
let url: URL;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(endpoint, window.location.origin);
|
|
53
|
+
} catch {
|
|
54
|
+
return null; // endpoint 写了个非法 URL
|
|
55
|
+
}
|
|
56
|
+
if (clientId) url.searchParams.set('client_id', clientId);
|
|
57
|
+
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 3000);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(url.toString(), {
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
credentials: options.omitCredentials === false ? 'include' : 'omit',
|
|
64
|
+
headers: { Accept: 'application/json' }
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return null;
|
|
67
|
+
return normalizeRemoteThemeConfig(await res.json());
|
|
68
|
+
} catch {
|
|
69
|
+
// 网络错误 / 超时 / JSON 解析失败:一律静默
|
|
70
|
+
return null;
|
|
71
|
+
} finally {
|
|
72
|
+
window.clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题意图(intent)—— 明暗意图 + 会话级记忆槽 + 「明暗 ≡ 配色系别」联动
|
|
3
|
+
*
|
|
4
|
+
* 从 `oauth21/src/stores/theme-intent.ts` 下沉(2026-10-19)。本文件只负责「用户**想**要什么明暗」:
|
|
5
|
+
* · `mode` 三态意图(light / dark / system);
|
|
6
|
+
* · 白系 / 黑系两侧的会话级记忆槽(只记「切走前正在用的那套」,不持久化);
|
|
7
|
+
* · `syncColorToTone` —— 从意图推导并切到匹配系别的配色(联动);
|
|
8
|
+
* · 用户显式操作:`setTheme` / `setMode` / `cycleMode` / `toggleTheme`。
|
|
9
|
+
*
|
|
10
|
+
* === 依赖方向 ===
|
|
11
|
+
* intent 只依赖 scope(activeDevice/activePage/activeView/themeId + 清 URL 覆盖),
|
|
12
|
+
* 不依赖 runtime —— 它不碰 token 注入、不碰 DOM。
|
|
13
|
+
*
|
|
14
|
+
* @author yijiu2025
|
|
15
|
+
* @since 2026-10-19(从 oauth21/src/stores/theme-intent.ts 下沉,keys 参数化)
|
|
16
|
+
*/
|
|
17
|
+
import { ref, watch } from 'vue';
|
|
18
|
+
import { DEFAULT_THEME_COLOR, DEFAULT_THEME_DARK_COLOR } from '../constants';
|
|
19
|
+
import { MODE_CYCLE } from '../scheme';
|
|
20
|
+
import type { ThemeMode, ThemeTone } from '../scheme';
|
|
21
|
+
import { isKnownTheme, listColorIdsOfTone, resolveThemeId, toneOfAnyScope } from '../registry';
|
|
22
|
+
import { readInitialMode, safeSet } from './theme-persistence';
|
|
23
|
+
import type { ThemeStorageKeys } from './theme-persistence';
|
|
24
|
+
import type { ThemeScope } from './theme-scope';
|
|
25
|
+
|
|
26
|
+
export function useThemeIntent(scope: ThemeScope, keys: ThemeStorageKeys) {
|
|
27
|
+
const mode = ref<ThemeMode>(readInitialMode(keys));
|
|
28
|
+
|
|
29
|
+
/** 系统偏好(始终跟踪,是否采用由 mode 决定) */
|
|
30
|
+
const systemDark = ref(false);
|
|
31
|
+
const mediaQuery =
|
|
32
|
+
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
33
|
+
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
34
|
+
: null;
|
|
35
|
+
if (mediaQuery) systemDark.value = mediaQuery.matches;
|
|
36
|
+
|
|
37
|
+
const onSystemChange = (e: MediaQueryListEvent) => {
|
|
38
|
+
systemDark.value = e.matches;
|
|
39
|
+
};
|
|
40
|
+
mediaQuery?.addEventListener('change', onSystemChange);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 白系 / 黑系两侧的会话级记忆槽:只记「切走前正在用的那套」,不持久化。
|
|
44
|
+
* 点色卡不写槽;只有「明暗切换」这个动作才写。
|
|
45
|
+
*/
|
|
46
|
+
const lightColor = ref<string | null>(null);
|
|
47
|
+
const darkColor = ref<string | null>(null);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把配色切到与目标明暗匹配的系别(**明暗 → 配色**,单向)。
|
|
51
|
+
* 三种情况什么都不做(都刻意):同系别 / 该版式没有目标系别(绝不跨系别回落)/
|
|
52
|
+
* 目标与当前相同。槽只记「切走前」的会话级值,不落盘。
|
|
53
|
+
*/
|
|
54
|
+
function syncColorToTone(dark: boolean): void {
|
|
55
|
+
const target: ThemeTone = dark ? 'dark' : 'light';
|
|
56
|
+
const device = scope.activeDevice.value;
|
|
57
|
+
const page = scope.activePage.value;
|
|
58
|
+
// view ≡ pkg:activeView 的值就是当前包名,配色查找按它(= 包)过滤
|
|
59
|
+
const pkg = scope.activeView.value;
|
|
60
|
+
|
|
61
|
+
// ① 已经同系别 —— 按**任意登记处**查系别,不只看当前作用域。
|
|
62
|
+
if (toneOfAnyScope(scope.themeId.value) === target) return;
|
|
63
|
+
|
|
64
|
+
// 记住「切走前」当前系别正在用的那套(切回时恢复它)。只有明暗切换才写槽。
|
|
65
|
+
const curTone = toneOfAnyScope(scope.themeId.value);
|
|
66
|
+
if (curTone === 'light') lightColor.value = scope.themeId.value;
|
|
67
|
+
else if (curTone === 'dark') darkColor.value = scope.themeId.value;
|
|
68
|
+
|
|
69
|
+
// 优先取该系别槽里那套;它在当前包下不存在时弃用
|
|
70
|
+
const slot = dark ? darkColor.value : lightColor.value;
|
|
71
|
+
const fromSlot = slot && isKnownTheme(slot, device, page, pkg) ? slot : null;
|
|
72
|
+
// 目标系别配色:优先标配白/黑,没有时退回该系别第一套。
|
|
73
|
+
const standard = target === 'dark' ? DEFAULT_THEME_DARK_COLOR : DEFAULT_THEME_COLOR;
|
|
74
|
+
const ids = listColorIdsOfTone(device, page, pkg, target);
|
|
75
|
+
const next = fromSlot ?? (ids.includes(standard) ? standard : ids[0]) ?? null;
|
|
76
|
+
|
|
77
|
+
// ② / ③
|
|
78
|
+
if (!next || next === scope.themeId.value) return;
|
|
79
|
+
|
|
80
|
+
scope.themeId.value = next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 首屏对齐:按当前「系别意图」把配色校正到匹配的系别。
|
|
85
|
+
* 该设备被判 URL 锁定时不干预(部署方/用户的明确意图必须压过联动)。
|
|
86
|
+
*/
|
|
87
|
+
if (!scope.urlLockedThemeOf(scope.activeDevice.value)) {
|
|
88
|
+
syncColorToTone(mode.value === 'system' ? systemDark.value : mode.value === 'dark');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 明暗意图变化 → 联动切配色(唯一运行时入口);首屏那一次已由上面的显式调用处理 */
|
|
92
|
+
watch(mode, m => syncColorToTone(m === 'system' ? systemDark.value : m === 'dark'));
|
|
93
|
+
watch(systemDark, dark => {
|
|
94
|
+
if (mode.value === 'system') syncColorToTone(dark);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 设置明暗意图(底层写入口,**不清** URL 覆盖)。
|
|
99
|
+
* 用户的显式操作请走 cycleMode / toggleTheme —— 它们会先清掉当前设备的覆盖。
|
|
100
|
+
*/
|
|
101
|
+
function setMode(next: ThemeMode): void {
|
|
102
|
+
mode.value = next;
|
|
103
|
+
safeSet(keys.mode, next);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 循环切换明暗意图(跟随系统 → 明 → 暗 → 跟随系统);显式操作 → 先清 URL 覆盖 */
|
|
107
|
+
function cycleMode(): void {
|
|
108
|
+
scope.clearDeviceThemeOverride();
|
|
109
|
+
const idx = MODE_CYCLE.indexOf(mode.value);
|
|
110
|
+
setMode(MODE_CYCLE[(idx + 1) % MODE_CYCLE.length]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 在明/暗之间切换(= 白系 ↔ 黑系);纯反色,不进 'system' */
|
|
114
|
+
function toggleTheme(): void {
|
|
115
|
+
scope.clearDeviceThemeOverride();
|
|
116
|
+
setMode(scope.isDark.value ? 'light' : 'dark');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 设置配色(开发者/部署方调用 → 落盘)。
|
|
121
|
+
* 会清掉当前设备的 URL 覆盖(点色卡是显式操作,必须能压过部署方链接)。
|
|
122
|
+
* 点色卡同步把 mode 设成该配色的系别,并打断「切走前」记忆链(清空对侧槽)。
|
|
123
|
+
*/
|
|
124
|
+
function setTheme(id: string): boolean {
|
|
125
|
+
const resolved = resolveThemeId(id);
|
|
126
|
+
if (!resolved) return false;
|
|
127
|
+
scope.clearDeviceThemeOverride();
|
|
128
|
+
scope.themeId.value = resolved;
|
|
129
|
+
safeSet(keys.theme, resolved);
|
|
130
|
+
const tone = toneOfAnyScope(resolved);
|
|
131
|
+
if (tone === 'dark') lightColor.value = null;
|
|
132
|
+
else if (tone === 'light') darkColor.value = null;
|
|
133
|
+
if (tone) {
|
|
134
|
+
const next: ThemeMode = tone === 'dark' ? 'dark' : 'light';
|
|
135
|
+
if (mode.value !== next) {
|
|
136
|
+
mode.value = next;
|
|
137
|
+
safeSet(keys.mode, next);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 应用外部明暗(父应用旧协议兼容) */
|
|
144
|
+
function applyTheme(dark: boolean): void {
|
|
145
|
+
setMode(dark ? 'dark' : 'light');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 清理系统主题监听(HMR / 单测场景) */
|
|
149
|
+
function dispose(): void {
|
|
150
|
+
mediaQuery?.removeEventListener('change', onSystemChange);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { mode, systemDark, lightColor, darkColor, setMode, cycleMode, toggleTheme, setTheme, applyTheme, dispose };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** intent 的公开面(供 runtime / store 引用;类型由 `useThemeIntent` 返回处推断) */
|
|
157
|
+
export type ThemeIntent = ReturnType<typeof useThemeIntent>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题状态的**持久化与 URL 解析层**(localStorage 安全读写 + 旧数据迁移 + URL 多设备意图)
|
|
3
|
+
*
|
|
4
|
+
* 从 `oauth21/src/stores/theme-persistence.ts` 下沉(2026-10-19)。
|
|
5
|
+
*
|
|
6
|
+
* === 为什么下沉 ===
|
|
7
|
+
* 这些是「明暗 ≡ 配色系别」状态机的**纯函数半边**:不依赖 store 实例、无闭包、无副作用顺序。
|
|
8
|
+
* 它配合 scope / intent / runtime 一起构成完整状态机,五个前端(oauth21 / retroweb / firewall /
|
|
9
|
+
* admin / posecraft)都应复用同一份,而不是各抄一遍。键名与 URL 参数是**主题机制的约定**,
|
|
10
|
+
* 下沉后作为默认值,允许宿主通过 `ThemeStateOptions.storageKeys` / `colorParam` 覆盖(与自身
|
|
11
|
+
* 存储冲突时)。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 这些函数**不自带 DOM 守卫**(如 `typeof window !== 'undefined'`):SPA 场景下
|
|
14
|
+
* window 必然存在。下沉不改变这一点。
|
|
15
|
+
*
|
|
16
|
+
* @author yijiu2025
|
|
17
|
+
* @since 2026-10-19(从 oauth21/src/stores/theme-persistence.ts 下沉,参数化键名/参数前缀)
|
|
18
|
+
*/
|
|
19
|
+
import { DEFAULT_THEME_ID } from '../constants';
|
|
20
|
+
import { getDefaultThemeId, resolveThemeId } from '../registry';
|
|
21
|
+
import type { ThemeMode } from '../scheme';
|
|
22
|
+
import { deviceIds, fallbackDevice, urlKeyOf } from './define-theme-device';
|
|
23
|
+
|
|
24
|
+
/** localStorage 键名集合(主题机制约定;与宿主存储冲突时通过 `ThemeStateOptions` 覆盖) */
|
|
25
|
+
export interface ThemeStorageKeys {
|
|
26
|
+
/** 明暗意图键(三态 'system' / 'light' / 'dark') */
|
|
27
|
+
mode: string;
|
|
28
|
+
/** 配色 id 键 */
|
|
29
|
+
theme: string;
|
|
30
|
+
/** 旧版本存配色 id 的键,只读不写(迁移用) */
|
|
31
|
+
legacySkin: string;
|
|
32
|
+
/** 旧版本标记「用户手动选过明暗」的键,只读不写(迁移用) */
|
|
33
|
+
legacyManual: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 默认键名(沿用旧键,让老用户的手动选择可以平滑迁移) */
|
|
37
|
+
export const DEFAULT_STORAGE_KEYS: ThemeStorageKeys = {
|
|
38
|
+
mode: 'theme',
|
|
39
|
+
theme: 'theme-id',
|
|
40
|
+
legacySkin: 'theme-skin',
|
|
41
|
+
legacyManual: 'theme-manual'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** URL 配色意图的参数前缀(配合设备 urlKey 组成 `th_color_<urlKey>`) */
|
|
45
|
+
export const DEFAULT_COLOR_PARAM = 'th_color';
|
|
46
|
+
|
|
47
|
+
/** 安全读 localStorage:隐私模式 / 禁用存储时会抛错,不能让它把应用带崩 */
|
|
48
|
+
export function safeGet(key: string): string | null {
|
|
49
|
+
try {
|
|
50
|
+
return localStorage.getItem(key);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 安全写 localStorage:存不了就算了,本次会话内仍正常工作 */
|
|
57
|
+
export function safeSet(key: string, value: string): void {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(key, value);
|
|
60
|
+
} catch {
|
|
61
|
+
/* 存不了就算了,本次会话内仍正常工作 */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 初始化明暗模式(含旧数据迁移)
|
|
67
|
+
*
|
|
68
|
+
* 旧版本把明暗写成 'light'/'dark',另用 `legacyManual` 标记是否手动选过。
|
|
69
|
+
* 迁移规则:
|
|
70
|
+
* • 存的是三态值 'system' → 直接用
|
|
71
|
+
* • 存的是 'light'/'dark' 且有 manual 标记(用户真的手动选过)→ 保留其选择
|
|
72
|
+
* • 存的是 'light'/'dark' 但没有 manual 标记 → 那只是旧版跟随系统的副产物,算 'system'
|
|
73
|
+
*/
|
|
74
|
+
export function readInitialMode(keys: ThemeStorageKeys = DEFAULT_STORAGE_KEYS): ThemeMode {
|
|
75
|
+
const saved = safeGet(keys.mode);
|
|
76
|
+
if (saved === 'system') return 'system';
|
|
77
|
+
if (saved === 'light' || saved === 'dark') {
|
|
78
|
+
return safeGet(keys.legacyManual) === 'true' ? saved : 'system';
|
|
79
|
+
}
|
|
80
|
+
return 'system';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 初始化主题:旧键 `legacySkin` 兜底
|
|
85
|
+
*
|
|
86
|
+
* 未登记 / 非法一律回**兜底设备 × 默认页面 × 默认包**里的兜底配色 ——
|
|
87
|
+
* 即 `DEFAULT_THEME_COLOR`(`white`)。这里不按具体设备判:落盘的只是一个
|
|
88
|
+
* "用户/部署方选过的配色",它在哪端可用由各调用点按自己的设备解析(见 scope)。
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ 最后兜底是 `DEFAULT_THEME_ID`(`'default'`)而**不是**设备名:拿设备名当配色 id
|
|
91
|
+
* 会得到一个永远查不到的 id,排查时极具误导性。
|
|
92
|
+
*/
|
|
93
|
+
export function readInitialTheme(keys: ThemeStorageKeys = DEFAULT_STORAGE_KEYS): string {
|
|
94
|
+
return (
|
|
95
|
+
resolveThemeId(safeGet(keys.theme) ?? safeGet(keys.legacySkin)) ??
|
|
96
|
+
getDefaultThemeId({ device: fallbackDevice() }) ??
|
|
97
|
+
DEFAULT_THEME_ID
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** URL 上按设备解析出的配色意图:只记"这台设备被**显式**指定了哪套色" */
|
|
102
|
+
export type DeviceThemeIntent = Partial<Record<string, string>>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 读取 URL 上的主题意图(**含设备维度**)
|
|
106
|
+
*
|
|
107
|
+
* === 每台设备各一条取值链 ===
|
|
108
|
+
* `?<colorParam>_<urlKey>` → `?<colorParam>` → (缺则不算命中)
|
|
109
|
+
* 即"**设备专属优先,缺了退到通用键**"。缺了这台设备就走落盘偏好 / 该范围默认色。
|
|
110
|
+
*
|
|
111
|
+
* 设备专属键的后缀是**缩写名(urlKey)**(下划线连接)。明暗(`?mode=`)URL 入口已删。
|
|
112
|
+
* 非法值一律折成 null —— 不抛错、不锁项,让后端配置与本地存储照常参与。
|
|
113
|
+
*
|
|
114
|
+
* 🔴 **空串 = 未指定**:`?<colorParam>_<urlKey>=` 不能把 `?<colorParam>=blue` 顶掉。
|
|
115
|
+
*/
|
|
116
|
+
export function readUrlIntent(
|
|
117
|
+
colorParam: string = DEFAULT_COLOR_PARAM
|
|
118
|
+
): { deviceTheme: DeviceThemeIntent } {
|
|
119
|
+
const deviceTheme: DeviceThemeIntent = {};
|
|
120
|
+
try {
|
|
121
|
+
const params = new URLSearchParams(window.location.search);
|
|
122
|
+
/** 空串 / 纯空白视为**未指定** —— 与 `readDeviceParam` 的 `isPresent()` 同一口径 */
|
|
123
|
+
const present = (raw: string | null): string | null =>
|
|
124
|
+
raw && raw.trim() !== '' ? raw : null;
|
|
125
|
+
// 无设备前缀的通用键:三端共用,任一设备没有专属键时用它
|
|
126
|
+
const anyScope = present(params.get(colorParam));
|
|
127
|
+
for (const device of deviceIds()) {
|
|
128
|
+
const scoped = present(params.get(`${colorParam}_${urlKeyOf(device)}`));
|
|
129
|
+
const resolved = resolveThemeId(scoped ?? anyScope);
|
|
130
|
+
if (resolved) deviceTheme[device] = resolved;
|
|
131
|
+
}
|
|
132
|
+
return { deviceTheme };
|
|
133
|
+
} catch {
|
|
134
|
+
return { deviceTheme };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题运行时(runtime)—— token 注入 + 附加样式 + 明暗类 + 外部配置
|
|
3
|
+
*
|
|
4
|
+
* 从 `oauth21/src/stores/theme-runtime.ts` 下沉(2026-10-19)。本文件只负责「把生效配色**作用到 DOM/副作用**」:
|
|
5
|
+
* · token 分层注入(`applyThemeLayers` → html inline 变量)+ `html.dark` 类;
|
|
6
|
+
* · 附加样式 `theme.scss` 的惰性加载(带 epoch 防串台);
|
|
7
|
+
* · `data-mauth-theme` 属性写入;
|
|
8
|
+
* · 切版式自动重置配色;
|
|
9
|
+
* · 外部配置(后端 / 父应用)合入。
|
|
10
|
+
*
|
|
11
|
+
* === 依赖方向 ===
|
|
12
|
+
* runtime 依赖 scope(themeRecordFor / activeDevice / isDark / themeId / deviceTheme)
|
|
13
|
+
* 和 intent(mode 的写入口),不反向 —— 它是编排链的最末端。
|
|
14
|
+
*
|
|
15
|
+
* === 🔴 TDZ 顺序(拆分后仍必须严格保持)===
|
|
16
|
+
* `styleEpoch` 与 `loadThemeStyle` 必须声明在 `watch([themeId…], {immediate})` **之前**:
|
|
17
|
+
* immediate watcher 会在 `watch()` 当场同步跑一遍 loadThemeStyle,若 `let styleEpoch`
|
|
18
|
+
* 还未求值就踩 TDZ,抛出的 ReferenceError 被 async 转成 rejected promise 又 `void` 丢掉,
|
|
19
|
+
* 现象是「只有首屏那次附加样式加载不到,之后手动切主题都正常」—— 极难从表象定位。
|
|
20
|
+
*
|
|
21
|
+
* @author yijiu2025
|
|
22
|
+
* @since 2026-10-19(从 oauth21/src/stores/theme-runtime.ts 下沉,签名不变)
|
|
23
|
+
*/
|
|
24
|
+
import { ref, watch } from 'vue';
|
|
25
|
+
import { getDefaultThemeId, getThemeRecord, resolveThemeId, toneOfAnyScope } from '../registry';
|
|
26
|
+
import type { ThemeTokenOverrides } from '../tokens';
|
|
27
|
+
import { applyThemeLayers } from './token-runtime';
|
|
28
|
+
import { useThemeSources } from './useThemeSources';
|
|
29
|
+
import type { ThemeScope } from './theme-scope';
|
|
30
|
+
import type { ThemeIntent } from './theme-intent';
|
|
31
|
+
|
|
32
|
+
/** 承载主题附加样式(theme.scss)的 <style> 元素 id,切换主题时整体替换 */
|
|
33
|
+
const STYLE_ELEMENT_ID = 'mauth-theme-css';
|
|
34
|
+
|
|
35
|
+
export function useThemeRuntime(scope: ThemeScope, intent: ThemeIntent) {
|
|
36
|
+
/** 主题包自带 token(按当前生效作用域解析;一组扁平值,不分明暗档) */
|
|
37
|
+
const themeTokens = ref<ThemeTokenOverrides | null>(scope.themeRecordFor().tokens ?? null);
|
|
38
|
+
/** 外部覆写 token(后端下发 / 父应用 postMessage);优先级高于主题包 */
|
|
39
|
+
const externalTokens = ref<ThemeTokenOverrides | null>(null);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 统一注入入口:配色 token、外部覆写、生效范围任一变化都重算。
|
|
43
|
+
* 注入的是 html 上的 inline style(整份文档唯一),所以只用一个设备的 token(activeDevice)。
|
|
44
|
+
* isDark 只负责给 <html> 挂/摘 dark 类(基线 SCSS 用),与注入挂在同一个 watch。
|
|
45
|
+
*/
|
|
46
|
+
watch(
|
|
47
|
+
[themeTokens, externalTokens, scope.activeDevice, scope.isDark],
|
|
48
|
+
([, , , dark]) => {
|
|
49
|
+
document.documentElement.classList.toggle('dark', dark);
|
|
50
|
+
const rejected = applyThemeLayers({
|
|
51
|
+
theme: themeTokens.value,
|
|
52
|
+
external: externalTokens.value
|
|
53
|
+
});
|
|
54
|
+
if (rejected.length > 0) {
|
|
55
|
+
console.warn(`[theme] 忽略了 ${rejected.length} 条不合规的主题变量:${rejected.join('、')}`);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{ immediate: true }
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 加载主题附加样式(theme.scss,惰性动态 import),带递增 epoch 防串台。
|
|
63
|
+
* 失败静默:附加样式拿不到只是少个背景图,绝不能挡住登录。
|
|
64
|
+
*/
|
|
65
|
+
let styleEpoch = 0;
|
|
66
|
+
async function loadThemeStyle(id: string, pkg: string, device: string, page: string): Promise<void> {
|
|
67
|
+
const epoch = ++styleEpoch;
|
|
68
|
+
const loader = getThemeRecord({ id, device, pkg, page }).loadStyle;
|
|
69
|
+
|
|
70
|
+
if (!loader) {
|
|
71
|
+
document.getElementById(STYLE_ELEMENT_ID)?.remove();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const css = await loader();
|
|
76
|
+
if (epoch !== styleEpoch) return; // 期间又切了主题,丢弃本次结果
|
|
77
|
+
let node = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
|
|
78
|
+
if (!node) {
|
|
79
|
+
node = document.createElement('style');
|
|
80
|
+
node.id = STYLE_ELEMENT_ID;
|
|
81
|
+
document.head.appendChild(node);
|
|
82
|
+
}
|
|
83
|
+
node.textContent = css;
|
|
84
|
+
} catch {
|
|
85
|
+
/* 主题附加样式加载失败不影响登录 */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 承载配色标识 + 同步 token / 附加样式。
|
|
91
|
+
* 生效 id 按设备取 URL 覆盖(与 themeRecordFor 同口径)。
|
|
92
|
+
* 仅当"该设备没有这套配色、用了回落档"(record.meta.id !== id)时才 delete 属性。
|
|
93
|
+
*/
|
|
94
|
+
watch(
|
|
95
|
+
[scope.themeId, scope.deviceTheme, scope.activeDevice, scope.activePage, scope.activeView],
|
|
96
|
+
([, , device, page, view]) => {
|
|
97
|
+
const id = scope.deviceTheme.value[device] ?? scope.themeId.value;
|
|
98
|
+
// view ≡ pkg —— 用 activeView 充当 pkg
|
|
99
|
+
const record = getThemeRecord({ id, device, pkg: view, page });
|
|
100
|
+
const root = document.documentElement;
|
|
101
|
+
if (record.meta.id === id) root.dataset.mauthTheme = id;
|
|
102
|
+
else delete root.dataset.mauthTheme;
|
|
103
|
+
|
|
104
|
+
themeTokens.value = record.tokens ?? null;
|
|
105
|
+
void loadThemeStyle(id, view, device, page);
|
|
106
|
+
},
|
|
107
|
+
{ immediate: true }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 切版式 → 自动重置配色(让用户立刻感受到新版的视觉特征)。
|
|
112
|
+
* 守门:仅新版式触发、仅 activeView 真的变了、该设备被 URL 锁定时不动、不落盘、不动 mode/槽。
|
|
113
|
+
*/
|
|
114
|
+
watch(scope.activeView, (newView, oldView) => {
|
|
115
|
+
if (!newView || newView === oldView) return;
|
|
116
|
+
// 用旧 view(= 旧包)查 themeId 当前解析到的包
|
|
117
|
+
const currentPkg = scope.themeRecordFor(oldView, scope.activeDevice.value, scope.activePage.value).pkg;
|
|
118
|
+
if (currentPkg === newView) return;
|
|
119
|
+
if (scope.urlLockedThemeOf(scope.activeDevice.value)) return;
|
|
120
|
+
const fallback = getDefaultThemeId({ device: scope.activeDevice.value, page: scope.activePage.value, pkg: newView });
|
|
121
|
+
if (!fallback || fallback === scope.themeId.value) return;
|
|
122
|
+
scope.themeId.value = fallback;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** 应用外部配色配置(后端下发 / 父应用同步);不写 localStorage(后端默认值不覆盖用户偏好) */
|
|
126
|
+
function applyThemeConfig(config: {
|
|
127
|
+
theme?: string;
|
|
128
|
+
skin?: string;
|
|
129
|
+
mode?: 'light' | 'dark' | 'system';
|
|
130
|
+
tokens?: ThemeTokenOverrides;
|
|
131
|
+
} | null): void {
|
|
132
|
+
if (!config) {
|
|
133
|
+
externalTokens.value = null;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const wanted = config.theme ?? config.skin;
|
|
138
|
+
if (wanted !== undefined && !scope.urlLockedThemeOf(scope.activeDevice.value)) {
|
|
139
|
+
const resolved = resolveThemeId(wanted);
|
|
140
|
+
if (resolved) {
|
|
141
|
+
scope.themeId.value = resolved;
|
|
142
|
+
const tone = toneOfAnyScope(resolved);
|
|
143
|
+
if (tone) {
|
|
144
|
+
const next = tone === 'dark' ? 'dark' : 'light';
|
|
145
|
+
if (intent.mode.value !== next) intent.mode.value = next;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (config.mode) intent.mode.value = config.mode;
|
|
150
|
+
if (config.tokens) externalTokens.value = config.tokens;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 主题意图来源同步:父主题同步 + 拉后端配色(读 env,宿主零传参)
|
|
154
|
+
const disposeSources = useThemeSources({
|
|
155
|
+
applyThemeConfig,
|
|
156
|
+
applyTheme: dark => intent.applyTheme(dark)
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return { themeTokens, externalTokens, applyThemeConfig, disposeSources };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** runtime 的公开面(类型由 `useThemeRuntime` 返回处推断) */
|
|
163
|
+
export type ThemeRuntime = ReturnType<typeof useThemeRuntime>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题作用域(scope)—— 设备 / 页面 / 版式三个查找维度 + 配色解析入口
|
|
3
|
+
*
|
|
4
|
+
* 从 `oauth21/src/stores/theme-scope.ts` 下沉(2026-10-19)。
|
|
5
|
+
* 本文件只负责「作用域」这件事:
|
|
6
|
+
* · 设备身份 `activeDevice`:订阅库内设备域判定结果;
|
|
7
|
+
* · 页面 / 版式 `activePage` / `activeView`:由容器在 setup 里告知;
|
|
8
|
+
* · `themeRecordFor`:把「配色意图(themeId / deviceTheme)+ 作用域」解析成可渲染配色;
|
|
9
|
+
* · 派生值:`isDark`(= 生效配色 tone)、`activeTone`、`packageId`、`device`。
|
|
10
|
+
*
|
|
11
|
+
* === 依赖方向(拆分的骨架)===
|
|
12
|
+
* scope ← intent(明暗意图,读 activeDevice/activePage/activeView/themeId)
|
|
13
|
+
* scope ← runtime(token 注入 / 附加样式,读 themeRecordFor/isDark/activeDevice/themeId/deviceTheme)
|
|
14
|
+
* 本文件**不反向依赖** intent / runtime —— 它是唯一的共享基础。
|
|
15
|
+
*
|
|
16
|
+
* @author yijiu2025
|
|
17
|
+
* @since 2026-10-19(从 oauth21/src/stores/theme-scope.ts 下沉,keys/colorParam 参数化)
|
|
18
|
+
*/
|
|
19
|
+
import { computed, ref, watch } from 'vue';
|
|
20
|
+
import { DEFAULT_THEME_PACKAGE, DEFAULT_THEME_PAGE } from '../constants';
|
|
21
|
+
import { getThemeRecord, listThemes } from '../registry';
|
|
22
|
+
import { currentDevice } from './device-domain';
|
|
23
|
+
import { fallbackDevice } from './define-theme-device';
|
|
24
|
+
import { readInitialTheme, readUrlIntent } from './theme-persistence';
|
|
25
|
+
import type { DeviceThemeIntent, ThemeStorageKeys } from './theme-persistence';
|
|
26
|
+
|
|
27
|
+
export function useThemeScope(keys: ThemeStorageKeys, colorParam: string) {
|
|
28
|
+
const urlIntent = readUrlIntent(colorParam);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* URL 按设备指定的配色(**响应式**)
|
|
32
|
+
* 做成 ref 而非普通常量,是**用户显式操作会清掉当前设备那一条**(见 `clearDeviceThemeOverride`)。
|
|
33
|
+
*/
|
|
34
|
+
const deviceTheme = ref<DeviceThemeIntent>({ ...urlIntent.deviceTheme });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 某设备的配色是否被 URL **显式**指定 → 该设备"锁定"。
|
|
38
|
+
* 锁定只挡**自动**改写,不挡用户显式操作;逐设备判定是必须的。
|
|
39
|
+
*/
|
|
40
|
+
function urlLockedThemeOf(device: string): boolean {
|
|
41
|
+
return deviceTheme.value[device] !== undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 清掉某设备被 URL 写下的配色 —— **只该由用户的显式操作调用**。
|
|
46
|
+
*/
|
|
47
|
+
function clearDeviceThemeOverride(device: string = activeDevice.value): void {
|
|
48
|
+
if (deviceTheme.value[device] === undefined) return;
|
|
49
|
+
const next = { ...deviceTheme.value };
|
|
50
|
+
delete next[device];
|
|
51
|
+
deviceTheme.value = next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* **全局**配色 —— 只被"URL 没有覆盖这台设备"的那些设备采用。
|
|
56
|
+
* 设备专属 URL 值在 `themeRecordFor` 里优先于它。
|
|
57
|
+
*/
|
|
58
|
+
const themeId = ref<string>(urlIntent.deviceTheme[fallbackDevice()] ?? readInitialTheme(keys));
|
|
59
|
+
|
|
60
|
+
/** 当前**生效设备** —— 决定把哪一套配色 token 注入 html;是"当前渲染视图属于哪种设备" */
|
|
61
|
+
const activeDevice = ref<string>(fallbackDevice());
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 从**设备域**订阅当前生效设备(设备判定已收进库内设备域,这里只读)。
|
|
65
|
+
* `app.use(theme, { router })` 里设备域监听路由 + 视口 → `pickDeviceId` 判定 → `activeDevice`,
|
|
66
|
+
* 这里把它同步进作用域(`immediate` 覆盖 store 实例化时设备域已判好的值)。
|
|
67
|
+
*/
|
|
68
|
+
watch(
|
|
69
|
+
currentDevice(),
|
|
70
|
+
device => {
|
|
71
|
+
if (device && device !== activeDevice.value) {
|
|
72
|
+
activeDevice.value = device;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{ immediate: true }
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function setActiveDevice(device: string): void {
|
|
79
|
+
if (activeDevice.value !== device) activeDevice.value = device;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 当前生效页面 / 版式(配色归属 = 包 × 设备 × 页面) */
|
|
83
|
+
const activePage = ref<string>(DEFAULT_THEME_PAGE);
|
|
84
|
+
const activeView = ref<string>(DEFAULT_THEME_PACKAGE);
|
|
85
|
+
|
|
86
|
+
function setActivePageView(page: string, view: string): void {
|
|
87
|
+
if (page && activePage.value !== page) activePage.value = page;
|
|
88
|
+
const next = view || DEFAULT_THEME_PACKAGE;
|
|
89
|
+
if (activeView.value !== next) activeView.value = next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 按设备 + 页面 + 版式解析出**实际生效**的配色记录。
|
|
94
|
+
* 🔴 设备专属 URL 配色优先于全局 themeId:`deviceTheme[device] ?? themeId`。
|
|
95
|
+
* 🔴 接收 pkg 参数:view ≡ pkg,配色查找必须按当前包。
|
|
96
|
+
*/
|
|
97
|
+
function themeRecordFor(
|
|
98
|
+
pkg: string = DEFAULT_THEME_PACKAGE,
|
|
99
|
+
device: string = activeDevice.value,
|
|
100
|
+
page: string = activePage.value
|
|
101
|
+
): ReturnType<typeof getThemeRecord> {
|
|
102
|
+
const id = deviceTheme.value[device] ?? themeId.value;
|
|
103
|
+
return getThemeRecord({ id, device, pkg, page });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 最终是否深色 = 当前**生效配色**的系别(明暗 ≡ 色系) */
|
|
107
|
+
const isDark = computed(() => themeRecordFor().tone === 'dark');
|
|
108
|
+
|
|
109
|
+
/** 当前生效配色的系别 */
|
|
110
|
+
const activeTone = computed(() => themeRecordFor().tone ?? 'light');
|
|
111
|
+
|
|
112
|
+
/** 当前生效主题包(做成 computed 让容器的 watch 能当响应源) */
|
|
113
|
+
const packageId = computed(() => themeRecordFor().pkg);
|
|
114
|
+
|
|
115
|
+
/** 当前生效设备(只读别名) */
|
|
116
|
+
const device = computed(() => activeDevice.value);
|
|
117
|
+
|
|
118
|
+
/** 可选主题列表(注册表静态,取一次) */
|
|
119
|
+
const themes = listThemes();
|
|
120
|
+
|
|
121
|
+
/** 取当前配色为某页面声明的版式 id(只取值不校验) */
|
|
122
|
+
function viewFor(page: string, dev: string = activeDevice.value): string | undefined {
|
|
123
|
+
return themeRecordFor(packageId.value, dev, page).views?.[page];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
urlIntent,
|
|
128
|
+
deviceTheme,
|
|
129
|
+
themeId,
|
|
130
|
+
activeDevice,
|
|
131
|
+
activePage,
|
|
132
|
+
activeView,
|
|
133
|
+
isDark,
|
|
134
|
+
activeTone,
|
|
135
|
+
packageId,
|
|
136
|
+
device,
|
|
137
|
+
themes,
|
|
138
|
+
urlLockedThemeOf,
|
|
139
|
+
clearDeviceThemeOverride,
|
|
140
|
+
setActiveDevice,
|
|
141
|
+
setActivePageView,
|
|
142
|
+
themeRecordFor,
|
|
143
|
+
viewFor
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** scope 的公开面(供 intent / runtime 引用;类型由 `useThemeScope` 返回处推断) */
|
|
148
|
+
export type ThemeScope = ReturnType<typeof useThemeScope>;
|