fix(dashboard): stabilize sidebar hash navigation on startup (#6159)

* fix(dashboard): stabilize sidebar hash navigation on startup

* fix(dashboard): reuse shared extension tab route helpers

* fix(dashboard): avoid leaking extension route query state

* fix(dashboard): preserve route params in tab locations

* fix(dashboard): harden hash tab routing fallbacks

* fix(dashboard): warn on tab route navigation failures

* fix(dashboard): harden extension tab startup routing
This commit is contained in:
エイカク
2026-03-13 11:53:50 +09:00
committed by GitHub
parent a21bb5b234
commit a8ff2b3d9c
8 changed files with 230 additions and 39 deletions
+6 -3
View File
@@ -11,19 +11,21 @@ import VueApexCharts from 'vue3-apexcharts';
import print from 'vue3-print-nb';
import { loader } from '@guolao/vue-monaco-editor'
import axios from 'axios';
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
// 初始化新的i18n系统,等待完成后再挂载应用
setupI18n().then(() => {
setupI18n().then(async () => {
console.log('🌍 新i18n系统初始化完成');
const app = createApp(App);
app.use(router);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
await router.isReady();
app.mount('#app');
// 挂载后同步 Vuetify 主题
@@ -49,14 +51,15 @@ setupI18n().then(() => {
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
const app = createApp(App);
app.use(router);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
waitForRouterReadyInBackground(router);
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
+3 -1
View File
@@ -1,3 +1,5 @@
import { EXTENSION_ROUTE_NAME } from './routeConstants.mjs';
const MainRoutes = {
path: '/main',
meta: {
@@ -17,7 +19,7 @@ const MainRoutes = {
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Extensions',
name: EXTENSION_ROUTE_NAME,
path: '/extension',
component: () => import('@/views/ExtensionPage.vue')
},
+1
View File
@@ -0,0 +1 @@
export const EXTENSION_ROUTE_NAME = 'Extensions';
+46
View File
@@ -0,0 +1,46 @@
import { EXTENSION_ROUTE_NAME } from '../router/routeConstants.mjs';
export function getValidHashTab(routeHash, validTabs) {
const hash = String(routeHash || '');
const tab = hash.includes('#') ? hash.slice(hash.lastIndexOf('#') + 1) : hash;
return validTabs.includes(tab) ? tab : null;
}
export function createTabRouteLocation(route, tab, fallbackRouteName = EXTENSION_ROUTE_NAME) {
const query = route?.query ? { ...route.query } : {};
const params = route?.params ? { ...route.params } : undefined;
if (route?.name) {
return {
name: route.name,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
if (route?.path) {
return {
path: route.path,
query,
hash: `#${tab}`,
};
}
return {
name: fallbackRouteName,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
export async function replaceTabRoute(router, route, tab, logger = console) {
try {
await router.replace(createTabRouteLocation(route, tab));
return true;
} catch (error) {
logger.warn?.('Failed to update extension tab route:', error);
return false;
}
}
+5
View File
@@ -0,0 +1,5 @@
export function waitForRouterReadyInBackground(router, logger = console) {
router.isReady().catch((error) => {
logger.warn?.('Router did not become ready after fallback mount:', error);
});
}
@@ -10,6 +10,10 @@ import {
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -103,16 +107,11 @@ export const useExtensionPage = () => {
const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () =>
typeof window !== "undefined" ? window.location.hash : "";
const extractTabFromHash = (hash) => {
const lastHashIndex = (hash || "").lastIndexOf("#");
if (lastHashIndex === -1) return "";
return hash.slice(lastHashIndex + 1);
};
const getLocationHash = () => route.hash || "";
const extractTabFromHash = (hash) => getValidHashTab(hash, validTabs);
const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash);
if (isValidTab(tab)) {
if (tab) {
activeTab.value = tab;
return true;
}
@@ -1436,9 +1435,7 @@ export const useExtensionPage = () => {
// 生命周期
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
if (typeof window !== "undefined") {
window.location.hash = `#${activeTab.value}`;
}
await replaceTabRoute(router, route, activeTab.value);
}
await getExtensions();
@@ -1446,17 +1443,9 @@ export const useExtensionPage = () => {
loadCustomSources();
// 检查是否有 open_config 参数
let urlParams;
if (window.location.hash) {
// For hash mode (#/path?param=value)
const hashQuery = window.location.hash.split("?")[1] || "";
urlParams = new URLSearchParams(hashQuery);
} else {
// For history mode (/path?param=value)
urlParams = new URLSearchParams(window.location.search);
}
console.log("URL Parameters:", urlParams.toString());
const plugin_name = urlParams.get("open_config");
const plugin_name = Array.isArray(route.query.open_config)
? route.query.open_config[0]
: route.query.open_config;
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
@@ -1528,10 +1517,10 @@ export const useExtensionPage = () => {
);
watch(
() => route.fullPath,
() => {
const tab = extractTabFromHash(getLocationHash());
if (isValidTab(tab) && tab !== activeTab.value) {
() => route.hash,
(newHash) => {
const tab = extractTabFromHash(newHash);
if (tab && tab !== activeTab.value) {
activeTab.value = tab;
}
},
@@ -1539,15 +1528,8 @@ export const useExtensionPage = () => {
watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return;
const currentTab = extractTabFromHash(getLocationHash());
if (currentTab === newTab) return;
const hash = getLocationHash();
const lastHashIndex = hash.lastIndexOf("#");
const nextHash =
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
if (typeof window !== "undefined") {
window.location.hash = nextHash;
}
if (route.hash === `#${newTab}`) return;
void replaceTabRoute(router, route, newTab);
});
return {
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as hashRouteTabs from '../src/utils/hashRouteTabs.mjs';
import { EXTENSION_ROUTE_NAME } from '../src/router/routeConstants.mjs';
const { createTabRouteLocation, getValidHashTab } = hashRouteTabs;
test('getValidHashTab returns the tab name for a valid route hash', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#market', validTabs), 'market');
});
test('getValidHashTab rejects empty and unknown hashes', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('', validTabs), null);
assert.equal(getValidHashTab('#unknown', validTabs), null);
});
test('getValidHashTab uses the last hash segment when multiple hashes are present', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#/extension#foo#installed', validTabs), 'installed');
});
test('createTabRouteLocation preserves the current path and query', () => {
const query = { open_config: 'sample-plugin', page: '2' };
const location = createTabRouteLocation(
{
path: '/extension',
query,
},
'market',
);
assert.deepEqual(location, {
path: '/extension',
query: { open_config: 'sample-plugin', page: '2' },
hash: '#market',
});
assert.notEqual(location.query, query);
});
test('createTabRouteLocation falls back to the extension route name', () => {
const location = createTabRouteLocation(undefined, 'installed');
assert.deepEqual(location, {
name: EXTENSION_ROUTE_NAME,
query: {},
hash: '#installed',
});
});
test('createTabRouteLocation prefers route name and preserves params', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
name: 'ExtensionDetails',
path: '/extension/demo-plugin',
params,
query: { tab: 'details' },
},
'market',
);
assert.deepEqual(location, {
name: 'ExtensionDetails',
params: { pluginId: 'demo-plugin' },
query: { tab: 'details' },
hash: '#market',
});
assert.notEqual(location.params, params);
});
test('createTabRouteLocation omits params for path-based routes', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
path: '/extension/demo-plugin',
params,
},
'installed',
);
assert.deepEqual(location, {
path: '/extension/demo-plugin',
query: {},
hash: '#installed',
});
assert.equal(location.params, undefined);
});
test('replaceTabRoute catches rejected router updates', async () => {
assert.equal(typeof hashRouteTabs.replaceTabRoute, 'function');
const error = new Error('blocked');
let logged;
const router = {
replace: async () => {
throw error;
},
};
const logger = {
warn: (message, cause) => {
logged = { message, cause };
},
};
const result = await hashRouteTabs.replaceTabRoute(
router,
{ name: EXTENSION_ROUTE_NAME, query: { page: '1' } },
'installed',
logger,
);
assert.equal(result, false);
assert.deepEqual(logged, {
message: 'Failed to update extension tab route:',
cause: error,
});
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('waitForRouterReadyInBackground returns immediately and logs failures', async () => {
const module = await import('../src/utils/routerReadiness.mjs').catch(() => null);
assert.ok(module?.waitForRouterReadyInBackground);
const error = new Error('router blocked');
let warned;
const readyPromise = Promise.reject(error);
const logger = {
warn: (message, cause) => {
warned = { message, cause };
},
};
const result = module.waitForRouterReadyInBackground(
{ isReady: () => readyPromise },
logger,
);
assert.equal(result, undefined);
await Promise.resolve();
assert.deepEqual(warned, {
message: 'Router did not become ready after fallback mount:',
cause: error,
});
});