fix: 完善插件运行态与安装交互 (#694)

This commit is contained in:
InfinityPacer
2026-08-21 18:10:51 +08:00
committed by GitHub
parent 4f1c40af12
commit ea6d0c8178
22 changed files with 1522 additions and 116 deletions
+13
View File
@@ -955,6 +955,8 @@ export interface Plugin {
installed?: boolean
// 运行状态
state?: boolean
// 插件源码、依赖和运行时加载状态
runtime_status?: 'source_missing' | 'dependency_pending' | 'ready' | 'active' | 'blocked_by_policy' | 'load_failed'
// 是否有详情页面
has_page?: boolean
// 是否有新版本
@@ -985,6 +987,17 @@ export interface Plugin {
user_rating?: number | null
}
export interface PluginRuntimeSummary {
// 本轮插件源码、依赖和加载是否已收敛
ready: boolean
// 插件运行状态变化代次
generation: number
// 仍处于准备阶段的插件数量
pending_count: number
// 加载失败或被策略阻止的插件数量
failed_count: number
}
// 插件评分结果
export interface PluginRating {
plugin_id: string
+11
View File
@@ -18,12 +18,15 @@ const PluginVersionHistoryDialog = defineAsyncComponent(
)
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
type InstallHandler = (releaseVersion?: string, repoUrl?: string) => unknown
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
width: String,
height: String,
count: Number,
installHandler: Function as PropType<InstallHandler>,
})
const globalSettingsStore = useGlobalSettingsStore()
@@ -146,6 +149,13 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!isConfirmed) return
}
if (props.installHandler) {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
await props.installHandler(releaseVersion, repoUrl)
return
}
try {
showInstallProgress(
t('plugin.installing', {
@@ -187,6 +197,7 @@ function showPluginDetail() {
{
plugin: props.plugin,
count: props.count,
installHandler: props.installHandler,
},
{
install: () => emit('install'),
+105 -4
View File
@@ -33,6 +33,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
runtimeSettling: {
type: Boolean,
default: false,
},
})
const globalSettingsStore = useGlobalSettingsStore()
@@ -44,6 +48,38 @@ const { t } = useI18n()
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
const hasCardStatus = computed(() => Boolean(props.plugin?.has_update) || hasCardRating.value)
const runtimeStatus = computed(() => props.plugin?.runtime_status)
const runtimePending = computed(
() => props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || ''),
)
const runtimeUnavailable = computed(
() =>
['blocked_by_policy', 'load_failed'].includes(runtimeStatus.value || '') ||
(!props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || '')),
)
const runtimeActionsBlocked = computed(() => runtimePending.value || runtimeUnavailable.value)
const runtimePendingStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = {
source_missing: 'plugin.sourceRestoring',
dependency_pending: 'plugin.dependencyInstalling',
ready: 'plugin.runtimeLoading',
}
const runtimeUnavailableStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = {
source_missing: 'plugin.sourceMissing',
dependency_pending: 'plugin.dependencyPending',
ready: 'plugin.runtimeReady',
blocked_by_policy: 'plugin.blockedByPolicy',
load_failed: 'plugin.runtimeLoadFailed',
}
const showRuntimeStatusDot = computed(() => !runtimeStatus.value || runtimeStatus.value === 'active')
const runtimeStatusDotColor = computed(() => (props.plugin?.state ? 'success' : 'secondary'))
const runtimeStatusText = computed(() => {
const status = runtimeStatus.value
const statusKey = status
? (runtimePending.value ? runtimePendingStatusKeys : runtimeUnavailableStatusKeys)[status]
: undefined
return statusKey ? t(statusKey) : ''
})
const cardRatingValue = computed(() => Number(props.plugin?.average_rating || 0).toFixed(1))
const cardRatingSummary = computed(() =>
t('plugin.ratingSummary', {
@@ -410,6 +446,7 @@ async function visitPluginPage() {
// 打开插件详情
function openPluginDetail() {
if (runtimeActionsBlocked.value) return
if (props.plugin?.has_page) showPluginInfo()
else showPluginConfig()
}
@@ -625,9 +662,12 @@ watch(
:class="{
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
'plugin-card--runtime-blocked': runtimeActionsBlocked,
'plugin-card--runtime-pending': runtimePending,
'plugin-card--runtime-unavailable': runtimeUnavailable,
}"
:style="accentStyle"
:ripple="!props.sortable"
:ripple="!props.sortable && !runtimeActionsBlocked"
>
<div class="plugin-card__banner flex-grow">
<VCardText class="px-2 pt-2 pb-0">
@@ -635,7 +675,13 @@ watch(
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
:class="{ 'plugin-card__title--with-status': hasCardStatus }"
>
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
<VBadge
v-if="showRuntimeStatusDot"
dot
inline
:color="runtimeStatusDotColor"
:aria-label="props.plugin?.state ? t('plugin.running') : t('plugin.disable')"
/>
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
@@ -650,7 +696,7 @@ watch(
class="relative flex-shrink-0 self-center pb-3"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VAvatar size="48">
<VAvatar size="48" class="plugin-card__plugin-icon">
<VImg
ref="imageRef"
:src="iconPath"
@@ -662,6 +708,21 @@ watch(
</VAvatar>
</div>
</div>
<div
v-if="runtimePending || runtimeUnavailable"
class="plugin-card__runtime-state"
:class="{ 'plugin-card__runtime-state--error': runtimeUnavailable }"
role="status"
aria-live="polite"
>
<VProgressCircular v-if="runtimePending" indeterminate size="22" width="2" />
<VIcon
v-else
:icon="runtimeStatus === 'blocked_by_policy' ? 'mdi-shield-lock-outline' : 'mdi-alert-circle-outline'"
size="22"
/>
<span>{{ runtimeStatusText }}</span>
</div>
</div>
<VCardText
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
@@ -692,7 +753,7 @@ watch(
</span>
</div>
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn @click.stop>
<IconBtn class="plugin-card__menu" @click.stop>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
@@ -701,6 +762,7 @@ watch(
v-show="item.show"
:key="i"
:base-color="item.props.color"
:disabled="runtimeActionsBlocked && [1, 2, 4, 8].includes(item.value)"
@click="item.props.click"
>
<template #prepend>
@@ -764,6 +826,45 @@ watch(
font-weight: 600;
}
.plugin-card--runtime-pending {
cursor: progress;
}
.plugin-card--runtime-unavailable {
cursor: not-allowed;
border: var(--app-card-light-border) !important;
}
.plugin-card--runtime-blocked .plugin-card__plugin-icon,
.plugin-card--runtime-blocked .plugin-card__menu {
filter: grayscale(1);
opacity: 0.56;
}
.plugin-card--runtime-blocked .plugin-card__banner {
border-block-end: 0 !important;
}
.plugin-card__runtime-state {
position: absolute !important;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: rgb(var(--v-theme-on-surface));
background: rgba(var(--v-theme-surface), 88%);
font-size: 0.875rem;
font-weight: 600;
inset: 0;
text-align: center;
}
.plugin-card__runtime-state--error {
color: rgb(var(--v-theme-error));
background: rgba(var(--v-theme-surface), 94%);
}
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */
@@ -16,6 +16,7 @@ interface Props {
pluginActions?: { [key: string]: boolean }
showRemoveButton?: boolean
sortable?: boolean
runtimeSettling?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -23,6 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
pluginActions: () => ({}),
showRemoveButton: false,
sortable: false,
runtimeSettling: false,
})
const emit = defineEmits<{
@@ -108,6 +110,7 @@ function handleDropToFolder(event: DragEvent) {
:plugin="item.data"
:action="pluginActions[item.id] || false"
:sortable="sortable"
:runtime-settling="runtimeSettling"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@rating="$emit('rating', $event)"
@@ -133,6 +133,23 @@ describe('PluginAppCard rating badge', () => {
expect(mocks.apiGet).not.toHaveBeenCalled()
})
it('passes the host install handler into market details', async () => {
const installHandler = vi.fn().mockResolvedValue(undefined)
const { container } = await renderWithProviders(PluginAppCard, {
props: { plugin, installHandler },
})
await fireEvent.click(container.querySelector('.v-card')!)
const detailProps = mocks.openSharedDialog.mock.calls[0][1] as {
installHandler?: (...args: unknown[]) => unknown
}
expect(detailProps.installHandler).toBe(installHandler)
await detailProps.installHandler?.()
expect(installHandler).toHaveBeenCalledWith()
expect(mocks.apiGet).not.toHaveBeenCalled()
})
it('installs a selected release with exact parameters and emits completion', async () => {
mocks.apiGet.mockResolvedValue({ success: true })
const lifecyclePlugin = {
@@ -415,4 +415,44 @@ describe('PluginCard lifecycle actions', () => {
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(emitted().actionDone).toHaveLength(1)
})
it('distinguishes a running recovery from a settled unavailable plugin', async () => {
const recovering = await renderWithProviders(PluginCard, {
props: {
plugin: { ...plugin, runtime_status: 'dependency_pending' },
runtimeSettling: true,
},
})
expect(screen.getByText('正在安装插件依赖')).toBeInTheDocument()
await fireEvent.click(recovering.container.querySelector('.v-card')!)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
recovering.unmount()
await renderWithProviders(PluginCard, {
props: {
plugin: { ...plugin, runtime_status: 'dependency_pending' },
runtimeSettling: false,
},
})
expect(screen.getByText('插件依赖未就绪')).toBeInTheDocument()
})
it('uses the host policy and load failure copy for terminal runtime states', async () => {
const blocked = await renderWithProviders(PluginCard, {
props: {
plugin: { ...plugin, runtime_status: 'blocked_by_policy' },
runtimeSettling: false,
},
})
expect(screen.getByText('未通过用户认证,请查看日志')).toBeInTheDocument()
blocked.unmount()
await renderWithProviders(PluginCard, {
props: {
plugin: { ...plugin, runtime_status: 'load_failed' },
runtimeSettling: false,
},
})
expect(screen.getByText('插件加载失败,请查看日志')).toBeInTheDocument()
})
})
@@ -34,6 +34,11 @@ const props = defineProps({
required: true,
},
count: Number,
// 搜索入口交由列表页接管安装,以便先关闭详情并显示插件级加载状态。
installHandler: {
type: Function as PropType<(releaseVersion?: string, repoUrl?: string) => unknown>,
default: undefined,
},
})
// 定义触发的自定义事件
@@ -130,6 +135,14 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!isConfirmed) return
}
if (props.installHandler) {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
await props.installHandler(releaseVersion, repoUrl)
return
}
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
try {
@@ -61,11 +61,12 @@ const ImageStub = defineComponent({
template: '<button data-testid="plugin-image" @contextmenu.prevent="$emit(\'error\')" />',
})
async function renderDialog(plugin: Plugin, stubs: Stubs = {}) {
async function renderDialog(plugin: Plugin, stubs: Stubs = {}, extraProps: Record<string, unknown> = {}) {
return renderWithProviders(PluginMarketDetailDialog, {
props: {
modelValue: true,
plugin,
...extraProps,
},
global: {
components: { VDialogCloseBtn: DialogCloseBtn },
@@ -202,6 +203,18 @@ describe('PluginMarketDetailDialog', () => {
expect(mocks.dialogClose).toHaveBeenCalled()
})
it('delegates a confirmed install without calling the API itself', async () => {
const installHandler = vi.fn().mockResolvedValue(undefined)
const { emitted } = await renderDialog({ ...basePlugin, installed: false }, {}, { installHandler })
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
expect(installHandler).toHaveBeenCalledWith(undefined, undefined)
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
expect(emitted()['update:modelValue']).toContainEqual([false])
})
it('keeps the detail open and emits nothing after a business failure', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
+104 -8
View File
@@ -75,6 +75,8 @@ let lastMeasuredColumnCount = 0
let lastMeasuredColumnWidth = 0
let documentOverlayLocked = false
const scrollRevealGap = 16
const safeGap = computed(() => Math.max(0, props.gap))
const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCount)))
const safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize)))
@@ -721,6 +723,10 @@ function syncProgressiveWindow() {
return
}
// 列表缩短后先裁剪历史渐进窗口,追加数据时仍按批次继续渲染。
progressiveStartIndex.value = clamp(progressiveStartIndex.value, 0, props.items.length)
progressiveEndIndex.value = clamp(progressiveEndIndex.value, 0, props.items.length)
const range = calculatedVisibleRange.value
const viewportRange = calculatedViewportRange.value
const overlapsViewport =
@@ -789,6 +795,78 @@ function getTrackScrollTop() {
return trackRect.top - scrollRect.top + scrollElement.scrollTop
}
function getScrollViewport() {
const target = scrollTarget
if (!target || target === window) {
return {
bottom: window.innerHeight,
top: 0,
}
}
const rect = (target as HTMLElement).getBoundingClientRect()
return {
bottom: rect.bottom,
top: rect.top,
}
}
function getFixedTopInset() {
if (!scrollTarget || typeof document === 'undefined') {
return 0
}
const viewport = getScrollViewport()
const navbar = document.querySelector<HTMLElement>('.layout-navbar')
if (!navbar) {
return 0
}
const navbarStyle = window.getComputedStyle(navbar)
if (navbarStyle.position !== 'fixed' && navbarStyle.position !== 'sticky') {
return 0
}
const navbarRect = navbar.getBoundingClientRect()
if (navbarRect.bottom <= viewport.top || navbarRect.top > viewport.top) {
return 0
}
return clamp(navbarRect.bottom - viewport.top, 0, viewport.bottom - viewport.top)
}
function getMaxScrollTop() {
const target = scrollTarget
if (!target || target === window) {
const scrollingElement = document.scrollingElement || document.documentElement
return Math.max(0, scrollingElement.scrollHeight - window.innerHeight)
}
const element = target as HTMLElement
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function getRevealScrollTop(targetTop: number, itemHeight: number) {
const viewport = getScrollViewport()
const viewportHeight = Math.max(0, viewport.bottom - viewport.top)
const topInset = getFixedTopInset() + scrollRevealGap
const bottomInset = scrollRevealGap
const visibleBottom = Math.max(topInset, viewportHeight - bottomInset - itemHeight)
const targetScrollTop = getTrackScrollTop() + targetTop
// 优先把卡片顶边放在顶栏下方,列表末尾则退化为让卡片底边留在视口内。
const preferredScrollTop = targetScrollTop - topInset
const minimumScrollTop = targetScrollTop - visibleBottom
return clamp(Math.max(preferredScrollTop, minimumScrollTop), 0, getMaxScrollTop())
}
function adjustScrollTop(delta: number) {
if (!scrollTarget || Math.abs(delta) < 0.5) {
return
@@ -805,12 +883,12 @@ function adjustScrollTop(delta: number) {
}
}
function scrollToRelativeTop(top: number) {
function scrollToRelativeTop(top: number, itemHeight: number) {
if (!scrollTarget) {
return
}
const targetTop = getTrackScrollTop() + top
const targetTop = getRevealScrollTop(top, itemHeight)
if (scrollTarget === window) {
window.scrollTo({
@@ -836,8 +914,9 @@ async function revealItem(index: number) {
const row = Math.floor(index / columnCount.value)
const top = rowMetrics.value.offsets[row] ?? 0
const itemHeight = rowMetrics.value.heights[row] ?? estimatedHeight.value
scrollToRelativeTop(top)
scrollToRelativeTop(top, itemHeight)
}
function requestRevealItem(index: number) {
@@ -891,16 +970,33 @@ function pruneMeasurements() {
}
}
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
function preservesLeadingKeys(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!previousKeys.length || !nextKeys.length) {
return false
}
return previousKeys.every((key, index) => key === nextKeys[index])
const sharedLength = Math.min(nextKeys.length, previousKeys.length)
for (let index = 0; index < sharedLength; index += 1) {
if (nextKeys[index] !== previousKeys[index]) {
return false
}
}
return true
}
function hasSameKeySet(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (nextKeys.length !== previousKeys.length) {
return false
}
const previousKeySet = new Set(previousKeys)
return previousKeySet.size === nextKeys.length && nextKeys.every(key => previousKeySet.has(key))
}
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
if (!preservesLeadingKeys(nextKeys, previousKeys) && !hasSameKeySet(nextKeys, previousKeys) && itemHeights.size) {
itemHeights.clear()
heightVersion.value += 1
}
@@ -997,7 +1093,7 @@ onUnmounted(() => {
watch(
itemKeys,
(nextKeys, previousKeys) => {
if (!didKeysAppend(nextKeys, previousKeys)) {
if (!preservesLeadingKeys(nextKeys, previousKeys) && !hasSameKeySet(nextKeys, previousKeys)) {
cancelProgressiveRender()
progressiveStartIndex.value = 0
progressiveEndIndex.value = 0
@@ -49,6 +49,78 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => {
expect(container.querySelector('.progressive-card-grid__track')).toHaveAttribute('data-layout-size-source')
})
it('reveals a target below the fixed navbar on the current viewport', async () => {
const navbar = document.createElement('header')
navbar.className = 'layout-navbar'
navbar.style.position = 'fixed'
navbar.getBoundingClientRect = () =>
({
bottom: 80,
height: 80,
left: 0,
right: 1024,
top: 0,
width: 1024,
}) as DOMRect
document.body.append(navbar)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(4000)
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
render(ProgressiveCardGrid, {
props: {
columns: 1,
estimatedItemHeight: 100,
items: Array.from({ length: 10 }, (_, id) => ({ id })),
scrollToIndex: 3,
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
await waitFor(() => expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', top: 252 }))
navbar.remove()
})
it('keeps a target near the end of a list within the maximum scroll position', async () => {
const navbar = document.createElement('header')
navbar.className = 'layout-navbar'
navbar.style.position = 'fixed'
navbar.getBoundingClientRect = () =>
({
bottom: 112,
height: 112,
left: 0,
right: 1024,
top: 0,
width: 1024,
}) as DOMRect
document.body.append(navbar)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(1500)
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
render(ProgressiveCardGrid, {
props: {
columns: 1,
estimatedItemHeight: 100,
items: Array.from({ length: 10 }, (_, id) => ({ id })),
scrollToIndex: 9,
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
await waitFor(() => expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', top: 700 }))
navbar.remove()
})
})
describe('ProgressiveCardGrid mount scheduling', () => {
@@ -226,4 +298,141 @@ describe('ProgressiveCardGrid mount scheduling', () => {
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(18)
})
it('keeps the rendered window when the same items are reordered', async () => {
const callbacks = new Map<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
callbacks.delete(id)
})
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100)
const flushFrame = async () => {
const frameCallbacks = [...callbacks.values()]
callbacks.clear()
frameCallbacks.forEach(callback => callback(performance.now()))
await nextTick()
}
const items = Array.from({ length: 100 }, (_, id) => ({ id }))
const { container, rerender } = render(ProgressiveCardGrid, {
props: {
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items,
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
await flushFrame()
const renderedBefore = container.querySelectorAll('[data-progressive-grid-index]').length
expect(renderedBefore).toBeGreaterThan(0)
await rerender({
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items: [...items].reverse(),
getItemKey: (item: { id: number }) => item.id,
})
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(renderedBefore)
})
it('keeps existing nodes when items are truncated from the end', async () => {
const callbacks = new Map<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
callbacks.delete(id)
})
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100)
const flushFrame = async () => {
const frameCallbacks = [...callbacks.values()]
callbacks.clear()
frameCallbacks.forEach(callback => callback(performance.now()))
await nextTick()
}
const items = Array.from({ length: 20 }, (_, id) => ({ id }))
const { container, rerender } = render(ProgressiveCardGrid, {
props: {
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items,
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
await flushFrame()
await flushFrame()
await flushFrame()
const nodesBefore = Array.from(container.querySelectorAll('[data-progressive-grid-index]'))
expect(nodesBefore).toHaveLength(16)
await rerender({
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items: items.slice(0, 16),
getItemKey: (item: { id: number }) => item.id,
})
const nodesAfter = Array.from(container.querySelectorAll('[data-progressive-grid-index]'))
expect(nodesAfter).toHaveLength(16)
expect(nodesAfter.every((node, index) => node === nodesBefore[index])).toBe(true)
await rerender({
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items: items.slice(0, 4),
getItemKey: (item: { id: number }) => item.id,
})
const truncatedCount = container.querySelectorAll('[data-progressive-grid-index]').length
expect(truncatedCount).toBeLessThanOrEqual(8)
await rerender({
batchSize: 4,
columns: 4,
estimatedItemHeight: 100,
gap: 0,
initialCount: 4,
items,
getItemKey: (item: { id: number }) => item.id,
})
expect(container.querySelectorAll('[data-progressive-grid-index]').length).toBeLessThanOrEqual(truncatedCount + 4)
await flushFrame()
expect(container.querySelectorAll('[data-progressive-grid-index]').length).toBe(truncatedCount + 8)
})
})
@@ -11,7 +11,7 @@ import QuickAccess from './QuickAccess.vue'
import HeaderTab from './HeaderTab.vue'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.vue'
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { useGlobalSettingsStore, usePluginRuntimeStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { NavMenu } from '@/@layouts/types'
@@ -50,6 +50,7 @@ const showThemeCustomizer = ref(false)
// 用户 Store
const userStore = useUserStore()
const pluginRuntimeStore = usePluginRuntimeStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
const globalSettingsStore = useGlobalSettingsStore()
@@ -432,6 +433,7 @@ function handlePluginClick() {
// 组件卸载时清理监听
onBeforeUnmount(() => {
pluginRuntimeStore.stop()
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
})
@@ -479,6 +481,22 @@ watch([() => pluginSidebarNavStore.items, userPermissions], () => {
if (sidebarMenusMounted) rebuildSidebarMenus()
})
watch(
() => pluginRuntimeStore.reconciliation,
reconciliation => {
if (reconciliation > 0) void pluginSidebarNavStore.ensureSidebarNav(true)
},
)
watch(
() => userStore.superUser,
superUser => {
if (superUser) pluginRuntimeStore.start()
else pluginRuntimeStore.stop()
},
{ immediate: true },
)
onMounted(async () => {
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
@@ -10,6 +10,12 @@ interface SidebarStoreMock {
items: PluginSidebarNavItem[]
}
interface RuntimeStoreMock {
reconciliation: number
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
}
interface UserStoreMock {
permissions: Record<string, unknown>
superUser: boolean
@@ -23,7 +29,10 @@ const mocks = vi.hoisted(() => ({
props: ['item'],
template: '<span data-testid="vertical-nav-link">{{ item.title }}</span>',
},
runtimeStore: undefined as RuntimeStoreMock | undefined,
sidebarStore: undefined as SidebarStoreMock | undefined,
startPluginRuntime: vi.fn(),
stopPluginRuntime: vi.fn(),
userStore: undefined as UserStoreMock | undefined,
verticalNavLayout: { template: '<div><slot name="vertical-nav-content" /></div>' },
}))
@@ -49,6 +58,11 @@ vi.mock('@/stores', async () => {
ensureSidebarNav: mocks.ensureSidebarNav,
items: [] as PluginSidebarNavItem[],
})
mocks.runtimeStore = reactive({
reconciliation: 0,
start: mocks.startPluginRuntime,
stop: mocks.stopPluginRuntime,
})
mocks.userStore = reactive({
permissions: {
admin: false,
@@ -63,6 +77,7 @@ vi.mock('@/stores', async () => {
return {
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
usePluginRuntimeStore: () => mocks.runtimeStore,
usePluginSidebarNavStore: () => mocks.sidebarStore,
useUserStore: () => mocks.userStore,
}
@@ -110,6 +125,9 @@ describe('DefaultLayout', () => {
beforeEach(() => {
mocks.ensureSidebarNav.mockReset()
mocks.ensureSidebarNav.mockResolvedValue(undefined)
mocks.startPluginRuntime.mockReset()
mocks.stopPluginRuntime.mockReset()
mocks.runtimeStore!.reconciliation = 0
mocks.sidebarStore!.items = []
mocks.userStore!.permissions = {
admin: false,
@@ -166,6 +184,24 @@ describe('DefaultLayout', () => {
await flushPromises()
})
it('does not poll the superuser runtime endpoint for an ordinary authenticated user', async () => {
const wrapper = shallowMount(DefaultLayout, {
global: {
renderStubDefaultSlot: true,
stubs: {
IconBtn: mocks.emptyComponent,
RouterLink: mocks.emptyComponent,
VerticalNavLayout: mocks.verticalNavLayout,
VerticalNavLink: mocks.navLink,
},
},
})
await flushPromises()
expect(mocks.startPluginRuntime).not.toHaveBeenCalled()
wrapper.unmount()
})
it('replaces plugin links when the shared snapshot refreshes after mount', async () => {
mocks.sidebarStore!.items = [
{
@@ -207,6 +243,37 @@ describe('DefaultLayout', () => {
expect(wrapper.text()).not.toContain('Old plugin')
})
it('refreshes plugin navigation for the first runtime reconciliation and later generations', async () => {
mocks.userStore!.superUser = true
const wrapper = shallowMount(DefaultLayout, {
global: {
renderStubDefaultSlot: true,
stubs: {
IconBtn: mocks.emptyComponent,
RouterLink: mocks.emptyComponent,
VerticalNavLayout: mocks.verticalNavLayout,
VerticalNavLink: mocks.navLink,
},
},
})
await flushPromises()
expect(mocks.startPluginRuntime).toHaveBeenCalled()
mocks.ensureSidebarNav.mockClear()
mocks.runtimeStore!.reconciliation = 1
await nextTick()
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
mocks.ensureSidebarNav.mockClear()
mocks.runtimeStore!.reconciliation = 2
await nextTick()
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
wrapper.unmount()
expect(mocks.stopPluginRuntime).toHaveBeenCalledTimes(1)
})
it('rebuilds plugin links when permissions change after mount', async () => {
mocks.sidebarStore!.items = [
{
+8
View File
@@ -3721,6 +3721,14 @@ export default {
installingPlugin: 'Installing plugin...',
installing: 'Installing {name} v{version} ...',
installSuccess: 'Plugin {name} installed successfully!',
sourceRestoring: 'Restoring plugin files',
dependencyInstalling: 'Installing plugin dependencies',
runtimeLoading: 'Loading plugin',
sourceMissing: 'Plugin files are missing',
dependencyPending: 'Plugin dependencies are not ready',
runtimeReady: 'Plugin is waiting to load',
blockedByPolicy: 'User authentication failed. Check the logs.',
runtimeLoadFailed: 'Plugin failed to load. Check the logs.',
installFailed: 'Plugin {name} installation failed: {message}',
filterPlugins: 'Filter Plugins',
name: 'Name',
+8
View File
@@ -3660,6 +3660,14 @@ export default {
installingPlugin: '正在安装插件...',
installing: '正在安装 {name} v{version} ...',
installSuccess: '插件 {name} 安装成功!',
sourceRestoring: '正在恢复插件文件',
dependencyInstalling: '正在安装插件依赖',
runtimeLoading: '正在加载插件',
sourceMissing: '插件文件缺失',
dependencyPending: '插件依赖未就绪',
runtimeReady: '插件等待加载',
blockedByPolicy: '未通过用户认证,请查看日志',
runtimeLoadFailed: '插件加载失败,请查看日志',
installFailed: '插件 {name} 安装失败:{message}',
filterPlugins: '过滤插件',
name: '名称',
+8
View File
@@ -3658,6 +3658,14 @@ export default {
installingPlugin: '正在安装插件...',
installing: '正在安装 {name} v{version} ...',
installSuccess: '插件 {name} 安装成功!',
sourceRestoring: '正在恢復插件文件',
dependencyInstalling: '正在安裝插件依賴',
runtimeLoading: '正在載入插件',
sourceMissing: '插件文件缺失',
dependencyPending: '插件依賴未就緒',
runtimeReady: '插件等待載入',
blockedByPolicy: '未通過用戶認證,請查看日誌',
runtimeLoadFailed: '插件載入失敗,請查看日誌',
installFailed: '插件 {name} 安装失败:{message}',
filterPlugins: '過濾插件',
name: '名稱',
+19
View File
@@ -1,4 +1,5 @@
import DashboardPage from '@/pages/dashboard.vue'
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'
@@ -286,6 +287,24 @@ describe('dashboard page initial layout', () => {
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
})
it('reloads plugin dashboard metadata when the active runtime generation changes', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === '/user/config/DashboardOrder' || url === '/user/config/Dashboard') return { data: {} }
if (url === '/user/config/DashboardGridLayout') return { data: {} }
if (url === '/plugin/dashboard/meta') return []
throw new Error('Unexpected GET ' + url)
})
const { pinia } = await renderDashboard()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/plugin/dashboard/meta'))
mocks.apiGet.mockClear()
const runtimeStore = usePluginRuntimeStore(pinia)
runtimeStore.reconciliation = 1
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/plugin/dashboard/meta'))
})
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
mocks.themeName.value = 'glass'
mocks.apiGet.mockImplementation((url: string) => {
+11 -1
View File
@@ -10,7 +10,7 @@ import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useD
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import { usePluginRuntimeStore, useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useDisplay, useTheme } from 'vuetify'
@@ -26,6 +26,7 @@ const { appMode } = usePWA()
const display = useDisplay()
const vuetifyTheme = useTheme()
const userStore = useUserStore()
const pluginRuntimeStore = usePluginRuntimeStore()
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery'))
@@ -1673,6 +1674,15 @@ watch(
},
)
watch(
() => pluginRuntimeStore.reconciliation,
reconciliation => {
if (reconciliation > 0 && isDashboardConfigLoaded.value && isRequest.value && route.path === '/dashboard') {
void getPluginDashboardMeta()
}
},
)
hydrateDashboardConfigFromLocal()
onBeforeMount(async () => {
+108
View File
@@ -0,0 +1,108 @@
import type { PluginRuntimeSummary } from '@/api/types'
import { usePluginRuntimeStore } from '@/stores/pluginRuntime'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: apiMocks.get,
}),
}))
function createSummary(overrides: Partial<PluginRuntimeSummary> = {}): PluginRuntimeSummary {
return {
failed_count: 0,
generation: 1,
pending_count: 0,
ready: true,
...overrides,
}
}
describe('plugin runtime store', () => {
let store: ReturnType<typeof usePluginRuntimeStore>
beforeEach(() => {
setActivePinia(createPinia())
apiMocks.get.mockReset()
store = usePluginRuntimeStore()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
store.stop()
vi.useRealTimers()
})
it('reconciles the first snapshot and each later generation exactly once', async () => {
apiMocks.get
.mockResolvedValueOnce(createSummary({ generation: 4 }))
.mockResolvedValueOnce(createSummary({ generation: 4 }))
.mockResolvedValueOnce(createSummary({ generation: 5 }))
await store.refresh()
expect(store.reconciliation).toBe(1)
expect(store.summary?.generation).toBe(4)
await store.refresh()
expect(store.reconciliation).toBe(1)
await store.refresh()
expect(store.reconciliation).toBe(2)
expect(store.summary?.generation).toBe(5)
})
it('does not let an older backend generation replace the current snapshot', async () => {
apiMocks.get
.mockResolvedValueOnce(createSummary({ generation: 8, ready: true }))
.mockResolvedValueOnce(createSummary({ generation: 7, pending_count: 1, ready: false }))
await store.refresh()
await store.refresh()
expect(store.summary).toEqual(createSummary({ generation: 8, ready: true }))
expect(store.reconciliation).toBe(1)
})
it('polls pending runtime state quickly and settled state at a lower frequency', async () => {
vi.useFakeTimers()
apiMocks.get
.mockResolvedValueOnce(createSummary({ generation: 1, pending_count: 1, ready: false }))
.mockResolvedValueOnce(createSummary({ generation: 2, ready: true }))
.mockResolvedValueOnce(createSummary({ generation: 2, ready: true }))
store.start()
await vi.advanceTimersByTimeAsync(0)
expect(apiMocks.get).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2000)
expect(apiMocks.get).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(14999)
expect(apiMocks.get).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(apiMocks.get).toHaveBeenCalledTimes(3)
})
it('invalidates a pending response when the authenticated layout stops', async () => {
let resolveSummary!: (summary: PluginRuntimeSummary) => void
apiMocks.get.mockReturnValueOnce(
new Promise<PluginRuntimeSummary>(resolve => {
resolveSummary = resolve
}),
)
store.start()
store.stop()
resolveSummary(createSummary({ generation: 9 }))
await Promise.resolve()
await Promise.resolve()
expect(store.summary).toBeNull()
expect(store.reconciliation).toBe(0)
})
})
+2 -1
View File
@@ -14,5 +14,6 @@ import { useAuthStore } from './auth'
import { useUserStore } from './user'
import { useGlobalSettingsStore } from './global'
import { usePluginSidebarNavStore } from './pluginSidebarNav'
import { usePluginRuntimeStore } from './pluginRuntime'
export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore }
export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore, usePluginRuntimeStore }
+97
View File
@@ -0,0 +1,97 @@
import { defineStore } from 'pinia'
import api from '@/api'
import type { PluginRuntimeSummary } from '@/api/types'
const SETTLING_POLL_INTERVAL = 2000
const SETTLED_POLL_INTERVAL = 15000
/**
*
*/
export const usePluginRuntimeStore = defineStore('pluginRuntime', {
state: () => ({
summary: null as PluginRuntimeSummary | null,
/** 首次取得摘要及后续代际变化都会递增,消费者无需推断初始代际值。 */
reconciliation: 0,
active: false,
inflight: null as Promise<void> | null,
requestGeneration: 0,
pollTimer: undefined as ReturnType<typeof setTimeout> | undefined,
}),
actions: {
start() {
if (this.active) return
this.active = true
document.addEventListener('visibilitychange', this.handleVisibilityChange)
void this.refresh()
},
stop() {
const wasActive = this.active
this.active = false
this.requestGeneration++
this.clearPollTimer()
if (wasActive) document.removeEventListener('visibilitychange', this.handleVisibilityChange)
this.summary = null
this.inflight = null
},
async refresh(): Promise<void> {
if (this.inflight) return this.inflight
const requestGeneration = ++this.requestGeneration
const request = this.fetchSummary(requestGeneration)
this.inflight = request
return request
},
async fetchSummary(requestGeneration: number): Promise<void> {
try {
const summary = await api.get<PluginRuntimeSummary>('plugin/runtime', { feedback: 'silent' })
if (requestGeneration !== this.requestGeneration) return
const previousGeneration = this.summary?.generation
if (previousGeneration !== undefined && summary.generation < previousGeneration) return
this.summary = summary
if (previousGeneration === undefined || summary.generation !== previousGeneration) {
this.reconciliation++
}
} catch (error) {
if (requestGeneration === this.requestGeneration) console.error(error)
} finally {
if (requestGeneration === this.requestGeneration) {
this.inflight = null
this.schedulePoll()
}
}
},
schedulePoll() {
this.clearPollTimer()
if (!this.active || document.hidden) return
const interval = this.summary?.ready === false ? SETTLING_POLL_INTERVAL : SETTLED_POLL_INTERVAL
this.pollTimer = setTimeout(() => {
this.pollTimer = undefined
void this.refresh()
}, interval)
},
clearPollTimer() {
if (this.pollTimer === undefined) return
clearTimeout(this.pollTimer)
this.pollTimer = undefined
},
handleVisibilityChange() {
if (document.hidden) {
this.clearPollTimer()
return
}
void this.refresh()
},
},
})
+284 -93
View File
@@ -12,7 +12,7 @@ import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import { usePluginRuntimeStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
//
@@ -20,6 +20,7 @@ const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const pluginRuntimeStore = usePluginRuntimeStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
/** 用户保存的插件与文件夹混合顺序。 */
@@ -51,7 +52,7 @@ const PluginFolderCreateDialog = defineAsyncComponent(() => import('@/components
const PluginMarketSettingDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginMarketSettingDialog.vue'),
)
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
// APP
@@ -141,6 +142,7 @@ registerHeaderTab({
// ID
const pluginId = ref(route.query.id)
const installScrollPluginId = ref<string | null>(null)
//
const activeSort = ref<PluginSortKey | null>(null)
@@ -179,7 +181,16 @@ const marketList = ref<Plugin[]>([])
const sortedUninstalledList = ref<Plugin[]>([])
//
const displayUninstalledList = ref<Plugin[]>([])
const marketPageSize = 20
const marketVisibleCount = ref(marketPageSize)
const displayUninstalledList = computed(() => sortedUninstalledList.value.slice(0, marketVisibleCount.value))
//
const tabScrollPositions: Record<'installed' | 'market', number> = {
installed: 0,
market: 0,
}
let tabScrollRestoreGeneration = 0
//
const isRefreshed = ref(false)
@@ -204,12 +215,15 @@ const PluginRatings = ref<{ [key: string]: PluginRating }>({})
//
const isMarketRefreshing = ref(false)
const pluginRuntimeSummary = computed(() => pluginRuntimeStore.summary)
const installingPluginIds = ref(new Set<string>())
const isPluginPageActive = ref(false)
// writer Promise
let installedWriterGeneration = 0
let marketWriterGeneration = 0
let ratingWriterGeneration = 0
let statisticWriterGeneration = 0
//
const keyword = ref('')
@@ -219,10 +233,7 @@ const pluginActions: Ref<{ [key: string]: boolean }> = ref({})
//
const $toast = useToast()
//
const progressText = ref(t('plugin.installingPlugin'))
let folderCreateDialogController: ReturnType<typeof openSharedDialog> | null = null
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let searchDialogController: ReturnType<typeof openSharedDialog> | null = null
//
@@ -353,11 +364,14 @@ const canDragSort = computed(() => sortMode.value && activeTab.value === 'instal
const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
const installedScrollToIndex = computed(() => {
if (sortMode.value || currentFolder.value || !pluginId.value) {
if (sortMode.value || currentFolder.value) {
return undefined
}
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
const targetPluginId = installScrollPluginId.value || pluginId.value
if (!targetPluginId) return undefined
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === targetPluginId)
return targetIndex >= 0 ? targetIndex : undefined
})
@@ -617,6 +631,7 @@ function sortPluginOrder() {
if (dataList.value.length === 0) {
return
}
// PluginOrder
dataList.value.sort((a, b) => {
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
@@ -836,47 +851,66 @@ function pluginDialogClose() {
PluginAppDialog.value = false
}
//
function openPluginProgressDialog(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
//
function closePluginProgressDialog() {
progressDialogController?.close()
progressDialogController = null
}
//
async function installPlugin(item: Plugin) {
if (item?.system_version_compatible === false) {
async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: string) {
const pluginId = item?.id
if (!pluginId || installingPluginIds.value.has(pluginId)) {
return
}
if (!releaseVersion && item?.system_version_compatible === false) {
$toast.error(item.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
//
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
openPluginProgressDialog(progressText.value)
const previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id)
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
sortMode.value = false
currentFolder.value = ''
installedFilter.value = null
hasUpdateFilter.value = false
enabledFilter.value = false
tabScrollPositions.installed = 0
installScrollPluginId.value = pluginId
installingPluginIds.value = new Set([...installingPluginIds.value, pluginId])
dataList.value = [
...dataList.value.filter(plugin => plugin.id !== pluginId),
{
...item,
installed: true,
state: false,
runtime_status: 'source_missing',
},
]
activeTab.value = 'installed'
pluginDialogClose()
await api.get(`plugin/install/${item?.id}`, {
let installed = false
try {
await api.get(`plugin/install/${pluginId}`, {
params: {
repo_url: item?.repo_url,
force: item?.has_update,
repo_url: repoUrl || item?.repo_url,
release_version: releaseVersion,
force: item?.has_update || Boolean(releaseVersion),
},
feedback: 'silent',
})
installed = true
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
//
hasUpdateFilter.value = false
enabledFilter.value = false
installedFilter.value = null
//
await refreshData()
await fetchInstalledPlugins({ silent: true })
if (userStore.superUser) await pluginRuntimeStore.refresh()
await pluginSidebarNavStore.ensureSidebarNav(true)
await nextTick()
if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null
} catch (error) {
const pending = new Set(installingPluginIds.value)
pending.delete(pluginId)
installingPluginIds.value = pending
const nextData = dataList.value.filter(plugin => plugin.id !== pluginId)
if (previousPlugin) nextData.splice(Math.min(previousIndex, nextData.length), 0, previousPlugin)
dataList.value = nextData
if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null
console.error(error)
$toast.error(
t('plugin.installFailed', {
@@ -884,20 +918,40 @@ async function installPlugin(item: Plugin) {
message: error instanceof Error ? error.message : '',
}),
)
//
void fetchInstalledPlugins({ silent: true })
} finally {
closePluginProgressDialog()
if (!installed) {
const pending = new Set(installingPluginIds.value)
pending.delete(pluginId)
installingPluginIds.value = pending
}
}
}
/** 打开未安装插件的市场详情,确认后才进入安装流程。 */
function openPluginMarketDetail(item: Plugin) {
openSharedDialog(
PluginMarketDetailDialog,
{
plugin: item,
count: PluginStatistics.value[item.id || '0'],
installHandler: (releaseVersion?: string, repoUrl?: string) => installPlugin(item, releaseVersion, repoUrl),
},
{
install: pluginInstalled,
},
{ closeOn: ['close', 'install', 'update:modelValue'] },
)
}
//
function openPlugin(item: Plugin) {
//
if (item.installed === true) {
//
//
pluginActions.value[item.id || '0'] = true
} else {
//
installPlugin(item)
openPluginMarketDetail(item)
}
closeSearchDialog()
}
@@ -923,7 +977,7 @@ const filterPlugins = computed(() => {
})
//
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}): Promise<boolean> {
const generation = ++installedWriterGeneration
if (!context.silent || !isRefreshed.value) {
installedLoadError.value = false
@@ -935,23 +989,59 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
state: 'installed',
},
})
if (generation !== installedWriterGeneration) return
if (generation !== installedWriterGeneration) return false
mergeRatingsIntoPlugins(installedPlugins)
dataList.value = installedPlugins
const previousById = new Map([...uninstalledList.value, ...dataList.value].map(plugin => [plugin.id, plugin]))
const mergedPlugins = installedPlugins.map(plugin => {
const previous = previousById.get(plugin.id)
const isRuntimePlaceholder = plugin.plugin_name === plugin.id && !plugin.plugin_version
return {
...(previous || {}),
...plugin,
...(isRuntimePlaceholder && previous
? {
plugin_name: previous.plugin_name,
plugin_desc: previous.plugin_desc,
plugin_icon: previous.plugin_icon,
plugin_version: previous.plugin_version,
plugin_author: previous.plugin_author,
author_url: previous.author_url,
repo_url: previous.repo_url,
}
: {}),
}
})
const serverIds = new Set(mergedPlugins.map(plugin => plugin.id))
const optimisticPlugins = dataList.value.filter(
plugin => installingPluginIds.value.has(plugin.id) && !serverIds.has(plugin.id),
)
if (installingPluginIds.value.size > 0) {
const pending = new Set(installingPluginIds.value)
serverIds.forEach(pluginId => pending.delete(pluginId))
installingPluginIds.value = pending
}
mergeRatingsIntoPlugins(mergedPlugins)
dataList.value = [...mergedPlugins, ...optimisticPlugins]
mergeMarketMetadataIntoInstalled()
//
sortPluginOrder()
isRefreshed.value = true
installedLoadError.value = false
return true
} catch (error) {
console.error(error)
if (generation === installedWriterGeneration && !isRefreshed.value) {
installedLoadError.value = true
}
return false
}
}
function isPluginRuntimeSettling(pluginId: string) {
return pluginRuntimeSummary.value?.ready === false || installingPluginIds.value.has(pluginId)
}
/** 将市场更新元数据投影到当前已安装快照。 */
function mergeMarketMetadataIntoInstalled() {
const marketById = new Map(
@@ -970,8 +1060,37 @@ function mergeMarketMetadataIntoInstalled() {
})
}
interface PluginMarketMetrics {
statistics?: { [key: string]: number }
ratings?: { [key: string]: PluginRating }
}
type CompletePluginMarketMetrics = Required<PluginMarketMetrics>
/** 将市场快照一次性投影到列表和过滤选项,避免请求过程暴露半成品状态。 */
function applyMarketSnapshot(marketResponse: Plugin[], metrics?: CompletePluginMarketMetrics) {
if (metrics) {
PluginStatistics.value = metrics.statistics
PluginRatings.value = metrics.ratings
}
mergeRatingsIntoPlugins(marketResponse, metrics?.ratings)
uninstalledList.value = marketResponse
mergeMarketMetadataIntoInstalled()
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
authorFilterOptions.value = []
labelFilterOptions.value = []
repoFilterOptions.value = []
marketList.value.forEach(initOptions)
isAppMarketLoaded.value = true
marketLoadError.value = false
}
//
async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliveRefreshContext = {}) {
async function fetchUninstalledPlugins(
force: boolean = false,
context: KeepAliveRefreshContext = {},
commit = true,
): Promise<Plugin[] | undefined> {
const generation = ++marketWriterGeneration
if (!context.silent || !isAppMarketLoaded.value) {
marketLoadError.value = false
@@ -986,52 +1105,38 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
})
if (generation !== marketWriterGeneration) return
mergeRatingsIntoPlugins(marketResponse)
uninstalledList.value = marketResponse
mergeMarketMetadataIntoInstalled()
//
// ""
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
//
authorFilterOptions.value = []
labelFilterOptions.value = []
repoFilterOptions.value = []
marketList.value.forEach(initOptions)
// APP
isAppMarketLoaded.value = true
marketLoadError.value = false
if (commit) applyMarketSnapshot(marketResponse)
return marketResponse
} catch (error) {
console.error(error)
if (generation === marketWriterGeneration && !isAppMarketLoaded.value) {
marketLoadError.value = true
}
return undefined
}
}
//
async function getPluginStatistics() {
async function fetchPluginStatistics(): Promise<{ [key: string]: number } | undefined> {
const generation = ++statisticWriterGeneration
try {
const statistics = await api.get<Record<string, number>, Record<string, number>>('plugin/statistic')
if (generation === statisticWriterGeneration) {
PluginStatistics.value = statistics
}
return generation === statisticWriterGeneration ? statistics : undefined
} catch (error) {
console.error(error)
return undefined
}
}
/** 批量加载插件评分并合并到已安装和市场插件对象。 */
async function getPluginRatings() {
async function fetchPluginRatings(
plugins: Plugin[] = marketList.value,
marketGeneration = marketWriterGeneration,
): Promise<{ [key: string]: PluginRating } | undefined> {
const generation = ++ratingWriterGeneration
const pluginIds = Array.from(
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
)
const pluginIds = Array.from(new Set([...dataList.value, ...plugins].map(plugin => plugin.id).filter(Boolean)))
if (pluginIds.length === 0) {
if (generation === ratingWriterGeneration) {
PluginRatings.value = {}
}
return
return generation === ratingWriterGeneration ? {} : undefined
}
try {
@@ -1047,21 +1152,40 @@ async function getPluginRatings() {
}
const currentPluginIds = Array.from(
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
new Set([...dataList.value, ...plugins].map(plugin => plugin.id).filter(Boolean)),
)
if (generation !== ratingWriterGeneration || currentPluginIds.join('\0') !== pluginIds.join('\0')) return
if (
generation !== ratingWriterGeneration ||
marketGeneration !== marketWriterGeneration ||
currentPluginIds.join('\0') !== pluginIds.join('\0')
)
return
PluginRatings.value = ratings
mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
return ratings
} catch (error) {
console.error(error)
return undefined
}
}
/** 下载量与评分属于同一份市场指标快照,始终在同一刷新时机加载。 */
async function getPluginMarketMetrics() {
await Promise.all([getPluginStatistics(), getPluginRatings()])
async function getPluginMarketMetrics(
plugins: Plugin[] = marketList.value,
commit = true,
): Promise<PluginMarketMetrics> {
const marketGeneration = marketWriterGeneration
const [statistics, ratings] = await Promise.all([
fetchPluginStatistics(),
fetchPluginRatings(plugins, marketGeneration),
])
if (commit && statistics !== undefined && ratings !== undefined) {
PluginStatistics.value = statistics
PluginRatings.value = ratings
mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
}
return { statistics, ratings }
}
/** 新列表写入前复用最近一次评分快照,避免静默刷新期间评分闪烁。 */
@@ -1107,6 +1231,33 @@ async function refreshData(context: KeepAliveRefreshContext = {}) {
await loadPluginFolders()
}
/** 只有列表、评分和统计均来自同一轮请求时才发布市场快照。 */
async function refreshMarketData(
force = false,
context: KeepAliveRefreshContext = {},
resetScroll = false,
): Promise<boolean> {
const marketResponse = await fetchUninstalledPlugins(force, context, false)
if (!marketResponse) return false
const metrics = await getPluginMarketMetrics(marketResponse, false)
if (metrics.statistics === undefined || metrics.ratings === undefined) {
if (!isAppMarketLoaded.value) marketLoadError.value = true
console.warn('插件市场指标快照不完整,保留上一份市场数据')
return false
}
applyMarketSnapshot(marketResponse, {
statistics: metrics.statistics,
ratings: metrics.ratings,
})
if (resetScroll) {
marketVisibleCount.value = marketPageSize
tabScrollPositions.market = 0
}
return true
}
// uninstalledListsortedUninstalledList
watch([marketList, filterForm, activeSort, PluginStatistics, PluginRatings], () => {
//
@@ -1166,8 +1317,11 @@ watch([marketList, filterForm, activeSort, PluginStatistics, PluginRatings], ()
})
}
// 20
displayUninstalledList.value = sortedUninstalledList.value.splice(0, 20)
//
marketVisibleCount.value = Math.max(
marketPageSize,
Math.min(marketVisibleCount.value, sortedUninstalledList.value.length),
)
})
//
@@ -1189,8 +1343,11 @@ async function refreshMarket() {
isMarketRefreshing.value = true
try {
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
await getPluginMarketMetrics()
const refreshed = await refreshMarketData(true, { silent: false, source: 'manual' }, true)
if (!refreshed) return
await nextTick()
window.scrollTo({ behavior: 'auto', top: 0 })
} catch (error) {
console.error(error)
} finally {
@@ -1251,23 +1408,23 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
//
function loadMarketMore({ done }: { done: (status: 'ok' | 'empty' | 'loading' | 'error') => void }) {
// dataList 20
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
if (itemsToMove.length === 0) {
if (marketVisibleCount.value >= sortedUninstalledList.value.length) {
done('empty')
return
}
displayUninstalledList.value.push(...itemsToMove)
marketVisibleCount.value = Math.min(marketVisibleCount.value + marketPageSize, sortedUninstalledList.value.length)
done('ok')
}
//
onMounted(async () => {
isPluginPageActive.value = true
await loadPluginOrderConfig()
await loadPluginFolders() //
await refreshData()
if (userStore.superUser) await pluginRuntimeStore.refresh()
if (activeTab.value != 'market' && pluginId.value) {
//
const plugin = dataList.value.find(item => item.id === pluginId.value)
@@ -1277,16 +1434,41 @@ onMounted(async () => {
}
})
const { refresh: refreshKeepAliveData } = useKeepAliveRefresh(refreshActiveTabData)
useKeepAliveRefresh(refreshActiveTabData)
watch(activeTab, (newTab, oldTab) => {
if (!oldTab || newTab === oldTab) return
if (!oldTab || newTab === oldTab || (newTab !== 'installed' && newTab !== 'market')) return
refreshKeepAliveData({ silent: true, source: 'tab' })
if (oldTab === 'installed' || oldTab === 'market') {
tabScrollPositions[oldTab] = window.scrollY
}
const generation = ++tabScrollRestoreGeneration
void nextTick().then(() => {
if (generation !== tabScrollRestoreGeneration) return
window.scrollTo({ behavior: 'auto', top: tabScrollPositions[newTab] })
})
})
watch(
() => pluginRuntimeStore.reconciliation,
() => {
if (isPluginPageActive.value && activeTab.value === 'installed' && !document.hidden) {
void fetchInstalledPlugins({ silent: true })
}
},
)
onActivated(() => {
isPluginPageActive.value = true
})
onDeactivated(() => {
isPluginPageActive.value = false
})
onUnmounted(() => {
closePluginProgressDialog()
isPluginPageActive.value = false
folderCreateDialogController?.close()
searchDialogController?.close()
})
@@ -1977,6 +2159,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:item="element"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)"
:sortable="true"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@@ -2006,6 +2189,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:item="item"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)"
:sortable="false"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@@ -2041,6 +2225,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)"
:sortable="true"
:show-remove-button="true"
@refresh-data="refreshData"
@@ -2066,6 +2251,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)"
:sortable="false"
:show-remove-button="true"
@refresh-data="refreshData"
@@ -2127,12 +2313,17 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
<ProgressiveCardGrid
v-if="displayUninstalledList.length > 0"
:items="displayUninstalledList"
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="260"
>
<template #default="{ item }">
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
<PluginAppCard
:plugin="item"
:count="PluginStatistics[item.id || '0']"
:install-handler="(releaseVersion, repoUrl) => installPlugin(item, releaseVersion, repoUrl)"
@install="pluginInstalled"
/>
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
@@ -1,7 +1,8 @@
import type { Plugin, PluginRating } from '@/api/types'
import type { Plugin, PluginRating, PluginRuntimeSummary } from '@/api/types'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { usePluginRuntimeStore } from '@/stores/pluginRuntime'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
@@ -18,6 +19,7 @@ const apiUrls = {
list: new URL('plugin/', API_BASE_URL).href,
order: new URL('user/config/PluginOrder', API_BASE_URL).href,
rating: new URL('plugin/rating', API_BASE_URL).href,
runtime: new URL('plugin/runtime', API_BASE_URL).href,
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
statistic: new URL('plugin/statistic', API_BASE_URL).href,
}
@@ -121,11 +123,13 @@ const ProgressiveCardGridStub = defineComponent({
props: {
getItemKey: { type: Function as PropType<(item: unknown) => string>, default: undefined },
items: { type: Array as PropType<unknown[]>, required: true },
scrollToIndex: { type: Number, default: undefined },
},
setup(props, { slots }) {
return () =>
h(
'section',
{ 'data-scroll-to-index': props.scrollToIndex ?? '' },
props.items.flatMap(item => {
props.getItemKey?.(item)
return slots.default?.({ item }) ?? []
@@ -139,6 +143,7 @@ const PluginMixedSortCardStub = defineComponent({
props: {
item: { type: Object as PropType<Record<string, unknown>>, required: true },
pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) },
runtimeSettling: Boolean,
sortable: Boolean,
},
emits: [
@@ -163,6 +168,7 @@ const PluginMixedSortCardStub = defineComponent({
has_update?: boolean
plugin_name?: string
repo_url?: string
runtime_status?: Plugin['runtime_status']
}
| undefined
const name = type === 'folder' ? id : data?.plugin_name || id
@@ -176,6 +182,8 @@ const PluginMixedSortCardStub = defineComponent({
? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false))
: h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''),
type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null,
type === 'plugin' ? h('output', { 'aria-label': `runtime-${id}` }, data?.runtime_status || '') : null,
type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : null,
type === 'plugin'
? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? ''))
: null,
@@ -243,14 +251,24 @@ const PluginMixedSortCardStub = defineComponent({
const PluginAppCardStub = defineComponent({
name: 'PluginAppCard',
props: { plugin: { type: Object as PropType<Plugin>, required: true } },
props: {
plugin: { type: Object as PropType<Plugin>, required: true },
installHandler: Function as PropType<() => unknown>,
},
emits: ['install'],
setup(props, { emit }) {
return () =>
h('article', { 'data-testid': `market-${props.plugin.id}` }, [
h('span', `market:${props.plugin.plugin_name}`),
h('output', { 'aria-label': `rating-${props.plugin.id}` }, String(props.plugin.average_rating ?? '')),
h('button', { onClick: () => emit('install'), type: 'button' }, `installed-${props.plugin.id}`),
h(
'button',
{
onClick: () => (props.installHandler ? props.installHandler() : emit('install')),
type: 'button',
},
`installed-${props.plugin.id}`,
),
])
},
})
@@ -360,6 +378,7 @@ interface ListResponses {
marketStatus?: number
order?: unknown[]
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
runtime?: () => PluginRuntimeSummary | Promise<PluginRuntimeSummary>
statistic?: () => Record<string, number> | Promise<Record<string, number>>
}
@@ -377,6 +396,16 @@ function registerListHandlers(responses: ListResponses = {}) {
})
}),
http.get(apiUrls.statistic, async () => HttpResponse.json((await responses.statistic?.()) ?? {})),
http.get(apiUrls.runtime, async () =>
HttpResponse.json(
(await responses.runtime?.()) ?? {
failed_count: 0,
generation: 0,
pending_count: 0,
ready: true,
},
),
),
http.get(apiUrls.sidebar, () => HttpResponse.json([])),
http.get(apiUrls.rating, async ({ request }) => {
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
@@ -385,14 +414,14 @@ function registerListHandlers(responses: ListResponses = {}) {
)
}
async function renderList(responses: ListResponses = {}) {
async function renderList(responses: ListResponses = {}, options: { superUser?: boolean } = {}) {
registerListHandlers(responses)
return renderWithProviders(PluginCardListView, {
initialRoute: '/plugins',
initialState: {
user: {
permissions: DEFAULT_PERMISSIONS,
superUser: true,
superUser: options.superUser ?? true,
},
},
global: {
@@ -448,6 +477,12 @@ function getDialogEvents(index = -1) {
return call[2] as Record<string, (...args: unknown[]) => unknown>
}
function getDialogProps(index = -1) {
const call = mocks.openSharedDialog.mock.calls.at(index)
if (!call) throw new Error('未打开共享弹窗')
return call[1] as { plugin?: Plugin; installHandler?: (...args: unknown[]) => unknown }
}
async function waitForRequestsToFinish() {
await new Promise(resolve => setTimeout(resolve, 0))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
@@ -734,6 +769,42 @@ describe('PluginCardListView loading and request ownership', () => {
await waitForRequestsToFinish()
})
it('refreshes only the installed snapshot when the shared runtime generation changes', async () => {
let installedRequests = 0
let marketRequests = 0
const { pinia } = await renderList({
installed: () => {
installedRequests += 1
return [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })]
},
market: () => {
marketRequests += 1
return [createPlugin({ id: 'Market', plugin_name: '市场插件' })]
},
})
await waitForRequestsToFinish()
const initialInstalledRequests = installedRequests
const initialMarketRequests = marketRequests
const runtimeStore = usePluginRuntimeStore(pinia)
runtimeStore.reconciliation = 1
await waitFor(() => expect(installedRequests).toBe(initialInstalledRequests + 1))
expect(marketRequests).toBe(initialMarketRequests)
await waitForRequestsToFinish()
})
it('does not request the superuser runtime summary for an ordinary administrator', async () => {
server.use(
http.get(apiUrls.runtime, () => {
throw new Error('ordinary administrator must not request plugin runtime')
}),
)
await renderList({}, { superUser: false })
await waitForRequestsToFinish()
})
it('leaves an initial market failure in a retryable error state instead of permanent loading', async () => {
await renderList({
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
@@ -904,6 +975,131 @@ describe('PluginCardListView market filtering and pagination', () => {
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
await waitForRequestsToFinish()
})
it('preserves loaded market pages when switching tabs without refetching', async () => {
const market = Array.from({ length: 45 }, (_, index) =>
createPlugin({
id: `Market-${index}`,
plugin_name: `市场插件 ${index}`,
repo_url: 'https://github.com/example/repo',
}),
)
let marketRequests = 0
await renderList({
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
market: () => {
marketRequests += 1
return market
},
})
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20))
await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' }))
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(40))
expect(marketRequests).toBe(1)
getHeaderConfig().modelValue.value = 'installed'
await nextTick()
getHeaderConfig().modelValue.value = 'market'
await nextTick()
expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(40)
expect(screen.getByText('market:市场插件 39')).toBeInTheDocument()
expect(marketRequests).toBe(1)
getHeaderButton('mdi-refresh').action?.()
await waitFor(() => expect(marketRequests).toBe(2))
await waitForRequestsToFinish()
expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20)
expect(screen.queryByText('market:市场插件 39')).not.toBeInTheDocument()
})
it('restores each tab window scroll position after switching tabs', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
let currentScrollTop = 0
vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => currentScrollTop)
await renderList({
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
market: () => Array.from({ length: 25 }, (_, index) => createPlugin({ id: `Market-${index}` })),
})
await waitForRequestsToFinish()
getHeaderConfig().modelValue.value = 'market'
await nextTick()
currentScrollTop = 1800
getHeaderConfig().modelValue.value = 'installed'
await nextTick()
currentScrollTop = 0
getHeaderConfig().modelValue.value = 'market'
await nextTick()
await waitFor(() => expect(scrollTo).toHaveBeenLastCalledWith({ behavior: 'auto', top: 1800 }))
scrollTo.mockRestore()
})
it('keeps the previous market snapshot until a manual refresh is complete', async () => {
const refreshedMarket = createDeferred<Plugin[]>()
const refreshedStatistics = createDeferred<Record<string, number>>()
let marketRequests = 0
let statisticRequests = 0
await renderList({
market: () => {
marketRequests += 1
return marketRequests === 1
? [createPlugin({ id: 'BeforeRefresh', plugin_name: '刷新前插件' })]
: refreshedMarket.promise
},
statistic: () => {
statisticRequests += 1
return statisticRequests === 1 ? {} : refreshedStatistics.promise
},
})
expect(await screen.findByText('market:刷新前插件')).toBeInTheDocument()
const refresh = getHeaderButton('mdi-refresh').action
if (!refresh) throw new Error('未注册市场刷新操作')
void refresh()
await waitFor(() => expect(marketRequests).toBe(2))
refreshedMarket.resolve([createPlugin({ id: 'AfterRefresh', plugin_name: '刷新后插件' })])
await waitFor(() => expect(statisticRequests).toBe(2))
await nextTick()
expect(screen.getByText('market:刷新前插件')).toBeInTheDocument()
expect(screen.queryByText('market:刷新后插件')).not.toBeInTheDocument()
refreshedStatistics.resolve({})
expect(await screen.findByText('market:刷新后插件')).toBeInTheDocument()
expect(screen.queryByText('market:刷新前插件')).not.toBeInTheDocument()
await waitForRequestsToFinish()
})
it('keeps the previous market snapshot when a manual metric request fails', async () => {
let marketRequests = 0
await renderList({
market: () => {
marketRequests += 1
return marketRequests === 1
? [createPlugin({ id: 'BeforeRefresh', plugin_name: '刷新前插件' })]
: [createPlugin({ id: 'AfterRefresh', plugin_name: '刷新后插件' })]
},
statistic: () => ({ BeforeRefresh: 10 }),
})
expect(await screen.findByText('market:刷新前插件')).toBeInTheDocument()
server.use(http.get(apiUrls.statistic, () => HttpResponse.json({ message: 'metrics failed' }, { status: 503 })))
const refresh = getHeaderButton('mdi-refresh').action
if (!refresh) throw new Error('未注册市场刷新操作')
await refresh()
await waitForRequestsToFinish()
expect(screen.getByText('market:刷新前插件')).toBeInTheDocument()
expect(screen.queryByText('market:刷新后插件')).not.toBeInTheDocument()
})
})
describe('PluginCardListView installed filtering and host callbacks', () => {
@@ -986,9 +1182,10 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
getHeaderConfig().modelValue.value = 'market'
await nextTick()
await waitForRequestsToFinish()
expect(marketRequests).toBe(1)
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
getDialogEvents().save()
await waitFor(() => expect(marketRequests).toBeGreaterThanOrEqual(3))
await waitFor(() => expect(marketRequests).toBe(2))
const requestsAfterSave = marketRequests
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
@@ -996,10 +1193,42 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
getDialogEvents().changed()
await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave))
server.use(http.get(apiUrls.install('Available'), () => HttpResponse.json({ data: null, success: true })))
await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' }))
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
await waitForRequestsToFinish()
})
it('moves a market installation to the installed tab and targets its card', async () => {
const installGate = createDeferred<void>()
let installed = false
const target = createPlugin({ id: 'MarketInstall', plugin_name: '市场安装插件' })
await renderList({
installed: () => (installed ? [{ ...target, installed: true }] : []),
market: () => [target],
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('MarketInstall'), async () => {
await installGate.promise
installed = true
return HttpResponse.json({ data: null, success: true })
}),
)
getHeaderConfig().modelValue.value = 'market'
await nextTick()
await fireEvent.click(screen.getByRole('button', { name: 'installed-MarketInstall' }))
expect(getHeaderConfig().modelValue.value).toBe('installed')
expect(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument()
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 市场安装插件 安装成功!'))
await waitForRequestsToFinish()
})
})
describe('PluginCardListView search installation', () => {
@@ -1010,7 +1239,7 @@ describe('PluginCardListView search installation', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('blocks incompatible plugins and reports business and HTTP failures without success', async () => {
it('opens the install detail before making a request and preserves install failures', async () => {
let installRequests = 0
let mode: 'business' | 'http' = 'business'
await renderList()
@@ -1033,12 +1262,16 @@ describe('PluginCardListView search installation', () => {
system_version_message: '版本不兼容',
}),
)
expect(getDialogProps().plugin).toMatchObject({ id: 'SearchPlugin', plugin_name: '不兼容插件' })
expect(installRequests).toBe(0)
await getDialogProps().installHandler?.()
expect(mocks.toastError).toHaveBeenLastCalledWith('版本不兼容')
expect(installRequests).toBe(0)
mocks.toastError.mockClear()
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: '业务失败插件' }))
await getDialogProps().installHandler?.()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(installRequests).toBe(1)
expect(mocks.toastSuccess).not.toHaveBeenCalled()
@@ -1047,6 +1280,7 @@ describe('PluginCardListView search installation', () => {
mode = 'http'
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: 'HTTP 失败插件' }))
await getDialogProps().installHandler?.()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(installRequests).toBe(2)
expect(mocks.toastSuccess).not.toHaveBeenCalled()
@@ -1079,6 +1313,7 @@ describe('PluginCardListView search installation', () => {
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
await getDialogProps().installHandler?.()
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
@@ -1087,6 +1322,126 @@ describe('PluginCardListView search installation', () => {
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
await waitForRequestsToFinish()
})
it('shows a per-plugin loading card while installation is still running', async () => {
const installGate = createDeferred<void>()
let installed = false
const target = createPlugin({ id: 'PendingPlugin', plugin_name: '后台安装插件' })
await renderList({
installed: () =>
installed
? [
createPlugin({
id: 'PendingPlugin',
installed: true,
plugin_name: '后台安装插件',
runtime_status: 'active',
}),
]
: [],
market: () => [target],
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('PendingPlugin'), async () => {
await installGate.promise
installed = true
return HttpResponse.json({ data: null, success: true })
}),
)
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
void getDialogProps().installHandler?.()
expect(await screen.findByText('plugin:后台安装插件')).toBeInTheDocument()
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('source_missing')
expect(screen.getByLabelText('settling-PendingPlugin')).toHaveTextContent('true')
expect(mocks.toastSuccess).not.toHaveBeenCalled()
installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 后台安装插件 安装成功!'))
await waitFor(() => expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('active'))
await waitForRequestsToFinish()
})
it('deduplicates concurrent installation requests for the same plugin', async () => {
const installGate = createDeferred<void>()
let installRequests = 0
const target = createPlugin({ id: 'DuplicatePlugin', plugin_name: '重复安装插件' })
await renderList({ market: () => [target] })
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('DuplicatePlugin'), async () => {
installRequests += 1
await installGate.promise
return HttpResponse.json({ data: null, success: true })
}),
)
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
const install = getDialogProps().installHandler
if (!install) throw new Error('未打开插件安装操作')
void install()
void install()
await waitFor(() => expect(installRequests).toBe(1))
installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 重复安装插件 安装成功!'))
await waitForRequestsToFinish()
})
it('shows installation failure before a slow rollback refresh completes', async () => {
const refreshGate = createDeferred<Plugin[]>()
let firstInstalledRequest = true
const target = createPlugin({ id: 'SlowRollbackPlugin', plugin_name: '慢回滚插件' })
await renderList({
installed: () => {
if (firstInstalledRequest) {
firstInstalledRequest = false
return []
}
return refreshGate.promise
},
market: () => [target],
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('SlowRollbackPlugin'), () =>
HttpResponse.json({ message: '依赖安装失败', success: false }),
),
)
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
void getDialogProps().installHandler?.()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(getActiveRequestsCount()).toBeGreaterThan(0)
refreshGate.resolve([])
await waitForRequestsToFinish()
})
it('rolls back only the failed optimistic plugin card', async () => {
const stable = createPlugin({ id: 'StablePlugin', installed: true, plugin_name: '稳定插件' })
const target = createPlugin({ id: 'FailedPlugin', plugin_name: '失败插件' })
await renderList({ installed: () => [stable], market: () => [target] })
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('FailedPlugin'), () => HttpResponse.json({ message: '依赖安装失败', success: false })),
)
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
void getDialogProps().installHandler?.()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(screen.getByText('plugin:稳定插件')).toBeInTheDocument()
expect(screen.queryByText('plugin:失败插件')).not.toBeInTheDocument()
await waitForRequestsToFinish()
})
})
describe('PluginCardListView folders and persistence', () => {