@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,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,42 @@
1
+ import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
2
+ import { appConfig } from './appConfig'
3
+ import { toSafeError, reportError } from './errorUtils'
4
+
5
+ /**
6
+ * 基座平台接口专属 Axios 请求实例;不与子应用公共 requestUtils.ts 共享可变配置或凭据。
7
+ * Token 由 platform.ts 针对可信平台接口显式传入,不隐式读取子应用 bridge。
8
+ * 默认访问应用部署源的 /api/,开发代理及生产网关由项目部署配置决定。
9
+ * Cookie 仅同源携带;跨域请求须显式配置可信 baseURL、withCredentials 与服务端 CORS。
10
+ * 登录到菜单加载期间使用该次局部 Token,避免认证完成前将候选凭据提交到全局。
11
+ * 业务 code/data 解包、401 后的会话协调由业务适配层负责,本实例不弹窗或跳转。
12
+ */
13
+ export const http = new AxiosHttpClient({
14
+ baseURL: appConfig.apiBase,
15
+ timeout: appConfig.timeout,
16
+ withCredentials: appConfig.withCredentials,
17
+ })
18
+
19
+ /**
20
+ * 为本轮平台会话安装 401 协调;请求发送时捕获宿主私有版本,旧请求不得退出新身份。
21
+ * @param capture 未登录/认证初始化返回 undefined,避免将登录拒绝误判成工作会话失效。
22
+ * @param invalidate 同步复核版本并撤销身份,不等待离页守卫或远程退出。
23
+ * @returns 幂等清理函数,卸载后旧响应不再触发副作用;失败始终保持原始拒绝。
24
+ */
25
+ export function installPlatformExpiry(capture: () => number | undefined, invalidate: (revision: number) => void): () => void {
26
+ let active = true
27
+ const revisions = new WeakMap<object, number>()
28
+ const request = http.interceptors.request.use(config => {
29
+ const revision = capture()
30
+ if (revision !== undefined) revisions.set(config, revision)
31
+ return config
32
+ })
33
+ const response = http.interceptors.response.use(value => value, error => {
34
+ const config = error?.config ?? error?.response?.config
35
+ const revision = config && typeof config === 'object' ? revisions.get(config) : undefined
36
+ if (active && revision !== undefined && toSafeError(error).kind === 'unauthenticated') {
37
+ try { invalidate(revision) } catch (cause) { reportError(cause) }
38
+ }
39
+ return Promise.reject(error)
40
+ })
41
+ return () => { active = false; http.interceptors.request.eject(request); http.interceptors.response.eject(response) }
42
+ }
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ /** 主页只展示当前身份与真实可用入口,不依赖外部子应用,也不编造业务统计。 */
3
+ defineProps<{ userName: string; demo: boolean; pending: boolean }>()
4
+ const emit = defineEmits<{ profile: [] }>()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="home-view" :inert="pending || undefined">
9
+ <section class="welcome-panel"><div><p class="eyebrow">FIN WORKSPACE</p><h1>{{ userName }},欢迎来到 __PROJECT_NAME__</h1><p>这里是宿主本地主页,无需启动子应用即可使用。</p></div></section>
10
+ <el-alert v-if="demo" title="开发演示模式:固定演示身份,无真实认证与业务数据" type="warning" :closable="false" />
11
+ <section class="home-panel">
12
+ <div class="panel-heading"><h2>开始使用工作台</h2><span>本地入口已就绪</span></div>
13
+ <p>通过左侧菜单或顶部搜索打开主页、个人中心;使用页签切换、刷新、关闭当前页或批量关闭。</p>
14
+ <p>业务应用是可选远端入口,需要另行启动可信子应用;连接失败不会影响本地主页与个人中心。</p>
15
+ <el-button type="primary" @click="emit('profile')">查看个人信息</el-button>
16
+ </section>
17
+ <section class="home-panel"><h2>会话与安全边界</h2><p>{{ demo ? '刷新会恢复当前标签页的演示会话及已授权页签;退出登录会清除演示恢复标记和页签。' : '当前身份来自平台接口。刷新会重新验证身份与授权菜单,失败不会切换为演示模式。' }}</p><p>本地页面不签发 Token,真实业务权限与数据访问仍由服务端校验。</p></section>
18
+ </div>
19
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+ import { useRouter, useRoute } from 'vue-router'
4
+ import LoginView from './LoginView.vue'
5
+ import { usePlatformSession } from '../hooks/usePlatformSession'
6
+ import { authorizedRedirect } from '../router/targets'
7
+ import { reportError } from '../utils/errorUtils'
8
+ const controller = usePlatformSession()
9
+ const { session, loading, error, signIn, restore } = controller
10
+ const router = useRouter()
11
+ const route = useRoute()
12
+ watch(session, value => {
13
+ if (value) void router.replace(authorizedRedirect(router, value, route.query.redirect)).catch(reportError)
14
+ })
15
+ </script>
16
+ <template><LoginView :loading="loading" :error="error" :sign-in="signIn" @restore="restore" /></template>
@@ -0,0 +1,88 @@
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) return
16
+ if (demoMode) {
17
+ // 演示不展示或采集账号密码;固定身份只用于本地工作台体验。
18
+ await props.signIn('', '')
19
+ return
20
+ }
21
+ if (!username.value.trim() || !password.value) return
22
+ const credential = password.value
23
+ // 提交即清空密码输入,不在会话或全局状态中保存凭据。
24
+ password.value = ''
25
+ await props.signIn(username.value.trim(), credential)
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <main class="login-page">
31
+ <!-- 仅迁移品牌与表单外观,身份恢复与登录结果仍由上层会话控制器决定。 -->
32
+ <section class="login-brand" aria-label="平台介绍">
33
+ <div class="login-logo"><span class="login-logo-mark" aria-hidden="true">F</span> FIN WORKSPACE</div>
34
+ <div class="login-story"><p class="login-eyebrow">连接业务 · 协同向前</p><h1>一个工作空间,<br />让业务高效运转。</h1><p class="login-description">统一入口、按需协同,为团队打造清晰、可靠的企业工作台。</p>
35
+ <div class="login-features"><div><span aria-hidden="true">01</span><strong>统一业务入口</strong><p>集中访问业务应用,减少重复切换。</p></div><div><span aria-hidden="true">02</span><strong>连续工作体验</strong><p>多页签承载工作上下文,专注当前任务。</p></div></div>
36
+ </div><p class="login-brand-footer">FIN MICRO · 企业应用基座模板</p>
37
+ </section>
38
+ <section class="login-panel" aria-labelledby="login-title"><div class="login-card" :aria-busy="loading">
39
+ <p class="login-eyebrow">欢迎回来</p><h2 id="login-title">登录工作台</h2><p class="login-subtitle">{{ demoMode ? '以固定演示用户体验本地主页,无需账号或密码。' : '使用你的工作账号,继续 __PROJECT_NAME__ 的工作。' }}</p>
40
+ <el-alert v-if="demoMode" title="仅演示登录,不提供生产身份认证" type="warning" :closable="false" />
41
+ <p>{{ demoMode ? '仅开发演示,不连接认证接口、不收集真实密码;刷新仅恢复当前标签页的演示会话。' : '请接入 src/platform.ts 的真实平台接口;认证失败不会降级为演示身份。' }}</p>
42
+ <el-form class="login-form" label-position="top" size="large" @submit.prevent="submit">
43
+ <el-form-item v-if="!demoMode" label="用户名"><el-input v-model="username" autocomplete="username" placeholder="请输入你的工作账号" :disabled="loading" /></el-form-item>
44
+ <el-form-item v-if="!demoMode" label="密码"><el-input v-model="password" type="password" autocomplete="current-password" placeholder="请输入登录密码" show-password :disabled="loading" /></el-form-item>
45
+ <p v-if="error" class="login-error" role="alert">{{ error }}</p>
46
+ <div class="actions">
47
+ <el-button class="login-submit" type="primary" native-type="submit" :loading="loading" :disabled="loading">{{ demoMode ? '演示登录,进入主页' : '登录工作台' }}</el-button>
48
+ <el-button :disabled="loading" @click="emit('restore')">重试恢复会话</el-button>
49
+ </div>
50
+ </el-form>
51
+ <p class="login-help">{{ demoMode ? '生产构建始终使用真实认证,演示标记不具有任何认证效力。' : '如需开通账号或重置密码,请联系企业管理员。' }}</p>
52
+ </div><footer class="login-footer">统一工作空间 · 专注每一步</footer></section>
53
+ </main>
54
+ </template>
55
+
56
+ <style scoped>
57
+ /* 双栏品牌区沿用示例外观;不依赖示例认证或表单实现。 */
58
+ .login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(400px, 1fr); background: #fff; color: #1d2b43; }
59
+ .login-brand { display: flex; flex-direction: column; padding: 42px 64px 28px; background: #f5f7fa; border-right: 1px solid #e6edf6; }
60
+ .login-logo { display: inline-flex; align-items: center; gap: 12px; color: #243d66; font-size: 15px; font-weight: 750; letter-spacing: 1.6px; }
61
+ .login-logo-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; background: #2b62d9; color: #fff; font-size: 24px; }
62
+ .login-story { margin: auto 0; padding: 65px 0; max-width: 480px; }
63
+ .login-eyebrow { color: #526b96; font-size: 13px; font-weight: 600; letter-spacing: 2px; margin: 0 0 16px; }
64
+ .login-story h1 { font-size: clamp(30px, 3.1vw, 46px); line-height: 1.5; letter-spacing: -1px; margin: 0 0 22px; }
65
+ .login-description { font-size: 15px; color: #65758d; line-height: 1.9; max-width: 390px; }
66
+ .login-features { margin-top: 45px; display: grid; gap: 24px; }
67
+ .login-features > div { position: relative; padding-left: 46px; }
68
+ .login-features span { position: absolute; left: 0; top: 0; font-size: 13px; color: #5479bd; background: #e3ecfb; border: 1px solid #d6e3fa; border-radius: 8px; padding: 7px; }
69
+ .login-features strong { font-size: 14px; }
70
+ .login-features p { font-size: 13px; color: #72809a; margin: 7px 0 0; line-height: 1.6; }
71
+ .login-brand-footer { font-size: 11px; color: #8191aa; letter-spacing: 1px; }
72
+ .login-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; padding: 64px 48px 76px; }
73
+ .login-card { width: 100%; max-width: 390px; }
74
+ .login-card h2 { font-size: 30px; margin: 0 0 12px; font-weight: 650; }
75
+ .login-subtitle { color: #7a879b; font-size: 14px; margin: 0 0 30px; line-height: 1.8; }
76
+ .login-card > p:not([class]) { font-size: 12px; color: #72809a; line-height: 1.8; }
77
+ .login-error { padding: 12px 14px; margin-bottom: 18px; border: 1px solid #f4c6c6; border-radius: 8px; background: #fff3f3; color: #b43b3b; font-size: 13px; overflow-wrap: anywhere; }
78
+ .login-form :deep(.el-form-item__label) { font-weight: 500; color: #394a64; }
79
+ .login-form :deep(.el-form-item) { margin-bottom: 24px; }
80
+ .login-form .actions { flex-direction: column; align-items: stretch; }
81
+ .login-form .actions :deep(.el-button + .el-button) { margin-left: 0; }
82
+ .login-submit { width: 100%; --el-button-bg-color: #2b62d9; --el-button-border-color: #2b62d9; border-radius: 7px; letter-spacing: 1px; }
83
+ .login-help { text-align: center; color: #8792a4; font-size: 12px; line-height: 1.8; margin-top: 24px; }
84
+ .login-footer { position: absolute; bottom: 26px; color: #a0a9b8; font-size: 11px; letter-spacing: 1px; }
85
+ @media (max-width: 960px) { .login-brand { padding: 36px; } .login-panel { padding: 48px 32px 70px; } }
86
+ @media (max-width: 720px) { .login-page { grid-template-columns: minmax(0, 1fr); } .login-brand { display: none; } .login-panel { padding: 38px 24px 72px; } .login-card h2 { font-size: 26px; } }
87
+ @media (prefers-reduced-motion: reduce) { .login-page :deep(*) { transition: none !important; } }
88
+ </style>
@@ -0,0 +1 @@
1
+ <template><main><h1>404:页面不存在</h1><p>请检查地址,或返回工作台。</p><RouterLink to="/home">返回主页</RouterLink><RouterLink to="/login">登录页</RouterLink></main></template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import type { PlatformSession } from '../types/platform'
3
+ /** 仅展示平台公开会话字段,不读取、显示或持久化私有凭据。操作交回宿主统一导航与退出守卫。 */
4
+ defineProps<{ session: PlatformSession; demo: boolean; pending: boolean }>()
5
+ const emit = defineEmits<{ home: []; logout: [] }>()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="home-view" :inert="pending || undefined">
10
+ <section class="home-panel">
11
+ <div class="panel-heading"><h1>个人中心</h1><span>{{ demo ? '开发演示身份' : '平台认证身份' }}</span></div>
12
+ <el-alert v-if="demo" title="以下为固定演示资料,不代表真实账号或平台权限" type="warning" :closable="false" />
13
+ <el-descriptions title="公开会话信息" :column="1" border>
14
+ <el-descriptions-item label="显示名称">{{ session.user.name }}</el-descriptions-item>
15
+ <el-descriptions-item label="用户标识">{{ session.user.id }}</el-descriptions-item>
16
+ <el-descriptions-item label="权限标识">{{ session.permissions.join('、') || '未分配业务权限' }}</el-descriptions-item>
17
+ <el-descriptions-item label="会话方式">{{ demo ? '当前标签页演示标记(非 Token)' : '平台真实认证' }}</el-descriptions-item>
18
+ </el-descriptions>
19
+ <p>这里只展示公开身份,不提供未接入的资料编辑、修改密码或其他虚假操作。</p>
20
+ <el-button type="primary" :disabled="pending" @click="emit('home')">回到主页</el-button>
21
+ <el-button :disabled="pending" @click="emit('logout')">退出登录</el-button>
22
+ </section>
23
+ </div>
24
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import HostLayout from '../layout/HostLayout.vue'
5
+ import { usePlatformSession } from '../hooks/usePlatformSession'
6
+ import { reportError } from '../utils/errorUtils'
7
+ const { session, signOut, expire } = usePlatformSession()
8
+ const router = useRouter()
9
+ watch(session, value => { if (!value) void router.replace('/login').catch(reportError) }, { flush: 'sync' })
10
+ </script>
11
+ <template><HostLayout v-if="session" :key="session.user.id" :session="session" @logout="signOut" @expired="expire" /></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,67 @@
1
+ import { createApp } from 'vue'
2
+ import { disposePinia } from 'pinia'
3
+ import ElementPlus from 'element-plus'
4
+ import { connectMicroRouter } from '@axewo/fin-micro'
5
+ import { installBridgeAuth } from './bridge/auth'
6
+ import { reportError, toSafeError, cleanupAll } from './utils/errorUtils'
7
+ import App from './App.vue'
8
+ import { installAppLocale } from './locales'
9
+ import { createBridgeSession } from './bridge/session'
10
+ import { createMicroRouter } from './router'
11
+ import { createAppPinia } from './stores'
12
+ let cleanup: (() => void) | undefined
13
+
14
+ /** 幂等卸载,停止路由握手和会话订阅;晚到的初始化不得再挂载旧实例。 */
15
+ export function unmount(): void { cleanup?.(); cleanup = undefined }
16
+
17
+ /** Wujie 与独立运行共用入口;每次创建全新 router/session,失败清理并展示可重试边界。 */
18
+ export async function mount(): Promise<void> {
19
+ unmount()
20
+ const session = createBridgeSession()
21
+ const { bridge } = session
22
+ const router = createMicroRouter(Boolean(bridge))
23
+ const app = createApp(App, { session })
24
+ const pinia = createAppPinia()
25
+ let stopped = false
26
+ let mounted = false
27
+ let offRouter: (() => void) | undefined
28
+ let offSession: (() => void) | undefined
29
+ let offToken: (() => void) | undefined
30
+ cleanup = () => {
31
+ stopped = true
32
+ // 先取消挂起的 Token 读取并还原 provider,避免旧 bridge 被后续请求复用。
33
+ cleanupAll([
34
+ () => offToken?.(), () => offRouter?.(), () => offSession?.(),
35
+ () => { if (mounted) app.unmount() },
36
+ () => router.options.history.destroy(), () => disposePinia(pinia),
37
+ ])
38
+ }
39
+ try {
40
+ // 每轮挂载安装独立 i18n,重试/重挂载不复用上一轮语言实例。
41
+ app.config.errorHandler = reportError
42
+ installAppLocale(app)
43
+ app.use(ElementPlus)
44
+ // 每轮挂载安装独立 Pinia,不与基座或其他子应用共享状态。
45
+ app.use(pinia)
46
+ app.use(router)
47
+ await router.isReady()
48
+ if (stopped) return
49
+ if (bridge) {
50
+ if (bridge.appId !== '__APP_ID__') throw new Error('宿主 appId 与子应用 __APP_ID__ 配置不一致')
51
+ if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken', 'beginAuthRequest', 'reportAuthFailure'] }).compatible) throw new Error('宿主协议不兼容')
52
+ // 只在已验证的嵌入生命周期安装;独立模式与基座请求不会隐式探测 window bridge。
53
+ offToken = installBridgeAuth(bridge)
54
+ offSession = bridge.subscribeSession(value => { if (!stopped) session.identity.value = value })
55
+ // MicroRouteView 按固定页签快照解析真实路由,不全局 router.replace 污染其他缓存页面。
56
+ offRouter = connectMicroRouter(bridge, { async replace() { if (!stopped) session.status.value = '宿主已连接' } },
57
+ error => { if (!stopped) session.status.value = toSafeError(error).message })
58
+ } else session.status.value = '独立演示:宿主命令不可用,CRUD 数据仅保存在当前页面内存'
59
+ app.mount('#app')
60
+ mounted = true
61
+ } catch (error) {
62
+ if (stopped) return
63
+ unmount()
64
+ // 交由 main 的启动代次保护展示安全边界,不能在卸载后用旧按钮重新挂载。
65
+ throw error
66
+ }
67
+ }
@@ -0,0 +1,23 @@
1
+ import { getToken, TokenError, type AppBridge, type AuthCapabilities } from '@axewo/fin-micro/sdk'
2
+ import { installRequestTokenProvider } from '../utils/requestUtils'
3
+ import { appConfig } from '../utils/appConfig'
4
+
5
+ /**
6
+ * 为本次嵌入生命周期安装正式认证能力;卸载调用返回函数,独立演示不安装。
7
+ * 请求票据先绑定宿主私有版本,再读取 Token,前后复查公开会话;版本变化直接拒绝发送。
8
+ * 401/Token 未登录上报原票据,宿主独立核验对象身份、来源及私有版本;不传任意 revision。
9
+ * 不自动刷新凭据、不重放写请求;可信应用白名单不是 XSS 隔离沙箱。
10
+ */
11
+ export function installBridgeAuth(bridge: AppBridge<AuthCapabilities>): () => void {
12
+ return installRequestTokenProvider(async signal => {
13
+ const before = bridge.getSession()
14
+ const token = await getToken({ bridge, signal })
15
+ if (!before.authenticated || bridge.getSession().revision !== before.revision) throw new TokenError('SESSION_CHANGED')
16
+ return token
17
+ }, appConfig.trustedApiBase, async signal => {
18
+ const before = bridge.getSession()
19
+ const ticket = await bridge.callHostWithOptions('beginAuthRequest', { signal })
20
+ if (bridge.getSession().revision !== before.revision) throw new TokenError('SESSION_CHANGED')
21
+ return () => bridge.callHost('reportAuthFailure', ticket)
22
+ })
23
+ }
@@ -0,0 +1,16 @@
1
+ import { inject, ref, shallowRef, type InjectionKey } from 'vue'
2
+ import { getMicroBridge, type SessionSnapshot, type AuthCapabilities } from '@axewo/fin-micro'
3
+
4
+ /** 每次挂载获取独立会话;仅保存公开身份,不读取宿主 Token,不构造虚假的平台能力。 */
5
+ export function createBridgeSession() {
6
+ return { bridge: getMicroBridge<AuthCapabilities>(), 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,13 @@
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 { createStartupLifecycle } from './utils/errorUtils'
6
+
7
+ // 配置及 bootstrap 静态依赖加载失败也显示安全兜底;卸载取消尚未完成的导入。
8
+ const { mount, unmount } = createStartupLifecycle(() => import('./bootstrap'))
9
+
10
+ // 环境判断、Wujie 回调注册、独立启动和 pagehide 统一由库管理。
11
+ // 业务替换点在 bootstrap:创建/销毁应用,并取消本轮尚未完成的异步初始化。
12
+ const dispose = setupWujieLifecycle({ mount, unmount })
13
+ if (import.meta.hot) import.meta.hot.dispose(dispose)
@@ -0,0 +1,16 @@
1
+ import { createMemoryHistory, createRouter, createWebHashHistory } from 'vue-router'
2
+ import { demoRoutes } from './demo'
3
+
4
+ /** 嵌入模式使用 memory history 不争用宿主 URL;独立模式使用 hash 路由便于静态部署。 */
5
+ export function createMicroRouter(embedded: boolean) {
6
+ return createRouter({
7
+ history: embedded ? createMemoryHistory() : createWebHashHistory(),
8
+ routes: [
9
+ { path: '/', redirect: '/list' },
10
+ { path: '/list', component: () => import('../views/ListView.vue') },
11
+ { path: '/detail', component: () => import('../views/DetailView.vue') },
12
+ ...demoRoutes,
13
+ { path: '/:pathMatch(.*)*', component: () => import('../views/NotFoundView.vue') },
14
+ ],
15
+ })
16
+ }
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useMicroPage, useMicroTab, usePermission } from '@axewo/fin-micro'
5
+ import { useBridgeSession } from '../bridge/session'
6
+ import { toSafeError } from '../utils/errorUtils'
7
+ const { bridge, identity } = useBridgeSession()
8
+ const permission = usePermission(identity)
9
+ const canRead = permission.can('demo:read')
10
+ const tab = bridge ? useMicroTab() : undefined
11
+ const sdk = bridge && tab ? useMicroPage(bridge, tab) : undefined
12
+ const route = useRoute()
13
+ const router = useRouter()
14
+ const draft = ref('')
15
+ const dirty = ref(false)
16
+ const message = ref('')
17
+ // 缓存停用不注销守卫;关闭非活动脏页也必须确认。浏览器退出与 LRU 淘汰仍需业务持久化保护。
18
+ // useMicroPage 在真正卸载时统一 dispose,会释放本 SDK 注册的守卫,无需再登记重复清理钩子。
19
+ sdk?.beforeLeave(() => !dirty.value || window.confirm('存在未保存的演示草稿,确认离开?'))
20
+
21
+ /** 仅演示标记已保存,不宣称写入后台;真实业务必须 await 保存成功后才清除 dirty。 */
22
+ function save(): void {
23
+ if (bridge && !canRead.value) { message.value = '403:无权执行此操作'; return }
24
+ dirty.value = false
25
+ message.value = '演示草稿已标记保存,未写入服务器'
26
+ }
27
+
28
+ /** SDK 固定绑定创建时的原页面,即使等待期间切换页签,也不关闭或刷新其他页面。 */
29
+ async function action(type: 'close' | 'refresh' | 'back'): Promise<void> {
30
+ try {
31
+ if (!sdk) {
32
+ if (type === 'refresh') { draft.value = ''; dirty.value = false }
33
+ else if (!dirty.value || window.confirm('确认丢弃演示草稿?')) await router.push('/list')
34
+ return
35
+ }
36
+ if (type === 'close') await sdk.closeCurrent()
37
+ else if (type === 'refresh') await sdk.refreshCurrent()
38
+ else await sdk.back()
39
+ } catch (error) { message.value = toSafeError(error).message }
40
+ }
41
+ </script>
42
+ <template>
43
+ <section v-if="bridge && !canRead" class="panel" role="alert"><h2>403</h2><p>没有详情访问权限,请联系管理员。</p></section>
44
+ <section v-else class="panel">
45
+ <p v-if="!bridge">独立演示不创建登录身份,仅允许本地草稿操作,不发送受保护请求。</p>
46
+ <h2>详情 {{ route.query.id }}</h2>
47
+ <p>切换宿主页签保留草稿;刷新/关闭只影响本页生命周期。</p>
48
+ <el-input v-model="draft" type="textarea" placeholder="编辑演示草稿" @input="dirty = true" />
49
+ <div class="actions">
50
+ <el-button type="primary" @click="save">标记保存(演示)</el-button>
51
+ <el-button @click="action('refresh')">刷新本页</el-button>
52
+ <el-button @click="action('close')">关闭本页</el-button>
53
+ <el-button @click="action('back')">返回来源</el-button>
54
+ </div>
55
+ <p role="status">{{ dirty ? '存在未保存草稿' : '无未保存草稿' }} · {{ message }}</p>
56
+ </section>
57
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { FCrudTable, type TableConfig } from '@axewo/fin-ui'
5
+ import { useMicroPage, useMicroTab, usePermission } from '@axewo/fin-micro'
6
+ import { useBridgeSession } from '../bridge/session'
7
+ import { toSafeError } from '../utils/errorUtils'
8
+ const { bridge, identity } = useBridgeSession()
9
+ const permission = usePermission(identity)
10
+ const canRead = permission.can('demo:read')
11
+ const router = useRouter()
12
+ // SDK 在 setup 中绑定缓存边界的固定 tab.key;后续异步操作不会误操作另一个活动页。
13
+ const tab = bridge ? useMicroTab() : undefined
14
+ const sdk = bridge && tab ? useMicroPage(bridge, tab) : undefined
15
+ const message = ref('')
16
+ /** 组件异常只展示文本,兼容公开版本未提供强类型事件声明的情况。 */
17
+ function reportError(error: unknown): void { message.value = toSafeError(error).message }
18
+ interface RecordRow { id: number; name: string }
19
+ // 最小内存适配器仅演示组件 API;每个页面独立数据,刷新会重置,生产请替换为真实业务请求。
20
+ let rows: RecordRow[] = [{ id: 1, name: '示例记录' }]
21
+ let nextId = 2
22
+ const tableConfig: TableConfig = {
23
+ rowKey: 'id', title: '示例记录', pagination: false,
24
+ columns: [
25
+ { field: 'id', label: '编号', width: 100 },
26
+ { field: 'name', label: '名称', formItem: { component: 'FInput', required: true, componentProps: { maxlength: 80 } } },
27
+ ],
28
+ requestConfig: { api: async () => ({ list: rows.map(row => ({ ...row })), total: rows.length }) },
29
+ crudConfig: {
30
+ // 由 FCrudTable 统一生成新增/编辑弹框及删除确认,不另造重复表单。
31
+ createApi: async data => { rows.push({ id: nextId++, name: String(data.name) }) },
32
+ updateApi: async data => { rows = rows.map(row => row.id === data.id ? { ...row, name: String(data.name) } : row) },
33
+ deleteApi: async data => { rows = rows.filter(row => row.id !== data.id) },
34
+ onSuccess: () => { message.value = '演示内存数据已更新(刷新后重置)' },
35
+ },
36
+ }
37
+
38
+ /** 同应用详情用页面 SDK 打开宿主页签;独立模式才使用普通 router,错误留在原页面。 */
39
+ async function openDetail(): Promise<void> {
40
+ try {
41
+ if (bridge && !canRead.value) { message.value = '403:无权访问详情'; return }
42
+ if (sdk && bridge) await sdk.openTab({ appId: bridge.appId, tabId: `${bridge.appId}:detail:1`, title: '示例详情 1', route: '/detail?id=1' })
43
+ else await router.push('/detail?id=1')
44
+ } catch (error) { message.value = toSafeError(error).message }
45
+ }
46
+ </script>
47
+ <template>
48
+ <section class="panel">
49
+ <h1>__PROJECT_NAME__</h1>
50
+ <p>以下 CRUD 为内存示例,非生产业务接口;新增、编辑和删除均使用 fin-ui 内置流程。</p>
51
+ <div class="actions"><el-button :disabled="Boolean(bridge) && !canRead" @click="openDetail">打开详情页</el-button></div>
52
+ <p v-if="message" role="status">{{ message }}</p>
53
+ <FCrudTable :table-config="tableConfig" @error="reportError" />
54
+ </section>
55
+ </template>
@@ -0,0 +1 @@
1
+ <template><section class="panel"><h2>页面不存在</h2><p>请检查宿主菜单 route 与 src/router/index.ts 的页面注册配置。</p></section></template>