mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-30 12:39:26 +08:00
fix: 完善插件运行态与安装交互 (#694)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '名称',
|
||||
|
||||
@@ -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: '名稱',
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 对uninstalledList进行排序到sortedUninstalledList
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user