mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(navigation): add layout editor
This commit is contained in:
1 parent
7907435cff
commit
a54af007ae
14 files changed
+787
-16
No files matched your search
+26
-13
@@ -20,12 +20,13 @@ import {
|
||||
} from '@/composables/usePreferencesSync'
|
||||
import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay'
|
||||
import { configureHttpClient } from '@/services/utils/http'
|
||||
import { reportError } from '@/services/log-report'
|
||||
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 { redirectFromHiddenInsightPage } from '@/navigation/router'
|
||||
import type { PresentationPreferences } from '@/services/preferences/types'
|
||||
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
|
||||
import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap'
|
||||
@@ -135,15 +136,25 @@ function scheduleNonCriticalUiPrefetch() {
|
||||
}
|
||||
|
||||
async function hydrateNavigationLayout(): Promise<void> {
|
||||
const result = await useNavigationLayoutService().load()
|
||||
navigationLayoutController.applyLoadResult(result)
|
||||
try {
|
||||
const result = await useNavigationLayoutService().load()
|
||||
navigationLayoutController.applyLoadResult(result)
|
||||
} catch (error) {
|
||||
navigationLayoutController.applyLoadFailure()
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
reportError(`Navigation layout load failed: ${normalized.message}`, normalized.stack)
|
||||
}
|
||||
|
||||
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 })
|
||||
await redirectFromHiddenInsightPageSafely()
|
||||
}
|
||||
|
||||
async function redirectFromHiddenInsightPageSafely(): Promise<void> {
|
||||
try {
|
||||
await redirectFromHiddenInsightPage(router, navigationLayoutController)
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
reportError(`Navigation layout route fallback failed: ${normalized.message}`, normalized.stack)
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
@@ -176,11 +187,8 @@ async function initializeApp() {
|
||||
},
|
||||
deferAfterPresentationError: () =>
|
||||
PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated,
|
||||
initializeShell: hydrateNavigationLayout,
|
||||
initializeBackground: [
|
||||
{
|
||||
name: 'navigation-layout',
|
||||
run: hydrateNavigationLayout,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
run: async () => {
|
||||
@@ -338,6 +346,11 @@ watch(
|
||||
|
||||
watch(isBootstrapMaskVisible, () => syncBootstrapMask(bootstrapDialogRef.value), { flush: 'post' })
|
||||
|
||||
watch(
|
||||
() => route.meta.insightPageId,
|
||||
() => void redirectFromHiddenInsightPageSafely()
|
||||
)
|
||||
|
||||
useWindowsTitleBarOverlay([
|
||||
colorMode,
|
||||
() => route.fullPath,
|
||||
|
||||
@@ -126,6 +126,7 @@ test('reveals the shell after presentation state and runs independent startup wo
|
||||
},
|
||||
applyPresentation: (presentation) => void calls.push(`apply-${presentation.locale}`),
|
||||
applyPresentationFallback: () => void calls.push('fallback'),
|
||||
initializeShell: async () => void calls.push('shell'),
|
||||
initializeBackground: ['preferences', 'llm', 'sessions'].map((name) => ({
|
||||
name,
|
||||
run: async () => {
|
||||
@@ -144,6 +145,7 @@ test('reveals the shell after presentation state and runs independent startup wo
|
||||
'services',
|
||||
'load-presentation',
|
||||
'apply-zh-CN',
|
||||
'shell',
|
||||
'pull-listener',
|
||||
'start-preferences',
|
||||
'start-llm',
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface ProgressiveInitializationPorts<TPresentation> {
|
||||
applyPresentation(presentation: TPresentation): Promise<void> | void
|
||||
applyPresentationFallback(): Promise<void> | void
|
||||
deferAfterPresentationError?: (error: unknown) => boolean
|
||||
initializeShell?: () => Promise<void>
|
||||
initializeBackground: Array<{
|
||||
name: string
|
||||
run(): Promise<void>
|
||||
@@ -99,6 +100,7 @@ export async function initializeProgressiveAppRuntime<TPresentation>(
|
||||
await ports.applyPresentationFallback()
|
||||
}
|
||||
|
||||
await ports.initializeShell?.()
|
||||
const stopListeningForPullResults = ports.listenForPullResults?.() ?? null
|
||||
const background = Promise.allSettled(ports.initializeBackground.map((task) => task.run())).then((results) =>
|
||||
results.flatMap((result, index) =>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { NavigationLayout, NavigationLayoutGroupItem } from '@openchatlab/shared-types'
|
||||
import { UiBadge, UiButton, UiIcon, UiIconButton } from '@/components/UI/primitives'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
canWriteNavigationLayout,
|
||||
createDefaultNavigationLayout,
|
||||
createEditableNavigationLayout,
|
||||
DEFAULT_INSIGHT_NAVIGATION_GROUP_ID,
|
||||
moveGroupedNavigationEntry,
|
||||
moveHiddenNavigationEntry,
|
||||
movePrimaryNavigationItem,
|
||||
placeNavigationEntry,
|
||||
} from '@/navigation/layout'
|
||||
import { redirectFromHiddenInsightPage } from '@/navigation/router'
|
||||
import { useNavigationLayout } from '@/navigation/vue'
|
||||
import { useHostLocale, useInsightPluginRuntime } from '@/plugins/insight-vue'
|
||||
import { listInsightShellNavigation } from '@/plugins/insight-catalog'
|
||||
import { useNavigationLayoutService } from '@/services'
|
||||
import { reportError } from '@/services/log-report'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { translate } = useHostLocale()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const runtime = useInsightPluginRuntime()
|
||||
const { controller, snapshot } = useNavigationLayout()
|
||||
const service = useNavigationLayoutService()
|
||||
const availableEntries = new Map(listInsightShellNavigation(runtime).map((entry) => [entry.entryId, entry.page]))
|
||||
|
||||
const draft = ref(createEditableNavigationLayout(runtime, controller.getSnapshot().layout))
|
||||
const baseline = ref(serializeLayout(draft.value))
|
||||
const isSaving = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const hasChanges = computed(() => serializeLayout(draft.value) !== baseline.value)
|
||||
const loadSource = computed(() => snapshot.value.source)
|
||||
const canWriteLayout = computed(() => canWriteNavigationLayout(loadSource.value))
|
||||
|
||||
const groups = computed(() => draft.value.primary.filter((item) => item.kind === 'group'))
|
||||
|
||||
function serializeLayout(layout: NavigationLayout): string {
|
||||
return JSON.stringify(layout)
|
||||
}
|
||||
|
||||
function groupTitle(group: NavigationLayoutGroupItem): string {
|
||||
if (group.title) return group.title
|
||||
return group.id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID ? t('layout.insight') : group.id
|
||||
}
|
||||
|
||||
function entryTitle(entryId: string): string {
|
||||
const page = availableEntries.get(entryId)
|
||||
return page ? translate(page.title) : entryId
|
||||
}
|
||||
|
||||
function entryIcon(entryId: string): string {
|
||||
return availableEntries.get(entryId)?.icon ?? 'i-heroicons-puzzle-piece'
|
||||
}
|
||||
|
||||
function destinationOptions() {
|
||||
const targetGroups = [...groups.value]
|
||||
if (!targetGroups.some(({ id }) => id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID)) {
|
||||
targetGroups.push({ kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, children: [] })
|
||||
}
|
||||
return [
|
||||
{ label: t('settings.navigation.primary'), value: 'primary' },
|
||||
...targetGroups.map((group) => ({
|
||||
label: t('settings.navigation.inGroup', { group: groupTitle(group) }),
|
||||
value: `group:${group.id}`,
|
||||
})),
|
||||
{ label: t('settings.navigation.hidden'), value: 'hidden' },
|
||||
]
|
||||
}
|
||||
|
||||
function destinationValue(entryId: string): string {
|
||||
if (draft.value.hiddenEntryIds.includes(entryId)) return 'hidden'
|
||||
for (const item of draft.value.primary) {
|
||||
if (item.kind === 'entry' && item.entryId === entryId) return 'primary'
|
||||
if (item.kind === 'group' && item.children.includes(entryId)) return `group:${item.id}`
|
||||
}
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
function updateDestination(entryId: string, value: unknown): void {
|
||||
if (value === 'primary' || value === 'hidden') {
|
||||
draft.value = placeNavigationEntry(draft.value, entryId, { kind: value })
|
||||
return
|
||||
}
|
||||
if (typeof value === 'string' && value.startsWith('group:')) {
|
||||
draft.value = placeNavigationEntry(draft.value, entryId, { kind: 'group', groupId: value.slice(6) })
|
||||
}
|
||||
}
|
||||
|
||||
function movePrimary(index: number, offset: -1 | 1): void {
|
||||
draft.value = movePrimaryNavigationItem(draft.value, index, offset)
|
||||
}
|
||||
|
||||
function moveGrouped(groupId: string, index: number, offset: -1 | 1): void {
|
||||
draft.value = moveGroupedNavigationEntry(draft.value, groupId, index, offset)
|
||||
}
|
||||
|
||||
function moveHidden(index: number, offset: -1 | 1): void {
|
||||
draft.value = moveHiddenNavigationEntry(draft.value, index, offset)
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!canWriteLayout.value || !hasChanges.value || isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await service.save(draft.value)
|
||||
controller.applySavedLayout(result.layout)
|
||||
draft.value = createEditableNavigationLayout(runtime, result.layout)
|
||||
baseline.value = serializeLayout(draft.value)
|
||||
await redirectFromHiddenInsightPage(router, controller)
|
||||
toast.success(t('settings.navigation.saved'))
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
reportError(`Navigation layout save failed: ${normalized.message}`, normalized.stack)
|
||||
toast.fail(t('settings.navigation.saveFailed'))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!canWriteLayout.value || isResetting.value) return
|
||||
isResetting.value = true
|
||||
try {
|
||||
await service.reset()
|
||||
controller.applyDefaultLayout()
|
||||
draft.value = createDefaultNavigationLayout(runtime)
|
||||
baseline.value = serializeLayout(draft.value)
|
||||
await redirectFromHiddenInsightPage(router, controller)
|
||||
toast.success(t('settings.navigation.resetDone'))
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
reportError(`Navigation layout reset failed: ${normalized.message}`, normalized.stack)
|
||||
toast.fail(t('settings.navigation.resetFailed'))
|
||||
} finally {
|
||||
isResetting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5 pb-6">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UiIcon name="i-heroicons-bars-3-bottom-left" size="sm" class="text-primary-500" />
|
||||
{{ t('settings.navigation.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.navigation.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadSource === 'invalid'"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
>
|
||||
{{ t('settings.navigation.invalidLayout') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadSource === 'failed'"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
>
|
||||
{{ t('settings.navigation.loadFailed') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(item, primaryIndex) in draft.primary"
|
||||
:key="`${item.kind}:${item.kind === 'entry' ? item.entryId : item.id}`"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<UiIcon
|
||||
:name="item.kind === 'entry' ? entryIcon(item.entryId) : 'i-heroicons-folder'"
|
||||
size="sm"
|
||||
class="shrink-0 text-gray-500"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ item.kind === 'entry' ? entryTitle(item.entryId) : groupTitle(item) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.kind === 'entry' ? t('settings.navigation.primary') : t('settings.navigation.group') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-up"
|
||||
:label="t('settings.navigation.moveUp')"
|
||||
:disabled="primaryIndex === 0"
|
||||
@click="movePrimary(primaryIndex, -1)"
|
||||
/>
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-down"
|
||||
:label="t('settings.navigation.moveDown')"
|
||||
:disabled="primaryIndex === draft.primary.length - 1"
|
||||
@click="movePrimary(primaryIndex, 1)"
|
||||
/>
|
||||
<USelect
|
||||
v-if="item.kind === 'entry'"
|
||||
:model-value="destinationValue(item.entryId)"
|
||||
:items="destinationOptions()"
|
||||
:ui="{ content: 'z-[200]' }"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
:aria-label="t('settings.navigation.locationFor', { title: entryTitle(item.entryId) })"
|
||||
@update:model-value="updateDestination(item.entryId, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.kind === 'group'" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
v-for="(entryId, childIndex) in item.children"
|
||||
:key="entryId"
|
||||
class="flex items-center gap-3 border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-700"
|
||||
>
|
||||
<UiIcon :name="entryIcon(entryId)" size="sm" class="ml-4 shrink-0 text-gray-500" />
|
||||
<p class="min-w-0 flex-1 truncate text-sm text-gray-800 dark:text-gray-200">
|
||||
{{ entryTitle(entryId) }}
|
||||
</p>
|
||||
<UiBadge
|
||||
v-if="!availableEntries.has(entryId)"
|
||||
:label="t('settings.navigation.unavailable')"
|
||||
size="xs"
|
||||
tone="warning"
|
||||
/>
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-up"
|
||||
:label="t('settings.navigation.moveUp')"
|
||||
:disabled="childIndex === 0"
|
||||
@click="moveGrouped(item.id, childIndex, -1)"
|
||||
/>
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-down"
|
||||
:label="t('settings.navigation.moveDown')"
|
||||
:disabled="childIndex === item.children.length - 1"
|
||||
@click="moveGrouped(item.id, childIndex, 1)"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="destinationValue(entryId)"
|
||||
:items="destinationOptions()"
|
||||
:ui="{ content: 'z-[200]' }"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
:aria-label="t('settings.navigation.locationFor', { title: entryTitle(entryId) })"
|
||||
@update:model-value="updateDestination(entryId, $event)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="item.children.length === 0" class="px-4 py-3 text-xs text-gray-400">
|
||||
{{ t('settings.navigation.emptyGroup') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="draft.hiddenEntryIds.length > 0"
|
||||
class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<UiIcon name="i-heroicons-eye-slash" size="sm" />
|
||||
{{ t('settings.navigation.hidden') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(entryId, hiddenIndex) in draft.hiddenEntryIds"
|
||||
:key="entryId"
|
||||
class="flex items-center gap-3 border-t border-gray-200 px-3 py-2.5 dark:border-gray-700"
|
||||
>
|
||||
<UiIcon :name="entryIcon(entryId)" size="sm" class="shrink-0 text-gray-400" />
|
||||
<p class="min-w-0 flex-1 truncate text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ entryTitle(entryId) }}
|
||||
</p>
|
||||
<UiBadge
|
||||
v-if="!availableEntries.has(entryId)"
|
||||
:label="t('settings.navigation.unavailable')"
|
||||
size="xs"
|
||||
tone="warning"
|
||||
/>
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-up"
|
||||
:label="t('settings.navigation.moveUp')"
|
||||
:disabled="hiddenIndex === 0"
|
||||
@click="moveHidden(hiddenIndex, -1)"
|
||||
/>
|
||||
<UiIconButton
|
||||
icon="i-heroicons-chevron-down"
|
||||
:label="t('settings.navigation.moveDown')"
|
||||
:disabled="hiddenIndex === draft.hiddenEntryIds.length - 1"
|
||||
@click="moveHidden(hiddenIndex, 1)"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="destinationValue(entryId)"
|
||||
:items="destinationOptions()"
|
||||
:ui="{ content: 'z-[200]' }"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
:aria-label="t('settings.navigation.locationFor', { title: entryTitle(entryId) })"
|
||||
@update:model-value="updateDestination(entryId, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-200 pt-4 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('settings.navigation.storageHint') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
:label="t('settings.navigation.reset')"
|
||||
tone="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="isResetting"
|
||||
:disabled="!canWriteLayout || isSaving"
|
||||
@click="reset"
|
||||
/>
|
||||
<UiButton
|
||||
:label="t('common.save')"
|
||||
size="sm"
|
||||
:loading="isSaving"
|
||||
:disabled="!canWriteLayout || !hasChanges || isResetting"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import SettingsDialogShell from './Settings/SettingsDialogShell.vue'
|
||||
import { watchLazyOverlayVisibility } from './lazy-overlay-visibility'
|
||||
|
||||
const AISettingsTab = defineAsyncComponent(() => import('./Settings/AISettingsTab.vue'))
|
||||
const NavigationSettingsTab = defineAsyncComponent(() => import('./Settings/NavigationSettingsTab.vue'))
|
||||
const BatchManageTab = defineAsyncComponent(() => import('./Settings/BatchManageTab.vue'))
|
||||
const StorageTab = defineAsyncComponent(() => import('./Settings/StorageTab.vue'))
|
||||
const AboutTab = defineAsyncComponent(() => import('./Settings/AboutTab.vue'))
|
||||
@@ -30,6 +31,7 @@ interface ScrollableTab {
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'settings', label: t('settings.tabs.basic'), icon: 'i-heroicons-cog-6-tooth' },
|
||||
{ id: 'navigation', label: t('settings.tabs.navigation'), icon: 'i-heroicons-bars-3-bottom-left' },
|
||||
{ id: 'ai', label: t('settings.tabs.ai'), icon: 'i-heroicons-sparkles' },
|
||||
{ id: 'api', label: t('settings.tabs.api'), icon: 'i-heroicons-server-stack' },
|
||||
{ id: 'data', label: t('settings.tabs.dataManage'), icon: 'i-heroicons-rectangle-stack' },
|
||||
@@ -91,6 +93,7 @@ watchLazyOverlayVisibility(showSettings, async (visible) => {
|
||||
:ref="(el: unknown) => setTabRef('ai', el)"
|
||||
@config-changed="handleAIConfigChanged"
|
||||
/>
|
||||
<NavigationSettingsTab v-else-if="activeTab === 'navigation'" key="navigation" />
|
||||
<BatchManageTab
|
||||
v-else-if="activeTab === 'data'"
|
||||
key="data"
|
||||
|
||||
@@ -529,7 +529,7 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
|
||||
<template v-if="showInsightNavigation">
|
||||
<SidebarButton
|
||||
v-for="item in resolvedPrimaryNavigation"
|
||||
:key="item.kind === 'entry' ? item.entryId : item.id"
|
||||
:key="`${item.kind}:${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="
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "Settings",
|
||||
"tabs": {
|
||||
"basic": "General",
|
||||
"navigation": "Navigation",
|
||||
"ai": "AI Settings",
|
||||
"aiConfig": "Model Service",
|
||||
"aiDefaultModel": "Default Model",
|
||||
@@ -80,6 +81,27 @@
|
||||
"saveFailed": "Save failed"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"title": "Navigation Layout",
|
||||
"description": "Choose whether analysis features appear as sidebar entries or as secondary pages under Insight. Changes apply after saving.",
|
||||
"invalidLayout": "The saved navigation layout could not be read, so plugin defaults are in use. Saving or resetting will replace the invalid configuration.",
|
||||
"loadFailed": "The saved navigation layout could not be loaded, so plugin defaults are shown temporarily. Reload the app to try again; saving and resetting are disabled until loading succeeds.",
|
||||
"primary": "Primary entry",
|
||||
"inGroup": "Secondary · {group}",
|
||||
"group": "Secondary navigation group",
|
||||
"hidden": "Hidden",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"locationFor": "Set navigation location for {title}",
|
||||
"unavailable": "Plugin unavailable",
|
||||
"emptyGroup": "This group currently has no navigation entries.",
|
||||
"saved": "Navigation layout saved",
|
||||
"saveFailed": "Failed to save navigation layout",
|
||||
"reset": "Reset to defaults",
|
||||
"resetDone": "Default navigation layout restored",
|
||||
"resetFailed": "Failed to restore the default navigation layout",
|
||||
"storageHint": "Desktop and CLI Web share this layout from .chatlab."
|
||||
},
|
||||
"defaultModel": {
|
||||
"title": "Default Model",
|
||||
"noConfigs": "Please add a model configuration in \"Model Service\" first",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "設定",
|
||||
"tabs": {
|
||||
"basic": "基本設定",
|
||||
"navigation": "ナビゲーション",
|
||||
"ai": "AI 設定",
|
||||
"aiConfig": "モデルサービス",
|
||||
"aiDefaultModel": "デフォルトモデル",
|
||||
@@ -80,6 +81,27 @@
|
||||
"saveFailed": "保存に失敗しました"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"title": "ナビゲーションレイアウト",
|
||||
"description": "分析機能をサイドバーの第1階層またはインサイト内の第2階層に配置します。変更は保存後に反映されます。",
|
||||
"invalidLayout": "保存済みのナビゲーションレイアウトを読み込めないため、プラグインのデフォルトを使用しています。保存またはリセットすると無効な設定が置き換えられます。",
|
||||
"loadFailed": "保存済みのナビゲーションレイアウトを読み込めませんでした。一時的にプラグインのデフォルトを表示しています。アプリを再読み込みして再試行してください。読み込みに成功するまで保存とリセットはできません。",
|
||||
"primary": "第1階層",
|
||||
"inGroup": "第2階層 · {group}",
|
||||
"group": "第2階層ナビゲーショングループ",
|
||||
"hidden": "非表示",
|
||||
"moveUp": "上へ移動",
|
||||
"moveDown": "下へ移動",
|
||||
"locationFor": "{title} のナビゲーション位置を設定",
|
||||
"unavailable": "プラグインを利用できません",
|
||||
"emptyGroup": "このグループには現在ナビゲーション項目がありません。",
|
||||
"saved": "ナビゲーションレイアウトを保存しました",
|
||||
"saveFailed": "ナビゲーションレイアウトを保存できませんでした",
|
||||
"reset": "デフォルトに戻す",
|
||||
"resetDone": "デフォルトのナビゲーションレイアウトに戻しました",
|
||||
"resetFailed": "デフォルトのナビゲーションレイアウトに戻せませんでした",
|
||||
"storageHint": "Desktop と CLI Web は .chatlab 内のこのレイアウトを共有します。"
|
||||
},
|
||||
"defaultModel": {
|
||||
"title": "デフォルトモデル",
|
||||
"noConfigs": "先に「モデルサービス」でモデル設定を追加してください",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "设置",
|
||||
"tabs": {
|
||||
"basic": "基础设置",
|
||||
"navigation": "导航布局",
|
||||
"ai": "AI 设置",
|
||||
"aiConfig": "模型服务",
|
||||
"aiDefaultModel": "默认模型",
|
||||
@@ -80,6 +81,27 @@
|
||||
"saveFailed": "保存失败"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"title": "导航布局",
|
||||
"description": "调整分析功能在侧边栏一级入口和足迹二级导航中的位置。更改仅在保存后生效。",
|
||||
"invalidLayout": "已保存的导航布局无法读取,当前正在使用插件默认布局。保存或恢复默认后会替换无效配置。",
|
||||
"loadFailed": "未能读取已保存的导航布局,当前仅临时显示插件默认布局。请刷新应用后重试;读取成功前不能保存或恢复默认。",
|
||||
"primary": "一级入口",
|
||||
"inGroup": "二级 · {group}",
|
||||
"group": "二级导航组",
|
||||
"hidden": "已隐藏",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"locationFor": "设置 {title} 的导航位置",
|
||||
"unavailable": "插件不可用",
|
||||
"emptyGroup": "该分组暂时没有导航项。",
|
||||
"saved": "导航布局已保存",
|
||||
"saveFailed": "导航布局保存失败",
|
||||
"reset": "恢复默认",
|
||||
"resetDone": "已恢复默认导航布局",
|
||||
"resetFailed": "恢复默认导航布局失败",
|
||||
"storageHint": "Desktop 与 CLI Web 共用 .chatlab 中的这份布局。"
|
||||
},
|
||||
"defaultModel": {
|
||||
"title": "默认模型",
|
||||
"noConfigs": "请先在「模型服务」中添加模型配置",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "設定",
|
||||
"tabs": {
|
||||
"basic": "基本設定",
|
||||
"navigation": "導覽版面",
|
||||
"ai": "AI 設定",
|
||||
"aiConfig": "模型服務",
|
||||
"aiDefaultModel": "預設模型",
|
||||
@@ -80,6 +81,27 @@
|
||||
"saveFailed": "儲存失敗"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"title": "導覽版面",
|
||||
"description": "調整分析功能在側邊欄一級入口和足跡二級導覽中的位置。變更只會在儲存後生效。",
|
||||
"invalidLayout": "已儲存的導覽版面無法讀取,目前正使用外掛預設版面。儲存或恢復預設後會取代無效設定。",
|
||||
"loadFailed": "無法讀取已儲存的導覽版面,目前只會暫時顯示外掛預設版面。請重新載入應用程式後再試;讀取成功前無法儲存或恢復預設。",
|
||||
"primary": "一級入口",
|
||||
"inGroup": "二級 · {group}",
|
||||
"group": "二級導覽群組",
|
||||
"hidden": "已隱藏",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"locationFor": "設定 {title} 的導覽位置",
|
||||
"unavailable": "外掛不可用",
|
||||
"emptyGroup": "此群組目前沒有導覽項目。",
|
||||
"saved": "導覽版面已儲存",
|
||||
"saveFailed": "導覽版面儲存失敗",
|
||||
"reset": "恢復預設",
|
||||
"resetDone": "已恢復預設導覽版面",
|
||||
"resetFailed": "恢復預設導覽版面失敗",
|
||||
"storageHint": "Desktop 與 CLI Web 共用 .chatlab 中的這份版面。"
|
||||
},
|
||||
"defaultModel": {
|
||||
"title": "預設模型",
|
||||
"noConfigs": "請先在「模型服務」中新增模型設定",
|
||||
|
||||
@@ -5,7 +5,16 @@ import { createInsightPluginRuntime, type InsightPlugin } from '@/plugins/insigh
|
||||
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'
|
||||
import {
|
||||
createDefaultNavigationLayout,
|
||||
createEditableNavigationLayout,
|
||||
moveGroupedNavigationEntry,
|
||||
movePrimaryNavigationItem,
|
||||
NavigationLayoutController,
|
||||
canWriteNavigationLayout,
|
||||
placeNavigationEntry,
|
||||
resolveNavigationLayout,
|
||||
} from './layout'
|
||||
|
||||
function createRuntime() {
|
||||
const locale = new PluginLocaleHost({
|
||||
@@ -116,3 +125,113 @@ test('controller falls back to defaults for missing or invalid saved layouts', (
|
||||
assert.equal(controller.getResolvedLayout().primary.length, 1)
|
||||
assert.equal(notifications, 1)
|
||||
})
|
||||
|
||||
test('controller preserves load failure provenance and blocks layout writes', () => {
|
||||
const controller = new NavigationLayoutController(createRuntime())
|
||||
|
||||
controller.applyLoadFailure()
|
||||
|
||||
assert.equal(controller.getSnapshot().source, 'failed')
|
||||
assert.equal(controller.getResolvedLayout().primary.length, 1)
|
||||
assert.equal(canWriteNavigationLayout(controller.getSnapshot().source), false)
|
||||
assert.equal(canWriteNavigationLayout('invalid'), true)
|
||||
assert.equal(canWriteNavigationLayout('missing'), true)
|
||||
assert.equal(canWriteNavigationLayout('saved'), true)
|
||||
assert.equal(canWriteNavigationLayout('default'), true)
|
||||
})
|
||||
|
||||
test('editable layout materializes newly available entries while preserving unknown IDs', () => {
|
||||
const layout: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [
|
||||
{ kind: 'entry', entryId: 'entry.first' },
|
||||
{ kind: 'group', id: 'custom', title: 'Custom', children: ['plugin.unavailable'] },
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
|
||||
assert.deepEqual(createEditableNavigationLayout(createRuntime(), layout), {
|
||||
schemaVersion: 1,
|
||||
primary: [
|
||||
{ kind: 'entry', entryId: 'entry.first' },
|
||||
{ kind: 'group', id: 'custom', title: 'Custom', children: ['plugin.unavailable'] },
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'host.insight',
|
||||
children: ['entry.second', 'insight.relationship-changes'],
|
||||
},
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
})
|
||||
assert.equal(layout.primary.length, 2)
|
||||
})
|
||||
|
||||
test('navigation editing moves entries between primary, groups, and hidden without losing other IDs', () => {
|
||||
const initial: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [
|
||||
{ kind: 'entry', entryId: 'entry.first' },
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'host.insight',
|
||||
children: ['entry.second', 'plugin.unavailable', 'insight.relationship-changes'],
|
||||
},
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
|
||||
const promoted = placeNavigationEntry(initial, 'entry.second', { kind: 'primary' })
|
||||
const hidden = placeNavigationEntry(promoted, 'entry.first', { kind: 'hidden' })
|
||||
const restored = placeNavigationEntry(hidden, 'entry.first', { kind: 'group', groupId: 'host.insight' })
|
||||
|
||||
assert.deepEqual(restored, {
|
||||
schemaVersion: 1,
|
||||
primary: [
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'host.insight',
|
||||
children: ['plugin.unavailable', 'insight.relationship-changes', 'entry.first'],
|
||||
},
|
||||
{ kind: 'entry', entryId: 'entry.second' },
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
})
|
||||
assert.deepEqual(initial.primary[1], {
|
||||
kind: 'group',
|
||||
id: 'host.insight',
|
||||
children: ['entry.second', 'plugin.unavailable', 'insight.relationship-changes'],
|
||||
})
|
||||
})
|
||||
|
||||
test('navigation editing reorders primary items and grouped entries within two levels', () => {
|
||||
const layout: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [
|
||||
{ kind: 'entry', entryId: 'entry.first' },
|
||||
{ kind: 'group', id: 'host.insight', children: ['entry.second', 'insight.relationship-changes'] },
|
||||
],
|
||||
hiddenEntryIds: [],
|
||||
}
|
||||
|
||||
const primaryMoved = movePrimaryNavigationItem(layout, 1, -1)
|
||||
const childMoved = moveGroupedNavigationEntry(primaryMoved, 'host.insight', 1, -1)
|
||||
|
||||
assert.deepEqual(
|
||||
childMoved.primary.map((item) => (item.kind === 'entry' ? item.entryId : item.children)),
|
||||
[['insight.relationship-changes', 'entry.second'], 'entry.first']
|
||||
)
|
||||
})
|
||||
|
||||
test('restoring a hidden entry recreates the default group when the saved layout has no groups', () => {
|
||||
const layout: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [],
|
||||
hiddenEntryIds: ['entry.first'],
|
||||
}
|
||||
|
||||
assert.deepEqual(placeNavigationEntry(layout, 'entry.first', { kind: 'group', groupId: 'host.insight' }), {
|
||||
schemaVersion: 1,
|
||||
primary: [{ kind: 'group', id: 'host.insight', children: ['entry.first'] }],
|
||||
hiddenEntryIds: [],
|
||||
})
|
||||
})
|
||||
+141
-1
@@ -2,6 +2,7 @@ import {
|
||||
NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
type NavigationLayout,
|
||||
type NavigationLayoutLoadResult,
|
||||
type NavigationLayoutPrimaryItem,
|
||||
} from '@openchatlab/shared-types'
|
||||
import type { InsightPageDefinition, InsightPluginRuntime } from '@/plugins/insight'
|
||||
import { listInsightShellNavigation } from '@/plugins/insight-catalog'
|
||||
@@ -33,12 +34,18 @@ export function listResolvedNavigationEntries(layout: ResolvedNavigationLayout):
|
||||
return layout.primary.flatMap((item) => (item.kind === 'entry' ? [item] : item.entries))
|
||||
}
|
||||
|
||||
export type NavigationLayoutSource = NavigationLayoutLoadResult['status'] | 'default' | 'failed'
|
||||
|
||||
export interface NavigationLayoutSnapshot {
|
||||
layout: NavigationLayout
|
||||
source: NavigationLayoutLoadResult['status'] | 'default'
|
||||
source: NavigationLayoutSource
|
||||
revision: number
|
||||
}
|
||||
|
||||
export function canWriteNavigationLayout(source: NavigationLayoutSource): boolean {
|
||||
return source !== 'failed'
|
||||
}
|
||||
|
||||
export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): NavigationLayout {
|
||||
return {
|
||||
schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION,
|
||||
@@ -53,6 +60,133 @@ export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): Na
|
||||
}
|
||||
}
|
||||
|
||||
export function createEditableNavigationLayout(
|
||||
runtime: InsightPluginRuntime,
|
||||
layout: NavigationLayout
|
||||
): NavigationLayout {
|
||||
const editable = cloneNavigationLayout(layout)
|
||||
const positionedEntryIds = new Set<string>(editable.hiddenEntryIds)
|
||||
for (const item of editable.primary) {
|
||||
if (item.kind === 'entry') positionedEntryIds.add(item.entryId)
|
||||
else for (const entryId of item.children) positionedEntryIds.add(entryId)
|
||||
}
|
||||
|
||||
const newEntryIds = listInsightShellNavigation(runtime)
|
||||
.map(({ entryId }) => entryId)
|
||||
.filter((entryId) => !positionedEntryIds.has(entryId))
|
||||
if (newEntryIds.length === 0) return editable
|
||||
|
||||
let defaultGroup = editable.primary.find(
|
||||
(item): item is Extract<NavigationLayoutPrimaryItem, { kind: 'group' }> =>
|
||||
item.kind === 'group' && item.id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID
|
||||
)
|
||||
if (!defaultGroup) {
|
||||
defaultGroup = { kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, children: [] }
|
||||
editable.primary.push(defaultGroup)
|
||||
}
|
||||
defaultGroup.children.push(...newEntryIds)
|
||||
return editable
|
||||
}
|
||||
|
||||
export type NavigationEntryDestination = { kind: 'primary' } | { kind: 'group'; groupId: string } | { kind: 'hidden' }
|
||||
|
||||
export function placeNavigationEntry(
|
||||
layout: NavigationLayout,
|
||||
entryId: string,
|
||||
destination: NavigationEntryDestination
|
||||
): NavigationLayout {
|
||||
const currentDestination = findNavigationEntryDestination(layout, entryId)
|
||||
if (
|
||||
currentDestination?.kind === destination.kind &&
|
||||
(currentDestination.kind !== 'group' ||
|
||||
(destination.kind === 'group' && currentDestination.groupId === destination.groupId))
|
||||
) {
|
||||
return cloneNavigationLayout(layout)
|
||||
}
|
||||
|
||||
const next = cloneNavigationLayout(layout)
|
||||
const primary: NavigationLayoutPrimaryItem[] = []
|
||||
for (const item of next.primary) {
|
||||
if (item.kind === 'entry') {
|
||||
if (item.entryId !== entryId) primary.push(item)
|
||||
} else {
|
||||
primary.push({ ...item, children: item.children.filter((childId) => childId !== entryId) })
|
||||
}
|
||||
}
|
||||
next.primary = primary
|
||||
next.hiddenEntryIds = next.hiddenEntryIds.filter((hiddenId) => hiddenId !== entryId)
|
||||
|
||||
if (destination.kind === 'primary') next.primary.push({ kind: 'entry', entryId })
|
||||
else if (destination.kind === 'hidden') next.hiddenEntryIds.push(entryId)
|
||||
else {
|
||||
let group = next.primary.find(
|
||||
(item): item is Extract<NavigationLayoutPrimaryItem, { kind: 'group' }> =>
|
||||
item.kind === 'group' && item.id === destination.groupId
|
||||
)
|
||||
if (!group && destination.groupId === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID) {
|
||||
group = { kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, children: [] }
|
||||
next.primary.push(group)
|
||||
}
|
||||
if (!group) throw new Error(`Navigation group "${destination.groupId}" does not exist`)
|
||||
group.children.push(entryId)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function movePrimaryNavigationItem(layout: NavigationLayout, index: number, offset: -1 | 1): NavigationLayout {
|
||||
const next = cloneNavigationLayout(layout)
|
||||
moveArrayItem(next.primary, index, offset)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveGroupedNavigationEntry(
|
||||
layout: NavigationLayout,
|
||||
groupId: string,
|
||||
index: number,
|
||||
offset: -1 | 1
|
||||
): NavigationLayout {
|
||||
const next = cloneNavigationLayout(layout)
|
||||
const group = next.primary.find(
|
||||
(item): item is Extract<NavigationLayoutPrimaryItem, { kind: 'group' }> =>
|
||||
item.kind === 'group' && item.id === groupId
|
||||
)
|
||||
if (!group) throw new Error(`Navigation group "${groupId}" does not exist`)
|
||||
moveArrayItem(group.children, index, offset)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveHiddenNavigationEntry(layout: NavigationLayout, index: number, offset: -1 | 1): NavigationLayout {
|
||||
const next = cloneNavigationLayout(layout)
|
||||
moveArrayItem(next.hiddenEntryIds, index, offset)
|
||||
return next
|
||||
}
|
||||
|
||||
export function cloneNavigationLayout(layout: NavigationLayout): NavigationLayout {
|
||||
return {
|
||||
schemaVersion: layout.schemaVersion,
|
||||
primary: layout.primary.map((item) =>
|
||||
item.kind === 'entry' ? { ...item } : { ...item, children: [...item.children] }
|
||||
),
|
||||
hiddenEntryIds: [...layout.hiddenEntryIds],
|
||||
}
|
||||
}
|
||||
|
||||
function findNavigationEntryDestination(layout: NavigationLayout, entryId: string): NavigationEntryDestination | null {
|
||||
if (layout.hiddenEntryIds.includes(entryId)) return { kind: 'hidden' }
|
||||
for (const item of layout.primary) {
|
||||
if (item.kind === 'entry' && item.entryId === entryId) return { kind: 'primary' }
|
||||
if (item.kind === 'group' && item.children.includes(entryId)) return { kind: 'group', groupId: item.id }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function moveArrayItem<T>(items: T[], index: number, offset: -1 | 1): void {
|
||||
const targetIndex = index + offset
|
||||
if (index < 0 || index >= items.length || targetIndex < 0 || targetIndex >= items.length) return
|
||||
const [item] = items.splice(index, 1)
|
||||
items.splice(targetIndex, 0, item!)
|
||||
}
|
||||
|
||||
export function resolveNavigationLayout(
|
||||
runtime: InsightPluginRuntime,
|
||||
layout: NavigationLayout
|
||||
@@ -133,6 +267,12 @@ export class NavigationLayoutController {
|
||||
this.emit()
|
||||
}
|
||||
|
||||
applyLoadFailure(): void {
|
||||
this.layout = createDefaultNavigationLayout(this.runtime)
|
||||
this.source = 'failed'
|
||||
this.emit()
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): Disposer {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import type { NavigationLayout } from '@openchatlab/shared-types'
|
||||
import { desktopCliWebNavigationLayout } from '@/plugins/desktop-cli-web'
|
||||
import { redirectFromHiddenInsightPage } from './router'
|
||||
|
||||
const entryIds = {
|
||||
annualSummary: 'insight.annual-summary',
|
||||
relationshipChanges: 'insight.relationship-changes',
|
||||
timeInvestment: 'insight.time-investment',
|
||||
} as const
|
||||
|
||||
test('redirects a hidden current Insight page to the first visible entry and falls back home when all are hidden', async (t) => {
|
||||
t.after(() => desktopCliWebNavigationLayout.applyDefaultLayout())
|
||||
const view = { template: '<div />' }
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: view },
|
||||
{
|
||||
path: '/annual-summary',
|
||||
name: 'insight-annual-summary',
|
||||
component: view,
|
||||
meta: { insightPageId: 'annual-summary' },
|
||||
},
|
||||
{
|
||||
path: '/time-investment',
|
||||
name: 'insight-time-investment',
|
||||
component: view,
|
||||
meta: { insightPageId: 'time-investment' },
|
||||
},
|
||||
],
|
||||
})
|
||||
await router.push({ name: 'insight-annual-summary' })
|
||||
|
||||
const timeInvestmentOnly: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [{ kind: 'entry', entryId: entryIds.timeInvestment }],
|
||||
hiddenEntryIds: [entryIds.annualSummary, entryIds.relationshipChanges],
|
||||
}
|
||||
desktopCliWebNavigationLayout.applySavedLayout(timeInvestmentOnly)
|
||||
await redirectFromHiddenInsightPage(router, desktopCliWebNavigationLayout)
|
||||
assert.equal(router.currentRoute.value.name, 'insight-time-investment')
|
||||
|
||||
const allHidden: NavigationLayout = {
|
||||
schemaVersion: 1,
|
||||
primary: [],
|
||||
hiddenEntryIds: Object.values(entryIds),
|
||||
}
|
||||
desktopCliWebNavigationLayout.applySavedLayout(allHidden)
|
||||
await redirectFromHiddenInsightPage(router, desktopCliWebNavigationLayout)
|
||||
assert.equal(router.currentRoute.value.name, 'home')
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { listResolvedNavigationEntries, type NavigationLayoutController } from './layout'
|
||||
|
||||
export async function redirectFromHiddenInsightPage(
|
||||
router: Router,
|
||||
controller: NavigationLayoutController
|
||||
): Promise<void> {
|
||||
const currentPageId = String(router.currentRoute.value.meta.insightPageId ?? '')
|
||||
if (!currentPageId) return
|
||||
const visibleEntries = listResolvedNavigationEntries(controller.getResolvedLayout())
|
||||
if (visibleEntries.some(({ page }) => page.id === currentPageId)) return
|
||||
|
||||
const fallback = visibleEntries[0]
|
||||
await router.replace(fallback ? { name: fallback.page.routeName } : { name: 'home' })
|
||||
}
|
||||
Reference in new issue
Block a user