@axewo/fin-micro 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +181 -4
  2. package/cli/index.mjs +22 -6
  3. package/dist/browser.cjs +1 -1
  4. package/dist/browser.mjs +68 -66
  5. package/dist/core.cjs +1 -1
  6. package/dist/core.mjs +459 -441
  7. package/dist/index.cjs +1 -1
  8. package/dist/index.mjs +31 -24
  9. package/dist/layout.cjs +1 -0
  10. package/dist/layout.css +1 -0
  11. package/dist/layout.mjs +719 -0
  12. package/dist/{router-view-impl-C1KG6mxu.mjs → router-view-impl-B1NzCGKV.mjs} +18 -18
  13. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  14. package/dist/runtime.cjs +1 -1
  15. package/dist/runtime.mjs +40 -39
  16. package/dist/sdk.cjs +1 -1
  17. package/dist/sdk.mjs +92 -56
  18. package/dist/types/browser/index.d.ts +4 -0
  19. package/dist/types/core/index.d.ts +1 -1
  20. package/dist/types/index.d.ts +2 -0
  21. package/dist/types/layout/HostShell.vue.d.ts +528 -0
  22. package/dist/types/layout/ShellHeader.vue.d.ts +46 -0
  23. package/dist/types/layout/ShellMenu.vue.d.ts +12 -0
  24. package/dist/types/layout/ShellTabs.vue.d.ts +23 -0
  25. package/dist/types/layout/StandaloneMenuItem.vue.d.ts +11 -0
  26. package/dist/types/layout/StandaloneShell.vue.d.ts +40 -0
  27. package/dist/types/layout/index.d.ts +5 -0
  28. package/dist/types/layout/types.d.ts +65 -0
  29. package/dist/types/runtime/lifecycle.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +65 -4
  31. package/dist/types/vue/index.d.ts +13 -2
  32. package/dist/types/vue/router-view-impl.d.ts +1 -1
  33. package/dist/types/watermark/FinWatermark.vue.d.ts +16 -0
  34. package/dist/types/watermark/config.d.ts +36 -0
  35. package/dist/types/watermark/index.d.ts +4 -0
  36. package/dist/vue.cjs +1 -1
  37. package/dist/vue.mjs +49 -35
  38. package/dist/watermark.cjs +2 -0
  39. package/dist/watermark.mjs +119 -0
  40. package/package.json +29 -4
  41. package/scaffold/base/index.html +2 -1
  42. package/scaffold/base/package.json.template +5 -1
  43. package/scaffold/base/pnpm-workspace.yaml +6 -0
  44. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  45. package/scaffold/base/src/locales/en.ts +12 -0
  46. package/scaffold/base/src/locales/index.ts +7 -1
  47. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  48. package/scaffold/base/src/main.ts +13 -5
  49. package/scaffold/base/src/router/demo.ts +6 -0
  50. package/scaffold/base/src/router/index.ts +13 -0
  51. package/scaffold/base/src/stores/demo.ts +12 -0
  52. package/scaffold/base/src/stores/index.ts +11 -0
  53. package/scaffold/base/src/utils/appConfig.ts +49 -0
  54. package/scaffold/base/src/utils/errorUtils.ts +109 -0
  55. package/scaffold/base/src/utils/requestUtils.ts +32 -11
  56. package/scaffold/base/src/utils/watermarkConfig.ts +18 -0
  57. package/scaffold/base/src/views/demo/index.vue +26 -0
  58. package/scaffold/host/.env.example +11 -3
  59. package/scaffold/host/src/App.vue +1 -13
  60. package/scaffold/host/src/bootstrap.ts +40 -8
  61. package/scaffold/host/src/hooks/usePlatformSession.ts +40 -8
  62. package/scaffold/host/src/layout/HostHeader.vue +65 -5
  63. package/scaffold/host/src/layout/HostLayout.vue +65 -21
  64. package/scaffold/host/src/layout/HostMenu.vue +11 -2
  65. package/scaffold/host/src/layout/HostTabs.vue +91 -8
  66. package/scaffold/host/src/layout/HostWorkspace.vue +29 -7
  67. package/scaffold/host/src/localPages.ts +45 -0
  68. package/scaffold/host/src/platform.ts +51 -14
  69. package/scaffold/host/src/router/guards.ts +128 -0
  70. package/scaffold/host/src/router/index.ts +25 -0
  71. package/scaffold/host/src/router/targets.ts +30 -0
  72. package/scaffold/host/src/router/workspace.ts +63 -0
  73. package/scaffold/host/src/styles/shell.css +776 -0
  74. package/scaffold/host/src/utils/request.ts +30 -3
  75. package/scaffold/host/src/views/home/index.vue +19 -0
  76. package/scaffold/host/src/views/login/components/LoginForm.vue +83 -0
  77. package/scaffold/host/src/views/login/index.vue +16 -0
  78. package/scaffold/host/src/views/not-found/index.vue +1 -0
  79. package/scaffold/host/src/views/profile/index.vue +24 -0
  80. package/scaffold/host/src/views/workspace/index.vue +11 -0
  81. package/scaffold/layouts/standard/host/src/layout/HostLayout.vue +116 -0
  82. package/scaffold/layouts/standard/host/src/styles/shell.css +168 -0
  83. package/scaffold/layouts/standard/vue/src/layout/StandaloneLayout.vue +26 -0
  84. package/scaffold/vue/src/App.vue +13 -7
  85. package/scaffold/vue/src/bootstrap.ts +25 -24
  86. package/scaffold/vue/src/bridge/auth.ts +23 -0
  87. package/scaffold/vue/src/bridge/session.ts +2 -2
  88. package/scaffold/vue/src/layout/Header.vue +15 -0
  89. package/scaffold/vue/src/layout/Sidebar.vue +21 -0
  90. package/scaffold/vue/src/layout/SidebarItem.vue +21 -0
  91. package/scaffold/vue/src/layout/StandaloneLayout.vue +34 -0
  92. package/scaffold/vue/src/main.ts +5 -2
  93. package/scaffold/vue/src/router/index.ts +9 -4
  94. package/scaffold/vue/src/router/menu.ts +38 -0
  95. package/scaffold/vue/src/views/demo/configs/table.ts +24 -0
  96. package/scaffold/vue/src/views/demo/hooks/useRecordNavigation.ts +32 -0
  97. package/scaffold/vue/src/views/demo/index.vue +17 -0
  98. package/scaffold/vue/src/views/{DetailView.vue → demo/modules/detail/index.vue} +15 -6
  99. package/scaffold/vue/src/views/demo/types.ts +5 -0
  100. package/dist/router-view-impl-CgLEalx4.cjs +0 -1
  101. package/scaffold/host/src/views/LoginView.vue +0 -38
  102. package/scaffold/vue/src/views/ListView.vue +0 -51
  103. /package/scaffold/vue/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
@@ -1,4 +1,6 @@
1
1
  import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
2
+ import { appConfig } from './appConfig'
3
+ import { toSafeError, reportError } from './errorUtils'
2
4
 
3
5
  /**
4
6
  * 基座平台接口专属 Axios 请求实例;不与子应用公共 requestUtils.ts 共享可变配置或凭据。
@@ -9,7 +11,32 @@ import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
9
11
  * 业务 code/data 解包、401 后的会话协调由业务适配层负责,本实例不弹窗或跳转。
10
12
  */
11
13
  export const http = new AxiosHttpClient({
12
- baseURL: '/api/',
13
- timeout: 30000,
14
- withCredentials: false,
14
+ baseURL: appConfig.apiBase,
15
+ timeout: appConfig.timeout,
16
+ withCredentials: appConfig.withCredentials,
15
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,83 @@
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(demoMode ? 'demo' : '')
13
+ const password = ref(demoMode ? '123456' : '')
14
+ async function submit(): Promise<void> {
15
+ if (props.loading) return
16
+ if (!username.value.trim() || !password.value) return
17
+ const credential = password.value
18
+ // 提交即清空密码输入,不在会话或全局状态中保存凭据。
19
+ password.value = ''
20
+ await props.signIn(username.value.trim(), credential)
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <main class="login-page">
26
+ <!-- 仅迁移品牌与表单外观,身份恢复与登录结果仍由上层会话控制器决定。 -->
27
+ <section class="login-brand" aria-label="平台介绍">
28
+ <div class="login-logo"><span class="login-logo-mark" aria-hidden="true">F</span> FIN WORKSPACE</div>
29
+ <div class="login-story"><p class="login-eyebrow">连接业务 · 协同向前</p><h1>一个工作空间,<br />让业务高效运转。</h1><p class="login-description">统一入口、按需协同,为团队打造清晰、可靠的企业工作台。</p>
30
+ <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>
31
+ </div><p class="login-brand-footer">FIN MICRO · 企业应用基座模板</p>
32
+ </section>
33
+ <section class="login-panel" aria-labelledby="login-title"><div class="login-card" :aria-busy="loading">
34
+ <p class="login-eyebrow">欢迎回来</p><h2 id="login-title">登录工作台</h2><p class="login-subtitle">{{ demoMode ? '仅开发模拟:使用 demo / 123456 登录本地主页。' : '使用你的工作账号,继续 __PROJECT_NAME__ 的工作。' }}</p>
35
+ <el-alert v-if="demoMode" title="仅演示登录,不提供生产身份认证" type="warning" :closable="false" />
36
+ <p>{{ demoMode ? '仅开发模拟,校验演示账号密码,不连接认证接口,请勿输入真实凭据;刷新仅恢复当前标签页的演示会话。' : '请接入 src/platform.ts 的真实平台接口;认证失败不会降级为演示身份。' }}</p>
37
+ <el-form class="login-form" label-position="top" size="large" @submit.prevent="submit">
38
+ <el-form-item label="用户名"><el-input v-model="username" autocomplete="username" placeholder="请输入你的工作账号" :disabled="loading" /></el-form-item>
39
+ <el-form-item label="密码"><el-input v-model="password" type="password" autocomplete="current-password" placeholder="请输入登录密码" show-password :disabled="loading" /></el-form-item>
40
+ <p v-if="error" class="login-error" role="alert">{{ error }}</p>
41
+ <div class="actions">
42
+ <el-button class="login-submit" type="primary" native-type="submit" :loading="loading" :disabled="loading">{{ demoMode ? '演示登录,进入主页' : '登录工作台' }}</el-button>
43
+ <el-button :disabled="loading" @click="emit('restore')">重试恢复会话</el-button>
44
+ </div>
45
+ </el-form>
46
+ <p class="login-help">{{ demoMode ? '生产构建始终使用真实认证,演示标记不具有任何认证效力。' : '如需开通账号或重置密码,请联系企业管理员。' }}</p>
47
+ </div><footer class="login-footer">统一工作空间 · 专注每一步</footer></section>
48
+ </main>
49
+ </template>
50
+
51
+ <style scoped>
52
+ /* 双栏品牌区沿用示例外观;不依赖示例认证或表单实现。 */
53
+ .login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(400px, 1fr); background: #fff; color: #1d2b43; }
54
+ .login-brand { display: flex; flex-direction: column; padding: 42px 64px 28px; background: #f5f7fa; border-right: 1px solid #e6edf6; }
55
+ .login-logo { display: inline-flex; align-items: center; gap: 12px; color: #243d66; font-size: 15px; font-weight: 750; letter-spacing: 1.6px; }
56
+ .login-logo-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; background: #2b62d9; color: #fff; font-size: 24px; }
57
+ .login-story { margin: auto 0; padding: 65px 0; max-width: 480px; }
58
+ .login-eyebrow { color: #526b96; font-size: 13px; font-weight: 600; letter-spacing: 2px; margin: 0 0 16px; }
59
+ .login-story h1 { font-size: clamp(30px, 3.1vw, 46px); line-height: 1.5; letter-spacing: -1px; margin: 0 0 22px; }
60
+ .login-description { font-size: 15px; color: #65758d; line-height: 1.9; max-width: 390px; }
61
+ .login-features { margin-top: 45px; display: grid; gap: 24px; }
62
+ .login-features > div { position: relative; padding-left: 46px; }
63
+ .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; }
64
+ .login-features strong { font-size: 14px; }
65
+ .login-features p { font-size: 13px; color: #72809a; margin: 7px 0 0; line-height: 1.6; }
66
+ .login-brand-footer { font-size: 11px; color: #8191aa; letter-spacing: 1px; }
67
+ .login-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; padding: 64px 48px 76px; }
68
+ .login-card { width: 100%; max-width: 390px; }
69
+ .login-card h2 { font-size: 30px; margin: 0 0 12px; font-weight: 650; }
70
+ .login-subtitle { color: #7a879b; font-size: 14px; margin: 0 0 30px; line-height: 1.8; }
71
+ .login-card > p:not([class]) { font-size: 12px; color: #72809a; line-height: 1.8; }
72
+ .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; }
73
+ .login-form :deep(.el-form-item__label) { font-weight: 500; color: #394a64; }
74
+ .login-form :deep(.el-form-item) { margin-bottom: 24px; }
75
+ .login-form .actions { flex-direction: column; align-items: stretch; }
76
+ .login-form .actions :deep(.el-button + .el-button) { margin-left: 0; }
77
+ .login-submit { width: 100%; --el-button-bg-color: #2b62d9; --el-button-border-color: #2b62d9; border-radius: 7px; letter-spacing: 1px; }
78
+ .login-help { text-align: center; color: #8792a4; font-size: 12px; line-height: 1.8; margin-top: 24px; }
79
+ .login-footer { position: absolute; bottom: 26px; color: #a0a9b8; font-size: 11px; letter-spacing: 1px; }
80
+ @media (max-width: 960px) { .login-brand { padding: 36px; } .login-panel { padding: 48px 32px 70px; } }
81
+ @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; } }
82
+ @media (prefers-reduced-motion: reduce) { .login-page :deep(*) { transition: none !important; } }
83
+ </style>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+ import { useRouter, useRoute } from 'vue-router'
4
+ import LoginForm from './components/LoginForm.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><LoginForm :loading="loading" :error="error" :sign-in="signIn" @restore="restore" /></template>
@@ -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,116 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref } from 'vue'
3
+ import { createMicroHost, createWujieRuntime, useMicroHost, type MicroMenu, type NavigationOperation } from '@axewo/fin-micro'
4
+ import { createTokenCapability, createAuthFailureCapabilities, type AuthCapabilities } from '@axewo/fin-micro/sdk'
5
+ import { canOpenRoute, getAccessToken, getTokenRevision, publicSession } from '../platform'
6
+ import type { PlatformSession } from '../types/platform'
7
+ import { HostShell } from '@axewo/fin-micro/layout'
8
+ import '@axewo/fin-micro/layout/style.css'
9
+ import HostWorkspace from './HostWorkspace.vue'
10
+ import { isHome, localApp, localTarget, workspaceOperation } from '../localPages'
11
+ import { useRouter } from 'vue-router'
12
+ import { connectWorkspaceBrowser, connectWorkspaceRouter } from '../router/workspace'
13
+ import '../styles/shell.css'
14
+ import { toSafeError, reportError } from '../utils/errorUtils'
15
+ const props = defineProps<{ session: PlatformSession }>()
16
+ const emit = defineEmits<{ logout: []; expired: [revision: number] }>()
17
+ const shell = ref<InstanceType<typeof HostShell>>()
18
+ const notice = ref('')
19
+ const exiting = ref(false)
20
+ let alive = true
21
+ const sessionRevision = getTokenRevision()
22
+ const authenticated = () => alive && getTokenRevision() === sessionRevision
23
+ const report = (error: unknown) => { if (alive) notice.value = toSafeError(error).message }
24
+ const trustedApps = props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId)
25
+ const failureCapabilities = createAuthFailureCapabilities({
26
+ apps: trustedApps,
27
+ getRevision: getTokenRevision,
28
+ invalidate: () => {
29
+ if (!alive) return
30
+ // 立即撤销公开身份与新能力准入;父控制器同步清除私有凭据,不能被 beforeLeave 阻断。
31
+ host.updateSession({ authenticated: false, permissions: [] })
32
+ emit('expired', getTokenRevision())
33
+ alive = false
34
+ },
35
+ })
36
+ // 认证成功后才创建;只允许当前后台授权并经过可信注册表转换的应用。
37
+ const host = createMicroHost<AuthCapabilities>([localApp, ...props.session.apps], {
38
+ capabilities: {
39
+ ...failureCapabilities,
40
+ getToken: createTokenCapability({
41
+ // 只授权当前已登录且出自本地可信注册表的业务应用,不使用接口传入的任意 appId。
42
+ apps: props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId),
43
+ // exiting 只是离页确认状态;守卫中的保存仍需凭据,确认退出后才由认证控制器清除。
44
+ getSession: () => ({ ...publicSession(props.session), authenticated: authenticated(), revision: getTokenRevision() }),
45
+ // 业务替换点:可改为接收 context.signal 的异步后台读取;helper 会复查会话版本与存活。
46
+ provider: () => getAccessToken(),
47
+ }),
48
+ },
49
+ onError: report,
50
+ canOpen: input => authenticated() && !exiting.value && canOpenRoute(props.session, input),
51
+ // 不开放任意请求代理;Token 仅由上面的显式白名单能力提供,跨应用导航另行制定白名单。
52
+ canNavigate: () => false,
53
+ })
54
+ host.updateSession(publicSession(props.session))
55
+ // 身份与授权菜单就绪后才能恢复 URL;用户隔离存储,禁止恢复未通过 canOpen 的入口。
56
+ const router = useRouter()
57
+ // 源示例同样在恢复前登记主页:首位、去重并迁移缺少主页的旧快照,不先清空再补页。
58
+ // 此时没有页面或业务守卫;交互切回主页仍须经过 operate 的离页确认。
59
+ const browser = connectWorkspaceBrowser(host, router, { storageKey: `fin-micro:tabs:${props.session.user.id}`, onError: report })
60
+ const workspaceRouter = connectWorkspaceRouter(host, router, () => authenticated() && !exiting.value, report)
61
+ // 运行时只接收远端注册表,本地应用永远不会变成 Wujie 网络加载目标。
62
+ const runtime = createWujieRuntime({ apps: host.apps.filter(app => app.kind !== 'local'), bridge: host.bridge })
63
+ const state = useMicroHost(host)
64
+ const active = computed(() => state.value.tabs.find(tab => tab.tabId === state.value.activeTabId))
65
+ const epochs = ref<Record<string, number>>({})
66
+ let previous = new Set<string>()
67
+ const off = host.subscribe(snapshot => {
68
+ const present = new Set(snapshot.tabs.map(tab => tab.appId))
69
+ // 同 tick 关闭最后一页后重开,也不能复用已经失效的 bridge 和应用容器。
70
+ for (const appId of previous) if (!present.has(appId)) epochs.value[appId] = (epochs.value[appId] ?? 0) + 1
71
+ previous = present
72
+ })
73
+
74
+ /** 所有 UI 导航均走核心事务,携带原 key,避免异步操作误关重开后的新页签。 */
75
+ async function operate(operation: NavigationOperation): Promise<void> {
76
+ if (exiting.value) return
77
+ try {
78
+ const result = await host.operate(workspaceOperation(operation, host.getState().tabs))
79
+ if (result.status === 'cancelled') notice.value = '操作已取消'
80
+ if (result.status === 'stale') notice.value = '目标页签已变化,请重试'
81
+ } catch (error) { report(error) }
82
+ }
83
+ function open(appId: string, menu: MicroMenu): void {
84
+ if (menu.target) void operate({ type: 'open', input: { appId, ...menu.target, title: menu.name, activeMenuId: menu.menuId } })
85
+ }
86
+ /** 退出先由核心统一确认全部页面;取消不注销,确认后由父组件撤销会话并卸载工作区。 */
87
+ async function signOut(): Promise<void> {
88
+ if (exiting.value) return
89
+ shell.value?.closePopovers()
90
+ exiting.value = true
91
+ try {
92
+ const result = await host.operate({ type: 'closeMany', scope: 'all' })
93
+ if (result.status === 'committed' || result.status === 'noop') emit('logout')
94
+ else notice.value = '退出已取消,请先处理未保存页面'
95
+ } catch (error) { report(error) }
96
+ finally { exiting.value = false }
97
+ }
98
+ onBeforeUnmount(() => {
99
+ alive = false
100
+ off()
101
+ workspaceRouter.dispose()
102
+ browser.dispose()
103
+ // 先撤销公开身份,再释放沙箱和核心;立即 dispose 阻止任何旧 SDK 发起新动作。
104
+ host.updateSession({ authenticated: false, permissions: [] })
105
+ void runtime.dispose().catch(reportError)
106
+ host.dispose()
107
+ })
108
+ </script>
109
+
110
+ <template>
111
+ <!-- 外壳只发事件;认证、导航守卫与生命周期仍由本项目唯一核心管理。 -->
112
+ <HostShell ref="shell" :brand="{ title: '__PROJECT_NAME__' }" :apps="host.apps" :tabs="state.tabs" :active-key="active?.key" :user-name="session.user.name" :pending="exiting" :notice="notice" :is-fixed-tab="isHome" @open="open" @operate="operate" @logout="signOut" @profile="operate({ type: 'open', input: localTarget('profile') })" @dismiss-notice="notice = ''" @error="report">
113
+ <!-- 业务替换点:主页/个人资料和每应用唯一容器仍属项目,不由外壳创建运行时。 -->
114
+ <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" />
115
+ </HostShell>
116
+ </template>
@@ -0,0 +1,168 @@
1
+ /* 项目本地业务页面样式,公共外壳样式从 npm 子路径导入。 */
2
+ .host-shell .home-view {
3
+ max-width: 1440px;
4
+ margin: 0 auto;
5
+ }
6
+ .host-shell .welcome-panel {
7
+ background: #f4f7fc;
8
+ border: 1px solid #e5ebf5;
9
+ border-radius: 10px;
10
+ padding: 28px 32px;
11
+ min-height: 152px;
12
+ display: flex;
13
+ justify-content: space-between;
14
+ align-items: center;
15
+ margin-bottom: 22px;
16
+ overflow: hidden;
17
+ }
18
+ .host-shell .eyebrow {
19
+ color: #7f94b4 !important;
20
+ font-size: 10px !important;
21
+ letter-spacing: 1.8px;
22
+ margin: 0 0 10px !important;
23
+ }
24
+ .host-shell .welcome-panel h1 {
25
+ font-size: 23px;
26
+ letter-spacing: 0.3px;
27
+ margin: 0 0 9px;
28
+ font-weight: 600;
29
+ color: #25344e;
30
+ }
31
+ .host-shell .welcome-panel p {
32
+ font-size: 12px;
33
+ color: #8b97aa;
34
+ margin: 0;
35
+ }
36
+ .host-shell .home-panel {
37
+ background: #fff;
38
+ border: 1px solid var(--shell-border);
39
+ border-radius: 9px;
40
+ padding: 22px 24px;
41
+ margin-bottom: 22px;
42
+ }
43
+ .host-shell .panel-heading {
44
+ display: flex;
45
+ justify-content: space-between;
46
+ align-items: center;
47
+ gap: 12px;
48
+ margin-bottom: 20px;
49
+ }
50
+ .host-shell .panel-heading h2 {
51
+ margin: 0;
52
+ font-size: 14px;
53
+ font-weight: 600;
54
+ }
55
+ .host-shell .panel-heading > span {
56
+ color: #9ba4b4;
57
+ font-size: 11px;
58
+ }
59
+ .host-shell .shortcut-grid {
60
+ display: grid;
61
+ grid-template-columns: repeat(3, minmax(0, 1fr));
62
+ gap: 16px;
63
+ }
64
+ .host-shell .shortcut-card {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 14px;
68
+ padding: 18px;
69
+ background: white;
70
+ border: 1px solid #e9edf5;
71
+ border-radius: 7px;
72
+ text-align: left;
73
+ transition:
74
+ border-color 0.15s,
75
+ background 0.15s;
76
+ }
77
+ .host-shell .shortcut-card:hover {
78
+ border-color: #acc2f5;
79
+ background: #fafcff;
80
+ }
81
+ .host-shell .entry-icon {
82
+ width: 40px;
83
+ height: 40px;
84
+ display: grid;
85
+ place-items: center;
86
+ border-radius: 9px;
87
+ background: #edf3ff;
88
+ color: #4c79dc;
89
+ font-size: 17px;
90
+ }
91
+ .host-shell .shortcut-card:nth-child(2) .entry-icon {
92
+ background: #edf8f5;
93
+ color: #4a9a88;
94
+ }
95
+ .host-shell .shortcut-card strong {
96
+ display: block;
97
+ font-size: 13px;
98
+ font-weight: 500;
99
+ }
100
+ .host-shell .shortcut-card small {
101
+ display: block;
102
+ color: #99a3b4;
103
+ font-size: 11px;
104
+ margin-top: 4px;
105
+ }
106
+ .host-shell .entry-arrow {
107
+ margin-left: auto;
108
+ color: #a0acbe;
109
+ }
110
+ .host-shell .home-columns {
111
+ display: grid;
112
+ grid-template-columns: 1.2fr 1fr;
113
+ gap: 22px;
114
+ }
115
+ .host-shell .home-columns .home-panel {
116
+ min-height: 266px;
117
+ }
118
+ .host-shell .empty-state {
119
+ text-align: center;
120
+ color: #9ba5b4;
121
+ padding: 25px 12px;
122
+ font-size: 12px;
123
+ }
124
+ .host-shell .empty-state strong {
125
+ display: block;
126
+ font-weight: 400;
127
+ color: #8491a6;
128
+ margin-bottom: 6px;
129
+ }
130
+ .host-shell .empty-state p {
131
+ margin: 0;
132
+ font-size: 11px;
133
+ }
134
+ .host-shell .empty-symbol {
135
+ display: block;
136
+ color: #ced7e5;
137
+ font-size: 32px;
138
+ line-height: 1.2;
139
+ margin-bottom: 13px;
140
+ }
141
+ .host-shell .recent-list {
142
+ list-style: none;
143
+ margin: 0;
144
+ padding: 0;
145
+ }
146
+ .host-shell .recent-list li + li {
147
+ border-top: 1px solid #f1f3f7;
148
+ }
149
+ .host-shell .recent-list button {
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: space-between;
153
+ gap: 16px;
154
+ width: 100%;
155
+ border: 0;
156
+ background: white;
157
+ text-align: left;
158
+ padding: 12px 0;
159
+ font-size: 12px;
160
+ }
161
+ .host-shell .recent-list button:hover {
162
+ color: #3267e3;
163
+ }
164
+ .host-shell .recent-list small {
165
+ color: #99a5b5;
166
+ font-size: 11px;
167
+ white-space: nowrap;
168
+ }
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { FinWatermark } from '@axewo/fin-micro/watermark'
4
+ import { watermarkConfig } from '../utils/watermarkConfig'
5
+ import { useRoute, useRouter } from 'vue-router'
6
+ import { StandaloneShell } from '@axewo/fin-micro/layout'
7
+ import '@axewo/fin-micro/layout/style.css'
8
+ import { createStandaloneMenus, standaloneActiveMenu } from '../router/menu'
9
+ import { reportError } from '../utils/errorUtils'
10
+
11
+ // 菜单映射与路由实例归项目;公共外壳不创建第二个 router 或读取后台权限。
12
+ const router = useRouter()
13
+ const route = useRoute()
14
+ const menus = computed(() => createStandaloneMenus(router.options.routes))
15
+ const activeMenu = computed(() => standaloneActiveMenu(route))
16
+ /** 路由失败进入项目脱敏错误边界,不产生未处理 Promise;路由守卫仍由 router 执行。 */
17
+ function navigate(path: string): void { void router.push(path).catch(reportError) }
18
+ </script>
19
+ <template>
20
+ <!-- 业务替换点:品牌、header-actions 等局部插槽不会接管其他默认区域。 -->
21
+ <StandaloneShell :brand="{ title: '__PROJECT_NAME__' }" :title="route.meta.title ?? '业务应用'" :menus="menus" :active-menu="activeMenu" @navigate="navigate">
22
+ <slot />
23
+ <!-- 公共外壳提供稳定业务视口;嵌入时 App 不挂载本布局。 -->
24
+ <template #workspace-overlay><FinWatermark :config="watermarkConfig" /></template>
25
+ </StandaloneShell>
26
+ </template>
@@ -1,24 +1,30 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, provide } from 'vue'
3
3
  import AppProviders from './components/AppProviders.vue'
4
+ import StandaloneLayout from './layout/StandaloneLayout.vue'
5
+ import { isMicroFrontend } from '@axewo/fin-micro/runtime'
4
6
  import { RouterView } from 'vue-router'
5
7
  import { MicroTabCache, MicroRouteView, useMicroHost } from '@axewo/fin-micro'
6
8
  import { sessionKey, type BridgeSession } from './bridge/session'
7
9
  const props = defineProps<{ session: BridgeSession }>()
8
10
  provide(sessionKey, props.session)
9
11
  const { bridge, status, identity } = props.session
12
+ // 环境与连接状态分开:嵌入但未取得 bridge 时也不能显示第二套头部菜单。
13
+ const embedded = isMicroFrontend()
10
14
  const state = bridge ? useMicroHost(bridge) : undefined
11
15
  const tabs = computed(() => state?.value.tabs.filter(tab => tab.appId === bridge?.appId) ?? [])
12
16
  const activeKey = computed(() => state?.value.tabs.find(tab => tab.tabId === state.value.activeTabId)?.key)
13
17
  </script>
14
18
  <template>
15
19
  <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>
20
+ <component :is="embedded ? 'main' : StandaloneLayout">
21
+ <div class="page">
22
+ <small role="status">{{ status }} · {{ identity?.user?.name ?? '未连接公开身份' }}</small>
23
+ <!-- 缓存以核心 tab.key 为身份;关闭、刷新和淘汰销毁实例,切换只停用。
24
+ LRU 不是持久化,重要草稿需要业务另行保存,不能依赖守卫阻止浏览器刷新。 -->
25
+ <MicroTabCache v-if="bridge && state" :tabs="tabs" :active-key="activeKey" :max="20"><MicroRouteView /></MicroTabCache>
26
+ <RouterView v-else />
27
+ </div>
28
+ </component>
23
29
  </AppProviders>
24
30
  </template>
@@ -1,12 +1,15 @@
1
1
  import { createApp } from 'vue'
2
+ import { disposePinia } from 'pinia'
2
3
  import ElementPlus from 'element-plus'
3
4
  import { connectMicroRouter } from '@axewo/fin-micro'
4
- import { getToken } from '@axewo/fin-micro/sdk'
5
- import { installRequestTokenProvider } from './utils/requestUtils'
5
+ import { isMicroFrontend } from '@axewo/fin-micro/runtime'
6
+ import { installBridgeAuth } from './bridge/auth'
7
+ import { reportError, toSafeError, cleanupAll } from './utils/errorUtils'
6
8
  import App from './App.vue'
7
9
  import { installAppLocale } from './locales'
8
10
  import { createBridgeSession } from './bridge/session'
9
11
  import { createMicroRouter } from './router'
12
+ import { createAppPinia } from './stores'
10
13
  let cleanup: (() => void) | undefined
11
14
 
12
15
  /** 幂等卸载,停止路由握手和会话订阅;晚到的初始化不得再挂载旧实例。 */
@@ -17,8 +20,11 @@ export async function mount(): Promise<void> {
17
20
  unmount()
18
21
  const session = createBridgeSession()
19
22
  const { bridge } = session
20
- const router = createMicroRouter(Boolean(bridge))
23
+ // history 与布局使用同一 SDK 环境标识,缺桥不应争用宿主 URL。
24
+ const embedded = isMicroFrontend()
25
+ const router = createMicroRouter(embedded)
21
26
  const app = createApp(App, { session })
27
+ const pinia = createAppPinia()
22
28
  let stopped = false
23
29
  let mounted = false
24
30
  let offRouter: (() => void) | undefined
@@ -27,45 +33,40 @@ export async function mount(): Promise<void> {
27
33
  cleanup = () => {
28
34
  stopped = true
29
35
  // 先取消挂起的 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
+ cleanupAll([
37
+ () => offToken?.(), () => offRouter?.(), () => offSession?.(),
38
+ () => { if (mounted) app.unmount() },
39
+ () => router.options.history.destroy(), () => disposePinia(pinia),
40
+ ])
36
41
  }
37
42
  try {
38
43
  // 每轮挂载安装独立 i18n,重试/重挂载不复用上一轮语言实例。
44
+ app.config.errorHandler = reportError
39
45
  installAppLocale(app)
40
46
  app.use(ElementPlus)
47
+ // 每轮挂载安装独立 Pinia,不与基座或其他子应用共享状态。
48
+ app.use(pinia)
41
49
  app.use(router)
42
50
  await router.isReady()
43
51
  if (stopped) return
44
52
  if (bridge) {
45
53
  if (bridge.appId !== '__APP_ID__') throw new Error('宿主 appId 与子应用 __APP_ID__ 配置不一致')
46
- if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken'] }).compatible) throw new Error('宿主协议不兼容')
54
+ if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken', 'beginAuthRequest', 'reportAuthFailure'] }).compatible) throw new Error('宿主协议不兼容')
47
55
  // 只在已验证的嵌入生命周期安装;独立模式与基座请求不会隐式探测 window bridge。
48
- offToken = installRequestTokenProvider(signal => getToken({ bridge, signal }))
56
+ offToken = installBridgeAuth(bridge)
49
57
  offSession = bridge.subscribeSession(value => { if (!stopped) session.identity.value = value })
50
58
  // MicroRouteView 按固定页签快照解析真实路由,不全局 router.replace 污染其他缓存页面。
51
59
  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 数据仅保存在当前页面内存'
60
+ error => { if (!stopped) session.status.value = toSafeError(error).message })
61
+ } else session.status.value = embedded
62
+ ? '嵌入运行:未连接宿主'
63
+ : '独立演示:宿主命令不可用,CRUD 数据仅保存在当前页面内存'
54
64
  app.mount('#app')
55
65
  mounted = true
56
66
  } catch (error) {
57
67
  if (stopped) return
58
68
  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
- }
69
+ // 交由 main 的启动代次保护展示安全边界,不能在卸载后用旧按钮重新挂载。
70
+ throw error
70
71
  }
71
72
  }