diff --git a/eslint-suppressions.json b/eslint-suppressions.json index caf63ae1..0ab83faf 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -612,11 +612,6 @@ "count": 1 } }, - "src/pages/dashboard.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/pages/resource.vue": { "@typescript-eslint/no-explicit-any": { "count": 3 diff --git a/src/composables/__tests__/useDashboardMediaGridCapacity.spec.ts b/src/composables/__tests__/useDashboardMediaGridCapacity.spec.ts new file mode 100644 index 00000000..ecf01588 --- /dev/null +++ b/src/composables/__tests__/useDashboardMediaGridCapacity.spec.ts @@ -0,0 +1,220 @@ +import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity' +import { fireEvent, render, screen } from '@testing-library/vue' +import { defineComponent, nextTick, ref, type Ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +class ResizeObserverMock implements ResizeObserver { + static instances: ResizeObserverMock[] = [] + + readonly targets = new Set() + readonly disconnect = vi.fn(() => this.targets.clear()) + readonly observe = vi.fn((target: Element) => this.targets.add(target)) + readonly unobserve = vi.fn((target: Element) => this.targets.delete(target)) + + constructor(private readonly callback: ResizeObserverCallback) { + ResizeObserverMock.instances.push(this) + } + + trigger() { + this.callback([], this) + } +} + +interface CapacityHarnessOptions { + contentSelector?: string + horizontalPadding?: number + maxCount?: number + minItemWidth: number + rows?: Ref +} + +function createFrameHarness() { + const callbacks = new Map() + let nextFrameId = 0 + + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + nextFrameId += 1 + callbacks.set(nextFrameId, callback) + return nextFrameId + }) + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(frameId => { + callbacks.delete(frameId) + }) + + return { + callbacks, + cancelFrame, + flush() { + const queuedCallbacks = [...callbacks.values()] + callbacks.clear() + queuedCallbacks.forEach(callback => callback(performance.now())) + }, + } +} + +function renderCapacityHarness(options: CapacityHarnessOptions) { + let capacity!: ReturnType + + const Harness = defineComponent({ + setup() { + capacity = useDashboardMediaGridCapacity(options) + + return { + columnCount: capacity.columnCount, + containerRef: capacity.containerRef, + itemCount: capacity.itemCount, + } + }, + template: ` +
+
+ {{ columnCount }} + {{ itemCount }} +
+ `, + }) + + const rendered = render(Harness) + + return { capacity, ...rendered } +} + +function setClientWidth(element: HTMLElement, width: number) { + Object.defineProperty(element, 'clientWidth', { configurable: true, value: width }) +} + +function setBoundingWidth(element: HTMLElement, width: number) { + element.getBoundingClientRect = () => + ({ + bottom: 0, + height: 0, + left: 0, + right: width, + toJSON: () => ({}), + top: 0, + width, + x: 0, + y: 0, + }) as DOMRect +} + +async function settleInitialMeasurement(frames: ReturnType) { + await nextTick() + frames.flush() + await nextTick() +} + +describe('dashboard media grid capacity', () => { + beforeEach(() => { + ResizeObserverMock.instances = [] + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + }) + + it('uses the selected content box and responsive row count to calculate the request size', async () => { + const frames = createFrameHarness() + const rows = ref(2) + const { capacity } = renderCapacityHarness({ + contentSelector: '.dashboard-media-content', + maxCount: 10, + minItemWidth: 144, + rows, + }) + const content = screen.getByTestId('content') + setClientWidth(content, 760) + content.style.paddingLeft = '20px' + content.style.paddingRight = '20px' + + capacity.refreshCapacity() + await nextTick() + expect(screen.getByTestId('columns')).toHaveTextContent('4') + expect(screen.getByTestId('items')).toHaveTextContent('8') + + rows.value = 3 + await nextTick() + expect(screen.getByTestId('items')).toHaveTextContent('10') + + await settleInitialMeasurement(frames) + }) + + it('falls back to the host width and subtracts the declared horizontal padding', async () => { + const frames = createFrameHarness() + const { capacity } = renderCapacityHarness({ horizontalPadding: 40, minItemWidth: 240 }) + const container = screen.getByTestId('container') + setBoundingWidth(container, 760) + + capacity.refreshCapacity() + await nextTick() + + expect(screen.getByTestId('columns')).toHaveTextContent('2') + expect(screen.getByTestId('items')).toHaveTextContent('4') + await settleInitialMeasurement(frames) + }) + + it('coalesces observer and window resize notifications into one measurement frame', async () => { + const frames = createFrameHarness() + renderCapacityHarness({ minItemWidth: 200 }) + const container = screen.getByTestId('container') + setBoundingWidth(container, 640) + await settleInitialMeasurement(frames) + const observer = ResizeObserverMock.instances.at(-1) + expect(observer?.targets.has(container)).toBe(true) + + observer?.trigger() + window.dispatchEvent(new Event('resize')) + + expect(frames.callbacks).toHaveLength(1) + frames.flush() + await nextTick() + expect(screen.getByTestId('columns')).toHaveTextContent('3') + expect(screen.getByTestId('items')).toHaveTextContent('6') + }) + + it('refreshes after KeepAlive activation and releases observers, listeners, and queued frames on unmount', async () => { + const frames = createFrameHarness() + let capacity!: ReturnType + const Card = defineComponent({ + setup() { + capacity = useDashboardMediaGridCapacity({ minItemWidth: 200 }) + return { containerRef: capacity.containerRef, itemCount: capacity.itemCount } + }, + template: '
{{ itemCount }}
', + }) + const Harness = defineComponent({ + components: { Card }, + setup() { + const active = ref(true) + return { active } + }, + template: ` + + + + `, + }) + const rendered = render(Harness) + const container = screen.getByTestId('container') + setBoundingWidth(container, 432) + capacity.refreshCapacity() + await nextTick() + expect(container).toHaveTextContent('4') + + await fireEvent.click(screen.getByRole('button', { name: '停用' })) + setBoundingWidth(container, 648) + await fireEvent.click(screen.getByRole('button', { name: '启用' })) + await nextTick() + expect(screen.getByTestId('container')).toHaveTextContent('6') + + frames.flush() + const observer = ResizeObserverMock.instances.at(-1) + observer?.trigger() + const queuedFrameId = [...frames.callbacks.keys()][0] + rendered.unmount() + + expect(observer?.disconnect).toHaveBeenCalledOnce() + expect(frames.cancelFrame).toHaveBeenCalledWith(queuedFrameId) + expect(frames.callbacks).toHaveLength(0) + + window.dispatchEvent(new Event('resize')) + expect(frames.callbacks).toHaveLength(0) + }) +}) diff --git a/src/pages/__tests__/dashboard.spec.ts b/src/pages/__tests__/dashboard.spec.ts index d2ecfe39..1087bc0f 100644 --- a/src/pages/__tests__/dashboard.spec.ts +++ b/src/pages/__tests__/dashboard.spec.ts @@ -3,6 +3,7 @@ import { usePluginRuntimeStore } from '@/stores/pluginRuntime' import { DEFAULT_PERMISSIONS } from '@/utils/permission' import { renderWithProviders } from '@tests/support/render' import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { toRaw } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { @@ -220,13 +221,18 @@ function deferred() { return { promise, resolve } } -async function renderDashboard() { +async function renderDashboard( + user: { + permissions?: Partial + superUser?: boolean + } = {}, +) { return renderWithProviders(DashboardPage, { initialRoute: '/dashboard', initialState: { user: { - permissions: { ...DEFAULT_PERMISSIONS, discovery: true }, - superUser: true, + permissions: { ...DEFAULT_PERMISSIONS, discovery: true, ...user.permissions }, + superUser: user.superUser ?? true, }, }, }) @@ -300,11 +306,259 @@ describe('dashboard page initial layout', () => { mocks.apiGet.mockClear() const runtimeStore = usePluginRuntimeStore(pinia) - runtimeStore.reconciliation = 1 + runtimeStore.reconciliation = 2 await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/plugin/dashboard/meta')) }) + it('removes a plugin dashboard when runtime metadata no longer advertises it', async () => { + const pluginDashboardId = 'plugin-a:summary' + const enabled = { ...enabledOnlySystemInfo, [pluginDashboardId]: true, systemInfo: false } + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ enabled, items: { [pluginDashboardId]: { x: 0, y: 0, w: 6, h: 8 } }, updatedAt: 10 }), + ) + localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify([{ id: 'plugin-a', key: 'summary' }])) + const latePluginDashboard = deferred>() + let detailReads = 0 + let metaReads = 0 + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') return { data: {} } + if (url === '/plugin/dashboard/meta') { + metaReads += 1 + return metaReads <= 2 ? [{ id: 'plugin-a', key: 'summary', name: '插件摘要' }] : [] + } + if (url === '/plugin/dashboard/plugin-a/summary') { + detailReads += 1 + const dashboard = { + attrs: { refresh: 30 }, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '原始名称', + rows: 8, + } + return detailReads === 1 ? dashboard : latePluginDashboard.promise + } + throw new Error('Unexpected GET ' + url) + }) + + const { pinia } = await renderDashboard() + expect(await screen.findByText('插件摘要')).toBeInTheDocument() + await new Promise(resolve => setTimeout(resolve, 0)) + + const runtimeStore = usePluginRuntimeStore(pinia) + runtimeStore.reconciliation = 1 + await waitFor(() => expect(detailReads).toBe(2)) + + runtimeStore.reconciliation = 2 + + await waitFor(() => expect(metaReads).toBe(3)) + await waitFor(() => expect(screen.queryByText('插件摘要')).not.toBeInTheDocument()) + + latePluginDashboard.resolve({ + attrs: { refresh: 30 }, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '迟到详情', + rows: 8, + }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(screen.queryByText('插件摘要')).not.toBeInTheDocument() + }) + + it('ignores an older plugin metadata response after a newer runtime snapshot is applied', async () => { + const pluginDashboardId = 'plugin-a:summary' + const enabled = { ...enabledOnlySystemInfo, [pluginDashboardId]: true, systemInfo: false } + const staleMeta = deferred>() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ enabled, items: { [pluginDashboardId]: { x: 0, y: 0, w: 6, h: 8 } }, updatedAt: 10 }), + ) + let detailReads = 0 + let metaReads = 0 + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') return { data: {} } + if (url === '/plugin/dashboard/meta') { + metaReads += 1 + if (metaReads === 1) return [{ id: 'plugin-a', key: 'summary', name: '插件摘要' }] + if (metaReads === 2) return staleMeta.promise + return [] + } + if (url === '/plugin/dashboard/plugin-a/summary') { + detailReads += 1 + return { + attrs: {}, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '原始名称', + rows: 8, + } + } + throw new Error('Unexpected GET ' + url) + }) + + const { pinia } = await renderDashboard() + expect(await screen.findByText('插件摘要')).toBeInTheDocument() + await new Promise(resolve => setTimeout(resolve, 0)) + const runtimeStore = usePluginRuntimeStore(pinia) + + runtimeStore.reconciliation = 1 + await waitFor(() => expect(metaReads).toBe(2)) + runtimeStore.reconciliation = 2 + await waitFor(() => expect(metaReads).toBe(3)) + await waitFor(() => expect(screen.queryByText('插件摘要')).not.toBeInTheDocument()) + + staleMeta.resolve([{ id: 'plugin-a', key: 'summary', name: '过期插件摘要' }]) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(screen.queryByText('过期插件摘要')).not.toBeInTheDocument() + expect(detailReads).toBe(1) + }) + + it('replaces a plugin refresh timer and clears the active timer on unmount', async () => { + const pluginDashboardId = 'plugin-a:summary' + const enabled = { ...enabledOnlySystemInfo, [pluginDashboardId]: true, systemInfo: false } + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ enabled, items: { [pluginDashboardId]: { x: 0, y: 0, w: 6, h: 8 } }, updatedAt: 10 }), + ) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + let detailReads = 0 + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') return { data: {} } + if (url === '/plugin/dashboard/meta') return [{ id: 'plugin-a', key: 'summary', name: '插件摘要' }] + if (url === '/plugin/dashboard/plugin-a/summary') { + detailReads += 1 + return { + attrs: { refresh: 30 }, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '原始名称', + rows: 8, + } + } + throw new Error('Unexpected GET ' + url) + }) + + const rendered = await renderDashboard() + expect(await screen.findByText('插件摘要')).toBeInTheDocument() + await waitFor(() => expect(setTimeoutSpy.mock.calls.filter(([, delay]) => delay === 30_000)).toHaveLength(1)) + const firstTimerCallIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 30_000) + const firstTimer = setTimeoutSpy.mock.results[firstTimerCallIndex].value + const firstTimerCallback = setTimeoutSpy.mock.calls[firstTimerCallIndex][0] + if (typeof firstTimerCallback !== 'function') throw new Error('Expected a plugin refresh callback') + + firstTimerCallback() + + await waitFor(() => expect(detailReads).toBe(2)) + await waitFor(() => expect(setTimeoutSpy.mock.calls.filter(([, delay]) => delay === 30_000)).toHaveLength(2)) + const pluginTimerCalls = setTimeoutSpy.mock.calls + .map(([, delay], index) => ({ delay, index })) + .filter(call => call.delay === 30_000) + const secondTimer = setTimeoutSpy.mock.results[pluginTimerCalls[1].index].value + expect(clearTimeoutSpy.mock.calls.some(([timer]) => toRaw(timer) === firstTimer)).toBe(true) + + rendered.unmount() + + expect(clearTimeoutSpy.mock.calls.some(([timer]) => toRaw(timer) === secondTimer)).toBe(true) + }) + + it('keeps the accepted plugin refresh chain across transient metadata and detail failures', async () => { + const pluginDashboardId = 'plugin-a:summary' + const enabled = { ...enabledOnlySystemInfo, [pluginDashboardId]: true, systemInfo: false } + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ enabled, items: { [pluginDashboardId]: { x: 0, y: 0, w: 6, h: 8 } }, updatedAt: 10 }), + ) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + vi.spyOn(console, 'error').mockImplementation(() => {}) + const staleDetail = deferred>() + let detailReads = 0 + let metaReads = 0 + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') return { data: {} } + if (url === '/plugin/dashboard/meta') { + metaReads += 1 + if (metaReads === 2) return Promise.reject(new Error('temporary metadata failure')) + return [{ id: 'plugin-a', key: 'summary', name: '插件摘要' }] + } + if (url === '/plugin/dashboard/plugin-a/summary') { + detailReads += 1 + if (detailReads === 3) return staleDetail.promise + if (detailReads === 4) return Promise.reject(new Error('temporary detail failure')) + return { + attrs: { refresh: 30 }, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '原始名称', + rows: 8, + } + } + throw new Error('Unexpected GET ' + url) + }) + + const { pinia } = await renderDashboard() + expect(await screen.findByText('插件摘要')).toBeInTheDocument() + await new Promise(resolve => setTimeout(resolve, 0)) + const runtimeStore = usePluginRuntimeStore(pinia) + const pluginTimerCallbacks = () => + setTimeoutSpy.mock.calls.filter(([, delay]) => delay === 30_000).map(([callback]) => callback as () => void) + + runtimeStore.reconciliation += 1 + await waitFor(() => expect(metaReads).toBe(2)) + pluginTimerCallbacks()[0]() + await waitFor(() => expect(detailReads).toBe(2)) + await waitFor(() => expect(pluginTimerCallbacks()).toHaveLength(2)) + + pluginTimerCallbacks()[1]() + await waitFor(() => expect(detailReads).toBe(3)) + runtimeStore.reconciliation += 1 + await waitFor(() => expect(detailReads).toBe(4)) + staleDetail.resolve({ + attrs: { refresh: 30 }, + cols: { cols: 12 }, + elements: [], + id: 'plugin-a', + key: 'summary', + name: '过期详情', + rows: 8, + }) + + await waitFor(() => expect(pluginTimerCallbacks()).toHaveLength(3)) + }) + + it('hides discovery content and dashboard controls without their permissions', async () => { + mocks.apiGet.mockImplementation((url: string) => { + if ( + url === '/user/config/DashboardOrder' || + url === '/user/config/DashboardGridLayout' || + url === '/user/config/Dashboard' + ) { + return { data: {} } + } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + const { container } = await renderDashboard({ + permissions: { admin: false, discovery: false }, + superUser: false, + }) + + expect(container.querySelector('[data-dashboard-id="mediaRecommend"]')).not.toBeInTheDocument() + expect(document.querySelector('.compact-fab-stack')).not.toBeInTheDocument() + }) + it('disables automatic grid transitions only while browsing with the glass theme', async () => { mocks.themeName.value = 'glass' mocks.apiGet.mockImplementation((url: string) => { diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index 4499fc33..6081296d 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -97,6 +97,15 @@ interface DashboardGridItem { widget: GridStackWidget } +interface PluginDashboardMetaItem { + /** 插件稳定 ID。 */ + id: string + /** 多仪表板入口键;空值表示插件唯一仪表板。 */ + key?: string + /** 插件声明的仪表板显示名称。 */ + name?: string +} + // 是否处于仪表板布局编辑模式 const isLayoutEditing = ref(false) @@ -309,11 +318,15 @@ const dashboardConfigs = ref([ ]) // 插件的仪表板元信息 -const pluginDashboardMeta = ref([]) +const pluginDashboardMeta = ref([]) // 插件仪表板的刷新状态 const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({}) +// 请求序号只负责丢弃乱序响应;已接受代际用于约束详情响应不得跨运行态恢复旧卡片。 +let latestPluginDashboardMetaRequestId = 0 +let acceptedPluginDashboardMetaGeneration = 0 + // 当前启用且可渲染的仪表板 Grid 项。 const dashboardGridItems = computed(() => dashboardConfigs.value @@ -1091,23 +1104,49 @@ async function saveDashboardConfig(payload?: { enabled?: Record } // 构造插件仪表板主ID -function buildPluginDashboardId(plugin_id: string, key: string) { +function buildPluginDashboardId(plugin_id: string, key?: string) { if (!key) return plugin_id return plugin_id + ':' + key } // 调用API获取所有插件的仪表板元信息 async function getPluginDashboardMeta() { + const requestId = ++latestPluginDashboardMetaRequestId + try { - pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? [] + const nextPluginDashboardMeta: PluginDashboardMetaItem[] = (await api.get('/plugin/dashboard/meta')) ?? [] + if (requestId !== latestPluginDashboardMetaRequestId) return + + const metaGeneration = ++acceptedPluginDashboardMetaGeneration + + const nextPluginDashboardIds = new Set( + nextPluginDashboardMeta.map(item => buildPluginDashboardId(item.id, item.key)), + ) + const removedPluginDashboardIds = pluginDashboardMeta.value + .map(item => buildPluginDashboardId(item.id, item.key)) + .filter(id => !nextPluginDashboardIds.has(id)) + + pluginDashboardMeta.value = nextPluginDashboardMeta + if (removedPluginDashboardIds.length > 0) { + const removedIdSet = new Set(removedPluginDashboardIds) + removedPluginDashboardIds.forEach(pluginDashboardId => { + clearPluginDashboardTimer(pluginDashboardId) + delete pluginDashboardRefreshStatus.value[pluginDashboardId] + }) + // 运行态集合不再包含这些插件时移除渲染配置;用户保存的启用与布局偏好继续保留。 + dashboardConfigs.value = dashboardConfigs.value.filter( + item => !removedIdSet.has(buildPluginDashboardId(item.id, item.key)), + ) + } + if (!isNullOrEmptyObject(pluginDashboardMeta.value)) { // 下载插件仪表板配置 await Promise.all( - pluginDashboardMeta.value.map(async (pluginDashboard: { id: string; key: string }) => { + pluginDashboardMeta.value.map(async pluginDashboard => { const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key) // 初始化插件仪表板的刷新状态 pluginDashboardRefreshStatus.value[pluginDashboardId] = true - await getPluginDashboard(pluginDashboard.id, pluginDashboard.key) + await getPluginDashboard(pluginDashboard.id, pluginDashboard.key ?? '', metaGeneration) }), ) } @@ -1136,34 +1175,46 @@ function schedulePluginDashboardRefresh(item: DashboardItem) { isRequest.value ) { refreshTimers.value[pluginDashboardId] = setTimeout(() => { - void getPluginDashboard(item.id, item.key) + void getPluginDashboard(item.id, item.key, acceptedPluginDashboardMetaGeneration) }, item.attrs.refresh * 1000) } } +// 并发刷新失败或过期时,沿用当前已渲染配置保证仍有且仅有一个后续刷新任务。 +function ensurePluginDashboardRefresh(id: string, key: string) { + const isCurrentPluginDashboard = pluginDashboardMeta.value.some(item => item.id === id && (item.key ?? '') === key) + if (!isCurrentPluginDashboard) return + + const currentDashboard = dashboardConfigs.value.find(item => item.id === id && item.key === key) + if (currentDashboard) schedulePluginDashboardRefresh(currentDashboard) +} + // 重新拉取当前启用的插件仪表板数据。 function refreshEnabledPluginDashboards() { if (isNullOrEmptyObject(pluginDashboardMeta.value)) return - pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => { + pluginDashboardMeta.value.forEach(pluginDashboard => { const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key) if (enableConfig.value[pluginDashboardId]) { - void getPluginDashboard(pluginDashboard.id, pluginDashboard.key) + void getPluginDashboard(pluginDashboard.id, pluginDashboard.key ?? '', acceptedPluginDashboardMetaGeneration) } }) } // 获取一个插件的仪表板配置项 -async function getPluginDashboard(id: string, key: string) { +async function getPluginDashboard(id: string, key: string, metaGeneration: number) { + let refreshScheduled = false + try { const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}` const res: DashboardItem | undefined = await api.get(url) if (res) { + if (metaGeneration !== acceptedPluginDashboardMetaGeneration) return + // 名称替换为元信息的名称 - const meta = pluginDashboardMeta.value.find( - (item: { id: string; key: string }) => item.id === id && item.key === key, - ) - if (meta) res.name = meta.name + const meta = pluginDashboardMeta.value.find(item => item.id === id && (item.key ?? '') === key) + if (!meta) return + if (meta.name) res.name = meta.name // 保存到仪表板配置中,如果已经存在则替换 const index = dashboardConfigs.value.findIndex( (item: { id: string; key: string }) => item.id === id && item.key === key, @@ -1177,9 +1228,12 @@ async function getPluginDashboard(id: string, key: string) { } // 定时刷新 schedulePluginDashboardRefresh(res) + refreshScheduled = true } } catch (error) { console.error(error) + } finally { + if (!refreshScheduled) ensurePluginDashboardRefresh(id, key) } } diff --git a/vite.config.ts b/vite.config.ts index 627fef13..7a19aac0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -343,6 +343,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/views/subscribe/SubscribeShareView.vue', 'src/composables/useMediaSubscribe.ts', 'src/composables/useLlmProviderDirectory.ts', + 'src/composables/useDashboardMediaGridCapacity.ts', 'src/composables/useOfflineStatus.ts', 'src/composables/useScheduleProgress.ts', 'src/composables/useServerConnectionProbe.ts', @@ -436,6 +437,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/composables/useDashboardMediaGridCapacity.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/composables/useOfflineStatus.ts': { branches: 85, functions: 90,