mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(navigation): apply plugin layout
This commit is contained in:
1 parent
b5816be99f
commit
7907435cff
14 files changed
+427
-27
No files matched your search
@@ -18,8 +18,15 @@ import { WebWasmSessionSync } from './session-sync'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { initializeWebWasmLocale } from './locale-bootstrap'
|
||||
import { installInsightPluginRuntime } from '@/plugins/insight-vue'
|
||||
import { webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiHost, webWasmUiServices } from '@/plugins/web-wasm'
|
||||
import {
|
||||
webWasmInsightBuiltins,
|
||||
webWasmInsightRuntime,
|
||||
webWasmNavigationLayout,
|
||||
webWasmUiHost,
|
||||
webWasmUiServices,
|
||||
} from '@/plugins/web-wasm'
|
||||
import { installStaticInsightPluginUiServices } from '@/plugins/static-insight'
|
||||
import { installNavigationLayout } from '@/navigation/vue'
|
||||
import '@/assets/styles/main.css'
|
||||
|
||||
initializeWebWasmLocale()
|
||||
@@ -53,6 +60,7 @@ async function start(): Promise<void> {
|
||||
app.use(router)
|
||||
app.use(ui)
|
||||
app.use(i18n)
|
||||
installNavigationLayout(app, webWasmNavigationLayout)
|
||||
await installStaticInsightPluginUiServices(webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiServices)
|
||||
installInsightPluginRuntime(app, webWasmInsightRuntime)
|
||||
|
||||
|
||||
@@ -87,8 +87,20 @@ test('navigation layout validation enforces two levels, stable unique entries, a
|
||||
() =>
|
||||
normalizeNavigationLayout({
|
||||
...layout,
|
||||
primary: [{ kind: 'group', id: 'group', title: 'Group', children: [{ kind: 'entry', entryId: 'nested' }] }],
|
||||
primary: [{ kind: 'group', id: 'group', children: [{ kind: 'entry', entryId: 'nested' }] }],
|
||||
}),
|
||||
/must be a non-empty string/
|
||||
)
|
||||
assert.deepEqual(
|
||||
normalizeNavigationLayout({
|
||||
schemaVersion: 1,
|
||||
primary: [{ kind: 'group', id: 'host.insight', children: [] }],
|
||||
hiddenEntryIds: [],
|
||||
}),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
primary: [{ kind: 'group', id: 'host.insight', children: [] }],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -101,15 +101,20 @@ function normalizePrimaryItem(
|
||||
if (item.kind === 'group') {
|
||||
const id = requireId(item.id, `primary[${index}].id`)
|
||||
addUnique(groupIds, id, 'navigation group')
|
||||
if (typeof item.title !== 'string' || item.title.trim().length === 0) {
|
||||
throw new NavigationLayoutValidationError(`primary[${index}].title must be a non-empty string`)
|
||||
if (item.title !== undefined && (typeof item.title !== 'string' || item.title.trim().length === 0)) {
|
||||
throw new NavigationLayoutValidationError(`primary[${index}].title must be a non-empty string when provided`)
|
||||
}
|
||||
if (!Array.isArray(item.children)) {
|
||||
throw new NavigationLayoutValidationError(`primary[${index}].children must be an array`)
|
||||
}
|
||||
const children = normalizeUniqueIds(item.children, `primary[${index}].children`)
|
||||
for (const entryId of children) addUnique(visibleEntryIds, entryId, 'navigation entry')
|
||||
return { kind: 'group', id, title: item.title, children }
|
||||
return {
|
||||
kind: 'group',
|
||||
id,
|
||||
...(typeof item.title === 'string' && { title: item.title }),
|
||||
children,
|
||||
}
|
||||
}
|
||||
throw new NavigationLayoutValidationError(`primary[${index}].kind must be "entry" or "group"`)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export interface NavigationLayoutEntryItem {
|
||||
export interface NavigationLayoutGroupItem {
|
||||
kind: 'group'
|
||||
id: string
|
||||
/** User-authored content. Keep it as-is instead of translating it. */
|
||||
title: string
|
||||
/** User-authored override. Missing values use the host-localized group title. */
|
||||
title?: string
|
||||
/** Stable navigation entry IDs. Unknown IDs are intentionally preserved. */
|
||||
children: string[]
|
||||
}
|
||||
|
||||
+20
@@ -23,6 +23,9 @@ import { configureHttpClient } from '@/services/utils/http'
|
||||
import { IS_ELECTRON } from '@/utils/platform'
|
||||
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
|
||||
import { usePlatformService } from '@/services'
|
||||
import { useNavigationLayoutService } from '@/services'
|
||||
import { listResolvedNavigationEntries } from '@/navigation/layout'
|
||||
import { useNavigationLayout } from '@/navigation/vue'
|
||||
import type { PresentationPreferences } from '@/services/preferences/types'
|
||||
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
|
||||
import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap'
|
||||
@@ -55,6 +58,7 @@ const authStore = useAuthStore()
|
||||
const apiServerStore = useApiServerStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { controller: navigationLayoutController } = useNavigationLayout()
|
||||
const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } =
|
||||
useLockScreenBootstrap(IS_ELECTRON)
|
||||
|
||||
@@ -130,6 +134,18 @@ function scheduleNonCriticalUiPrefetch() {
|
||||
cancelNonCriticalUiPrefetch = () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
async function hydrateNavigationLayout(): Promise<void> {
|
||||
const result = await useNavigationLayoutService().load()
|
||||
navigationLayoutController.applyLoadResult(result)
|
||||
|
||||
const currentPageId = String(router.currentRoute.value.meta.insightPageId ?? '')
|
||||
if (!currentPageId) return
|
||||
const visibleEntries = listResolvedNavigationEntries(navigationLayoutController.getResolvedLayout())
|
||||
if (visibleEntries.some(({ page }) => page.id === currentPageId)) return
|
||||
const fallback = visibleEntries[0]
|
||||
if (fallback) await router.replace({ name: fallback.page.routeName })
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
if (initInProgress || isRuntimeReady.value) return
|
||||
initInProgress = true
|
||||
@@ -161,6 +177,10 @@ async function initializeApp() {
|
||||
deferAfterPresentationError: () =>
|
||||
PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated,
|
||||
initializeBackground: [
|
||||
{
|
||||
name: 'navigation-layout',
|
||||
run: hydrateNavigationLayout,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
run: async () => {
|
||||
|
||||
@@ -11,11 +11,13 @@ import { markStartupPhase } from '@/bootstrap/startup-performance'
|
||||
import {
|
||||
desktopCliWebInsightBuiltins,
|
||||
desktopCliWebInsightRuntime,
|
||||
desktopCliWebNavigationLayout,
|
||||
desktopCliWebUiHost,
|
||||
desktopCliWebUiServices,
|
||||
} from '@/plugins/desktop-cli-web'
|
||||
import { installInsightPluginRuntime } from '@/plugins/insight-vue'
|
||||
import { installStaticInsightPluginUiServices } from '@/plugins/static-insight'
|
||||
import { installNavigationLayout } from '@/navigation/vue'
|
||||
import '@/assets/styles/main.css'
|
||||
|
||||
export interface MountChatLabAppOptions {
|
||||
@@ -41,6 +43,7 @@ export async function mountChatLabApp(options: MountChatLabAppOptions = {}): Pro
|
||||
app.use(router)
|
||||
app.use(ui)
|
||||
app.use(i18n)
|
||||
installNavigationLayout(app, desktopCliWebNavigationLayout)
|
||||
await installStaticInsightPluginUiServices(
|
||||
desktopCliWebInsightBuiltins,
|
||||
desktopCliWebInsightRuntime,
|
||||
|
||||
@@ -22,6 +22,9 @@ import { CompactTabs } from '@/components/navigation'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { usePlatformService } from '@/services'
|
||||
import { useHostLocale } from '@/plugins/insight-vue'
|
||||
import { DEFAULT_INSIGHT_NAVIGATION_GROUP_ID } from '@/navigation/layout'
|
||||
import { useNavigationLayout } from '@/navigation/vue'
|
||||
import { IS_ELECTRON } from '@/utils/platform'
|
||||
import logoSvg from '@/assets/images/logo.svg'
|
||||
|
||||
@@ -42,6 +45,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { translate } = useHostLocale()
|
||||
const LATEST_VERSION_URL = 'https://chatlab.fun/latest-version'
|
||||
const UPDATE_CHECK_CACHE_KEY = 'chatlab:latest-version-check:v2'
|
||||
const hasMacTitlebarInset =
|
||||
@@ -54,6 +58,7 @@ const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
|
||||
const { toggleSidebar } = layoutStore
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { controller: navigationLayoutController, snapshot: navigationLayoutSnapshot } = useNavigationLayout()
|
||||
const SESSION_ROW_SIZE = 44
|
||||
const SESSION_LIST_BOTTOM_PADDING = 32
|
||||
|
||||
@@ -68,10 +73,21 @@ const showContactsEntry = computed(() =>
|
||||
groupSessionCount: groupSessionCount.value,
|
||||
})
|
||||
)
|
||||
const showInsightEntry = computed(() => props.backendFeatures || props.insightEnabled)
|
||||
const showInsightNavigation = computed(() => props.backendFeatures || props.insightEnabled)
|
||||
const resolvedPrimaryNavigation = computed(() => {
|
||||
void navigationLayoutSnapshot.value.revision
|
||||
return navigationLayoutController.getResolvedLayout().primary
|
||||
})
|
||||
const activeInsightPageId = computed(() => String(route.meta.insightPageId ?? ''))
|
||||
|
||||
// 是否在足迹页
|
||||
const isInsightPage = computed(() => String(route.name ?? '').startsWith('insight-'))
|
||||
function navigationGroupTitle(groupId: string, customTitle?: string): string {
|
||||
if (customTitle) return customTitle
|
||||
return groupId === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID ? t('layout.insight') : groupId
|
||||
}
|
||||
|
||||
function openInsightRoute(routeName: string): void {
|
||||
void router.push({ name: routeName })
|
||||
}
|
||||
|
||||
// 重命名相关状态
|
||||
const showRenameModal = ref(false)
|
||||
@@ -510,14 +526,20 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
|
||||
@click="handleImport"
|
||||
/>
|
||||
|
||||
<!-- 足迹 -->
|
||||
<SidebarButton
|
||||
v-if="showInsightEntry"
|
||||
icon="i-heroicons-presentation-chart-bar"
|
||||
:title="t('layout.insight')"
|
||||
:active="isInsightPage"
|
||||
@click="router.push('/insight')"
|
||||
/>
|
||||
<template v-if="showInsightNavigation">
|
||||
<SidebarButton
|
||||
v-for="item in resolvedPrimaryNavigation"
|
||||
:key="item.kind === 'entry' ? item.entryId : item.id"
|
||||
:icon="item.kind === 'entry' ? item.page.icon : 'i-heroicons-presentation-chart-bar'"
|
||||
:title="item.kind === 'entry' ? translate(item.page.title) : navigationGroupTitle(item.id, item.title)"
|
||||
:active="
|
||||
item.kind === 'entry'
|
||||
? activeInsightPageId === item.page.id
|
||||
: item.entries.some(({ page }) => page.id === activeInsightPageId)
|
||||
"
|
||||
@click="openInsightRoute(item.kind === 'entry' ? item.page.routeName : item.entries[0]!.page.routeName)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SidebarButton
|
||||
v-if="props.backendFeatures && showContactsEntry"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { NAVIGATION_LAYOUT_SCHEMA_VERSION, type NavigationLayout } from '@openchatlab/shared-types'
|
||||
import { createInsightPluginRuntime, type InsightPlugin } from '@/plugins/insight'
|
||||
import { InsightScopeController } from '@/plugins/insight-scope'
|
||||
import { PluginLocaleHost } from '@/plugins/locale'
|
||||
import { UiServiceRegistry, type UiHostContext } from '@/plugins/ui-host'
|
||||
import { createDefaultNavigationLayout, NavigationLayoutController, resolveNavigationLayout } from './layout'
|
||||
|
||||
function createRuntime() {
|
||||
const locale = new PluginLocaleHost({
|
||||
getLocale: () => 'en-US',
|
||||
subscribe: () => () => {},
|
||||
translate: (key) => key,
|
||||
})
|
||||
const ui: UiHostContext = { locale, insightScope: new InsightScopeController(), services: new UiServiceRegistry() }
|
||||
const plugin = (id: string, pageId: string, order: number): InsightPlugin => ({
|
||||
id,
|
||||
platforms: ['cli-web'],
|
||||
activate(context) {
|
||||
context.locale.register(`plugins.${id}`, {
|
||||
'en-US': { title: pageId },
|
||||
'zh-CN': { title: pageId },
|
||||
'zh-TW': { title: pageId },
|
||||
'ja-JP': { title: pageId },
|
||||
})
|
||||
context.pages.register({
|
||||
id: pageId,
|
||||
path: pageId,
|
||||
routeName: `insight-${pageId}`,
|
||||
title: { namespace: `plugins.${id}`, key: 'title' },
|
||||
icon: `icon-${pageId}`,
|
||||
view: { load: async () => ({}) },
|
||||
})
|
||||
context.navigation.register({ id: `entry.${pageId}`, pageId, order })
|
||||
},
|
||||
})
|
||||
return createInsightPluginRuntime('cli-web', ui, locale, [
|
||||
plugin('first', 'first', 10),
|
||||
plugin('second', 'second', 20),
|
||||
])
|
||||
}
|
||||
|
||||
test('default layout keeps the current single Insight primary group and contribution order', () => {
|
||||
const layout = createDefaultNavigationLayout(createRuntime())
|
||||
|
||||
assert.deepEqual(layout, {
|
||||
schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
primary: [
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'host.insight',
|
||||
children: ['entry.first', 'entry.second', 'insight.relationship-changes'],
|
||||
},
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('saved layout promotes entries, preserves custom groups, hides entries, and omits unavailable IDs', () => {
|
||||
const runtime = createRuntime()
|
||||
const layout: NavigationLayout = {
|
||||
schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
primary: [
|
||||
{ kind: 'entry', entryId: 'entry.second' },
|
||||
{ kind: 'group', id: 'custom', title: 'My group', children: ['plugin.unavailable', 'entry.first'] },
|
||||
],
|
||||
hiddenEntryIds: ['insight.relationship-changes'],
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
resolveNavigationLayout(runtime, layout).primary.map((item) =>
|
||||
item.kind === 'entry'
|
||||
? { kind: item.kind, entryId: item.entryId }
|
||||
: { kind: item.kind, id: item.id, title: item.title, entries: item.entries.map(({ entryId }) => entryId) }
|
||||
),
|
||||
[
|
||||
{ kind: 'entry', entryId: 'entry.second' },
|
||||
{ kind: 'group', id: 'custom', title: 'My group', entries: ['entry.first'] },
|
||||
]
|
||||
)
|
||||
assert.deepEqual(layout.primary[1], {
|
||||
kind: 'group',
|
||||
id: 'custom',
|
||||
title: 'My group',
|
||||
children: ['plugin.unavailable', 'entry.first'],
|
||||
})
|
||||
})
|
||||
|
||||
test('new plugin entries are appended to the localized default group without mutating saved layout', () => {
|
||||
const runtime = createRuntime()
|
||||
const layout: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [{ kind: 'entry', entryId: 'entry.first' }],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
const resolved = resolveNavigationLayout(runtime, layout)
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.primary.map((item) =>
|
||||
item.kind === 'entry' ? item.entryId : { id: item.id, entries: item.entries.map(({ entryId }) => entryId) }
|
||||
),
|
||||
['entry.first', { id: 'host.insight', entries: ['entry.second', 'insight.relationship-changes'] }]
|
||||
)
|
||||
assert.deepEqual(layout.primary, [{ kind: 'entry', entryId: 'entry.first' }])
|
||||
})
|
||||
|
||||
test('controller falls back to defaults for missing or invalid saved layouts', () => {
|
||||
const controller = new NavigationLayoutController(createRuntime())
|
||||
let notifications = 0
|
||||
controller.subscribe(() => notifications++)
|
||||
|
||||
controller.applyLoadResult({ status: 'invalid', layout: null })
|
||||
|
||||
assert.equal(controller.getSnapshot().source, 'invalid')
|
||||
assert.equal(controller.getResolvedLayout().primary.length, 1)
|
||||
assert.equal(notifications, 1)
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
type NavigationLayout,
|
||||
type NavigationLayoutLoadResult,
|
||||
} from '@openchatlab/shared-types'
|
||||
import type { InsightPageDefinition, InsightPluginRuntime } from '@/plugins/insight'
|
||||
import { listInsightShellNavigation } from '@/plugins/insight-catalog'
|
||||
import type { Disposer } from '@/plugins/core'
|
||||
|
||||
export const DEFAULT_INSIGHT_NAVIGATION_GROUP_ID = 'host.insight'
|
||||
|
||||
export interface ResolvedNavigationEntry {
|
||||
kind: 'entry'
|
||||
entryId: string
|
||||
page: InsightPageDefinition
|
||||
}
|
||||
|
||||
export interface ResolvedNavigationGroup {
|
||||
kind: 'group'
|
||||
id: string
|
||||
title?: string
|
||||
entries: ResolvedNavigationEntry[]
|
||||
}
|
||||
|
||||
export type ResolvedPrimaryNavigationItem = ResolvedNavigationEntry | ResolvedNavigationGroup
|
||||
|
||||
export interface ResolvedNavigationLayout {
|
||||
primary: ResolvedPrimaryNavigationItem[]
|
||||
hiddenEntryIds: string[]
|
||||
}
|
||||
|
||||
export function listResolvedNavigationEntries(layout: ResolvedNavigationLayout): ResolvedNavigationEntry[] {
|
||||
return layout.primary.flatMap((item) => (item.kind === 'entry' ? [item] : item.entries))
|
||||
}
|
||||
|
||||
export interface NavigationLayoutSnapshot {
|
||||
layout: NavigationLayout
|
||||
source: NavigationLayoutLoadResult['status'] | 'default'
|
||||
revision: number
|
||||
}
|
||||
|
||||
export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): NavigationLayout {
|
||||
return {
|
||||
schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
primary: [
|
||||
{
|
||||
kind: 'group',
|
||||
id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID,
|
||||
children: listInsightShellNavigation(runtime).map(({ entryId }) => entryId),
|
||||
},
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNavigationLayout(
|
||||
runtime: InsightPluginRuntime,
|
||||
layout: NavigationLayout
|
||||
): ResolvedNavigationLayout {
|
||||
const catalog = listInsightShellNavigation(runtime)
|
||||
const availableEntries = new Map(
|
||||
catalog.map(({ entryId, page }) => [entryId, { kind: 'entry' as const, entryId, page }])
|
||||
)
|
||||
const hiddenEntryIds = new Set(layout.hiddenEntryIds)
|
||||
const positionedEntryIds = new Set<string>()
|
||||
const primary: ResolvedPrimaryNavigationItem[] = []
|
||||
|
||||
for (const item of layout.primary) {
|
||||
if (item.kind === 'entry') {
|
||||
positionedEntryIds.add(item.entryId)
|
||||
const entry = availableEntries.get(item.entryId)
|
||||
if (entry && !hiddenEntryIds.has(item.entryId)) primary.push(entry)
|
||||
continue
|
||||
}
|
||||
|
||||
const entries: ResolvedNavigationEntry[] = []
|
||||
for (const entryId of item.children) {
|
||||
positionedEntryIds.add(entryId)
|
||||
const entry = availableEntries.get(entryId)
|
||||
if (entry && !hiddenEntryIds.has(entryId)) entries.push(entry)
|
||||
}
|
||||
if (entries.length > 0) primary.push({ kind: 'group', id: item.id, title: item.title, entries })
|
||||
}
|
||||
|
||||
const newEntries = catalog
|
||||
.filter(({ entryId }) => !positionedEntryIds.has(entryId) && !hiddenEntryIds.has(entryId))
|
||||
.map(({ entryId }) => availableEntries.get(entryId)!)
|
||||
if (newEntries.length > 0) {
|
||||
const defaultGroup = primary.find(
|
||||
(item): item is ResolvedNavigationGroup =>
|
||||
item.kind === 'group' && item.id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID
|
||||
)
|
||||
if (defaultGroup) defaultGroup.entries.push(...newEntries)
|
||||
else primary.push({ kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, entries: newEntries })
|
||||
}
|
||||
|
||||
return { primary, hiddenEntryIds: [...layout.hiddenEntryIds] }
|
||||
}
|
||||
|
||||
export class NavigationLayoutController {
|
||||
private layout: NavigationLayout
|
||||
private source: NavigationLayoutSnapshot['source'] = 'default'
|
||||
private revision = 0
|
||||
private readonly listeners = new Set<() => void>()
|
||||
|
||||
constructor(readonly runtime: InsightPluginRuntime) {
|
||||
this.layout = createDefaultNavigationLayout(runtime)
|
||||
}
|
||||
|
||||
getSnapshot(): NavigationLayoutSnapshot {
|
||||
return { layout: this.layout, source: this.source, revision: this.revision }
|
||||
}
|
||||
|
||||
getResolvedLayout(): ResolvedNavigationLayout {
|
||||
return resolveNavigationLayout(this.runtime, this.layout)
|
||||
}
|
||||
|
||||
applyLoadResult(result: NavigationLayoutLoadResult): void {
|
||||
this.layout = result.layout ?? createDefaultNavigationLayout(this.runtime)
|
||||
this.source = result.status
|
||||
this.emit()
|
||||
}
|
||||
|
||||
applySavedLayout(layout: NavigationLayout): void {
|
||||
this.layout = layout
|
||||
this.source = 'saved'
|
||||
this.emit()
|
||||
}
|
||||
|
||||
applyDefaultLayout(): void {
|
||||
this.layout = createDefaultNavigationLayout(this.runtime)
|
||||
this.source = 'default'
|
||||
this.emit()
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): Disposer {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.revision++
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { inject, onBeforeUnmount, shallowRef, type App, type InjectionKey, type ShallowRef } from 'vue'
|
||||
import type { NavigationLayoutController, NavigationLayoutSnapshot } from './layout'
|
||||
|
||||
const NAVIGATION_LAYOUT_KEY: InjectionKey<NavigationLayoutController> = Symbol('NavigationLayoutController')
|
||||
|
||||
export function installNavigationLayout(app: App, controller: NavigationLayoutController): void {
|
||||
app.provide(NAVIGATION_LAYOUT_KEY, controller)
|
||||
}
|
||||
|
||||
export function useNavigationLayout(): {
|
||||
controller: NavigationLayoutController
|
||||
snapshot: Readonly<ShallowRef<NavigationLayoutSnapshot>>
|
||||
} {
|
||||
const controller = inject(NAVIGATION_LAYOUT_KEY)
|
||||
if (!controller) throw new Error('Navigation layout controller is unavailable')
|
||||
const snapshot = shallowRef(controller.getSnapshot())
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
snapshot.value = controller.getSnapshot()
|
||||
})
|
||||
onBeforeUnmount(unsubscribe)
|
||||
return { controller, snapshot }
|
||||
}
|
||||
@@ -8,12 +8,14 @@ import { PageTabs } from '@/components/navigation'
|
||||
import { listInsightShellPages } from '@/plugins/insight-catalog'
|
||||
import { InsightScopeController } from '@/plugins/insight-scope'
|
||||
import { useHostLocale, useInsightPluginRuntime } from '@/plugins/insight-vue'
|
||||
import { useNavigationLayout } from '@/navigation/vue'
|
||||
import { createInsightTimeRange, getInsightTimeFilterSignature } from './insight-time-range'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { translate } = useHostLocale()
|
||||
const route = useRoute()
|
||||
const pluginRuntime = useInsightPluginRuntime()
|
||||
const { controller: navigationLayoutController, snapshot: navigationLayoutSnapshot } = useNavigationLayout()
|
||||
const pages = listInsightShellPages(pluginRuntime)
|
||||
const activeSubpage = computed(() => String(route.meta.insightPageId ?? pages[0]?.id ?? ''))
|
||||
const activePage = computed(() => pages.find((page) => page.id === activeSubpage.value))
|
||||
@@ -54,8 +56,17 @@ const stopSyncingScope = watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const resolvedNavigation = computed(() => {
|
||||
void navigationLayoutSnapshot.value.revision
|
||||
return navigationLayoutController.getResolvedLayout()
|
||||
})
|
||||
const activeNavigationGroup = computed(() =>
|
||||
resolvedNavigation.value.primary.find(
|
||||
(item) => item.kind === 'group' && item.entries.some(({ page }) => page.id === activeSubpage.value)
|
||||
)
|
||||
)
|
||||
const navigationItems = computed(() =>
|
||||
pages.map((page) => ({
|
||||
(activeNavigationGroup.value?.kind === 'group' ? activeNavigationGroup.value.entries : []).map(({ page }) => ({
|
||||
id: page.id,
|
||||
label: translate(page.title),
|
||||
icon: page.icon,
|
||||
@@ -89,6 +100,7 @@ onBeforeUnmount(() => {
|
||||
size="compact"
|
||||
>
|
||||
<PageTabs
|
||||
v-if="navigationItems.length > 0"
|
||||
class="mt-3 pb-1.5"
|
||||
:model-value="activeSubpage"
|
||||
:items="navigationItems"
|
||||
@@ -105,6 +117,16 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
</PageTabs>
|
||||
<div v-else-if="timeFilter" class="mt-3 flex justify-end pb-1.5">
|
||||
<TimeSelect
|
||||
:key="`${componentKey}:${timeFilterSignature}`"
|
||||
v-model="modelValue"
|
||||
:range-source="rangeSource"
|
||||
:allowed-modes="allowedTimeModes"
|
||||
:allowed-recent-days="allowedRecentDays"
|
||||
:initial-state="initialState"
|
||||
/>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLegacyInsightPages } from './insight-catalog'
|
||||
import { createStaticInsightPluginRuntime } from './static-insight'
|
||||
import { UiServiceRegistry } from './ui-host'
|
||||
import { createVueUiHostContext } from './vue-ui-host'
|
||||
import { NavigationLayoutController } from '@/navigation/layout'
|
||||
|
||||
const platform = PLATFORM_CAPABILITIES.platform
|
||||
export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin, timeInvestmentBuiltin] as const
|
||||
@@ -17,3 +18,4 @@ export const desktopCliWebInsightRuntime = createStaticInsightPluginRuntime(
|
||||
desktopCliWebInsightBuiltins,
|
||||
getLegacyInsightPages(platform)
|
||||
)
|
||||
export const desktopCliWebNavigationLayout = new NavigationLayoutController(desktopCliWebInsightRuntime)
|
||||
@@ -2,6 +2,7 @@ import type { RuntimePlatform } from '@/utils/platform-capabilities'
|
||||
import type { InsightPageDefinition, InsightPluginRuntime } from './insight'
|
||||
|
||||
export interface LegacyInsightPage extends InsightPageDefinition {
|
||||
navigationId: string
|
||||
platforms: readonly RuntimePlatform[]
|
||||
order: number
|
||||
}
|
||||
@@ -9,6 +10,7 @@ export interface LegacyInsightPage extends InsightPageDefinition {
|
||||
export const LEGACY_INSIGHT_PAGES: readonly LegacyInsightPage[] = [
|
||||
{
|
||||
id: 'relationship-changes',
|
||||
navigationId: 'insight.relationship-changes',
|
||||
path: 'relationship-changes',
|
||||
routeName: 'insight-relationship-changes',
|
||||
title: { key: 'insight.tabs.relationshipChanges' },
|
||||
@@ -22,14 +24,7 @@ export function listInsightShellPages(
|
||||
runtime: InsightPluginRuntime,
|
||||
legacyPages: readonly LegacyInsightPage[] = LEGACY_INSIGHT_PAGES
|
||||
): InsightPageDefinition[] {
|
||||
const pluginPages = runtime.listNavigation().map(({ entry, page }) => ({ page, order: entry.order }))
|
||||
const availableLegacyPages = legacyPages
|
||||
.filter((page) => page.platforms.includes(runtime.platform))
|
||||
.map((page) => ({ page, order: page.order }))
|
||||
|
||||
const pages = [...pluginPages, ...availableLegacyPages]
|
||||
.sort((left, right) => left.order - right.order || left.page.id.localeCompare(right.page.id))
|
||||
.map(({ page }) => page)
|
||||
const pages = listInsightShellNavigation(runtime, legacyPages).map(({ page }) => page)
|
||||
|
||||
const pageIds = new Set<string>()
|
||||
for (const page of pages) {
|
||||
@@ -39,6 +34,30 @@ export function listInsightShellPages(
|
||||
return pages
|
||||
}
|
||||
|
||||
export interface InsightShellNavigationItem {
|
||||
entryId: string
|
||||
order: number
|
||||
page: InsightPageDefinition
|
||||
}
|
||||
|
||||
export function listInsightShellNavigation(
|
||||
runtime: InsightPluginRuntime,
|
||||
legacyPages: readonly LegacyInsightPage[] = LEGACY_INSIGHT_PAGES
|
||||
): InsightShellNavigationItem[] {
|
||||
const pluginItems = runtime.listNavigation().map(({ entry, page }) => ({
|
||||
entryId: entry.id,
|
||||
order: entry.order,
|
||||
page,
|
||||
}))
|
||||
const legacyItems = legacyPages
|
||||
.filter((page) => page.platforms.includes(runtime.platform))
|
||||
.map((page) => ({ entryId: page.navigationId, order: page.order, page }))
|
||||
|
||||
return [...pluginItems, ...legacyItems].sort(
|
||||
(left, right) => left.order - right.order || left.entryId.localeCompare(right.entryId)
|
||||
)
|
||||
}
|
||||
|
||||
export function getLegacyInsightPages(platform: RuntimePlatform): InsightPageDefinition[] {
|
||||
return LEGACY_INSIGHT_PAGES.filter((page) => page.platforms.includes(platform))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getLegacyInsightPages } from './insight-catalog'
|
||||
import { createStaticInsightPluginRuntime } from './static-insight'
|
||||
import { UiServiceRegistry } from './ui-host'
|
||||
import { createVueUiHostContext } from './vue-ui-host'
|
||||
import { NavigationLayoutController } from '@/navigation/layout'
|
||||
|
||||
export const webWasmInsightBuiltins = [timeInvestmentBuiltin] as const
|
||||
export const webWasmUiServices = new UiServiceRegistry()
|
||||
@@ -14,3 +15,4 @@ export const webWasmInsightRuntime = createStaticInsightPluginRuntime(
|
||||
webWasmInsightBuiltins,
|
||||
getLegacyInsightPages('web-wasm')
|
||||
)
|
||||
export const webWasmNavigationLayout = new NavigationLayoutController(webWasmInsightRuntime)
|
||||
Reference in new issue
Block a user