@axewo/fin-micro 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -3
- package/cli/index.mjs +22 -6
- package/dist/core.cjs +1 -1
- package/dist/core.mjs +131 -127
- package/dist/layout.cjs +1 -0
- package/dist/layout.css +1 -0
- package/dist/layout.mjs +719 -0
- package/dist/types/layout/HostShell.vue.d.ts +528 -0
- package/dist/types/layout/ShellHeader.vue.d.ts +46 -0
- package/dist/types/layout/ShellMenu.vue.d.ts +12 -0
- package/dist/types/layout/ShellTabs.vue.d.ts +23 -0
- package/dist/types/layout/StandaloneMenuItem.vue.d.ts +11 -0
- package/dist/types/layout/StandaloneShell.vue.d.ts +40 -0
- package/dist/types/layout/index.d.ts +5 -0
- package/dist/types/layout/types.d.ts +65 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/watermark/FinWatermark.vue.d.ts +16 -0
- package/dist/types/watermark/config.d.ts +36 -0
- package/dist/types/watermark/index.d.ts +4 -0
- package/dist/watermark.cjs +2 -0
- package/dist/watermark.mjs +119 -0
- package/package.json +28 -3
- package/scaffold/base/index.html +2 -1
- package/scaffold/base/pnpm-workspace.yaml +6 -0
- package/scaffold/base/src/router/demo.ts +1 -1
- package/scaffold/base/src/utils/errorUtils.ts +19 -2
- package/scaffold/base/src/utils/watermarkConfig.ts +18 -0
- package/scaffold/base/src/views/{DemoView.vue → demo/index.vue} +1 -1
- package/scaffold/host/src/layout/HostMenu.vue +10 -2
- package/scaffold/host/src/layout/HostTabs.vue +55 -5
- package/scaffold/host/src/layout/HostWorkspace.vue +12 -2
- package/scaffold/host/src/localPages.ts +2 -2
- package/scaffold/host/src/platform.ts +12 -6
- package/scaffold/host/src/router/index.ts +3 -3
- package/scaffold/host/src/styles/shell.css +16 -1
- package/scaffold/host/src/views/{LoginView.vue → login/components/LoginForm.vue} +7 -12
- package/scaffold/host/src/views/{LoginRoute.vue → login/index.vue} +5 -5
- package/scaffold/host/src/views/{ProfileView.vue → profile/index.vue} +1 -1
- package/scaffold/host/src/views/{WorkspaceRoute.vue → workspace/index.vue} +3 -3
- package/scaffold/layouts/standard/host/src/layout/HostLayout.vue +116 -0
- package/scaffold/layouts/standard/host/src/styles/shell.css +168 -0
- package/scaffold/layouts/standard/vue/src/layout/StandaloneLayout.vue +26 -0
- package/scaffold/vue/src/App.vue +13 -7
- package/scaffold/vue/src/bootstrap.ts +7 -2
- package/scaffold/vue/src/layout/Header.vue +15 -0
- package/scaffold/vue/src/layout/Sidebar.vue +21 -0
- package/scaffold/vue/src/layout/SidebarItem.vue +21 -0
- package/scaffold/vue/src/layout/StandaloneLayout.vue +34 -0
- package/scaffold/vue/src/router/index.ts +8 -5
- package/scaffold/vue/src/router/menu.ts +38 -0
- package/scaffold/vue/src/views/demo/configs/table.ts +24 -0
- package/scaffold/vue/src/views/demo/hooks/useRecordNavigation.ts +32 -0
- package/scaffold/vue/src/views/demo/index.vue +17 -0
- package/scaffold/vue/src/views/{DetailView.vue → demo/modules/detail/index.vue} +2 -2
- package/scaffold/vue/src/views/demo/types.ts +5 -0
- package/scaffold/vue/src/views/ListView.vue +0 -55
- /package/scaffold/host/src/views/{HomeView.vue → home/index.vue} +0 -0
- /package/scaffold/host/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
- /package/scaffold/vue/src/views/{NotFoundView.vue → not-found/index.vue} +0 -0
|
@@ -5,8 +5,8 @@ export const LOCAL_APP_ID = 'host-local'
|
|
|
5
5
|
export const localApp: MicroApp = {
|
|
6
6
|
kind: 'local', appId: LOCAL_APP_ID, name: '工作台',
|
|
7
7
|
menus: [
|
|
8
|
-
{ menuId: 'home', name: '主页', target: { tabId: 'host-local:home', route: '/home' } },
|
|
9
|
-
{ menuId: 'profile', name: '个人中心', target: { tabId: 'host-local:profile', route: '/profile' } },
|
|
8
|
+
{ menuId: 'home', name: '主页', icon: 'HomeFilled', target: { tabId: 'host-local:home', route: '/home' } },
|
|
9
|
+
{ menuId: 'profile', name: '个人中心', icon: 'User', target: { tabId: 'host-local:profile', route: '/profile' } },
|
|
10
10
|
],
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -39,8 +39,8 @@ if (appId === LOCAL_APP_ID) throw new Error('业务 appId 不能占用宿主保
|
|
|
39
39
|
const registry: MicroApp[] = [{
|
|
40
40
|
appId, name: '业务应用', entry,
|
|
41
41
|
}]
|
|
42
|
-
const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', children: [
|
|
43
|
-
{ menuId: 'records', name: '示例记录', target: { tabId: `${appId}:list`, route: '/list' } },
|
|
42
|
+
const demoMenus: MicroMenu[] = [{ menuId: 'business', name: '业务工作区', icon: 'Folder', children: [
|
|
43
|
+
{ menuId: 'records', name: '示例记录', icon: 'List', target: { tabId: `${appId}:list`, route: '/list' } },
|
|
44
44
|
] }]
|
|
45
45
|
|
|
46
46
|
/** 开发默认可体验;显式 false 可联调真实认证。生产即使配置 true 也绝不启用或失败降级。 */
|
|
@@ -128,15 +128,18 @@ function decodeMenus(value: unknown, tabIds: Set<string>): MicroMenu[] {
|
|
|
128
128
|
throw new Error('菜单超出数量限制或 menuId/name 无效、重复')
|
|
129
129
|
}
|
|
130
130
|
menuIds.add(item.menuId)
|
|
131
|
+
// 后台只传图标标识;不接收 SVG、组件代码、HTML 或外部 URL,未知名称由展示层回退。
|
|
132
|
+
if (item.icon != null && (typeof item.icon !== 'string' || !/^[A-Za-z][A-Za-z0-9-]{0,63}$/.test(item.icon))) throw new Error('菜单图标标识无效')
|
|
133
|
+
const icon = typeof item.icon === 'string' && item.icon ? { icon: item.icon } : {}
|
|
131
134
|
if (item.children !== undefined) {
|
|
132
135
|
if (item.target !== undefined || !Array.isArray(item.children) || item.children.length === 0) throw new Error('菜单分组需要非空 children 且不能同时具有 target')
|
|
133
|
-
return { menuId: item.menuId, name: item.name, children: walk(item.children, depth + 1) }
|
|
136
|
+
return { menuId: item.menuId, name: item.name, ...icon, children: walk(item.children, depth + 1) }
|
|
134
137
|
}
|
|
135
138
|
if (!isObject(item.target) || !identifier(item.target.tabId) || !localRoute(item.target.route) || tabIds.has(item.target.tabId)) {
|
|
136
139
|
throw new Error('菜单入口 target 无效或 tabId 在宿主内重复')
|
|
137
140
|
}
|
|
138
141
|
tabIds.add(item.target.tabId)
|
|
139
|
-
return { menuId: item.menuId, name: item.name, target: { tabId: item.target.tabId, route: item.target.route } }
|
|
142
|
+
return { menuId: item.menuId, name: item.name, ...icon, target: { tabId: item.target.tabId, route: item.target.route } }
|
|
140
143
|
})
|
|
141
144
|
}
|
|
142
145
|
return walk(value, 1)
|
|
@@ -216,11 +219,14 @@ export async function restoreSession(signal: AbortSignal): Promise<PlatformSessi
|
|
|
216
219
|
/**
|
|
217
220
|
* username/password 为登录表单输入,signal 同时覆盖登录和后续菜单请求。
|
|
218
221
|
* 返回完整平台会话;登录拒绝、菜单失败或取消均拒绝 Promise,不能显示半就绪工作区。
|
|
219
|
-
*
|
|
222
|
+
* 演示仅在开发模式校验 demo/123456 后提供固定身份;绝非生产认证,不持久化凭据。
|
|
220
223
|
*/
|
|
221
224
|
export async function login(username: string, password: string, signal: AbortSignal): Promise<PlatformSession> {
|
|
222
225
|
if (signal.aborted) throw new DOMException('登录已取消', 'AbortError')
|
|
223
|
-
if (demoMode)
|
|
226
|
+
if (demoMode) {
|
|
227
|
+
if (username !== 'demo' || password !== '123456') throw new Error('用户名或密码错误')
|
|
228
|
+
return demoSession()
|
|
229
|
+
}
|
|
224
230
|
const value = await request('login', signal, { username, password })
|
|
225
231
|
if (value === null) throw new Error('用户名或密码错误')
|
|
226
232
|
return loadSession(value, signal)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRouter, createWebHistory, type RouterHistory } from 'vue-router'
|
|
2
|
-
import LoginRoute from '../views/
|
|
3
|
-
import WorkspaceRoute from '../views/
|
|
4
|
-
import NotFoundView from '../views/
|
|
2
|
+
import LoginRoute from '../views/login/index.vue'
|
|
3
|
+
import WorkspaceRoute from '../views/workspace/index.vue'
|
|
4
|
+
import NotFoundView from '../views/not-found/index.vue'
|
|
5
5
|
import type { PlatformSessionController } from '../hooks/usePlatformSession'
|
|
6
6
|
import { installHostGuards, type HostGuardOptions } from './guards'
|
|
7
7
|
|
|
@@ -313,7 +313,8 @@
|
|
|
313
313
|
.host-shell .tab-strip {
|
|
314
314
|
display: flex;
|
|
315
315
|
overflow-x: auto;
|
|
316
|
-
|
|
316
|
+
overflow-y: hidden;
|
|
317
|
+
scrollbar-width: none;
|
|
317
318
|
flex: 1;
|
|
318
319
|
min-width: 0;
|
|
319
320
|
gap: 4px;
|
|
@@ -358,6 +359,20 @@
|
|
|
358
359
|
.host-shell .shell-tab .tab-close:hover {
|
|
359
360
|
color: #cf4b4b;
|
|
360
361
|
}
|
|
362
|
+
.host-shell .tab-strip::-webkit-scrollbar { display: none; }
|
|
363
|
+
.host-shell .tab-scroll {
|
|
364
|
+
flex: 0 0 32px;
|
|
365
|
+
width: 32px;
|
|
366
|
+
padding: 0;
|
|
367
|
+
border: 0;
|
|
368
|
+
border-right: 1px solid var(--shell-border);
|
|
369
|
+
background: white;
|
|
370
|
+
display: inline-flex;
|
|
371
|
+
align-items: center;
|
|
372
|
+
justify-content: center;
|
|
373
|
+
}
|
|
374
|
+
.host-shell .tab-scroll svg { width: 14px; height: 14px; }
|
|
375
|
+
.host-shell .tab-scroll:not(:disabled):hover { background: #f0f5ff; color: #3267e3; }
|
|
361
376
|
.host-shell .tab-more {
|
|
362
377
|
border: 0;
|
|
363
378
|
border-left: 1px solid var(--shell-border);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import { demoMode } from '
|
|
3
|
+
import { demoMode } from '../../../platform'
|
|
4
4
|
|
|
5
5
|
// 表单只保留本地输入;会话恢复、取消与错误状态统一由上层会话组合函数管理。
|
|
6
6
|
const props = defineProps<{
|
|
@@ -9,15 +9,10 @@ const props = defineProps<{
|
|
|
9
9
|
signIn: (username: string, password: string) => Promise<void>
|
|
10
10
|
}>()
|
|
11
11
|
const emit = defineEmits<{ restore: [] }>()
|
|
12
|
-
const username = ref('')
|
|
13
|
-
const password = ref('')
|
|
12
|
+
const username = ref(demoMode ? 'demo' : '')
|
|
13
|
+
const password = ref(demoMode ? '123456' : '')
|
|
14
14
|
async function submit(): Promise<void> {
|
|
15
15
|
if (props.loading) return
|
|
16
|
-
if (demoMode) {
|
|
17
|
-
// 演示不展示或采集账号密码;固定身份只用于本地工作台体验。
|
|
18
|
-
await props.signIn('', '')
|
|
19
|
-
return
|
|
20
|
-
}
|
|
21
16
|
if (!username.value.trim() || !password.value) return
|
|
22
17
|
const credential = password.value
|
|
23
18
|
// 提交即清空密码输入,不在会话或全局状态中保存凭据。
|
|
@@ -36,12 +31,12 @@ async function submit(): Promise<void> {
|
|
|
36
31
|
</div><p class="login-brand-footer">FIN MICRO · 企业应用基座模板</p>
|
|
37
32
|
</section>
|
|
38
33
|
<section class="login-panel" aria-labelledby="login-title"><div class="login-card" :aria-busy="loading">
|
|
39
|
-
<p class="login-eyebrow">欢迎回来</p><h2 id="login-title">登录工作台</h2><p class="login-subtitle">{{ demoMode ? '
|
|
34
|
+
<p class="login-eyebrow">欢迎回来</p><h2 id="login-title">登录工作台</h2><p class="login-subtitle">{{ demoMode ? '仅开发模拟:使用 demo / 123456 登录本地主页。' : '使用你的工作账号,继续 __PROJECT_NAME__ 的工作。' }}</p>
|
|
40
35
|
<el-alert v-if="demoMode" title="仅演示登录,不提供生产身份认证" type="warning" :closable="false" />
|
|
41
|
-
<p>{{ demoMode ? '
|
|
36
|
+
<p>{{ demoMode ? '仅开发模拟,校验演示账号密码,不连接认证接口,请勿输入真实凭据;刷新仅恢复当前标签页的演示会话。' : '请接入 src/platform.ts 的真实平台接口;认证失败不会降级为演示身份。' }}</p>
|
|
42
37
|
<el-form class="login-form" label-position="top" size="large" @submit.prevent="submit">
|
|
43
|
-
<el-form-item
|
|
44
|
-
<el-form-item
|
|
38
|
+
<el-form-item label="用户名"><el-input v-model="username" autocomplete="username" placeholder="请输入你的工作账号" :disabled="loading" /></el-form-item>
|
|
39
|
+
<el-form-item label="密码"><el-input v-model="password" type="password" autocomplete="current-password" placeholder="请输入登录密码" show-password :disabled="loading" /></el-form-item>
|
|
45
40
|
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
|
|
46
41
|
<div class="actions">
|
|
47
42
|
<el-button class="login-submit" type="primary" native-type="submit" :loading="loading" :disabled="loading">{{ demoMode ? '演示登录,进入主页' : '登录工作台' }}</el-button>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { watch } from 'vue'
|
|
3
3
|
import { useRouter, useRoute } from 'vue-router'
|
|
4
|
-
import
|
|
5
|
-
import { usePlatformSession } from '
|
|
6
|
-
import { authorizedRedirect } from '
|
|
7
|
-
import { reportError } from '
|
|
4
|
+
import LoginForm from './components/LoginForm.vue'
|
|
5
|
+
import { usePlatformSession } from '../../hooks/usePlatformSession'
|
|
6
|
+
import { authorizedRedirect } from '../../router/targets'
|
|
7
|
+
import { reportError } from '../../utils/errorUtils'
|
|
8
8
|
const controller = usePlatformSession()
|
|
9
9
|
const { session, loading, error, signIn, restore } = controller
|
|
10
10
|
const router = useRouter()
|
|
@@ -13,4 +13,4 @@ watch(session, value => {
|
|
|
13
13
|
if (value) void router.replace(authorizedRedirect(router, value, route.query.redirect)).catch(reportError)
|
|
14
14
|
})
|
|
15
15
|
</script>
|
|
16
|
-
<template><
|
|
16
|
+
<template><LoginForm :loading="loading" :error="error" :sign-in="signIn" @restore="restore" /></template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { PlatformSession } from '
|
|
2
|
+
import type { PlatformSession } from '../../types/platform'
|
|
3
3
|
/** 仅展示平台公开会话字段,不读取、显示或持久化私有凭据。操作交回宿主统一导航与退出守卫。 */
|
|
4
4
|
defineProps<{ session: PlatformSession; demo: boolean; pending: boolean }>()
|
|
5
5
|
const emit = defineEmits<{ home: []; logout: [] }>()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { watch } from 'vue'
|
|
3
3
|
import { useRouter } from 'vue-router'
|
|
4
|
-
import HostLayout from '
|
|
5
|
-
import { usePlatformSession } from '
|
|
6
|
-
import { reportError } from '
|
|
4
|
+
import HostLayout from '../../layout/HostLayout.vue'
|
|
5
|
+
import { usePlatformSession } from '../../hooks/usePlatformSession'
|
|
6
|
+
import { reportError } from '../../utils/errorUtils'
|
|
7
7
|
const { session, signOut, expire } = usePlatformSession()
|
|
8
8
|
const router = useRouter()
|
|
9
9
|
watch(session, value => { if (!value) void router.replace('/login').catch(reportError) }, { flush: 'sync' })
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
3
|
+
import { createMicroHost, createWujieRuntime, useMicroHost, type MicroMenu, type NavigationOperation } from '@axewo/fin-micro'
|
|
4
|
+
import { createTokenCapability, createAuthFailureCapabilities, type AuthCapabilities } from '@axewo/fin-micro/sdk'
|
|
5
|
+
import { canOpenRoute, getAccessToken, getTokenRevision, publicSession } from '../platform'
|
|
6
|
+
import type { PlatformSession } from '../types/platform'
|
|
7
|
+
import { HostShell } from '@axewo/fin-micro/layout'
|
|
8
|
+
import '@axewo/fin-micro/layout/style.css'
|
|
9
|
+
import HostWorkspace from './HostWorkspace.vue'
|
|
10
|
+
import { isHome, localApp, localTarget, workspaceOperation } from '../localPages'
|
|
11
|
+
import { useRouter } from 'vue-router'
|
|
12
|
+
import { connectWorkspaceBrowser, connectWorkspaceRouter } from '../router/workspace'
|
|
13
|
+
import '../styles/shell.css'
|
|
14
|
+
import { toSafeError, reportError } from '../utils/errorUtils'
|
|
15
|
+
const props = defineProps<{ session: PlatformSession }>()
|
|
16
|
+
const emit = defineEmits<{ logout: []; expired: [revision: number] }>()
|
|
17
|
+
const shell = ref<InstanceType<typeof HostShell>>()
|
|
18
|
+
const notice = ref('')
|
|
19
|
+
const exiting = ref(false)
|
|
20
|
+
let alive = true
|
|
21
|
+
const sessionRevision = getTokenRevision()
|
|
22
|
+
const authenticated = () => alive && getTokenRevision() === sessionRevision
|
|
23
|
+
const report = (error: unknown) => { if (alive) notice.value = toSafeError(error).message }
|
|
24
|
+
const trustedApps = props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId)
|
|
25
|
+
const failureCapabilities = createAuthFailureCapabilities({
|
|
26
|
+
apps: trustedApps,
|
|
27
|
+
getRevision: getTokenRevision,
|
|
28
|
+
invalidate: () => {
|
|
29
|
+
if (!alive) return
|
|
30
|
+
// 立即撤销公开身份与新能力准入;父控制器同步清除私有凭据,不能被 beforeLeave 阻断。
|
|
31
|
+
host.updateSession({ authenticated: false, permissions: [] })
|
|
32
|
+
emit('expired', getTokenRevision())
|
|
33
|
+
alive = false
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
// 认证成功后才创建;只允许当前后台授权并经过可信注册表转换的应用。
|
|
37
|
+
const host = createMicroHost<AuthCapabilities>([localApp, ...props.session.apps], {
|
|
38
|
+
capabilities: {
|
|
39
|
+
...failureCapabilities,
|
|
40
|
+
getToken: createTokenCapability({
|
|
41
|
+
// 只授权当前已登录且出自本地可信注册表的业务应用,不使用接口传入的任意 appId。
|
|
42
|
+
apps: props.session.apps.filter(app => app.appId === (import.meta.env.VITE_MICRO_APP_ID || 'business')).map(app => app.appId),
|
|
43
|
+
// exiting 只是离页确认状态;守卫中的保存仍需凭据,确认退出后才由认证控制器清除。
|
|
44
|
+
getSession: () => ({ ...publicSession(props.session), authenticated: authenticated(), revision: getTokenRevision() }),
|
|
45
|
+
// 业务替换点:可改为接收 context.signal 的异步后台读取;helper 会复查会话版本与存活。
|
|
46
|
+
provider: () => getAccessToken(),
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
onError: report,
|
|
50
|
+
canOpen: input => authenticated() && !exiting.value && canOpenRoute(props.session, input),
|
|
51
|
+
// 不开放任意请求代理;Token 仅由上面的显式白名单能力提供,跨应用导航另行制定白名单。
|
|
52
|
+
canNavigate: () => false,
|
|
53
|
+
})
|
|
54
|
+
host.updateSession(publicSession(props.session))
|
|
55
|
+
// 身份与授权菜单就绪后才能恢复 URL;用户隔离存储,禁止恢复未通过 canOpen 的入口。
|
|
56
|
+
const router = useRouter()
|
|
57
|
+
// 源示例同样在恢复前登记主页:首位、去重并迁移缺少主页的旧快照,不先清空再补页。
|
|
58
|
+
// 此时没有页面或业务守卫;交互切回主页仍须经过 operate 的离页确认。
|
|
59
|
+
const browser = connectWorkspaceBrowser(host, router, { storageKey: `fin-micro:tabs:${props.session.user.id}`, onError: report })
|
|
60
|
+
const workspaceRouter = connectWorkspaceRouter(host, router, () => authenticated() && !exiting.value, report)
|
|
61
|
+
// 运行时只接收远端注册表,本地应用永远不会变成 Wujie 网络加载目标。
|
|
62
|
+
const runtime = createWujieRuntime({ apps: host.apps.filter(app => app.kind !== 'local'), bridge: host.bridge })
|
|
63
|
+
const state = useMicroHost(host)
|
|
64
|
+
const active = computed(() => state.value.tabs.find(tab => tab.tabId === state.value.activeTabId))
|
|
65
|
+
const epochs = ref<Record<string, number>>({})
|
|
66
|
+
let previous = new Set<string>()
|
|
67
|
+
const off = host.subscribe(snapshot => {
|
|
68
|
+
const present = new Set(snapshot.tabs.map(tab => tab.appId))
|
|
69
|
+
// 同 tick 关闭最后一页后重开,也不能复用已经失效的 bridge 和应用容器。
|
|
70
|
+
for (const appId of previous) if (!present.has(appId)) epochs.value[appId] = (epochs.value[appId] ?? 0) + 1
|
|
71
|
+
previous = present
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** 所有 UI 导航均走核心事务,携带原 key,避免异步操作误关重开后的新页签。 */
|
|
75
|
+
async function operate(operation: NavigationOperation): Promise<void> {
|
|
76
|
+
if (exiting.value) return
|
|
77
|
+
try {
|
|
78
|
+
const result = await host.operate(workspaceOperation(operation, host.getState().tabs))
|
|
79
|
+
if (result.status === 'cancelled') notice.value = '操作已取消'
|
|
80
|
+
if (result.status === 'stale') notice.value = '目标页签已变化,请重试'
|
|
81
|
+
} catch (error) { report(error) }
|
|
82
|
+
}
|
|
83
|
+
function open(appId: string, menu: MicroMenu): void {
|
|
84
|
+
if (menu.target) void operate({ type: 'open', input: { appId, ...menu.target, title: menu.name, activeMenuId: menu.menuId } })
|
|
85
|
+
}
|
|
86
|
+
/** 退出先由核心统一确认全部页面;取消不注销,确认后由父组件撤销会话并卸载工作区。 */
|
|
87
|
+
async function signOut(): Promise<void> {
|
|
88
|
+
if (exiting.value) return
|
|
89
|
+
shell.value?.closePopovers()
|
|
90
|
+
exiting.value = true
|
|
91
|
+
try {
|
|
92
|
+
const result = await host.operate({ type: 'closeMany', scope: 'all' })
|
|
93
|
+
if (result.status === 'committed' || result.status === 'noop') emit('logout')
|
|
94
|
+
else notice.value = '退出已取消,请先处理未保存页面'
|
|
95
|
+
} catch (error) { report(error) }
|
|
96
|
+
finally { exiting.value = false }
|
|
97
|
+
}
|
|
98
|
+
onBeforeUnmount(() => {
|
|
99
|
+
alive = false
|
|
100
|
+
off()
|
|
101
|
+
workspaceRouter.dispose()
|
|
102
|
+
browser.dispose()
|
|
103
|
+
// 先撤销公开身份,再释放沙箱和核心;立即 dispose 阻止任何旧 SDK 发起新动作。
|
|
104
|
+
host.updateSession({ authenticated: false, permissions: [] })
|
|
105
|
+
void runtime.dispose().catch(reportError)
|
|
106
|
+
host.dispose()
|
|
107
|
+
})
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<!-- 外壳只发事件;认证、导航守卫与生命周期仍由本项目唯一核心管理。 -->
|
|
112
|
+
<HostShell ref="shell" :brand="{ title: '__PROJECT_NAME__' }" :apps="host.apps" :tabs="state.tabs" :active-key="active?.key" :user-name="session.user.name" :pending="exiting" :notice="notice" :is-fixed-tab="isHome" @open="open" @operate="operate" @logout="signOut" @profile="operate({ type: 'open', input: localTarget('profile') })" @dismiss-notice="notice = ''" @error="report">
|
|
113
|
+
<!-- 业务替换点:主页/个人资料和每应用唯一容器仍属项目,不由外壳创建运行时。 -->
|
|
114
|
+
<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" />
|
|
115
|
+
</HostShell>
|
|
116
|
+
</template>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* 项目本地业务页面样式,公共外壳样式从 npm 子路径导入。 */
|
|
2
|
+
.host-shell .home-view {
|
|
3
|
+
max-width: 1440px;
|
|
4
|
+
margin: 0 auto;
|
|
5
|
+
}
|
|
6
|
+
.host-shell .welcome-panel {
|
|
7
|
+
background: #f4f7fc;
|
|
8
|
+
border: 1px solid #e5ebf5;
|
|
9
|
+
border-radius: 10px;
|
|
10
|
+
padding: 28px 32px;
|
|
11
|
+
min-height: 152px;
|
|
12
|
+
display: flex;
|
|
13
|
+
justify-content: space-between;
|
|
14
|
+
align-items: center;
|
|
15
|
+
margin-bottom: 22px;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
.host-shell .eyebrow {
|
|
19
|
+
color: #7f94b4 !important;
|
|
20
|
+
font-size: 10px !important;
|
|
21
|
+
letter-spacing: 1.8px;
|
|
22
|
+
margin: 0 0 10px !important;
|
|
23
|
+
}
|
|
24
|
+
.host-shell .welcome-panel h1 {
|
|
25
|
+
font-size: 23px;
|
|
26
|
+
letter-spacing: 0.3px;
|
|
27
|
+
margin: 0 0 9px;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
color: #25344e;
|
|
30
|
+
}
|
|
31
|
+
.host-shell .welcome-panel p {
|
|
32
|
+
font-size: 12px;
|
|
33
|
+
color: #8b97aa;
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
.host-shell .home-panel {
|
|
37
|
+
background: #fff;
|
|
38
|
+
border: 1px solid var(--shell-border);
|
|
39
|
+
border-radius: 9px;
|
|
40
|
+
padding: 22px 24px;
|
|
41
|
+
margin-bottom: 22px;
|
|
42
|
+
}
|
|
43
|
+
.host-shell .panel-heading {
|
|
44
|
+
display: flex;
|
|
45
|
+
justify-content: space-between;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 12px;
|
|
48
|
+
margin-bottom: 20px;
|
|
49
|
+
}
|
|
50
|
+
.host-shell .panel-heading h2 {
|
|
51
|
+
margin: 0;
|
|
52
|
+
font-size: 14px;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
}
|
|
55
|
+
.host-shell .panel-heading > span {
|
|
56
|
+
color: #9ba4b4;
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
}
|
|
59
|
+
.host-shell .shortcut-grid {
|
|
60
|
+
display: grid;
|
|
61
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
62
|
+
gap: 16px;
|
|
63
|
+
}
|
|
64
|
+
.host-shell .shortcut-card {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 14px;
|
|
68
|
+
padding: 18px;
|
|
69
|
+
background: white;
|
|
70
|
+
border: 1px solid #e9edf5;
|
|
71
|
+
border-radius: 7px;
|
|
72
|
+
text-align: left;
|
|
73
|
+
transition:
|
|
74
|
+
border-color 0.15s,
|
|
75
|
+
background 0.15s;
|
|
76
|
+
}
|
|
77
|
+
.host-shell .shortcut-card:hover {
|
|
78
|
+
border-color: #acc2f5;
|
|
79
|
+
background: #fafcff;
|
|
80
|
+
}
|
|
81
|
+
.host-shell .entry-icon {
|
|
82
|
+
width: 40px;
|
|
83
|
+
height: 40px;
|
|
84
|
+
display: grid;
|
|
85
|
+
place-items: center;
|
|
86
|
+
border-radius: 9px;
|
|
87
|
+
background: #edf3ff;
|
|
88
|
+
color: #4c79dc;
|
|
89
|
+
font-size: 17px;
|
|
90
|
+
}
|
|
91
|
+
.host-shell .shortcut-card:nth-child(2) .entry-icon {
|
|
92
|
+
background: #edf8f5;
|
|
93
|
+
color: #4a9a88;
|
|
94
|
+
}
|
|
95
|
+
.host-shell .shortcut-card strong {
|
|
96
|
+
display: block;
|
|
97
|
+
font-size: 13px;
|
|
98
|
+
font-weight: 500;
|
|
99
|
+
}
|
|
100
|
+
.host-shell .shortcut-card small {
|
|
101
|
+
display: block;
|
|
102
|
+
color: #99a3b4;
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
margin-top: 4px;
|
|
105
|
+
}
|
|
106
|
+
.host-shell .entry-arrow {
|
|
107
|
+
margin-left: auto;
|
|
108
|
+
color: #a0acbe;
|
|
109
|
+
}
|
|
110
|
+
.host-shell .home-columns {
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: 1.2fr 1fr;
|
|
113
|
+
gap: 22px;
|
|
114
|
+
}
|
|
115
|
+
.host-shell .home-columns .home-panel {
|
|
116
|
+
min-height: 266px;
|
|
117
|
+
}
|
|
118
|
+
.host-shell .empty-state {
|
|
119
|
+
text-align: center;
|
|
120
|
+
color: #9ba5b4;
|
|
121
|
+
padding: 25px 12px;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
}
|
|
124
|
+
.host-shell .empty-state strong {
|
|
125
|
+
display: block;
|
|
126
|
+
font-weight: 400;
|
|
127
|
+
color: #8491a6;
|
|
128
|
+
margin-bottom: 6px;
|
|
129
|
+
}
|
|
130
|
+
.host-shell .empty-state p {
|
|
131
|
+
margin: 0;
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
}
|
|
134
|
+
.host-shell .empty-symbol {
|
|
135
|
+
display: block;
|
|
136
|
+
color: #ced7e5;
|
|
137
|
+
font-size: 32px;
|
|
138
|
+
line-height: 1.2;
|
|
139
|
+
margin-bottom: 13px;
|
|
140
|
+
}
|
|
141
|
+
.host-shell .recent-list {
|
|
142
|
+
list-style: none;
|
|
143
|
+
margin: 0;
|
|
144
|
+
padding: 0;
|
|
145
|
+
}
|
|
146
|
+
.host-shell .recent-list li + li {
|
|
147
|
+
border-top: 1px solid #f1f3f7;
|
|
148
|
+
}
|
|
149
|
+
.host-shell .recent-list button {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: space-between;
|
|
153
|
+
gap: 16px;
|
|
154
|
+
width: 100%;
|
|
155
|
+
border: 0;
|
|
156
|
+
background: white;
|
|
157
|
+
text-align: left;
|
|
158
|
+
padding: 12px 0;
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
}
|
|
161
|
+
.host-shell .recent-list button:hover {
|
|
162
|
+
color: #3267e3;
|
|
163
|
+
}
|
|
164
|
+
.host-shell .recent-list small {
|
|
165
|
+
color: #99a5b5;
|
|
166
|
+
font-size: 11px;
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { FinWatermark } from '@axewo/fin-micro/watermark'
|
|
4
|
+
import { watermarkConfig } from '../utils/watermarkConfig'
|
|
5
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
6
|
+
import { StandaloneShell } from '@axewo/fin-micro/layout'
|
|
7
|
+
import '@axewo/fin-micro/layout/style.css'
|
|
8
|
+
import { createStandaloneMenus, standaloneActiveMenu } from '../router/menu'
|
|
9
|
+
import { reportError } from '../utils/errorUtils'
|
|
10
|
+
|
|
11
|
+
// 菜单映射与路由实例归项目;公共外壳不创建第二个 router 或读取后台权限。
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const route = useRoute()
|
|
14
|
+
const menus = computed(() => createStandaloneMenus(router.options.routes))
|
|
15
|
+
const activeMenu = computed(() => standaloneActiveMenu(route))
|
|
16
|
+
/** 路由失败进入项目脱敏错误边界,不产生未处理 Promise;路由守卫仍由 router 执行。 */
|
|
17
|
+
function navigate(path: string): void { void router.push(path).catch(reportError) }
|
|
18
|
+
</script>
|
|
19
|
+
<template>
|
|
20
|
+
<!-- 业务替换点:品牌、header-actions 等局部插槽不会接管其他默认区域。 -->
|
|
21
|
+
<StandaloneShell :brand="{ title: '__PROJECT_NAME__' }" :title="route.meta.title ?? '业务应用'" :menus="menus" :active-menu="activeMenu" @navigate="navigate">
|
|
22
|
+
<slot />
|
|
23
|
+
<!-- 公共外壳提供稳定业务视口;嵌入时 App 不挂载本布局。 -->
|
|
24
|
+
<template #workspace-overlay><FinWatermark :config="watermarkConfig" /></template>
|
|
25
|
+
</StandaloneShell>
|
|
26
|
+
</template>
|
package/scaffold/vue/src/App.vue
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, provide } from 'vue'
|
|
3
3
|
import AppProviders from './components/AppProviders.vue'
|
|
4
|
+
import StandaloneLayout from './layout/StandaloneLayout.vue'
|
|
5
|
+
import { isMicroFrontend } from '@axewo/fin-micro/runtime'
|
|
4
6
|
import { RouterView } from 'vue-router'
|
|
5
7
|
import { MicroTabCache, MicroRouteView, useMicroHost } from '@axewo/fin-micro'
|
|
6
8
|
import { sessionKey, type BridgeSession } from './bridge/session'
|
|
7
9
|
const props = defineProps<{ session: BridgeSession }>()
|
|
8
10
|
provide(sessionKey, props.session)
|
|
9
11
|
const { bridge, status, identity } = props.session
|
|
12
|
+
// 环境与连接状态分开:嵌入但未取得 bridge 时也不能显示第二套头部菜单。
|
|
13
|
+
const embedded = isMicroFrontend()
|
|
10
14
|
const state = bridge ? useMicroHost(bridge) : undefined
|
|
11
15
|
const tabs = computed(() => state?.value.tabs.filter(tab => tab.appId === bridge?.appId) ?? [])
|
|
12
16
|
const activeKey = computed(() => state?.value.tabs.find(tab => tab.tabId === state.value.activeTabId)?.key)
|
|
13
17
|
</script>
|
|
14
18
|
<template>
|
|
15
19
|
<AppProviders>
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
<component :is="embedded ? 'main' : StandaloneLayout">
|
|
21
|
+
<div class="page">
|
|
22
|
+
<small role="status">{{ status }} · {{ identity?.user?.name ?? '未连接公开身份' }}</small>
|
|
23
|
+
<!-- 缓存以核心 tab.key 为身份;关闭、刷新和淘汰销毁实例,切换只停用。
|
|
24
|
+
LRU 不是持久化,重要草稿需要业务另行保存,不能依赖守卫阻止浏览器刷新。 -->
|
|
25
|
+
<MicroTabCache v-if="bridge && state" :tabs="tabs" :active-key="activeKey" :max="20"><MicroRouteView /></MicroTabCache>
|
|
26
|
+
<RouterView v-else />
|
|
27
|
+
</div>
|
|
28
|
+
</component>
|
|
23
29
|
</AppProviders>
|
|
24
30
|
</template>
|
|
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|
|
2
2
|
import { disposePinia } from 'pinia'
|
|
3
3
|
import ElementPlus from 'element-plus'
|
|
4
4
|
import { connectMicroRouter } from '@axewo/fin-micro'
|
|
5
|
+
import { isMicroFrontend } from '@axewo/fin-micro/runtime'
|
|
5
6
|
import { installBridgeAuth } from './bridge/auth'
|
|
6
7
|
import { reportError, toSafeError, cleanupAll } from './utils/errorUtils'
|
|
7
8
|
import App from './App.vue'
|
|
@@ -19,7 +20,9 @@ export async function mount(): Promise<void> {
|
|
|
19
20
|
unmount()
|
|
20
21
|
const session = createBridgeSession()
|
|
21
22
|
const { bridge } = session
|
|
22
|
-
|
|
23
|
+
// history 与布局使用同一 SDK 环境标识,缺桥不应争用宿主 URL。
|
|
24
|
+
const embedded = isMicroFrontend()
|
|
25
|
+
const router = createMicroRouter(embedded)
|
|
23
26
|
const app = createApp(App, { session })
|
|
24
27
|
const pinia = createAppPinia()
|
|
25
28
|
let stopped = false
|
|
@@ -55,7 +58,9 @@ export async function mount(): Promise<void> {
|
|
|
55
58
|
// MicroRouteView 按固定页签快照解析真实路由,不全局 router.replace 污染其他缓存页面。
|
|
56
59
|
offRouter = connectMicroRouter(bridge, { async replace() { if (!stopped) session.status.value = '宿主已连接' } },
|
|
57
60
|
error => { if (!stopped) session.status.value = toSafeError(error).message })
|
|
58
|
-
} else session.status.value =
|
|
61
|
+
} else session.status.value = embedded
|
|
62
|
+
? '嵌入运行:未连接宿主'
|
|
63
|
+
: '独立演示:宿主命令不可用,CRUD 数据仅保存在当前页面内存'
|
|
59
64
|
app.mount('#app')
|
|
60
65
|
mounted = true
|
|
61
66
|
} catch (error) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** 独立应用顶栏只展示本地标题,不创建登录状态或宿主能力。 */
|
|
3
|
+
defineProps<{ title: string }>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<header class="standalone-header">
|
|
8
|
+
<strong>__PROJECT_NAME__</strong>
|
|
9
|
+
<span>{{ title }} · 独立运行</span>
|
|
10
|
+
</header>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<style scoped>
|
|
14
|
+
.standalone-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 24px; background: #fff; border-bottom: 1px solid #e5e7eb; }
|
|
15
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElMenu } from 'element-plus'
|
|
3
|
+
import type { StandaloneMenu } from '../router/menu'
|
|
4
|
+
import SidebarItem from './SidebarItem.vue'
|
|
5
|
+
|
|
6
|
+
/** 菜单只负责本地导航;活动项随路由更新,详情页可显式关联列表。 */
|
|
7
|
+
defineProps<{ menus: StandaloneMenu[]; activeMenu: string }>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<aside class="standalone-sidebar" aria-label="独立应用导航">
|
|
12
|
+
<ElMenu router :default-active="activeMenu" :collapse-transition="false">
|
|
13
|
+
<SidebarItem v-for="item in menus" :key="item.path" :item="item" />
|
|
14
|
+
</ElMenu>
|
|
15
|
+
</aside>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.standalone-sidebar { overflow: auto; background: #fff; border-right: 1px solid #e5e7eb; }
|
|
20
|
+
.standalone-sidebar :deep(.el-menu) { border-right: 0; }
|
|
21
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ElIcon, ElMenuItem, ElSubMenu } from 'element-plus'
|
|
3
|
+
import type { StandaloneMenu } from '../router/menu'
|
|
4
|
+
|
|
5
|
+
/** 递归保留实际路由分组;叶子使用完整路径交给同一个 Vue Router。 */
|
|
6
|
+
defineProps<{ item: StandaloneMenu }>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<ElSubMenu v-if="item.children.length" :index="item.path">
|
|
11
|
+
<template #title>
|
|
12
|
+
<ElIcon v-if="item.icon"><component :is="item.icon" /></ElIcon>
|
|
13
|
+
<span>{{ item.title }}</span>
|
|
14
|
+
</template>
|
|
15
|
+
<SidebarItem v-for="child in item.children" :key="child.path" :item="child" />
|
|
16
|
+
</ElSubMenu>
|
|
17
|
+
<ElMenuItem v-else :index="item.path">
|
|
18
|
+
<ElIcon v-if="item.icon"><component :is="item.icon" /></ElIcon>
|
|
19
|
+
<span>{{ item.title }}</span>
|
|
20
|
+
</ElMenuItem>
|
|
21
|
+
</template>
|