@axewo/fin-micro 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +181 -4
  2. package/cli/index.mjs +22 -6
  3. package/dist/browser.cjs +1 -1
  4. package/dist/browser.mjs +68 -66
  5. package/dist/core.cjs +1 -1
  6. package/dist/core.mjs +459 -441
  7. package/dist/index.cjs +1 -1
  8. package/dist/index.mjs +31 -24
  9. package/dist/layout.cjs +1 -0
  10. package/dist/layout.css +1 -0
  11. package/dist/layout.mjs +719 -0
  12. package/dist/{router-view-impl-C1KG6mxu.mjs → router-view-impl-B1NzCGKV.mjs} +18 -18
  13. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  14. package/dist/runtime.cjs +1 -1
  15. package/dist/runtime.mjs +40 -39
  16. package/dist/sdk.cjs +1 -1
  17. package/dist/sdk.mjs +92 -56
  18. package/dist/types/browser/index.d.ts +4 -0
  19. package/dist/types/core/index.d.ts +1 -1
  20. package/dist/types/index.d.ts +2 -0
  21. package/dist/types/layout/HostShell.vue.d.ts +528 -0
  22. package/dist/types/layout/ShellHeader.vue.d.ts +46 -0
  23. package/dist/types/layout/ShellMenu.vue.d.ts +12 -0
  24. package/dist/types/layout/ShellTabs.vue.d.ts +23 -0
  25. package/dist/types/layout/StandaloneMenuItem.vue.d.ts +11 -0
  26. package/dist/types/layout/StandaloneShell.vue.d.ts +40 -0
  27. package/dist/types/layout/index.d.ts +5 -0
  28. package/dist/types/layout/types.d.ts +65 -0
  29. package/dist/types/runtime/lifecycle.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +65 -4
  31. package/dist/types/vue/index.d.ts +13 -2
  32. package/dist/types/vue/router-view-impl.d.ts +1 -1
  33. package/dist/types/watermark/FinWatermark.vue.d.ts +16 -0
  34. package/dist/types/watermark/config.d.ts +36 -0
  35. package/dist/types/watermark/index.d.ts +4 -0
  36. package/dist/vue.cjs +1 -1
  37. package/dist/vue.mjs +49 -35
  38. package/dist/watermark.cjs +2 -0
  39. package/dist/watermark.mjs +119 -0
  40. package/package.json +29 -4
  41. package/scaffold/base/index.html +2 -1
  42. package/scaffold/base/package.json.template +5 -1
  43. package/scaffold/base/pnpm-workspace.yaml +6 -0
  44. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  45. package/scaffold/base/src/locales/en.ts +12 -0
  46. package/scaffold/base/src/locales/index.ts +7 -1
  47. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  48. package/scaffold/base/src/main.ts +13 -5
  49. package/scaffold/base/src/router/demo.ts +6 -0
  50. package/scaffold/base/src/router/index.ts +13 -0
  51. package/scaffold/base/src/stores/demo.ts +12 -0
  52. package/scaffold/base/src/stores/index.ts +11 -0
  53. package/scaffold/base/src/utils/appConfig.ts +49 -0
  54. package/scaffold/base/src/utils/errorUtils.ts +109 -0
  55. package/scaffold/base/src/utils/requestUtils.ts +32 -11
  56. package/scaffold/base/src/utils/watermarkConfig.ts +18 -0
  57. package/scaffold/base/src/views/demo/index.vue +26 -0
  58. package/scaffold/host/.env.example +11 -3
  59. package/scaffold/host/src/App.vue +1 -13
  60. package/scaffold/host/src/bootstrap.ts +40 -8
  61. package/scaffold/host/src/hooks/usePlatformSession.ts +40 -8
  62. package/scaffold/host/src/layout/HostHeader.vue +65 -5
  63. package/scaffold/host/src/layout/HostLayout.vue +65 -21
  64. package/scaffold/host/src/layout/HostMenu.vue +11 -2
  65. package/scaffold/host/src/layout/HostTabs.vue +91 -8
  66. package/scaffold/host/src/layout/HostWorkspace.vue +29 -7
  67. package/scaffold/host/src/localPages.ts +45 -0
  68. package/scaffold/host/src/platform.ts +51 -14
  69. package/scaffold/host/src/router/guards.ts +128 -0
  70. package/scaffold/host/src/router/index.ts +25 -0
  71. package/scaffold/host/src/router/targets.ts +30 -0
  72. package/scaffold/host/src/router/workspace.ts +63 -0
  73. package/scaffold/host/src/styles/shell.css +776 -0
  74. package/scaffold/host/src/utils/request.ts +30 -3
  75. package/scaffold/host/src/views/home/index.vue +19 -0
  76. package/scaffold/host/src/views/login/components/LoginForm.vue +83 -0
  77. package/scaffold/host/src/views/login/index.vue +16 -0
  78. package/scaffold/host/src/views/not-found/index.vue +1 -0
  79. package/scaffold/host/src/views/profile/index.vue +24 -0
  80. package/scaffold/host/src/views/workspace/index.vue +11 -0
  81. package/scaffold/layouts/standard/host/src/layout/HostLayout.vue +116 -0
  82. package/scaffold/layouts/standard/host/src/styles/shell.css +168 -0
  83. package/scaffold/layouts/standard/vue/src/layout/StandaloneLayout.vue +26 -0
  84. package/scaffold/vue/src/App.vue +13 -7
  85. package/scaffold/vue/src/bootstrap.ts +25 -24
  86. package/scaffold/vue/src/bridge/auth.ts +23 -0
  87. package/scaffold/vue/src/bridge/session.ts +2 -2
  88. package/scaffold/vue/src/layout/Header.vue +15 -0
  89. package/scaffold/vue/src/layout/Sidebar.vue +21 -0
  90. package/scaffold/vue/src/layout/SidebarItem.vue +21 -0
  91. package/scaffold/vue/src/layout/StandaloneLayout.vue +34 -0
  92. package/scaffold/vue/src/main.ts +5 -2
  93. package/scaffold/vue/src/router/index.ts +9 -4
  94. package/scaffold/vue/src/router/menu.ts +38 -0
  95. package/scaffold/vue/src/views/demo/configs/table.ts +24 -0
  96. package/scaffold/vue/src/views/demo/hooks/useRecordNavigation.ts +32 -0
  97. package/scaffold/vue/src/views/demo/index.vue +17 -0
  98. package/scaffold/vue/src/views/{DetailView.vue → demo/modules/detail/index.vue} +15 -6
  99. package/scaffold/vue/src/views/demo/types.ts +5 -0
  100. package/dist/router-view-impl-CgLEalx4.cjs +0 -1
  101. package/scaffold/host/src/views/LoginView.vue +0 -38
  102. package/scaffold/vue/src/views/ListView.vue +0 -51
  103. /package/scaffold/vue/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
@@ -1,14 +1,97 @@
1
1
  <script setup lang="ts">
2
+ import { nextTick, onMounted, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
2
4
  import type { MicroTab, NavigationOperation } from '@axewo/fin-micro'
3
- defineProps<{ tabs: readonly MicroTab[]; activeKey?: string }>()
4
- const emit = defineEmits<{ operate: [operation: NavigationOperation] }>()
5
+ import { isHome } from '../localPages'
6
+
7
+ /** 页签仅发出真实核心命令;菜单保存原 key,防止异步操作作用于重开的新页面。 */
8
+ const props = defineProps<{ tabs: readonly MicroTab[]; activeKey?: string; pending: boolean }>()
9
+ const emit = defineEmits<{ operate: [operation: NavigationOperation]; 'popover-opened': []; error: [error: unknown] }>()
10
+ const strip = ref<HTMLElement>()
11
+ const menu = ref<{ tab: MicroTab; x?: number; y?: number } | null>(null)
12
+ function closePopovers(): void { menu.value = null }
13
+ function showMenu(tab: MicroTab, event?: MouseEvent): void {
14
+ if (props.pending) return
15
+ 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)) } : {}) }
16
+ emit('popover-opened')
17
+ }
18
+ function command(action: 'refresh' | 'close' | 'all' | 'others' | 'left' | 'right'): void {
19
+ const tab = menu.value?.tab
20
+ closePopovers()
21
+ if (!tab || props.pending || (isHome(tab) && (action === 'close' || action === 'refresh'))) return
22
+ const target = { tabId: tab.tabId, key: tab.key }
23
+ emit('operate', action === 'refresh' || action === 'close' ? { type: action, target } : { type: 'closeMany', scope: action, anchor: target })
24
+ }
25
+ /** 仅调整页签条的水平位置,不滚动业务容器,也不触发页签激活事务。 */
26
+ const overflowing = ref(false)
27
+ const canScrollLeft = ref(false)
28
+ const canScrollRight = ref(false)
29
+ let observer: ResizeObserver | undefined
30
+ let disposed = false
31
+ function measure(): void {
32
+ const el = strip.value
33
+ if (!el || disposed) return
34
+ // 两枚箭头各占 32px;按不含箭头时的总宽度判定,避免临界尺寸反复闪现。
35
+ overflowing.value = el.scrollWidth > el.clientWidth + (overflowing.value ? 64 : 0) + 1
36
+ canScrollLeft.value = el.scrollLeft > 1
37
+ canScrollRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 1
38
+ }
39
+ function revealActive(): void {
40
+ const el = strip.value
41
+ const tab = el?.querySelector<HTMLElement>('[aria-selected="true"]')?.parentElement
42
+ if (!el || !tab || disposed) return
43
+ const viewport = el.getBoundingClientRect()
44
+ const bounds = tab.getBoundingClientRect()
45
+ if (bounds.left < viewport.left) el.scrollLeft += bounds.left - viewport.left
46
+ else if (bounds.right > viewport.right) el.scrollLeft += Math.min(bounds.right - viewport.right, bounds.left - viewport.left)
47
+ measure()
48
+ }
49
+ /** 箭头每次移动约一屏,保留部分重叠便于定位;边界由滚动事件重新测量。 */
50
+ function scrollTabs(direction: -1 | 1): void {
51
+ if (props.pending) return
52
+ strip.value?.scrollBy({ left: direction * Math.max(80, strip.value.clientWidth * 0.8), behavior: 'smooth' })
53
+ }
54
+ function updateViewport(): void {
55
+ void nextTick().then(() => {
56
+ if (disposed) return
57
+ measure()
58
+ // 箭头出现或消失后,可视宽度会再次变化,等布局提交后再定位活动项。
59
+ return nextTick().then(revealActive)
60
+ }).catch(error => emit('error', error))
61
+ }
62
+ onMounted(() => {
63
+ if (typeof ResizeObserver !== 'undefined') {
64
+ observer = new ResizeObserver(updateViewport)
65
+ if (strip.value) observer.observe(strip.value)
66
+ }
67
+ if (typeof window !== 'undefined') window.addEventListener('resize', updateViewport)
68
+ updateViewport()
69
+ })
70
+ onBeforeUnmount(() => {
71
+ disposed = true
72
+ observer?.disconnect()
73
+ if (typeof window !== 'undefined') window.removeEventListener('resize', updateViewport)
74
+ })
75
+ watch(() => [props.activeKey, ...props.tabs.map(tab => `${tab.key}:${tab.title}`)], updateViewport)
76
+ watch(() => props.pending, pending => { if (pending) closePopovers() })
77
+ defineExpose({ closePopovers })
5
78
  </script>
6
79
  <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>
80
+ <div class="shell-tabbar" :inert="pending || undefined" @keydown.esc="closePopovers">
81
+ <button v-if="menu" class="popover-backdrop" aria-label="关闭页签菜单" tabindex="-1" @click="closePopovers" />
82
+ <button v-if="overflowing" class="tab-scroll" aria-label="向左查看页签" title="向左查看页签" :disabled="pending || !canScrollLeft" @click="scrollTabs(-1)"><ArrowLeft aria-hidden="true" /></button>
83
+ <div ref="strip" class="tab-strip" role="tablist" aria-label="已打开页面" @scroll="measure">
84
+ <div v-for="tab in tabs" :key="tab.key" class="shell-tab" :class="{ active: tab.key === activeKey }" @contextmenu.prevent="showMenu(tab, $event)">
85
+ <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>
86
+ <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>
87
+ </div>
12
88
  </div>
13
- </nav>
89
+ <button v-if="overflowing" class="tab-scroll" aria-label="向右查看页签" title="向右查看页签" :disabled="pending || !canScrollRight" @click="scrollTabs(1)"><ArrowRight aria-hidden="true" /></button>
90
+ <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>
91
+ <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="页签操作菜单">
92
+ <p class="popover-label">{{ menu.tab.title }}</p>
93
+ <button role="menuitem" :disabled="isHome(menu.tab)" @click="command('refresh')">刷新页面</button><button role="menuitem" :disabled="isHome(menu.tab)" @click="command('close')">关闭当前</button><hr />
94
+ <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>
95
+ </div>
96
+ </div>
14
97
  </template>
@@ -1,14 +1,36 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { FinWatermark } from '@axewo/fin-micro/watermark'
4
+ import { watermarkConfig } from '../utils/watermarkConfig'
3
5
  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)))
6
+ import { demoMode } from '../platform'
7
+ import { LOCAL_APP_ID } from '../localPages'
8
+ import type { PlatformSession } from '../types/platform'
9
+ import HomeView from '../views/home/index.vue'
10
+ import ProfileView from '../views/profile/index.vue'
11
+ const props = defineProps<{ apps: readonly MicroApp[]; tabs: readonly MicroTab[]; active?: MicroTab; runtime: MicroRuntime; epochs: Record<string, number>; pending: boolean; session: PlatformSession }>()
12
+ const emit = defineEmits<{ home: []; profile: []; logout: [] }>()
13
+ // 远端按 appId 唯一挂载;本地页面使用相同 core 页签及 key,但不创建沙箱或第二套页签状态。
14
+ const mounted = computed(() => props.apps.filter(app => app.kind !== 'local' && props.tabs.some(tab => tab.appId === app.appId)))
15
+ const local = computed(() => props.active?.appId === LOCAL_APP_ID ? props.active : undefined)
7
16
  </script>
8
17
  <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" />
18
+ <!-- 稳定视口与原滚动区分离;不改变 appId/key 或子应用缓存身份。 -->
19
+ <div class="workspace-viewport">
20
+ <section class="shell-content">
21
+ <!-- 仅清理或异常空快照时兜底;正常交互始终保留核心主页,不用此入口模拟固定页签。 -->
22
+ <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>
23
+ <!-- key 直接使用 core 生命周期身份,页签刷新会重建本地 SFC;不使用 h 拼装业务页面。 -->
24
+ <HomeView v-if="local?.route === '/home'" :key="local.key" :user-name="session.user.name" :demo="demoMode" :pending="pending" @profile="emit('profile')" />
25
+ <ProfileView v-else-if="local?.route === '/profile'" :key="local.key" :session="session" :demo="demoMode" :pending="pending" @home="emit('home')" @logout="emit('logout')" />
26
+ <!-- 容器内置加载、失败和重试状态;切换仅隐藏,最后一个远端页签关闭才卸载。 -->
27
+ <!-- 仅局部覆盖通用容器的最小高度;挂载身份与保活策略不变,低视口不会撑开整个基座。 -->
28
+ <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
29
  </section>
30
+ <FinWatermark :config="watermarkConfig" />
31
+ </div>
14
32
  </template>
33
+ <style scoped>
34
+ .workspace-viewport { position: relative; isolation: isolate; display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
35
+ .workspace-viewport > .shell-content { min-width: 0; }
36
+ </style>
@@ -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: '主页', icon: 'HomeFilled', target: { tabId: 'host-local:home', route: '/home' } },
9
+ { menuId: 'profile', name: '个人中心', icon: 'User', 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
- const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', children: [
38
- { menuId: 'records', name: '示例记录', target: { tabId: `${appId}:list`, route: '/list' } },
42
+ const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', icon: 'Folder', children: [
43
+ { menuId: 'records', name: '示例记录', icon: 'List', 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。
@@ -106,15 +128,18 @@ function decodeMenus(value: unknown, tabIds: Set<string>): MicroMenu[] {
106
128
  throw new Error('菜单超出数量限制或 menuId/name 无效、重复')
107
129
  }
108
130
  menuIds.add(item.menuId)
131
+ // 后台只传图标标识;不接收 SVG、组件代码、HTML 或外部 URL,未知名称由展示层回退。
132
+ if (item.icon != null && (typeof item.icon !== 'string' || !/^[A-Za-z][A-Za-z0-9-]{0,63}$/.test(item.icon))) throw new Error('菜单图标标识无效')
133
+ const icon = typeof item.icon === 'string' && item.icon ? { icon: item.icon } : {}
109
134
  if (item.children !== undefined) {
110
135
  if (item.target !== undefined || !Array.isArray(item.children) || item.children.length === 0) throw new Error('菜单分组需要非空 children 且不能同时具有 target')
111
- return { menuId: item.menuId, name: item.name, children: walk(item.children, depth + 1) }
136
+ return { menuId: item.menuId, name: item.name, ...icon, children: walk(item.children, depth + 1) }
112
137
  }
113
138
  if (!isObject(item.target) || !identifier(item.target.tabId) || !localRoute(item.target.route) || tabIds.has(item.target.tabId)) {
114
139
  throw new Error('菜单入口 target 无效或 tabId 在宿主内重复')
115
140
  }
116
141
  tabIds.add(item.target.tabId)
117
- return { menuId: item.menuId, name: item.name, target: { tabId: item.target.tabId, route: item.target.route } }
142
+ return { menuId: item.menuId, name: item.name, ...icon, target: { tabId: item.target.tabId, route: item.target.route } }
118
143
  })
119
144
  }
120
145
  return walk(value, 1)
@@ -133,7 +158,8 @@ async function loadSession(value: unknown, signal: AbortSignal): Promise<Platfor
133
158
  const payload = await request('menus', signal, undefined, token)
134
159
  if (!isObject(payload) || !Array.isArray(payload.apps)) throw new Error('平台菜单加载失败,预期 /api/menus 返回 {apps:[{appId,menus}]}')
135
160
  const seen = new Set<string>()
136
- const tabIds = new Set<string>()
161
+ // 本地页与远端共享宿主 tabId 命名空间,后台不能占用保留页签导致本地主页无法打开。
162
+ const tabIds = new Set(localApp.menus!.map(menu => menu.target!.tabId))
137
163
  const apps: MicroApp[] = payload.apps.map(item => {
138
164
  if (!isObject(item) || !identifier(item.appId) || !identity.appIds.includes(item.appId) || seen.has(item.appId)) {
139
165
  throw new Error('菜单接口包含未授权或重复 appId')
@@ -164,6 +190,8 @@ function leaves(menus: readonly MicroMenu[]): MicroMenu[] {
164
190
  * 新业务详情应替换本规则并在后台验证真实权限,不得笼统放行任意 /detail。
165
191
  */
166
192
  export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean {
193
+ // 本地页面仅在已创建的认证工作区使用,不依赖后台 appIds,也不扩大远端应用权限。
194
+ if (input.appId === LOCAL_APP_ID) return canOpenLocal(input)
167
195
  const app = session.apps.find(item => item.appId === input.appId)
168
196
  if (!app || !localRoute(input.route)) return false
169
197
  const menus = leaves(app.menus ?? [])
@@ -176,10 +204,14 @@ export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean
176
204
 
177
205
  /**
178
206
  * 恢复会话并加载授权菜单。signal 为本次操作取消信号,未登录返回 null,接口/解码错误拒绝 Promise。
179
- * 演示模式不自动恢复身份;真实模式必须等待两个接口均完成后才能创建宿主。
207
+ * 演示模式仅按当前标签页的版本标记恢复固定体验身份;真实模式必须等待两个接口均完成。
180
208
  */
181
209
  export async function restoreSession(signal: AbortSignal): Promise<PlatformSession | null> {
182
- if (demoMode) return null
210
+ if (demoMode) {
211
+ if (signal.aborted) throw new DOMException('会话恢复已取消', 'AbortError')
212
+ try { return window.sessionStorage.getItem(DEMO_SESSION_KEY) === '1' ? demoSession() : null }
213
+ catch { return null }
214
+ }
183
215
  const value = await request('session', signal)
184
216
  return value === null ? null : loadSession(value, signal)
185
217
  }
@@ -187,10 +219,14 @@ export async function restoreSession(signal: AbortSignal): Promise<PlatformSessi
187
219
  /**
188
220
  * username/password 为登录表单输入,signal 同时覆盖登录和后续菜单请求。
189
221
  * 返回完整平台会话;登录拒绝、菜单失败或取消均拒绝 Promise,不能显示半就绪工作区。
190
- * 演示仅在显式开发开关下提供内存身份,绝非生产认证,不持久化密码或凭据。
222
+ * 演示仅在开发模式校验 demo/123456 后提供固定身份;绝非生产认证,不持久化凭据。
191
223
  */
192
224
  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' }] })) }
225
+ if (signal.aborted) throw new DOMException('登录已取消', 'AbortError')
226
+ if (demoMode) {
227
+ if (username !== 'demo' || password !== '123456') throw new Error('用户名或密码错误')
228
+ return demoSession()
229
+ }
194
230
  const value = await request('login', signal, { username, password })
195
231
  if (value === null) throw new Error('用户名或密码错误')
196
232
  return loadSession(value, signal)
@@ -199,7 +235,8 @@ export async function login(username: string, password: string, signal: AbortSig
199
235
  /** signal 为注销取消信号,成功返回 void;远程失败拒绝 Promise,但调用方不得恢复旧本地身份。 */
200
236
  export async function logout(signal: AbortSignal, previousToken: string | null = null): Promise<void> {
201
237
  // 本地凭据已清除;局部旧值仅用于可信注销接口,不重新写回私有存储。
202
- if (!demoMode) await request('logout', signal, {}, previousToken)
238
+ if (demoMode) clearDemoSession()
239
+ else await request('logout', signal, {}, previousToken)
203
240
  }
204
241
 
205
242
  /** 输入当前平台会话,返回 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/login/index.vue'
3
+ import WorkspaceRoute from '../views/workspace/index.vue'
4
+ import NotFoundView from '../views/not-found/index.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
+ }