@axewo/fin-micro 1.0.0 → 2.0.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.
Files changed (68) hide show
  1. package/README.md +101 -3
  2. package/dist/browser.cjs +1 -1
  3. package/dist/browser.mjs +68 -66
  4. package/dist/core.cjs +1 -1
  5. package/dist/core.mjs +447 -433
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.mjs +31 -24
  8. package/dist/{router-view-impl-C1KG6mxu.mjs → router-view-impl-B1NzCGKV.mjs} +18 -18
  9. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  10. package/dist/runtime.cjs +1 -1
  11. package/dist/runtime.mjs +40 -39
  12. package/dist/sdk.cjs +1 -1
  13. package/dist/sdk.mjs +92 -56
  14. package/dist/types/browser/index.d.ts +4 -0
  15. package/dist/types/core/index.d.ts +1 -1
  16. package/dist/types/index.d.ts +2 -0
  17. package/dist/types/runtime/lifecycle.d.ts +1 -1
  18. package/dist/types/sdk/index.d.ts +63 -4
  19. package/dist/types/vue/index.d.ts +13 -2
  20. package/dist/types/vue/router-view-impl.d.ts +1 -1
  21. package/dist/vue.cjs +1 -1
  22. package/dist/vue.mjs +49 -35
  23. package/package.json +2 -2
  24. package/scaffold/base/package.json.template +5 -1
  25. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  26. package/scaffold/base/src/locales/en.ts +12 -0
  27. package/scaffold/base/src/locales/index.ts +7 -1
  28. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  29. package/scaffold/base/src/main.ts +13 -5
  30. package/scaffold/base/src/router/demo.ts +6 -0
  31. package/scaffold/base/src/router/index.ts +13 -0
  32. package/scaffold/base/src/stores/demo.ts +12 -0
  33. package/scaffold/base/src/stores/index.ts +11 -0
  34. package/scaffold/base/src/utils/appConfig.ts +49 -0
  35. package/scaffold/base/src/utils/errorUtils.ts +92 -0
  36. package/scaffold/base/src/utils/requestUtils.ts +32 -11
  37. package/scaffold/base/src/views/DemoView.vue +26 -0
  38. package/scaffold/host/.env.example +11 -3
  39. package/scaffold/host/src/App.vue +1 -13
  40. package/scaffold/host/src/bootstrap.ts +40 -8
  41. package/scaffold/host/src/hooks/usePlatformSession.ts +40 -8
  42. package/scaffold/host/src/layout/HostHeader.vue +65 -5
  43. package/scaffold/host/src/layout/HostLayout.vue +65 -21
  44. package/scaffold/host/src/layout/HostMenu.vue +1 -0
  45. package/scaffold/host/src/layout/HostTabs.vue +41 -8
  46. package/scaffold/host/src/layout/HostWorkspace.vue +19 -7
  47. package/scaffold/host/src/localPages.ts +45 -0
  48. package/scaffold/host/src/platform.ts +41 -10
  49. package/scaffold/host/src/router/guards.ts +128 -0
  50. package/scaffold/host/src/router/index.ts +25 -0
  51. package/scaffold/host/src/router/targets.ts +30 -0
  52. package/scaffold/host/src/router/workspace.ts +63 -0
  53. package/scaffold/host/src/styles/shell.css +761 -0
  54. package/scaffold/host/src/utils/request.ts +30 -3
  55. package/scaffold/host/src/views/HomeView.vue +19 -0
  56. package/scaffold/host/src/views/LoginRoute.vue +16 -0
  57. package/scaffold/host/src/views/LoginView.vue +59 -9
  58. package/scaffold/host/src/views/NotFoundView.vue +1 -0
  59. package/scaffold/host/src/views/ProfileView.vue +24 -0
  60. package/scaffold/host/src/views/WorkspaceRoute.vue +11 -0
  61. package/scaffold/vue/src/bootstrap.ts +18 -22
  62. package/scaffold/vue/src/bridge/auth.ts +23 -0
  63. package/scaffold/vue/src/bridge/session.ts +2 -2
  64. package/scaffold/vue/src/main.ts +5 -2
  65. package/scaffold/vue/src/router/index.ts +2 -0
  66. package/scaffold/vue/src/views/DetailView.vue +14 -5
  67. package/scaffold/vue/src/views/ListView.vue +9 -5
  68. package/dist/router-view-impl-CgLEalx4.cjs +0 -1
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { MicroMenu } from '@axewo/fin-micro'
3
+ /** 薄递归只渲染后台授权菜单,展开与选中状态交给 Element Plus。 */
3
4
  defineProps<{ appId: string; menus: readonly MicroMenu[] }>()
4
5
  const emit = defineEmits<{ open: [appId: string, menu: MicroMenu] }>()
5
6
  </script>
@@ -1,14 +1,47 @@
1
1
  <script setup lang="ts">
2
+ import { nextTick, ref, watch } from 'vue'
2
3
  import type { MicroTab, NavigationOperation } from '@axewo/fin-micro'
3
- defineProps<{ tabs: readonly MicroTab[]; activeKey?: string }>()
4
- const emit = defineEmits<{ operate: [operation: NavigationOperation] }>()
4
+ import { isHome } from '../localPages'
5
+
6
+ /** 页签仅发出真实核心命令;菜单保存原 key,防止异步操作作用于重开的新页面。 */
7
+ const props = defineProps<{ tabs: readonly MicroTab[]; activeKey?: string; pending: boolean }>()
8
+ const emit = defineEmits<{ operate: [operation: NavigationOperation]; 'popover-opened': []; error: [error: unknown] }>()
9
+ const strip = ref<HTMLElement>()
10
+ const menu = ref<{ tab: MicroTab; x?: number; y?: number } | null>(null)
11
+ function closePopovers(): void { menu.value = null }
12
+ function showMenu(tab: MicroTab, event?: MouseEvent): void {
13
+ if (props.pending) return
14
+ menu.value = { tab, ...(event ? { x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 264)) } : {}) }
15
+ emit('popover-opened')
16
+ }
17
+ function command(action: 'refresh' | 'close' | 'all' | 'others' | 'left' | 'right'): void {
18
+ const tab = menu.value?.tab
19
+ closePopovers()
20
+ if (!tab || props.pending || (isHome(tab) && (action === 'close' || action === 'refresh'))) return
21
+ const target = { tabId: tab.tabId, key: tab.key }
22
+ emit('operate', action === 'refresh' || action === 'close' ? { type: action, target } : { type: 'closeMany', scope: action, anchor: target })
23
+ }
24
+ // 只滚动当前页签,不触碰业务容器及其缓存生命周期。
25
+ watch(() => props.activeKey, () => {
26
+ void nextTick().then(() => strip.value?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })).catch(error => emit('error', error))
27
+ })
28
+ watch(() => props.pending, pending => { if (pending) closePopovers() })
29
+ defineExpose({ closePopovers })
5
30
  </script>
6
31
  <template>
7
- <nav class="tabbar" aria-label="已打开页面">
8
- <div v-for="tab in tabs" :key="tab.key" class="tab">
9
- <el-button :type="tab.key === activeKey ? 'primary' : 'default'" @click="emit('operate', { type: 'activate', target: { tabId: tab.tabId, key: tab.key } })">{{ tab.title }}</el-button>
10
- <el-button aria-label="刷新页面" @click="emit('operate', { type: 'refresh', target: { tabId: tab.tabId, key: tab.key } })">刷新</el-button>
11
- <el-button aria-label="关闭页面" @click="emit('operate', { type: 'close', target: { tabId: tab.tabId, key: tab.key } })">×</el-button>
32
+ <div class="shell-tabbar" :inert="pending || undefined" @keydown.esc="closePopovers">
33
+ <button v-if="menu" class="popover-backdrop" aria-label="关闭页签菜单" tabindex="-1" @click="closePopovers" />
34
+ <div ref="strip" class="tab-strip" role="tablist" aria-label="已打开页面">
35
+ <div v-for="tab in tabs" :key="tab.key" class="shell-tab" :class="{ active: tab.key === activeKey }" @contextmenu.prevent="showMenu(tab, $event)">
36
+ <button role="tab" :aria-selected="tab.key === activeKey" :title="tab.title" @click="!pending && emit('operate', { type: 'activate', target: { tabId: tab.tabId, key: tab.key } })">{{ tab.title }}</button>
37
+ <button v-if="!isHome(tab)" class="tab-close" :aria-label="`关闭 ${tab.title}`" @click="!pending && emit('operate', { type: 'close', target: { tabId: tab.tabId, key: tab.key } })">×</button>
38
+ </div>
12
39
  </div>
13
- </nav>
40
+ <button class="tab-more" aria-label="更多页签操作" :aria-expanded="!!menu" :disabled="!tabs.length || pending" @click="tabs.find(tab => tab.key === activeKey) && showMenu(tabs.find(tab => tab.key === activeKey)!)">···</button>
41
+ <div v-if="menu" class="shell-popover tab-actions" :class="{ 'context-actions': menu.x !== undefined }" :style="menu.x !== undefined ? { left: `${menu.x}px`, top: `${menu.y}px` } : undefined" role="menu" aria-label="页签操作菜单">
42
+ <p class="popover-label">{{ menu.tab.title }}</p>
43
+ <button role="menuitem" :disabled="isHome(menu.tab)" @click="command('refresh')">刷新页面</button><button role="menuitem" :disabled="isHome(menu.tab)" @click="command('close')">关闭当前</button><hr />
44
+ <button role="menuitem" @click="command('others')">关闭其他</button><button role="menuitem" @click="command('left')">关闭左侧</button><button role="menuitem" @click="command('right')">关闭右侧</button><button role="menuitem" @click="command('all')">关闭全部业务页</button>
45
+ </div>
46
+ </div>
14
47
  </template>
@@ -1,14 +1,26 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { MicroAppContainer, type MicroApp, type MicroTab, type MicroRuntime } from '@axewo/fin-micro'
4
- const props = defineProps<{ apps: readonly MicroApp[]; tabs: readonly MicroTab[]; active?: MicroTab; runtime: MicroRuntime; epochs: Record<string, number>; pending: boolean }>()
5
- // 应用按 appId 唯一挂载而非按 tabId 创建沙箱;同应用的页面缓存交给子应用 SDK。
6
- const mounted = computed(() => props.apps.filter(app => props.tabs.some(tab => tab.appId === app.appId)))
4
+ import { demoMode } from '../platform'
5
+ import { LOCAL_APP_ID } from '../localPages'
6
+ import type { PlatformSession } from '../types/platform'
7
+ import HomeView from '../views/HomeView.vue'
8
+ import ProfileView from '../views/ProfileView.vue'
9
+ const props = defineProps<{ apps: readonly MicroApp[]; tabs: readonly MicroTab[]; active?: MicroTab; runtime: MicroRuntime; epochs: Record<string, number>; pending: boolean; session: PlatformSession }>()
10
+ const emit = defineEmits<{ home: []; profile: []; logout: [] }>()
11
+ // 远端按 appId 唯一挂载;本地页面使用相同 core 页签及 key,但不创建沙箱或第二套页签状态。
12
+ const mounted = computed(() => props.apps.filter(app => app.kind !== 'local' && props.tabs.some(tab => tab.appId === app.appId)))
13
+ const local = computed(() => props.active?.appId === LOCAL_APP_ID ? props.active : undefined)
7
14
  </script>
8
15
  <template>
9
- <section class="micro-workspace">
10
- <div v-if="!active" class="panel"><h2>工作台</h2><p>从左侧多级菜单打开业务页面;平台只负责会话、菜单与页签编排。</p></div>
11
- <!-- 容器内置加载、失败和重试状态;切换仅隐藏,最后一个页签关闭才卸载。 -->
12
- <MicroAppContainer v-for="app in mounted" v-show="active?.appId === app.appId" :key="`${app.appId}:${epochs[app.appId] ?? 0}`" :app-id="app.appId" :runtime="runtime" :retry-disabled="pending" />
16
+ <section class="shell-content">
17
+ <!-- 仅清理或异常空快照时兜底;正常交互始终保留核心主页,不用此入口模拟固定页签。 -->
18
+ <div v-if="!active && !pending" class="home-view"><section class="home-panel"><h1>工作区暂不可用</h1><p>可返回本地主页重新开始。</p><el-button type="primary" @click="emit('home')">打开主页</el-button></section></div>
19
+ <!-- key 直接使用 core 生命周期身份,页签刷新会重建本地 SFC;不使用 h 拼装业务页面。 -->
20
+ <HomeView v-if="local?.route === '/home'" :key="local.key" :user-name="session.user.name" :demo="demoMode" :pending="pending" @profile="emit('profile')" />
21
+ <ProfileView v-else-if="local?.route === '/profile'" :key="local.key" :session="session" :demo="demoMode" :pending="pending" @home="emit('home')" @logout="emit('logout')" />
22
+ <!-- 容器内置加载、失败和重试状态;切换仅隐藏,最后一个远端页签关闭才卸载。 -->
23
+ <!-- 仅局部覆盖通用容器的最小高度;挂载身份与保活策略不变,低视口不会撑开整个基座。 -->
24
+ <MicroAppContainer class="shell-app-container" v-for="app in mounted" v-show="active?.appId === app.appId" :key="`${app.appId}:${epochs[app.appId] ?? 0}`" :app-id="app.appId" :runtime="runtime" :retry-disabled="pending" />
13
25
  </section>
14
26
  </template>
@@ -0,0 +1,45 @@
1
+ import type { MicroApp, MicroMenu, MicroTab, NavigationOperation, OpenTab } from '@axewo/fin-micro'
2
+
3
+ /** 宿主保留身份,不接受后台同名应用或组件路径;与远端共用 core 的页签标识及生命周期。 */
4
+ export const LOCAL_APP_ID = 'host-local'
5
+ export const localApp: MicroApp = {
6
+ kind: 'local', appId: LOCAL_APP_ID, name: '工作台',
7
+ menus: [
8
+ { menuId: 'home', name: '主页', target: { tabId: 'host-local:home', route: '/home' } },
9
+ { menuId: 'profile', name: '个人中心', target: { tabId: 'host-local:profile', route: '/profile' } },
10
+ ],
11
+ }
12
+
13
+ /** 固定本地白名单;仅接受精确的 appId/tabId/route 组合,绝不动态导入接口指定的页面。 */
14
+ export function canOpenLocal(input: OpenTab): boolean {
15
+ return input.appId === LOCAL_APP_ID && Boolean(localApp.menus?.some(menu =>
16
+ menu.target?.tabId === input.tabId && menu.target.route === input.route))
17
+ }
18
+
19
+ /** 顶栏、主页快捷入口和首次登录共用菜单元信息,不创建第二套本地导航状态。 */
20
+ export function localMenu(id: 'home' | 'profile'): MicroMenu {
21
+ return localApp.menus!.find(menu => menu.menuId === id)!
22
+ }
23
+ /** 只按保留身份识别主页,不依赖标题、索引或活动状态。 */
24
+ export function isHome(tab: Pick<MicroTab, 'appId' | 'tabId'>): boolean {
25
+ return tab.appId === LOCAL_APP_ID && tab.tabId === localMenu('home').target!.tabId
26
+ }
27
+
28
+ /** 交互关闭沿用源示例的排除协议;注销不调用此适配,仍确认并关闭完整快照。 */
29
+ export function workspaceOperation(operation: NavigationOperation, tabs: readonly MicroTab[]): NavigationOperation {
30
+ const home = tabs.find(isHome)
31
+ if (!home) return operation
32
+ const ref = { tabId: home.tabId, key: home.key }
33
+ if ((operation.type === 'close' || operation.type === 'refresh') && operation.target.tabId === home.tabId) {
34
+ // 转为空关闭集合,保留核心的取消和销毁检查,也不切换当前业务页。
35
+ return { type: 'closeMany', scope: 'all', exclude: tabs.map(tab => ({ tabId: tab.tabId, key: tab.key })), signal: operation.signal }
36
+ }
37
+ if (operation.type === 'close') return { ...operation, closeFallback: ref }
38
+ if (operation.type === 'closeMany') return { ...operation, exclude: [...operation.exclude ?? [], ref], closeFallback: ref }
39
+ return operation
40
+ }
41
+
42
+ export function localTarget(id: 'home' | 'profile'): OpenTab {
43
+ const menu = localMenu(id)
44
+ return { appId: LOCAL_APP_ID, ...menu.target!, title: menu.name, activeMenuId: menu.menuId }
45
+ }
@@ -1,7 +1,9 @@
1
1
  import type { MicroApp, MicroMenu, OpenTab, SessionInput } from '@axewo/fin-micro'
2
2
  import { http } from './utils/request'
3
+ import { resolveTrustedApp } from './utils/appConfig'
3
4
 
4
5
  import type { PlatformSession } from './types/platform'
6
+ import { canOpenLocal, localApp, LOCAL_APP_ID } from './localPages'
5
7
 
6
8
  // 仅驻留基座私有模块内存;不写 localStorage、会话快照、URL 或事件总线。
7
9
  let accessToken: string | null = null
@@ -27,19 +29,39 @@ export function clearAccessToken(): void { setAccessToken(null) }
27
29
  export function activateSessionToken(session: PlatformSession | null): void {
28
30
  setAccessToken(session ? pendingTokens.get(session) ?? null : null)
29
31
  if (session) pendingTokens.delete(session)
32
+ // 仅提交成功且仍属当前代次的演示身份;标记不是 Token,不可用于真实平台接口认证。
33
+ if (demoMode && session) writeDemoSession(true)
30
34
  }
31
35
 
32
36
  // 可配置部署注册表,不依赖项目生成顺序;实际子应用 appId 必须与此配置一致。
33
- const appId = import.meta.env.VITE_MICRO_APP_ID || 'business'
37
+ const { appId, entry } = resolveTrustedApp(import.meta.env)
38
+ if (appId === LOCAL_APP_ID) throw new Error('业务 appId 不能占用宿主保留身份 host-local')
34
39
  const registry: MicroApp[] = [{
35
- appId, name: '业务应用', entry: import.meta.env.VITE_MICRO_APP_ENTRY || 'http://localhost:7101',
40
+ appId, name: '业务应用', entry,
36
41
  }]
37
42
  const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', children: [
38
43
  { menuId: 'records', name: '示例记录', target: { tabId: `${appId}:list`, route: '/list' } },
39
44
  ] }]
40
45
 
41
- /** 仅在开发且显式启用时允许内存演示登录;任何真实接口失败均不会回退此身份。 */
42
- export const demoMode = import.meta.env.DEV && import.meta.env.VITE_AUTH_DEMO === 'true'
46
+ /** 开发默认可体验;显式 false 可联调真实认证。生产即使配置 true 也绝不启用或失败降级。 */
47
+ export const demoMode = import.meta.env.DEV && import.meta.env.VITE_AUTH_DEMO !== 'false'
48
+ const DEMO_SESSION_KEY = 'fin-micro:demo-session:v1'
49
+ /** 当前浏览器标签页只保存版本标记,不保存用户名、密码、Token 或服务端身份。存储禁用仍可内存体验。 */
50
+ function writeDemoSession(enabled: boolean): void {
51
+ if (!demoMode) return
52
+ try {
53
+ if (enabled) window.sessionStorage.setItem(DEMO_SESSION_KEY, '1')
54
+ else window.sessionStorage.removeItem(DEMO_SESSION_KEY)
55
+ } catch { /* 浏览器禁止存储时,仅放弃刷新恢复,不阻断登录或退出。 */ }
56
+ }
57
+ /** 正常退出或认证失效撤销演示恢复标记;组件卸载不清除此标记,允许整页刷新恢复。 */
58
+ export function clearDemoSession(): void { writeDemoSession(false) }
59
+ function demoSession(): PlatformSession {
60
+ return {
61
+ user: { id: 'demo', name: '演示用户' }, permissions: ['demo:read'],
62
+ apps: registry.map(app => ({ ...app, menus: demoMenus, menuRoutes: [{ pathPrefix: '/detail', menuId: 'records' }] })),
63
+ }
64
+ }
43
65
 
44
66
  /**
45
67
  * 同源平台接口边界,signal 必须沿用当前认证操作的取消信号,body 存在时发送 POST。
@@ -133,7 +155,8 @@ async function loadSession(value: unknown, signal: AbortSignal): Promise<Platfor
133
155
  const payload = await request('menus', signal, undefined, token)
134
156
  if (!isObject(payload) || !Array.isArray(payload.apps)) throw new Error('平台菜单加载失败,预期 /api/menus 返回 {apps:[{appId,menus}]}')
135
157
  const seen = new Set<string>()
136
- const tabIds = new Set<string>()
158
+ // 本地页与远端共享宿主 tabId 命名空间,后台不能占用保留页签导致本地主页无法打开。
159
+ const tabIds = new Set(localApp.menus!.map(menu => menu.target!.tabId))
137
160
  const apps: MicroApp[] = payload.apps.map(item => {
138
161
  if (!isObject(item) || !identifier(item.appId) || !identity.appIds.includes(item.appId) || seen.has(item.appId)) {
139
162
  throw new Error('菜单接口包含未授权或重复 appId')
@@ -164,6 +187,8 @@ function leaves(menus: readonly MicroMenu[]): MicroMenu[] {
164
187
  * 新业务详情应替换本规则并在后台验证真实权限,不得笼统放行任意 /detail。
165
188
  */
166
189
  export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean {
190
+ // 本地页面仅在已创建的认证工作区使用,不依赖后台 appIds,也不扩大远端应用权限。
191
+ if (input.appId === LOCAL_APP_ID) return canOpenLocal(input)
167
192
  const app = session.apps.find(item => item.appId === input.appId)
168
193
  if (!app || !localRoute(input.route)) return false
169
194
  const menus = leaves(app.menus ?? [])
@@ -176,10 +201,14 @@ export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean
176
201
 
177
202
  /**
178
203
  * 恢复会话并加载授权菜单。signal 为本次操作取消信号,未登录返回 null,接口/解码错误拒绝 Promise。
179
- * 演示模式不自动恢复身份;真实模式必须等待两个接口均完成后才能创建宿主。
204
+ * 演示模式仅按当前标签页的版本标记恢复固定体验身份;真实模式必须等待两个接口均完成。
180
205
  */
181
206
  export async function restoreSession(signal: AbortSignal): Promise<PlatformSession | null> {
182
- if (demoMode) return null
207
+ if (demoMode) {
208
+ if (signal.aborted) throw new DOMException('会话恢复已取消', 'AbortError')
209
+ try { return window.sessionStorage.getItem(DEMO_SESSION_KEY) === '1' ? demoSession() : null }
210
+ catch { return null }
211
+ }
183
212
  const value = await request('session', signal)
184
213
  return value === null ? null : loadSession(value, signal)
185
214
  }
@@ -187,10 +216,11 @@ export async function restoreSession(signal: AbortSignal): Promise<PlatformSessi
187
216
  /**
188
217
  * username/password 为登录表单输入,signal 同时覆盖登录和后续菜单请求。
189
218
  * 返回完整平台会话;登录拒绝、菜单失败或取消均拒绝 Promise,不能显示半就绪工作区。
190
- * 演示仅在显式开发开关下提供内存身份,绝非生产认证,不持久化密码或凭据。
219
+ * 演示仅在开发模式提供固定身份,忽略任何传入账号密码;绝非生产认证,不持久化凭据。
191
220
  */
192
221
  export async function login(username: string, password: string, signal: AbortSignal): Promise<PlatformSession> {
193
- if (demoMode) return { user: { id: 'demo', name: username }, permissions: ['demo:read'], apps: registry.map(app => ({ ...app, menus: demoMenus, menuRoutes: [{ pathPrefix: '/detail', menuId: 'records' }] })) }
222
+ if (signal.aborted) throw new DOMException('登录已取消', 'AbortError')
223
+ if (demoMode) return demoSession()
194
224
  const value = await request('login', signal, { username, password })
195
225
  if (value === null) throw new Error('用户名或密码错误')
196
226
  return loadSession(value, signal)
@@ -199,7 +229,8 @@ export async function login(username: string, password: string, signal: AbortSig
199
229
  /** signal 为注销取消信号,成功返回 void;远程失败拒绝 Promise,但调用方不得恢复旧本地身份。 */
200
230
  export async function logout(signal: AbortSignal, previousToken: string | null = null): Promise<void> {
201
231
  // 本地凭据已清除;局部旧值仅用于可信注销接口,不重新写回私有存储。
202
- if (!demoMode) await request('logout', signal, {}, previousToken)
232
+ if (demoMode) clearDemoSession()
233
+ else await request('logout', signal, {}, previousToken)
203
234
  }
204
235
 
205
236
  /** 输入当前平台会话,返回 SDK 白名单公开字段;不包含 Token,不暴露后台响应的其他属性。 */
@@ -0,0 +1,128 @@
1
+ import { watch } from 'vue'
2
+ import type { NavigationGuardReturn, RouteLocationNormalized, Router } from 'vue-router'
3
+ import type { PlatformSessionController } from '../hooks/usePlatformSession'
4
+ import type { PlatformSession } from '../types/platform'
5
+ import { canOpenRoute } from '../platform'
6
+ import { authorizedRedirect, routeTarget } from './targets'
7
+
8
+ /** 扩展只接收本次公开身份及导航快照;signal 在新导航、注销或守卫移除时协作取消。 */
9
+ export interface HostGuardContext {
10
+ to: RouteLocationNormalized
11
+ from: RouteLocationNormalized
12
+ session: PlatformSession
13
+ signal: AbortSignal
14
+ }
15
+ export interface HostGuardOptions {
16
+ /** 已通过内置认证及路由白名单后执行;false 取消,void/true 继续,不得替代服务端鉴权。 */
17
+ beforeEnter?: (context: HostGuardContext) => boolean | void | Promise<boolean | void>
18
+ /** 接收恢复、扩展或离页异常;调用方必须脱敏展示,回调自身异常不会放行导航。 */
19
+ onError?: (error: unknown) => void
20
+ }
21
+ /** 工作区只返回核心事务对应的放行/取消结果,不重复实现认证或重定向策略。 */
22
+ export type WorkspaceGuard = (context: HostGuardContext) => NavigationGuardReturn | Promise<NavigationGuardReturn>
23
+ const installations = new WeakMap<Router, {
24
+ workspace?: WorkspaceGuard
25
+ invalidate: () => void
26
+ dispose: () => void
27
+ }>()
28
+
29
+ /**
30
+ * 每个 router 只安装一次统一前置守卫:等待恢复、认证、授权、业务扩展,最后委托核心离页事务。
31
+ * @param router 当前 App 独享的 Vue Router。
32
+ * @param controller 当前 App 的认证控制器;ready 只等待首次恢复,之后每次读取最新 session。
33
+ * @param options 可选前置扩展与脱敏错误接收器;不额外注册 beforeEach。
34
+ * @returns 幂等撤销函数;重挂载及启动失败必须调用,取消所有待决导航并移除工作区委托。
35
+ * @throws 重复安装时抛错;异步恢复、扩展或委托异常会报告并取消导航,不形成重定向循环。
36
+ */
37
+ export function installHostGuards(router: Router, controller: PlatformSessionController, options: HostGuardOptions = {}): () => void {
38
+ if (installations.has(router)) throw new Error('当前路由器已安装宿主守卫')
39
+ let disposed = false
40
+ let generation = 0
41
+ let restored = false
42
+ let pending: AbortController | undefined
43
+ const invalidate = () => { generation++; pending?.abort() }
44
+ // 首次恢复提交身份是 ready 的一部分,不取消首次导航;之后的身份替换立即撤销旧事务。
45
+ const offSession = watch(controller.session, () => { if (restored) invalidate() }, { flush: 'sync' })
46
+ const installation = { invalidate, dispose: () => {}, workspace: undefined as WorkspaceGuard | undefined }
47
+ const off = router.beforeEach(async (to, from) => {
48
+ invalidate()
49
+ const current = generation
50
+ const navigation = new AbortController()
51
+ pending = navigation
52
+ const valid = () => !disposed && current === generation && !navigation.signal.aborted
53
+ // 取消只终止导航等待,不假定能终止后台副作用;迟到成功和异常均由 race 消费。
54
+ const wait = async <T>(value: T | Promise<T>): Promise<T | undefined> => {
55
+ let cancel = () => {}
56
+ try {
57
+ return await Promise.race([value, new Promise<undefined>(resolve => {
58
+ cancel = () => resolve(undefined)
59
+ navigation.signal.addEventListener('abort', cancel, { once: true })
60
+ if (navigation.signal.aborted) cancel()
61
+ })])
62
+ } finally { navigation.signal.removeEventListener('abort', cancel) }
63
+ }
64
+ try {
65
+ await wait(controller.ready)
66
+ if (!valid()) return false
67
+ restored = true
68
+ // 恢复在控制器内有代次保护;这里绝不缓存 await 之前的身份,避免旧恢复放行。
69
+ const session = controller.session.value
70
+ if (to.meta.authenticated && !session) return { path: '/login', query: { redirect: to.fullPath }, replace: true }
71
+ if (to.path === '/login' && session) {
72
+ const target = router.resolve(authorizedRedirect(router, session, to.query.redirect))
73
+ return { path: target.path, query: target.query, replace: true }
74
+ }
75
+ // 注销和真实失效优先移除身份;此时不能再调用旧工作区的未保存守卫。
76
+ if (!session) return
77
+ const context = { to, from, session, signal: navigation.signal }
78
+ if (to.meta.authenticated) {
79
+ const target = routeTarget(to)
80
+ const permissions = to.meta.permissions
81
+ const forbidden = (to.path !== '/workspace' || to.query.tab !== undefined) && (!target || !canOpenRoute(session, target))
82
+ const denied = permissions !== undefined && (!Array.isArray(permissions) || !permissions.every(value => typeof value === 'string' && session.permissions.includes(value)))
83
+ if (forbidden || denied) return to.path === '/home' ? false : { path: '/home', replace: true }
84
+ const accepted = await wait(options.beforeEnter?.(context))
85
+ if (!valid() || controller.session.value !== session) return false
86
+ if (accepted === false) return false
87
+ }
88
+ const result = await wait(installation.workspace?.(context))
89
+ if (!valid() || controller.session.value !== session) return false
90
+ return result
91
+ } catch (error) {
92
+ if (valid()) {
93
+ try { options.onError?.(error) } catch { /* 展示异常不能成为放行依据。 */ }
94
+ }
95
+ return false
96
+ }
97
+ })
98
+ installation.dispose = () => {
99
+ if (disposed) return
100
+ disposed = true
101
+ invalidate()
102
+ off()
103
+ offSession()
104
+ installation.workspace = undefined
105
+ installations.delete(router)
106
+ }
107
+ installations.set(router, installation)
108
+ return installation.dispose
109
+ }
110
+
111
+ /**
112
+ * 将当前工作区的核心离页事务挂到统一守卫,不注册第二个 beforeEach。
113
+ * @param router 已调用 installHostGuards 的路由器。
114
+ * @param guard 工作区事务委托;正常离页必须等待其结果,认证失效时不调用。
115
+ * @returns 幂等撤销函数;工作区卸载时调用,旧撤销函数不会删除新工作区的委托。
116
+ * @throws 未安装统一守卫或已有存活工作区时抛错,防止悄悄生成重复导航状态。
117
+ */
118
+ export function attachWorkspaceGuard(router: Router, guard: WorkspaceGuard): () => void {
119
+ const installation = installations.get(router)
120
+ if (!installation) throw new Error('请先安装宿主路由守卫')
121
+ if (installation.workspace) throw new Error('当前路由器已连接工作区')
122
+ installation.workspace = guard
123
+ return () => {
124
+ if (installation.workspace !== guard) return
125
+ installation.invalidate()
126
+ installation.workspace = undefined
127
+ }
128
+ }
@@ -0,0 +1,25 @@
1
+ import { createRouter, createWebHistory, type RouterHistory } from 'vue-router'
2
+ import LoginRoute from '../views/LoginRoute.vue'
3
+ import WorkspaceRoute from '../views/WorkspaceRoute.vue'
4
+ import NotFoundView from '../views/NotFoundView.vue'
5
+ import type { PlatformSessionController } from '../hooks/usePlatformSession'
6
+ import { installHostGuards, type HostGuardOptions } from './guards'
7
+
8
+ /**
9
+ * 创建独占顶级 URL 的宿主路由器,生产服务器需配置 SPA 回退。
10
+ * @param controller 本轮 App 的认证控制器;统一守卫等待其首次恢复。
11
+ * @param history 本轮 history,可在测试中传入 memory history。
12
+ * @param guards 可选业务准入扩展,例如按 to.meta.permissions 声明权限,或通过 beforeEnter 校验业务条件。
13
+ * @returns 带 disposeGuards 的 router;卸载须先撤销守卫,再销毁 history。
14
+ * @throws 路由构造或守卫安装失败时抛错,由 bootstrap 失败清理处理。
15
+ */
16
+ export function createAppRouter(controller: PlatformSessionController, history: RouterHistory = createWebHistory(import.meta.env.BASE_URL), guards: HostGuardOptions = {}) {
17
+ const router = createRouter({ history, routes: [
18
+ { path: '/', redirect: '/home' },
19
+ { path: '/login', component: LoginRoute },
20
+ { path: '/:page(home|profile|workspace)', component: WorkspaceRoute, meta: { authenticated: true } },
21
+ { path: '/:pathMatch(.*)*', component: NotFoundView },
22
+ ] })
23
+ const disposeGuards = installHostGuards(router, controller, guards)
24
+ return Object.assign(router, { disposeGuards })
25
+ }
@@ -0,0 +1,30 @@
1
+ import type { RouteLocationNormalized, Router } from 'vue-router'
2
+ import { decodeMicroLocation, encodeMicroLocation, type OpenTab } from '@axewo/fin-micro'
3
+ import { localTarget, LOCAL_APP_ID } from '../localPages'
4
+ import { canOpenRoute } from '../platform'
5
+ import type { PlatformSession } from '../types/platform'
6
+
7
+ /** URL 只携带公开导航元信息;授权仍由会话及 core 双重检查。 */
8
+ export function routeTarget(route: Pick<RouteLocationNormalized, 'path' | 'query'>): OpenTab | null {
9
+ if (route.path === '/home') return localTarget('home')
10
+ if (route.path === '/profile') return localTarget('profile')
11
+ if (route.path !== '/workspace' || typeof route.query.tab !== 'string') return null
12
+ try {
13
+ const target = decodeMicroLocation(route.query.tab)
14
+ return target && target.appId !== LOCAL_APP_ID ? { ...target, title: '业务页面' } : null
15
+ } catch { return null }
16
+ }
17
+ export function tabLocation(tab?: OpenTab) {
18
+ if (!tab) return { path: '/workspace', query: {} }
19
+ if (tab.appId === LOCAL_APP_ID) return { path: tab.route, query: {} }
20
+ return { path: '/workspace', query: { tab: encodeMicroLocation(tab) } }
21
+ }
22
+ /** 只接受可解析、同站且当前会话已授权的固定宿主路由,不回显任意输入。 */
23
+ export function authorizedRedirect(router: Router, session: PlatformSession, value: unknown): string {
24
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u0020\u007f]/.test(value)) return '/home'
25
+ try {
26
+ const resolved = router.resolve(value)
27
+ const target = routeTarget(resolved)
28
+ return target && !resolved.hash && canOpenRoute(session, target) ? router.resolve(tabLocation(target)).fullPath : '/home'
29
+ } catch { return '/home' }
30
+ }
@@ -0,0 +1,63 @@
1
+ import { connectMicroBrowser, type MicroHost, type MicroBrowserOptions } from '@axewo/fin-micro'
2
+ import type { Router } from 'vue-router'
3
+ import { routeTarget, tabLocation } from './targets'
4
+ import { localTarget, workspaceOperation } from '../localPages'
5
+ import { attachWorkspaceGuard } from './guards'
6
+
7
+ /**
8
+ * 渲染前先登记主页,旧存储与深链恢复复用同一核心状态,主页始终首位且不重复。
9
+ * @param host 已认证且尚未挂载业务组件的宿主。
10
+ * @param router 已完成首次导航的路由器,提供授权后的初始深链。
11
+ * @param options 用户隔离存储及错误回调;URL 始终交给 Vue Router。
12
+ * @returns 浏览器存储连接器,卸载时须调用 dispose。
13
+ * @throws 主页准入失败时抛错;存储或恢复错误由 options.onError 接收。
14
+ */
15
+ export function connectWorkspaceBrowser(host: MicroHost, router: Router, options: MicroBrowserOptions) {
16
+ host.openTab(localTarget('home'))
17
+ return connectMicroBrowser(host, { ...options, url: false, initialLocation: routeTarget(router.currentRoute.value) })
18
+ }
19
+
20
+ /**
21
+ * 统一 URL 与核心状态;工作区只挂接离页事务,不另建 beforeEach。
22
+ * @param host 当前工作区唯一宿主,所有离页仍委托核心 beforeLeave。
23
+ * @param router 已安装 installHostGuards 的路由器。
24
+ * @param authenticated 当前身份仍有效且未进入退出确认时返回 true。
25
+ * @param onError 脱敏错误接收器;事务异常会取消导航,不修改固定主页策略。
26
+ * @returns 带 dispose 的连接,卸载时撤销委托、取消待决事务并释放状态订阅。
27
+ * @throws 统一守卫未安装或工作区重复连接时抛错。
28
+ */
29
+ export function connectWorkspaceRouter(host: MicroHost, router: Router, authenticated: () => boolean, onError: (error: unknown) => void = () => {}) {
30
+ let navigating = false
31
+ let disposed = false
32
+ let generation = 0
33
+ const currentLocation = () => tabLocation(host.getState().tabs.find(tab => tab.tabId === host.getState().activeTabId))
34
+ function sync(replace = false): void {
35
+ if (disposed || navigating || !authenticated()) return
36
+ const target = currentLocation()
37
+ if (router.resolve(target).fullPath === router.currentRoute.value.fullPath) return
38
+ void (replace ? router.replace(target) : router.push(target)).catch(onError)
39
+ }
40
+ const offGuard = attachWorkspaceGuard(router, async ({ to, signal }) => {
41
+ const current = ++generation
42
+ navigating = false
43
+ // 认证失效由统一守卫优先放行清理;仍有身份但正在退出确认时,拒绝新的工作区导航。
44
+ if (!authenticated()) return false
45
+ const target = routeTarget(to)
46
+ const active = host.getState().tabs.find(tab => tab.tabId === host.getState().activeTabId)
47
+ if (target && active?.appId === target.appId && active.tabId === target.tabId && active.route === target.route) return
48
+ // 空工作区路径保留当前页签;外部页面或登录页离开前统一确认全部页签。
49
+ if (to.path === '/workspace' && !target) return
50
+ navigating = true
51
+ try {
52
+ const result = await host.operate(workspaceOperation(target ? { type: 'open', input: target, signal } : { type: 'closeMany', scope: 'all', signal }, host.getState().tabs))
53
+ if (disposed || current !== generation) return false
54
+ return result.status === 'committed' || result.status === 'noop'
55
+ } catch (error) {
56
+ if (!disposed && current === generation) onError(error)
57
+ return false
58
+ } finally { if (current === generation) navigating = false }
59
+ })
60
+ const off = host.subscribe(() => sync())
61
+ sync(true)
62
+ return { dispose() { disposed = true; generation++; off(); offGuard() } }
63
+ }