mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(dashboard): reconcile plugin dashboard lifecycle (#697)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 { 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<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function renderDashboard() {
|
||||
async function renderDashboard(
|
||||
user: {
|
||||
permissions?: Partial<typeof DEFAULT_PERMISSIONS>
|
||||
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<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 () => {
|
||||
mocks.themeName.value = 'glass'
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
|
||||
+67
-13
@@ -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<DashboardItem[]>([
|
||||
])
|
||||
|
||||
// 插件的仪表板元信息
|
||||
const pluginDashboardMeta = ref<any[]>([])
|
||||
const pluginDashboardMeta = ref<PluginDashboardMetaItem[]>([])
|
||||
|
||||
// 插件仪表板的刷新状态
|
||||
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 请求序号只负责丢弃乱序响应;已接受代际用于约束详情响应不得跨运行态恢复旧卡片。
|
||||
let latestPluginDashboardMetaRequestId = 0
|
||||
let acceptedPluginDashboardMetaGeneration = 0
|
||||
|
||||
// 当前启用且可渲染的仪表板 Grid 项。
|
||||
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
|
||||
dashboardConfigs.value
|
||||
@@ -1091,23 +1104,49 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
// 构造插件仪表板主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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user