@axewo/fin-micro 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -3
- package/dist/browser.cjs +1 -1
- package/dist/browser.mjs +68 -66
- package/dist/core.cjs +1 -1
- package/dist/core.mjs +447 -433
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +31 -24
- package/dist/{router-view-impl-C1KG6mxu.mjs → router-view-impl-B1NzCGKV.mjs} +18 -18
- package/dist/router-view-impl-BvOToV_Z.cjs +1 -0
- package/dist/runtime.cjs +1 -1
- package/dist/runtime.mjs +40 -39
- package/dist/sdk.cjs +1 -1
- package/dist/sdk.mjs +92 -56
- package/dist/types/browser/index.d.ts +4 -0
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/runtime/lifecycle.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +63 -4
- package/dist/types/vue/index.d.ts +13 -2
- package/dist/types/vue/router-view-impl.d.ts +1 -1
- package/dist/vue.cjs +1 -1
- package/dist/vue.mjs +49 -35
- package/package.json +2 -2
- package/scaffold/base/package.json.template +5 -1
- package/scaffold/base/scripts/check-runtime.mjs +43 -0
- package/scaffold/base/src/locales/en.ts +12 -0
- package/scaffold/base/src/locales/index.ts +7 -1
- package/scaffold/base/src/locales/zh-CN.ts +12 -0
- package/scaffold/base/src/main.ts +13 -5
- package/scaffold/base/src/router/demo.ts +6 -0
- package/scaffold/base/src/router/index.ts +13 -0
- package/scaffold/base/src/stores/demo.ts +12 -0
- package/scaffold/base/src/stores/index.ts +11 -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 +32 -11
- package/scaffold/base/src/views/DemoView.vue +26 -0
- package/scaffold/host/.env.example +11 -3
- package/scaffold/host/src/App.vue +1 -13
- package/scaffold/host/src/bootstrap.ts +40 -8
- package/scaffold/host/src/hooks/usePlatformSession.ts +40 -8
- package/scaffold/host/src/layout/HostHeader.vue +65 -5
- package/scaffold/host/src/layout/HostLayout.vue +65 -21
- package/scaffold/host/src/layout/HostMenu.vue +1 -0
- package/scaffold/host/src/layout/HostTabs.vue +41 -8
- package/scaffold/host/src/layout/HostWorkspace.vue +19 -7
- package/scaffold/host/src/localPages.ts +45 -0
- package/scaffold/host/src/platform.ts +41 -10
- 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/utils/request.ts +30 -3
- 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 +59 -9
- 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/bootstrap.ts +18 -22
- package/scaffold/vue/src/bridge/auth.ts +23 -0
- package/scaffold/vue/src/bridge/session.ts +2 -2
- package/scaffold/vue/src/main.ts +5 -2
- package/scaffold/vue/src/router/index.ts +2 -0
- package/scaffold/vue/src/views/DetailView.vue +14 -5
- package/scaffold/vue/src/views/ListView.vue +9 -5
- package/dist/router-view-impl-CgLEalx4.cjs +0 -1
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
|
|
2
|
+
import { appConfig, validateAddress } from './appConfig'
|
|
3
|
+
import { toSafeError, reportError } from './errorUtils'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* 应用独立的 Axios 请求实例;由 base 生成到基座和子应用,各项目自行定义拦截器。
|
|
@@ -11,9 +13,9 @@ import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
|
|
|
11
13
|
* 响应拦截器须返回AxiosResponse,而不是直接返回response.data;错误保持AxiosError/isCancel语义。
|
|
12
14
|
*/
|
|
13
15
|
export const http = new AxiosHttpClient({
|
|
14
|
-
baseURL:
|
|
15
|
-
timeout:
|
|
16
|
-
withCredentials:
|
|
16
|
+
baseURL: appConfig.apiBase,
|
|
17
|
+
timeout: appConfig.timeout,
|
|
18
|
+
withCredentials: appConfig.withCredentials,
|
|
17
19
|
})
|
|
18
20
|
|
|
19
21
|
/** 按请求读取最新凭据;独立应用可显式安装自己的 provider,基座默认不读取 bridge。 */
|
|
@@ -24,6 +26,12 @@ interface TokenBinding {
|
|
|
24
26
|
active: boolean
|
|
25
27
|
pending: Set<AbortController>
|
|
26
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) }
|
|
27
35
|
}
|
|
28
36
|
let tokenBinding: TokenBinding | undefined
|
|
29
37
|
/**
|
|
@@ -35,13 +43,15 @@ let tokenBinding: TokenBinding | undefined
|
|
|
35
43
|
* @returns 幂等释放函数,取消挂起读取并恢复仍有效的前一个绑定,不撤回已发出的网络请求。
|
|
36
44
|
* @throws 可信地址无效时立即抛错;provider 失败使本次请求拒绝,不降级匿名重发。
|
|
37
45
|
*/
|
|
38
|
-
export function installRequestTokenProvider(provider: RequestTokenProvider, trustedApiBase =
|
|
46
|
+
export function installRequestTokenProvider(provider: RequestTokenProvider, trustedApiBase = appConfig.trustedApiBase,
|
|
47
|
+
begin?: (signal: AbortSignal) => Promise<() => Promise<unknown>>): () => void {
|
|
39
48
|
const location = globalThis.location
|
|
40
49
|
if (!location && !/^https:\/\//i.test(trustedApiBase)) throw new Error('无浏览器来源时必须显式配置绝对 HTTPS API')
|
|
50
|
+
validateAddress(trustedApiBase)
|
|
41
51
|
const api = new URL(trustedApiBase, location?.href)
|
|
42
52
|
if (!['http:', 'https:'].includes(api.protocol) || api.username || api.password || api.search || api.hash || !api.pathname.endsWith('/')) throw new Error('可信 API 必须是 HTTP(S) 目录地址')
|
|
43
53
|
if (api.protocol !== 'https:' && (!location || api.origin !== location.origin)) throw new Error('跨源可信 API 必须使用 HTTPS')
|
|
44
|
-
const binding: TokenBinding = { provider, api, active: true, pending: new Set(), previous: tokenBinding }
|
|
54
|
+
const binding: TokenBinding = { provider, api, begin, active: true, pending: new Set(), previous: tokenBinding }
|
|
45
55
|
tokenBinding = binding
|
|
46
56
|
return () => {
|
|
47
57
|
binding.active = false
|
|
@@ -79,10 +89,12 @@ http.interceptors.request.use(
|
|
|
79
89
|
config.signal?.addEventListener?.('abort', abort)
|
|
80
90
|
binding.pending.add(controller)
|
|
81
91
|
try {
|
|
82
|
-
if (controller.signal.aborted) throw new
|
|
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() })
|
|
83
95
|
// Promise 竞速确保不合作的独立 provider 也不会在卸载后继续发送请求。
|
|
84
96
|
const token = await new Promise<string | null>((resolve, reject) => {
|
|
85
|
-
const cancelled = () => reject(new
|
|
97
|
+
const cancelled = () => reject(new DOMException('凭据读取已取消', 'AbortError'))
|
|
86
98
|
controller.signal.addEventListener('abort', cancelled, { once: true })
|
|
87
99
|
// 竞速一旦结束立即释放监听,不等待不合作 provider 的底层 Promise。
|
|
88
100
|
const settle = <T>(callback: (value: T) => void, value: T) => {
|
|
@@ -91,11 +103,15 @@ http.interceptors.request.use(
|
|
|
91
103
|
}
|
|
92
104
|
Promise.resolve().then(() => binding.provider(controller.signal)).then(value => settle(resolve, value), error => settle(reject, error))
|
|
93
105
|
})
|
|
94
|
-
if (controller.signal.aborted || !binding.active || tokenBinding !== binding) throw new
|
|
106
|
+
if (controller.signal.aborted || !binding.active || tokenBinding !== binding) throw new DOMException('凭据来源已释放', 'AbortError')
|
|
95
107
|
if (token !== null && (typeof token !== 'string' || !token || token.length > 16384 || /[\s\u0000-\u001f\u007f]/.test(token))) throw new Error('凭据格式无效')
|
|
96
108
|
// Cookie 模式的 null 不添加头;不缓存凭据,不刷新登录、不自动重试写请求。
|
|
97
109
|
if (token !== null) config.headers.set('Authorization', `Bearer ${token}`)
|
|
98
110
|
return config
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (toSafeError(error).kind === 'unauthenticated') await notifyFailure(requestFailures.get(config))
|
|
113
|
+
requestFailures.delete(config)
|
|
114
|
+
throw error
|
|
99
115
|
} finally {
|
|
100
116
|
binding.pending.delete(controller)
|
|
101
117
|
config.signal?.removeEventListener?.('abort', abort)
|
|
@@ -113,11 +129,16 @@ http.interceptors.request.use(
|
|
|
113
129
|
*/
|
|
114
130
|
http.interceptors.response.use(
|
|
115
131
|
response => {
|
|
116
|
-
|
|
132
|
+
requestFailures.delete(response.config)
|
|
117
133
|
return response
|
|
118
134
|
},
|
|
119
|
-
error => {
|
|
120
|
-
|
|
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
|
+
}
|
|
121
142
|
return Promise.reject(error)
|
|
122
143
|
},
|
|
123
144
|
)
|
|
@@ -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>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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。
|
|
4
12
|
VITE_MICRO_APP_ID=business
|
|
5
13
|
VITE_MICRO_APP_ENTRY=http://localhost:7101
|
|
@@ -1,16 +1,4 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import AppProviders from './components/AppProviders.vue'
|
|
3
|
-
import HostLayout from './layout/HostLayout.vue'
|
|
4
|
-
import LoginView from './views/LoginView.vue'
|
|
5
|
-
import { usePlatformSession } from './hooks/usePlatformSession'
|
|
6
|
-
|
|
7
|
-
// 根组件只切换登录页与已认证工作区;身份就绪前不创建微前端宿主。
|
|
8
|
-
const { session, loading, error, signIn, restore, signOut } = usePlatformSession()
|
|
9
3
|
</script>
|
|
10
|
-
|
|
11
|
-
<template>
|
|
12
|
-
<AppProviders>
|
|
13
|
-
<HostLayout v-if="session" :key="session.user.id" :session="session" @logout="signOut" />
|
|
14
|
-
<LoginView v-else :loading="loading" :error="error" :sign-in="signIn" @restore="restore" />
|
|
15
|
-
</AppProviders>
|
|
16
|
-
</template>
|
|
4
|
+
<template><AppProviders><RouterView /></AppProviders></template>
|
|
@@ -1,18 +1,50 @@
|
|
|
1
|
-
import { createApp
|
|
1
|
+
import { createApp } from 'vue'
|
|
2
|
+
import { disposePinia } from 'pinia'
|
|
2
3
|
import ElementPlus from 'element-plus'
|
|
3
4
|
import App from './App.vue'
|
|
4
5
|
import { installAppLocale } from './locales'
|
|
5
|
-
|
|
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
|
|
6
11
|
|
|
7
12
|
/** 幂等释放整个认证与宿主组件树,组件内负责停止请求、会话订阅及 runtime。 */
|
|
8
|
-
export function unmount(): void {
|
|
13
|
+
export function unmount(): void { cleanup?.(); cleanup = undefined }
|
|
9
14
|
|
|
10
15
|
/** 无参数启动平台;宿主只会在 App 完成认证后创建,不在此绕过登录门禁。 */
|
|
11
16
|
export async function mount(): Promise<void> {
|
|
12
17
|
unmount()
|
|
13
|
-
app = createApp(App)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
app.
|
|
17
|
-
|
|
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
|
+
}
|
|
18
50
|
}
|
|
@@ -1,21 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { activateSessionToken, clearAccessToken, getAccessToken, login, logout, restoreSession } from '../platform'
|
|
1
|
+
import { inject, ref, shallowRef, type InjectionKey } from 'vue'
|
|
2
|
+
import { activateSessionToken, clearAccessToken, clearDemoSession, getAccessToken, login, logout, restoreSession } from '../platform'
|
|
3
3
|
import type { PlatformSession } from '../types/platform'
|
|
4
|
+
import { getTokenRevision } from '../platform'
|
|
5
|
+
import { installPlatformExpiry } from '../utils/request'
|
|
6
|
+
import { toSafeError } from '../utils/errorUtils'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
*
|
|
9
|
+
* 每个 App 在 bootstrap 创建一次控制器并发起恢复;卸载时必须调用 dispose。
|
|
7
10
|
* @returns 响应式身份/忙碌/错误状态及登录、恢复、退出操作;操作错误写入 error,不向表单抛出。
|
|
8
11
|
* 此处只管理身份请求,导航和页面资源由工作区负责;生产登录/CSRF协议在 platform.ts 替换。
|
|
9
12
|
*/
|
|
10
|
-
export function
|
|
13
|
+
export function createPlatformSession() {
|
|
11
14
|
const session = shallowRef<PlatformSession | null>(null)
|
|
12
15
|
const loading = ref(false)
|
|
13
16
|
const error = ref('')
|
|
14
17
|
let request: AbortController | undefined
|
|
15
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)
|
|
16
33
|
|
|
17
34
|
/** 每次操作中止上一次请求;卸载与重试后晚到响应不能恢复旧身份。 */
|
|
18
35
|
async function run(action: (signal: AbortSignal) => Promise<PlatformSession | null>): Promise<void> {
|
|
36
|
+
if (disposed) return
|
|
19
37
|
const current = ++generation
|
|
20
38
|
request?.abort()
|
|
21
39
|
// 重新认证/恢复先撤销旧身份及凭据;失败不保留可用的旧 Token。
|
|
@@ -32,7 +50,7 @@ export function usePlatformSession() {
|
|
|
32
50
|
session.value = value
|
|
33
51
|
}
|
|
34
52
|
} catch (cause) {
|
|
35
|
-
if (current === generation && !controller.signal.aborted) error.value =
|
|
53
|
+
if (current === generation && !controller.signal.aborted) error.value = toSafeError(cause).message
|
|
36
54
|
} finally {
|
|
37
55
|
if (current === generation) loading.value = false
|
|
38
56
|
}
|
|
@@ -49,11 +67,25 @@ export function usePlatformSession() {
|
|
|
49
67
|
/** 立即移除工作区触发资源释放;远程注销失败也不恢复本地旧会话。 */
|
|
50
68
|
function signOut(): void {
|
|
51
69
|
const previousToken = getAccessToken()
|
|
70
|
+
clearDemoSession()
|
|
52
71
|
session.value = null
|
|
53
72
|
void run(async signal => { await logout(signal, previousToken); return null })
|
|
54
73
|
}
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
59
91
|
}
|
|
@@ -1,9 +1,69 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
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 })
|
|
4
41
|
</script>
|
|
5
42
|
<template>
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
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>
|
|
9
69
|
</template>
|
|
@@ -1,41 +1,74 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
3
|
import type { MenuInstance } from 'element-plus'
|
|
4
|
-
import { createMicroHost, createWujieRuntime,
|
|
5
|
-
import { createTokenCapability, type
|
|
4
|
+
import { createMicroHost, createWujieRuntime, useMicroHost, type MicroMenu, type NavigationOperation } from '@axewo/fin-micro'
|
|
5
|
+
import { createTokenCapability, createAuthFailureCapabilities, type AuthCapabilities } from '@axewo/fin-micro/sdk'
|
|
6
6
|
import { canOpenRoute, getAccessToken, getTokenRevision, publicSession } from '../platform'
|
|
7
7
|
import type { PlatformSession } from '../types/platform'
|
|
8
8
|
import HostHeader from './HostHeader.vue'
|
|
9
9
|
import HostMenu from './HostMenu.vue'
|
|
10
10
|
import HostTabs from './HostTabs.vue'
|
|
11
11
|
import HostWorkspace from './HostWorkspace.vue'
|
|
12
|
+
import { localApp, localTarget, workspaceOperation } from '../localPages'
|
|
13
|
+
import { useRouter } from 'vue-router'
|
|
14
|
+
import { connectWorkspaceBrowser, connectWorkspaceRouter } from '../router/workspace'
|
|
15
|
+
import '../styles/shell.css'
|
|
16
|
+
import { toSafeError, reportError } from '../utils/errorUtils'
|
|
12
17
|
const props = defineProps<{ session: PlatformSession }>()
|
|
13
|
-
const emit = defineEmits<{ logout: [] }>()
|
|
18
|
+
const emit = defineEmits<{ logout: []; expired: [revision: number] }>()
|
|
19
|
+
// 折叠及弹层仅影响宿主展示,不改变授权菜单、浏览器恢复或应用运行时。
|
|
20
|
+
const collapsed = ref(false)
|
|
21
|
+
const header = ref<InstanceType<typeof HostHeader>>()
|
|
22
|
+
const tabBar = ref<InstanceType<typeof HostTabs>>()
|
|
23
|
+
function closePopovers(): void {
|
|
24
|
+
header.value?.closePopovers()
|
|
25
|
+
tabBar.value?.closePopovers()
|
|
26
|
+
}
|
|
14
27
|
const notice = ref('')
|
|
15
28
|
const exiting = ref(false)
|
|
16
29
|
let alive = true
|
|
17
|
-
const
|
|
30
|
+
const sessionRevision = getTokenRevision()
|
|
31
|
+
const authenticated = () => alive && getTokenRevision() === sessionRevision
|
|
32
|
+
const report = (error: unknown) => { if (alive) notice.value = toSafeError(error).message }
|
|
33
|
+
const trustedApps = props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId)
|
|
34
|
+
const failureCapabilities = createAuthFailureCapabilities({
|
|
35
|
+
apps: trustedApps,
|
|
36
|
+
getRevision: getTokenRevision,
|
|
37
|
+
invalidate: () => {
|
|
38
|
+
if (!alive) return
|
|
39
|
+
// 立即撤销公开身份与新能力准入;父控制器同步清除私有凭据,不能被 beforeLeave 阻断。
|
|
40
|
+
host.updateSession({ authenticated: false, permissions: [] })
|
|
41
|
+
emit('expired', getTokenRevision())
|
|
42
|
+
alive = false
|
|
43
|
+
},
|
|
44
|
+
})
|
|
18
45
|
// 认证成功后才创建;只允许当前后台授权并经过可信注册表转换的应用。
|
|
19
|
-
const host = createMicroHost<
|
|
46
|
+
const host = createMicroHost<AuthCapabilities>([localApp, ...props.session.apps], {
|
|
20
47
|
capabilities: {
|
|
48
|
+
...failureCapabilities,
|
|
21
49
|
getToken: createTokenCapability({
|
|
22
50
|
// 只授权当前已登录且出自本地可信注册表的业务应用,不使用接口传入的任意 appId。
|
|
23
51
|
apps: props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId),
|
|
24
52
|
// exiting 只是离页确认状态;守卫中的保存仍需凭据,确认退出后才由认证控制器清除。
|
|
25
|
-
getSession: () => ({ ...publicSession(props.session), authenticated:
|
|
53
|
+
getSession: () => ({ ...publicSession(props.session), authenticated: authenticated(), revision: getTokenRevision() }),
|
|
26
54
|
// 业务替换点:可改为接收 context.signal 的异步后台读取;helper 会复查会话版本与存活。
|
|
27
55
|
provider: () => getAccessToken(),
|
|
28
56
|
}),
|
|
29
57
|
},
|
|
30
58
|
onError: report,
|
|
31
|
-
canOpen: input =>
|
|
59
|
+
canOpen: input => authenticated() && !exiting.value && canOpenRoute(props.session, input),
|
|
32
60
|
// 不开放任意请求代理;Token 仅由上面的显式白名单能力提供,跨应用导航另行制定白名单。
|
|
33
61
|
canNavigate: () => false,
|
|
34
62
|
})
|
|
35
63
|
host.updateSession(publicSession(props.session))
|
|
36
64
|
// 身份与授权菜单就绪后才能恢复 URL;用户隔离存储,禁止恢复未通过 canOpen 的入口。
|
|
37
|
-
const
|
|
38
|
-
|
|
65
|
+
const router = useRouter()
|
|
66
|
+
// 源示例同样在恢复前登记主页:首位、去重并迁移缺少主页的旧快照,不先清空再补页。
|
|
67
|
+
// 此时没有页面或业务守卫;交互切回主页仍须经过 operate 的离页确认。
|
|
68
|
+
const browser = connectWorkspaceBrowser(host, router, { storageKey: `fin-micro:tabs:${props.session.user.id}`, onError: report })
|
|
69
|
+
const workspaceRouter = connectWorkspaceRouter(host, router, () => authenticated() && !exiting.value, report)
|
|
70
|
+
// 运行时只接收远端注册表,本地应用永远不会变成 Wujie 网络加载目标。
|
|
71
|
+
const runtime = createWujieRuntime({ apps: host.apps.filter(app => app.kind !== 'local'), bridge: host.bridge })
|
|
39
72
|
const state = useMicroHost(host)
|
|
40
73
|
const active = computed(() => state.value.tabs.find(tab => tab.tabId === state.value.activeTabId))
|
|
41
74
|
const menu = ref<MenuInstance>()
|
|
@@ -77,7 +110,7 @@ const off = host.subscribe(snapshot => {
|
|
|
77
110
|
async function operate(operation: NavigationOperation): Promise<void> {
|
|
78
111
|
if (exiting.value) return
|
|
79
112
|
try {
|
|
80
|
-
const result = await host.operate(operation)
|
|
113
|
+
const result = await host.operate(workspaceOperation(operation, host.getState().tabs))
|
|
81
114
|
if (result.status === 'cancelled') notice.value = '操作已取消'
|
|
82
115
|
if (result.status === 'stale') notice.value = '目标页签已变化,请重试'
|
|
83
116
|
} catch (error) { report(error) }
|
|
@@ -88,6 +121,7 @@ function open(appId: string, menu: MicroMenu): void {
|
|
|
88
121
|
/** 退出先由核心统一确认全部页面;取消不注销,确认后由父组件撤销会话并卸载工作区。 */
|
|
89
122
|
async function signOut(): Promise<void> {
|
|
90
123
|
if (exiting.value) return
|
|
124
|
+
closePopovers()
|
|
91
125
|
exiting.value = true
|
|
92
126
|
try {
|
|
93
127
|
const result = await host.operate({ type: 'closeMany', scope: 'all' })
|
|
@@ -99,26 +133,36 @@ async function signOut(): Promise<void> {
|
|
|
99
133
|
onBeforeUnmount(() => {
|
|
100
134
|
alive = false
|
|
101
135
|
off()
|
|
136
|
+
workspaceRouter.dispose()
|
|
102
137
|
browser.dispose()
|
|
103
138
|
// 先撤销公开身份,再释放沙箱和核心;立即 dispose 阻止任何旧 SDK 发起新动作。
|
|
104
139
|
host.updateSession({ authenticated: false, permissions: [] })
|
|
105
|
-
void runtime.dispose().catch(
|
|
140
|
+
void runtime.dispose().catch(reportError)
|
|
106
141
|
host.dispose()
|
|
107
142
|
})
|
|
108
143
|
</script>
|
|
109
144
|
|
|
110
145
|
<template>
|
|
111
|
-
<div class="shell">
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
<div class="host-shell" :class="{ 'is-collapsed': collapsed }" :aria-busy="exiting" @keydown.esc="closePopovers">
|
|
147
|
+
<!-- 只锁定宿主入口,不能锁住业务容器内的未保存确认框。 -->
|
|
148
|
+
<HostHeader ref="header" v-model:collapsed="collapsed" :apps="host.apps" :user-name="session.user.name" :active-title="active?.title ?? '工作台'" :pending="exiting" @logout="signOut" @profile="operate({ type: 'open', input: localTarget('profile') })" @open="open" @popover-opened="tabBar?.closePopovers()" />
|
|
149
|
+
<aside class="shell-sidebar" aria-label="主导航" :inert="exiting || undefined">
|
|
150
|
+
<!-- 折叠后显示应用缩写;再次点击展开,菜单状态仍由同一个 Element Plus 实例维护。 -->
|
|
151
|
+
<template v-if="collapsed">
|
|
152
|
+
<button v-for="app in host.apps.filter(item => item.menus?.length)" :key="app.appId" class="collapsed-entry" :class="{ selected: active?.appId === app.appId }" :title="app.name" :aria-label="`展开 ${app.name} 菜单`" @click="!exiting && (collapsed = false)">{{ app.name.slice(0, 1) }}</button>
|
|
116
153
|
</template>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
<!-- 侧栏用 v-show 保留菜单实例;禁用 ElMenu 折叠过渡,避免隐藏时写入窄宽度和 overflow:hidden,展开后仍裁剪菜单。 -->
|
|
155
|
+
<el-menu v-show="!collapsed" :collapse-transition="false" ref="menu" class="sidebar-menu" :default-active="resetting ? '__reset__' : selected" :default-openeds="defaultOpeneds" :router="false" @select="syncMenu">
|
|
156
|
+
<template v-for="app in host.apps" :key="app.appId">
|
|
157
|
+
<HostMenu :app-id="app.appId" :menus="app.menus ?? []" @open="open" />
|
|
158
|
+
</template>
|
|
159
|
+
</el-menu>
|
|
160
|
+
<div class="sidebar-footer"><span class="status-dot" /><span class="sidebar-label">工作空间已就绪</span></div>
|
|
161
|
+
</aside>
|
|
162
|
+
<main class="shell-main">
|
|
163
|
+
<HostTabs ref="tabBar" :tabs="state.tabs" :active-key="active?.key" :pending="exiting" @operate="operate" @error="report" @popover-opened="header?.closePopovers()" />
|
|
164
|
+
<div v-if="notice" class="shell-feedback" role="status"><span>{{ notice }}</span><button aria-label="关闭操作提示" @click="notice = ''">×</button></div>
|
|
165
|
+
<HostWorkspace :apps="host.apps" :tabs="state.tabs" :active="active" :runtime="runtime" :epochs="epochs" :pending="exiting" :session="session" @home="operate({ type: 'open', input: localTarget('home') })" @profile="operate({ type: 'open', input: localTarget('profile') })" @logout="signOut" />
|
|
122
166
|
</main>
|
|
123
167
|
</div>
|
|
124
168
|
</template>
|