feat(navigation): add layout editor

This commit is contained in:
digua committed 2026-08-18 14:29:38 +08:00
1 parent 7907435cff
commit a54af007ae
14 files changed
+787 -16

No files matched your search

+26 -13
View File
@@ -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,
+2
View File
@@ -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',
+2
View File
@@ -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>
+3
View File
@@ -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"
+1 -1
View File
@@ -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="
+22
View File
@@ -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",
+22
View File
@@ -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": "先に「モデルサービス」でモデル設定を追加してください",
+22
View File
@@ -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": "请先在「模型服务」中添加模型配置",
+22
View File
@@ -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": "請先在「模型服務」中新增模型設定",
+120 -1
View File
@@ -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
View File
@@ -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)
+54
View File
@@ -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')
})
+15
View File
@@ -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' })
}