@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
package/README.md CHANGED
@@ -24,6 +24,71 @@ pnpm --filter @axewo/fin-docs dev
24
24
 
25
25
  文档路径为 `/micro/`。模板位于 `apps/micro-host` 和 `apps/micro-vue`,首次接入建议先运行模板,再替换业务视图与权限规则。
26
26
 
27
+ ## 可配置业务区水印
28
+
29
+ `@axewo/fin-micro/watermark` 独立导出 `FinWatermark`、`WatermarkConfig`、`normalizeWatermarkConfig`,核心入口不加载水印副作用。host/vue 的 standard/custom 模板均接入,配置在生成项目 `src/utils/watermarkConfig.ts`:
30
+
31
+ ```ts
32
+ export const watermarkConfig = reactive<WatermarkConfig>({
33
+ enabled: true,
34
+ text: ['内部资料', '自定义文字'],
35
+ color: '#64748b',
36
+ opacity: 0.12,
37
+ rotate: -22,
38
+ fontSize: 16,
39
+ fontFamily: 'sans-serif',
40
+ gap: [100, 80], // 水平/垂直间距,越小越密
41
+ })
42
+ ```
43
+
44
+ 当前为前端配置;后续由项目 `apis/` 适配真实接口并 `Object.assign(watermarkConfig, result)`,无需更换组件,也没有预设后端协议。不要把 Token 等凭据当水印内容。字号限制 10~64、角度 -180~180、间距 16~600、透明度 0~1,文字最多四行、每行 120 字。
45
+
46
+ 水印只覆盖右侧稳定业务视口,业务长页滚动不带走水印,不覆盖头部、侧栏和页签;登录页不绘制。Vue 嵌入运行由基座统一绘制,独立运行才挂载自己的水印。自定义接入需将组件置于 `position: relative` 的非滚动视口内,与业务滚动容器作为兄弟节点;公共 `StandaloneShell` 提供 `workspace-overlay` 插槽。
47
+
48
+ 组件使用 Canvas 平铺、`pointer-events: none`,观察自身属性和直接父容器的节点删除,并定时检查常见隐藏样式。合法配置更新、关闭和卸载均清理观察器和定时器。Canvas 不可用或绘制失败会触发 `error` 事件并告警,本轮不启动无限重试;应用可按合规要求自行决定是否阻断业务。
49
+
50
+ **纯前端水印不能保证无法通过 JS、样式或开发者工具移除**:祖先容器被隐藏、脚本停用、截图后处理等均不受此机制保障,自动恢复只能提高篡改成本,不能当安全边界。下载文件需要水印时应在服务端生成;更强溯源需配合服务端审计与权限控制。
51
+
52
+ ## 标准公共外壳与自定义布局
53
+
54
+ CLI 支持 `--layout standard|custom`,省略时默认 `standard`;交互创建时也可选择。
55
+
56
+ ```sh
57
+ npx @axewo/fin-micro@latest create my-host --template host --layout standard
58
+ npx @axewo/fin-micro@latest create my-business --template vue --layout custom
59
+ ```
60
+
61
+ - **standard**:生成项目从 `@axewo/fin-micro/layout` 导入 `HostShell` 或 `StandaloneShell`,并显式导入 `@axewo/fin-micro/layout/style.css`。默认头部、菜单、页签等展示由包维护,升级依赖并重新构建后获得未覆盖区域的公共修复。
62
+ - **custom**:保留完整可运行的本地布局 SFC 与样式,功能基线一致;展示代码的后续变更需要项目自行合并,不会被 npm 升级覆盖。
63
+ - 宿主侧栏菜单名称前展示图标:菜单 `icon` 使用 `HomeFilled`、`Folder`、`List`、`User` 等 Element Plus 图标标识;未配置或不在本地映射白名单中的名称会安全回退到文件/文件夹图标。后台只传标识字符串,不传组件代码、HTML 或远程图标 URL。独立 Vue 菜单使用路由 `meta.icon` 组件。
64
+ - 两种模式的认证、授权、Token、唯一 host/runtime、路由连接、页签事务和卸载逻辑仍由项目原有装配管理。公共外壳不创建第二套状态机,不导入项目的 `platform.ts` 或 `localPages.ts`。
65
+ - 子应用只有独立运行时使用独立外壳;嵌入模式即使暂未连接 bridge,也不显示自己的头部、侧栏。独立菜单仍由本地 router meta 映射,不额外请求菜单接口。
66
+
67
+ ### 配置与插槽的升级边界
68
+
69
+ `HostShell` 支持 `brand`(`title`、`mark`、`workspaceTitle`)、受控 `collapsed`、`isFixedTab` 展示规则,以及 `brand`、`header-actions`、`header`、`sidebar`、`tabs` 插槽。业务内容使用默认插槽。`StandaloneShell` 支持 `brand`、`header-actions`、`sidebar` 和默认内容插槽。
70
+
71
+ ```vue
72
+ <HostShell :apps="apps" :tabs="tabs" :active-key="activeKey" user-name="张三"
73
+ :brand="{ title: '业务工作台' }" @operate="operate" @open="open"
74
+ @logout="signOut" @profile="openProfile">
75
+ <template #header-actions><button @click="openHelp">帮助</button></template>
76
+ <HostWorkspace />
77
+ </HostShell>
78
+ ```
79
+
80
+ 上例只示意展示扩展,实际项目继续使用生成模板中完整的属性、事件和认证接线。局部插槽不会把整个应用变成 custom;替换整个 `header` 后,仅该区域的默认展示升级不再体现,其他默认区域继续升级。插槽动作只发出事件,项目处理器仍必须执行原有核心授权和离页守卫;不得直接调用可信底层原语绕过事务。固定页签谓词也仅控制按钮,真正约束继续由项目的 `workspaceOperation` 实施。
81
+
82
+ 公共样式限定 `.fin-micro-shell` / `.fin-micro-standalone`,不写全局 body/reset,不把主页业务样式放进公共库。可在外层设置 `--fin-shell-accent`、`--fin-shell-border`、`--fin-shell-muted`;独立外壳还支持 `--fin-shell-surface`、`--fin-shell-sidebar-width`。Element Plus 与图标是 layout 入口的可选 peer;仅使用 core/SDK 不会导入布局、Element 或 CSS。按需指构建依赖隔离,不是 npm 选择性下载包内容。
83
+
84
+ ### 已有项目接入
85
+
86
+ 升级依赖不会自动改写已经复制到项目中的模板。已有 custom 项目需要**一次性迁移**:保留认证/host/runtime/router/业务工作区,将头部、侧栏和页签展示改为公共 `HostShell`,接回原有事件处理器,并导入公共 CSS;Vue 独立布局可改用 `StandaloneShell`。不要重新生成覆盖现有项目。迁移后,再次升级依赖才会同步公共外壳未覆盖区域。反向切换 custom 时,把当前版本布局源码复制到项目并改回本地导入,核心接线不变。
87
+
88
+ ### 刷新启动占位
89
+
90
+ 公共 HTML 在模块加载前显示根节点内的启动提示;认证恢复和 `router.isReady()` 等待期间不提前渲染未授权工作区。Vue 挂载后自然替换。启动失败显示脱敏文案与重试,重试恢复 loading;取消可重试,旧导入、卸载后的按钮和迟到失败均受代次隔离。占位不使用 fixed 全屏遮罩,嵌入子应用只影响自身根区域。本次不是普通业务 HTTP 请求的全局遮罩,也不实现自动 Token 刷新。
91
+
27
92
  ## 按需获取当前 Token
28
93
 
29
94
  ```ts
@@ -46,7 +111,7 @@ const dispose = setupWujieLifecycle({ mount, unmount })
46
111
  if (import.meta.hot) import.meta.hot.dispose(dispose)
47
112
  ```
48
113
 
49
- `setupWujieLifecycle` 统一注册 Wujie 回调、通知嵌入挂载、独立启动、unload 和幂等清理;重复注册会释放旧注册。`dispose()` 返回 Promise,可等待清理完成。错误会报告至 console.error,显式调用生命周期回调可接收失败。bootstrap 自身仍须在 unmount 时使异步初始化失效,封装不能撤销回调内部副作用。SSR 检测为 false,注册不启动应用。环境检测不会把普通 iframe 或自行注册的挂载函数误认为微前端,不探测其他未适配的微前端框架。
114
+ `setupWujieLifecycle` 统一注册 Wujie 回调、通知嵌入挂载、独立启动、pagehide 和幂等清理;重复注册会释放旧注册。`pagehide.persisted === true` 时保留实例与监听,避免 BFCache 返回后留下已销毁的应用;普通离开才释放注册和应用。Wujie 显式 unmount、dispose 与 HMR 清理不受 persisted 影响,不注册 unload,也不通过放宽 Permissions-Policy 掩盖警告。`dispose()` 返回 Promise,可等待清理完成。错误会报告至 console.error,显式调用生命周期回调可接收失败。bootstrap 自身仍须在 unmount 时使异步初始化失效,封装不能撤销回调内部副作用。SSR 检测为 false,注册不启动应用。环境检测不会把普通 iframe 或自行注册的挂载函数误认为微前端,不探测其他未适配的微前端框架。
50
115
 
51
116
  基座使用普通启动入口,只有 vue 子应用模板接入上述封装;不再在 base 中注册子应用专属全局回调。
52
117
 
@@ -55,7 +120,7 @@ if (import.meta.hot) import.meta.hot.dispose(dispose)
55
120
  运行库与创建命令使用同一个包,不另发 create-fin-micro。包发布后可执行以下命令;当前开发不代表已经发布至 npm:
56
121
 
57
122
  ```sh
58
- # 交互式:依次输入目录、选择 host/vue、输入子应用 app-id 和端口
123
+ # 交互式:依次输入目录、选择 host/vue、选择布局、输入子应用 app-id 和端口
59
124
  npx @axewo/fin-micro@latest create
60
125
 
61
126
  # 参数式:适合脚本和 CI,不触发问答
@@ -72,16 +137,128 @@ node packages/fin-micro/cli/index.mjs create ./order-app --template vue --app-id
72
137
 
73
138
  `host` 默认端口 7100,`vue` 默认端口 7101;项目名与 appId 只接受小写字母、数字和连字符。子应用示例建议指定 `--app-id business`,与基座默认登记保持一致;或调整基座的环境变量。目标目录必须不存在,父目录必须已存在;命令不覆盖文件、不自动安装依赖、不自动注册远端应用、不执行发布。进入生成项目后运行 `pnpm install`、`pnpm dev` 和 `pnpm build`。生成源码属于项目,CLI 不升级或覆盖既有源码。
74
139
 
75
- 基座联调时,在 `.env.local` 显式配置 `VITE_AUTH_DEMO=true` 才能启用开发演示身份;正式模式请求 `/api/session`、`/api/login`、`/api/menus`、`/api/logout`,协议与替换点见快速开始章节。后台菜单只提供授权路由,应用执行地址来自可信本地注册表。
140
+ ### 业务功能目录
141
+
142
+ 生成项目的业务页按功能聚合:`views/demo/index.vue` 是列表装配层,`views/demo/configs/table.ts` 维护 fin-ui 表格/表单配置,`views/demo/hooks/useRecordNavigation.ts` 处理权限与详情导航,`views/demo/types.ts` 定义仅本功能使用的类型,`views/demo/modules/detail/index.vue` 是二级详情页。公共计数示例保留在 `views/demo/index.vue`(由 base 提供,子应用覆盖为业务列表)。宿主的 `views/login/index.vue` 编排登录会话,表单展示位于 `views/login/components/LoginForm.vue`;主页、个人中心、工作区和 404 分别在各自的 `views/{功能}/index.vue`。按实际职责扩展 `api/`、`configs/`、`hooks/`、`components/`、`modules/`,不建立空目录或纯转发 hook。功能独有请求放功能 `api/`,可复用请求遵循项目约定放 `src/api/{domain}`;已生成项目的旧路径不会被包升级自动改写。
143
+
144
+ ### vue 独立布局与本地路由菜单
145
+
146
+ Vue 子应用独立运行显示头部、Element Plus 左侧菜单和内容区:standard 模式由公共 `StandaloneShell` 展示,custom 模式保留 `layout/Header.vue`、`Sidebar.vue` 和 `StandaloneLayout.vue` 本地源码;嵌入基座时均隐藏子应用头部和菜单。AppProviders 始终包裹全树,两种模式共用原业务页面、会话和 SDK 生命周期;嵌入继续使用原 MicroTabCache / MicroRouteView 及 tab.key 缓存契约。布局和 history 均使用 SDK `isMicroFrontend()` 判断 Wujie 环境,不以 bridge 是否连接、URL 或端口推断;缺桥的嵌入运行仍隐藏外壳并使用 memory history。
147
+
148
+ 独立菜单由 `router.options.routes` 的本地声明树生成,不请求后台菜单,不新增后台或认证。`meta.title` 为菜单文字,`icon` 为 Vue 图标组件(示例使用 `@element-plus/icons-vue`),`order` 按同级升序排列(默认 0,同值保留声明顺序),`hidden` 隐藏整棵菜单子树,`activeMenu` 指定活动菜单完整路径。默认展示“业务列表 / 基础示例”;详情 `/detail` 设置 `hidden: true, activeMenu: '/list'`,可正常访问并高亮列表。默认跳转、404、动态参数路由不生成菜单;无标题父级提升可见子项,有标题且有可见子项的父级递归显示 Element 子菜单,相对子路径拼为完整路径。菜单不是授权规则,嵌入授权继续由基座负责。
149
+
150
+ CLI 回归编译真实 App/布局 SFC 并渲染 Element 菜单,覆盖独立与嵌入分支、菜单排序/过滤/递归及详情高亮。完整 `prepack` 保留原有测试;已有模板固定版本的 Vue、Vue Router、Pinia 和 TypeScript 时,可设置 `FIN_SCAFFOLD_DEPENDENCIES` 为这些依赖所在项目目录,只读复用并核对版本,避免测试临时安装依赖,不清理该目录。
151
+
152
+ ### host 本地登录与主页体验
153
+
154
+ 用户未指定已生成项目时,修改目标是 `scaffold/host`,不会自动覆盖其他生成项目。仓库内可在一个已存在的父目录创建全新项目:
155
+
156
+ ```sh
157
+ node packages/fin-micro/cli/index.mjs create ./my-host --template host
158
+ # 进入 my-host 后安装依赖并启动(需要可用的 fin-core / fin-ui / fin-micro 对应版本)
159
+ pnpm install
160
+ pnpm dev
161
+ ```
162
+
163
+ 使用 pnpm 12.3.4 安装时,生成项目自带 `pnpm-workspace.yaml`,通过 `allowBuilds` 仅批准 `esbuild@0.21.5`、`vue-demi@0.14.10` 的安装脚本,不必为这两个版本重复执行 `pnpm approve-builds`。其他包或版本仍需审核,不启用全量脚本放行。已生成的项目须手动合并该配置;若项目已有 `pnpm-workspace.yaml`,保留原设置,只合并 `allowBuilds`,再执行 `pnpm install`。pnpm 低于 10.26 的版本需按其版本支持的方式授权;不要混用已移除的旧配置。
164
+
165
+ 打开 Vite 输出的本机地址(默认端口 7100),在现有账号密码表单中使用 `demo / 123456`(开发模拟时默认预填),点击“演示登录,进入主页”。开发默认启用模拟登录,不需要后端或子应用,严格校验演示账号密码,错误或空凭据拒绝登录,成功后固定显示“演示用户”。此凭据仅用于开发模拟,请勿输入真实凭据;提交即清空密码输入,加载期间禁止重复提交。登录后默认打开真正的本地主页;左侧 Element 菜单、顶部搜索、个人菜单均可打开“主页 / 个人中心”。个人页展示公开身份,可回主页或退出。主页与 `apps/micro-host` 一样属于唯一 core tabs,而非独立入口或第二套标签状态:认证后在恢复前同步登记,初始、直链和刷新恢复均固定首位且不重复;缺少主页或主页顺序错误的旧快照自动迁移,业务页之间的顺序不变。主页无关闭按钮,右键菜单禁用刷新与关闭;“关闭全部业务页 / 其他 / 左侧 / 右侧”始终排除主页(个人中心也属于可关闭页)。业务页仍可正常刷新;关闭活动页在一次核心事务内回落主页,URL 经同一 Vue Router 更新,守卫拒绝则保持原页和 URL。不放置虚假业务数据或未实现业务按钮。
166
+
167
+ 刷新仅凭当前标签页 `sessionStorage` 的 `fin-micro:demo-session:v1=1` 标记恢复固定演示身份,随后通过原 `connectMicroBrowser` 恢复已授权页签,产生新的生命周期 key。它不是 Token,不含账号密码,不访问认证接口。禁用浏览器存储时仍可登录,但无法跨刷新恢复。退出须经原核心离页守卫;确认后关闭全部页签、清除演示标记与私有凭据。认证失效也清除标记,组件卸载不清除标记以支持刷新。
168
+
169
+ 如需真实后端联调,在 `.env.local` 设置 `VITE_AUTH_DEMO=false`。生产构建始终禁止演示,即使设置 true;请求 `/api/session`、`/api/login`、`/api/menus`、`/api/logout`,任何网络、认证或菜单失败均不降级演示。部署前必须接入真实认证、服务端授权及 CSRF 防护;开发标记不具有任何生产认证效力。后台菜单只提供授权路由,应用执行地址来自可信本地注册表。
170
+
171
+ 本地页使用明确的 `{ kind: 'local', appId: 'host-local' }` 注册,禁止 entry;`host-local` 为宿主保留 appId,不能配置给远端应用。`localPages.ts` 固定 route/tabId 白名单,`HostWorkspace` 直接使用 `HomeView/ProfileView` SFC,Wujie 明确拒绝 local;不会把本地页面伪装成 HTTP 远端。两类页面复用同一 core 的标识、激活、关闭、刷新、守卫和 browser 恢复。远端仍按 appId 单实例挂载,Token 与 Auth 失败能力只向原可信业务应用白名单授权。可选业务入口需要启动子应用,但它离线不影响本地工作台。
76
172
 
77
173
  `scaffold/base` 保存公共骨架,`scaffold/host`、`scaffold/vue` 按类型覆盖差异文件;两类模板统一接入 fin-ui。基座仅包含登录、公开用户信息、菜单与微前端平台编排,不包含系统管理业务。认证示例不是生产认证,需替换接口适配并补齐服务端鉴权;应用入口、appId、菜单权限需按实际配置。
78
174
 
79
175
  运行库通过 npm 依赖导入,不复制 core 源码。公开包使用 files 白名单,仅交付运行库 JS、类型声明、创建命令与可公开的骨架源码,不交付运行库 src 或 sourcemap。编译 JavaScript 仍然可被分析,不应含密钥或保密逻辑。库升级需回归、构建、部署,无需重新创建项目;模板更新不会自动迁移生成项目。
80
176
 
81
- 生成项目的 `src` 统一具有 `stores/router/layout/locales/hooks/utils/types/components/views/styles`。布局放 layout,页面放 views,公共样式放 styles;未启用的状态管理仅保留目录,不新增 plugins 目录。公共 `components/AppProviders.vue` 使用 `FConfigProvider` 与 `ElConfigProvider` 包裹 host/vue 全树,以 `useFinLocale()` 的全局 locale 响应式选择 fin-ui 的 `zhCN/en` 和 Element Plus 语言包,并同步 size(默认 default)。`locales/index.ts` 通过 fin-ui 重导出的 `installFinI18n` 在每次 createApp 后、mount 前独立安装,默认 zh-CN;不共享模块级 i18n 实例,`config.locale` 仅是语言包数据,不负责安装插件。模板显式安装 fin-locale 要求的 peer 依赖 `vue-i18n ^9.14.0`;业务子组件可用 `useFinLocale().locale.value = 'en'` 切换当前应用语言,不自动跨应用广播或翻译硬编码业务文案。公共 `base/src/utils/requestUtils.ts` 同时生成到基座与 Vue 子应用,通过 `@axewo/fin-core/http/axios` 为各项目创建独立 `AxiosHttpClient`,提供请求/响应拦截器的应用级替换点。基座平台适配使用专属 `utils/request.ts` 的独立 Axios 实例,配置原生 timeout/withCredentials;平台显式处理 Axios 401,并以局部 Token 请求菜单/注销。子应用不生成该平台实例。业务中使用 `import { http } from '../utils/requestUtils'`(未配置别名时使用相对路径),调用 `http.get/post`;返回整个响应体,业务 code/data 不自动解包。子应用拦截器通过 `http.interceptors` 配置,响应拦截器需保留 AxiosResponse,Token/401策略由业务明确接入。依赖含该Axios入口的 fin-core 2.1.0 源码版本,公开安装前需先发布该依赖;此前旧2.1.0候选tgz不包含Axios入口,不能用于本次模板。本地联合安装前需重新构建、打包 core 与 micro,旧候选包不能代表本次源码。
177
+ 生成项目的 `src` 统一具有 `stores/router/layout/locales/hooks/utils/types/components/views/styles`。布局放 layout,页面放 views,公共样式放 styles,不新增 plugins 目录。公共依赖显式包含 Vue Router 4.5.0 与 Pinia 2.3.1(支持模板的 Vue 3.5.13 / TypeScript 5.6.3)。`base/src/router/index.ts` 保留通用 memory 工厂,host overlay 的 `router/index.ts` 则提供 `createAppRouter(controller, history?)`,默认使用 `createWebHistory(BASE_URL)`,由 Vue Router 独占顶级路径。`App.vue` 仅包含 Provider 与 `RouterView`,不再以 v-if 切换登录和工作区。vue overlay 继续使用 `createMicroRouter(embedded)`,嵌入模式 memory、独立模式 hash,保留列表/详情路由。公共 `stores/index.ts` 的 `createAppPinia()` 每次返回新容器;两个 bootstrap 均在 mount 前安装 router/Pinia,在卸载、失败和重挂载时销毁本轮 history 及 Pinia 作用域,不共享应用级状态。公共 `components/AppProviders.vue` 使用 `FConfigProvider` 与 `ElConfigProvider` 包裹 host/vue 全树,以 `useFinLocale()` 的全局 locale 响应式选择 fin-ui 的 `zhCN/en` 和 Element Plus 语言包,并同步 size(默认 default)。`locales/index.ts` 通过 fin-ui 重导出的 `installFinI18n` 在每次 createApp 后、mount 前独立安装,默认 zh-CN;不共享模块级 i18n 实例,`config.locale` 仅是语言包数据,不负责安装插件。模板显式安装 fin-locale 要求的 peer 依赖 `vue-i18n ^9.14.0`;业务子组件可用 `useFinLocale().locale.value = 'en'` 切换当前应用语言,不自动跨应用广播或翻译硬编码业务文案。公共 `base/src/utils/requestUtils.ts` 同时生成到基座与 Vue 子应用,通过 `@axewo/fin-core/http/axios` 为各项目创建独立 `AxiosHttpClient`,提供请求/响应拦截器的应用级替换点。基座平台适配使用专属 `utils/request.ts` 的独立 Axios 实例,配置原生 timeout/withCredentials;平台显式处理 Axios 401,并以局部 Token 请求菜单/注销。子应用不生成该平台实例。业务中使用 `import { http } from '../utils/requestUtils'`(未配置别名时使用相对路径),调用 `http.get/post`;返回整个响应体,业务 code/data 不自动解包。子应用拦截器通过 `http.interceptors` 配置,响应拦截器需保留 AxiosResponse,Token/401策略由业务明确接入。模板当前声明 fin-core 2.1.1,必须实际具备该 Axios 子路径和编译导出;其他版本同样以实际导出及兼容回归为准。本地联合安装前需重新构建、打包 core 与 micro,旧候选包不能代表本次源码。
82
178
 
83
179
  地址栏修改仍经过基座 canOpen;子应用本地路由不是权限表。独立子应用当前为演示,直接访问路由可以显示示例页;生产独立运行需接身份/权限检查或禁用独立业务模式。前端检查不能代替后台 API 的登录、操作权限与资源归属验证;隐藏页面可有显式授权,不能只以菜单是否展示判断访问权限。
84
180
 
181
+ ### host 真实路由与 URL 所有权
182
+
183
+ - `/login`:真实登录路由;登录成功只允许跳转到当前会话已授权的内部目标,外链、协议相对地址、反斜杠、登录循环、未知或未授权目标均回落 `/home`。
184
+ - `/home`、`/profile`:已认证工作区中的本地页;`/workspace?tab=...` 是远端活动页的公开导航快照(appId/tabId/route,不含凭据)。`/` 重定向主页,未匹配路径展示 404。
185
+ - `bootstrap` 每个 App 仅创建一个 `createPlatformSession()`,一次恢复 Promise 供初始路由守卫等待;`usePlatformSession()` 仅注入,不重复 restore 或注册 401 监听。正常退出先经 core 关闭全部页签的守卫再撤销会话并进入 `/login`;401 失效立即撤销,不被离页确认阻断。
186
+ - host 的 `connectWorkspaceBrowser` 先登记固定主页,再使用 `connectMicroBrowser(host, { url: false, initialLocation, storageKey })` 仅恢复/持久化用户隔离页签。`router/workspace.ts` 统一协调 Vue Router 和 core:UI 导航写入 router,直链/返回经过路由与页签守卫。连接器不再监听或覆盖 host 的 hash;未配置 `url:false` 的旧使用方式仍保留 hash 协议。
187
+ - 首次深链优先于存储,恢复逐项经过 `canOpen`;整页刷新产生新的 core key,不恢复 Token、草稿或沙箱实例。生产服务器必须将 `/login`、`/home`、`/profile`、`/workspace` 及其他 SPA 路径回退到该应用 `index.html`,静态资源与 `/api/` 不应被改写。子目录部署需同时配置 Vite base 和服务器回退目录。
188
+
189
+ 交互关闭统一通过宿主 `workspaceOperation` 加上 `exclude` 与 `closeFallback`,不修改通用库的默认关闭规则。`close/closeMany.closeFallback` 是可选的生命周期引用:只在活动页被关闭且目标仍存活时同事务回落,不创建页面;旧 key 不复活重开页,页面 SDK 不能借它跨应用导航。正常退出直接提交不排除主页的完整 `closeMany all`,仍尊重业务守卫;真实失效或 host dispose 直接清理全部,不被固定页策略阻断。固定不是权限或资源保留机制;底层宿主原语仍允许显式清理。模板与运行库需使用本次配套构建,不能仅更新模板沿用旧 dist。
190
+
191
+ ### host 统一路由守卫与扩展
192
+
193
+ `router/guards.ts` 是唯一 `beforeEach` 注册入口,执行顺序为:等待 `controller.ready` → 读取最新身份 → 登录与安全 redirect → 固定路由/远端授权和可选 `meta.permissions` → `beforeEnter` 业务扩展 → 当前 workspace 核心事务。`router/index.ts` 只声明路由并安装守卫,`router/workspace.ts` 使用 `attachWorkspaceGuard` 挂接现有 core `operate/beforeLeave`,不再注册第二个前置守卫。未登录受保护页进入 `/login?redirect=...`;已登录访问 `/login` 只返回经过 `authorizedRedirect` 检查的内部授权目标。404 保持独立兜底;已登录工作区离开到 404 仍须通过业务未保存确认。
194
+
195
+ 公开结构:
196
+
197
+ - `installHostGuards(router, controller, options?) → dispose`:每个 router 只允许一个安装;返回幂等撤销函数。
198
+ - `HostGuardOptions.beforeEnter({ to, from, session, signal }) → boolean | void | Promise<boolean | void>`:在内置授权后执行,返回 false 取消,true/void 继续;抛错或拒绝会取消并报告,不能凭扩展放宽内置白名单。
199
+ - `HostGuardOptions.onError(error)`:供宿主脱敏展示,不直接把原始异常写入 UI。
200
+ - `attachWorkspaceGuard(router, delegate) → detach`:只供工作区桥接核心离页事务,重复连接拒绝;卸载撤销旧委托,不能通过组件另建 beforeEach。
201
+ - `createAppRouter(controller, history?, options?) → router & { disposeGuards() }`:bootstrap 在卸载、启动失败和重挂载时显式撤销守卫,再销毁 history。
202
+
203
+ 可在 bootstrap 的原 `createAppRouter` 调用处增加业务限制,**不要另调 router.beforeEach**:
204
+
205
+ ```ts
206
+ const router = createAppRouter(controller, undefined, {
207
+ onError: reportError,
208
+ beforeEnter: ({ to, session, signal }) => {
209
+ if (signal.aborted) return false
210
+ // 示例:业务条件只能进一步限制已获本地路由白名单授权的入口。
211
+ return to.path !== '/profile' || Boolean(session.user.id)
212
+ },
213
+ })
214
+ ```
215
+
216
+ 路由记录可加 `meta: { authenticated: true, permissions: ['demo:read'] }`,表示必须具备全部声明权限;生产替换为真实权限码,同时补齐本地 route/tabId 白名单与页面映射,不能仅靠 meta 添加任意受保护页面。该前端检查不代替后台授权。
217
+
218
+ 新导航、身份替换或撤销会中止旧守卫等待;每次异步边界后复查导航代次和身份对象,迟到恢复/扩展结果不能放行旧导航。取消信号只撤销导航等待,不自动取消已经发生的保存或外部副作用。正常退出仍先完成核心离页事务;真实失效及注销撤销身份后跳过旧 workspace 委托,优先清理资源。首次恢复提交身份属于 ready 本身,不会误取消首次直链或刷新导航。
219
+
220
+ ### 启动依赖门禁
221
+
222
+ 生成项目在 `npm run dev/build` 前自动执行 `scripts/check-runtime.mjs`,也可单独运行 `npm run check:runtime`。它加载实际安装包并校验模板使用的导出,host 额外要求 `@axewo/fin-micro` 导出 `hostRouterProtocol === 1`(local、认证能力、仅存储连接器),缺失时在 Vite 链接前明确报告模块/API。版本号由生成器实际 package.json 注入;同版本的旧构建仍可能缺能力,不能靠版本字符串保证可用。请安装配套构建的 core/UI/micro 包,模板不会自动升级或降级依赖,也不能以源码 alias 验证代替实际安装验证。直接运行 `vite` 会绕过 npm 的前置门禁,不推荐。
223
+
224
+ DEV 的启动界面只增加固定阶段 `load-bootstrap/mount` 和白名单标准错误名;模块 `SyntaxError` 会提示先运行能力检查。生产仍只显示固定脱敏文案;两种环境均不回显异常 message、Token、请求/响应或堆栈。配置类错误需核对 `.env` 的公开配置;能力门禁不是后端连通性或接口协议测试。
225
+
226
+ ### 公共配置、错误边界与登录失效
227
+
228
+ 公共 `utils/appConfig.ts` 集中校验 `VITE_API_BASE`(默认 `/api/`)、`VITE_TRUSTED_API_BASE`(默认与 API 一致)、`VITE_API_TIMEOUT`(默认 30000 毫秒)及 `VITE_API_CREDENTIALS`(仅接受字符串 true/false,默认 false)。两个 API 目录必须完全一致、以 `/` 结尾;拒绝 userinfo、查询、片段、路径穿越及编码分隔符。跨源生产网关与应用代码入口使用 HTTPS,本机开发允许 HTTP。环境变量是公开部署配置,不能包含秘密。跨源 Cookie 需后端精确 CORS 与 CSRF 防护;默认相对 API 属于当前子应用运行源,并非自动代理到宿主。
229
+
230
+ `utils/errorUtils.ts` 将 Axios/HTTP/SDK 错误统一映射为取消、未登录、无权限、超时、网络及未知错误。界面与日志仅消费固定安全文案,不展示原始 message、响应体、URL、头、Token 或堆栈;取消静默。Vue `errorHandler`、登录表单、导航与连接失败使用相同分类。入口动态加载 bootstrap,因此模块加载/配置校验错误也有纯文本启动兜底与重试;卸载使晚到导入失效,失败与重挂载继续清理 router/Pinia/凭据订阅。构建配置错误需要更正环境并重新构建/刷新;重试不保证浏览器重新求值已失败的 ESM 模块。
231
+
232
+ Vue 嵌入时协商并安装 `getToken/beginAuthRequest/reportAuthFailure`。`createAuthFailureCapabilities` 是 SDK 正式契约,复用 `callHost` 白名单和生命周期;每次可信请求先获取不含凭据的不透明内存票据,绑定宿主私有凭据版本与可信 appId。401 或 Token 未登录按原票据报告,宿主核验后立即撤销身份、推进版本;并发仅失效一次,旧响应不能退出新登录。票据不得复制、序列化、持久化或用于 iframe RPC。平台自身请求也在发送时捕获私有版本。正常主动退出仍等待页面守卫,真实认证失效不允许被未保存守卫阻止。模板不刷新 Token、不自动重放任何请求,也不新增总线或请求库。
233
+
234
+ 独立 Vue 演示不安装 bridge 身份、不生成已登录快照,权限默认全部拒绝;列表/详情仅显式允许本地内存展示,不表示获得平台或 API 权限。生产独立部署应安装自己的认证与权限准入,或禁用该演示路径。基座演示身份另由 `DEV && VITE_AUTH_DEMO !== 'false'` 控制(开发默认启用),真实接口失败绝不降级为演示登录。
235
+
236
+ ### 响应式权限示例
237
+
238
+ ```ts
239
+ import { computed } from 'vue'
240
+ import { usePermission } from '@axewo/fin-micro/vue'
241
+
242
+ // identity 来自 createBridgeSession / subscribeSession,不读取或存储 Token。
243
+ const permission = usePermission(identity)
244
+ const canRead = permission.can('demo:read')
245
+ const canEdit = computed(() => permission.hasPermission('demo:write'))
246
+ const canUse = computed(() => permission.hasAny(['demo:read', 'demo:write']))
247
+ const canManage = computed(() => permission.hasAll(['demo:read', 'demo:write']))
248
+ ```
249
+
250
+ 框架无关逻辑可直接从 `/sdk` 导入 `hasPermission(session, name)`、`hasAny(session, names)`、`hasAll(session, names)`。未登录、缺失会话、空权限名及空数组均安全拒绝;不支持通配符或角色推断。Vue 权限变更/退出立即更新计算值;普通函数在 computed/模板内调用才追踪依赖,不要把单次布尔结果当作响应式状态。按钮显隐仅辅助体验,核心 canOpen 和服务端仍须独立鉴权。
251
+
252
+ ### locales / router / stores 联动示例
253
+
254
+ 两类生成项目均包含 `locales/zh-CN.ts`、`locales/en.ts`、`stores/demo.ts`、`router/demo.ts` 和 `views/demo/index.vue`。`installAppLocale` 将 `demo.*` 业务词条合并到 fin-ui 内置语言包;页面通过全局 `useI18n` 切换中文/英文,通过 `storeToRefs(useDemoStore())` 展示计数与双倍值,并调用 `increment/reset`。状态只属于本轮应用的 Pinia,不持久化、不跨应用共享,也不存储 Token。
255
+
256
+ - **vue 独立运行**:启动生成项目后访问 `/#/demo`(默认端口 7101)。`createMicroRouter` 复用 `demoRoutes`,原列表/详情与缓存接线不变。
257
+ - **vue 嵌入基座**:仅注册本地 `/demo` 不会获得访问授权。后台 `/api/menus` 须为当前已授权应用返回菜单,例如 `{ menuId: 'demo', name: '基础示例', target: { tabId: 'business:demo', route: '/demo' } }`(替换实际 appId 前缀),再从基座菜单打开。模板不自动添加 demo 菜单,也不放宽 `canOpenRoute`;未授权深链仍被拒绝。
258
+ - **host 本地扩展**:在 host 的 `router/index.ts` 添加真实路由记录及认证元信息,并在 `router/targets.ts` 补充固定路由到 core 页签的映射;工作区页面还需补充 `localPages.ts` 的精确白名单及 `HostWorkspace.vue` 的显式 SFC 映射。不可直接把 `demoRoutes` 传给会话参数,亦不可用本地路由代替平台授权。
259
+
260
+ 组件外使用 store 时显式传所属实例:`const demo = useDemoStore(pinia)`;组件内由已安装的 Pinia 注入。多页签属于同一子应用时共享该应用的计数,这是应用级状态,不是页签级缓存。
261
+
85
262
  ## 包入口
86
263
 
87
264
  | 入口 | 用途 |
package/cli/index.mjs CHANGED
@@ -14,13 +14,14 @@ const HELP = `fin-micro 离线项目创建工具
14
14
 
15
15
  用法:
16
16
  fin-micro create
17
- fin-micro create <directory> --template host|vue [--app-id <id>] [--port <port>]
17
+ fin-micro create <directory> --template host|vue [--app-id <id>] [--port <port>] [--layout standard|custom]
18
18
  fin-micro --help
19
19
 
20
20
  说明:
21
21
  目标目录必须不存在,父目录必须真实存在。
22
22
  目录名和应用 ID 仅支持小写字母、数字与连字符,首字符为字母或数字。
23
23
  host 默认端口 7100,vue 默认端口 7101;端口范围为 1–65535。
24
+ layout 默认 standard(包内公共外壳),custom 生成完整本地布局源码。
24
25
  不自动安装依赖,不访问网络。
25
26
  `;
26
27
 
@@ -49,7 +50,7 @@ export function parseArgs(args, { partial = false } = {}) {
49
50
  const directory = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
50
51
  if (!directory && !partial) throw new Error('请指定目标目录。');
51
52
  const options = directory ? { directory } : {};
52
- const names = { '--template': 'template', '--app-id': 'appId', '--port': 'port' };
53
+ const names = { '--template': 'template', '--app-id': 'appId', '--port': 'port', '--layout': 'layout' };
53
54
  for (let index = directory ? 2 : 1; index < args.length; index += 2) {
54
55
  const option = args[index];
55
56
  if (!Object.hasOwn(names, option)) throw new Error(`未知选项:${option}`);
@@ -84,7 +85,8 @@ function collectTemplate(root, current = root, files = new Map()) {
84
85
 
85
86
  /** 校验选项和真实父路径,不创建文件;交互重输与最终写入前复核共用此边界。 */
86
87
  function validateOptions(options, cwd) {
87
- const { directory, template } = options;
88
+ const { directory, template, layout = 'standard' } = options;
89
+ if (layout !== 'standard' && layout !== 'custom') throw new Error('布局只能是 standard 或 custom。');
88
90
  if (template !== 'host' && template !== 'vue') throw new Error('模板只能是 host 或 vue。');
89
91
  if (typeof directory !== 'string' || !directory || directory.endsWith('/') || directory.endsWith('\\')) {
90
92
  throw new Error('目标目录必须包含有效目录名,且不能以分隔符结尾。');
@@ -107,7 +109,7 @@ function validateOptions(options, cwd) {
107
109
  ? canonicalParent.toLowerCase() === resolve(parent).toLowerCase()
108
110
  : canonicalParent === resolve(parent);
109
111
  if (!sameParent) throw new Error('目标的父路径必须是真实存在的目录,且不能通过符号链接访问。');
110
- return { target, directory, template, projectName, appId, port };
112
+ return { target, directory, template, layout, projectName, appId, port };
111
113
  }
112
114
 
113
115
  /**
@@ -119,7 +121,7 @@ function validateOptions(options, cwd) {
119
121
  * 可预见错误在写入前失败;磁盘故障或外部并发篡改仍可能留下部分新文件,不递归删除以免误删用户内容。
120
122
  */
121
123
  export function createProject(options, { cwd = process.cwd(), packageRoot = PACKAGE_ROOT } = {}) {
122
- const { target, directory, template, projectName, appId, port } = validateOptions(options, cwd);
124
+ const { target, directory, template, layout, projectName, appId, port } = validateOptions(options, cwd);
123
125
  const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
124
126
  if (typeof metadata.version !== 'string' || !metadata.version) throw new Error('当前包缺少有效版本号。');
125
127
  const replacements = {
@@ -131,6 +133,14 @@ export function createProject(options, { cwd = process.cwd(), packageRoot = PACK
131
133
  };
132
134
  const files = collectTemplate(join(packageRoot, 'scaffold', 'base'));
133
135
  for (const [path, content] of collectTemplate(join(packageRoot, 'scaffold', template))) files.set(path, content);
136
+ // 本地模板是完整 custom 基线;standard 只替换装配层并移除已由包维护的重复展示。
137
+ // 所有素材仍先完成校验,任何非法 layout 都不得用于拼接任意文件系统路径。
138
+ if (layout === 'standard') {
139
+ const overlay = join(packageRoot, 'scaffold', 'layouts', 'standard', template);
140
+ for (const [path, content] of collectTemplate(overlay)) files.set(path, content);
141
+ const owned = template === 'host' ? ['HostHeader.vue', 'HostMenu.vue', 'HostTabs.vue'] : ['Header.vue', 'Sidebar.vue', 'SidebarItem.vue'];
142
+ for (const name of owned) files.delete(join('src', 'layout', name));
143
+ }
134
144
  const planned = new Map();
135
145
  for (const [path, content] of files) {
136
146
  const rendered = content.replace(/__[A-Z][A-Z0-9_]*?__/g, (placeholder) => {
@@ -154,7 +164,7 @@ export function createProject(options, { cwd = process.cwd(), packageRoot = PACK
154
164
  mkdirSync(dirname(destination), { recursive: true });
155
165
  writeFileSync(destination, content, { flag: 'wx' });
156
166
  }
157
- return { target, directory, template, appId, port };
167
+ return { target, directory, template, layout, appId, port };
158
168
  }
159
169
 
160
170
  /** 用户取消或输入流关闭时终止问答;在全部确认前不创建任何文件。 */
@@ -192,6 +202,12 @@ export async function completeOptions(initial, { interactive, ask, write = () =>
192
202
  options.template = ({ 1: 'host', 2: 'vue' })[options.template] ?? options.template;
193
203
  // 先校验显式参数,避免非法模板或标识被交互流程隐藏。
194
204
  validateOptions(options, cwd);
205
+ if (options.layout === undefined) {
206
+ const answer = await inquire('布局模式 [1 standard 公共外壳 / 2 custom 本地布局]', 'standard', value => {
207
+ if (!['1', '2', 'standard', 'custom'].includes(value)) throw new Error('请选择 1/standard 或 2/custom。');
208
+ });
209
+ options.layout = ({ 1: 'standard', 2: 'custom' })[answer] ?? answer;
210
+ }
195
211
  if (options.template === 'vue' && options.appId === undefined) {
196
212
  options.appId = await inquire('应用 app-id', basename(resolve(cwd, options.directory)), (value) => {
197
213
  if (!value || !NAME_PATTERN.test(value)) throw new Error('应用 ID 仅支持小写字母、数字和连字符。');
package/dist/browser.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function H(o){return`#/apps/${encodeURIComponent(o.appId)}/${encodeURIComponent(o.tabId)}?route=${encodeURIComponent(o.route)}`}function U(o){if(!o||o==="#/"||o==="#")return null;const p=/^#\/apps\/([^/?#]+)\/([^/?#]+)\?route=([^&#]*)$/.exec(o);if(!p)throw new Error("无效的微应用 URL");const[,r,v,h]=p;return{appId:decodeURIComponent(r),tabId:decodeURIComponent(v),route:decodeURIComponent(h)}}function P(o,p={}){const r=p.window??window,v=p.storageKey??"fin-micro:tabs:v1",h="fin-micro:navigation:v1",L=`${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`,f=t=>{var e;try{(e=p.onError)==null||e.call(p,t instanceof Error?t:new Error(String(t)))}catch{}},A=new Set(o.apps.map(t=>t.appId)),w=(t,e)=>typeof t=="string"&&t.length>0&&t.length<=e&&!/[\u0000-\u001f\u007f]/.test(t);function m(t){if(!t||typeof t!="object")return!1;const e=t;return w(e.appId,200)&&A.has(e.appId)&&w(e.tabId,500)&&w(e.title,500)&&w(e.route,8192)&&e.route.startsWith("/")&&!e.route.startsWith("//")&&!e.route.includes("\\")}const M=t=>({appId:t.appId,tabId:t.tabId,title:t.title,route:t.route,...t.activeMenuId!==void 0?{activeMenuId:t.activeMenuId}:{}});function $(t){try{r.sessionStorage.setItem(v,JSON.stringify({version:1,tabs:t.tabs.map(M),activeTabId:t.activeTabId}))}catch(e){f(e)}}function k(t,e=!1){try{const n=t.tabs.find(R=>R.tabId===t.activeTabId),s=n?H(n):"#/",a=r.history.state,c=a==null?void 0:a[h],d=r.location.hash===s;if(d&&(c==null?void 0:c.session)===L&&(c==null?void 0:c.key)===((n==null?void 0:n.key)??null))return;const i=`${r.location.pathname}${r.location.search}${s}`,b={...a&&typeof a=="object"?a:{},[h]:{session:L,key:(n==null?void 0:n.key)??null}};e&&!d?r.history.pushState(b,"",i):r.history.replaceState(b,"",i)}catch(n){f(n)}}function K(t){const e=o.getState().tabs.find(s=>s.tabId===t.tabId),n={...t,title:(e==null?void 0:e.title)??`${t.appId} · ${t.route}`.slice(0,500)};if(!m(n))throw new Error("URL 包含未知应用或无效页签/路由");o.openTab(n)}let u=null;try{u=U(r.location.hash)}catch(t){f(t)}try{const t=r.sessionStorage.getItem(v);if(t){const e=JSON.parse(t);if(!e||typeof e!="object")throw new Error("无效的页签存储");const n=e;if(n.version!==1||!Array.isArray(n.tabs)||n.tabs.length>100)throw new Error("不支持的页签存储版本或格式");const s=new Set(o.getState().tabs.map(a=>a.tabId));for(const a of n.tabs)if(!(!m(a)||s.has(a.tabId))&&!(u&&m({...u,title:"深链恢复"})&&u.tabId===a.tabId&&(u.appId!==a.appId||u.route!==a.route)))try{o.openTab(M(a)),s.add(a.tabId)}catch(c){f(c)}typeof n.activeTabId=="string"&&o.activateTab(n.activeTabId)}}catch(t){f(t)}try{u&&K(u)}catch(t){f(t)}let l=o.getState();k(l),$(l);let E=!1,y=!1;const O=o.subscribe(t=>{const e=l.tabs.some(n=>!t.tabs.some(s=>s.key===n.key));l=t,$(t),E||k(t,!e)});let g=0,I,S;function T(t){var c;if(y)return;const e=++g;I==null||I.abort();const n=new AbortController;I=n;const s=r.location.hash,a=(c=r.history.state)==null?void 0:c[h];E=!0,(async()=>{await Promise.resolve();try{if(y||e!==g)return;const d=U(s);if(!d)return;const i=o.getState().tabs.find(R=>R.tabId===d.tabId),b={...d,title:(i==null?void 0:i.title)??`${d.appId} · ${d.route}`.slice(0,500)};if(!m(b))throw new Error("URL 包含未知应用或无效页签/路由");if(t){if((a==null?void 0:a.session)!==L||!i||a.key!==i.key||i.appId!==b.appId||i.route!==b.route)return;await o.operate({type:"activate",target:{tabId:i.tabId,key:i.key},signal:n.signal})}else await o.operate({type:"open",input:b,signal:n.signal})}catch(d){!y&&e===g&&f(d)}finally{!y&&e===g&&(E=!1,l=o.getState(),k(l),$(l))}})()}const C=()=>{S=r.location.hash,T(!0)},j=t=>{const e=t.newURL,n=e?new URL(e).hash:r.location.hash;if(S===n){S=void 0;return}S=void 0,T(!1)};return r.addEventListener("popstate",C),r.addEventListener("hashchange",j),{dispose(){y||(y=!0,g++,I==null||I.abort(),O(),r.removeEventListener("popstate",C),r.removeEventListener("hashchange",j))}}}exports.connectMicroBrowser=P;exports.decodeMicroLocation=U;exports.encodeMicroLocation=H;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function H(o){return`#/apps/${encodeURIComponent(o.appId)}/${encodeURIComponent(o.tabId)}?route=${encodeURIComponent(o.route)}`}function U(o){if(!o||o==="#/"||o==="#")return null;const i=/^#\/apps\/([^/?#]+)\/([^/?#]+)\?route=([^&#]*)$/.exec(o);if(!i)throw new Error("无效的微应用 URL");const[,r,v,h]=i;return{appId:decodeURIComponent(r),tabId:decodeURIComponent(v),route:decodeURIComponent(h)}}function P(o,i={}){const r=i.window??window,v=i.storageKey??"fin-micro:tabs:v1",h="fin-micro:navigation:v1",S=`${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`,p=t=>{var e;try{(e=i.onError)==null||e.call(i,t instanceof Error?t:new Error(String(t)))}catch{}},A=new Set(o.apps.map(t=>t.appId)),w=(t,e)=>typeof t=="string"&&t.length>0&&t.length<=e&&!/[\u0000-\u001f\u007f]/.test(t);function m(t){if(!t||typeof t!="object")return!1;const e=t;return w(e.appId,200)&&A.has(e.appId)&&w(e.tabId,500)&&w(e.title,500)&&w(e.route,8192)&&e.route.startsWith("/")&&!e.route.startsWith("//")&&!e.route.includes("\\")}const M=t=>({appId:t.appId,tabId:t.tabId,title:t.title,route:t.route,...t.activeMenuId!==void 0?{activeMenuId:t.activeMenuId}:{}});function $(t){try{r.sessionStorage.setItem(v,JSON.stringify({version:1,tabs:t.tabs.map(M),activeTabId:t.activeTabId}))}catch(e){p(e)}}function k(t,e=!1){if(i.url!==!1)try{const n=t.tabs.find(R=>R.tabId===t.activeTabId),s=n?H(n):"#/",a=r.history.state,d=a==null?void 0:a[h],f=r.location.hash===s;if(f&&(d==null?void 0:d.session)===S&&(d==null?void 0:d.key)===((n==null?void 0:n.key)??null))return;const c=`${r.location.pathname}${r.location.search}${s}`,b={...a&&typeof a=="object"?a:{},[h]:{session:S,key:(n==null?void 0:n.key)??null}};e&&!f?r.history.pushState(b,"",c):r.history.replaceState(b,"",c)}catch(n){p(n)}}function K(t){var s;const e=o.getState().tabs.find(a=>a.tabId===t.tabId),n={...t,title:(e==null?void 0:e.title)??((s=i.initialLocation)==null?void 0:s.title)??`${t.appId} · ${t.route}`.slice(0,500)};if(!m(n))throw new Error("URL 包含未知应用或无效页签/路由");o.openTab(n)}let u=null;try{u=i.initialLocation??(i.url===!1?null:U(r.location.hash))}catch(t){p(t)}try{const t=r.sessionStorage.getItem(v);if(t){const e=JSON.parse(t);if(!e||typeof e!="object")throw new Error("无效的页签存储");const n=e;if(n.version!==1||!Array.isArray(n.tabs)||n.tabs.length>100)throw new Error("不支持的页签存储版本或格式");const s=new Set(o.getState().tabs.map(a=>a.tabId));for(const a of n.tabs)if(!(!m(a)||s.has(a.tabId))&&!(u&&m({...u,title:"深链恢复"})&&u.tabId===a.tabId&&(u.appId!==a.appId||u.route!==a.route)))try{o.openTab(M(a)),s.add(a.tabId)}catch(d){p(d)}typeof n.activeTabId=="string"&&o.activateTab(n.activeTabId)}}catch(t){p(t)}try{u&&K(u)}catch(t){p(t)}let l=o.getState();k(l),$(l);let E=!1,y=!1;const O=o.subscribe(t=>{const e=l.tabs.some(n=>!t.tabs.some(s=>s.key===n.key));l=t,$(t),E||k(t,!e)});let g=0,I,L;function T(t){var d;if(y)return;const e=++g;I==null||I.abort();const n=new AbortController;I=n;const s=r.location.hash,a=(d=r.history.state)==null?void 0:d[h];E=!0,(async()=>{await Promise.resolve();try{if(y||e!==g)return;const f=U(s);if(!f)return;const c=o.getState().tabs.find(R=>R.tabId===f.tabId),b={...f,title:(c==null?void 0:c.title)??`${f.appId} · ${f.route}`.slice(0,500)};if(!m(b))throw new Error("URL 包含未知应用或无效页签/路由");if(t){if((a==null?void 0:a.session)!==S||!c||a.key!==c.key||c.appId!==b.appId||c.route!==b.route)return;await o.operate({type:"activate",target:{tabId:c.tabId,key:c.key},signal:n.signal})}else await o.operate({type:"open",input:b,signal:n.signal})}catch(f){!y&&e===g&&p(f)}finally{!y&&e===g&&(E=!1,l=o.getState(),k(l),$(l))}})()}const C=()=>{L=r.location.hash,T(!0)},j=t=>{const e=t.newURL,n=e?new URL(e).hash:r.location.hash;if(L===n){L=void 0;return}L=void 0,T(!1)};return i.url!==!1&&(r.addEventListener("popstate",C),r.addEventListener("hashchange",j)),{dispose(){y||(y=!0,g++,I==null||I.abort(),O(),i.url!==!1&&(r.removeEventListener("popstate",C),r.removeEventListener("hashchange",j)))}}}exports.connectMicroBrowser=P;exports.decodeMicroLocation=U;exports.encodeMicroLocation=H;
package/dist/browser.mjs CHANGED
@@ -1,25 +1,25 @@
1
- function x(o) {
2
- return `#/apps/${encodeURIComponent(o.appId)}/${encodeURIComponent(o.tabId)}?route=${encodeURIComponent(o.route)}`;
1
+ function x(r) {
2
+ return `#/apps/${encodeURIComponent(r.appId)}/${encodeURIComponent(r.tabId)}?route=${encodeURIComponent(r.route)}`;
3
3
  }
4
- function H(o) {
5
- if (!o || o === "#/" || o === "#") return null;
6
- const p = /^#\/apps\/([^/?#]+)\/([^/?#]+)\?route=([^&#]*)$/.exec(o);
7
- if (!p) throw new Error("无效的微应用 URL");
8
- const [, r, v, y] = p;
4
+ function H(r) {
5
+ if (!r || r === "#/" || r === "#") return null;
6
+ const i = /^#\/apps\/([^/?#]+)\/([^/?#]+)\?route=([^&#]*)$/.exec(r);
7
+ if (!i) throw new Error("无效的微应用 URL");
8
+ const [, o, v, y] = i;
9
9
  return {
10
- appId: decodeURIComponent(r),
10
+ appId: decodeURIComponent(o),
11
11
  tabId: decodeURIComponent(v),
12
12
  route: decodeURIComponent(y)
13
13
  };
14
14
  }
15
- function J(o, p = {}) {
16
- const r = p.window ?? window, v = p.storageKey ?? "fin-micro:tabs:v1", y = "fin-micro:navigation:v1", L = `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`, u = (t) => {
15
+ function J(r, i = {}) {
16
+ const o = i.window ?? window, v = i.storageKey ?? "fin-micro:tabs:v1", y = "fin-micro:navigation:v1", S = `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`, u = (t) => {
17
17
  var e;
18
18
  try {
19
- (e = p.onError) == null || e.call(p, t instanceof Error ? t : new Error(String(t)));
19
+ (e = i.onError) == null || e.call(i, t instanceof Error ? t : new Error(String(t)));
20
20
  } catch {
21
21
  }
22
- }, j = new Set(o.apps.map((t) => t.appId)), w = (t, e) => typeof t == "string" && t.length > 0 && t.length <= e && !/[\u0000-\u001f\u007f]/.test(t);
22
+ }, j = new Set(r.apps.map((t) => t.appId)), w = (t, e) => typeof t == "string" && t.length > 0 && t.length <= e && !/[\u0000-\u001f\u007f]/.test(t);
23
23
  function m(t) {
24
24
  if (!t || typeof t != "object") return !1;
25
25
  const e = t;
@@ -34,7 +34,7 @@ function J(o, p = {}) {
34
34
  });
35
35
  function $(t) {
36
36
  try {
37
- r.sessionStorage.setItem(
37
+ o.sessionStorage.setItem(
38
38
  v,
39
39
  JSON.stringify({
40
40
  version: 1,
@@ -47,116 +47,118 @@ function J(o, p = {}) {
47
47
  }
48
48
  }
49
49
  function k(t, e = !1) {
50
- try {
51
- const n = t.tabs.find((R) => R.tabId === t.activeTabId), s = n ? x(n) : "#/", a = r.history.state, c = a == null ? void 0 : a[y], d = r.location.hash === s;
52
- if (d && (c == null ? void 0 : c.session) === L && (c == null ? void 0 : c.key) === ((n == null ? void 0 : n.key) ?? null)) return;
53
- const i = `${r.location.pathname}${r.location.search}${s}`, b = {
54
- ...a && typeof a == "object" ? a : {},
55
- [y]: { session: L, key: (n == null ? void 0 : n.key) ?? null }
56
- };
57
- e && !d ? r.history.pushState(b, "", i) : r.history.replaceState(b, "", i);
58
- } catch (n) {
59
- u(n);
60
- }
50
+ if (i.url !== !1)
51
+ try {
52
+ const n = t.tabs.find((R) => R.tabId === t.activeTabId), s = n ? x(n) : "#/", a = o.history.state, d = a == null ? void 0 : a[y], f = o.location.hash === s;
53
+ if (f && (d == null ? void 0 : d.session) === S && (d == null ? void 0 : d.key) === ((n == null ? void 0 : n.key) ?? null)) return;
54
+ const c = `${o.location.pathname}${o.location.search}${s}`, b = {
55
+ ...a && typeof a == "object" ? a : {},
56
+ [y]: { session: S, key: (n == null ? void 0 : n.key) ?? null }
57
+ };
58
+ e && !f ? o.history.pushState(b, "", c) : o.history.replaceState(b, "", c);
59
+ } catch (n) {
60
+ u(n);
61
+ }
61
62
  }
62
63
  function A(t) {
63
- const e = o.getState().tabs.find((s) => s.tabId === t.tabId), n = {
64
+ var s;
65
+ const e = r.getState().tabs.find((a) => a.tabId === t.tabId), n = {
64
66
  ...t,
65
- title: (e == null ? void 0 : e.title) ?? `${t.appId} · ${t.route}`.slice(0, 500)
67
+ title: (e == null ? void 0 : e.title) ?? ((s = i.initialLocation) == null ? void 0 : s.title) ?? `${t.appId} · ${t.route}`.slice(0, 500)
66
68
  };
67
69
  if (!m(n)) throw new Error("URL 包含未知应用或无效页签/路由");
68
- o.openTab(n);
70
+ r.openTab(n);
69
71
  }
70
- let f = null;
72
+ let p = null;
71
73
  try {
72
- f = H(r.location.hash);
74
+ p = i.initialLocation ?? (i.url === !1 ? null : H(o.location.hash));
73
75
  } catch (t) {
74
76
  u(t);
75
77
  }
76
78
  try {
77
- const t = r.sessionStorage.getItem(v);
79
+ const t = o.sessionStorage.getItem(v);
78
80
  if (t) {
79
81
  const e = JSON.parse(t);
80
82
  if (!e || typeof e != "object") throw new Error("无效的页签存储");
81
83
  const n = e;
82
84
  if (n.version !== 1 || !Array.isArray(n.tabs) || n.tabs.length > 100)
83
85
  throw new Error("不支持的页签存储版本或格式");
84
- const s = new Set(o.getState().tabs.map((a) => a.tabId));
86
+ const s = new Set(r.getState().tabs.map((a) => a.tabId));
85
87
  for (const a of n.tabs)
86
- if (!(!m(a) || s.has(a.tabId)) && !(f && m({ ...f, title: "深链恢复" }) && f.tabId === a.tabId && (f.appId !== a.appId || f.route !== a.route)))
88
+ if (!(!m(a) || s.has(a.tabId)) && !(p && m({ ...p, title: "深链恢复" }) && p.tabId === a.tabId && (p.appId !== a.appId || p.route !== a.route)))
87
89
  try {
88
- o.openTab(U(a)), s.add(a.tabId);
89
- } catch (c) {
90
- u(c);
90
+ r.openTab(U(a)), s.add(a.tabId);
91
+ } catch (d) {
92
+ u(d);
91
93
  }
92
- typeof n.activeTabId == "string" && o.activateTab(n.activeTabId);
94
+ typeof n.activeTabId == "string" && r.activateTab(n.activeTabId);
93
95
  }
94
96
  } catch (t) {
95
97
  u(t);
96
98
  }
97
99
  try {
98
- f && A(f);
100
+ p && A(p);
99
101
  } catch (t) {
100
102
  u(t);
101
103
  }
102
- let I = o.getState();
103
- k(I), $(I);
104
+ let l = r.getState();
105
+ k(l), $(l);
104
106
  let E = !1, h = !1;
105
- const K = o.subscribe((t) => {
106
- const e = I.tabs.some((n) => !t.tabs.some((s) => s.key === n.key));
107
- I = t, $(t), E || k(t, !e);
107
+ const K = r.subscribe((t) => {
108
+ const e = l.tabs.some((n) => !t.tabs.some((s) => s.key === n.key));
109
+ l = t, $(t), E || k(t, !e);
108
110
  });
109
- let g = 0, l, S;
111
+ let g = 0, I, L;
110
112
  function T(t) {
111
- var c;
113
+ var d;
112
114
  if (h) return;
113
115
  const e = ++g;
114
- l == null || l.abort();
116
+ I == null || I.abort();
115
117
  const n = new AbortController();
116
- l = n;
117
- const s = r.location.hash, a = (c = r.history.state) == null ? void 0 : c[y];
118
+ I = n;
119
+ const s = o.location.hash, a = (d = o.history.state) == null ? void 0 : d[y];
118
120
  E = !0, (async () => {
119
121
  await Promise.resolve();
120
122
  try {
121
123
  if (h || e !== g) return;
122
- const d = H(s);
123
- if (!d) return;
124
- const i = o.getState().tabs.find((R) => R.tabId === d.tabId), b = {
125
- ...d,
126
- title: (i == null ? void 0 : i.title) ?? `${d.appId} · ${d.route}`.slice(0, 500)
124
+ const f = H(s);
125
+ if (!f) return;
126
+ const c = r.getState().tabs.find((R) => R.tabId === f.tabId), b = {
127
+ ...f,
128
+ title: (c == null ? void 0 : c.title) ?? `${f.appId} · ${f.route}`.slice(0, 500)
127
129
  };
128
130
  if (!m(b)) throw new Error("URL 包含未知应用或无效页签/路由");
129
131
  if (t) {
130
- if ((a == null ? void 0 : a.session) !== L || !i || a.key !== i.key || i.appId !== b.appId || i.route !== b.route)
132
+ if ((a == null ? void 0 : a.session) !== S || !c || a.key !== c.key || c.appId !== b.appId || c.route !== b.route)
131
133
  return;
132
- await o.operate({
134
+ await r.operate({
133
135
  type: "activate",
134
- target: { tabId: i.tabId, key: i.key },
136
+ target: { tabId: c.tabId, key: c.key },
135
137
  signal: n.signal
136
138
  });
137
139
  } else
138
- await o.operate({ type: "open", input: b, signal: n.signal });
139
- } catch (d) {
140
- !h && e === g && u(d);
140
+ await r.operate({ type: "open", input: b, signal: n.signal });
141
+ } catch (f) {
142
+ !h && e === g && u(f);
141
143
  } finally {
142
- !h && e === g && (E = !1, I = o.getState(), k(I), $(I));
144
+ !h && e === g && (E = !1, l = r.getState(), k(l), $(l));
143
145
  }
144
146
  })();
145
147
  }
146
148
  const C = () => {
147
- S = r.location.hash, T(!0);
149
+ L = o.location.hash, T(!0);
148
150
  }, M = (t) => {
149
- const e = t.newURL, n = e ? new URL(e).hash : r.location.hash;
150
- if (S === n) {
151
- S = void 0;
151
+ const e = t.newURL, n = e ? new URL(e).hash : o.location.hash;
152
+ if (L === n) {
153
+ L = void 0;
152
154
  return;
153
155
  }
154
- S = void 0, T(!1);
156
+ L = void 0, T(!1);
155
157
  };
156
- return r.addEventListener("popstate", C), r.addEventListener("hashchange", M), {
158
+ return i.url !== !1 && (o.addEventListener("popstate", C), o.addEventListener("hashchange", M)), {
157
159
  /** 幂等移除本连接器的宿主订阅和浏览器事件;不清空存储、不关闭页签、不销毁宿主。 */
158
160
  dispose() {
159
- h || (h = !0, g++, l == null || l.abort(), K(), r.removeEventListener("popstate", C), r.removeEventListener("hashchange", M));
161
+ h || (h = !0, g++, I == null || I.abort(), K(), i.url !== !1 && (o.removeEventListener("popstate", C), o.removeEventListener("hashchange", M)));
160
162
  }
161
163
  };
162
164
  }