@axewo/fin-micro 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +223 -2
  2. package/cli/index.mjs +240 -0
  3. package/dist/browser.cjs +1 -0
  4. package/dist/browser.mjs +169 -0
  5. package/dist/core.cjs +1 -0
  6. package/dist/core.mjs +654 -0
  7. package/dist/iframe.cjs +1 -0
  8. package/dist/iframe.mjs +146 -0
  9. package/dist/index.cjs +1 -0
  10. package/dist/index.mjs +35 -0
  11. package/dist/router-view-impl-B1NzCGKV.mjs +76 -0
  12. package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
  13. package/dist/runtime.cjs +1 -0
  14. package/dist/runtime.mjs +149 -0
  15. package/dist/sdk.cjs +1 -0
  16. package/dist/sdk.mjs +118 -0
  17. package/dist/types/browser/index.d.ts +46 -0
  18. package/dist/types/core/index.d.ts +63 -0
  19. package/dist/types/iframe/index.d.ts +77 -0
  20. package/dist/types/index.d.ts +8 -0
  21. package/dist/types/runtime/index.d.ts +21 -0
  22. package/dist/types/runtime/lifecycle.d.ts +23 -0
  23. package/dist/types/sdk/index.d.ts +397 -0
  24. package/dist/types/vue/index.d.ts +102 -0
  25. package/dist/types/vue/router-view-impl.d.ts +41 -0
  26. package/dist/types/vue/router-view.d.ts +42 -0
  27. package/dist/types/vue/tab-context.d.ts +6 -0
  28. package/dist/vue.cjs +1 -0
  29. package/dist/vue.mjs +180 -0
  30. package/package.json +91 -3
  31. package/scaffold/base/index.html +12 -0
  32. package/scaffold/base/package.json.template +33 -0
  33. package/scaffold/base/scripts/check-runtime.mjs +43 -0
  34. package/scaffold/base/src/components/.gitkeep +1 -0
  35. package/scaffold/base/src/components/AppProviders.vue +25 -0
  36. package/scaffold/base/src/hooks/.gitkeep +1 -0
  37. package/scaffold/base/src/layout/.gitkeep +1 -0
  38. package/scaffold/base/src/locales/.gitkeep +1 -0
  39. package/scaffold/base/src/locales/en.ts +12 -0
  40. package/scaffold/base/src/locales/index.ts +13 -0
  41. package/scaffold/base/src/locales/zh-CN.ts +12 -0
  42. package/scaffold/base/src/main.ts +22 -0
  43. package/scaffold/base/src/router/.gitkeep +1 -0
  44. package/scaffold/base/src/router/demo.ts +6 -0
  45. package/scaffold/base/src/router/index.ts +13 -0
  46. package/scaffold/base/src/stores/.gitkeep +1 -0
  47. package/scaffold/base/src/stores/demo.ts +12 -0
  48. package/scaffold/base/src/stores/index.ts +11 -0
  49. package/scaffold/base/src/styles/index.css +18 -0
  50. package/scaffold/base/src/types/.gitkeep +1 -0
  51. package/scaffold/base/src/utils/.gitkeep +1 -0
  52. package/scaffold/base/src/utils/appConfig.ts +49 -0
  53. package/scaffold/base/src/utils/errorUtils.ts +92 -0
  54. package/scaffold/base/src/utils/requestUtils.ts +144 -0
  55. package/scaffold/base/src/views/.gitkeep +1 -0
  56. package/scaffold/base/src/views/DemoView.vue +26 -0
  57. package/scaffold/base/tsconfig.json +16 -0
  58. package/scaffold/base/vite.config.ts +8 -0
  59. package/scaffold/host/.env.example +13 -0
  60. package/scaffold/host/src/App.vue +4 -0
  61. package/scaffold/host/src/bootstrap.ts +50 -0
  62. package/scaffold/host/src/hooks/usePlatformSession.ts +91 -0
  63. package/scaffold/host/src/layout/HostHeader.vue +69 -0
  64. package/scaffold/host/src/layout/HostLayout.vue +168 -0
  65. package/scaffold/host/src/layout/HostMenu.vue +15 -0
  66. package/scaffold/host/src/layout/HostTabs.vue +47 -0
  67. package/scaffold/host/src/layout/HostWorkspace.vue +26 -0
  68. package/scaffold/host/src/localPages.ts +45 -0
  69. package/scaffold/host/src/platform.ts +239 -0
  70. package/scaffold/host/src/router/guards.ts +128 -0
  71. package/scaffold/host/src/router/index.ts +25 -0
  72. package/scaffold/host/src/router/targets.ts +30 -0
  73. package/scaffold/host/src/router/workspace.ts +63 -0
  74. package/scaffold/host/src/styles/shell.css +761 -0
  75. package/scaffold/host/src/types/platform.ts +12 -0
  76. package/scaffold/host/src/utils/request.ts +42 -0
  77. package/scaffold/host/src/views/HomeView.vue +19 -0
  78. package/scaffold/host/src/views/LoginRoute.vue +16 -0
  79. package/scaffold/host/src/views/LoginView.vue +88 -0
  80. package/scaffold/host/src/views/NotFoundView.vue +1 -0
  81. package/scaffold/host/src/views/ProfileView.vue +24 -0
  82. package/scaffold/host/src/views/WorkspaceRoute.vue +11 -0
  83. package/scaffold/vue/src/App.vue +24 -0
  84. package/scaffold/vue/src/bootstrap.ts +67 -0
  85. package/scaffold/vue/src/bridge/auth.ts +23 -0
  86. package/scaffold/vue/src/bridge/session.ts +16 -0
  87. package/scaffold/vue/src/main.ts +13 -0
  88. package/scaffold/vue/src/router/index.ts +16 -0
  89. package/scaffold/vue/src/views/DetailView.vue +57 -0
  90. package/scaffold/vue/src/views/ListView.vue +55 -0
  91. package/scaffold/vue/src/views/NotFoundView.vue +1 -0
@@ -0,0 +1 @@
1
+ # 预留业务通用工具目录;没有复用需求时不创建空工具封装。
@@ -0,0 +1,49 @@
1
+ /** 构建时公开配置;VITE_* 均会进入前端产物,禁止放入任何密码或 Token。 */
2
+ export interface AppConfig {
3
+ /** Axios API 目录;相对地址仅允许当前源的绝对路径。 */
4
+ readonly apiBase: string
5
+ /** 凭据允许发送的精确 API 目录,不从请求参数推断或扩大。 */
6
+ readonly trustedApiBase: string
7
+ /** 请求超时毫秒数,必须为正有限整数且不超过原生计时器上限。 */
8
+ readonly timeout: number
9
+ /** 是否允许跨源 Cookie;服务端仍须配置精确 CORS 与 CSRF 防护。 */
10
+ readonly withCredentials: boolean
11
+ }
12
+
13
+ /** 校验公开 HTTP(S) 地址;拒绝凭据、查询、片段、协议相对地址及编码路径歧义,不回显输入。 */
14
+ export function validateAddress(value: string, directory = true): string {
15
+ if (typeof value !== 'string' || !value || value.trim() !== value || /[\\\s\u0000-\u001f\u007f]/.test(value) || /%(?:2f|5c|2e)/i.test(value) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(value)) throw new Error('应用地址配置无效')
16
+ const relative = value.startsWith('/') && !value.startsWith('//')
17
+ if (!relative && !/^https?:\/\//i.test(value)) throw new Error('应用地址必须为 HTTP(S) 或同源绝对路径')
18
+ const url = new URL(value, 'https://config.invalid')
19
+ if (url.username || url.password || /[?#]/.test(value) || (directory && !url.pathname.endsWith('/'))) throw new Error('应用地址必须为无凭据、查询和片段的目录')
20
+ // 只有本机开发地址允许 HTTP;生产网关和代码入口必须使用 HTTPS。
21
+ if (!relative && url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('非本机应用地址必须使用 HTTPS')
22
+ return value
23
+ }
24
+
25
+ /** 将 Vite 环境转换成只读配置;非法值立即拒绝启动,默认值不扩大凭据目录。 */
26
+ export function resolveAppConfig(env: Record<string, unknown> = {}): AppConfig {
27
+ const apiBase = validateAddress((env.VITE_API_BASE ?? '/api/') as string)
28
+ const trustedApiBase = validateAddress((env.VITE_TRUSTED_API_BASE ?? apiBase) as string)
29
+ if (apiBase !== trustedApiBase) throw new Error('API 与可信凭据目录必须一致')
30
+ const rawTimeout = env.VITE_API_TIMEOUT ?? 30000
31
+ if ((typeof rawTimeout !== 'string' && typeof rawTimeout !== 'number') || !/^\d+$/.test(String(rawTimeout))) throw new Error('API 超时配置无效')
32
+ const timeout = Number(rawTimeout)
33
+ if (!Number.isInteger(timeout) || timeout <= 0 || timeout > 2147483647) throw new Error('API 超时配置无效')
34
+ const credentials = env.VITE_API_CREDENTIALS ?? 'false'
35
+ if (credentials !== 'true' && credentials !== 'false') throw new Error('API credentials 配置必须为 true 或 false')
36
+ return Object.freeze({ apiBase, trustedApiBase, timeout, withCredentials: credentials === 'true' })
37
+ }
38
+
39
+ /** 解析部署者控制的可信代码入口;只读取构建环境,后台 appIds/menus 不得覆盖 entry。 */
40
+ export function resolveTrustedApp(env: Record<string, unknown> = {}): { readonly appId: string; readonly entry: string } {
41
+ const appId = env.VITE_MICRO_APP_ID ?? 'business'
42
+ const entry = env.VITE_MICRO_APP_ENTRY ?? 'http://localhost:7101'
43
+ if (typeof appId !== 'string' || appId.length > 214 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(appId)) throw new Error('可信应用 appId 配置无效')
44
+ if (typeof entry !== 'string' || !/^https?:\/\//i.test(entry)) throw new Error('可信应用 entry 必须为完整 HTTP(S) 地址')
45
+ return Object.freeze({ appId, entry: validateAddress(entry, false) })
46
+ }
47
+
48
+ /** 应用模块专属公开配置,不包含宿主认证状态;Node 测试环境使用相同默认值。 */
49
+ export const appConfig = resolveAppConfig(import.meta.env)
@@ -0,0 +1,92 @@
1
+ /** 可直接展示/记录的错误分类,不附带原始异常、URL、请求头、响应体或堆栈。 */
2
+ export interface SafeError {
3
+ readonly kind: 'cancelled' | 'unauthenticated' | 'forbidden' | 'timeout' | 'network' | 'unknown'
4
+ readonly message: string
5
+ }
6
+
7
+ /** 复用 Axios、HttpError 和 SDK 的公开判别字段;绝不采用外部 message/cause 作为用户文案。 */
8
+ export function toSafeError(error: unknown): SafeError {
9
+ const value = error && typeof error === 'object' ? error as Record<string, unknown> : {}
10
+ const response = value.response && typeof value.response === 'object' ? value.response as Record<string, unknown> : {}
11
+ const code = value.code
12
+ const status = response.status ?? value.status
13
+ // 分段读取 Axios 旧版取消标识,避免被脚手架的大写双下划线占位符校验误识别。
14
+ if (code === 'ERR_CANCELED' || code === 'CANCELLED' || code === 'SESSION_CHANGED' || value.name === 'AbortError' || value['__CANCEL' + '__'] === true) return { kind: 'cancelled', message: '' }
15
+ if (status === 401 || code === 'UNAUTHENTICATED') return { kind: 'unauthenticated', message: '登录已失效,请重新登录' }
16
+ if (status === 403 || code === 'FORBIDDEN') return { kind: 'forbidden', message: '403:无权执行此操作,请联系管理员' }
17
+ if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'TIMEOUT') return { kind: 'timeout', message: '操作超时,请稍后重试' }
18
+ if (code === 'ERR_NETWORK' || value.name === 'TypeError') return { kind: 'network', message: '连接失败,请检查网络后重试' }
19
+ return { kind: 'unknown', message: '操作失败,请稍后重试或联系管理员' }
20
+ }
21
+
22
+ /** Vue/启动边界的保底接收器;只记录白名单分类与固定文案,不日志原始敏感对象。 */
23
+ export function reportError(error: unknown): void {
24
+ const safe = toSafeError(error)
25
+ if (safe.kind !== 'cancelled') console.error(safe.message + devDiagnostic(error, 'runtime'))
26
+ }
27
+
28
+ /** DEV 仅输出固定阶段和标准错误名;不采用外部 name/message、请求、响应或栈。 */
29
+ export function devDiagnostic(error: unknown, stage: 'load-bootstrap' | 'mount' | 'runtime'): string {
30
+ if (!import.meta.env?.DEV) return ''
31
+ const name = error && typeof error === 'object' && 'name' in error ? error.name : ''
32
+ const allowed = ['Error', 'TypeError', 'SyntaxError', 'ReferenceError', 'RangeError', 'URIError', 'AbortError']
33
+ const kind = typeof name === 'string' && allowed.includes(name) ? name : 'UnknownError'
34
+ return ` [阶段=${stage}; 错误=${kind}]${kind === 'SyntaxError' ? ' 请先执行 npm run check:runtime 检查模块导出及依赖能力' : ''}`
35
+ }
36
+
37
+ /** 启动失败的纯文本可重试边界;不插入错误 HTML,不依赖尚未成功安装的 Vue/UI 插件。 */
38
+ export function showStartupError(error: unknown, retry: () => Promise<void>, stage: 'load-bootstrap' | 'mount' = 'mount'): void {
39
+ if (toSafeError(error).kind === 'cancelled' || typeof document === 'undefined') return
40
+ const container = document.getElementById('app')
41
+ if (!container) return
42
+ const text = document.createElement('p')
43
+ text.setAttribute('role', 'alert')
44
+ text.textContent = `应用启动失败:${toSafeError(error).message}${devDiagnostic(error, stage)}`
45
+ const button = document.createElement('button')
46
+ button.textContent = '重试启动'
47
+ button.onclick = () => { void retry().catch(reportError) }
48
+ container.replaceChildren(text, button)
49
+ }
50
+
51
+ /**
52
+ * 在入口动态加载 bootstrap,捕获其静态依赖中的配置解析/代码加载失败。
53
+ * 入口卸载立即推进代次;尚未完成的 import 不得在卸载或下一轮启动后挂载旧应用。
54
+ * 仅协调启动边界,不替代 bootstrap 内的 router、Pinia 和身份资源清理。
55
+ */
56
+ export function createStartupLifecycle(load: () => Promise<{ mount(): Promise<void>; unmount(): void }>) {
57
+ let generation = 0
58
+ let current: { unmount(): void } | undefined
59
+ function unmount(): void {
60
+ generation++
61
+ const previous = current
62
+ current = undefined
63
+ previous?.unmount()
64
+ }
65
+ async function mount(): Promise<void> {
66
+ unmount()
67
+ const version = generation
68
+ let stage: 'load-bootstrap' | 'mount' = 'load-bootstrap'
69
+ try {
70
+ const bootstrap = await load()
71
+ if (generation !== version) return
72
+ current = bootstrap
73
+ stage = 'mount'
74
+ await bootstrap.mount()
75
+ } catch (error) {
76
+ if (generation !== version) return
77
+ unmount()
78
+ reportError(error)
79
+ // 仅当前失败轮次可重试;卸载后遗留按钮或旧回调不得重启已释放的应用。
80
+ const failedVersion = generation
81
+ showStartupError(error, () => generation === failedVersion ? mount() : Promise.resolve(), stage)
82
+ }
83
+ }
84
+ return { mount, unmount }
85
+ }
86
+
87
+ /** 独立执行全部清理回调;一个回调失败不能阻断后续凭据撤销或资源释放。 */
88
+ export function cleanupAll(callbacks: readonly (() => void)[]): void {
89
+ for (const callback of callbacks) {
90
+ try { callback() } catch (error) { reportError(error) }
91
+ }
92
+ }
@@ -0,0 +1,144 @@
1
+ import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
2
+ import { appConfig, validateAddress } from './appConfig'
3
+ import { toSafeError, reportError } from './errorUtils'
4
+
5
+ /**
6
+ * 应用独立的 Axios 请求实例;由 base 生成到基座和子应用,各项目自行定义拦截器。
7
+ * 仅共享模板与封装实现,不跨应用共享可变配置、请求实例或身份信息。
8
+ * 默认 /api/ 指向当前应用运行环境解析出的源,嵌入运行也不会自动代理到基座后端。
9
+ * 部署时须配置真实API网关/开发代理;跨域Cookie须显式启用withCredentials并由后端配置CORS。
10
+ * 如需Token,通过installRequestTokenProvider每次读取最新值;不要在模块初始化时缓存Token。
11
+ * 不擅自从window/localStorage读取基座凭据,不硬编码Token,不自动刷新登录或重试写请求。
12
+ * get/post等返回整个响应体;业务code/data解包和401会话协调由业务层明确实现。
13
+ * 响应拦截器须返回AxiosResponse,而不是直接返回response.data;错误保持AxiosError/isCancel语义。
14
+ */
15
+ export const http = new AxiosHttpClient({
16
+ baseURL: appConfig.apiBase,
17
+ timeout: appConfig.timeout,
18
+ withCredentials: appConfig.withCredentials,
19
+ })
20
+
21
+ /** 按请求读取最新凭据;独立应用可显式安装自己的 provider,基座默认不读取 bridge。 */
22
+ export type RequestTokenProvider = (signal: AbortSignal) => Promise<string | null> | string | null
23
+ interface TokenBinding {
24
+ provider: RequestTokenProvider
25
+ api: URL
26
+ active: boolean
27
+ pending: Set<AbortController>
28
+ previous?: TokenBinding
29
+ begin?: (signal: AbortSignal) => Promise<() => Promise<unknown>>
30
+ }
31
+ const requestFailures = new WeakMap<object, () => Promise<unknown>>()
32
+ /** 失效回调只影响认证;自身失败隔离,不覆盖原始 Axios/SDK 错误。 */
33
+ async function notifyFailure(callback: (() => Promise<unknown>) | undefined): Promise<void> {
34
+ try { await callback?.() } catch (error) { reportError(error) }
35
+ }
36
+ let tokenBinding: TokenBinding | undefined
37
+ /**
38
+ * 在应用挂载时安装,卸载必须调用返回的还原函数;同时取消所有尚未完成的凭据读取。
39
+ * trustedApiBase 是业务明确配置的可信 API 目录(默认当前源 /api/),不是从请求的 baseURL 推断。
40
+ * 跨源网关必须显式传完整 HTTPS 地址,且与 http 的 baseURL 保持一致。
41
+ * @param provider 每次读取最新凭据,接收请求取消信号;null 代表 Cookie 模式,不伪造已登录。
42
+ * @param trustedApiBase 可信 API 目录,必须以 / 结束;SSR 无浏览器来源时只能显式使用绝对 HTTPS 地址。
43
+ * @returns 幂等释放函数,取消挂起读取并恢复仍有效的前一个绑定,不撤回已发出的网络请求。
44
+ * @throws 可信地址无效时立即抛错;provider 失败使本次请求拒绝,不降级匿名重发。
45
+ */
46
+ export function installRequestTokenProvider(provider: RequestTokenProvider, trustedApiBase = appConfig.trustedApiBase,
47
+ begin?: (signal: AbortSignal) => Promise<() => Promise<unknown>>): () => void {
48
+ const location = globalThis.location
49
+ if (!location && !/^https:\/\//i.test(trustedApiBase)) throw new Error('无浏览器来源时必须显式配置绝对 HTTPS API')
50
+ validateAddress(trustedApiBase)
51
+ const api = new URL(trustedApiBase, location?.href)
52
+ if (!['http:', 'https:'].includes(api.protocol) || api.username || api.password || api.search || api.hash || !api.pathname.endsWith('/')) throw new Error('可信 API 必须是 HTTP(S) 目录地址')
53
+ if (api.protocol !== 'https:' && (!location || api.origin !== location.origin)) throw new Error('跨源可信 API 必须使用 HTTPS')
54
+ const binding: TokenBinding = { provider, api, begin, active: true, pending: new Set(), previous: tokenBinding }
55
+ tokenBinding = binding
56
+ return () => {
57
+ binding.active = false
58
+ for (const controller of binding.pending) controller.abort()
59
+ binding.pending.clear()
60
+ if (tokenBinding === binding) {
61
+ tokenBinding = binding.previous
62
+ while (tokenBinding && !tokenBinding.active) tokenBinding = tokenBinding.previous
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 应用级请求拦截器:每次请求时处理当前应用的动态配置。
69
+ * 业务替换点:从应用的身份模块读取最新 Token,按后台协议设置请求头;
70
+ * 不在这里缓存初始 Token,也不默认读取基座的 window/localStorage 凭据。
71
+ * 必须返回 config;异常原样拒绝,由调用方继续处理。
72
+ */
73
+ http.interceptors.request.use(
74
+ async config => {
75
+ const binding = tokenBinding
76
+ if (!binding?.active) return config
77
+ const origin = globalThis.location?.href
78
+ if (!origin && !/^https:\/\//i.test(config.baseURL ?? '')) return config
79
+ const base = new URL(config.baseURL ?? '', origin)
80
+ const url = config.url ?? ''
81
+ // 不对绝对 URL、协议相对地址、反斜线或被覆盖的 baseURL 发送任何宿主凭据。
82
+ // 即使绝对 URL 指向可信源也拒绝自动注入,防止后续调用者无意扩大授权面。
83
+ if (url.trim() !== url || /[\u0000-\u001f\u007f]/.test(url) || /%(?:2f|5c)/i.test(url) || /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith('//') || url.includes('\\') || base.href !== binding.api.href) return config
84
+ const target = new URL(url.replace(/^\/+/, ''), base)
85
+ if (target.origin !== binding.api.origin || !target.pathname.startsWith(binding.api.pathname)) return config
86
+ const controller = new AbortController()
87
+ const abort = () => controller.abort()
88
+ if (config.signal?.aborted) controller.abort()
89
+ config.signal?.addEventListener?.('abort', abort)
90
+ binding.pending.add(controller)
91
+ try {
92
+ if (controller.signal.aborted) throw new DOMException('凭据读取已取消', 'AbortError')
93
+ const failure = await binding.begin?.(controller.signal)
94
+ if (failure) requestFailures.set(config, async () => { if (binding.active && tokenBinding === binding) await failure() })
95
+ // Promise 竞速确保不合作的独立 provider 也不会在卸载后继续发送请求。
96
+ const token = await new Promise<string | null>((resolve, reject) => {
97
+ const cancelled = () => reject(new DOMException('凭据读取已取消', 'AbortError'))
98
+ controller.signal.addEventListener('abort', cancelled, { once: true })
99
+ // 竞速一旦结束立即释放监听,不等待不合作 provider 的底层 Promise。
100
+ const settle = <T>(callback: (value: T) => void, value: T) => {
101
+ controller.signal.removeEventListener('abort', cancelled)
102
+ callback(value)
103
+ }
104
+ Promise.resolve().then(() => binding.provider(controller.signal)).then(value => settle(resolve, value), error => settle(reject, error))
105
+ })
106
+ if (controller.signal.aborted || !binding.active || tokenBinding !== binding) throw new DOMException('凭据来源已释放', 'AbortError')
107
+ if (token !== null && (typeof token !== 'string' || !token || token.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(token))) throw new Error('凭据格式无效')
108
+ // Cookie 模式的 null 不添加头;不缓存凭据,不刷新登录、不自动重试写请求。
109
+ if (token !== null) config.headers.set('Authorization', `Bearer ${token}`)
110
+ return config
111
+ } catch (error) {
112
+ if (toSafeError(error).kind === 'unauthenticated') await notifyFailure(requestFailures.get(config))
113
+ requestFailures.delete(config)
114
+ throw error
115
+ } finally {
116
+ binding.pending.delete(controller)
117
+ config.signal?.removeEventListener?.('abort', abort)
118
+ }
119
+ },
120
+ error => Promise.reject(error),
121
+ )
122
+
123
+ /**
124
+ * 应用级响应拦截器:业务码校验、错误提示及 401 会话协调在此按实际协议实现。
125
+ * 成功分支必须保留完整 AxiosResponse;http.get/post 等已统一返回 response.data,
126
+ * 此处再次解包会破坏客户端返回契约。业务失败可抛出应用定义的错误。
127
+ * 错误分支保持拒绝,不能吞掉错误、返回 undefined 或自动重试写请求;
128
+ * 如需登录失效处理,应协调并发请求,取消请求通常不展示错误提示。
129
+ */
130
+ http.interceptors.response.use(
131
+ response => {
132
+ requestFailures.delete(response.config)
133
+ return response
134
+ },
135
+ async error => {
136
+ const config = error?.config ?? error?.response?.config
137
+ if (config && typeof config === 'object') {
138
+ const callback = requestFailures.get(config)
139
+ requestFailures.delete(config)
140
+ if (toSafeError(error).kind === 'unauthenticated') await notifyFailure(callback)
141
+ }
142
+ return Promise.reject(error)
143
+ },
144
+ )
@@ -0,0 +1 @@
1
+ # 预留页面业务视图目录;布局与跨页面逻辑分别放入 layout 和 hooks。
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { storeToRefs } from 'pinia'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useDemoStore } from '../stores/demo'
5
+
6
+ // 全局词条来自 installAppLocale,切换仅影响当前 Vue App 的 Provider。
7
+ const { t, locale } = useI18n({ useScope: 'global' })
8
+ const demo = useDemoStore()
9
+ // 解构响应式状态使用 storeToRefs;action 仍通过 store 调用。
10
+ const { count, doubled } = storeToRefs(demo)
11
+ function switchLanguage(): void {
12
+ locale.value = locale.value === 'zh-CN' ? 'en' : 'zh-CN'
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <section>
18
+ <h1>{{ t('demo.title') }}</h1>
19
+ <p>{{ t('demo.description') }}</p>
20
+ <p aria-live="polite">{{ t('demo.count', { count }) }}</p>
21
+ <p>{{ t('demo.doubled', { count: doubled }) }}</p>
22
+ <button type="button" @click="demo.increment()">{{ t('demo.increment') }}</button>
23
+ <button type="button" @click="demo.reset()">{{ t('demo.reset') }}</button>
24
+ <button type="button" @click="switchLanguage">{{ t('demo.switchLanguage') }}</button>
25
+ </section>
26
+ </template>
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "isolatedModules": true,
10
+ "resolveJsonModule": true,
11
+ "esModuleInterop": true,
12
+ "noEmit": true,
13
+ "types": ["vite/client"]
14
+ },
15
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
16
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ // 只引用已发布依赖,不配置指向 monorepo 源码的 alias。
5
+ export default defineConfig({
6
+ plugins: [vue()],
7
+ server: { port: __PORT__, strictPort: true, cors: true },
8
+ })
@@ -0,0 +1,13 @@
1
+ # 开发默认演示登录,无需后端、不收集账号密码;设为 false 后联调真实认证。
2
+ # 生产构建始终禁用演示,即使设置 true;必须接入 src/platform.ts 的真实认证接口。
3
+ VITE_AUTH_DEMO=true
4
+ # 公开 API 配置会进入浏览器产物,严禁填写密码或 Token;子应用可在自己的 .env.local 使用同名字段。
5
+ # 两个目录必须完全一致且以 / 结尾;跨源网关使用 HTTPS。超时单位为毫秒。
6
+ VITE_API_BASE=/api/
7
+ VITE_TRUSTED_API_BASE=/api/
8
+ VITE_API_TIMEOUT=30000
9
+ # 跨源 Cookie 必须显式打开,并在服务端配置精确 CORS、HttpOnly、SameSite 与 CSRF 防护。
10
+ VITE_API_CREDENTIALS=false
11
+ # 业务子应用地址和 appId 必须与子应用部署配置一致;仅本机开发允许 HTTP,正式入口使用 HTTPS。
12
+ VITE_MICRO_APP_ID=business
13
+ VITE_MICRO_APP_ENTRY=http://localhost:7101
@@ -0,0 +1,4 @@
1
+ <script setup lang="ts">
2
+ import AppProviders from './components/AppProviders.vue'
3
+ </script>
4
+ <template><AppProviders><RouterView /></AppProviders></template>
@@ -0,0 +1,50 @@
1
+ import { createApp } from 'vue'
2
+ import { disposePinia } from 'pinia'
3
+ import ElementPlus from 'element-plus'
4
+ import App from './App.vue'
5
+ import { installAppLocale } from './locales'
6
+ import { createAppRouter } from './router'
7
+ import { createAppPinia } from './stores'
8
+ import { reportError, cleanupAll } from './utils/errorUtils'
9
+ import { createPlatformSession, platformSessionKey } from './hooks/usePlatformSession'
10
+ let cleanup: (() => void) | undefined
11
+
12
+ /** 幂等释放整个认证与宿主组件树,组件内负责停止请求、会话订阅及 runtime。 */
13
+ export function unmount(): void { cleanup?.(); cleanup = undefined }
14
+
15
+ /** 无参数启动平台;宿主只会在 App 完成认证后创建,不在此绕过登录门禁。 */
16
+ export async function mount(): Promise<void> {
17
+ unmount()
18
+ const app = createApp(App)
19
+ const controller = createPlatformSession()
20
+ const router = createAppRouter(controller, undefined, { onError: reportError })
21
+ app.provide(platformSessionKey, controller)
22
+ const pinia = createAppPinia()
23
+ let mounted = false
24
+ let stopped = false
25
+ cleanup = () => {
26
+ if (stopped) return
27
+ stopped = true
28
+ cleanupAll([
29
+ () => { if (mounted) app.unmount() },
30
+ () => router.disposeGuards(), () => router.options.history.destroy(), () => disposePinia(pinia), () => controller.dispose(),
31
+ ])
32
+ }
33
+ try {
34
+ // 根组件 setup 前安装,登录页和工作区共用当前应用的语言上下文。
35
+ app.config.errorHandler = reportError
36
+ installAppLocale(app)
37
+ app.use(ElementPlus)
38
+ app.use(pinia)
39
+ // 首次导航等待一次性认证恢复,不渲染半就绪工作区。
40
+ app.use(router)
41
+ await router.isReady()
42
+ if (stopped) return
43
+ app.mount('#app')
44
+ mounted = true
45
+ } catch (error) {
46
+ unmount()
47
+ // 启动文案与可重试边界由 main 的代次保护入口统一展示,不遗留旧启动回调。
48
+ throw error
49
+ }
50
+ }
@@ -0,0 +1,91 @@
1
+ import { inject, ref, shallowRef, type InjectionKey } from 'vue'
2
+ import { activateSessionToken, clearAccessToken, clearDemoSession, getAccessToken, login, logout, restoreSession } from '../platform'
3
+ import type { PlatformSession } from '../types/platform'
4
+ import { getTokenRevision } from '../platform'
5
+ import { installPlatformExpiry } from '../utils/request'
6
+ import { toSafeError } from '../utils/errorUtils'
7
+
8
+ /**
9
+ * 每个 App 在 bootstrap 创建一次控制器并发起恢复;卸载时必须调用 dispose。
10
+ * @returns 响应式身份/忙碌/错误状态及登录、恢复、退出操作;操作错误写入 error,不向表单抛出。
11
+ * 此处只管理身份请求,导航和页面资源由工作区负责;生产登录/CSRF协议在 platform.ts 替换。
12
+ */
13
+ export function createPlatformSession() {
14
+ const session = shallowRef<PlatformSession | null>(null)
15
+ const loading = ref(false)
16
+ const error = ref('')
17
+ let request: AbortController | undefined
18
+ let generation = 0
19
+ let disposed = false
20
+ /** 真正失效优先撤销凭据和身份;同步推进版本合并并发 401,不运行正常退出守卫。 */
21
+ function expire(revision: number): void {
22
+ if (!session.value || revision !== getTokenRevision()) return
23
+ generation++
24
+ request?.abort()
25
+ clearAccessToken()
26
+ // 演示失效也必须撤销刷新恢复标记,不能在下一次刷新复活身份。
27
+ clearDemoSession()
28
+ session.value = null
29
+ loading.value = false
30
+ error.value = '登录已失效,请重新登录'
31
+ }
32
+ const offExpiry = installPlatformExpiry(() => session.value ? getTokenRevision() : undefined, expire)
33
+
34
+ /** 每次操作中止上一次请求;卸载与重试后晚到响应不能恢复旧身份。 */
35
+ async function run(action: (signal: AbortSignal) => Promise<PlatformSession | null>): Promise<void> {
36
+ if (disposed) return
37
+ const current = ++generation
38
+ request?.abort()
39
+ // 重新认证/恢复先撤销旧身份及凭据;失败不保留可用的旧 Token。
40
+ clearAccessToken()
41
+ session.value = null
42
+ const controller = new AbortController()
43
+ request = controller
44
+ loading.value = true
45
+ error.value = ''
46
+ try {
47
+ const value = await action(controller.signal)
48
+ if (current === generation && !controller.signal.aborted) {
49
+ activateSessionToken(value)
50
+ session.value = value
51
+ }
52
+ } catch (cause) {
53
+ if (current === generation && !controller.signal.aborted) error.value = toSafeError(cause).message
54
+ } finally {
55
+ if (current === generation) loading.value = false
56
+ }
57
+ }
58
+
59
+ /** 输入表单凭据;完成身份与菜单加载后一次性替换会话,不持久化密码。 */
60
+ function signIn(username: string, password: string): Promise<void> {
61
+ return run(signal => login(username, password, signal))
62
+ }
63
+ /** 恢复接口失败保留错误,未登录返回空会话;不会回退为演示身份。 */
64
+ function restore(): Promise<void> {
65
+ return run(restoreSession)
66
+ }
67
+ /** 立即移除工作区触发资源释放;远程注销失败也不恢复本地旧会话。 */
68
+ function signOut(): void {
69
+ const previousToken = getAccessToken()
70
+ clearDemoSession()
71
+ session.value = null
72
+ void run(async signal => { await logout(signal, previousToken); return null })
73
+ }
74
+
75
+ const ready = restore()
76
+ function dispose(): void {
77
+ if (disposed) return
78
+ disposed = true
79
+ offExpiry(); generation++; request?.abort(); clearAccessToken(); session.value = null
80
+ }
81
+ return { session, loading, error, signIn, restore, signOut, expire, ready, dispose }
82
+ }
83
+
84
+ export type PlatformSessionController = ReturnType<typeof createPlatformSession>
85
+ export const platformSessionKey: InjectionKey<PlatformSessionController> = Symbol('platform-session')
86
+ /** 只读取当前 App 提供的控制器,不发起第二次恢复或注册监听。 */
87
+ export function usePlatformSession(): PlatformSessionController {
88
+ const controller = inject(platformSessionKey)
89
+ if (!controller) throw new Error('未安装平台会话控制器')
90
+ return controller
91
+ }
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { ArrowDown, Bell, Expand, Fold, Search, SwitchButton, User } from '@element-plus/icons-vue'
4
+ import type { MicroApp, MicroMenu } from '@axewo/fin-micro'
5
+
6
+ /** 顶栏仅维护展示状态;搜索结果仍交由父级授权导航,退出必须经过核心守卫。 */
7
+ const props = defineProps<{ userName: string; activeTitle: string; pending: boolean; collapsed: boolean; apps: readonly MicroApp[] }>()
8
+ const emit = defineEmits<{
9
+ logout: []
10
+ profile: []
11
+ 'update:collapsed': [value: boolean]
12
+ open: [appId: string, menu: MicroMenu]
13
+ 'popover-opened': []
14
+ }>()
15
+ const search = ref('')
16
+ const popover = ref<'search' | 'messages' | 'user' | null>(null)
17
+ /** 只检索当前会话已经授权的真实菜单,不构造额外路由或演示入口。 */
18
+ const entries = computed(() => {
19
+ const collect = (appId: string, menus: readonly MicroMenu[], parent: string): { appId: string; menu: MicroMenu; path: string }[] => menus.flatMap(menu => {
20
+ const path = `${parent} / ${menu.name}`
21
+ return [...(menu.target ? [{ appId, menu, path }] : []), ...collect(appId, menu.children ?? [], path)]
22
+ })
23
+ return props.apps.flatMap(app => collect(app.appId, app.menus ?? [], app.name))
24
+ })
25
+ const results = computed(() => entries.value.filter(entry => entry.path.toLowerCase().includes(search.value.trim().toLowerCase())))
26
+ function closePopovers(): void { popover.value = null }
27
+ function showPopover(name: 'search' | 'messages' | 'user'): void {
28
+ if (props.pending) return
29
+ popover.value = popover.value === name && name !== 'search' ? null : name
30
+ if (popover.value) emit('popover-opened')
31
+ }
32
+ function navigate(entry: { appId: string; menu: MicroMenu }): void {
33
+ if (props.pending) return
34
+ closePopovers()
35
+ search.value = ''
36
+ emit('open', entry.appId, entry.menu)
37
+ }
38
+ // 退出确认可能由业务应用弹窗完成,必须先移除顶栏背板。
39
+ watch(() => props.pending, pending => { if (pending) closePopovers() })
40
+ defineExpose({ closePopovers })
41
+ </script>
42
+ <template>
43
+ <button v-if="popover" class="popover-backdrop" aria-label="关闭弹出菜单" tabindex="-1" @click="closePopovers" />
44
+ <header class="shell-topbar" :inert="pending || undefined" @keydown.esc="closePopovers">
45
+ <div class="brand"><span class="brand-icon" aria-hidden="true">F</span><strong>FIN <span>__PROJECT_NAME__</span></strong></div>
46
+ <button class="icon-button collapse-toggle" :aria-label="collapsed ? '展开侧栏' : '折叠侧栏'" :aria-expanded="!collapsed" @click="!pending && emit('update:collapsed', !collapsed)">
47
+ <el-icon aria-hidden="true"><component :is="collapsed ? Expand : Fold" /></el-icon>
48
+ </button>
49
+ <div class="shell-breadcrumb"><span>工作空间</span><span>/</span><strong>{{ props.activeTitle }}</strong></div>
50
+ <!-- 弹性间隔把搜索及账号操作推向右侧,面包屑仍紧跟折叠按钮。 -->
51
+ <div class="topbar-spacer" />
52
+ <div class="menu-search">
53
+ <el-icon aria-hidden="true"><Search /></el-icon><input v-model="search" aria-label="搜索菜单" placeholder="搜索菜单、应用…" @focus="showPopover('search')" @keydown.enter.prevent="!$event.isComposing && results[0] && navigate(results[0])" />
54
+ <div v-if="popover === 'search'" class="shell-popover search-results">
55
+ <p class="popover-label">菜单搜索</p>
56
+ <button v-for="entry in results" :key="JSON.stringify([entry.appId, entry.menu.menuId])" @click="navigate(entry)"><strong>{{ entry.menu.name }}</strong><small>{{ entry.path }}</small></button>
57
+ <p v-if="!results.length" class="popover-empty">未找到匹配的菜单</p>
58
+ </div>
59
+ </div>
60
+ <div class="topbar-action">
61
+ <button class="icon-button" aria-label="消息通知" :aria-expanded="popover === 'messages'" @click="showPopover('messages')"><el-icon aria-hidden="true"><Bell /></el-icon></button>
62
+ <div v-if="popover === 'messages'" class="shell-popover message-popover"><h3>消息通知</h3><div class="empty-state"><strong>尚未接入消息服务</strong><p>接入后将在这里显示真实的通知消息。</p></div></div>
63
+ </div>
64
+ <div class="topbar-action">
65
+ <button class="user-trigger" :aria-expanded="popover === 'user'" @click="showPopover('user')"><span class="avatar">{{ userName.slice(0, 1) }}</span><span>{{ userName }}</span><el-icon aria-hidden="true"><ArrowDown /></el-icon></button>
66
+ <div v-if="popover === 'user'" class="shell-popover user-popover"><strong>{{ userName }}</strong><hr /><button :disabled="pending" @click="closePopovers(); !pending && emit('profile')"><el-icon aria-hidden="true"><User /></el-icon>个人中心</button><button :disabled="pending" @click="!pending && emit('logout')"><el-icon aria-hidden="true"><SwitchButton /></el-icon>{{ pending ? '正在确认退出…' : '退出登录' }}</button></div>
67
+ </div>
68
+ </header>
69
+ </template>