@axewo/fin-micro 1.0.0 → 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 (68) hide show
  1. package/README.md +101 -3
  2. package/dist/browser.cjs +1 -1
  3. package/dist/browser.mjs +68 -66
  4. package/dist/core.cjs +1 -1
  5. package/dist/core.mjs +447 -433
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.mjs +31 -24
  8. package/dist/{router-view-impl-C1KG6mxu.mjs → router-view-impl-B1NzCGKV.mjs} +18 -18
  9. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  10. package/dist/runtime.cjs +1 -1
  11. package/dist/runtime.mjs +40 -39
  12. package/dist/sdk.cjs +1 -1
  13. package/dist/sdk.mjs +92 -56
  14. package/dist/types/browser/index.d.ts +4 -0
  15. package/dist/types/core/index.d.ts +1 -1
  16. package/dist/types/index.d.ts +2 -0
  17. package/dist/types/runtime/lifecycle.d.ts +1 -1
  18. package/dist/types/sdk/index.d.ts +63 -4
  19. package/dist/types/vue/index.d.ts +13 -2
  20. package/dist/types/vue/router-view-impl.d.ts +1 -1
  21. package/dist/vue.cjs +1 -1
  22. package/dist/vue.mjs +49 -35
  23. package/package.json +2 -2
  24. package/scaffold/base/package.json.template +5 -1
  25. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  26. package/scaffold/base/src/locales/en.ts +12 -0
  27. package/scaffold/base/src/locales/index.ts +7 -1
  28. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  29. package/scaffold/base/src/main.ts +13 -5
  30. package/scaffold/base/src/router/demo.ts +6 -0
  31. package/scaffold/base/src/router/index.ts +13 -0
  32. package/scaffold/base/src/stores/demo.ts +12 -0
  33. package/scaffold/base/src/stores/index.ts +11 -0
  34. package/scaffold/base/src/utils/appConfig.ts +49 -0
  35. package/scaffold/base/src/utils/errorUtils.ts +92 -0
  36. package/scaffold/base/src/utils/requestUtils.ts +32 -11
  37. package/scaffold/base/src/views/DemoView.vue +26 -0
  38. package/scaffold/host/.env.example +11 -3
  39. package/scaffold/host/src/App.vue +1 -13
  40. package/scaffold/host/src/bootstrap.ts +40 -8
  41. package/scaffold/host/src/hooks/usePlatformSession.ts +40 -8
  42. package/scaffold/host/src/layout/HostHeader.vue +65 -5
  43. package/scaffold/host/src/layout/HostLayout.vue +65 -21
  44. package/scaffold/host/src/layout/HostMenu.vue +1 -0
  45. package/scaffold/host/src/layout/HostTabs.vue +41 -8
  46. package/scaffold/host/src/layout/HostWorkspace.vue +19 -7
  47. package/scaffold/host/src/localPages.ts +45 -0
  48. package/scaffold/host/src/platform.ts +41 -10
  49. package/scaffold/host/src/router/guards.ts +128 -0
  50. package/scaffold/host/src/router/index.ts +25 -0
  51. package/scaffold/host/src/router/targets.ts +30 -0
  52. package/scaffold/host/src/router/workspace.ts +63 -0
  53. package/scaffold/host/src/styles/shell.css +761 -0
  54. package/scaffold/host/src/utils/request.ts +30 -3
  55. package/scaffold/host/src/views/HomeView.vue +19 -0
  56. package/scaffold/host/src/views/LoginRoute.vue +16 -0
  57. package/scaffold/host/src/views/LoginView.vue +59 -9
  58. package/scaffold/host/src/views/NotFoundView.vue +1 -0
  59. package/scaffold/host/src/views/ProfileView.vue +24 -0
  60. package/scaffold/host/src/views/WorkspaceRoute.vue +11 -0
  61. package/scaffold/vue/src/bootstrap.ts +18 -22
  62. package/scaffold/vue/src/bridge/auth.ts +23 -0
  63. package/scaffold/vue/src/bridge/session.ts +2 -2
  64. package/scaffold/vue/src/main.ts +5 -2
  65. package/scaffold/vue/src/router/index.ts +2 -0
  66. package/scaffold/vue/src/views/DetailView.vue +14 -5
  67. package/scaffold/vue/src/views/ListView.vue +9 -5
  68. package/dist/router-view-impl-CgLEalx4.cjs +0 -1
@@ -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,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>
@@ -12,7 +12,13 @@ const emit = defineEmits<{ restore: [] }>()
12
12
  const username = ref('')
13
13
  const password = ref('')
14
14
  async function submit(): Promise<void> {
15
- if (props.loading || !username.value.trim() || (!demoMode && !password.value)) return
15
+ if (props.loading) return
16
+ if (demoMode) {
17
+ // 演示不展示或采集账号密码;固定身份只用于本地工作台体验。
18
+ await props.signIn('', '')
19
+ return
20
+ }
21
+ if (!username.value.trim() || !password.value) return
16
22
  const credential = password.value
17
23
  // 提交即清空密码输入,不在会话或全局状态中保存凭据。
18
24
  password.value = ''
@@ -21,18 +27,62 @@ async function submit(): Promise<void> {
21
27
  </script>
22
28
 
23
29
  <template>
24
- <main class="login panel">
25
- <h1>__PROJECT_NAME__</h1>
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>
26
40
  <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>
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>
32
46
  <div class="actions">
33
- <el-button type="primary" native-type="submit" :loading="loading">登录</el-button>
47
+ <el-button class="login-submit" type="primary" native-type="submit" :loading="loading" :disabled="loading">{{ demoMode ? '演示登录,进入主页' : '登录工作台' }}</el-button>
34
48
  <el-button :disabled="loading" @click="emit('restore')">重试恢复会话</el-button>
35
49
  </div>
36
50
  </el-form>
51
+ <p class="login-help">{{ demoMode ? '生产构建始终使用真实认证,演示标记不具有任何认证效力。' : '如需开通账号或重置密码,请联系企业管理员。' }}</p>
52
+ </div><footer class="login-footer">统一工作空间 · 专注每一步</footer></section>
37
53
  </main>
38
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>
@@ -1,12 +1,14 @@
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 { installBridgeAuth } from './bridge/auth'
6
+ import { reportError, toSafeError, cleanupAll } from './utils/errorUtils'
6
7
  import App from './App.vue'
7
8
  import { installAppLocale } from './locales'
8
9
  import { createBridgeSession } from './bridge/session'
9
10
  import { createMicroRouter } from './router'
11
+ import { createAppPinia } from './stores'
10
12
  let cleanup: (() => void) | undefined
11
13
 
12
14
  /** 幂等卸载,停止路由握手和会话订阅;晚到的初始化不得再挂载旧实例。 */
@@ -19,6 +21,7 @@ export async function mount(): Promise<void> {
19
21
  const { bridge } = session
20
22
  const router = createMicroRouter(Boolean(bridge))
21
23
  const app = createApp(App, { session })
24
+ const pinia = createAppPinia()
22
25
  let stopped = false
23
26
  let mounted = false
24
27
  let offRouter: (() => void) | undefined
@@ -27,45 +30,38 @@ export async function mount(): Promise<void> {
27
30
  cleanup = () => {
28
31
  stopped = true
29
32
  // 先取消挂起的 Token 读取并还原 provider,避免旧 bridge 被后续请求复用。
30
- offToken?.()
31
- offRouter?.()
32
- offSession?.()
33
- if (mounted) app.unmount()
34
- // 初始化在 app.mount 前取消/失败时,Vue 尚未接管销毁,仍须释放 history 的浏览器监听。
35
- router.options.history.destroy()
33
+ cleanupAll([
34
+ () => offToken?.(), () => offRouter?.(), () => offSession?.(),
35
+ () => { if (mounted) app.unmount() },
36
+ () => router.options.history.destroy(), () => disposePinia(pinia),
37
+ ])
36
38
  }
37
39
  try {
38
40
  // 每轮挂载安装独立 i18n,重试/重挂载不复用上一轮语言实例。
41
+ app.config.errorHandler = reportError
39
42
  installAppLocale(app)
40
43
  app.use(ElementPlus)
44
+ // 每轮挂载安装独立 Pinia,不与基座或其他子应用共享状态。
45
+ app.use(pinia)
41
46
  app.use(router)
42
47
  await router.isReady()
43
48
  if (stopped) return
44
49
  if (bridge) {
45
50
  if (bridge.appId !== '__APP_ID__') throw new Error('宿主 appId 与子应用 __APP_ID__ 配置不一致')
46
- if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken'] }).compatible) throw new Error('宿主协议不兼容')
51
+ if (!bridge.negotiate({ version: '1.0', capabilities: ['getToken', 'beginAuthRequest', 'reportAuthFailure'] }).compatible) throw new Error('宿主协议不兼容')
47
52
  // 只在已验证的嵌入生命周期安装;独立模式与基座请求不会隐式探测 window bridge。
48
- offToken = installRequestTokenProvider(signal => getToken({ bridge, signal }))
53
+ offToken = installBridgeAuth(bridge)
49
54
  offSession = bridge.subscribeSession(value => { if (!stopped) session.identity.value = value })
50
55
  // MicroRouteView 按固定页签快照解析真实路由,不全局 router.replace 污染其他缓存页面。
51
56
  offRouter = connectMicroRouter(bridge, { async replace() { if (!stopped) session.status.value = '宿主已连接' } },
52
- error => { if (!stopped) session.status.value = String(error) })
57
+ error => { if (!stopped) session.status.value = toSafeError(error).message })
53
58
  } else session.status.value = '独立演示:宿主命令不可用,CRUD 数据仅保存在当前页面内存'
54
59
  app.mount('#app')
55
60
  mounted = true
56
61
  } catch (error) {
57
62
  if (stopped) return
58
63
  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
- }
64
+ // 交由 main 的启动代次保护展示安全边界,不能在卸载后用旧按钮重新挂载。
65
+ throw error
70
66
  }
71
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
+ }
@@ -1,9 +1,9 @@
1
1
  import { inject, ref, shallowRef, type InjectionKey } from 'vue'
2
- import { getMicroBridge, type SessionSnapshot } from '@axewo/fin-micro'
2
+ import { getMicroBridge, type SessionSnapshot, type AuthCapabilities } from '@axewo/fin-micro'
3
3
 
4
4
  /** 每次挂载获取独立会话;仅保存公开身份,不读取宿主 Token,不构造虚假的平台能力。 */
5
5
  export function createBridgeSession() {
6
- return { bridge: getMicroBridge(), identity: shallowRef<SessionSnapshot>(), status: ref('正在连接宿主') }
6
+ return { bridge: getMicroBridge<AuthCapabilities>(), identity: shallowRef<SessionSnapshot>(), status: ref('正在连接宿主') }
7
7
  }
8
8
  export type BridgeSession = ReturnType<typeof createBridgeSession>
9
9
  export const sessionKey: InjectionKey<BridgeSession> = Symbol('micro-session')
@@ -2,9 +2,12 @@ import 'element-plus/dist/index.css'
2
2
  import '@axewo/fin-ui/style.css'
3
3
  import './styles/index.css'
4
4
  import { setupWujieLifecycle } from '@axewo/fin-micro/runtime'
5
- import { mount, unmount } from './bootstrap'
5
+ import { createStartupLifecycle } from './utils/errorUtils'
6
6
 
7
- // 环境判断、Wujie 回调注册、独立启动和 unload 统一由库管理。
7
+ // 配置及 bootstrap 静态依赖加载失败也显示安全兜底;卸载取消尚未完成的导入。
8
+ const { mount, unmount } = createStartupLifecycle(() => import('./bootstrap'))
9
+
10
+ // 环境判断、Wujie 回调注册、独立启动和 pagehide 统一由库管理。
8
11
  // 业务替换点在 bootstrap:创建/销毁应用,并取消本轮尚未完成的异步初始化。
9
12
  const dispose = setupWujieLifecycle({ mount, unmount })
10
13
  if (import.meta.hot) import.meta.hot.dispose(dispose)
@@ -1,4 +1,5 @@
1
1
  import { createMemoryHistory, createRouter, createWebHashHistory } from 'vue-router'
2
+ import { demoRoutes } from './demo'
2
3
 
3
4
  /** 嵌入模式使用 memory history 不争用宿主 URL;独立模式使用 hash 路由便于静态部署。 */
4
5
  export function createMicroRouter(embedded: boolean) {
@@ -8,6 +9,7 @@ export function createMicroRouter(embedded: boolean) {
8
9
  { path: '/', redirect: '/list' },
9
10
  { path: '/list', component: () => import('../views/ListView.vue') },
10
11
  { path: '/detail', component: () => import('../views/DetailView.vue') },
12
+ ...demoRoutes,
11
13
  { path: '/:pathMatch(.*)*', component: () => import('../views/NotFoundView.vue') },
12
14
  ],
13
15
  })
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { ref } from 'vue'
3
3
  import { useRoute, useRouter } from 'vue-router'
4
- import { useMicroPage, useMicroTab } from '@axewo/fin-micro'
4
+ import { useMicroPage, useMicroTab, usePermission } from '@axewo/fin-micro'
5
5
  import { useBridgeSession } from '../bridge/session'
6
- const { bridge } = useBridgeSession()
6
+ import { toSafeError } from '../utils/errorUtils'
7
+ const { bridge, identity } = useBridgeSession()
8
+ const permission = usePermission(identity)
9
+ const canRead = permission.can('demo:read')
7
10
  const tab = bridge ? useMicroTab() : undefined
8
11
  const sdk = bridge && tab ? useMicroPage(bridge, tab) : undefined
9
12
  const route = useRoute()
@@ -16,7 +19,11 @@ const message = ref('')
16
19
  sdk?.beforeLeave(() => !dirty.value || window.confirm('存在未保存的演示草稿,确认离开?'))
17
20
 
18
21
  /** 仅演示标记已保存,不宣称写入后台;真实业务必须 await 保存成功后才清除 dirty。 */
19
- function save(): void { dirty.value = false; message.value = '演示草稿已标记保存,未写入服务器' }
22
+ function save(): void {
23
+ if (bridge && !canRead.value) { message.value = '403:无权执行此操作'; return }
24
+ dirty.value = false
25
+ message.value = '演示草稿已标记保存,未写入服务器'
26
+ }
20
27
 
21
28
  /** SDK 固定绑定创建时的原页面,即使等待期间切换页签,也不关闭或刷新其他页面。 */
22
29
  async function action(type: 'close' | 'refresh' | 'back'): Promise<void> {
@@ -29,11 +36,13 @@ async function action(type: 'close' | 'refresh' | 'back'): Promise<void> {
29
36
  if (type === 'close') await sdk.closeCurrent()
30
37
  else if (type === 'refresh') await sdk.refreshCurrent()
31
38
  else await sdk.back()
32
- } catch (error) { message.value = String(error) }
39
+ } catch (error) { message.value = toSafeError(error).message }
33
40
  }
34
41
  </script>
35
42
  <template>
36
- <section class="panel">
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>
37
46
  <h2>详情 {{ route.query.id }}</h2>
38
47
  <p>切换宿主页签保留草稿;刷新/关闭只影响本页生命周期。</p>
39
48
  <el-input v-model="draft" type="textarea" placeholder="编辑演示草稿" @input="dirty = true" />
@@ -2,16 +2,19 @@
2
2
  import { ref } from 'vue'
3
3
  import { useRouter } from 'vue-router'
4
4
  import { FCrudTable, type TableConfig } from '@axewo/fin-ui'
5
- import { useMicroPage, useMicroTab } from '@axewo/fin-micro'
5
+ import { useMicroPage, useMicroTab, usePermission } from '@axewo/fin-micro'
6
6
  import { useBridgeSession } from '../bridge/session'
7
- const { bridge } = useBridgeSession()
7
+ import { toSafeError } from '../utils/errorUtils'
8
+ const { bridge, identity } = useBridgeSession()
9
+ const permission = usePermission(identity)
10
+ const canRead = permission.can('demo:read')
8
11
  const router = useRouter()
9
12
  // SDK 在 setup 中绑定缓存边界的固定 tab.key;后续异步操作不会误操作另一个活动页。
10
13
  const tab = bridge ? useMicroTab() : undefined
11
14
  const sdk = bridge && tab ? useMicroPage(bridge, tab) : undefined
12
15
  const message = ref('')
13
16
  /** 组件异常只展示文本,兼容公开版本未提供强类型事件声明的情况。 */
14
- function reportError(error: unknown): void { message.value = error instanceof Error ? error.message : String(error) }
17
+ function reportError(error: unknown): void { message.value = toSafeError(error).message }
15
18
  interface RecordRow { id: number; name: string }
16
19
  // 最小内存适配器仅演示组件 API;每个页面独立数据,刷新会重置,生产请替换为真实业务请求。
17
20
  let rows: RecordRow[] = [{ id: 1, name: '示例记录' }]
@@ -35,16 +38,17 @@ const tableConfig: TableConfig = {
35
38
  /** 同应用详情用页面 SDK 打开宿主页签;独立模式才使用普通 router,错误留在原页面。 */
36
39
  async function openDetail(): Promise<void> {
37
40
  try {
41
+ if (bridge && !canRead.value) { message.value = '403:无权访问详情'; return }
38
42
  if (sdk && bridge) await sdk.openTab({ appId: bridge.appId, tabId: `${bridge.appId}:detail:1`, title: '示例详情 1', route: '/detail?id=1' })
39
43
  else await router.push('/detail?id=1')
40
- } catch (error) { message.value = String(error) }
44
+ } catch (error) { message.value = toSafeError(error).message }
41
45
  }
42
46
  </script>
43
47
  <template>
44
48
  <section class="panel">
45
49
  <h1>__PROJECT_NAME__</h1>
46
50
  <p>以下 CRUD 为内存示例,非生产业务接口;新增、编辑和删除均使用 fin-ui 内置流程。</p>
47
- <div class="actions"><el-button @click="openDetail">打开详情页</el-button></div>
51
+ <div class="actions"><el-button :disabled="Boolean(bridge) && !canRead" @click="openDetail">打开详情页</el-button></div>
48
52
  <p v-if="message" role="status">{{ message }}</p>
49
53
  <FCrudTable :table-config="tableConfig" @error="reportError" />
50
54
  </section>
@@ -1 +0,0 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const t=require("vue"),n=require("vue-router"),O=require("./vue.cjs");class f extends Error{constructor(a){super(a),this.code=a}}function R(h,a){let e=h.resolve(a,n.START_LOCATION);const p=e,c=new Set;for(let i=0;i<30;i++){if(!e.matched.length)throw new f("NOT_FOUND");const l=e.matched[e.matched.length-1];if(!l.redirect)return e===p?e:{...e,redirectedFrom:p};if(c.has(e.fullPath))throw new f("REDIRECT_LOOP");c.add(e.fullPath);const r=typeof l.redirect=="function"?l.redirect(e,n.START_LOCATION):l.redirect;let u;if(typeof r=="string")if(r.includes("?")||r.includes("#")){const o=h.resolve(r,n.START_LOCATION);u={path:o.path,query:o.query,hash:o.hash}}else u={path:r};else u=r;const d="path"in u&&u.path!=null;if(!d&&!("name"in u))throw new f("RESOLVE_FAILED");e=h.resolve({query:e.query,hash:e.hash,params:d?{}:e.params,...u},n.START_LOCATION)}throw new f("REDIRECT_LOOP")}const T=t.defineComponent({name:"MicroRouteViewImpl",props:{name:{type:String,default:"default"}},slots:Object,setup(h,{slots:a}){const e=O.useMicroTab(),p=n.useRouter(),c=t.shallowRef(),i=t.shallowRef(),l=t.shallowRef(!1);let r=!1;const u={};for(const o of Object.keys(n.START_LOCATION))Object.defineProperty(u,o,{enumerable:!0,get:()=>(c.value??n.START_LOCATION)[o]});t.provide(n.routeLocationKey,t.shallowReactive(u)),t.provide(n.viewDepthKey,0);async function d(){if(r||l.value)return;l.value=!0,i.value=void 0;let o="RESOLVE_FAILED";try{const s=R(p,e.route);o="LOAD_FAILED";const v=await n.loadRouteLocation(s);r||(c.value=v)}catch(s){r||(i.value=s instanceof f?s.code:o)}finally{r||(l.value=!1)}}return t.onBeforeUnmount(()=>{r=!0}),d(),()=>{var o,s;return i.value?((o=a.error)==null?void 0:o.call(a,{tab:e,code:i.value,retry:d}))??t.h("div",{role:"alert","data-micro-route-error":i.value},[t.h("p",`页面加载失败(${i.value})`),t.h("button",{type:"button",onClick:d},"重新加载")]):l.value||!c.value?((s=a.loading)==null?void 0:s.call(a,{tab:e}))??t.h("p",{role:"status"},"页面加载中…"):t.h(n.RouterView,{route:c.value,name:h.name,key:e.key})}}});exports.MicroRouteViewImpl=T;