@axewo/fin-micro 0.0.0-stage → 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 (91) hide show
  1. package/README.md +223 -2
  2. package/cli/index.mjs +240 -0
  3. package/dist/browser.cjs +1 -0
  4. package/dist/browser.mjs +169 -0
  5. package/dist/core.cjs +1 -0
  6. package/dist/core.mjs +654 -0
  7. package/dist/iframe.cjs +1 -0
  8. package/dist/iframe.mjs +146 -0
  9. package/dist/index.cjs +1 -0
  10. package/dist/index.mjs +35 -0
  11. package/dist/router-view-impl-B1NzCGKV.mjs +76 -0
  12. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  13. package/dist/runtime.cjs +1 -0
  14. package/dist/runtime.mjs +149 -0
  15. package/dist/sdk.cjs +1 -0
  16. package/dist/sdk.mjs +118 -0
  17. package/dist/types/browser/index.d.ts +46 -0
  18. package/dist/types/core/index.d.ts +63 -0
  19. package/dist/types/iframe/index.d.ts +77 -0
  20. package/dist/types/index.d.ts +8 -0
  21. package/dist/types/runtime/index.d.ts +21 -0
  22. package/dist/types/runtime/lifecycle.d.ts +23 -0
  23. package/dist/types/sdk/index.d.ts +397 -0
  24. package/dist/types/vue/index.d.ts +102 -0
  25. package/dist/types/vue/router-view-impl.d.ts +41 -0
  26. package/dist/types/vue/router-view.d.ts +42 -0
  27. package/dist/types/vue/tab-context.d.ts +6 -0
  28. package/dist/vue.cjs +1 -0
  29. package/dist/vue.mjs +180 -0
  30. package/package.json +91 -3
  31. package/scaffold/base/index.html +12 -0
  32. package/scaffold/base/package.json.template +33 -0
  33. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  34. package/scaffold/base/src/components/.gitkeep +1 -0
  35. package/scaffold/base/src/components/AppProviders.vue +25 -0
  36. package/scaffold/base/src/hooks/.gitkeep +1 -0
  37. package/scaffold/base/src/layout/.gitkeep +1 -0
  38. package/scaffold/base/src/locales/.gitkeep +1 -0
  39. package/scaffold/base/src/locales/en.ts +12 -0
  40. package/scaffold/base/src/locales/index.ts +13 -0
  41. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  42. package/scaffold/base/src/main.ts +22 -0
  43. package/scaffold/base/src/router/.gitkeep +1 -0
  44. package/scaffold/base/src/router/demo.ts +6 -0
  45. package/scaffold/base/src/router/index.ts +13 -0
  46. package/scaffold/base/src/stores/.gitkeep +1 -0
  47. package/scaffold/base/src/stores/demo.ts +12 -0
  48. package/scaffold/base/src/stores/index.ts +11 -0
  49. package/scaffold/base/src/styles/index.css +18 -0
  50. package/scaffold/base/src/types/.gitkeep +1 -0
  51. package/scaffold/base/src/utils/.gitkeep +1 -0
  52. package/scaffold/base/src/utils/appConfig.ts +49 -0
  53. package/scaffold/base/src/utils/errorUtils.ts +92 -0
  54. package/scaffold/base/src/utils/requestUtils.ts +144 -0
  55. package/scaffold/base/src/views/.gitkeep +1 -0
  56. package/scaffold/base/src/views/DemoView.vue +26 -0
  57. package/scaffold/base/tsconfig.json +16 -0
  58. package/scaffold/base/vite.config.ts +8 -0
  59. package/scaffold/host/.env.example +13 -0
  60. package/scaffold/host/src/App.vue +4 -0
  61. package/scaffold/host/src/bootstrap.ts +50 -0
  62. package/scaffold/host/src/hooks/usePlatformSession.ts +91 -0
  63. package/scaffold/host/src/layout/HostHeader.vue +69 -0
  64. package/scaffold/host/src/layout/HostLayout.vue +168 -0
  65. package/scaffold/host/src/layout/HostMenu.vue +15 -0
  66. package/scaffold/host/src/layout/HostTabs.vue +47 -0
  67. package/scaffold/host/src/layout/HostWorkspace.vue +26 -0
  68. package/scaffold/host/src/localPages.ts +45 -0
  69. package/scaffold/host/src/platform.ts +239 -0
  70. package/scaffold/host/src/router/guards.ts +128 -0
  71. package/scaffold/host/src/router/index.ts +25 -0
  72. package/scaffold/host/src/router/targets.ts +30 -0
  73. package/scaffold/host/src/router/workspace.ts +63 -0
  74. package/scaffold/host/src/styles/shell.css +761 -0
  75. package/scaffold/host/src/types/platform.ts +12 -0
  76. package/scaffold/host/src/utils/request.ts +42 -0
  77. package/scaffold/host/src/views/HomeView.vue +19 -0
  78. package/scaffold/host/src/views/LoginRoute.vue +16 -0
  79. package/scaffold/host/src/views/LoginView.vue +88 -0
  80. package/scaffold/host/src/views/NotFoundView.vue +1 -0
  81. package/scaffold/host/src/views/ProfileView.vue +24 -0
  82. package/scaffold/host/src/views/WorkspaceRoute.vue +11 -0
  83. package/scaffold/vue/src/App.vue +24 -0
  84. package/scaffold/vue/src/bootstrap.ts +67 -0
  85. package/scaffold/vue/src/bridge/auth.ts +23 -0
  86. package/scaffold/vue/src/bridge/session.ts +16 -0
  87. package/scaffold/vue/src/main.ts +13 -0
  88. package/scaffold/vue/src/router/index.ts +16 -0
  89. package/scaffold/vue/src/views/DetailView.vue +57 -0
  90. package/scaffold/vue/src/views/ListView.vue +55 -0
  91. package/scaffold/vue/src/views/NotFoundView.vue +1 -0
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
+ import type { MenuInstance } from 'element-plus'
4
+ import { createMicroHost, createWujieRuntime, useMicroHost, type MicroMenu, type NavigationOperation } from '@axewo/fin-micro'
5
+ import { createTokenCapability, createAuthFailureCapabilities, type AuthCapabilities } from '@axewo/fin-micro/sdk'
6
+ import { canOpenRoute, getAccessToken, getTokenRevision, publicSession } from '../platform'
7
+ import type { PlatformSession } from '../types/platform'
8
+ import HostHeader from './HostHeader.vue'
9
+ import HostMenu from './HostMenu.vue'
10
+ import HostTabs from './HostTabs.vue'
11
+ import HostWorkspace from './HostWorkspace.vue'
12
+ import { localApp, localTarget, workspaceOperation } from '../localPages'
13
+ import { useRouter } from 'vue-router'
14
+ import { connectWorkspaceBrowser, connectWorkspaceRouter } from '../router/workspace'
15
+ import '../styles/shell.css'
16
+ import { toSafeError, reportError } from '../utils/errorUtils'
17
+ const props = defineProps<{ session: PlatformSession }>()
18
+ const emit = defineEmits<{ logout: []; expired: [revision: number] }>()
19
+ // 折叠及弹层仅影响宿主展示,不改变授权菜单、浏览器恢复或应用运行时。
20
+ const collapsed = ref(false)
21
+ const header = ref<InstanceType<typeof HostHeader>>()
22
+ const tabBar = ref<InstanceType<typeof HostTabs>>()
23
+ function closePopovers(): void {
24
+ header.value?.closePopovers()
25
+ tabBar.value?.closePopovers()
26
+ }
27
+ const notice = ref('')
28
+ const exiting = ref(false)
29
+ let alive = true
30
+ const sessionRevision = getTokenRevision()
31
+ const authenticated = () => alive && getTokenRevision() === sessionRevision
32
+ const report = (error: unknown) => { if (alive) notice.value = toSafeError(error).message }
33
+ const trustedApps = props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId)
34
+ const failureCapabilities = createAuthFailureCapabilities({
35
+ apps: trustedApps,
36
+ getRevision: getTokenRevision,
37
+ invalidate: () => {
38
+ if (!alive) return
39
+ // 立即撤销公开身份与新能力准入;父控制器同步清除私有凭据,不能被 beforeLeave 阻断。
40
+ host.updateSession({ authenticated: false, permissions: [] })
41
+ emit('expired', getTokenRevision())
42
+ alive = false
43
+ },
44
+ })
45
+ // 认证成功后才创建;只允许当前后台授权并经过可信注册表转换的应用。
46
+ const host = createMicroHost<AuthCapabilities>([localApp, ...props.session.apps], {
47
+ capabilities: {
48
+ ...failureCapabilities,
49
+ getToken: createTokenCapability({
50
+ // 只授权当前已登录且出自本地可信注册表的业务应用,不使用接口传入的任意 appId。
51
+ apps: props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId),
52
+ // exiting 只是离页确认状态;守卫中的保存仍需凭据,确认退出后才由认证控制器清除。
53
+ getSession: () => ({ ...publicSession(props.session), authenticated: authenticated(), revision: getTokenRevision() }),
54
+ // 业务替换点:可改为接收 context.signal 的异步后台读取;helper 会复查会话版本与存活。
55
+ provider: () => getAccessToken(),
56
+ }),
57
+ },
58
+ onError: report,
59
+ canOpen: input => authenticated() && !exiting.value && canOpenRoute(props.session, input),
60
+ // 不开放任意请求代理;Token 仅由上面的显式白名单能力提供,跨应用导航另行制定白名单。
61
+ canNavigate: () => false,
62
+ })
63
+ host.updateSession(publicSession(props.session))
64
+ // 身份与授权菜单就绪后才能恢复 URL;用户隔离存储,禁止恢复未通过 canOpen 的入口。
65
+ const router = useRouter()
66
+ // 源示例同样在恢复前登记主页:首位、去重并迁移缺少主页的旧快照,不先清空再补页。
67
+ // 此时没有页面或业务守卫;交互切回主页仍须经过 operate 的离页确认。
68
+ const browser = connectWorkspaceBrowser(host, router, { storageKey: `fin-micro:tabs:${props.session.user.id}`, onError: report })
69
+ const workspaceRouter = connectWorkspaceRouter(host, router, () => authenticated() && !exiting.value, report)
70
+ // 运行时只接收远端注册表,本地应用永远不会变成 Wujie 网络加载目标。
71
+ const runtime = createWujieRuntime({ apps: host.apps.filter(app => app.kind !== 'local'), bridge: host.bridge })
72
+ const state = useMicroHost(host)
73
+ const active = computed(() => state.value.tabs.find(tab => tab.tabId === state.value.activeTabId))
74
+ const menu = ref<MenuInstance>()
75
+ const resetting = ref(false)
76
+ const menuIndex = (appId: string, menuId: string) => JSON.stringify([appId, menuId])
77
+ const selected = computed(() => active.value?.activeMenuId ? menuIndex(active.value.appId, active.value.activeMenuId) : '')
78
+ /** Element 菜单会先选中点击项;重置公开 prop,确保异步守卫拒绝时仍高亮实际活动页。 */
79
+ async function syncMenu(): Promise<void> {
80
+ resetting.value = true
81
+ await nextTick()
82
+ if (alive) resetting.value = false
83
+ }
84
+ /** 只计算菜单祖先,展开交给 Element Plus,不重新实现菜单状态机。 */
85
+ function ancestors(menus: readonly MicroMenu[], id?: string): string[] {
86
+ for (const item of menus) {
87
+ if (item.menuId === id) return [item.menuId]
88
+ const children = ancestors(item.children ?? [], id)
89
+ if (children.length) return [item.menuId, ...children]
90
+ }
91
+ return []
92
+ }
93
+ // 首次渲染也展开恢复页的祖先;后续用户手动展开由 ElMenu 自己维护。
94
+ const defaultOpeneds = computed(() => host.apps.flatMap(app => ancestors(app.menus ?? [], active.value?.appId === app.appId ? active.value.activeMenuId : undefined).slice(0, -1).map(id => menuIndex(app.appId, id))))
95
+ watch(selected, () => {
96
+ const app = host.apps.find(item => item.appId === active.value?.appId)
97
+ if (!app) return
98
+ for (const id of ancestors(app.menus ?? [], active.value?.activeMenuId).slice(0, -1)) menu.value?.open(menuIndex(app.appId, id))
99
+ }, { flush: 'post', immediate: true })
100
+ const epochs = ref<Record<string, number>>({})
101
+ let previous = new Set<string>()
102
+ const off = host.subscribe(snapshot => {
103
+ const present = new Set(snapshot.tabs.map(tab => tab.appId))
104
+ // 同 tick 关闭最后一页后重开,也不能复用已经失效的 bridge 和应用容器。
105
+ for (const appId of previous) if (!present.has(appId)) epochs.value[appId] = (epochs.value[appId] ?? 0) + 1
106
+ previous = present
107
+ })
108
+
109
+ /** 所有 UI 导航均走核心事务,携带原 key,避免异步操作误关重开后的新页签。 */
110
+ async function operate(operation: NavigationOperation): Promise<void> {
111
+ if (exiting.value) return
112
+ try {
113
+ const result = await host.operate(workspaceOperation(operation, host.getState().tabs))
114
+ if (result.status === 'cancelled') notice.value = '操作已取消'
115
+ if (result.status === 'stale') notice.value = '目标页签已变化,请重试'
116
+ } catch (error) { report(error) }
117
+ }
118
+ function open(appId: string, menu: MicroMenu): void {
119
+ if (menu.target) void operate({ type: 'open', input: { appId, ...menu.target, title: menu.name, activeMenuId: menu.menuId } })
120
+ }
121
+ /** 退出先由核心统一确认全部页面;取消不注销,确认后由父组件撤销会话并卸载工作区。 */
122
+ async function signOut(): Promise<void> {
123
+ if (exiting.value) return
124
+ closePopovers()
125
+ exiting.value = true
126
+ try {
127
+ const result = await host.operate({ type: 'closeMany', scope: 'all' })
128
+ if (result.status === 'committed' || result.status === 'noop') emit('logout')
129
+ else notice.value = '退出已取消,请先处理未保存页面'
130
+ } catch (error) { report(error) }
131
+ finally { exiting.value = false }
132
+ }
133
+ onBeforeUnmount(() => {
134
+ alive = false
135
+ off()
136
+ workspaceRouter.dispose()
137
+ browser.dispose()
138
+ // 先撤销公开身份,再释放沙箱和核心;立即 dispose 阻止任何旧 SDK 发起新动作。
139
+ host.updateSession({ authenticated: false, permissions: [] })
140
+ void runtime.dispose().catch(reportError)
141
+ host.dispose()
142
+ })
143
+ </script>
144
+
145
+ <template>
146
+ <div class="host-shell" :class="{ 'is-collapsed': collapsed }" :aria-busy="exiting" @keydown.esc="closePopovers">
147
+ <!-- 只锁定宿主入口,不能锁住业务容器内的未保存确认框。 -->
148
+ <HostHeader ref="header" v-model:collapsed="collapsed" :apps="host.apps" :user-name="session.user.name" :active-title="active?.title ?? '工作台'" :pending="exiting" @logout="signOut" @profile="operate({ type: 'open', input: localTarget('profile') })" @open="open" @popover-opened="tabBar?.closePopovers()" />
149
+ <aside class="shell-sidebar" aria-label="主导航" :inert="exiting || undefined">
150
+ <!-- 折叠后显示应用缩写;再次点击展开,菜单状态仍由同一个 Element Plus 实例维护。 -->
151
+ <template v-if="collapsed">
152
+ <button v-for="app in host.apps.filter(item => item.menus?.length)" :key="app.appId" class="collapsed-entry" :class="{ selected: active?.appId === app.appId }" :title="app.name" :aria-label="`展开 ${app.name} 菜单`" @click="!exiting && (collapsed = false)">{{ app.name.slice(0, 1) }}</button>
153
+ </template>
154
+ <!-- 侧栏用 v-show 保留菜单实例;禁用 ElMenu 折叠过渡,避免隐藏时写入窄宽度和 overflow:hidden,展开后仍裁剪菜单。 -->
155
+ <el-menu v-show="!collapsed" :collapse-transition="false" ref="menu" class="sidebar-menu" :default-active="resetting ? '__reset__' : selected" :default-openeds="defaultOpeneds" :router="false" @select="syncMenu">
156
+ <template v-for="app in host.apps" :key="app.appId">
157
+ <HostMenu :app-id="app.appId" :menus="app.menus ?? []" @open="open" />
158
+ </template>
159
+ </el-menu>
160
+ <div class="sidebar-footer"><span class="status-dot" /><span class="sidebar-label">工作空间已就绪</span></div>
161
+ </aside>
162
+ <main class="shell-main">
163
+ <HostTabs ref="tabBar" :tabs="state.tabs" :active-key="active?.key" :pending="exiting" @operate="operate" @error="report" @popover-opened="header?.closePopovers()" />
164
+ <div v-if="notice" class="shell-feedback" role="status"><span>{{ notice }}</span><button aria-label="关闭操作提示" @click="notice = ''">×</button></div>
165
+ <HostWorkspace :apps="host.apps" :tabs="state.tabs" :active="active" :runtime="runtime" :epochs="epochs" :pending="exiting" :session="session" @home="operate({ type: 'open', input: localTarget('home') })" @profile="operate({ type: 'open', input: localTarget('profile') })" @logout="signOut" />
166
+ </main>
167
+ </div>
168
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { MicroMenu } from '@axewo/fin-micro'
3
+ /** 薄递归只渲染后台授权菜单,展开与选中状态交给 Element Plus。 */
4
+ defineProps<{ appId: string; menus: readonly MicroMenu[] }>()
5
+ const emit = defineEmits<{ open: [appId: string, menu: MicroMenu] }>()
6
+ </script>
7
+ <template>
8
+ <template v-for="menu in menus" :key="JSON.stringify([appId, menu.menuId])">
9
+ <el-sub-menu v-if="menu.children?.length" :index="JSON.stringify([appId, menu.menuId])">
10
+ <template #title>{{ menu.name }}</template>
11
+ <HostMenu :app-id="appId" :menus="menu.children" @open="(id, item) => emit('open', id, item)" />
12
+ </el-sub-menu>
13
+ <el-menu-item v-else-if="menu.target" :index="JSON.stringify([appId, menu.menuId])" @click="emit('open', appId, menu)">{{ menu.name }}</el-menu-item>
14
+ </template>
15
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, ref, watch } from 'vue'
3
+ import type { MicroTab, NavigationOperation } from '@axewo/fin-micro'
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 })
30
+ </script>
31
+ <template>
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>
39
+ </div>
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>
47
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { MicroAppContainer, type MicroApp, type MicroTab, type MicroRuntime } from '@axewo/fin-micro'
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)
14
+ </script>
15
+ <template>
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" />
25
+ </section>
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
+ }
@@ -0,0 +1,239 @@
1
+ import type { MicroApp, MicroMenu, OpenTab, SessionInput } from '@axewo/fin-micro'
2
+ import { http } from './utils/request'
3
+ import { resolveTrustedApp } from './utils/appConfig'
4
+
5
+ import type { PlatformSession } from './types/platform'
6
+ import { canOpenLocal, localApp, LOCAL_APP_ID } from './localPages'
7
+
8
+ // 仅驻留基座私有模块内存;不写 localStorage、会话快照、URL 或事件总线。
9
+ let accessToken: string | null = null
10
+ let tokenRevision = 0
11
+ const pendingTokens = new WeakMap<PlatformSession, string | null>()
12
+ /** 返回基座当前私有凭据;null 表示无可读 Token,不单独代表用户已登录。不得记录或广播结果。 */
13
+ export function getAccessToken(): string | null { return accessToken }
14
+ /** 返回凭据版本;每次提交/清除均递增,供异步能力交付前判断身份或凭据是否变化。 */
15
+ export function getTokenRevision(): number { return tokenRevision }
16
+ /**
17
+ * 提交基座私有内存凭据并使旧版本失效;不持久化,也不建立登录身份。
18
+ * @param value 最新访问 Token 或 null;刷新成功后同样调用,调用方须先验证认证操作仍有效。
19
+ * @throws 格式无效时拒绝且不修改当前值,错误不回显凭据。
20
+ */
21
+ export function setAccessToken(value: string | null): void {
22
+ if (value !== null && (typeof value !== 'string' || !value || value.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(value))) throw new Error('平台 Token 格式错误')
23
+ accessToken = value
24
+ tokenRevision++
25
+ }
26
+ /** 清除本地凭据并递增版本;不替代服务端注销,已交付到可信子应用的凭据无法直接撤回。 */
27
+ export function clearAccessToken(): void { setAccessToken(null) }
28
+ /** 只在 usePlatformSession 的 generation 验证通过后提交候选凭据,晚到响应不会覆盖新用户。 */
29
+ export function activateSessionToken(session: PlatformSession | null): void {
30
+ setAccessToken(session ? pendingTokens.get(session) ?? null : null)
31
+ if (session) pendingTokens.delete(session)
32
+ // 仅提交成功且仍属当前代次的演示身份;标记不是 Token,不可用于真实平台接口认证。
33
+ if (demoMode && session) writeDemoSession(true)
34
+ }
35
+
36
+ // 可配置部署注册表,不依赖项目生成顺序;实际子应用 appId 必须与此配置一致。
37
+ const { appId, entry } = resolveTrustedApp(import.meta.env)
38
+ if (appId === LOCAL_APP_ID) throw new Error('业务 appId 不能占用宿主保留身份 host-local')
39
+ const registry: MicroApp[] = [{
40
+ appId, name: '业务应用', entry,
41
+ }]
42
+ const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', children: [
43
+ { menuId: 'records', name: '示例记录', target: { tabId: `${appId}:list`, route: '/list' } },
44
+ ] }]
45
+
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
+ }
65
+
66
+ /**
67
+ * 同源平台接口边界,signal 必须沿用当前认证操作的取消信号,body 存在时发送 POST。
68
+ * 返回未解码 JSON;401 返回 null,其他错误或网络失败拒绝 Promise。
69
+ * 默认采用 HttpOnly Cookie;仅 menus/logout 显式传入该次局部 Token,上线前接入后台自己的 CSRF 协议。
70
+ */
71
+ async function request(path: string, signal: AbortSignal, body?: Record<string, unknown>, token: string | null = null): Promise<unknown> {
72
+ const headers = token === null ? undefined : { Authorization: `Bearer ${token}` }
73
+ try {
74
+ // 平台接口只发送 JSON 对象,编码、超时和取消由 fin-core 统一实现。
75
+ const value = body === undefined
76
+ ? await http.get<unknown>(path, { signal, headers })
77
+ : await http.post<unknown>(path, body, { signal, headers })
78
+ return value ?? null
79
+ } catch (error) {
80
+ // 只有身份协议将 401 解释为未登录;通用请求类不擅自改变认证或导航。
81
+ if (typeof error === 'object' && error !== null && 'isAxiosError' in error && error.isAxiosError === true &&
82
+ 'response' in error && typeof error.response === 'object' && error.response !== null &&
83
+ 'status' in error.response && error.response.status === 401) return null
84
+ throw error
85
+ }
86
+ }
87
+
88
+ function isObject(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
90
+ }
91
+ function text(value: unknown, limit: number): value is string {
92
+ return typeof value === 'string' && !!value.trim() && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value)
93
+ }
94
+ function identifier(value: unknown): value is string {
95
+ // CLI 的 appId 最长 214 字符,派生 tabId 还包含业务后缀;与 core 的 500 字符标识边界一致。
96
+ return text(value, 500) && /^[a-zA-Z0-9:_-]+$/.test(value)
97
+ }
98
+ function localRoute(value: unknown): value is string {
99
+ return text(value, 1000) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
100
+ }
101
+
102
+ /** 只读取白名单身份字段;拒绝未知 appId,不允许接口提供 entry 或代码导入路径。 */
103
+ function decodeIdentity(value: unknown): Omit<PlatformSession, 'apps'> & { appIds: string[] } {
104
+ if (!isObject(value) || !isObject(value.user) || !text(value.user.id, 120) || !text(value.user.name, 120) ||
105
+ !Array.isArray(value.permissions) || !value.permissions.every(item => text(item, 120)) ||
106
+ !Array.isArray(value.appIds) || !value.appIds.every(identifier)) {
107
+ throw new Error('平台身份格式错误,预期 {user:{id,name},permissions:string[],appIds:string[]}')
108
+ }
109
+ const ids = value.appIds as string[]
110
+ if (new Set(ids).size !== ids.length || ids.some(id => !registry.some(app => app.appId === id))) {
111
+ throw new Error('平台返回重复或未在可信注册表登记的 appId,请检查部署配置')
112
+ }
113
+ return { user: { id: value.user.id, name: value.user.name }, permissions: [...value.permissions] as string[], appIds: ids }
114
+ }
115
+
116
+ /**
117
+ * 解码后台递归菜单,只复制 SDK 的 menuId/name/children/target 四类字段。
118
+ * 限制深度和数量,并检查应用内 menuId、宿主内 tabId 唯一;不接受任意 entry 或 HTML。
119
+ * 分组与叶节点互斥;格式错误直接拒绝整次初始化,避免静默扩大或误缩权限。
120
+ */
121
+ function decodeMenus(value: unknown, tabIds: Set<string>): MicroMenu[] {
122
+ const menuIds = new Set<string>()
123
+ let count = 0
124
+ function walk(items: unknown, depth: number): MicroMenu[] {
125
+ if (!Array.isArray(items) || depth > 8) throw new Error('菜单格式错误或嵌套超过 8 层')
126
+ return items.map(item => {
127
+ if (++count > 500 || !isObject(item) || !identifier(item.menuId) || !text(item.name, 80) || menuIds.has(item.menuId)) {
128
+ throw new Error('菜单超出数量限制或 menuId/name 无效、重复')
129
+ }
130
+ menuIds.add(item.menuId)
131
+ if (item.children !== undefined) {
132
+ if (item.target !== undefined || !Array.isArray(item.children) || item.children.length === 0) throw new Error('菜单分组需要非空 children 且不能同时具有 target')
133
+ return { menuId: item.menuId, name: item.name, children: walk(item.children, depth + 1) }
134
+ }
135
+ if (!isObject(item.target) || !identifier(item.target.tabId) || !localRoute(item.target.route) || tabIds.has(item.target.tabId)) {
136
+ throw new Error('菜单入口 target 无效或 tabId 在宿主内重复')
137
+ }
138
+ tabIds.add(item.target.tabId)
139
+ return { menuId: item.menuId, name: item.name, target: { tabId: item.target.tabId, route: item.target.route } }
140
+ })
141
+ }
142
+ return walk(value, 1)
143
+ }
144
+
145
+ /**
146
+ * 身份成功后再加载 /api/menus,明确协议为 {apps:[{appId,menus:MicroMenu[]}]}。
147
+ * 只接受当前身份 appIds 内的应用,名称和 entry 始终采用本地可信注册表。
148
+ * 接口应返回全部已授权应用(没有菜单可传空数组);缺失、401 或解码失败均拒绝挂载宿主。
149
+ */
150
+ async function loadSession(value: unknown, signal: AbortSignal): Promise<PlatformSession> {
151
+ const identity = decodeIdentity(value)
152
+ // 该次认证的局部凭据只用于可信平台菜单接口;此时尚未提交全局,不会污染新一代登录。
153
+ const token = isObject(value) && value.accessToken !== undefined ? value.accessToken : null
154
+ if (token !== null && (typeof token !== 'string' || !token || token.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(token))) throw new Error('平台 Token 格式错误')
155
+ const payload = await request('menus', signal, undefined, token)
156
+ if (!isObject(payload) || !Array.isArray(payload.apps)) throw new Error('平台菜单加载失败,预期 /api/menus 返回 {apps:[{appId,menus}]}')
157
+ const seen = new Set<string>()
158
+ // 本地页与远端共享宿主 tabId 命名空间,后台不能占用保留页签导致本地主页无法打开。
159
+ const tabIds = new Set(localApp.menus!.map(menu => menu.target!.tabId))
160
+ const apps: MicroApp[] = payload.apps.map(item => {
161
+ if (!isObject(item) || !identifier(item.appId) || !identity.appIds.includes(item.appId) || seen.has(item.appId)) {
162
+ throw new Error('菜单接口包含未授权或重复 appId')
163
+ }
164
+ seen.add(item.appId)
165
+ const registered = registry.find(app => app.appId === item.appId)!
166
+ const menus = decodeMenus(item.menus, tabIds)
167
+ const hasRecords = leaves(menus).some(menu => menu.menuId === 'records' && menu.target?.route === '/list')
168
+ return { ...registered, menus, menuRoutes: hasRecords ? [{ pathPrefix: '/detail', menuId: 'records' }] : [] }
169
+ })
170
+ if (seen.size !== identity.appIds.length) throw new Error('菜单接口遗漏已授权应用,请检查 /api/menus')
171
+ // signal 与上层 generation 双重隔离晚到结果;已取消操作不得创建新会话。
172
+ if (signal.aborted) throw new DOMException('会话加载已取消', 'AbortError')
173
+ // 后台可选 accessToken;省略保持默认 HttpOnly Cookie 模式,不生成演示 Token。
174
+ const session: PlatformSession = { user: identity.user, permissions: identity.permissions, apps }
175
+ pendingTokens.set(session, token)
176
+ return session
177
+ }
178
+
179
+ function leaves(menus: readonly MicroMenu[]): MicroMenu[] {
180
+ return menus.flatMap(menu => menu.children ? leaves(menu.children) : [menu])
181
+ }
182
+
183
+ /**
184
+ * 宿主统一导航授权入口。session 为完成菜单加载的当前会话,input 为待打开的页签元信息。
185
+ * 返回 true 仅代表前端准入:普通入口须精确匹配已授权菜单的 route/tabId;不根据 URL 猜测权限。
186
+ * 详情是显式演示业务规则:必须存在 records(/list) 菜单并具有 demo:read,且 ID 和 tabId 匹配。
187
+ * 新业务详情应替换本规则并在后台验证真实权限,不得笼统放行任意 /detail。
188
+ */
189
+ export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean {
190
+ // 本地页面仅在已创建的认证工作区使用,不依赖后台 appIds,也不扩大远端应用权限。
191
+ if (input.appId === LOCAL_APP_ID) return canOpenLocal(input)
192
+ const app = session.apps.find(item => item.appId === input.appId)
193
+ if (!app || !localRoute(input.route)) return false
194
+ const menus = leaves(app.menus ?? [])
195
+ if (menus.some(menu => menu.target?.route === input.route && menu.target.tabId === input.tabId)) return true
196
+ const detail = /^\/detail\?id=([1-9]\d{0,9})$/.exec(input.route)
197
+ return Boolean(detail && session.permissions.includes('demo:read') &&
198
+ menus.some(menu => menu.menuId === 'records' && menu.target?.route === '/list') &&
199
+ input.tabId === `${app.appId}:detail:${detail[1]}`)
200
+ }
201
+
202
+ /**
203
+ * 恢复会话并加载授权菜单。signal 为本次操作取消信号,未登录返回 null,接口/解码错误拒绝 Promise。
204
+ * 演示模式仅按当前标签页的版本标记恢复固定体验身份;真实模式必须等待两个接口均完成。
205
+ */
206
+ export async function restoreSession(signal: AbortSignal): Promise<PlatformSession | 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
+ }
212
+ const value = await request('session', signal)
213
+ return value === null ? null : loadSession(value, signal)
214
+ }
215
+
216
+ /**
217
+ * username/password 为登录表单输入,signal 同时覆盖登录和后续菜单请求。
218
+ * 返回完整平台会话;登录拒绝、菜单失败或取消均拒绝 Promise,不能显示半就绪工作区。
219
+ * 演示仅在开发模式提供固定身份,忽略任何传入账号密码;绝非生产认证,不持久化凭据。
220
+ */
221
+ export async function login(username: string, password: string, signal: AbortSignal): Promise<PlatformSession> {
222
+ if (signal.aborted) throw new DOMException('登录已取消', 'AbortError')
223
+ if (demoMode) return demoSession()
224
+ const value = await request('login', signal, { username, password })
225
+ if (value === null) throw new Error('用户名或密码错误')
226
+ return loadSession(value, signal)
227
+ }
228
+
229
+ /** signal 为注销取消信号,成功返回 void;远程失败拒绝 Promise,但调用方不得恢复旧本地身份。 */
230
+ export async function logout(signal: AbortSignal, previousToken: string | null = null): Promise<void> {
231
+ // 本地凭据已清除;局部旧值仅用于可信注销接口,不重新写回私有存储。
232
+ if (demoMode) clearDemoSession()
233
+ else await request('logout', signal, {}, previousToken)
234
+ }
235
+
236
+ /** 输入当前平台会话,返回 SDK 白名单公开字段;不包含 Token,不暴露后台响应的其他属性。 */
237
+ export function publicSession(session: PlatformSession): SessionInput {
238
+ return { authenticated: true, user: { ...session.user }, permissions: [...session.permissions] }
239
+ }