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:
@@ -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 }) => {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const EXTENSION_ROUTE_NAME = 'Extensions';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user