@axewo/fin-micro 0.0.0-stage → 1.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.
- package/README.md +125 -2
- package/cli/index.mjs +240 -0
- package/dist/browser.cjs +1 -0
- package/dist/browser.mjs +167 -0
- package/dist/core.cjs +1 -0
- package/dist/core.mjs +640 -0
- package/dist/iframe.cjs +1 -0
- package/dist/iframe.mjs +146 -0
- package/dist/index.cjs +1 -0
- package/dist/index.mjs +28 -0
- package/dist/router-view-impl-C1KG6mxu.mjs +76 -0
- package/dist/router-view-impl-CgLEalx4.cjs +1 -0
- package/dist/runtime.cjs +1 -0
- package/dist/runtime.mjs +148 -0
- package/dist/sdk.cjs +1 -0
- package/dist/sdk.mjs +82 -0
- package/dist/types/browser/index.d.ts +42 -0
- package/dist/types/core/index.d.ts +63 -0
- package/dist/types/iframe/index.d.ts +77 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/runtime/index.d.ts +21 -0
- package/dist/types/runtime/lifecycle.d.ts +23 -0
- package/dist/types/sdk/index.d.ts +338 -0
- package/dist/types/vue/index.d.ts +91 -0
- package/dist/types/vue/router-view-impl.d.ts +41 -0
- package/dist/types/vue/router-view.d.ts +42 -0
- package/dist/types/vue/tab-context.d.ts +6 -0
- package/dist/vue.cjs +1 -0
- package/dist/vue.mjs +166 -0
- package/package.json +91 -3
- package/scaffold/base/index.html +12 -0
- package/scaffold/base/package.json.template +29 -0
- package/scaffold/base/src/components/.gitkeep +1 -0
- package/scaffold/base/src/components/AppProviders.vue +25 -0
- package/scaffold/base/src/hooks/.gitkeep +1 -0
- package/scaffold/base/src/layout/.gitkeep +1 -0
- package/scaffold/base/src/locales/.gitkeep +1 -0
- package/scaffold/base/src/locales/index.ts +7 -0
- package/scaffold/base/src/main.ts +14 -0
- package/scaffold/base/src/router/.gitkeep +1 -0
- package/scaffold/base/src/stores/.gitkeep +1 -0
- package/scaffold/base/src/styles/index.css +18 -0
- package/scaffold/base/src/types/.gitkeep +1 -0
- package/scaffold/base/src/utils/.gitkeep +1 -0
- package/scaffold/base/src/utils/requestUtils.ts +123 -0
- package/scaffold/base/src/views/.gitkeep +1 -0
- package/scaffold/base/tsconfig.json +16 -0
- package/scaffold/base/vite.config.ts +8 -0
- package/scaffold/host/.env.example +5 -0
- package/scaffold/host/src/App.vue +16 -0
- package/scaffold/host/src/bootstrap.ts +18 -0
- package/scaffold/host/src/hooks/usePlatformSession.ts +59 -0
- package/scaffold/host/src/layout/HostHeader.vue +9 -0
- package/scaffold/host/src/layout/HostLayout.vue +124 -0
- package/scaffold/host/src/layout/HostMenu.vue +14 -0
- package/scaffold/host/src/layout/HostTabs.vue +14 -0
- package/scaffold/host/src/layout/HostWorkspace.vue +14 -0
- package/scaffold/host/src/platform.ts +208 -0
- package/scaffold/host/src/types/platform.ts +12 -0
- package/scaffold/host/src/utils/request.ts +15 -0
- package/scaffold/host/src/views/LoginView.vue +38 -0
- package/scaffold/vue/src/App.vue +24 -0
- package/scaffold/vue/src/bootstrap.ts +71 -0
- package/scaffold/vue/src/bridge/session.ts +16 -0
- package/scaffold/vue/src/main.ts +10 -0
- package/scaffold/vue/src/router/index.ts +14 -0
- package/scaffold/vue/src/views/DetailView.vue +48 -0
- package/scaffold/vue/src/views/ListView.vue +51 -0
- package/scaffold/vue/src/views/NotFoundView.vue +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
|
2
|
+
import { activateSessionToken, clearAccessToken, getAccessToken, login, logout, restoreSession } from '../platform'
|
|
3
|
+
import type { PlatformSession } from '../types/platform'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 在组件 setup 内创建平台会话控制器,并立即发起恢复;组件卸载时中止请求。
|
|
7
|
+
* @returns 响应式身份/忙碌/错误状态及登录、恢复、退出操作;操作错误写入 error,不向表单抛出。
|
|
8
|
+
* 此处只管理身份请求,导航和页面资源由工作区负责;生产登录/CSRF协议在 platform.ts 替换。
|
|
9
|
+
*/
|
|
10
|
+
export function usePlatformSession() {
|
|
11
|
+
const session = shallowRef<PlatformSession | null>(null)
|
|
12
|
+
const loading = ref(false)
|
|
13
|
+
const error = ref('')
|
|
14
|
+
let request: AbortController | undefined
|
|
15
|
+
let generation = 0
|
|
16
|
+
|
|
17
|
+
/** 每次操作中止上一次请求;卸载与重试后晚到响应不能恢复旧身份。 */
|
|
18
|
+
async function run(action: (signal: AbortSignal) => Promise<PlatformSession | null>): Promise<void> {
|
|
19
|
+
const current = ++generation
|
|
20
|
+
request?.abort()
|
|
21
|
+
// 重新认证/恢复先撤销旧身份及凭据;失败不保留可用的旧 Token。
|
|
22
|
+
clearAccessToken()
|
|
23
|
+
session.value = null
|
|
24
|
+
const controller = new AbortController()
|
|
25
|
+
request = controller
|
|
26
|
+
loading.value = true
|
|
27
|
+
error.value = ''
|
|
28
|
+
try {
|
|
29
|
+
const value = await action(controller.signal)
|
|
30
|
+
if (current === generation && !controller.signal.aborted) {
|
|
31
|
+
activateSessionToken(value)
|
|
32
|
+
session.value = value
|
|
33
|
+
}
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
if (current === generation && !controller.signal.aborted) error.value = String(cause)
|
|
36
|
+
} finally {
|
|
37
|
+
if (current === generation) loading.value = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 输入表单凭据;完成身份与菜单加载后一次性替换会话,不持久化密码。 */
|
|
42
|
+
function signIn(username: string, password: string): Promise<void> {
|
|
43
|
+
return run(signal => login(username, password, signal))
|
|
44
|
+
}
|
|
45
|
+
/** 恢复接口失败保留错误,未登录返回空会话;不会回退为演示身份。 */
|
|
46
|
+
function restore(): Promise<void> {
|
|
47
|
+
return run(restoreSession)
|
|
48
|
+
}
|
|
49
|
+
/** 立即移除工作区触发资源释放;远程注销失败也不恢复本地旧会话。 */
|
|
50
|
+
function signOut(): void {
|
|
51
|
+
const previousToken = getAccessToken()
|
|
52
|
+
session.value = null
|
|
53
|
+
void run(async signal => { await logout(signal, previousToken); return null })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
void restore()
|
|
57
|
+
onBeforeUnmount(() => { generation++; request?.abort(); clearAccessToken(); session.value = null })
|
|
58
|
+
return { session, loading, error, signIn, restore, signOut }
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ userName: string; pending: boolean }>()
|
|
3
|
+
const emit = defineEmits<{ logout: [] }>()
|
|
4
|
+
</script>
|
|
5
|
+
<template>
|
|
6
|
+
<header class="shell-header"><strong>__PROJECT_NAME__</strong><div class="actions">
|
|
7
|
+
<span>{{ userName }}</span><el-button :loading="pending" @click="emit('logout')">退出登录</el-button>
|
|
8
|
+
</div></header>
|
|
9
|
+
</template>
|
|
@@ -0,0 +1,124 @@
|
|
|
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, connectMicroBrowser, useMicroHost, type MicroMenu, type NavigationOperation } from '@axewo/fin-micro'
|
|
5
|
+
import { createTokenCapability, type TokenCapabilities } 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
|
+
const props = defineProps<{ session: PlatformSession }>()
|
|
13
|
+
const emit = defineEmits<{ logout: [] }>()
|
|
14
|
+
const notice = ref('')
|
|
15
|
+
const exiting = ref(false)
|
|
16
|
+
let alive = true
|
|
17
|
+
const report = (error: unknown) => { if (alive) notice.value = String(error) }
|
|
18
|
+
// 认证成功后才创建;只允许当前后台授权并经过可信注册表转换的应用。
|
|
19
|
+
const host = createMicroHost<TokenCapabilities>(props.session.apps, {
|
|
20
|
+
capabilities: {
|
|
21
|
+
getToken: createTokenCapability({
|
|
22
|
+
// 只授权当前已登录且出自本地可信注册表的业务应用,不使用接口传入的任意 appId。
|
|
23
|
+
apps: props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId),
|
|
24
|
+
// exiting 只是离页确认状态;守卫中的保存仍需凭据,确认退出后才由认证控制器清除。
|
|
25
|
+
getSession: () => ({ ...publicSession(props.session), authenticated: alive, revision: getTokenRevision() }),
|
|
26
|
+
// 业务替换点:可改为接收 context.signal 的异步后台读取;helper 会复查会话版本与存活。
|
|
27
|
+
provider: () => getAccessToken(),
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
onError: report,
|
|
31
|
+
canOpen: input => alive && !exiting.value && canOpenRoute(props.session, input),
|
|
32
|
+
// 不开放任意请求代理;Token 仅由上面的显式白名单能力提供,跨应用导航另行制定白名单。
|
|
33
|
+
canNavigate: () => false,
|
|
34
|
+
})
|
|
35
|
+
host.updateSession(publicSession(props.session))
|
|
36
|
+
// 身份与授权菜单就绪后才能恢复 URL;用户隔离存储,禁止恢复未通过 canOpen 的入口。
|
|
37
|
+
const browser = connectMicroBrowser(host, { storageKey: `fin-micro:tabs:${props.session.user.id}`, onError: report })
|
|
38
|
+
const runtime = createWujieRuntime({ apps: host.apps, bridge: host.bridge })
|
|
39
|
+
const state = useMicroHost(host)
|
|
40
|
+
const active = computed(() => state.value.tabs.find(tab => tab.tabId === state.value.activeTabId))
|
|
41
|
+
const menu = ref<MenuInstance>()
|
|
42
|
+
const resetting = ref(false)
|
|
43
|
+
const menuIndex = (appId: string, menuId: string) => JSON.stringify([appId, menuId])
|
|
44
|
+
const selected = computed(() => active.value?.activeMenuId ? menuIndex(active.value.appId, active.value.activeMenuId) : '')
|
|
45
|
+
/** Element 菜单会先选中点击项;重置公开 prop,确保异步守卫拒绝时仍高亮实际活动页。 */
|
|
46
|
+
async function syncMenu(): Promise<void> {
|
|
47
|
+
resetting.value = true
|
|
48
|
+
await nextTick()
|
|
49
|
+
if (alive) resetting.value = false
|
|
50
|
+
}
|
|
51
|
+
/** 只计算菜单祖先,展开交给 Element Plus,不重新实现菜单状态机。 */
|
|
52
|
+
function ancestors(menus: readonly MicroMenu[], id?: string): string[] {
|
|
53
|
+
for (const item of menus) {
|
|
54
|
+
if (item.menuId === id) return [item.menuId]
|
|
55
|
+
const children = ancestors(item.children ?? [], id)
|
|
56
|
+
if (children.length) return [item.menuId, ...children]
|
|
57
|
+
}
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
// 首次渲染也展开恢复页的祖先;后续用户手动展开由 ElMenu 自己维护。
|
|
61
|
+
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))))
|
|
62
|
+
watch(selected, () => {
|
|
63
|
+
const app = host.apps.find(item => item.appId === active.value?.appId)
|
|
64
|
+
if (!app) return
|
|
65
|
+
for (const id of ancestors(app.menus ?? [], active.value?.activeMenuId).slice(0, -1)) menu.value?.open(menuIndex(app.appId, id))
|
|
66
|
+
}, { flush: 'post', immediate: true })
|
|
67
|
+
const epochs = ref<Record<string, number>>({})
|
|
68
|
+
let previous = new Set<string>()
|
|
69
|
+
const off = host.subscribe(snapshot => {
|
|
70
|
+
const present = new Set(snapshot.tabs.map(tab => tab.appId))
|
|
71
|
+
// 同 tick 关闭最后一页后重开,也不能复用已经失效的 bridge 和应用容器。
|
|
72
|
+
for (const appId of previous) if (!present.has(appId)) epochs.value[appId] = (epochs.value[appId] ?? 0) + 1
|
|
73
|
+
previous = present
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/** 所有 UI 导航均走核心事务,携带原 key,避免异步操作误关重开后的新页签。 */
|
|
77
|
+
async function operate(operation: NavigationOperation): Promise<void> {
|
|
78
|
+
if (exiting.value) return
|
|
79
|
+
try {
|
|
80
|
+
const result = await host.operate(operation)
|
|
81
|
+
if (result.status === 'cancelled') notice.value = '操作已取消'
|
|
82
|
+
if (result.status === 'stale') notice.value = '目标页签已变化,请重试'
|
|
83
|
+
} catch (error) { report(error) }
|
|
84
|
+
}
|
|
85
|
+
function open(appId: string, menu: MicroMenu): void {
|
|
86
|
+
if (menu.target) void operate({ type: 'open', input: { appId, ...menu.target, title: menu.name, activeMenuId: menu.menuId } })
|
|
87
|
+
}
|
|
88
|
+
/** 退出先由核心统一确认全部页面;取消不注销,确认后由父组件撤销会话并卸载工作区。 */
|
|
89
|
+
async function signOut(): Promise<void> {
|
|
90
|
+
if (exiting.value) return
|
|
91
|
+
exiting.value = true
|
|
92
|
+
try {
|
|
93
|
+
const result = await host.operate({ type: 'closeMany', scope: 'all' })
|
|
94
|
+
if (result.status === 'committed' || result.status === 'noop') emit('logout')
|
|
95
|
+
else notice.value = '退出已取消,请先处理未保存页面'
|
|
96
|
+
} catch (error) { report(error) }
|
|
97
|
+
finally { exiting.value = false }
|
|
98
|
+
}
|
|
99
|
+
onBeforeUnmount(() => {
|
|
100
|
+
alive = false
|
|
101
|
+
off()
|
|
102
|
+
browser.dispose()
|
|
103
|
+
// 先撤销公开身份,再释放沙箱和核心;立即 dispose 阻止任何旧 SDK 发起新动作。
|
|
104
|
+
host.updateSession({ authenticated: false, permissions: [] })
|
|
105
|
+
void runtime.dispose().catch(error => console.error('子应用资源释放失败', error))
|
|
106
|
+
host.dispose()
|
|
107
|
+
})
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<div class="shell">
|
|
112
|
+
<HostHeader :user-name="session.user.name" :pending="exiting" @logout="signOut" />
|
|
113
|
+
<aside class="sidebar" :inert="exiting || undefined"><el-menu ref="menu" :default-active="resetting ? '__reset__' : selected" :default-openeds="defaultOpeneds" @select="syncMenu">
|
|
114
|
+
<template v-for="app in host.apps" :key="app.appId">
|
|
115
|
+
<HostMenu :app-id="app.appId" :menus="app.menus ?? []" @open="open" />
|
|
116
|
+
</template>
|
|
117
|
+
</el-menu></aside>
|
|
118
|
+
<main class="workspace">
|
|
119
|
+
<HostTabs :tabs="state.tabs" :active-key="active?.key" @operate="operate" />
|
|
120
|
+
<p v-if="notice" class="feedback" role="status">{{ notice }}</p>
|
|
121
|
+
<HostWorkspace :apps="host.apps" :tabs="state.tabs" :active="active" :runtime="runtime" :epochs="epochs" :pending="exiting" />
|
|
122
|
+
</main>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MicroMenu } from '@axewo/fin-micro'
|
|
3
|
+
defineProps<{ appId: string; menus: readonly MicroMenu[] }>()
|
|
4
|
+
const emit = defineEmits<{ open: [appId: string, menu: MicroMenu] }>()
|
|
5
|
+
</script>
|
|
6
|
+
<template>
|
|
7
|
+
<template v-for="menu in menus" :key="JSON.stringify([appId, menu.menuId])">
|
|
8
|
+
<el-sub-menu v-if="menu.children?.length" :index="JSON.stringify([appId, menu.menuId])">
|
|
9
|
+
<template #title>{{ menu.name }}</template>
|
|
10
|
+
<HostMenu :app-id="appId" :menus="menu.children" @open="(id, item) => emit('open', id, item)" />
|
|
11
|
+
</el-sub-menu>
|
|
12
|
+
<el-menu-item v-else-if="menu.target" :index="JSON.stringify([appId, menu.menuId])" @click="emit('open', appId, menu)">{{ menu.name }}</el-menu-item>
|
|
13
|
+
</template>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MicroTab, NavigationOperation } from '@axewo/fin-micro'
|
|
3
|
+
defineProps<{ tabs: readonly MicroTab[]; activeKey?: string }>()
|
|
4
|
+
const emit = defineEmits<{ operate: [operation: NavigationOperation] }>()
|
|
5
|
+
</script>
|
|
6
|
+
<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>
|
|
12
|
+
</div>
|
|
13
|
+
</nav>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
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)))
|
|
7
|
+
</script>
|
|
8
|
+
<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" />
|
|
13
|
+
</section>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { MicroApp, MicroMenu, OpenTab, SessionInput } from '@axewo/fin-micro'
|
|
2
|
+
import { http } from './utils/request'
|
|
3
|
+
|
|
4
|
+
import type { PlatformSession } from './types/platform'
|
|
5
|
+
|
|
6
|
+
// 仅驻留基座私有模块内存;不写 localStorage、会话快照、URL 或事件总线。
|
|
7
|
+
let accessToken: string | null = null
|
|
8
|
+
let tokenRevision = 0
|
|
9
|
+
const pendingTokens = new WeakMap<PlatformSession, string | null>()
|
|
10
|
+
/** 返回基座当前私有凭据;null 表示无可读 Token,不单独代表用户已登录。不得记录或广播结果。 */
|
|
11
|
+
export function getAccessToken(): string | null { return accessToken }
|
|
12
|
+
/** 返回凭据版本;每次提交/清除均递增,供异步能力交付前判断身份或凭据是否变化。 */
|
|
13
|
+
export function getTokenRevision(): number { return tokenRevision }
|
|
14
|
+
/**
|
|
15
|
+
* 提交基座私有内存凭据并使旧版本失效;不持久化,也不建立登录身份。
|
|
16
|
+
* @param value 最新访问 Token 或 null;刷新成功后同样调用,调用方须先验证认证操作仍有效。
|
|
17
|
+
* @throws 格式无效时拒绝且不修改当前值,错误不回显凭据。
|
|
18
|
+
*/
|
|
19
|
+
export function setAccessToken(value: string | null): void {
|
|
20
|
+
if (value !== null && (typeof value !== 'string' || !value || value.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(value))) throw new Error('平台 Token 格式错误')
|
|
21
|
+
accessToken = value
|
|
22
|
+
tokenRevision++
|
|
23
|
+
}
|
|
24
|
+
/** 清除本地凭据并递增版本;不替代服务端注销,已交付到可信子应用的凭据无法直接撤回。 */
|
|
25
|
+
export function clearAccessToken(): void { setAccessToken(null) }
|
|
26
|
+
/** 只在 usePlatformSession 的 generation 验证通过后提交候选凭据,晚到响应不会覆盖新用户。 */
|
|
27
|
+
export function activateSessionToken(session: PlatformSession | null): void {
|
|
28
|
+
setAccessToken(session ? pendingTokens.get(session) ?? null : null)
|
|
29
|
+
if (session) pendingTokens.delete(session)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 可配置部署注册表,不依赖项目生成顺序;实际子应用 appId 必须与此配置一致。
|
|
33
|
+
const appId = import.meta.env.VITE_MICRO_APP_ID || 'business'
|
|
34
|
+
const registry: MicroApp[] = [{
|
|
35
|
+
appId, name: '业务应用', entry: import.meta.env.VITE_MICRO_APP_ENTRY || 'http://localhost:7101',
|
|
36
|
+
}]
|
|
37
|
+
const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', children: [
|
|
38
|
+
{ menuId: 'records', name: '示例记录', target: { tabId: `${appId}:list`, route: '/list' } },
|
|
39
|
+
] }]
|
|
40
|
+
|
|
41
|
+
/** 仅在开发且显式启用时允许内存演示登录;任何真实接口失败均不会回退此身份。 */
|
|
42
|
+
export const demoMode = import.meta.env.DEV && import.meta.env.VITE_AUTH_DEMO === 'true'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 同源平台接口边界,signal 必须沿用当前认证操作的取消信号,body 存在时发送 POST。
|
|
46
|
+
* 返回未解码 JSON;401 返回 null,其他错误或网络失败拒绝 Promise。
|
|
47
|
+
* 默认采用 HttpOnly Cookie;仅 menus/logout 显式传入该次局部 Token,上线前接入后台自己的 CSRF 协议。
|
|
48
|
+
*/
|
|
49
|
+
async function request(path: string, signal: AbortSignal, body?: Record<string, unknown>, token: string | null = null): Promise<unknown> {
|
|
50
|
+
const headers = token === null ? undefined : { Authorization: `Bearer ${token}` }
|
|
51
|
+
try {
|
|
52
|
+
// 平台接口只发送 JSON 对象,编码、超时和取消由 fin-core 统一实现。
|
|
53
|
+
const value = body === undefined
|
|
54
|
+
? await http.get<unknown>(path, { signal, headers })
|
|
55
|
+
: await http.post<unknown>(path, body, { signal, headers })
|
|
56
|
+
return value ?? null
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// 只有身份协议将 401 解释为未登录;通用请求类不擅自改变认证或导航。
|
|
59
|
+
if (typeof error === 'object' && error !== null && 'isAxiosError' in error && error.isAxiosError === true &&
|
|
60
|
+
'response' in error && typeof error.response === 'object' && error.response !== null &&
|
|
61
|
+
'status' in error.response && error.response.status === 401) return null
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
68
|
+
}
|
|
69
|
+
function text(value: unknown, limit: number): value is string {
|
|
70
|
+
return typeof value === 'string' && !!value.trim() && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value)
|
|
71
|
+
}
|
|
72
|
+
function identifier(value: unknown): value is string {
|
|
73
|
+
// CLI 的 appId 最长 214 字符,派生 tabId 还包含业务后缀;与 core 的 500 字符标识边界一致。
|
|
74
|
+
return text(value, 500) && /^[a-zA-Z0-9:_-]+$/.test(value)
|
|
75
|
+
}
|
|
76
|
+
function localRoute(value: unknown): value is string {
|
|
77
|
+
return text(value, 1000) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 只读取白名单身份字段;拒绝未知 appId,不允许接口提供 entry 或代码导入路径。 */
|
|
81
|
+
function decodeIdentity(value: unknown): Omit<PlatformSession, 'apps'> & { appIds: string[] } {
|
|
82
|
+
if (!isObject(value) || !isObject(value.user) || !text(value.user.id, 120) || !text(value.user.name, 120) ||
|
|
83
|
+
!Array.isArray(value.permissions) || !value.permissions.every(item => text(item, 120)) ||
|
|
84
|
+
!Array.isArray(value.appIds) || !value.appIds.every(identifier)) {
|
|
85
|
+
throw new Error('平台身份格式错误,预期 {user:{id,name},permissions:string[],appIds:string[]}')
|
|
86
|
+
}
|
|
87
|
+
const ids = value.appIds as string[]
|
|
88
|
+
if (new Set(ids).size !== ids.length || ids.some(id => !registry.some(app => app.appId === id))) {
|
|
89
|
+
throw new Error('平台返回重复或未在可信注册表登记的 appId,请检查部署配置')
|
|
90
|
+
}
|
|
91
|
+
return { user: { id: value.user.id, name: value.user.name }, permissions: [...value.permissions] as string[], appIds: ids }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 解码后台递归菜单,只复制 SDK 的 menuId/name/children/target 四类字段。
|
|
96
|
+
* 限制深度和数量,并检查应用内 menuId、宿主内 tabId 唯一;不接受任意 entry 或 HTML。
|
|
97
|
+
* 分组与叶节点互斥;格式错误直接拒绝整次初始化,避免静默扩大或误缩权限。
|
|
98
|
+
*/
|
|
99
|
+
function decodeMenus(value: unknown, tabIds: Set<string>): MicroMenu[] {
|
|
100
|
+
const menuIds = new Set<string>()
|
|
101
|
+
let count = 0
|
|
102
|
+
function walk(items: unknown, depth: number): MicroMenu[] {
|
|
103
|
+
if (!Array.isArray(items) || depth > 8) throw new Error('菜单格式错误或嵌套超过 8 层')
|
|
104
|
+
return items.map(item => {
|
|
105
|
+
if (++count > 500 || !isObject(item) || !identifier(item.menuId) || !text(item.name, 80) || menuIds.has(item.menuId)) {
|
|
106
|
+
throw new Error('菜单超出数量限制或 menuId/name 无效、重复')
|
|
107
|
+
}
|
|
108
|
+
menuIds.add(item.menuId)
|
|
109
|
+
if (item.children !== undefined) {
|
|
110
|
+
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) }
|
|
112
|
+
}
|
|
113
|
+
if (!isObject(item.target) || !identifier(item.target.tabId) || !localRoute(item.target.route) || tabIds.has(item.target.tabId)) {
|
|
114
|
+
throw new Error('菜单入口 target 无效或 tabId 在宿主内重复')
|
|
115
|
+
}
|
|
116
|
+
tabIds.add(item.target.tabId)
|
|
117
|
+
return { menuId: item.menuId, name: item.name, target: { tabId: item.target.tabId, route: item.target.route } }
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
return walk(value, 1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 身份成功后再加载 /api/menus,明确协议为 {apps:[{appId,menus:MicroMenu[]}]}。
|
|
125
|
+
* 只接受当前身份 appIds 内的应用,名称和 entry 始终采用本地可信注册表。
|
|
126
|
+
* 接口应返回全部已授权应用(没有菜单可传空数组);缺失、401 或解码失败均拒绝挂载宿主。
|
|
127
|
+
*/
|
|
128
|
+
async function loadSession(value: unknown, signal: AbortSignal): Promise<PlatformSession> {
|
|
129
|
+
const identity = decodeIdentity(value)
|
|
130
|
+
// 该次认证的局部凭据只用于可信平台菜单接口;此时尚未提交全局,不会污染新一代登录。
|
|
131
|
+
const token = isObject(value) && value.accessToken !== undefined ? value.accessToken : null
|
|
132
|
+
if (token !== null && (typeof token !== 'string' || !token || token.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(token))) throw new Error('平台 Token 格式错误')
|
|
133
|
+
const payload = await request('menus', signal, undefined, token)
|
|
134
|
+
if (!isObject(payload) || !Array.isArray(payload.apps)) throw new Error('平台菜单加载失败,预期 /api/menus 返回 {apps:[{appId,menus}]}')
|
|
135
|
+
const seen = new Set<string>()
|
|
136
|
+
const tabIds = new Set<string>()
|
|
137
|
+
const apps: MicroApp[] = payload.apps.map(item => {
|
|
138
|
+
if (!isObject(item) || !identifier(item.appId) || !identity.appIds.includes(item.appId) || seen.has(item.appId)) {
|
|
139
|
+
throw new Error('菜单接口包含未授权或重复 appId')
|
|
140
|
+
}
|
|
141
|
+
seen.add(item.appId)
|
|
142
|
+
const registered = registry.find(app => app.appId === item.appId)!
|
|
143
|
+
const menus = decodeMenus(item.menus, tabIds)
|
|
144
|
+
const hasRecords = leaves(menus).some(menu => menu.menuId === 'records' && menu.target?.route === '/list')
|
|
145
|
+
return { ...registered, menus, menuRoutes: hasRecords ? [{ pathPrefix: '/detail', menuId: 'records' }] : [] }
|
|
146
|
+
})
|
|
147
|
+
if (seen.size !== identity.appIds.length) throw new Error('菜单接口遗漏已授权应用,请检查 /api/menus')
|
|
148
|
+
// signal 与上层 generation 双重隔离晚到结果;已取消操作不得创建新会话。
|
|
149
|
+
if (signal.aborted) throw new DOMException('会话加载已取消', 'AbortError')
|
|
150
|
+
// 后台可选 accessToken;省略保持默认 HttpOnly Cookie 模式,不生成演示 Token。
|
|
151
|
+
const session: PlatformSession = { user: identity.user, permissions: identity.permissions, apps }
|
|
152
|
+
pendingTokens.set(session, token)
|
|
153
|
+
return session
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function leaves(menus: readonly MicroMenu[]): MicroMenu[] {
|
|
157
|
+
return menus.flatMap(menu => menu.children ? leaves(menu.children) : [menu])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 宿主统一导航授权入口。session 为完成菜单加载的当前会话,input 为待打开的页签元信息。
|
|
162
|
+
* 返回 true 仅代表前端准入:普通入口须精确匹配已授权菜单的 route/tabId;不根据 URL 猜测权限。
|
|
163
|
+
* 详情是显式演示业务规则:必须存在 records(/list) 菜单并具有 demo:read,且 ID 和 tabId 匹配。
|
|
164
|
+
* 新业务详情应替换本规则并在后台验证真实权限,不得笼统放行任意 /detail。
|
|
165
|
+
*/
|
|
166
|
+
export function canOpenRoute(session: PlatformSession, input: OpenTab): boolean {
|
|
167
|
+
const app = session.apps.find(item => item.appId === input.appId)
|
|
168
|
+
if (!app || !localRoute(input.route)) return false
|
|
169
|
+
const menus = leaves(app.menus ?? [])
|
|
170
|
+
if (menus.some(menu => menu.target?.route === input.route && menu.target.tabId === input.tabId)) return true
|
|
171
|
+
const detail = /^\/detail\?id=([1-9]\d{0,9})$/.exec(input.route)
|
|
172
|
+
return Boolean(detail && session.permissions.includes('demo:read') &&
|
|
173
|
+
menus.some(menu => menu.menuId === 'records' && menu.target?.route === '/list') &&
|
|
174
|
+
input.tabId === `${app.appId}:detail:${detail[1]}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 恢复会话并加载授权菜单。signal 为本次操作取消信号,未登录返回 null,接口/解码错误拒绝 Promise。
|
|
179
|
+
* 演示模式不自动恢复身份;真实模式必须等待两个接口均完成后才能创建宿主。
|
|
180
|
+
*/
|
|
181
|
+
export async function restoreSession(signal: AbortSignal): Promise<PlatformSession | null> {
|
|
182
|
+
if (demoMode) return null
|
|
183
|
+
const value = await request('session', signal)
|
|
184
|
+
return value === null ? null : loadSession(value, signal)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* username/password 为登录表单输入,signal 同时覆盖登录和后续菜单请求。
|
|
189
|
+
* 返回完整平台会话;登录拒绝、菜单失败或取消均拒绝 Promise,不能显示半就绪工作区。
|
|
190
|
+
* 演示仅在显式开发开关下提供内存身份,绝非生产认证,不持久化密码或凭据。
|
|
191
|
+
*/
|
|
192
|
+
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' }] })) }
|
|
194
|
+
const value = await request('login', signal, { username, password })
|
|
195
|
+
if (value === null) throw new Error('用户名或密码错误')
|
|
196
|
+
return loadSession(value, signal)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** signal 为注销取消信号,成功返回 void;远程失败拒绝 Promise,但调用方不得恢复旧本地身份。 */
|
|
200
|
+
export async function logout(signal: AbortSignal, previousToken: string | null = null): Promise<void> {
|
|
201
|
+
// 本地凭据已清除;局部旧值仅用于可信注销接口,不重新写回私有存储。
|
|
202
|
+
if (!demoMode) await request('logout', signal, {}, previousToken)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 输入当前平台会话,返回 SDK 白名单公开字段;不包含 Token,不暴露后台响应的其他属性。 */
|
|
206
|
+
export function publicSession(session: PlatformSession): SessionInput {
|
|
207
|
+
return { authenticated: true, user: { ...session.user }, permissions: [...session.permissions] }
|
|
208
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MicroApp } from '@axewo/fin-micro'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 完成身份及菜单加载后的平台会话,仅包含可公开字段。
|
|
5
|
+
* apps 的 entry 来自本地可信注册表,menus 来自已解码的后台授权菜单;不保存 Token。
|
|
6
|
+
* 此对象只能用于前端编排,不能替代服务端对每个业务请求的认证和权限校验。
|
|
7
|
+
*/
|
|
8
|
+
export interface PlatformSession {
|
|
9
|
+
user: { id: string; name: string }
|
|
10
|
+
permissions: string[]
|
|
11
|
+
apps: MicroApp[]
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 基座平台接口专属 Axios 请求实例;不与子应用公共 requestUtils.ts 共享可变配置或凭据。
|
|
5
|
+
* Token 由 platform.ts 针对可信平台接口显式传入,不隐式读取子应用 bridge。
|
|
6
|
+
* 默认访问应用部署源的 /api/,开发代理及生产网关由项目部署配置决定。
|
|
7
|
+
* Cookie 仅同源携带;跨域请求须显式配置可信 baseURL、withCredentials 与服务端 CORS。
|
|
8
|
+
* 登录到菜单加载期间使用该次局部 Token,避免认证完成前将候选凭据提交到全局。
|
|
9
|
+
* 业务 code/data 解包、401 后的会话协调由业务适配层负责,本实例不弹窗或跳转。
|
|
10
|
+
*/
|
|
11
|
+
export const http = new AxiosHttpClient({
|
|
12
|
+
baseURL: '/api/',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
withCredentials: false,
|
|
15
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { demoMode } from '../platform'
|
|
4
|
+
|
|
5
|
+
// 表单只保留本地输入;会话恢复、取消与错误状态统一由上层会话组合函数管理。
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
loading: boolean
|
|
8
|
+
error: string
|
|
9
|
+
signIn: (username: string, password: string) => Promise<void>
|
|
10
|
+
}>()
|
|
11
|
+
const emit = defineEmits<{ restore: [] }>()
|
|
12
|
+
const username = ref('')
|
|
13
|
+
const password = ref('')
|
|
14
|
+
async function submit(): Promise<void> {
|
|
15
|
+
if (props.loading || !username.value.trim() || (!demoMode && !password.value)) return
|
|
16
|
+
const credential = password.value
|
|
17
|
+
// 提交即清空密码输入,不在会话或全局状态中保存凭据。
|
|
18
|
+
password.value = ''
|
|
19
|
+
await props.signIn(username.value.trim(), credential)
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<main class="login panel">
|
|
25
|
+
<h1>__PROJECT_NAME__</h1>
|
|
26
|
+
<el-alert v-if="demoMode" title="仅演示登录,不提供生产身份认证" type="warning" :closable="false" />
|
|
27
|
+
<p>请接入 src/platform.ts 的平台接口;默认不启用演示身份。</p>
|
|
28
|
+
<el-form label-position="top" @submit.prevent="submit">
|
|
29
|
+
<el-form-item label="用户名"><el-input v-model="username" autocomplete="username" /></el-form-item>
|
|
30
|
+
<el-form-item v-if="!demoMode" label="密码"><el-input v-model="password" type="password" autocomplete="current-password" /></el-form-item>
|
|
31
|
+
<p v-if="error" role="alert">{{ error }}</p>
|
|
32
|
+
<div class="actions">
|
|
33
|
+
<el-button type="primary" native-type="submit" :loading="loading">登录</el-button>
|
|
34
|
+
<el-button :disabled="loading" @click="emit('restore')">重试恢复会话</el-button>
|
|
35
|
+
</div>
|
|
36
|
+
</el-form>
|
|
37
|
+
</main>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, provide } from 'vue'
|
|
3
|
+
import AppProviders from './components/AppProviders.vue'
|
|
4
|
+
import { RouterView } from 'vue-router'
|
|
5
|
+
import { MicroTabCache, MicroRouteView, useMicroHost } from '@axewo/fin-micro'
|
|
6
|
+
import { sessionKey, type BridgeSession } from './bridge/session'
|
|
7
|
+
const props = defineProps<{ session: BridgeSession }>()
|
|
8
|
+
provide(sessionKey, props.session)
|
|
9
|
+
const { bridge, status, identity } = props.session
|
|
10
|
+
const state = bridge ? useMicroHost(bridge) : undefined
|
|
11
|
+
const tabs = computed(() => state?.value.tabs.filter(tab => tab.appId === bridge?.appId) ?? [])
|
|
12
|
+
const activeKey = computed(() => state?.value.tabs.find(tab => tab.tabId === state.value.activeTabId)?.key)
|
|
13
|
+
</script>
|
|
14
|
+
<template>
|
|
15
|
+
<AppProviders>
|
|
16
|
+
<main class="page">
|
|
17
|
+
<small role="status">{{ status }} · {{ identity?.user?.name ?? '未连接公开身份' }}</small>
|
|
18
|
+
<!-- 缓存以核心 tab.key 为身份;关闭、刷新和淘汰销毁实例,切换只停用。
|
|
19
|
+
LRU 不是持久化,重要草稿需要业务另行保存,不能依赖守卫阻止浏览器刷新。 -->
|
|
20
|
+
<MicroTabCache v-if="bridge && state" :tabs="tabs" :active-key="activeKey" :max="20"><MicroRouteView /></MicroTabCache>
|
|
21
|
+
<RouterView v-else />
|
|
22
|
+
</main>
|
|
23
|
+
</AppProviders>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import ElementPlus from 'element-plus'
|
|
3
|
+
import { connectMicroRouter } from '@axewo/fin-micro'
|
|
4
|
+
import { getToken } from '@axewo/fin-micro/sdk'
|
|
5
|
+
import { installRequestTokenProvider } from './utils/requestUtils'
|
|
6
|
+
import App from './App.vue'
|
|
7
|
+
import { installAppLocale } from './locales'
|
|
8
|
+
import { createBridgeSession } from './bridge/session'
|
|
9
|
+
import { createMicroRouter } from './router'
|
|
10
|
+
let cleanup: (() => void) | undefined
|
|
11
|
+
|
|
12
|
+
/** 幂等卸载,停止路由握手和会话订阅;晚到的初始化不得再挂载旧实例。 */
|
|
13
|
+
export function unmount(): void { cleanup?.(); cleanup = undefined }
|
|
14
|
+
|
|
15
|
+
/** Wujie 与独立运行共用入口;每次创建全新 router/session,失败清理并展示可重试边界。 */
|
|
16
|
+
export async function mount(): Promise<void> {
|
|
17
|
+
unmount()
|
|
18
|
+
const session = createBridgeSession()
|
|
19
|
+
const { bridge } = session
|
|
20
|
+
const router = createMicroRouter(Boolean(bridge))
|
|
21
|
+
const app = createApp(App, { session })
|
|
22
|
+
let stopped = false
|
|
23
|
+
let mounted = false
|
|
24
|
+
let offRouter: (() => void) | undefined
|
|
25
|
+
let offSession: (() => void) | undefined
|
|
26
|
+
let offToken: (() => void) | undefined
|
|
27
|
+
cleanup = () => {
|
|
28
|
+
stopped = true
|
|
29
|
+
// 先取消挂起的 Token 读取并还原 provider,避免旧 bridge 被后续请求复用。
|
|
30
|
+
offToken?.()
|
|
31
|
+
offRouter?.()
|
|
32
|
+
offSession?.()
|
|
33
|
+
if (mounted) app.unmount()
|
|
34
|
+
// 初始化在 app.mount 前取消/失败时,Vue 尚未接管销毁,仍须释放 history 的浏览器监听。
|
|
35
|
+
router.options.history.destroy()
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
// 每轮挂载安装独立 i18n,重试/重挂载不复用上一轮语言实例。
|
|
39
|
+
installAppLocale(app)
|
|
40
|
+
app.use(ElementPlus)
|
|
41
|
+
app.use(router)
|
|
42
|
+
await router.isReady()
|
|
43
|
+
if (stopped) return
|
|
44
|
+
if (bridge) {
|
|
45
|
+
if (bridge.appId !== '__APP_ID__') throw new Error('宿主 appId 与子应用 __APP_ID__ 配置不一致')
|
|
46
|
+
if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken'] }).compatible) throw new Error('宿主协议不兼容')
|
|
47
|
+
// 只在已验证的嵌入生命周期安装;独立模式与基座请求不会隐式探测 window bridge。
|
|
48
|
+
offToken = installRequestTokenProvider(signal => getToken({ bridge, signal }))
|
|
49
|
+
offSession = bridge.subscribeSession(value => { if (!stopped) session.identity.value = value })
|
|
50
|
+
// MicroRouteView 按固定页签快照解析真实路由,不全局 router.replace 污染其他缓存页面。
|
|
51
|
+
offRouter = connectMicroRouter(bridge, { async replace() { if (!stopped) session.status.value = '宿主已连接' } },
|
|
52
|
+
error => { if (!stopped) session.status.value = String(error) })
|
|
53
|
+
} else session.status.value = '独立演示:宿主命令不可用,CRUD 数据仅保存在当前页面内存'
|
|
54
|
+
app.mount('#app')
|
|
55
|
+
mounted = true
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (stopped) return
|
|
58
|
+
unmount()
|
|
59
|
+
const container = document.getElementById('app')
|
|
60
|
+
if (container) {
|
|
61
|
+
// 不插入错误 HTML,避免异常消息进入 innerHTML;重试只重建当前应用。
|
|
62
|
+
container.replaceChildren()
|
|
63
|
+
const text = document.createElement('p')
|
|
64
|
+
text.textContent = `应用启动失败:${String(error)}`
|
|
65
|
+
const retry = document.createElement('button')
|
|
66
|
+
retry.textContent = '重试启动'
|
|
67
|
+
retry.onclick = () => { void mount() }
|
|
68
|
+
container.append(text, retry)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { inject, ref, shallowRef, type InjectionKey } from 'vue'
|
|
2
|
+
import { getMicroBridge, type SessionSnapshot } from '@axewo/fin-micro'
|
|
3
|
+
|
|
4
|
+
/** 每次挂载获取独立会话;仅保存公开身份,不读取宿主 Token,不构造虚假的平台能力。 */
|
|
5
|
+
export function createBridgeSession() {
|
|
6
|
+
return { bridge: getMicroBridge(), identity: shallowRef<SessionSnapshot>(), status: ref('正在连接宿主') }
|
|
7
|
+
}
|
|
8
|
+
export type BridgeSession = ReturnType<typeof createBridgeSession>
|
|
9
|
+
export const sessionKey: InjectionKey<BridgeSession> = Symbol('micro-session')
|
|
10
|
+
|
|
11
|
+
/** 页面 setup 内调用,返回本次挂载的会话;独立预览 bridge 为 undefined。 */
|
|
12
|
+
export function useBridgeSession(): BridgeSession {
|
|
13
|
+
const session = inject(sessionKey)
|
|
14
|
+
if (!session) throw new Error('页面缺少应用会话')
|
|
15
|
+
return session
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import 'element-plus/dist/index.css'
|
|
2
|
+
import '@axewo/fin-ui/style.css'
|
|
3
|
+
import './styles/index.css'
|
|
4
|
+
import { setupWujieLifecycle } from '@axewo/fin-micro/runtime'
|
|
5
|
+
import { mount, unmount } from './bootstrap'
|
|
6
|
+
|
|
7
|
+
// 环境判断、Wujie 回调注册、独立启动和 unload 统一由库管理。
|
|
8
|
+
// 业务替换点在 bootstrap:创建/销毁应用,并取消本轮尚未完成的异步初始化。
|
|
9
|
+
const dispose = setupWujieLifecycle({ mount, unmount })
|
|
10
|
+
if (import.meta.hot) import.meta.hot.dispose(dispose)
|