@axewo/fin-micro 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -2
- package/cli/index.mjs +240 -0
- package/dist/browser.cjs +1 -0
- package/dist/browser.mjs +169 -0
- package/dist/core.cjs +1 -0
- package/dist/core.mjs +654 -0
- package/dist/iframe.cjs +1 -0
- package/dist/iframe.mjs +146 -0
- package/dist/index.cjs +1 -0
- package/dist/index.mjs +35 -0
- package/dist/router-view-impl-B1NzCGKV.mjs +76 -0
- package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
- package/dist/runtime.cjs +1 -0
- package/dist/runtime.mjs +149 -0
- package/dist/sdk.cjs +1 -0
- package/dist/sdk.mjs +118 -0
- package/dist/types/browser/index.d.ts +46 -0
- package/dist/types/core/index.d.ts +63 -0
- package/dist/types/iframe/index.d.ts +77 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/runtime/index.d.ts +21 -0
- package/dist/types/runtime/lifecycle.d.ts +23 -0
- package/dist/types/sdk/index.d.ts +397 -0
- package/dist/types/vue/index.d.ts +102 -0
- package/dist/types/vue/router-view-impl.d.ts +41 -0
- package/dist/types/vue/router-view.d.ts +42 -0
- package/dist/types/vue/tab-context.d.ts +6 -0
- package/dist/vue.cjs +1 -0
- package/dist/vue.mjs +180 -0
- package/package.json +91 -3
- package/scaffold/base/index.html +12 -0
- package/scaffold/base/package.json.template +33 -0
- package/scaffold/base/scripts/check-runtime.mjs +43 -0
- package/scaffold/base/src/components/.gitkeep +1 -0
- package/scaffold/base/src/components/AppProviders.vue +25 -0
- package/scaffold/base/src/hooks/.gitkeep +1 -0
- package/scaffold/base/src/layout/.gitkeep +1 -0
- package/scaffold/base/src/locales/.gitkeep +1 -0
- package/scaffold/base/src/locales/en.ts +12 -0
- package/scaffold/base/src/locales/index.ts +13 -0
- package/scaffold/base/src/locales/zh-CN.ts +12 -0
- package/scaffold/base/src/main.ts +22 -0
- package/scaffold/base/src/router/.gitkeep +1 -0
- package/scaffold/base/src/router/demo.ts +6 -0
- package/scaffold/base/src/router/index.ts +13 -0
- package/scaffold/base/src/stores/.gitkeep +1 -0
- package/scaffold/base/src/stores/demo.ts +12 -0
- package/scaffold/base/src/stores/index.ts +11 -0
- package/scaffold/base/src/styles/index.css +18 -0
- package/scaffold/base/src/types/.gitkeep +1 -0
- package/scaffold/base/src/utils/.gitkeep +1 -0
- package/scaffold/base/src/utils/appConfig.ts +49 -0
- package/scaffold/base/src/utils/errorUtils.ts +92 -0
- package/scaffold/base/src/utils/requestUtils.ts +144 -0
- package/scaffold/base/src/views/.gitkeep +1 -0
- package/scaffold/base/src/views/DemoView.vue +26 -0
- package/scaffold/base/tsconfig.json +16 -0
- package/scaffold/base/vite.config.ts +8 -0
- package/scaffold/host/.env.example +13 -0
- package/scaffold/host/src/App.vue +4 -0
- package/scaffold/host/src/bootstrap.ts +50 -0
- package/scaffold/host/src/hooks/usePlatformSession.ts +91 -0
- package/scaffold/host/src/layout/HostHeader.vue +69 -0
- package/scaffold/host/src/layout/HostLayout.vue +168 -0
- package/scaffold/host/src/layout/HostMenu.vue +15 -0
- package/scaffold/host/src/layout/HostTabs.vue +47 -0
- package/scaffold/host/src/layout/HostWorkspace.vue +26 -0
- package/scaffold/host/src/localPages.ts +45 -0
- package/scaffold/host/src/platform.ts +239 -0
- package/scaffold/host/src/router/guards.ts +128 -0
- package/scaffold/host/src/router/index.ts +25 -0
- package/scaffold/host/src/router/targets.ts +30 -0
- package/scaffold/host/src/router/workspace.ts +63 -0
- package/scaffold/host/src/styles/shell.css +761 -0
- package/scaffold/host/src/types/platform.ts +12 -0
- package/scaffold/host/src/utils/request.ts +42 -0
- package/scaffold/host/src/views/HomeView.vue +19 -0
- package/scaffold/host/src/views/LoginRoute.vue +16 -0
- package/scaffold/host/src/views/LoginView.vue +88 -0
- package/scaffold/host/src/views/NotFoundView.vue +1 -0
- package/scaffold/host/src/views/ProfileView.vue +24 -0
- package/scaffold/host/src/views/WorkspaceRoute.vue +11 -0
- package/scaffold/vue/src/App.vue +24 -0
- package/scaffold/vue/src/bootstrap.ts +67 -0
- package/scaffold/vue/src/bridge/auth.ts +23 -0
- package/scaffold/vue/src/bridge/session.ts +16 -0
- package/scaffold/vue/src/main.ts +13 -0
- package/scaffold/vue/src/router/index.ts +16 -0
- package/scaffold/vue/src/views/DetailView.vue +57 -0
- package/scaffold/vue/src/views/ListView.vue +55 -0
- package/scaffold/vue/src/views/NotFoundView.vue +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,224 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @axewo/fin-micro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
统一页签式微前端库:无框架 core/SDK、Wujie 应用运行时、Vue 页级缓存适配、浏览器恢复和独立 iframe RPC。
|
|
4
|
+
|
|
5
|
+
## 完整使用手册
|
|
6
|
+
|
|
7
|
+
详细文档已接入仓库 VitePress 文档站的“微前端”栏目:
|
|
8
|
+
|
|
9
|
+
- [总览与设计边界](../../apps/docs/micro/index.md)
|
|
10
|
+
- [快速开始与项目结构](../../apps/docs/micro/getting-started.md)
|
|
11
|
+
- [路由参数与跨应用导航](../../apps/docs/micro/navigation.md)
|
|
12
|
+
- [页面缓存与浏览器恢复](../../apps/docs/micro/cache-and-browser.md)
|
|
13
|
+
- [宿主通信、会话与认证](../../apps/docs/micro/communication.md)
|
|
14
|
+
- [独立 iframe RPC](../../apps/docs/micro/iframe.md)
|
|
15
|
+
- [API 索引与排错验收](../../apps/docs/micro/reference.md)
|
|
16
|
+
|
|
17
|
+
在仓库根执行:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm --filter @axewo/fin-micro build
|
|
21
|
+
pnpm micro:dev
|
|
22
|
+
pnpm --filter @axewo/fin-docs dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
文档路径为 `/micro/`。模板位于 `apps/micro-host` 和 `apps/micro-vue`,首次接入建议先运行模板,再替换业务视图与权限规则。
|
|
26
|
+
|
|
27
|
+
## 按需获取当前 Token
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { getToken } from '@axewo/fin-micro/sdk'
|
|
31
|
+
const token = await getToken({ bridge, signal, timeoutMs: 5000 })
|
|
32
|
+
// 已登录 Cookie 模式返回 null;未登录、无桥、会话已变化均有独立错误码。
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
基座通过 `createTokenCapability({ apps, getSession, provider })` 显式授权可信应用,每次读取私有凭据,异步后复查会话 revision 与调用存活。Token 不进入公开会话或事件广播。CLI host 模板默认保留 HttpOnly Cookie,后台 login/session 可选返回 `accessToken`;退出、初始化失败及卸载清除内存。base 请求只在安装 provider 后动态注入,vue bootstrap 在嵌入挂载时安装、卸载时取消并还原;独立运行或 host 不隐式读取 bridge。默认仅可信 `/api/` 请求可携带 Token,绝对 URL 和被覆盖的 baseURL 不自动注入。详见[认证接线与安全边界](../../apps/docs/micro/communication.md)。
|
|
36
|
+
|
|
37
|
+
## 子应用生命周期与环境判断
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { isMicroFrontend, setupWujieLifecycle } from '@axewo/fin-micro/runtime'
|
|
41
|
+
import { mount, unmount } from './bootstrap'
|
|
42
|
+
|
|
43
|
+
// 仅检测当前 Wujie 环境,不代表 bridge 连接、身份或权限。
|
|
44
|
+
console.log(isMicroFrontend())
|
|
45
|
+
const dispose = setupWujieLifecycle({ mount, unmount })
|
|
46
|
+
if (import.meta.hot) import.meta.hot.dispose(dispose)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`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
|
+
|
|
51
|
+
基座使用普通启动入口,只有 vue 子应用模板接入上述封装;不再在 base 中注册子应用专属全局回调。
|
|
52
|
+
|
|
53
|
+
## 单包创建项目(本地首版)
|
|
54
|
+
|
|
55
|
+
运行库与创建命令使用同一个包,不另发 create-fin-micro。包发布后可执行以下命令;当前开发不代表已经发布至 npm:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
# 交互式:依次输入目录、选择 host/vue、输入子应用 app-id 和端口
|
|
59
|
+
npx @axewo/fin-micro@latest create
|
|
60
|
+
|
|
61
|
+
# 参数式:适合脚本和 CI,不触发问答
|
|
62
|
+
npx @axewo/fin-micro create my-host --template host
|
|
63
|
+
npx @axewo/fin-micro create order-app --template vue --app-id order --port 7101
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
仓库内无需发布即可验证:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
node packages/fin-micro/cli/index.mjs create ./my-host --template host
|
|
70
|
+
node packages/fin-micro/cli/index.mjs create ./order-app --template vue --app-id order
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`host` 默认端口 7100,`vue` 默认端口 7101;项目名与 appId 只接受小写字母、数字和连字符。子应用示例建议指定 `--app-id business`,与基座默认登记保持一致;或调整基座的环境变量。目标目录必须不存在,父目录必须已存在;命令不覆盖文件、不自动安装依赖、不自动注册远端应用、不执行发布。进入生成项目后运行 `pnpm install`、`pnpm dev` 和 `pnpm build`。生成源码属于项目,CLI 不升级或覆盖既有源码。
|
|
74
|
+
|
|
75
|
+
### host 本地登录与主页体验
|
|
76
|
+
|
|
77
|
+
用户未指定已生成项目时,修改目标是 `scaffold/host`,不会自动覆盖其他生成项目。仓库内可在一个已存在的父目录创建全新项目:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
node packages/fin-micro/cli/index.mjs create ./my-host --template host
|
|
81
|
+
# 进入 my-host 后安装依赖并启动(需要可用的 fin-core / fin-ui / fin-micro 对应版本)
|
|
82
|
+
pnpm install
|
|
83
|
+
pnpm dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
打开 Vite 输出的本机地址(默认端口 7100),点击“演示登录,进入主页”。开发默认启用演示,不需要后端或子应用,不展示或收集账号密码,固定显示“演示用户”。登录后默认打开真正的本地主页;左侧 Element 菜单、顶部搜索、个人菜单均可打开“主页 / 个人中心”。个人页展示公开身份,可回主页或退出。主页与 `apps/micro-host` 一样属于唯一 core tabs,而非独立入口或第二套标签状态:认证后在恢复前同步登记,初始、直链和刷新恢复均固定首位且不重复;缺少主页或主页顺序错误的旧快照自动迁移,业务页之间的顺序不变。主页无关闭按钮,右键菜单禁用刷新与关闭;“关闭全部业务页 / 其他 / 左侧 / 右侧”始终排除主页(个人中心也属于可关闭页)。业务页仍可正常刷新;关闭活动页在一次核心事务内回落主页,URL 经同一 Vue Router 更新,守卫拒绝则保持原页和 URL。不放置虚假业务数据或未实现业务按钮。
|
|
87
|
+
|
|
88
|
+
刷新仅凭当前标签页 `sessionStorage` 的 `fin-micro:demo-session:v1=1` 标记恢复固定演示身份,随后通过原 `connectMicroBrowser` 恢复已授权页签,产生新的生命周期 key。它不是 Token,不含账号密码,不访问认证接口。禁用浏览器存储时仍可登录,但无法跨刷新恢复。退出须经原核心离页守卫;确认后关闭全部页签、清除演示标记与私有凭据。认证失效也清除标记,组件卸载不清除标记以支持刷新。
|
|
89
|
+
|
|
90
|
+
如需真实后端联调,在 `.env.local` 设置 `VITE_AUTH_DEMO=false`。生产构建始终禁止演示,即使设置 true;请求 `/api/session`、`/api/login`、`/api/menus`、`/api/logout`,任何网络、认证或菜单失败均不降级演示。部署前必须接入真实认证、服务端授权及 CSRF 防护;开发标记不具有任何生产认证效力。后台菜单只提供授权路由,应用执行地址来自可信本地注册表。
|
|
91
|
+
|
|
92
|
+
本地页使用明确的 `{ 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 失败能力只向原可信业务应用白名单授权。可选业务入口需要启动子应用,但它离线不影响本地工作台。
|
|
93
|
+
|
|
94
|
+
`scaffold/base` 保存公共骨架,`scaffold/host`、`scaffold/vue` 按类型覆盖差异文件;两类模板统一接入 fin-ui。基座仅包含登录、公开用户信息、菜单与微前端平台编排,不包含系统管理业务。认证示例不是生产认证,需替换接口适配并补齐服务端鉴权;应用入口、appId、菜单权限需按实际配置。
|
|
95
|
+
|
|
96
|
+
运行库通过 npm 依赖导入,不复制 core 源码。公开包使用 files 白名单,仅交付运行库 JS、类型声明、创建命令与可公开的骨架源码,不交付运行库 src 或 sourcemap。编译 JavaScript 仍然可被分析,不应含密钥或保密逻辑。库升级需回归、构建、部署,无需重新创建项目;模板更新不会自动迁移生成项目。
|
|
97
|
+
|
|
98
|
+
生成项目的 `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,旧候选包不能代表本次源码。
|
|
99
|
+
|
|
100
|
+
地址栏修改仍经过基座 canOpen;子应用本地路由不是权限表。独立子应用当前为演示,直接访问路由可以显示示例页;生产独立运行需接身份/权限检查或禁用独立业务模式。前端检查不能代替后台 API 的登录、操作权限与资源归属验证;隐藏页面可有显式授权,不能只以菜单是否展示判断访问权限。
|
|
101
|
+
|
|
102
|
+
### host 真实路由与 URL 所有权
|
|
103
|
+
|
|
104
|
+
- `/login`:真实登录路由;登录成功只允许跳转到当前会话已授权的内部目标,外链、协议相对地址、反斜杠、登录循环、未知或未授权目标均回落 `/home`。
|
|
105
|
+
- `/home`、`/profile`:已认证工作区中的本地页;`/workspace?tab=...` 是远端活动页的公开导航快照(appId/tabId/route,不含凭据)。`/` 重定向主页,未匹配路径展示 404。
|
|
106
|
+
- `bootstrap` 每个 App 仅创建一个 `createPlatformSession()`,一次恢复 Promise 供初始路由守卫等待;`usePlatformSession()` 仅注入,不重复 restore 或注册 401 监听。正常退出先经 core 关闭全部页签的守卫再撤销会话并进入 `/login`;401 失效立即撤销,不被离页确认阻断。
|
|
107
|
+
- host 的 `connectWorkspaceBrowser` 先登记固定主页,再使用 `connectMicroBrowser(host, { url: false, initialLocation, storageKey })` 仅恢复/持久化用户隔离页签。`router/workspace.ts` 统一协调 Vue Router 和 core:UI 导航写入 router,直链/返回经过路由与页签守卫。连接器不再监听或覆盖 host 的 hash;未配置 `url:false` 的旧使用方式仍保留 hash 协议。
|
|
108
|
+
- 首次深链优先于存储,恢复逐项经过 `canOpen`;整页刷新产生新的 core key,不恢复 Token、草稿或沙箱实例。生产服务器必须将 `/login`、`/home`、`/profile`、`/workspace` 及其他 SPA 路径回退到该应用 `index.html`,静态资源与 `/api/` 不应被改写。子目录部署需同时配置 Vite base 和服务器回退目录。
|
|
109
|
+
|
|
110
|
+
交互关闭统一通过宿主 `workspaceOperation` 加上 `exclude` 与 `closeFallback`,不修改通用库的默认关闭规则。`close/closeMany.closeFallback` 是可选的生命周期引用:只在活动页被关闭且目标仍存活时同事务回落,不创建页面;旧 key 不复活重开页,页面 SDK 不能借它跨应用导航。正常退出直接提交不排除主页的完整 `closeMany all`,仍尊重业务守卫;真实失效或 host dispose 直接清理全部,不被固定页策略阻断。固定不是权限或资源保留机制;底层宿主原语仍允许显式清理。模板与运行库需使用本次配套构建,不能仅更新模板沿用旧 dist。
|
|
111
|
+
|
|
112
|
+
### host 统一路由守卫与扩展
|
|
113
|
+
|
|
114
|
+
`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 仍须通过业务未保存确认。
|
|
115
|
+
|
|
116
|
+
公开结构:
|
|
117
|
+
|
|
118
|
+
- `installHostGuards(router, controller, options?) → dispose`:每个 router 只允许一个安装;返回幂等撤销函数。
|
|
119
|
+
- `HostGuardOptions.beforeEnter({ to, from, session, signal }) → boolean | void | Promise<boolean | void>`:在内置授权后执行,返回 false 取消,true/void 继续;抛错或拒绝会取消并报告,不能凭扩展放宽内置白名单。
|
|
120
|
+
- `HostGuardOptions.onError(error)`:供宿主脱敏展示,不直接把原始异常写入 UI。
|
|
121
|
+
- `attachWorkspaceGuard(router, delegate) → detach`:只供工作区桥接核心离页事务,重复连接拒绝;卸载撤销旧委托,不能通过组件另建 beforeEach。
|
|
122
|
+
- `createAppRouter(controller, history?, options?) → router & { disposeGuards() }`:bootstrap 在卸载、启动失败和重挂载时显式撤销守卫,再销毁 history。
|
|
123
|
+
|
|
124
|
+
可在 bootstrap 的原 `createAppRouter` 调用处增加业务限制,**不要另调 router.beforeEach**:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const router = createAppRouter(controller, undefined, {
|
|
128
|
+
onError: reportError,
|
|
129
|
+
beforeEnter: ({ to, session, signal }) => {
|
|
130
|
+
if (signal.aborted) return false
|
|
131
|
+
// 示例:业务条件只能进一步限制已获本地路由白名单授权的入口。
|
|
132
|
+
return to.path !== '/profile' || Boolean(session.user.id)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
路由记录可加 `meta: { authenticated: true, permissions: ['demo:read'] }`,表示必须具备全部声明权限;生产替换为真实权限码,同时补齐本地 route/tabId 白名单与页面映射,不能仅靠 meta 添加任意受保护页面。该前端检查不代替后台授权。
|
|
138
|
+
|
|
139
|
+
新导航、身份替换或撤销会中止旧守卫等待;每次异步边界后复查导航代次和身份对象,迟到恢复/扩展结果不能放行旧导航。取消信号只撤销导航等待,不自动取消已经发生的保存或外部副作用。正常退出仍先完成核心离页事务;真实失效及注销撤销身份后跳过旧 workspace 委托,优先清理资源。首次恢复提交身份属于 ready 本身,不会误取消首次直链或刷新导航。
|
|
140
|
+
|
|
141
|
+
### 启动依赖门禁
|
|
142
|
+
|
|
143
|
+
生成项目在 `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 的前置门禁,不推荐。
|
|
144
|
+
|
|
145
|
+
DEV 的启动界面只增加固定阶段 `load-bootstrap/mount` 和白名单标准错误名;模块 `SyntaxError` 会提示先运行能力检查。生产仍只显示固定脱敏文案;两种环境均不回显异常 message、Token、请求/响应或堆栈。配置类错误需核对 `.env` 的公开配置;能力门禁不是后端连通性或接口协议测试。
|
|
146
|
+
|
|
147
|
+
### 公共配置、错误边界与登录失效
|
|
148
|
+
|
|
149
|
+
公共 `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 属于当前子应用运行源,并非自动代理到宿主。
|
|
150
|
+
|
|
151
|
+
`utils/errorUtils.ts` 将 Axios/HTTP/SDK 错误统一映射为取消、未登录、无权限、超时、网络及未知错误。界面与日志仅消费固定安全文案,不展示原始 message、响应体、URL、头、Token 或堆栈;取消静默。Vue `errorHandler`、登录表单、导航与连接失败使用相同分类。入口动态加载 bootstrap,因此模块加载/配置校验错误也有纯文本启动兜底与重试;卸载使晚到导入失效,失败与重挂载继续清理 router/Pinia/凭据订阅。构建配置错误需要更正环境并重新构建/刷新;重试不保证浏览器重新求值已失败的 ESM 模块。
|
|
152
|
+
|
|
153
|
+
Vue 嵌入时协商并安装 `getToken/beginAuthRequest/reportAuthFailure`。`createAuthFailureCapabilities` 是 SDK 正式契约,复用 `callHost` 白名单和生命周期;每次可信请求先获取不含凭据的不透明内存票据,绑定宿主私有凭据版本与可信 appId。401 或 Token 未登录按原票据报告,宿主核验后立即撤销身份、推进版本;并发仅失效一次,旧响应不能退出新登录。票据不得复制、序列化、持久化或用于 iframe RPC。平台自身请求也在发送时捕获私有版本。正常主动退出仍等待页面守卫,真实认证失效不允许被未保存守卫阻止。模板不刷新 Token、不自动重放任何请求,也不新增总线或请求库。
|
|
154
|
+
|
|
155
|
+
独立 Vue 演示不安装 bridge 身份、不生成已登录快照,权限默认全部拒绝;列表/详情仅显式允许本地内存展示,不表示获得平台或 API 权限。生产独立部署应安装自己的认证与权限准入,或禁用该演示路径。基座演示身份另由 `DEV && VITE_AUTH_DEMO !== 'false'` 控制(开发默认启用),真实接口失败绝不降级为演示登录。
|
|
156
|
+
|
|
157
|
+
### 响应式权限示例
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { computed } from 'vue'
|
|
161
|
+
import { usePermission } from '@axewo/fin-micro/vue'
|
|
162
|
+
|
|
163
|
+
// identity 来自 createBridgeSession / subscribeSession,不读取或存储 Token。
|
|
164
|
+
const permission = usePermission(identity)
|
|
165
|
+
const canRead = permission.can('demo:read')
|
|
166
|
+
const canEdit = computed(() => permission.hasPermission('demo:write'))
|
|
167
|
+
const canUse = computed(() => permission.hasAny(['demo:read', 'demo:write']))
|
|
168
|
+
const canManage = computed(() => permission.hasAll(['demo:read', 'demo:write']))
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
框架无关逻辑可直接从 `/sdk` 导入 `hasPermission(session, name)`、`hasAny(session, names)`、`hasAll(session, names)`。未登录、缺失会话、空权限名及空数组均安全拒绝;不支持通配符或角色推断。Vue 权限变更/退出立即更新计算值;普通函数在 computed/模板内调用才追踪依赖,不要把单次布尔结果当作响应式状态。按钮显隐仅辅助体验,核心 canOpen 和服务端仍须独立鉴权。
|
|
172
|
+
|
|
173
|
+
### locales / router / stores 联动示例
|
|
174
|
+
|
|
175
|
+
两类生成项目均包含 `locales/zh-CN.ts`、`locales/en.ts`、`stores/demo.ts`、`router/demo.ts` 和 `views/DemoView.vue`。`installAppLocale` 将 `demo.*` 业务词条合并到 fin-ui 内置语言包;页面通过全局 `useI18n` 切换中文/英文,通过 `storeToRefs(useDemoStore())` 展示计数与双倍值,并调用 `increment/reset`。状态只属于本轮应用的 Pinia,不持久化、不跨应用共享,也不存储 Token。
|
|
176
|
+
|
|
177
|
+
- **vue 独立运行**:启动生成项目后访问 `/#/demo`(默认端口 7101)。`createMicroRouter` 复用 `demoRoutes`,原列表/详情与缓存接线不变。
|
|
178
|
+
- **vue 嵌入基座**:仅注册本地 `/demo` 不会获得访问授权。后台 `/api/menus` 须为当前已授权应用返回菜单,例如 `{ menuId: 'demo', name: '基础示例', target: { tabId: 'business:demo', route: '/demo' } }`(替换实际 appId 前缀),再从基座菜单打开。模板不自动添加 demo 菜单,也不放宽 `canOpenRoute`;未授权深链仍被拒绝。
|
|
179
|
+
- **host 本地扩展**:在 host 的 `router/index.ts` 添加真实路由记录及认证元信息,并在 `router/targets.ts` 补充固定路由到 core 页签的映射;工作区页面还需补充 `localPages.ts` 的精确白名单及 `HostWorkspace.vue` 的显式 SFC 映射。不可直接把 `demoRoutes` 传给会话参数,亦不可用本地路由代替平台授权。
|
|
180
|
+
|
|
181
|
+
组件外使用 store 时显式传所属实例:`const demo = useDemoStore(pinia)`;组件内由已安装的 Pinia 注入。多页签属于同一子应用时共享该应用的计数,这是应用级状态,不是页签级缓存。
|
|
182
|
+
|
|
183
|
+
## 包入口
|
|
184
|
+
|
|
185
|
+
| 入口 | 用途 |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `/sdk` | 框架无关类型、桥获取、调用/导航契约 |
|
|
188
|
+
| `/core` | 应用登记、宿主状态、导航事务与能力白名单 |
|
|
189
|
+
| `/runtime` | Wujie 单 appId 单实例挂载和清理 |
|
|
190
|
+
| `/vue` | Vue 容器、路由连接、页签缓存与页面 SDK |
|
|
191
|
+
| `/browser` | 基座 URL 和公开导航元信息恢复 |
|
|
192
|
+
| `/iframe` | 显式窗口来源校验的独立 RPC |
|
|
193
|
+
|
|
194
|
+
非 Vue 应用使用独立子入口;根入口包含 Vue 适配。
|
|
195
|
+
|
|
196
|
+
Vue 子应用在本地维护正常 Vue Router 路由表,基座只传递 appId 和应用内 route,不传组件文件路径。缓存内容直接使用 `MicroRouteView`,不必复制路由解析器:
|
|
197
|
+
|
|
198
|
+
```vue
|
|
199
|
+
<MicroTabCache :tabs="tabs" :active-key="activeKey">
|
|
200
|
+
<MicroRouteView />
|
|
201
|
+
</MicroTabCache>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`MicroRouteView` 需要应用安装 Vue Router `^4.5.0` 并调用 `app.use(router)`;提供固定页面路由上下文、懒加载、路由记录 redirect、可见错误及重试。它不执行全局导航守卫、不修改宿主页签;嵌入模式不要同时对每个页签调用共享 router.replace,跳转和离页确认仍使用 PageSDK。详细示例与安全边界见缓存章节。
|
|
205
|
+
|
|
206
|
+
## 使用前必须知道
|
|
207
|
+
|
|
208
|
+
- 同 appId 只运行一个实例,应用内通过不同 tabId 打开多个路由页;key 代表具体页面生命周期。
|
|
209
|
+
- `openTab/operate` 保持应用隔离;跨应用打开使用 `page.navigateTo`,由宿主 `canNavigate` 显式授权并继续检查 `canOpen`。
|
|
210
|
+
- 导航 committed 不代表资源加载完成;运行时错误与重试另行处理。
|
|
211
|
+
- 公开会话不带 Token,宿主能力使用显式白名单;同窗口桥不是恶意代码安全沙箱。
|
|
212
|
+
- 超时/取消不回滚业务副作用;缓存和 sessionStorage 导航恢复不代替草稿持久化。
|
|
213
|
+
- iframe RPC 不是完整 AppBridge、守卫或缓存的透明代理。
|
|
214
|
+
|
|
215
|
+
## 验证
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
pnpm --filter @axewo/fin-micro typecheck
|
|
219
|
+
pnpm --filter @axewo/fin-micro test
|
|
220
|
+
pnpm --filter @axewo/fin-micro build
|
|
221
|
+
pnpm --filter @axewo/fin-docs build
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
真实浏览器部署验收不能由 happy-dom 单元测试替代,具体检查清单见手册。
|
package/cli/index.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 离线脚手架入口:只读取随包发布的模板,不安装依赖、不请求网络。
|
|
4
|
+
* 运行时入口不应导入本文件,只有命令行和测试使用这里的导出。
|
|
5
|
+
*/
|
|
6
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
|
|
11
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,213}$/;
|
|
13
|
+
const HELP = `fin-micro 离线项目创建工具
|
|
14
|
+
|
|
15
|
+
用法:
|
|
16
|
+
fin-micro create
|
|
17
|
+
fin-micro create <directory> --template host|vue [--app-id <id>] [--port <port>]
|
|
18
|
+
fin-micro --help
|
|
19
|
+
|
|
20
|
+
说明:
|
|
21
|
+
目标目录必须不存在,父目录必须真实存在。
|
|
22
|
+
目录名和应用 ID 仅支持小写字母、数字与连字符,首字符为字母或数字。
|
|
23
|
+
host 默认端口 7100,vue 默认端口 7101;端口范围为 1–65535。
|
|
24
|
+
不自动安装依赖,不访问网络。
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/** 检查路径本身是否存在;悬空符号链接同样视为已存在。 */
|
|
28
|
+
function exists(path) {
|
|
29
|
+
try {
|
|
30
|
+
lstatSync(path);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT') return false;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 解析有限命令集,不产生文件系统写入。
|
|
40
|
+
* @param {string[]} args 不含 node 和脚本路径的命令参数。
|
|
41
|
+
* @param {{ partial?: boolean }} config partial 仅允许缺少目录/模板,供后续交互补全;不放宽选项语法。
|
|
42
|
+
* @returns {object} 创建选项或 { help: true };默认值及路径安全在创建前统一校验。
|
|
43
|
+
* @throws {Error} 未知命令/选项、重复选项、缺值或严格模式缺少必要参数。
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgs(args, { partial = false } = {}) {
|
|
46
|
+
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) return { help: true };
|
|
47
|
+
if (args[0] !== 'create') throw new Error('请使用 create 命令,或通过 --help 查看用法。');
|
|
48
|
+
if (args.length === 2 && (args[1] === '--help' || args[1] === '-h')) return { help: true };
|
|
49
|
+
const directory = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
|
|
50
|
+
if (!directory && !partial) throw new Error('请指定目标目录。');
|
|
51
|
+
const options = directory ? { directory } : {};
|
|
52
|
+
const names = { '--template': 'template', '--app-id': 'appId', '--port': 'port' };
|
|
53
|
+
for (let index = directory ? 2 : 1; index < args.length; index += 2) {
|
|
54
|
+
const option = args[index];
|
|
55
|
+
if (!Object.hasOwn(names, option)) throw new Error(`未知选项:${option}`);
|
|
56
|
+
const key = names[option];
|
|
57
|
+
if (Object.hasOwn(options, key)) throw new Error(`选项不可重复:${option}`);
|
|
58
|
+
const value = args[index + 1];
|
|
59
|
+
if (!value || value.startsWith('--')) throw new Error(`选项缺少值:${option}`);
|
|
60
|
+
options[key] = value;
|
|
61
|
+
}
|
|
62
|
+
if (!options.template && !partial) throw new Error('必须指定 --template host 或 --template vue。');
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 递归读取可信模板目录,拒绝符号链接及非常规文件,避免越界读取。 */
|
|
67
|
+
function collectTemplate(root, current = root, files = new Map()) {
|
|
68
|
+
const stat = lstatSync(current);
|
|
69
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
70
|
+
throw new Error(`模板目录无效:${current}`);
|
|
71
|
+
}
|
|
72
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
73
|
+
const source = join(current, entry.name);
|
|
74
|
+
if (entry.isSymbolicLink()) throw new Error(`模板不可包含符号链接:${source}`);
|
|
75
|
+
if (entry.isDirectory()) collectTemplate(root, source, files);
|
|
76
|
+
else if (entry.isFile()) {
|
|
77
|
+
const target = relative(root, source).split(sep).map((part) => part === 'package.json.template' ? 'package.json' : part).join(sep);
|
|
78
|
+
if (files.has(target)) throw new Error(`同一模板含重复输出文件:${target}`);
|
|
79
|
+
files.set(target, readFileSync(source, 'utf8'));
|
|
80
|
+
} else throw new Error(`模板包含不支持的文件:${source}`);
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 校验选项和真实父路径,不创建文件;交互重输与最终写入前复核共用此边界。 */
|
|
86
|
+
function validateOptions(options, cwd) {
|
|
87
|
+
const { directory, template } = options;
|
|
88
|
+
if (template !== 'host' && template !== 'vue') throw new Error('模板只能是 host 或 vue。');
|
|
89
|
+
if (typeof directory !== 'string' || !directory || directory.endsWith('/') || directory.endsWith('\\')) {
|
|
90
|
+
throw new Error('目标目录必须包含有效目录名,且不能以分隔符结尾。');
|
|
91
|
+
}
|
|
92
|
+
const target = resolve(cwd, directory);
|
|
93
|
+
const projectName = basename(target);
|
|
94
|
+
if (!NAME_PATTERN.test(projectName)) throw new Error('目录名无效:仅支持小写字母、数字和连字符,长度不超过 214。');
|
|
95
|
+
const appId = options.appId ?? projectName;
|
|
96
|
+
if (!NAME_PATTERN.test(appId)) throw new Error('应用 ID 无效:仅支持小写字母、数字和连字符,长度不超过 214。');
|
|
97
|
+
const port = String(options.port ?? (template === 'host' ? 7100 : 7101));
|
|
98
|
+
if (!/^[1-9][0-9]{0,4}$/.test(port) || Number(port) > 65535) throw new Error('端口必须是 1–65535 的十进制整数。');
|
|
99
|
+
if (exists(target)) throw new Error(`目标已存在,拒绝覆盖:${target}`);
|
|
100
|
+
const parent = dirname(target);
|
|
101
|
+
if (!exists(parent) || !lstatSync(parent).isDirectory()) {
|
|
102
|
+
throw new Error('目标的父路径必须是真实存在的目录,且不能通过符号链接访问。');
|
|
103
|
+
}
|
|
104
|
+
// Windows 路径比较忽略大小写,但仍拒绝 junction 或其他符号链接造成的路径跳转。
|
|
105
|
+
const canonicalParent = realpathSync(parent);
|
|
106
|
+
const sameParent = process.platform === 'win32'
|
|
107
|
+
? canonicalParent.toLowerCase() === resolve(parent).toLowerCase()
|
|
108
|
+
: canonicalParent === resolve(parent);
|
|
109
|
+
if (!sameParent) throw new Error('目标的父路径必须是真实存在的目录,且不能通过符号链接访问。');
|
|
110
|
+
return { target, directory, template, projectName, appId, port };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 组合公共骨架和差异模板,替换占位符后排他写入新目录;不会安装依赖或访问网络。
|
|
115
|
+
* @param {object} options 目录、host/vue模板及可选appId/端口。
|
|
116
|
+
* @param {object} environment cwd为已有真实目录,packageRoot仅用于测试注入可信素材。
|
|
117
|
+
* @returns {object} 实际目标路径及归一化配置。
|
|
118
|
+
* @throws {Error} 参数无效、目录已存在、模板冲突/非法占位符或文件系统错误。
|
|
119
|
+
* 可预见错误在写入前失败;磁盘故障或外部并发篡改仍可能留下部分新文件,不递归删除以免误删用户内容。
|
|
120
|
+
*/
|
|
121
|
+
export function createProject(options, { cwd = process.cwd(), packageRoot = PACKAGE_ROOT } = {}) {
|
|
122
|
+
const { target, directory, template, projectName, appId, port } = validateOptions(options, cwd);
|
|
123
|
+
const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
124
|
+
if (typeof metadata.version !== 'string' || !metadata.version) throw new Error('当前包缺少有效版本号。');
|
|
125
|
+
const replacements = {
|
|
126
|
+
__PROJECT_NAME__: projectName,
|
|
127
|
+
__APP_ID__: appId,
|
|
128
|
+
__PORT__: port,
|
|
129
|
+
__FIN_MICRO_VERSION__: metadata.version,
|
|
130
|
+
__FIN_UI_VERSION__: '2.0.12',
|
|
131
|
+
};
|
|
132
|
+
const files = collectTemplate(join(packageRoot, 'scaffold', 'base'));
|
|
133
|
+
for (const [path, content] of collectTemplate(join(packageRoot, 'scaffold', template))) files.set(path, content);
|
|
134
|
+
const planned = new Map();
|
|
135
|
+
for (const [path, content] of files) {
|
|
136
|
+
const rendered = content.replace(/__[A-Z][A-Z0-9_]*?__/g, (placeholder) => {
|
|
137
|
+
if (!Object.hasOwn(replacements, placeholder)) throw new Error(`模板包含未知占位符:${placeholder}`);
|
|
138
|
+
return replacements[placeholder];
|
|
139
|
+
});
|
|
140
|
+
planned.set(path, rendered);
|
|
141
|
+
}
|
|
142
|
+
if (!planned.has('package.json')) throw new Error('模板缺少 package.json.template。');
|
|
143
|
+
JSON.parse(planned.get('package.json'));
|
|
144
|
+
// 检测文件与目录冲突,确保任何可预见的素材错误都在写入前失败。
|
|
145
|
+
for (const path of planned.keys()) {
|
|
146
|
+
for (let parentPath = dirname(path); parentPath !== '.'; parentPath = dirname(parentPath)) {
|
|
147
|
+
if (planned.has(parentPath)) throw new Error(`模板文件与目录冲突:${parentPath}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 不递归创建根目录,利用 mkdir 的排他语义再次阻止并发创建后的覆盖。
|
|
151
|
+
mkdirSync(target);
|
|
152
|
+
for (const [path, content] of planned) {
|
|
153
|
+
const destination = join(target, path);
|
|
154
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
155
|
+
writeFileSync(destination, content, { flag: 'wx' });
|
|
156
|
+
}
|
|
157
|
+
return { target, directory, template, appId, port };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 用户取消或输入流关闭时终止问答;在全部确认前不创建任何文件。 */
|
|
161
|
+
export class PromptCancelled extends Error {
|
|
162
|
+
constructor() { super('已取消创建,未写入项目文件。'); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 补全缺少的选项;ask 为可注入的异步问答函数,便于无终端测试。
|
|
167
|
+
* 已给出的选项不会静默修改;完整参数命令保持原有默认值及非交互行为。
|
|
168
|
+
* @param {object} initial parseArgs 的部分选项,不应含未知选项。
|
|
169
|
+
* @param {object} environment interactive指定真实交互环境,ask返回输入文本/null取消,write仅报告校验失败。
|
|
170
|
+
* @returns {Promise<object>} 补全后的创建选项;本函数不写文件,createProject仍需最终校验。
|
|
171
|
+
* @throws {PromptCancelled|Error} 取消、非交互缺项、显式参数无效或问答失败。
|
|
172
|
+
*/
|
|
173
|
+
export async function completeOptions(initial, { interactive, ask, write = () => {}, cwd = process.cwd() }) {
|
|
174
|
+
if (initial.directory && initial.template) return initial;
|
|
175
|
+
if (!interactive) throw new Error('非交互环境请提供项目目录及 --template host|vue;可通过 --help 查看用法。');
|
|
176
|
+
const options = { ...initial };
|
|
177
|
+
const inquire = async (label, fallback, validate) => {
|
|
178
|
+
for (;;) {
|
|
179
|
+
const answer = await ask(`${label}${fallback ? ` (${fallback})` : ''}:`);
|
|
180
|
+
if (answer === undefined || answer === null) throw new PromptCancelled();
|
|
181
|
+
const value = answer.trim() || fallback;
|
|
182
|
+
try { validate(value); return value; }
|
|
183
|
+
catch (error) { write(`${error.message}\n`); }
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (!options.directory) options.directory = await inquire('项目目录', undefined, (directory) => {
|
|
187
|
+
validateOptions({ directory, template: 'host' }, cwd);
|
|
188
|
+
});
|
|
189
|
+
if (!options.template) options.template = await inquire('项目类型 [1 host 基座 / 2 vue 子应用,输入序号或名称]', 'vue', (value) => {
|
|
190
|
+
if (!['1', '2', 'host', 'vue'].includes(value)) throw new Error('请选择 1/host 或 2/vue。');
|
|
191
|
+
});
|
|
192
|
+
options.template = ({ 1: 'host', 2: 'vue' })[options.template] ?? options.template;
|
|
193
|
+
// 先校验显式参数,避免非法模板或标识被交互流程隐藏。
|
|
194
|
+
validateOptions(options, cwd);
|
|
195
|
+
if (options.template === 'vue' && options.appId === undefined) {
|
|
196
|
+
options.appId = await inquire('应用 app-id', basename(resolve(cwd, options.directory)), (value) => {
|
|
197
|
+
if (!value || !NAME_PATTERN.test(value)) throw new Error('应用 ID 仅支持小写字母、数字和连字符。');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (options.port === undefined) options.port = await inquire('开发端口', options.template === 'host' ? '7100' : '7101', (port) => {
|
|
201
|
+
validateOptions({ ...options, port }, cwd);
|
|
202
|
+
});
|
|
203
|
+
return options;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 输出后续运行指引;仅真实终端启用问答,关闭、EOF和Ctrl+C均在写文件前结束。 */
|
|
207
|
+
export async function main(args = process.argv.slice(2)) {
|
|
208
|
+
let terminal;
|
|
209
|
+
try {
|
|
210
|
+
let options = parseArgs(args, { partial: true });
|
|
211
|
+
if (options.help) {
|
|
212
|
+
process.stdout.write(HELP);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
216
|
+
if (interactive && (!options.directory || !options.template)) {
|
|
217
|
+
terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
218
|
+
terminal.on('SIGINT', () => terminal.close());
|
|
219
|
+
}
|
|
220
|
+
options = await completeOptions(options, {
|
|
221
|
+
interactive,
|
|
222
|
+
write: (message) => process.stdout.write(message),
|
|
223
|
+
ask: (label) => new Promise((resolveAnswer, reject) => {
|
|
224
|
+
const cancel = () => reject(new PromptCancelled());
|
|
225
|
+
terminal.once('close', cancel);
|
|
226
|
+
terminal.question(label).then(resolveAnswer, () => reject(new PromptCancelled())).finally(() => terminal.removeListener('close', cancel));
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const result = createProject(options);
|
|
230
|
+
process.stdout.write(`项目已创建。运行指引:\n cd ${JSON.stringify(result.target)}\n npm install\n npm run dev\n`);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
process.stderr.write(error instanceof PromptCancelled ? `${error.message}\n` : `创建失败:${error.message}\n`);
|
|
233
|
+
process.exitCode = error instanceof PromptCancelled ? 130 : 1;
|
|
234
|
+
} finally {
|
|
235
|
+
terminal?.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 被测试导入时不执行命令,避免导入产生文件系统副作用。
|
|
240
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) main();
|
package/dist/browser.cjs
ADDED
|
@@ -0,0 +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 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;
|