@ubean/vue 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/LICENSE +21 -0
- package/README.md +493 -0
- package/README.zh-CN.md +500 -0
- package/dist/generator/index.d.ts +125 -0
- package/dist/generator/index.js +369 -0
- package/dist/index.d.ts +782 -0
- package/dist/index.js +1073 -0
- package/dist/types-VHF1RJu2.d.ts +132 -0
- package/dist/vite.d.ts +164 -0
- package/dist/vite.js +1189 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import { KeepAlive, Suspense, Transition, computed, createCommentVNode, defineAsyncComponent, defineComponent, h, inject, nextTick, onErrorCaptured, provide, reactive, ref, shallowRef, watch } from "vue";
|
|
2
|
+
import { RouterLink, RouterView, useRoute } from "vue-router";
|
|
3
|
+
//#region src/cache-views.ts
|
|
4
|
+
/**
|
|
5
|
+
* Page cache (keep-alive) store — a module-level singleton.
|
|
6
|
+
*
|
|
7
|
+
* ubean uses flat single-layer layout, so all page components are direct
|
|
8
|
+
* children of a single layout's `<RouterView>`. A `<keep-alive :include="...">`
|
|
9
|
+
* wraps that `<RouterView>`; the `include` list is the reactive array exposed
|
|
10
|
+
* here. Caching is keyed by the page's **route name**, which is injected as the
|
|
11
|
+
* component `name` at render time (see `createLayoutWrapper` in `app.ts`).
|
|
12
|
+
*
|
|
13
|
+
* In addition to the `include` list, an `exclude` list is also maintained.
|
|
14
|
+
* `<keep-alive :exclude="...">` skips caching for matched names even if they
|
|
15
|
+
* appear in `include`. This is useful for the "reload current page" pattern:
|
|
16
|
+
* temporarily push a route name into `exclude`, wait a tick, then remove it —
|
|
17
|
+
* the cached instance is pruned and the page remounts fresh on next visit.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* // declarative (compile-time): enable caching for a page
|
|
21
|
+
* definePage({ cache: true })
|
|
22
|
+
*
|
|
23
|
+
* // runtime: toggle caching imperatively
|
|
24
|
+
* import { enablePageCache, disablePageCache } from '@ubean/client';
|
|
25
|
+
* enablePageCache('DashboardIndex');
|
|
26
|
+
* disablePageCache('DashboardIndex');
|
|
27
|
+
*
|
|
28
|
+
* // reload a cached page (clears its instance, forces remount)
|
|
29
|
+
* import { resetRouteCache } from '@ubean/client';
|
|
30
|
+
* await resetRouteCache('DashboardIndex');
|
|
31
|
+
*/
|
|
32
|
+
const _g$1 = globalThis;
|
|
33
|
+
const _cachedViewNames = _g$1.__ubeanCachedViewNames ??= ref([]);
|
|
34
|
+
const _excludedViewNames = _g$1.__ubeanExcludedViewNames ??= ref([]);
|
|
35
|
+
const _cacheEnabled = _g$1.__ubeanCacheEnabled ??= ref(true);
|
|
36
|
+
const _wrapperRegistry = _g$1.__ubeanWrapperRegistry ??= /* @__PURE__ */ new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Composable for reactive access to the page cache store.
|
|
39
|
+
*
|
|
40
|
+
* Safe to call inside any component `setup()`; the returned computeds are
|
|
41
|
+
* tracked by the rendering effect so `<keep-alive :include>` stays in sync.
|
|
42
|
+
*/
|
|
43
|
+
function useCacheViews() {
|
|
44
|
+
return {
|
|
45
|
+
cachedViews: computed(() => _cacheEnabled.value ? _cachedViewNames.value : []),
|
|
46
|
+
cachedViewNames: computed(() => _cachedViewNames.value),
|
|
47
|
+
excludedViews: computed(() => _excludedViewNames.value),
|
|
48
|
+
enabled: computed(() => _cacheEnabled.value),
|
|
49
|
+
enable: () => {
|
|
50
|
+
_cacheEnabled.value = true;
|
|
51
|
+
},
|
|
52
|
+
disable: () => {
|
|
53
|
+
_cacheEnabled.value = false;
|
|
54
|
+
},
|
|
55
|
+
add: (name) => {
|
|
56
|
+
if (name && !_cachedViewNames.value.includes(name)) _cachedViewNames.value = [..._cachedViewNames.value, name];
|
|
57
|
+
},
|
|
58
|
+
remove: (name) => {
|
|
59
|
+
_cachedViewNames.value = _cachedViewNames.value.filter((n) => n !== name);
|
|
60
|
+
},
|
|
61
|
+
has: (name) => _cachedViewNames.value.includes(name),
|
|
62
|
+
clear: () => {
|
|
63
|
+
_cachedViewNames.value = [];
|
|
64
|
+
},
|
|
65
|
+
addExclude: (name) => {
|
|
66
|
+
if (name && !_excludedViewNames.value.includes(name)) _excludedViewNames.value = [..._excludedViewNames.value, name];
|
|
67
|
+
},
|
|
68
|
+
removeExclude: (name) => {
|
|
69
|
+
_excludedViewNames.value = _excludedViewNames.value.filter((n) => n !== name);
|
|
70
|
+
},
|
|
71
|
+
hasExclude: (name) => _excludedViewNames.value.includes(name),
|
|
72
|
+
clearExclude: () => {
|
|
73
|
+
_excludedViewNames.value = [];
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Enable keep-alive caching for a specific page (by route name). */
|
|
78
|
+
function enablePageCache(name) {
|
|
79
|
+
if (name && !_cachedViewNames.value.includes(name)) _cachedViewNames.value = [..._cachedViewNames.value, name];
|
|
80
|
+
}
|
|
81
|
+
/** Disable keep-alive caching for a specific page (by route name). */
|
|
82
|
+
function disablePageCache(name) {
|
|
83
|
+
_cachedViewNames.value = _cachedViewNames.value.filter((n) => n !== name);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Temporarily exclude a page from keep-alive (forces its cached instance
|
|
87
|
+
* to be pruned on next render). Pair with `includePageCache()` to restore.
|
|
88
|
+
*
|
|
89
|
+
* Useful for the "reload current page" pattern: push the route name into
|
|
90
|
+
* the exclude list, wait a tick, then remove it — the cached instance is
|
|
91
|
+
* pruned and the page remounts fresh on next render.
|
|
92
|
+
*/
|
|
93
|
+
function excludePageCache(name) {
|
|
94
|
+
if (name && !_excludedViewNames.value.includes(name)) _excludedViewNames.value = [..._excludedViewNames.value, name];
|
|
95
|
+
}
|
|
96
|
+
/** Remove a route name from the cache exclude list (restores caching). */
|
|
97
|
+
function includePageCache(name) {
|
|
98
|
+
_excludedViewNames.value = _excludedViewNames.value.filter((n) => n !== name);
|
|
99
|
+
}
|
|
100
|
+
/** Whether a specific page is currently excluded from cache. */
|
|
101
|
+
function isPageExcluded(name) {
|
|
102
|
+
return _excludedViewNames.value.includes(name);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reload a cached page by pruning its keep-alive instance.
|
|
106
|
+
*
|
|
107
|
+
* Semantics (mirrors `routeStore.resetRouteCache` in soybean-unify):
|
|
108
|
+
* 1. Remove the route name from the include list — the cached instance is
|
|
109
|
+
* pruned immediately, and when the user navigates away the instance is
|
|
110
|
+
* destroyed instead of being re-cached.
|
|
111
|
+
* 2. The original `meta.cache` declaration is restored automatically on the
|
|
112
|
+
* next navigation away from the page (hooked by `createUbeanRouter`'s
|
|
113
|
+
* `afterEach`), so the NEXT visit mounts a fresh instance which is
|
|
114
|
+
* cached again as declared.
|
|
115
|
+
*
|
|
116
|
+
* Note: pruning cannot take effect while the user stays on the page —
|
|
117
|
+
* `<KeepAlive>` re-caches the active instance on every render while its name
|
|
118
|
+
* is in the include list. To force an in-place remount of the current page,
|
|
119
|
+
* use `reloadPage(name)` instead (bumps the render key).
|
|
120
|
+
*
|
|
121
|
+
* @param name Route name. If omitted, this is a no-op.
|
|
122
|
+
* @param delay Ignored — kept for signature compatibility with the previous
|
|
123
|
+
* exclude→include implementation.
|
|
124
|
+
*/
|
|
125
|
+
async function resetRouteCache(name, _delay) {
|
|
126
|
+
const target = name;
|
|
127
|
+
if (!target) return;
|
|
128
|
+
if (!_cachedViewNames.value.includes(target)) return;
|
|
129
|
+
disablePageCache(target);
|
|
130
|
+
_pendingReincludes.add(target);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Route names whose cache declaration should be restored once the user has
|
|
134
|
+
* navigated away from the page (populated by `resetRouteCache`).
|
|
135
|
+
*/
|
|
136
|
+
const _pendingReincludes = /* @__PURE__ */ new Set();
|
|
137
|
+
/**
|
|
138
|
+
* Restore cache declarations for pages whose cache was reset via
|
|
139
|
+
* `resetRouteCache` once they are no longer the active page.
|
|
140
|
+
*
|
|
141
|
+
* @internal — called from `createUbeanRouter`'s `afterEach` hook.
|
|
142
|
+
*/
|
|
143
|
+
function _flushPendingReincludes(currentPageName) {
|
|
144
|
+
if (_pendingReincludes.size === 0) return;
|
|
145
|
+
for (const name of _pendingReincludes) if (name !== currentPageName) {
|
|
146
|
+
enablePageCache(name);
|
|
147
|
+
_pendingReincludes.delete(name);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Invalidate cached page instance(s).
|
|
152
|
+
*
|
|
153
|
+
* - With a `name`: removes that page from the cache (its instance is pruned
|
|
154
|
+
* on next navigation).
|
|
155
|
+
* - Without a `name`: clears all cached pages.
|
|
156
|
+
*
|
|
157
|
+
* Note: the page will be re-cached on next visit if its `definePage({ cache: true })`
|
|
158
|
+
* is still in effect or `enablePageCache(name)` is called again.
|
|
159
|
+
*/
|
|
160
|
+
function invalidatePageCache(name) {
|
|
161
|
+
if (name) disablePageCache(name);
|
|
162
|
+
else _cachedViewNames.value = [];
|
|
163
|
+
}
|
|
164
|
+
/** Whether a specific page is currently cached. */
|
|
165
|
+
function isPageCached(name) {
|
|
166
|
+
return _cachedViewNames.value.includes(name);
|
|
167
|
+
}
|
|
168
|
+
/** Whether page caching is globally enabled. */
|
|
169
|
+
function isCacheEnabled() {
|
|
170
|
+
return _cacheEnabled.value;
|
|
171
|
+
}
|
|
172
|
+
/** Reactive ref of cached route names — for advanced use cases. */
|
|
173
|
+
function getCachedViewNames() {
|
|
174
|
+
return _cachedViewNames;
|
|
175
|
+
}
|
|
176
|
+
/** Reactive ref of excluded route names — for advanced use cases. */
|
|
177
|
+
function getExcludedViewNames() {
|
|
178
|
+
return _excludedViewNames;
|
|
179
|
+
}
|
|
180
|
+
/** Reactive ref of the global cache-enabled flag. */
|
|
181
|
+
function getCacheEnabled() {
|
|
182
|
+
return _cacheEnabled;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Seed the cache include list from route metadata.
|
|
186
|
+
*
|
|
187
|
+
* Called once during app bootstrap (`createUbeanClientApp`) to honor pages that
|
|
188
|
+
* declared `definePage({ cache: true })`. Routes without `meta.cache` are
|
|
189
|
+
* left untouched so runtime toggling remains possible.
|
|
190
|
+
*/
|
|
191
|
+
function initCachedViewsFromRoutes(routes) {
|
|
192
|
+
const initial = [];
|
|
193
|
+
for (const route of routes) if (route.meta?.cache === true && typeof route.name === "string" && route.name) {
|
|
194
|
+
if (!initial.includes(route.name)) initial.push(route.name);
|
|
195
|
+
}
|
|
196
|
+
if (initial.length > 0) {
|
|
197
|
+
const existing = new Set(_cachedViewNames.value);
|
|
198
|
+
for (const name of initial) if (!existing.has(name)) existing.add(name);
|
|
199
|
+
_cachedViewNames.value = [...existing];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Get or create a named wrapper component for a page.
|
|
204
|
+
*
|
|
205
|
+
* The wrapper carries the route name as its component `name`, which is what
|
|
206
|
+
* `<keep-alive :include>` matches against. The original page component is
|
|
207
|
+
* rendered inside the wrapper. When the original changes (HMR in dev), the
|
|
208
|
+
* wrapper is rebuilt so the new component takes effect.
|
|
209
|
+
*
|
|
210
|
+
* @internal — used by `createLayoutWrapper` in `app.ts`.
|
|
211
|
+
*/
|
|
212
|
+
function getNamedPageWrapper(routeName, original) {
|
|
213
|
+
const cached = _wrapperRegistry.get(routeName);
|
|
214
|
+
if (cached && cached.original === original) return cached.wrapper;
|
|
215
|
+
const wrapper = defineComponent({
|
|
216
|
+
name: routeName,
|
|
217
|
+
setup() {
|
|
218
|
+
return () => h(original);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
_wrapperRegistry.set(routeName, {
|
|
222
|
+
wrapper,
|
|
223
|
+
original
|
|
224
|
+
});
|
|
225
|
+
return wrapper;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Clear the wrapper registry. Mainly useful in tests or when the entire
|
|
229
|
+
* route table is rebuilt.
|
|
230
|
+
*
|
|
231
|
+
* @internal
|
|
232
|
+
*/
|
|
233
|
+
function resetNamedPageWrappers() {
|
|
234
|
+
_wrapperRegistry.clear();
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/page-runtime.ts
|
|
238
|
+
/**
|
|
239
|
+
* Global page runtime config — animation mode + reload signal.
|
|
240
|
+
*
|
|
241
|
+
* These module-level singletons mirror the patterns found in soybean-unify's
|
|
242
|
+
* `themeStore.pageAnimateMode` and `appStore.reloadSignal`:
|
|
243
|
+
*
|
|
244
|
+
* - `pageAnimateMode` is a global transition name applied to every
|
|
245
|
+
* `<PageView />` unless overridden by the `transition` prop or
|
|
246
|
+
* `route.meta.transition`. Set to `'none'` (or empty string) to disable
|
|
247
|
+
* transitions globally.
|
|
248
|
+
*
|
|
249
|
+
* - `reloadSignal` is a counter that increments each time `reloadPage()` is
|
|
250
|
+
* called. `<PageView />` watches this counter (via `:key`) and remounts
|
|
251
|
+
* the current page when it changes — for cached pages the keep-alive
|
|
252
|
+
* instance is pruned and rebuilt around the remount so it is fresh.
|
|
253
|
+
*
|
|
254
|
+
* These are intentionally framework-provided singletons (not pinia stores)
|
|
255
|
+
* because ubean ships without a state management library and the values are
|
|
256
|
+
* globally unique by nature.
|
|
257
|
+
*/
|
|
258
|
+
const _g = globalThis;
|
|
259
|
+
const _pageTransitionName = _g.__ubeanPageTransitionName ??= ref("");
|
|
260
|
+
const _reloadCounter = _g.__ubeanReloadCounter ??= ref(0);
|
|
261
|
+
/**
|
|
262
|
+
* Composable for reactive access to the global page transition name.
|
|
263
|
+
*
|
|
264
|
+
* Used by `<PageView />` as the fallback transition name when neither the
|
|
265
|
+
* `transition` prop nor `route.meta.transition` is set.
|
|
266
|
+
*
|
|
267
|
+
* Layout components can also read this to display a settings UI:
|
|
268
|
+
*
|
|
269
|
+
* ```ts
|
|
270
|
+
* const transition = usePageTransition();
|
|
271
|
+
* // transition.name.value === 'fade-slide'
|
|
272
|
+
* transition.set('zoom-fadein');
|
|
273
|
+
* transition.clear();
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
function usePageTransition() {
|
|
277
|
+
return {
|
|
278
|
+
name: computed(() => _pageTransitionName.value),
|
|
279
|
+
set: (name) => {
|
|
280
|
+
_pageTransitionName.value = name === "none" ? "" : name;
|
|
281
|
+
},
|
|
282
|
+
clear: () => {
|
|
283
|
+
_pageTransitionName.value = "";
|
|
284
|
+
},
|
|
285
|
+
enabled: computed(() => _pageTransitionName.value !== "")
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** Imperatively set the global page transition name. */
|
|
289
|
+
function setPageTransition(name) {
|
|
290
|
+
_pageTransitionName.value = name === "none" ? "" : name;
|
|
291
|
+
}
|
|
292
|
+
/** Imperatively clear the global page transition name (disables transitions). */
|
|
293
|
+
function clearPageTransition() {
|
|
294
|
+
_pageTransitionName.value = "";
|
|
295
|
+
}
|
|
296
|
+
/** Reactive ref of the global transition name — for advanced use cases. */
|
|
297
|
+
function getPageTransitionName() {
|
|
298
|
+
return _pageTransitionName;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Composable for reactive access to the page reload signal.
|
|
302
|
+
*
|
|
303
|
+
* `<PageView />` uses the returned `counter` as part of its render key, so
|
|
304
|
+
* each `reload()` call forces the current page to remount. For cached pages
|
|
305
|
+
* the stale keep-alive instance is pruned around the remount (see
|
|
306
|
+
* `_remountWithFreshCache`), so the remount is fresh while the cache
|
|
307
|
+
* declaration stays in effect — equivalent to the
|
|
308
|
+
* `v-if="appStore.reloadSignal"` pattern in soybean-unify's layout-content.
|
|
309
|
+
*
|
|
310
|
+
* Usage from any component:
|
|
311
|
+
*
|
|
312
|
+
* ```ts
|
|
313
|
+
* const { reload } = useReloadSignal();
|
|
314
|
+
* async function onReloadClick() {
|
|
315
|
+
* await reload(); // reloads the current page
|
|
316
|
+
* }
|
|
317
|
+
* ```
|
|
318
|
+
*
|
|
319
|
+
* @param routeNameGetter Optional function returning the current route name.
|
|
320
|
+
* If provided (and `reload()` is called without an
|
|
321
|
+
* explicit name), used to prune/rebuild that page's
|
|
322
|
+
* cache around the remount. If omitted, `reload()`
|
|
323
|
+
* only bumps the counter.
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
326
|
+
* Internal flag — while `true`, `<PageView>` forces its `<Transition>` mode
|
|
327
|
+
* to `'default'` (defensive: the reload sequence below never enters the
|
|
328
|
+
* leave path, but if a stray re-render does, 'default' mode produces no
|
|
329
|
+
* hollow placeholders).
|
|
330
|
+
*
|
|
331
|
+
* @internal — consumed by `components.ts` (same package).
|
|
332
|
+
*/
|
|
333
|
+
const _reloading = _g.__ubeanReloading ??= ref(false);
|
|
334
|
+
/**
|
|
335
|
+
* Internal blank flag — while `true`, `<PageView>`'s `<KeepAlive>` slot
|
|
336
|
+
* renders a comment vnode instead of the page. Drives the reload sequence
|
|
337
|
+
* in `_remountWithFreshCache`.
|
|
338
|
+
*
|
|
339
|
+
* @internal — consumed by `components.ts` (same package).
|
|
340
|
+
*/
|
|
341
|
+
const _reloadBlank = _g.__ubeanReloadBlank ??= ref(false);
|
|
342
|
+
/**
|
|
343
|
+
* Per-page reload counts — ONLY the reloaded page's key changes, so other
|
|
344
|
+
* pages' KeepAlive cache entries (keyed by vnode key) stay valid across an
|
|
345
|
+
* unrelated page's reload. (A global counter in the key would invalidate
|
|
346
|
+
* every page's cache on any reload.)
|
|
347
|
+
*
|
|
348
|
+
* @internal — consumed by `components.ts` (same package).
|
|
349
|
+
*/
|
|
350
|
+
const _pageReloadCounts = _g.__ubeanPageReloadCounts ??= reactive(/* @__PURE__ */ new Map());
|
|
351
|
+
/**
|
|
352
|
+
* Internal reload driver — remounts the current page fresh via a brief
|
|
353
|
+
* blank window (the `v-if="reloadSignal"` pattern from soybean-unify):
|
|
354
|
+
*
|
|
355
|
+
* 1. Enter reload mode (Transition falls back to 'default' mode).
|
|
356
|
+
* 2. Blank the KeepAlive slot — the old page instance deactivates
|
|
357
|
+
* (cached) or unmounts; KeepAlive renders a comment. A comment inner
|
|
358
|
+
* child makes `<Transition>` skip its leave branch entirely, so Vue
|
|
359
|
+
* never produces an `emptyPlaceholder` hollow KeepAlive (children=null)
|
|
360
|
+
* whose patch crashes `updateSlots` (`Cannot read properties of null
|
|
361
|
+
* (reading '_')` — vue@3.5.41, same family as vuejs/core#10771).
|
|
362
|
+
* 3. Prune the stale old-key cache entry via an include toggle
|
|
363
|
+
* (disable→enable) — safe while the KeepAlive child is blank: include
|
|
364
|
+
* changes only affect the cache set, never a hollow-vnode patch.
|
|
365
|
+
* 4. Bump the page's reload count (new key) and un-blank in the same
|
|
366
|
+
* flush — the fresh page mounts; the enter transition plays. The
|
|
367
|
+
* global counter also bumps so `useReloadSignal().counter` consumers
|
|
368
|
+
* stay in sync.
|
|
369
|
+
* 5. Leave reload mode.
|
|
370
|
+
*
|
|
371
|
+
* Without a `name` there is no way to target a page's key, so the call is
|
|
372
|
+
* signal-only (global counter bump, no remount).
|
|
373
|
+
*/
|
|
374
|
+
async function _remountWithFreshCache(name) {
|
|
375
|
+
if (!name) {
|
|
376
|
+
_reloadCounter.value++;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
_reloading.value = true;
|
|
380
|
+
_reloadBlank.value = true;
|
|
381
|
+
await nextTick();
|
|
382
|
+
if (isPageCached(name)) {
|
|
383
|
+
disablePageCache(name);
|
|
384
|
+
await nextTick();
|
|
385
|
+
enablePageCache(name);
|
|
386
|
+
await nextTick();
|
|
387
|
+
}
|
|
388
|
+
_pageReloadCounts.set(name, (_pageReloadCounts.get(name) ?? 0) + 1);
|
|
389
|
+
_reloadCounter.value++;
|
|
390
|
+
_reloadBlank.value = false;
|
|
391
|
+
await nextTick();
|
|
392
|
+
_reloading.value = false;
|
|
393
|
+
}
|
|
394
|
+
function useReloadSignal(routeNameGetter) {
|
|
395
|
+
return {
|
|
396
|
+
counter: computed(() => _reloadCounter.value),
|
|
397
|
+
reloading: computed(() => _reloading.value),
|
|
398
|
+
reload: async (routeName, duration = 300) => {
|
|
399
|
+
_reloading.value = true;
|
|
400
|
+
try {
|
|
401
|
+
await _remountWithFreshCache(routeName ?? routeNameGetter?.());
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, duration));
|
|
403
|
+
} finally {
|
|
404
|
+
_reloading.value = false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Imperatively trigger a page reload.
|
|
411
|
+
*
|
|
412
|
+
* - `name`: route name to reload (REQUIRED for an actual remount). The page
|
|
413
|
+
* is blanked, its stale keep-alive entry pruned, and a fresh instance
|
|
414
|
+
* mounts under a new key — while its cache declaration stays in effect.
|
|
415
|
+
* Only THIS page's key changes: other pages' keep-alive entries survive.
|
|
416
|
+
* If omitted, the call is signal-only (the global counter bumps for
|
|
417
|
+
* `useReloadSignal().counter` consumers; nothing remounts).
|
|
418
|
+
* - `duration`: milliseconds to wait for transition/cleanup. Defaults to 300.
|
|
419
|
+
*/
|
|
420
|
+
async function reloadPage(name, duration = 300) {
|
|
421
|
+
_reloading.value = true;
|
|
422
|
+
try {
|
|
423
|
+
await _remountWithFreshCache(name);
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, duration));
|
|
425
|
+
} finally {
|
|
426
|
+
_reloading.value = false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Reactive ref of the reload counter — for advanced use cases. */
|
|
430
|
+
function getReloadCounter() {
|
|
431
|
+
return _reloadCounter;
|
|
432
|
+
}
|
|
433
|
+
/** Whether a reload is currently in progress. */
|
|
434
|
+
function isReloading() {
|
|
435
|
+
return _reloading.value;
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/view-transitions.ts
|
|
439
|
+
const _global = globalThis;
|
|
440
|
+
let _typesSupported = null;
|
|
441
|
+
function supportsTransitionTypes() {
|
|
442
|
+
if (_typesSupported !== null) return _typesSupported;
|
|
443
|
+
if (!supportsViewTransitions()) {
|
|
444
|
+
_typesSupported = false;
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
let calledWithObject = false;
|
|
449
|
+
const doc = _global.document;
|
|
450
|
+
const orig = doc.startViewTransition;
|
|
451
|
+
doc.startViewTransition = function(opts) {
|
|
452
|
+
if (opts && typeof opts === "object" && "update" in opts) calledWithObject = true;
|
|
453
|
+
return {
|
|
454
|
+
finished: Promise.resolve(),
|
|
455
|
+
ready: Promise.resolve(),
|
|
456
|
+
updateCallbackDone: Promise.resolve(),
|
|
457
|
+
skipTransition() {}
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
try {
|
|
461
|
+
doc.startViewTransition({
|
|
462
|
+
update: () => {},
|
|
463
|
+
types: ["test"]
|
|
464
|
+
});
|
|
465
|
+
} catch {}
|
|
466
|
+
doc.startViewTransition = orig;
|
|
467
|
+
_typesSupported = calledWithObject;
|
|
468
|
+
} catch {
|
|
469
|
+
_typesSupported = false;
|
|
470
|
+
}
|
|
471
|
+
return _typesSupported;
|
|
472
|
+
}
|
|
473
|
+
function supportsViewTransitions() {
|
|
474
|
+
if (typeof _global.document === "undefined") return false;
|
|
475
|
+
return typeof _global.document.startViewTransition === "function";
|
|
476
|
+
}
|
|
477
|
+
async function withViewTransition(callback, options = {}) {
|
|
478
|
+
const { enabled = true, types } = options;
|
|
479
|
+
if (!enabled || !supportsViewTransitions()) return callback();
|
|
480
|
+
const doc = _global.document;
|
|
481
|
+
const useTypesApi = types && types.length > 0 && supportsTransitionTypes();
|
|
482
|
+
let result;
|
|
483
|
+
let callbackError;
|
|
484
|
+
let callbackFailed = false;
|
|
485
|
+
const updateCallback = async () => {
|
|
486
|
+
try {
|
|
487
|
+
result = await callback();
|
|
488
|
+
} catch (e) {
|
|
489
|
+
callbackFailed = true;
|
|
490
|
+
callbackError = e;
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
let transition;
|
|
495
|
+
if (useTypesApi) transition = doc.startViewTransition({
|
|
496
|
+
update: updateCallback,
|
|
497
|
+
types
|
|
498
|
+
});
|
|
499
|
+
else transition = doc.startViewTransition(updateCallback);
|
|
500
|
+
try {
|
|
501
|
+
await transition.finished;
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (callbackFailed) throw callbackError;
|
|
504
|
+
throw err;
|
|
505
|
+
}
|
|
506
|
+
if (callbackFailed) throw callbackError;
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
function getNavigationType() {
|
|
510
|
+
try {
|
|
511
|
+
const nav = _global.navigation;
|
|
512
|
+
if (nav?.currentEntry && nav?.transitionType) return nav.transitionType;
|
|
513
|
+
} catch {}
|
|
514
|
+
return "push";
|
|
515
|
+
}
|
|
516
|
+
function useViewTransitionState(name) {
|
|
517
|
+
return { style: `view-transition-name: ${name};` };
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/components.ts
|
|
521
|
+
/**
|
|
522
|
+
* @ubean/vue — lean components & plugin.
|
|
523
|
+
*
|
|
524
|
+
* Hard boundary of this module (and the whole package):
|
|
525
|
+
* - dependencies are `vue` + `vue-router` ONLY — no `@ubean/*`, no `node:*`,
|
|
526
|
+
* no `@unhead/vue`
|
|
527
|
+
* - no i18n: `Link` renders paths verbatim unless a localizer is provided
|
|
528
|
+
* via `LOCALIZE_PATH_KEY` (the framework runtime `@ubean/client` provides
|
|
529
|
+
* the reactive `localizePath`; lean SPAs simply don't)
|
|
530
|
+
* - no islands directive: the `v-client` directive is a framework concern
|
|
531
|
+
* (`@ubean/client` registers it inside its app factories)
|
|
532
|
+
*/
|
|
533
|
+
/**
|
|
534
|
+
* Injection keys (exported for advanced use cases — e.g. testing utilities
|
|
535
|
+
* that need to provide the page/transition context outside the plugin).
|
|
536
|
+
*/
|
|
537
|
+
const PAGE_KEY = Symbol("ubean-page");
|
|
538
|
+
const TRANSITION_KEY = Symbol("ubean-transition");
|
|
539
|
+
const SSR_KEY = Symbol("ubean-ssr");
|
|
540
|
+
const LOADING_KEY = Symbol("ubean-loading");
|
|
541
|
+
const ERROR_KEY = Symbol("ubean-error");
|
|
542
|
+
/**
|
|
543
|
+
* Module-level reload placeholder — a single comment vnode reused across
|
|
544
|
+
* renders while `_reloadBlank` is active (see the KeepAlive slot in
|
|
545
|
+
* `PageView` for why it must be a comment, not null).
|
|
546
|
+
*/
|
|
547
|
+
const _reloadPlaceholder = createCommentVNode("ubean-reload");
|
|
548
|
+
/** Dev-only one-shot flag for the out-in degradation warning. */
|
|
549
|
+
let _warnedOutIn = false;
|
|
550
|
+
/**
|
|
551
|
+
* Injectable path localizer (optional i18n bridge). The framework runtime
|
|
552
|
+
* (`@ubean/client`) provides the reactive `localizePath`; when absent,
|
|
553
|
+
* `Link` renders paths verbatim — this package never imports i18n itself.
|
|
554
|
+
*/
|
|
555
|
+
const LOCALIZE_PATH_KEY = Symbol("ubean-localize-path");
|
|
556
|
+
const LAYOUT_CHAIN_KEY = Symbol("ubean-layout-chain");
|
|
557
|
+
/**
|
|
558
|
+
* LayoutChainRenderer — recursively renders a chain of nested layout
|
|
559
|
+
* components (framework factories build the chain; lean SPAs may use it
|
|
560
|
+
* directly for nested layouts via render functions).
|
|
561
|
+
*/
|
|
562
|
+
const LayoutChainRenderer = defineComponent({
|
|
563
|
+
name: "UbeanLayoutChain",
|
|
564
|
+
props: {
|
|
565
|
+
components: {
|
|
566
|
+
type: Array,
|
|
567
|
+
required: true
|
|
568
|
+
},
|
|
569
|
+
depth: {
|
|
570
|
+
type: Number,
|
|
571
|
+
default: 0
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
setup(props) {
|
|
575
|
+
provide(LAYOUT_CHAIN_KEY, {
|
|
576
|
+
get components() {
|
|
577
|
+
return props.components;
|
|
578
|
+
},
|
|
579
|
+
get depth() {
|
|
580
|
+
return props.depth;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
return () => {
|
|
584
|
+
const comp = props.components[props.depth];
|
|
585
|
+
if (!comp) return h(PageView);
|
|
586
|
+
return h(comp);
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
/**
|
|
591
|
+
* ErrorBoundary — catches rendering, async resolution, and setup errors
|
|
592
|
+
* from descendant components. When an error is caught, renders the
|
|
593
|
+
* configured `errorComponent` instead of the slot content. The error state
|
|
594
|
+
* resets automatically on route change.
|
|
595
|
+
*/
|
|
596
|
+
const ErrorBoundary = defineComponent({
|
|
597
|
+
name: "UbeanErrorBoundary",
|
|
598
|
+
props: { component: {
|
|
599
|
+
type: [Object, Function],
|
|
600
|
+
default: null
|
|
601
|
+
} },
|
|
602
|
+
setup(props, { slots }) {
|
|
603
|
+
const error = shallowRef(null);
|
|
604
|
+
const route = useRoute();
|
|
605
|
+
watch(() => route.fullPath, () => {
|
|
606
|
+
error.value = null;
|
|
607
|
+
});
|
|
608
|
+
onErrorCaptured((err) => {
|
|
609
|
+
error.value = err;
|
|
610
|
+
return false;
|
|
611
|
+
});
|
|
612
|
+
return () => {
|
|
613
|
+
if (error.value && props.component) return h(props.component, { error: error.value });
|
|
614
|
+
return slots.default?.();
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
/**
|
|
619
|
+
* PageView — renders the matched route page wrapped with `<Transition>` and
|
|
620
|
+
* `<KeepAlive>`. Layouts use `<PageView />` (instead of `<slot />`) as the
|
|
621
|
+
* page outlet.
|
|
622
|
+
*
|
|
623
|
+
* Equivalents of soybean-unify's layout-content pattern:
|
|
624
|
+
* - `pageAnimateMode` → `transition` prop + `route.meta.transition` + global `usePageTransition()`
|
|
625
|
+
* - `cachedRoutes` / `excludeCachedRoutes` → `useCacheViews().cachedViews` / `excludedViews`
|
|
626
|
+
* - `reloadSignal` → `useReloadSignal().counter` (incremented by `reloadPage()`)
|
|
627
|
+
*
|
|
628
|
+
* Props:
|
|
629
|
+
* - `transition`: transition name (string) or `false` to disable.
|
|
630
|
+
* Priority: prop > `route.meta.transition` > global `usePageTransition()`.
|
|
631
|
+
* - `reloadKey`: a reactive key — changing it forces remount (reload).
|
|
632
|
+
* When omitted, falls back to `route.fullPath` + per-page reload count.
|
|
633
|
+
* - `mode`: Vue `<Transition>` mode. Defaults to `'default'` (cross-fade —
|
|
634
|
+
* old page plays its leave while the new one enters, both driven through
|
|
635
|
+
* KeepAlive's delayLeave). `'out-in'` is DEGRADED to `'default'` with a
|
|
636
|
+
* dev warning: vue@3.5.41's `out-in` renders an `emptyPlaceholder` hollow
|
|
637
|
+
* KeepAlive on page swaps, which crashes hand-written render chains
|
|
638
|
+
* (`updateSlots` reading `children._`) and corrupts keep-alive cache
|
|
639
|
+
* state on compiled chains (vuejs/core#10771 family). Revisit when the
|
|
640
|
+
* upstream fix lands.
|
|
641
|
+
*
|
|
642
|
+
* On SSR (via `SSR_KEY`), KeepAlive/Transition/Suspense/ErrorBoundary are
|
|
643
|
+
* skipped (client-only concepts).
|
|
644
|
+
*/
|
|
645
|
+
const PageView = defineComponent({
|
|
646
|
+
name: "PageView",
|
|
647
|
+
props: {
|
|
648
|
+
transition: {
|
|
649
|
+
type: [String, Boolean],
|
|
650
|
+
default: void 0
|
|
651
|
+
},
|
|
652
|
+
reloadKey: {
|
|
653
|
+
type: [String, Number],
|
|
654
|
+
default: void 0
|
|
655
|
+
},
|
|
656
|
+
mode: {
|
|
657
|
+
type: String,
|
|
658
|
+
default: "default"
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
setup(props) {
|
|
662
|
+
const { cachedViews, excludedViews } = useCacheViews();
|
|
663
|
+
const globalTransition = usePageTransition();
|
|
664
|
+
const ssr = inject(SSR_KEY, false);
|
|
665
|
+
const loadingComp = inject(LOADING_KEY, null);
|
|
666
|
+
const errorComp = inject(ERROR_KEY, null);
|
|
667
|
+
const layoutChain = inject(LAYOUT_CHAIN_KEY, null);
|
|
668
|
+
const route = useRoute();
|
|
669
|
+
watch(() => route.fullPath, () => {
|
|
670
|
+
_flushPendingReincludes(route.meta?.pageName ?? (typeof route.name === "string" ? route.name : void 0));
|
|
671
|
+
}, { flush: "post" });
|
|
672
|
+
return () => {
|
|
673
|
+
if (layoutChain && layoutChain.depth < layoutChain.components.length - 1) return h(LayoutChainRenderer, {
|
|
674
|
+
components: layoutChain.components,
|
|
675
|
+
depth: layoutChain.depth + 1
|
|
676
|
+
});
|
|
677
|
+
return h(RouterView, null, { default: ({ Component, route: $route }) => {
|
|
678
|
+
if (!Component) return null;
|
|
679
|
+
if (ssr) return Component;
|
|
680
|
+
const rawComp = Component.type;
|
|
681
|
+
const pageName = $route.meta?.pageName;
|
|
682
|
+
const comp = pageName ? getNamedPageWrapper(pageName, rawComp) : rawComp;
|
|
683
|
+
const componentProps = Component.props || {};
|
|
684
|
+
const routeTransition = $route.meta?.transition;
|
|
685
|
+
const transitionName = props.transition === false ? void 0 : props.transition || routeTransition || globalTransition.name.value || void 0;
|
|
686
|
+
const pageReloadCount = pageName ? _pageReloadCounts.get(pageName) ?? 0 : 0;
|
|
687
|
+
const pageVNode = h(comp, {
|
|
688
|
+
...componentProps,
|
|
689
|
+
key: props.reloadKey ?? `${$route.fullPath}#${pageReloadCount}`
|
|
690
|
+
});
|
|
691
|
+
const keepAliveProps = { include: cachedViews.value };
|
|
692
|
+
if (excludedViews.value.length > 0) keepAliveProps.exclude = excludedViews.value;
|
|
693
|
+
const keepAliveVNode = h(KeepAlive, keepAliveProps, { default: () => _reloadBlank.value ? _reloadPlaceholder : pageVNode });
|
|
694
|
+
const contentVNode = !ssr && loadingComp ? h(Suspense, null, {
|
|
695
|
+
default: () => keepAliveVNode,
|
|
696
|
+
fallback: () => h(loadingComp)
|
|
697
|
+
}) : keepAliveVNode;
|
|
698
|
+
const transitionVNode = h(Transition, {
|
|
699
|
+
name: transitionName ?? "",
|
|
700
|
+
mode: _reloading.value || props.mode === "out-in" ? "default" : props.mode,
|
|
701
|
+
appear: !!transitionName
|
|
702
|
+
}, { default: () => contentVNode });
|
|
703
|
+
if (props.mode === "out-in" && import.meta.env?.DEV && !_warnedOutIn) {
|
|
704
|
+
_warnedOutIn = true;
|
|
705
|
+
console.warn("[PageView] mode=\"out-in\" is degraded to \"default\": vue@3.5.41 out-in + KeepAlive trips emptyPlaceholder bugs (updateSlots crash on hand-written chains, cache corruption on compiled chains). Using cross-fade instead.");
|
|
706
|
+
}
|
|
707
|
+
if (!ssr && errorComp) return h(ErrorBoundary, { component: errorComp }, { default: () => transitionVNode });
|
|
708
|
+
return transitionVNode;
|
|
709
|
+
} });
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
/**
|
|
714
|
+
* SlotView — renders a named parallel route slot. Parallel routes are
|
|
715
|
+
* registered as Vue Router named views on the matched route record;
|
|
716
|
+
* `<SlotView name="analytics" />` resolves the slot component from the
|
|
717
|
+
* current route (deepest matched record exposing the slot wins).
|
|
718
|
+
*
|
|
719
|
+
* Resolution is independent of RouterView depth chaining, so SlotView works
|
|
720
|
+
* both as a sibling of `<PageView />` and inside the default view page
|
|
721
|
+
* component (a nested `<RouterView>` there would look at depth + 1 and
|
|
722
|
+
* never find the named view).
|
|
723
|
+
*/
|
|
724
|
+
const SlotView = defineComponent({
|
|
725
|
+
name: "SlotView",
|
|
726
|
+
props: { name: {
|
|
727
|
+
type: String,
|
|
728
|
+
required: true
|
|
729
|
+
} },
|
|
730
|
+
setup(props) {
|
|
731
|
+
const route = useRoute();
|
|
732
|
+
const asyncWrappers = /* @__PURE__ */ new Map();
|
|
733
|
+
const resolveSlot = () => {
|
|
734
|
+
for (let i = route.matched.length - 1; i >= 0; i--) {
|
|
735
|
+
const components = route.matched[i].components;
|
|
736
|
+
if (components && props.name in components) return components[props.name];
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
};
|
|
740
|
+
return () => {
|
|
741
|
+
const comp = resolveSlot();
|
|
742
|
+
if (!comp) return createCommentVNode(`slot-view:${props.name}`);
|
|
743
|
+
const fn = comp;
|
|
744
|
+
if (typeof comp === "function" && !fn.render && !fn.setup) {
|
|
745
|
+
let wrapper = asyncWrappers.get(props.name);
|
|
746
|
+
if (!wrapper) {
|
|
747
|
+
wrapper = defineAsyncComponent(() => comp());
|
|
748
|
+
asyncWrappers.set(props.name, wrapper);
|
|
749
|
+
}
|
|
750
|
+
return h(wrapper);
|
|
751
|
+
}
|
|
752
|
+
return h(comp);
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
/**
|
|
757
|
+
* Link — internal links render via RouterLink; external links (`http*`,
|
|
758
|
+
* `//`, `#`) render as native `<a target="_blank" rel="noopener noreferrer">`.
|
|
759
|
+
*
|
|
760
|
+
* Path localization is opt-in via `LOCALIZE_PATH_KEY` (provided by the
|
|
761
|
+
* framework runtime); lean SPAs render paths verbatim.
|
|
762
|
+
*/
|
|
763
|
+
const Link = defineComponent({
|
|
764
|
+
name: "Link",
|
|
765
|
+
props: {
|
|
766
|
+
to: {
|
|
767
|
+
type: [String, Object],
|
|
768
|
+
default: void 0
|
|
769
|
+
},
|
|
770
|
+
href: {
|
|
771
|
+
type: String,
|
|
772
|
+
default: void 0
|
|
773
|
+
},
|
|
774
|
+
replace: {
|
|
775
|
+
type: Boolean,
|
|
776
|
+
default: false
|
|
777
|
+
},
|
|
778
|
+
prefetch: {
|
|
779
|
+
type: Boolean,
|
|
780
|
+
default: false
|
|
781
|
+
},
|
|
782
|
+
activeClass: {
|
|
783
|
+
type: String,
|
|
784
|
+
default: "router-link-active"
|
|
785
|
+
},
|
|
786
|
+
exactActiveClass: {
|
|
787
|
+
type: String,
|
|
788
|
+
default: "router-link-exact-active"
|
|
789
|
+
},
|
|
790
|
+
noActiveClass: {
|
|
791
|
+
type: Boolean,
|
|
792
|
+
default: false
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
setup(props, { slots, attrs }) {
|
|
796
|
+
const localize = inject(LOCALIZE_PATH_KEY, null);
|
|
797
|
+
const resolvedTo = computed(() => {
|
|
798
|
+
const path = props.to ?? props.href ?? "";
|
|
799
|
+
if (typeof path === "string" && (path.startsWith("http") || path.startsWith("//") || path.startsWith("#"))) return path;
|
|
800
|
+
return localize ? localize(path) : path;
|
|
801
|
+
});
|
|
802
|
+
const isExternal = computed(() => {
|
|
803
|
+
const path = String(resolvedTo.value);
|
|
804
|
+
return path.startsWith("http") || path.startsWith("//") || path.startsWith("#");
|
|
805
|
+
});
|
|
806
|
+
return () => {
|
|
807
|
+
if (isExternal.value) return h("a", {
|
|
808
|
+
...attrs,
|
|
809
|
+
href: resolvedTo.value,
|
|
810
|
+
target: "_blank",
|
|
811
|
+
rel: "noopener noreferrer"
|
|
812
|
+
}, slots.default ? slots.default() : void 0);
|
|
813
|
+
return h(RouterLink, {
|
|
814
|
+
...attrs,
|
|
815
|
+
to: resolvedTo.value,
|
|
816
|
+
replace: props.replace,
|
|
817
|
+
activeClass: props.noActiveClass ? "" : props.activeClass,
|
|
818
|
+
exactActiveClass: props.noActiveClass ? "" : props.exactActiveClass
|
|
819
|
+
}, slots.default);
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
const _EMPTY_PAGE_DATA = {};
|
|
824
|
+
/**
|
|
825
|
+
* `usePage()` — 读取 `PAGE_KEY` 注入的页面数据(精简版:仅 pageData)。
|
|
826
|
+
*
|
|
827
|
+
* 路由驱动的 url/params/query 请使用 vue-router 的 `useRoute()`;
|
|
828
|
+
* 导航请使用 vue-router 的 `useRouter()`(push/replace 自带
|
|
829
|
+
* RouteNamedMap 泛型推导)。
|
|
830
|
+
*
|
|
831
|
+
* 未提供 `PAGE_KEY` 时返回共享的空对象(字段均为 undefined)。
|
|
832
|
+
*/
|
|
833
|
+
function usePage() {
|
|
834
|
+
return inject(PAGE_KEY, null) ?? _EMPTY_PAGE_DATA;
|
|
835
|
+
}
|
|
836
|
+
/** `useViewTransition()` — whether native View Transitions are available/enabled. */
|
|
837
|
+
function useViewTransition() {
|
|
838
|
+
const opts = inject(TRANSITION_KEY, { enabled: true });
|
|
839
|
+
return {
|
|
840
|
+
enabled: opts.enabled !== false && supportsViewTransitions(),
|
|
841
|
+
supports: supportsViewTransitions(),
|
|
842
|
+
options: opts
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
const ubeanVue = { install(app, options) {
|
|
846
|
+
app.component("Link", Link);
|
|
847
|
+
app.component("PageView", PageView);
|
|
848
|
+
app.component("SlotView", SlotView);
|
|
849
|
+
if (options?.routes) initCachedViewsFromRoutes(options.routes);
|
|
850
|
+
} };
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/define-page.ts
|
|
853
|
+
/**
|
|
854
|
+
* Macro to declare a client page.
|
|
855
|
+
*
|
|
856
|
+
* used in `<script setup>` of SFC.
|
|
857
|
+
*
|
|
858
|
+
* @param options Page options.
|
|
859
|
+
*
|
|
860
|
+
* @example
|
|
861
|
+
* ```ts
|
|
862
|
+
* definePage({
|
|
863
|
+
* name: 'UserProfile',
|
|
864
|
+
* path: '/u/:id',
|
|
865
|
+
* cache: true,
|
|
866
|
+
* transition: 'fade',
|
|
867
|
+
* layout: 'admin',
|
|
868
|
+
* requiresAuth: true,
|
|
869
|
+
* meta: { custom: 'any' }
|
|
870
|
+
* });
|
|
871
|
+
* ```
|
|
872
|
+
*/
|
|
873
|
+
function definePage(options) {}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/router-location.ts
|
|
876
|
+
function resolveRoute(to, routeMap) {
|
|
877
|
+
if (typeof to === "string") {
|
|
878
|
+
if (to.startsWith("/") || to.startsWith("http") || to.startsWith("#")) return to;
|
|
879
|
+
return `/${to.replace(/^\//, "")}`;
|
|
880
|
+
}
|
|
881
|
+
let path = to.path || "/";
|
|
882
|
+
if (to.name && routeMap) {
|
|
883
|
+
const route = routeMap[to.name];
|
|
884
|
+
if (route) path = route.route;
|
|
885
|
+
}
|
|
886
|
+
if (to.params) for (const [key, value] of Object.entries(to.params)) path = path.replace(`:${key}`, String(value));
|
|
887
|
+
if (to.query) {
|
|
888
|
+
const qs = Object.entries(to.query).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
889
|
+
if (qs) path += (path.includes("?") ? "&" : "?") + qs;
|
|
890
|
+
}
|
|
891
|
+
if (to.hash) path += to.hash.startsWith("#") ? to.hash : `#${to.hash}`;
|
|
892
|
+
return path;
|
|
893
|
+
}
|
|
894
|
+
function isActiveRoute(currentPath, targetHref, exact = false) {
|
|
895
|
+
if (targetHref === "/" || targetHref === "") return currentPath === "/";
|
|
896
|
+
if (exact) return currentPath === targetHref || currentPath === `${targetHref}/`;
|
|
897
|
+
return currentPath === targetHref || currentPath.startsWith(`${targetHref}/`) || currentPath.startsWith(`${targetHref}?`);
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/matchers.ts
|
|
901
|
+
/**
|
|
902
|
+
* 全局 matcher 注册表(进程单例)。
|
|
903
|
+
*
|
|
904
|
+
* server / client 运行时各自维护一份;dev 模式下 HMR 不会自动清理注册表,
|
|
905
|
+
* 但 `defineMatcher` 同名覆盖,因此重新加载 matcher 文件会自动更新函数引用。
|
|
906
|
+
*/
|
|
907
|
+
const matcherRegistry = /* @__PURE__ */ new Map();
|
|
908
|
+
/**
|
|
909
|
+
* 定义并注册一个命名 route matcher。
|
|
910
|
+
*
|
|
911
|
+
* @param name matcher 名称,对应 `[paramName=name]` 中的 `name`
|
|
912
|
+
* @param fn matcher 函数,接收参数字符串值,返回 falsy 表示不匹配
|
|
913
|
+
* @returns 传入的 `fn`,便于链式/导出使用
|
|
914
|
+
*
|
|
915
|
+
* @example
|
|
916
|
+
* ```ts
|
|
917
|
+
* // src/matchers/numeric.ts
|
|
918
|
+
* import { defineMatcher } from '@ubean/vue';
|
|
919
|
+
* export default defineMatcher('numeric', (value) => /^\d+$/.test(value));
|
|
920
|
+
* ```
|
|
921
|
+
*/
|
|
922
|
+
function defineMatcher(name, fn) {
|
|
923
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError(`[ubean] defineMatcher: name must be a non-empty string, got: ${typeof name}`);
|
|
924
|
+
if (typeof fn !== "function") throw new TypeError(`[ubean] defineMatcher: fn must be a function, got: ${typeof fn}`);
|
|
925
|
+
matcherRegistry.set(name, fn);
|
|
926
|
+
return fn;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* 按名称获取已注册的 matcher。未注册时返回 `undefined`。
|
|
930
|
+
*/
|
|
931
|
+
function getMatcher(name) {
|
|
932
|
+
return matcherRegistry.get(name);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* 判断指定名称的 matcher 是否已注册。
|
|
936
|
+
*/
|
|
937
|
+
function hasMatcher(name) {
|
|
938
|
+
return matcherRegistry.has(name);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* 获取所有已注册 matcher 的名称列表(主要用于调试 / DevTools)。
|
|
942
|
+
*/
|
|
943
|
+
function listMatcherNames() {
|
|
944
|
+
return [...matcherRegistry.keys()];
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* 清空所有已注册的 matcher。
|
|
948
|
+
*
|
|
949
|
+
* **仅供测试使用** —— 应用代码不应调用,避免误删其他模块注册的 matcher。
|
|
950
|
+
*/
|
|
951
|
+
function clearMatchers() {
|
|
952
|
+
matcherRegistry.clear();
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* 校验一组路由参数是否通过对应的 matcher。
|
|
956
|
+
*
|
|
957
|
+
* @param matchers `ScannedPage.matchers` —— `{ paramName: matcherName }` 映射
|
|
958
|
+
* @param params 当前请求解析出的 `{ paramName: value }`
|
|
959
|
+
* @returns `true` 表示所有 matcher 通过(或无 matcher 需要校验);
|
|
960
|
+
* `false` 表示至少一个 matcher 拒绝,该路由不应匹配
|
|
961
|
+
*
|
|
962
|
+
* 行为细节:
|
|
963
|
+
* - 若 `matchers` 为空 / undefined,直接返回 `true`(无校验需求)
|
|
964
|
+
* - 若 matcher 名对应的函数未注册,**返回 `false`**(保守策略)
|
|
965
|
+
* - 若参数不存在于 `params` 中,视为校验失败返回 `false`
|
|
966
|
+
* - matcher 函数抛异常时,捕获并视为不匹配
|
|
967
|
+
*/
|
|
968
|
+
function validateParams(matchers, params) {
|
|
969
|
+
if (!matchers || Object.keys(matchers).length === 0) return true;
|
|
970
|
+
for (const [paramName, matcherName] of Object.entries(matchers)) {
|
|
971
|
+
const value = params[paramName];
|
|
972
|
+
if (value === void 0 || value === null) return false;
|
|
973
|
+
const values = Array.isArray(value) ? value : [value];
|
|
974
|
+
const matcherFn = getMatcher(matcherName);
|
|
975
|
+
if (!matcherFn) return false;
|
|
976
|
+
for (const v of values) try {
|
|
977
|
+
if (!matcherFn(v)) return false;
|
|
978
|
+
} catch {
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* 创建 Vue Router `beforeEach` 导航守卫,用于客户端校验动态路由 matchers。
|
|
986
|
+
*
|
|
987
|
+
* 路由记录的 `meta.matchers` 字段(由 route generator 自动注入)记录了
|
|
988
|
+
* `{ paramName: matcherName }` 映射。守卫读取该字段,调用 `validateParams`
|
|
989
|
+
* 校验 `to.params`;失败时跳转到 404 路由(若存在)或取消导航。
|
|
990
|
+
*
|
|
991
|
+
* **用法**:
|
|
992
|
+
*
|
|
993
|
+
* ```ts
|
|
994
|
+
* import { createMatcherGuard } from '@ubean/vue';
|
|
995
|
+
*
|
|
996
|
+
* const router = createRouter({ history: createWebHistory(), routes });
|
|
997
|
+
* router.beforeEach(createMatcherGuard());
|
|
998
|
+
* ```
|
|
999
|
+
*
|
|
1000
|
+
* **设计说明**:
|
|
1001
|
+
* - 守卫是**可选的**:不调用 `createMatcherGuard()` 时,客户端不做 matcher
|
|
1002
|
+
* 校验,完全依赖服务端 Hono 中间件拦截(适用于 SSR 应用)。
|
|
1003
|
+
* - 对于纯 SPA 应用(`ssr: false`),强烈建议注册此守卫,否则客户端导航到
|
|
1004
|
+
* `/users/abc`(应当被 `[id=numeric]` 拒绝)会渲染页面而非 404。
|
|
1005
|
+
*/
|
|
1006
|
+
function createMatcherGuard(options = {}) {
|
|
1007
|
+
const notFoundRouteName = options.notFoundRouteName ?? "NotFound";
|
|
1008
|
+
const onReject = options.onReject;
|
|
1009
|
+
return (to) => {
|
|
1010
|
+
const matchers = to.meta?.matchers;
|
|
1011
|
+
if (!matchers || Object.keys(matchers).length === 0) return;
|
|
1012
|
+
const params = to.params;
|
|
1013
|
+
if (!validateParams(matchers, params)) {
|
|
1014
|
+
onReject?.({
|
|
1015
|
+
path: to.path,
|
|
1016
|
+
params: to.params,
|
|
1017
|
+
matchers
|
|
1018
|
+
});
|
|
1019
|
+
return { name: notFoundRouteName };
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/head.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* 将静态 `PageHead` push 进 head 实例(falsy 字段自动跳过)。
|
|
1027
|
+
* 与 SSR 侧 `pushPageHead`(@ubean/ssr)语义一致,保证双端同构。
|
|
1028
|
+
*/
|
|
1029
|
+
function pushPageHead(head, pageHead) {
|
|
1030
|
+
const input = {};
|
|
1031
|
+
if (pageHead.title !== void 0) input.title = pageHead.title;
|
|
1032
|
+
if (pageHead.meta !== void 0) input.meta = pageHead.meta;
|
|
1033
|
+
if (pageHead.link !== void 0) input.link = pageHead.link;
|
|
1034
|
+
if (pageHead.script !== void 0) input.script = pageHead.script;
|
|
1035
|
+
if (pageHead.htmlAttrs !== void 0) input.htmlAttrs = pageHead.htmlAttrs;
|
|
1036
|
+
if (pageHead.bodyAttrs !== void 0) input.bodyAttrs = pageHead.bodyAttrs;
|
|
1037
|
+
head.push(input);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* SPA 页面级 head 守卫:导航完成后读取 `route.meta.head` 并 push 进 head 实例。
|
|
1041
|
+
*
|
|
1042
|
+
* ```ts
|
|
1043
|
+
* import { createRouter, createWebHistory } from 'vue-router';
|
|
1044
|
+
* import { setupPageHeadGuard, createPageHead } from '@ubean/vue';
|
|
1045
|
+
*
|
|
1046
|
+
* const head = await createPageHead(); // 按需加载 @unhead/vue
|
|
1047
|
+
* const router = createRouter({ history: createWebHistory(), routes });
|
|
1048
|
+
* setupPageHeadGuard(router, head); // 也可传任何已有 unhead 实例
|
|
1049
|
+
* ```
|
|
1050
|
+
*
|
|
1051
|
+
* 初始导航同样触发(vue-router 首次导航完成后 afterEach 执行)。
|
|
1052
|
+
*/
|
|
1053
|
+
function setupPageHeadGuard(router, head) {
|
|
1054
|
+
router.afterEach((to) => {
|
|
1055
|
+
const pageHead = to.meta.head;
|
|
1056
|
+
if (pageHead) pushPageHead(head, pageHead);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* 按需创建 head 实例(懒加载 `@unhead/vue`,optional peer dependency)。
|
|
1061
|
+
*
|
|
1062
|
+
* 项目已自带 unhead 实例时无需调用 —— 直接把实例传给
|
|
1063
|
+
* `setupPageHeadGuard` 即可,零额外加载。
|
|
1064
|
+
*/
|
|
1065
|
+
async function createPageHead() {
|
|
1066
|
+
try {
|
|
1067
|
+
const mod = await import("@unhead/vue");
|
|
1068
|
+
if (typeof mod.createHead === "function") return mod.createHead();
|
|
1069
|
+
} catch {}
|
|
1070
|
+
throw new Error("[ubean/vue] createPageHead() requires `@unhead/vue` to be installed (optional peer dependency)");
|
|
1071
|
+
}
|
|
1072
|
+
//#endregion
|
|
1073
|
+
export { ERROR_KEY, ErrorBoundary, LAYOUT_CHAIN_KEY, LOADING_KEY, LOCALIZE_PATH_KEY, LayoutChainRenderer, Link, PAGE_KEY, PageView, SSR_KEY, SlotView, TRANSITION_KEY, clearMatchers, clearPageTransition, createMatcherGuard, createPageHead, ubeanVue as default, ubeanVue, defineMatcher, definePage, disablePageCache, enablePageCache, excludePageCache, getCacheEnabled, getCachedViewNames, getExcludedViewNames, getMatcher, getNamedPageWrapper, getNavigationType, getPageTransitionName, getReloadCounter, hasMatcher, includePageCache, initCachedViewsFromRoutes, invalidatePageCache, isActiveRoute, isCacheEnabled, isPageCached, isPageExcluded, isReloading, listMatcherNames, pushPageHead, reloadPage, resetNamedPageWrappers, resetRouteCache, resolveRoute, setPageTransition, setupPageHeadGuard, supportsViewTransitions, useCacheViews, usePage, usePageTransition, useReloadSignal, useViewTransition, useViewTransitionState, validateParams, withViewTransition };
|