mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
fix(dashboard): reconcile plugin dashboard lifecycle (#697)
This commit is contained in:
@@ -612,11 +612,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/pages/dashboard.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/pages/resource.vue": {
|
"src/pages/resource.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
@@ -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<Element>()
|
||||||
|
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<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrameHarness() {
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>()
|
||||||
|
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<typeof useDashboardMediaGridCapacity>
|
||||||
|
|
||||||
|
const Harness = defineComponent({
|
||||||
|
setup() {
|
||||||
|
capacity = useDashboardMediaGridCapacity(options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount: capacity.columnCount,
|
||||||
|
containerRef: capacity.containerRef,
|
||||||
|
itemCount: capacity.itemCount,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div ref="containerRef" data-testid="container">
|
||||||
|
<div class="dashboard-media-content" data-testid="content" />
|
||||||
|
<span data-testid="columns">{{ columnCount }}</span>
|
||||||
|
<span data-testid="items">{{ itemCount }}</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
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<typeof createFrameHarness>) {
|
||||||
|
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<typeof useDashboardMediaGridCapacity>
|
||||||
|
const Card = defineComponent({
|
||||||
|
setup() {
|
||||||
|
capacity = useDashboardMediaGridCapacity({ minItemWidth: 200 })
|
||||||
|
return { containerRef: capacity.containerRef, itemCount: capacity.itemCount }
|
||||||
|
},
|
||||||
|
template: '<div ref="containerRef" data-testid="container">{{ itemCount }}</div>',
|
||||||
|
})
|
||||||
|
const Harness = defineComponent({
|
||||||
|
components: { Card },
|
||||||
|
setup() {
|
||||||
|
const active = ref(true)
|
||||||
|
return { active }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<button type="button" @click="active = false">停用</button>
|
||||||
|
<button type="button" @click="active = true">启用</button>
|
||||||
|
<KeepAlive><Card v-if="active" /></KeepAlive>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { usePluginRuntimeStore } from '@/stores/pluginRuntime'
|
|||||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { toRaw } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
@@ -220,13 +221,18 @@ function deferred<T>() {
|
|||||||
return { promise, resolve }
|
return { promise, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDashboard() {
|
async function renderDashboard(
|
||||||
|
user: {
|
||||||
|
permissions?: Partial<typeof DEFAULT_PERMISSIONS>
|
||||||
|
superUser?: boolean
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
return renderWithProviders(DashboardPage, {
|
return renderWithProviders(DashboardPage, {
|
||||||
initialRoute: '/dashboard',
|
initialRoute: '/dashboard',
|
||||||
initialState: {
|
initialState: {
|
||||||
user: {
|
user: {
|
||||||
permissions: { ...DEFAULT_PERMISSIONS, discovery: true },
|
permissions: { ...DEFAULT_PERMISSIONS, discovery: true, ...user.permissions },
|
||||||
superUser: true,
|
superUser: user.superUser ?? true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -300,11 +306,259 @@ describe('dashboard page initial layout', () => {
|
|||||||
mocks.apiGet.mockClear()
|
mocks.apiGet.mockClear()
|
||||||
|
|
||||||
const runtimeStore = usePluginRuntimeStore(pinia)
|
const runtimeStore = usePluginRuntimeStore(pinia)
|
||||||
runtimeStore.reconciliation = 1
|
runtimeStore.reconciliation = 2
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/plugin/dashboard/meta'))
|
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<Record<string, unknown>>()
|
||||||
|
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<Array<{ id: string; key: string; name: string }>>()
|
||||||
|
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<Record<string, unknown>>()
|
||||||
|
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 () => {
|
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
|
||||||
mocks.themeName.value = 'glass'
|
mocks.themeName.value = 'glass'
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
|||||||
+67
-13
@@ -97,6 +97,15 @@ interface DashboardGridItem {
|
|||||||
widget: GridStackWidget
|
widget: GridStackWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PluginDashboardMetaItem {
|
||||||
|
/** 插件稳定 ID。 */
|
||||||
|
id: string
|
||||||
|
/** 多仪表板入口键;空值表示插件唯一仪表板。 */
|
||||||
|
key?: string
|
||||||
|
/** 插件声明的仪表板显示名称。 */
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 是否处于仪表板布局编辑模式
|
// 是否处于仪表板布局编辑模式
|
||||||
const isLayoutEditing = ref(false)
|
const isLayoutEditing = ref(false)
|
||||||
|
|
||||||
@@ -309,11 +318,15 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 插件的仪表板元信息
|
// 插件的仪表板元信息
|
||||||
const pluginDashboardMeta = ref<any[]>([])
|
const pluginDashboardMeta = ref<PluginDashboardMetaItem[]>([])
|
||||||
|
|
||||||
// 插件仪表板的刷新状态
|
// 插件仪表板的刷新状态
|
||||||
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||||||
|
|
||||||
|
// 请求序号只负责丢弃乱序响应;已接受代际用于约束详情响应不得跨运行态恢复旧卡片。
|
||||||
|
let latestPluginDashboardMetaRequestId = 0
|
||||||
|
let acceptedPluginDashboardMetaGeneration = 0
|
||||||
|
|
||||||
// 当前启用且可渲染的仪表板 Grid 项。
|
// 当前启用且可渲染的仪表板 Grid 项。
|
||||||
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
|
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
|
||||||
dashboardConfigs.value
|
dashboardConfigs.value
|
||||||
@@ -1091,23 +1104,49 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构造插件仪表板主ID
|
// 构造插件仪表板主ID
|
||||||
function buildPluginDashboardId(plugin_id: string, key: string) {
|
function buildPluginDashboardId(plugin_id: string, key?: string) {
|
||||||
if (!key) return plugin_id
|
if (!key) return plugin_id
|
||||||
return plugin_id + ':' + key
|
return plugin_id + ':' + key
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API获取所有插件的仪表板元信息
|
// 调用API获取所有插件的仪表板元信息
|
||||||
async function getPluginDashboardMeta() {
|
async function getPluginDashboardMeta() {
|
||||||
|
const requestId = ++latestPluginDashboardMetaRequestId
|
||||||
|
|
||||||
try {
|
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)) {
|
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||||||
// 下载插件仪表板配置
|
// 下载插件仪表板配置
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pluginDashboardMeta.value.map(async (pluginDashboard: { id: string; key: string }) => {
|
pluginDashboardMeta.value.map(async pluginDashboard => {
|
||||||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||||
// 初始化插件仪表板的刷新状态
|
// 初始化插件仪表板的刷新状态
|
||||||
pluginDashboardRefreshStatus.value[pluginDashboardId] = true
|
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
|
isRequest.value
|
||||||
) {
|
) {
|
||||||
refreshTimers.value[pluginDashboardId] = setTimeout(() => {
|
refreshTimers.value[pluginDashboardId] = setTimeout(() => {
|
||||||
void getPluginDashboard(item.id, item.key)
|
void getPluginDashboard(item.id, item.key, acceptedPluginDashboardMetaGeneration)
|
||||||
}, item.attrs.refresh * 1000)
|
}, 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() {
|
function refreshEnabledPluginDashboards() {
|
||||||
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
|
||||||
|
|
||||||
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
|
pluginDashboardMeta.value.forEach(pluginDashboard => {
|
||||||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||||||
if (enableConfig.value[pluginDashboardId]) {
|
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 {
|
try {
|
||||||
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
|
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
|
||||||
const res: DashboardItem | undefined = await api.get(url)
|
const res: DashboardItem | undefined = await api.get(url)
|
||||||
if (res) {
|
if (res) {
|
||||||
|
if (metaGeneration !== acceptedPluginDashboardMetaGeneration) return
|
||||||
|
|
||||||
// 名称替换为元信息的名称
|
// 名称替换为元信息的名称
|
||||||
const meta = pluginDashboardMeta.value.find(
|
const meta = pluginDashboardMeta.value.find(item => item.id === id && (item.key ?? '') === key)
|
||||||
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
if (!meta) return
|
||||||
)
|
if (meta.name) res.name = meta.name
|
||||||
if (meta) res.name = meta.name
|
|
||||||
// 保存到仪表板配置中,如果已经存在则替换
|
// 保存到仪表板配置中,如果已经存在则替换
|
||||||
const index = dashboardConfigs.value.findIndex(
|
const index = dashboardConfigs.value.findIndex(
|
||||||
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
||||||
@@ -1177,9 +1228,12 @@ async function getPluginDashboard(id: string, key: string) {
|
|||||||
}
|
}
|
||||||
// 定时刷新
|
// 定时刷新
|
||||||
schedulePluginDashboardRefresh(res)
|
schedulePluginDashboardRefresh(res)
|
||||||
|
refreshScheduled = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (!refreshScheduled) ensurePluginDashboardRefresh(id, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/views/subscribe/SubscribeShareView.vue',
|
'src/views/subscribe/SubscribeShareView.vue',
|
||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
'src/composables/useLlmProviderDirectory.ts',
|
'src/composables/useLlmProviderDirectory.ts',
|
||||||
|
'src/composables/useDashboardMediaGridCapacity.ts',
|
||||||
'src/composables/useOfflineStatus.ts',
|
'src/composables/useOfflineStatus.ts',
|
||||||
'src/composables/useScheduleProgress.ts',
|
'src/composables/useScheduleProgress.ts',
|
||||||
'src/composables/useServerConnectionProbe.ts',
|
'src/composables/useServerConnectionProbe.ts',
|
||||||
@@ -436,6 +437,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/composables/useDashboardMediaGridCapacity.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/composables/useOfflineStatus.ts': {
|
'src/composables/useOfflineStatus.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user