mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix: 完善插件运行态与安装交互 (#694)
This commit is contained in:
@@ -955,6 +955,8 @@ export interface Plugin {
|
|||||||
installed?: boolean
|
installed?: boolean
|
||||||
// 运行状态
|
// 运行状态
|
||||||
state?: boolean
|
state?: boolean
|
||||||
|
// 插件源码、依赖和运行时加载状态
|
||||||
|
runtime_status?: 'source_missing' | 'dependency_pending' | 'ready' | 'active' | 'blocked_by_policy' | 'load_failed'
|
||||||
// 是否有详情页面
|
// 是否有详情页面
|
||||||
has_page?: boolean
|
has_page?: boolean
|
||||||
// 是否有新版本
|
// 是否有新版本
|
||||||
@@ -985,6 +987,17 @@ export interface Plugin {
|
|||||||
user_rating?: number | null
|
user_rating?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginRuntimeSummary {
|
||||||
|
// 本轮插件源码、依赖和加载是否已收敛
|
||||||
|
ready: boolean
|
||||||
|
// 插件运行状态变化代次
|
||||||
|
generation: number
|
||||||
|
// 仍处于准备阶段的插件数量
|
||||||
|
pending_count: number
|
||||||
|
// 加载失败或被策略阻止的插件数量
|
||||||
|
failed_count: number
|
||||||
|
}
|
||||||
|
|
||||||
// 插件评分结果
|
// 插件评分结果
|
||||||
export interface PluginRating {
|
export interface PluginRating {
|
||||||
plugin_id: string
|
plugin_id: string
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ const PluginVersionHistoryDialog = defineAsyncComponent(
|
|||||||
)
|
)
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
|
|
||||||
|
type InstallHandler = (releaseVersion?: string, repoUrl?: string) => unknown
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
plugin: Object as PropType<Plugin>,
|
plugin: Object as PropType<Plugin>,
|
||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
count: Number,
|
count: Number,
|
||||||
|
installHandler: Function as PropType<InstallHandler>,
|
||||||
})
|
})
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
@@ -146,6 +149,13 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.installHandler) {
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
await props.installHandler(releaseVersion, repoUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showInstallProgress(
|
showInstallProgress(
|
||||||
t('plugin.installing', {
|
t('plugin.installing', {
|
||||||
@@ -187,6 +197,7 @@ function showPluginDetail() {
|
|||||||
{
|
{
|
||||||
plugin: props.plugin,
|
plugin: props.plugin,
|
||||||
count: props.count,
|
count: props.count,
|
||||||
|
installHandler: props.installHandler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
install: () => emit('install'),
|
install: () => emit('install'),
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
runtimeSettling: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
@@ -44,6 +48,38 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
|
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
|
||||||
const hasCardStatus = computed(() => Boolean(props.plugin?.has_update) || hasCardRating.value)
|
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 cardRatingValue = computed(() => Number(props.plugin?.average_rating || 0).toFixed(1))
|
||||||
const cardRatingSummary = computed(() =>
|
const cardRatingSummary = computed(() =>
|
||||||
t('plugin.ratingSummary', {
|
t('plugin.ratingSummary', {
|
||||||
@@ -410,6 +446,7 @@ async function visitPluginPage() {
|
|||||||
|
|
||||||
// 打开插件详情
|
// 打开插件详情
|
||||||
function openPluginDetail() {
|
function openPluginDetail() {
|
||||||
|
if (runtimeActionsBlocked.value) return
|
||||||
if (props.plugin?.has_page) showPluginInfo()
|
if (props.plugin?.has_page) showPluginInfo()
|
||||||
else showPluginConfig()
|
else showPluginConfig()
|
||||||
}
|
}
|
||||||
@@ -625,9 +662,12 @@ watch(
|
|||||||
:class="{
|
:class="{
|
||||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||||
'cursor-move': props.sortable,
|
'cursor-move': props.sortable,
|
||||||
|
'plugin-card--runtime-blocked': runtimeActionsBlocked,
|
||||||
|
'plugin-card--runtime-pending': runtimePending,
|
||||||
|
'plugin-card--runtime-unavailable': runtimeUnavailable,
|
||||||
}"
|
}"
|
||||||
:style="accentStyle"
|
:style="accentStyle"
|
||||||
:ripple="!props.sortable"
|
:ripple="!props.sortable && !runtimeActionsBlocked"
|
||||||
>
|
>
|
||||||
<div class="plugin-card__banner flex-grow">
|
<div class="plugin-card__banner flex-grow">
|
||||||
<VCardText class="px-2 pt-2 pb-0">
|
<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="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
:class="{ 'plugin-card__title--with-status': hasCardStatus }"
|
: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 }}
|
{{ props.plugin?.plugin_name }}
|
||||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
@@ -650,7 +696,7 @@ watch(
|
|||||||
class="relative flex-shrink-0 self-center pb-3"
|
class="relative flex-shrink-0 self-center pb-3"
|
||||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||||
>
|
>
|
||||||
<VAvatar size="48">
|
<VAvatar size="48" class="plugin-card__plugin-icon">
|
||||||
<VImg
|
<VImg
|
||||||
ref="imageRef"
|
ref="imageRef"
|
||||||
:src="iconPath"
|
:src="iconPath"
|
||||||
@@ -662,6 +708,21 @@ watch(
|
|||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<VCardText
|
<VCardText
|
||||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
<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" />
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||||
<VList>
|
<VList>
|
||||||
@@ -701,6 +762,7 @@ watch(
|
|||||||
v-show="item.show"
|
v-show="item.show"
|
||||||
:key="i"
|
:key="i"
|
||||||
:base-color="item.props.color"
|
:base-color="item.props.color"
|
||||||
|
:disabled="runtimeActionsBlocked && [1, 2, 4, 8].includes(item.value)"
|
||||||
@click="item.props.click"
|
@click="item.props.click"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -764,6 +826,45 @@ watch(
|
|||||||
font-weight: 600;
|
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 {
|
.card-cover-blurred::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface Props {
|
|||||||
pluginActions?: { [key: string]: boolean }
|
pluginActions?: { [key: string]: boolean }
|
||||||
showRemoveButton?: boolean
|
showRemoveButton?: boolean
|
||||||
sortable?: boolean
|
sortable?: boolean
|
||||||
|
runtimeSettling?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -23,6 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
pluginActions: () => ({}),
|
pluginActions: () => ({}),
|
||||||
showRemoveButton: false,
|
showRemoveButton: false,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
runtimeSettling: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -108,6 +110,7 @@ function handleDropToFolder(event: DragEvent) {
|
|||||||
:plugin="item.data"
|
:plugin="item.data"
|
||||||
:action="pluginActions[item.id] || false"
|
:action="pluginActions[item.id] || false"
|
||||||
:sortable="sortable"
|
:sortable="sortable"
|
||||||
|
:runtime-settling="runtimeSettling"
|
||||||
@remove="$emit('refreshData')"
|
@remove="$emit('refreshData')"
|
||||||
@save="$emit('refreshData')"
|
@save="$emit('refreshData')"
|
||||||
@rating="$emit('rating', $event)"
|
@rating="$emit('rating', $event)"
|
||||||
|
|||||||
@@ -133,6 +133,23 @@ describe('PluginAppCard rating badge', () => {
|
|||||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
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 () => {
|
it('installs a selected release with exact parameters and emits completion', async () => {
|
||||||
mocks.apiGet.mockResolvedValue({ success: true })
|
mocks.apiGet.mockResolvedValue({ success: true })
|
||||||
const lifecyclePlugin = {
|
const lifecyclePlugin = {
|
||||||
|
|||||||
@@ -415,4 +415,44 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
expect(emitted().actionDone).toHaveLength(1)
|
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,
|
required: true,
|
||||||
},
|
},
|
||||||
count: Number,
|
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 (!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'
|
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ const ImageStub = defineComponent({
|
|||||||
template: '<button data-testid="plugin-image" @contextmenu.prevent="$emit(\'error\')" />',
|
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, {
|
return renderWithProviders(PluginMarketDetailDialog, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
plugin,
|
plugin,
|
||||||
|
...extraProps,
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
components: { VDialogCloseBtn: DialogCloseBtn },
|
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||||
@@ -202,6 +203,18 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
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 () => {
|
it('keeps the detail open and emits nothing after a business failure', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ let lastMeasuredColumnCount = 0
|
|||||||
let lastMeasuredColumnWidth = 0
|
let lastMeasuredColumnWidth = 0
|
||||||
let documentOverlayLocked = false
|
let documentOverlayLocked = false
|
||||||
|
|
||||||
|
const scrollRevealGap = 16
|
||||||
|
|
||||||
const safeGap = computed(() => Math.max(0, props.gap))
|
const safeGap = computed(() => Math.max(0, props.gap))
|
||||||
const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCount)))
|
const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCount)))
|
||||||
const safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize)))
|
const safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize)))
|
||||||
@@ -721,6 +723,10 @@ function syncProgressiveWindow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列表缩短后先裁剪历史渐进窗口,追加数据时仍按批次继续渲染。
|
||||||
|
progressiveStartIndex.value = clamp(progressiveStartIndex.value, 0, props.items.length)
|
||||||
|
progressiveEndIndex.value = clamp(progressiveEndIndex.value, 0, props.items.length)
|
||||||
|
|
||||||
const range = calculatedVisibleRange.value
|
const range = calculatedVisibleRange.value
|
||||||
const viewportRange = calculatedViewportRange.value
|
const viewportRange = calculatedViewportRange.value
|
||||||
const overlapsViewport =
|
const overlapsViewport =
|
||||||
@@ -789,6 +795,78 @@ function getTrackScrollTop() {
|
|||||||
return trackRect.top - scrollRect.top + scrollElement.scrollTop
|
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) {
|
function adjustScrollTop(delta: number) {
|
||||||
if (!scrollTarget || Math.abs(delta) < 0.5) {
|
if (!scrollTarget || Math.abs(delta) < 0.5) {
|
||||||
return
|
return
|
||||||
@@ -805,12 +883,12 @@ function adjustScrollTop(delta: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToRelativeTop(top: number) {
|
function scrollToRelativeTop(top: number, itemHeight: number) {
|
||||||
if (!scrollTarget) {
|
if (!scrollTarget) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTop = getTrackScrollTop() + top
|
const targetTop = getRevealScrollTop(top, itemHeight)
|
||||||
|
|
||||||
if (scrollTarget === window) {
|
if (scrollTarget === window) {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
@@ -836,8 +914,9 @@ async function revealItem(index: number) {
|
|||||||
|
|
||||||
const row = Math.floor(index / columnCount.value)
|
const row = Math.floor(index / columnCount.value)
|
||||||
const top = rowMetrics.value.offsets[row] ?? 0
|
const top = rowMetrics.value.offsets[row] ?? 0
|
||||||
|
const itemHeight = rowMetrics.value.heights[row] ?? estimatedHeight.value
|
||||||
|
|
||||||
scrollToRelativeTop(top)
|
scrollToRelativeTop(top, itemHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestRevealItem(index: number) {
|
function requestRevealItem(index: number) {
|
||||||
@@ -891,16 +970,33 @@ function pruneMeasurements() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
function preservesLeadingKeys(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||||
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
|
if (!previousKeys.length || !nextKeys.length) {
|
||||||
return false
|
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[] = []) {
|
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||||
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
|
if (!preservesLeadingKeys(nextKeys, previousKeys) && !hasSameKeySet(nextKeys, previousKeys) && itemHeights.size) {
|
||||||
itemHeights.clear()
|
itemHeights.clear()
|
||||||
heightVersion.value += 1
|
heightVersion.value += 1
|
||||||
}
|
}
|
||||||
@@ -997,7 +1093,7 @@ onUnmounted(() => {
|
|||||||
watch(
|
watch(
|
||||||
itemKeys,
|
itemKeys,
|
||||||
(nextKeys, previousKeys) => {
|
(nextKeys, previousKeys) => {
|
||||||
if (!didKeysAppend(nextKeys, previousKeys)) {
|
if (!preservesLeadingKeys(nextKeys, previousKeys) && !hasSameKeySet(nextKeys, previousKeys)) {
|
||||||
cancelProgressiveRender()
|
cancelProgressiveRender()
|
||||||
progressiveStartIndex.value = 0
|
progressiveStartIndex.value = 0
|
||||||
progressiveEndIndex.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')
|
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', () => {
|
describe('ProgressiveCardGrid mount scheduling', () => {
|
||||||
@@ -226,4 +298,141 @@ describe('ProgressiveCardGrid mount scheduling', () => {
|
|||||||
|
|
||||||
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(18)
|
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 HeaderTab from './HeaderTab.vue'
|
||||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||||
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.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 { getNavMenus } from '@/router/i18n-menu'
|
||||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
@@ -50,6 +50,7 @@ const showThemeCustomizer = ref(false)
|
|||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const pluginRuntimeStore = usePluginRuntimeStore()
|
||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
@@ -432,6 +433,7 @@ function handlePluginClick() {
|
|||||||
|
|
||||||
// 组件卸载时清理监听
|
// 组件卸载时清理监听
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
pluginRuntimeStore.stop()
|
||||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||||
})
|
})
|
||||||
@@ -479,6 +481,22 @@ watch([() => pluginSidebarNavStore.items, userPermissions], () => {
|
|||||||
if (sidebarMenusMounted) rebuildSidebarMenus()
|
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 () => {
|
onMounted(async () => {
|
||||||
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
||||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ interface SidebarStoreMock {
|
|||||||
items: PluginSidebarNavItem[]
|
items: PluginSidebarNavItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeStoreMock {
|
||||||
|
reconciliation: number
|
||||||
|
start: ReturnType<typeof vi.fn>
|
||||||
|
stop: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
interface UserStoreMock {
|
interface UserStoreMock {
|
||||||
permissions: Record<string, unknown>
|
permissions: Record<string, unknown>
|
||||||
superUser: boolean
|
superUser: boolean
|
||||||
@@ -23,7 +29,10 @@ const mocks = vi.hoisted(() => ({
|
|||||||
props: ['item'],
|
props: ['item'],
|
||||||
template: '<span data-testid="vertical-nav-link">{{ item.title }}</span>',
|
template: '<span data-testid="vertical-nav-link">{{ item.title }}</span>',
|
||||||
},
|
},
|
||||||
|
runtimeStore: undefined as RuntimeStoreMock | undefined,
|
||||||
sidebarStore: undefined as SidebarStoreMock | undefined,
|
sidebarStore: undefined as SidebarStoreMock | undefined,
|
||||||
|
startPluginRuntime: vi.fn(),
|
||||||
|
stopPluginRuntime: vi.fn(),
|
||||||
userStore: undefined as UserStoreMock | undefined,
|
userStore: undefined as UserStoreMock | undefined,
|
||||||
verticalNavLayout: { template: '<div><slot name="vertical-nav-content" /></div>' },
|
verticalNavLayout: { template: '<div><slot name="vertical-nav-content" /></div>' },
|
||||||
}))
|
}))
|
||||||
@@ -49,6 +58,11 @@ vi.mock('@/stores', async () => {
|
|||||||
ensureSidebarNav: mocks.ensureSidebarNav,
|
ensureSidebarNav: mocks.ensureSidebarNav,
|
||||||
items: [] as PluginSidebarNavItem[],
|
items: [] as PluginSidebarNavItem[],
|
||||||
})
|
})
|
||||||
|
mocks.runtimeStore = reactive({
|
||||||
|
reconciliation: 0,
|
||||||
|
start: mocks.startPluginRuntime,
|
||||||
|
stop: mocks.stopPluginRuntime,
|
||||||
|
})
|
||||||
mocks.userStore = reactive({
|
mocks.userStore = reactive({
|
||||||
permissions: {
|
permissions: {
|
||||||
admin: false,
|
admin: false,
|
||||||
@@ -63,6 +77,7 @@ vi.mock('@/stores', async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
|
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
|
||||||
|
usePluginRuntimeStore: () => mocks.runtimeStore,
|
||||||
usePluginSidebarNavStore: () => mocks.sidebarStore,
|
usePluginSidebarNavStore: () => mocks.sidebarStore,
|
||||||
useUserStore: () => mocks.userStore,
|
useUserStore: () => mocks.userStore,
|
||||||
}
|
}
|
||||||
@@ -110,6 +125,9 @@ describe('DefaultLayout', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.ensureSidebarNav.mockReset()
|
mocks.ensureSidebarNav.mockReset()
|
||||||
mocks.ensureSidebarNav.mockResolvedValue(undefined)
|
mocks.ensureSidebarNav.mockResolvedValue(undefined)
|
||||||
|
mocks.startPluginRuntime.mockReset()
|
||||||
|
mocks.stopPluginRuntime.mockReset()
|
||||||
|
mocks.runtimeStore!.reconciliation = 0
|
||||||
mocks.sidebarStore!.items = []
|
mocks.sidebarStore!.items = []
|
||||||
mocks.userStore!.permissions = {
|
mocks.userStore!.permissions = {
|
||||||
admin: false,
|
admin: false,
|
||||||
@@ -166,6 +184,24 @@ describe('DefaultLayout', () => {
|
|||||||
await flushPromises()
|
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 () => {
|
it('replaces plugin links when the shared snapshot refreshes after mount', async () => {
|
||||||
mocks.sidebarStore!.items = [
|
mocks.sidebarStore!.items = [
|
||||||
{
|
{
|
||||||
@@ -207,6 +243,37 @@ describe('DefaultLayout', () => {
|
|||||||
expect(wrapper.text()).not.toContain('Old plugin')
|
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 () => {
|
it('rebuilds plugin links when permissions change after mount', async () => {
|
||||||
mocks.sidebarStore!.items = [
|
mocks.sidebarStore!.items = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3721,6 +3721,14 @@ export default {
|
|||||||
installingPlugin: 'Installing plugin...',
|
installingPlugin: 'Installing plugin...',
|
||||||
installing: 'Installing {name} v{version} ...',
|
installing: 'Installing {name} v{version} ...',
|
||||||
installSuccess: 'Plugin {name} installed successfully!',
|
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}',
|
installFailed: 'Plugin {name} installation failed: {message}',
|
||||||
filterPlugins: 'Filter Plugins',
|
filterPlugins: 'Filter Plugins',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
|||||||
@@ -3660,6 +3660,14 @@ export default {
|
|||||||
installingPlugin: '正在安装插件...',
|
installingPlugin: '正在安装插件...',
|
||||||
installing: '正在安装 {name} v{version} ...',
|
installing: '正在安装 {name} v{version} ...',
|
||||||
installSuccess: '插件 {name} 安装成功!',
|
installSuccess: '插件 {name} 安装成功!',
|
||||||
|
sourceRestoring: '正在恢复插件文件',
|
||||||
|
dependencyInstalling: '正在安装插件依赖',
|
||||||
|
runtimeLoading: '正在加载插件',
|
||||||
|
sourceMissing: '插件文件缺失',
|
||||||
|
dependencyPending: '插件依赖未就绪',
|
||||||
|
runtimeReady: '插件等待加载',
|
||||||
|
blockedByPolicy: '未通过用户认证,请查看日志',
|
||||||
|
runtimeLoadFailed: '插件加载失败,请查看日志',
|
||||||
installFailed: '插件 {name} 安装失败:{message}',
|
installFailed: '插件 {name} 安装失败:{message}',
|
||||||
filterPlugins: '过滤插件',
|
filterPlugins: '过滤插件',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
|||||||
@@ -3658,6 +3658,14 @@ export default {
|
|||||||
installingPlugin: '正在安装插件...',
|
installingPlugin: '正在安装插件...',
|
||||||
installing: '正在安装 {name} v{version} ...',
|
installing: '正在安装 {name} v{version} ...',
|
||||||
installSuccess: '插件 {name} 安装成功!',
|
installSuccess: '插件 {name} 安装成功!',
|
||||||
|
sourceRestoring: '正在恢復插件文件',
|
||||||
|
dependencyInstalling: '正在安裝插件依賴',
|
||||||
|
runtimeLoading: '正在載入插件',
|
||||||
|
sourceMissing: '插件文件缺失',
|
||||||
|
dependencyPending: '插件依賴未就緒',
|
||||||
|
runtimeReady: '插件等待載入',
|
||||||
|
blockedByPolicy: '未通過用戶認證,請查看日誌',
|
||||||
|
runtimeLoadFailed: '插件載入失敗,請查看日誌',
|
||||||
installFailed: '插件 {name} 安装失败:{message}',
|
installFailed: '插件 {name} 安装失败:{message}',
|
||||||
filterPlugins: '過濾插件',
|
filterPlugins: '過濾插件',
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import DashboardPage from '@/pages/dashboard.vue'
|
import DashboardPage from '@/pages/dashboard.vue'
|
||||||
|
import { usePluginRuntimeStore } from '@/stores/pluginRuntime'
|
||||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
@@ -286,6 +287,24 @@ describe('dashboard page initial layout', () => {
|
|||||||
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
|
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 () => {
|
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
|
||||||
mocks.themeName.value = 'glass'
|
mocks.themeName.value = 'glass'
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
|||||||
+11
-1
@@ -10,7 +10,7 @@ import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useD
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useUserStore } from '@/stores'
|
import { usePluginRuntimeStore, useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { useDisplay, useTheme } from 'vuetify'
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ const { appMode } = usePWA()
|
|||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const pluginRuntimeStore = usePluginRuntimeStore()
|
||||||
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
||||||
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery'))
|
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()
|
hydrateDashboardConfigFromLocal()
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
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 { useUserStore } from './user'
|
||||||
import { useGlobalSettingsStore } from './global'
|
import { useGlobalSettingsStore } from './global'
|
||||||
import { usePluginSidebarNavStore } from './pluginSidebarNav'
|
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 { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
import { usePluginRuntimeStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -20,6 +20,7 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const pluginRuntimeStore = usePluginRuntimeStore()
|
||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
/** 用户保存的插件与文件夹混合顺序。 */
|
/** 用户保存的插件与文件夹混合顺序。 */
|
||||||
@@ -51,7 +52,7 @@ const PluginFolderCreateDialog = defineAsyncComponent(() => import('@/components
|
|||||||
const PluginMarketSettingDialog = defineAsyncComponent(
|
const PluginMarketSettingDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/PluginMarketSettingDialog.vue'),
|
() => 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'))
|
const PluginSearchDialog = defineAsyncComponent(() => import('@/components/dialog/PluginSearchDialog.vue'))
|
||||||
|
|
||||||
// APP
|
// APP
|
||||||
@@ -141,6 +142,7 @@ registerHeaderTab({
|
|||||||
|
|
||||||
// 插件ID参数
|
// 插件ID参数
|
||||||
const pluginId = ref(route.query.id)
|
const pluginId = ref(route.query.id)
|
||||||
|
const installScrollPluginId = ref<string | null>(null)
|
||||||
|
|
||||||
// 当前排序字段
|
// 当前排序字段
|
||||||
const activeSort = ref<PluginSortKey | null>(null)
|
const activeSort = ref<PluginSortKey | null>(null)
|
||||||
@@ -179,7 +181,16 @@ const marketList = ref<Plugin[]>([])
|
|||||||
const sortedUninstalledList = 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)
|
const isRefreshed = ref(false)
|
||||||
@@ -204,12 +215,15 @@ const PluginRatings = ref<{ [key: string]: PluginRating }>({})
|
|||||||
// 插件市场刷新状态
|
// 插件市场刷新状态
|
||||||
const isMarketRefreshing = ref(false)
|
const isMarketRefreshing = ref(false)
|
||||||
|
|
||||||
|
const pluginRuntimeSummary = computed(() => pluginRuntimeStore.summary)
|
||||||
|
const installingPluginIds = ref(new Set<string>())
|
||||||
|
const isPluginPageActive = ref(false)
|
||||||
|
|
||||||
// 每类远程快照独立管理 writer 代际,旧请求只能完成自身 Promise,不能覆盖新状态。
|
// 每类远程快照独立管理 writer 代际,旧请求只能完成自身 Promise,不能覆盖新状态。
|
||||||
let installedWriterGeneration = 0
|
let installedWriterGeneration = 0
|
||||||
let marketWriterGeneration = 0
|
let marketWriterGeneration = 0
|
||||||
let ratingWriterGeneration = 0
|
let ratingWriterGeneration = 0
|
||||||
let statisticWriterGeneration = 0
|
let statisticWriterGeneration = 0
|
||||||
|
|
||||||
// 搜索关键字
|
// 搜索关键字
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
|
||||||
@@ -219,10 +233,7 @@ const pluginActions: Ref<{ [key: string]: boolean }> = ref({})
|
|||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
// 进度框文本
|
|
||||||
const progressText = ref(t('plugin.installingPlugin'))
|
|
||||||
let folderCreateDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let folderCreateDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|
||||||
let searchDialogController: 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 shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
|
||||||
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
||||||
const installedScrollToIndex = computed(() => {
|
const installedScrollToIndex = computed(() => {
|
||||||
if (sortMode.value || currentFolder.value || !pluginId.value) {
|
if (sortMode.value || currentFolder.value) {
|
||||||
return undefined
|
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
|
return targetIndex >= 0 ? targetIndex : undefined
|
||||||
})
|
})
|
||||||
@@ -617,6 +631,7 @@ function sortPluginOrder() {
|
|||||||
if (dataList.value.length === 0) {
|
if (dataList.value.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// PluginOrder 是用户级展示顺序,未配置的插件保留后端持久化安装清单顺序。
|
||||||
dataList.value.sort((a, b) => {
|
dataList.value.sort((a, b) => {
|
||||||
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||||
const bIndex = orderValueMap.value.get(`plugin:${b.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
|
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) {
|
async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: string) {
|
||||||
if (item?.system_version_compatible === false) {
|
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'))
|
$toast.error(item.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id)
|
||||||
// 显示等待提示框
|
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
|
||||||
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
|
sortMode.value = false
|
||||||
openPluginProgressDialog(progressText.value)
|
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: {
|
params: {
|
||||||
repo_url: item?.repo_url,
|
repo_url: repoUrl || item?.repo_url,
|
||||||
force: item?.has_update,
|
release_version: releaseVersion,
|
||||||
|
force: item?.has_update || Boolean(releaseVersion),
|
||||||
},
|
},
|
||||||
feedback: 'silent',
|
feedback: 'silent',
|
||||||
})
|
})
|
||||||
|
installed = true
|
||||||
|
|
||||||
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
||||||
// 清空过滤条件
|
await fetchInstalledPlugins({ silent: true })
|
||||||
hasUpdateFilter.value = false
|
if (userStore.superUser) await pluginRuntimeStore.refresh()
|
||||||
enabledFilter.value = false
|
|
||||||
installedFilter.value = null
|
|
||||||
// 刷新
|
|
||||||
await refreshData()
|
|
||||||
await pluginSidebarNavStore.ensureSidebarNav(true)
|
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
|
await nextTick()
|
||||||
|
if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null
|
||||||
} catch (error) {
|
} 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)
|
console.error(error)
|
||||||
$toast.error(
|
$toast.error(
|
||||||
t('plugin.installFailed', {
|
t('plugin.installFailed', {
|
||||||
@@ -884,20 +918,40 @@ async function installPlugin(item: Plugin) {
|
|||||||
message: error instanceof Error ? error.message : '',
|
message: error instanceof Error ? error.message : '',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
// 列表校准不能延迟失败反馈,网络异常时也要立即告诉用户安装事务已回滚。
|
||||||
|
void fetchInstalledPlugins({ silent: true })
|
||||||
} finally {
|
} 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) {
|
function openPlugin(item: Plugin) {
|
||||||
// 如果是已安装插件则打开插件详情
|
|
||||||
if (item.installed === true) {
|
if (item.installed === true) {
|
||||||
// 标记插件动作
|
// 已安装插件继续进入对应的插件操作面板。
|
||||||
pluginActions.value[item.id || '0'] = true
|
pluginActions.value[item.id || '0'] = true
|
||||||
} else {
|
} else {
|
||||||
// 如果是未安装插件则安装
|
openPluginMarketDetail(item)
|
||||||
installPlugin(item)
|
|
||||||
}
|
}
|
||||||
closeSearchDialog()
|
closeSearchDialog()
|
||||||
}
|
}
|
||||||
@@ -923,7 +977,7 @@ const filterPlugins = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 获取插件列表数据
|
// 获取插件列表数据
|
||||||
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}): Promise<boolean> {
|
||||||
const generation = ++installedWriterGeneration
|
const generation = ++installedWriterGeneration
|
||||||
if (!context.silent || !isRefreshed.value) {
|
if (!context.silent || !isRefreshed.value) {
|
||||||
installedLoadError.value = false
|
installedLoadError.value = false
|
||||||
@@ -935,23 +989,59 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
|||||||
state: 'installed',
|
state: 'installed',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (generation !== installedWriterGeneration) return
|
if (generation !== installedWriterGeneration) return false
|
||||||
|
|
||||||
mergeRatingsIntoPlugins(installedPlugins)
|
const previousById = new Map([...uninstalledList.value, ...dataList.value].map(plugin => [plugin.id, plugin]))
|
||||||
dataList.value = installedPlugins
|
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()
|
mergeMarketMetadataIntoInstalled()
|
||||||
// 排序
|
// 排序
|
||||||
sortPluginOrder()
|
sortPluginOrder()
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
installedLoadError.value = false
|
installedLoadError.value = false
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
if (generation === installedWriterGeneration && !isRefreshed.value) {
|
if (generation === installedWriterGeneration && !isRefreshed.value) {
|
||||||
installedLoadError.value = true
|
installedLoadError.value = true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPluginRuntimeSettling(pluginId: string) {
|
||||||
|
return pluginRuntimeSummary.value?.ready === false || installingPluginIds.value.has(pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
/** 将市场更新元数据投影到当前已安装快照。 */
|
/** 将市场更新元数据投影到当前已安装快照。 */
|
||||||
function mergeMarketMetadataIntoInstalled() {
|
function mergeMarketMetadataIntoInstalled() {
|
||||||
const marketById = new Map(
|
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
|
const generation = ++marketWriterGeneration
|
||||||
if (!context.silent || !isAppMarketLoaded.value) {
|
if (!context.silent || !isAppMarketLoaded.value) {
|
||||||
marketLoadError.value = false
|
marketLoadError.value = false
|
||||||
@@ -986,52 +1105,38 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
|
|||||||
})
|
})
|
||||||
if (generation !== marketWriterGeneration) return
|
if (generation !== marketWriterGeneration) return
|
||||||
|
|
||||||
mergeRatingsIntoPlugins(marketResponse)
|
if (commit) applyMarketSnapshot(marketResponse)
|
||||||
uninstalledList.value = marketResponse
|
return 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
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
if (generation === marketWriterGeneration && !isAppMarketLoaded.value) {
|
if (generation === marketWriterGeneration && !isAppMarketLoaded.value) {
|
||||||
marketLoadError.value = true
|
marketLoadError.value = true
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载插件统计数据
|
// 加载插件统计数据
|
||||||
async function getPluginStatistics() {
|
async function fetchPluginStatistics(): Promise<{ [key: string]: number } | undefined> {
|
||||||
const generation = ++statisticWriterGeneration
|
const generation = ++statisticWriterGeneration
|
||||||
try {
|
try {
|
||||||
const statistics = await api.get<Record<string, number>, Record<string, number>>('plugin/statistic')
|
const statistics = await api.get<Record<string, number>, Record<string, number>>('plugin/statistic')
|
||||||
if (generation === statisticWriterGeneration) {
|
return generation === statisticWriterGeneration ? statistics : undefined
|
||||||
PluginStatistics.value = statistics
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 generation = ++ratingWriterGeneration
|
||||||
const pluginIds = Array.from(
|
const pluginIds = Array.from(new Set([...dataList.value, ...plugins].map(plugin => plugin.id).filter(Boolean)))
|
||||||
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
|
||||||
)
|
|
||||||
if (pluginIds.length === 0) {
|
if (pluginIds.length === 0) {
|
||||||
if (generation === ratingWriterGeneration) {
|
return generation === ratingWriterGeneration ? {} : undefined
|
||||||
PluginRatings.value = {}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1047,21 +1152,40 @@ async function getPluginRatings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentPluginIds = Array.from(
|
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
|
return ratings
|
||||||
|
|
||||||
mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载量与评分属于同一份市场指标快照,始终在同一刷新时机加载。 */
|
/** 下载量与评分属于同一份市场指标快照,始终在同一刷新时机加载。 */
|
||||||
async function getPluginMarketMetrics() {
|
async function getPluginMarketMetrics(
|
||||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
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()
|
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
|
// 对uninstalledList进行排序到sortedUninstalledList
|
||||||
watch([marketList, filterForm, activeSort, PluginStatistics, PluginRatings], () => {
|
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
|
isMarketRefreshing.value = true
|
||||||
try {
|
try {
|
||||||
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
const refreshed = await refreshMarketData(true, { silent: false, source: 'manual' }, true)
|
||||||
await getPluginMarketMetrics()
|
if (!refreshed) return
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
window.scrollTo({ behavior: 'auto', top: 0 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1251,23 +1408,23 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
|
|||||||
|
|
||||||
// 插件市场加载更多数据
|
// 插件市场加载更多数据
|
||||||
function loadMarketMore({ done }: { done: (status: 'ok' | 'empty' | 'loading' | 'error') => void }) {
|
function loadMarketMore({ done }: { done: (status: 'ok' | 'empty' | 'loading' | 'error') => void }) {
|
||||||
// 从 dataList 中获取最前面的 20 个元素
|
if (marketVisibleCount.value >= sortedUninstalledList.value.length) {
|
||||||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
|
||||||
if (itemsToMove.length === 0) {
|
|
||||||
done('empty')
|
done('empty')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
displayUninstalledList.value.push(...itemsToMove)
|
marketVisibleCount.value = Math.min(marketVisibleCount.value + marketPageSize, sortedUninstalledList.value.length)
|
||||||
done('ok')
|
done('ok')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载后
|
// 组件挂载后
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
isPluginPageActive.value = true
|
||||||
await loadPluginOrderConfig()
|
await loadPluginOrderConfig()
|
||||||
await loadPluginFolders() // 加载文件夹配置
|
await loadPluginFolders() // 加载文件夹配置
|
||||||
await refreshData()
|
await refreshData()
|
||||||
|
if (userStore.superUser) await pluginRuntimeStore.refresh()
|
||||||
if (activeTab.value != 'market' && pluginId.value) {
|
if (activeTab.value != 'market' && pluginId.value) {
|
||||||
// 找到这个插件
|
// 找到这个插件
|
||||||
const plugin = dataList.value.find(item => item.id === 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) => {
|
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(() => {
|
onUnmounted(() => {
|
||||||
closePluginProgressDialog()
|
isPluginPageActive.value = false
|
||||||
folderCreateDialogController?.close()
|
folderCreateDialogController?.close()
|
||||||
searchDialogController?.close()
|
searchDialogController?.close()
|
||||||
})
|
})
|
||||||
@@ -1977,6 +2159,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:item="element"
|
:item="element"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:runtime-settling="isPluginRuntimeSettling(element.id)"
|
||||||
:sortable="true"
|
:sortable="true"
|
||||||
@open-folder="openFolder"
|
@open-folder="openFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@@ -2006,6 +2189,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:item="item"
|
:item="item"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:runtime-settling="isPluginRuntimeSettling(item.id)"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@open-folder="openFolder"
|
@open-folder="openFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@@ -2041,6 +2225,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:runtime-settling="isPluginRuntimeSettling(element.id)"
|
||||||
:sortable="true"
|
:sortable="true"
|
||||||
:show-remove-button="true"
|
:show-remove-button="true"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@@ -2066,6 +2251,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:runtime-settling="isPluginRuntimeSettling(item.id)"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
:show-remove-button="true"
|
:show-remove-button="true"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@@ -2127,12 +2313,17 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
v-if="displayUninstalledList.length > 0"
|
v-if="displayUninstalledList.length > 0"
|
||||||
:items="displayUninstalledList"
|
:items="displayUninstalledList"
|
||||||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
:get-item-key="item => item.id"
|
||||||
:min-item-width="256"
|
:min-item-width="256"
|
||||||
:estimated-item-height="260"
|
:estimated-item-height="260"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<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>
|
</template>
|
||||||
</ProgressiveCardGrid>
|
</ProgressiveCardGrid>
|
||||||
</VInfiniteScroll>
|
</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 type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
|
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { usePluginRuntimeStore } from '@/stores/pluginRuntime'
|
||||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||||
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
||||||
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
|
||||||
@@ -18,6 +19,7 @@ const apiUrls = {
|
|||||||
list: new URL('plugin/', API_BASE_URL).href,
|
list: new URL('plugin/', API_BASE_URL).href,
|
||||||
order: new URL('user/config/PluginOrder', API_BASE_URL).href,
|
order: new URL('user/config/PluginOrder', API_BASE_URL).href,
|
||||||
rating: new URL('plugin/rating', 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,
|
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
|
||||||
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
@@ -121,11 +123,13 @@ const ProgressiveCardGridStub = defineComponent({
|
|||||||
props: {
|
props: {
|
||||||
getItemKey: { type: Function as PropType<(item: unknown) => string>, default: undefined },
|
getItemKey: { type: Function as PropType<(item: unknown) => string>, default: undefined },
|
||||||
items: { type: Array as PropType<unknown[]>, required: true },
|
items: { type: Array as PropType<unknown[]>, required: true },
|
||||||
|
scrollToIndex: { type: Number, default: undefined },
|
||||||
},
|
},
|
||||||
setup(props, { slots }) {
|
setup(props, { slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
h(
|
||||||
'section',
|
'section',
|
||||||
|
{ 'data-scroll-to-index': props.scrollToIndex ?? '' },
|
||||||
props.items.flatMap(item => {
|
props.items.flatMap(item => {
|
||||||
props.getItemKey?.(item)
|
props.getItemKey?.(item)
|
||||||
return slots.default?.({ item }) ?? []
|
return slots.default?.({ item }) ?? []
|
||||||
@@ -139,6 +143,7 @@ const PluginMixedSortCardStub = defineComponent({
|
|||||||
props: {
|
props: {
|
||||||
item: { type: Object as PropType<Record<string, unknown>>, required: true },
|
item: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) },
|
pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) },
|
||||||
|
runtimeSettling: Boolean,
|
||||||
sortable: Boolean,
|
sortable: Boolean,
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
@@ -163,6 +168,7 @@ const PluginMixedSortCardStub = defineComponent({
|
|||||||
has_update?: boolean
|
has_update?: boolean
|
||||||
plugin_name?: string
|
plugin_name?: string
|
||||||
repo_url?: string
|
repo_url?: string
|
||||||
|
runtime_status?: Plugin['runtime_status']
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
const name = type === 'folder' ? id : data?.plugin_name || id
|
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': `update-${id}` }, String(data?.has_update ?? false))
|
||||||
: h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''),
|
: 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': `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'
|
type === 'plugin'
|
||||||
? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? ''))
|
? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? ''))
|
||||||
: null,
|
: null,
|
||||||
@@ -243,14 +251,24 @@ const PluginMixedSortCardStub = defineComponent({
|
|||||||
|
|
||||||
const PluginAppCardStub = defineComponent({
|
const PluginAppCardStub = defineComponent({
|
||||||
name: 'PluginAppCard',
|
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'],
|
emits: ['install'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
return () =>
|
return () =>
|
||||||
h('article', { 'data-testid': `market-${props.plugin.id}` }, [
|
h('article', { 'data-testid': `market-${props.plugin.id}` }, [
|
||||||
h('span', `market:${props.plugin.plugin_name}`),
|
h('span', `market:${props.plugin.plugin_name}`),
|
||||||
h('output', { 'aria-label': `rating-${props.plugin.id}` }, String(props.plugin.average_rating ?? '')),
|
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
|
marketStatus?: number
|
||||||
order?: unknown[]
|
order?: unknown[]
|
||||||
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
|
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
|
||||||
|
runtime?: () => PluginRuntimeSummary | Promise<PluginRuntimeSummary>
|
||||||
statistic?: () => Record<string, number> | Promise<Record<string, number>>
|
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.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.sidebar, () => HttpResponse.json([])),
|
||||||
http.get(apiUrls.rating, async ({ request }) => {
|
http.get(apiUrls.rating, async ({ request }) => {
|
||||||
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
|
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)
|
registerListHandlers(responses)
|
||||||
return renderWithProviders(PluginCardListView, {
|
return renderWithProviders(PluginCardListView, {
|
||||||
initialRoute: '/plugins',
|
initialRoute: '/plugins',
|
||||||
initialState: {
|
initialState: {
|
||||||
user: {
|
user: {
|
||||||
permissions: DEFAULT_PERMISSIONS,
|
permissions: DEFAULT_PERMISSIONS,
|
||||||
superUser: true,
|
superUser: options.superUser ?? true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
@@ -448,6 +477,12 @@ function getDialogEvents(index = -1) {
|
|||||||
return call[2] as Record<string, (...args: unknown[]) => unknown>
|
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() {
|
async function waitForRequestsToFinish() {
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
@@ -734,6 +769,42 @@ describe('PluginCardListView loading and request ownership', () => {
|
|||||||
await waitForRequestsToFinish()
|
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 () => {
|
it('leaves an initial market failure in a retryable error state instead of permanent loading', async () => {
|
||||||
await renderList({
|
await renderList({
|
||||||
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
|
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
|
||||||
@@ -904,6 +975,131 @@ describe('PluginCardListView market filtering and pagination', () => {
|
|||||||
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
|
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
|
||||||
await waitForRequestsToFinish()
|
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', () => {
|
describe('PluginCardListView installed filtering and host callbacks', () => {
|
||||||
@@ -986,9 +1182,10 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
|
|||||||
getHeaderConfig().modelValue.value = 'market'
|
getHeaderConfig().modelValue.value = 'market'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
|
expect(marketRequests).toBe(1)
|
||||||
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
|
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
|
||||||
getDialogEvents().save()
|
getDialogEvents().save()
|
||||||
await waitFor(() => expect(marketRequests).toBeGreaterThanOrEqual(3))
|
await waitFor(() => expect(marketRequests).toBe(2))
|
||||||
|
|
||||||
const requestsAfterSave = marketRequests
|
const requestsAfterSave = marketRequests
|
||||||
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
|
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
|
||||||
@@ -996,10 +1193,42 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
|
|||||||
getDialogEvents().changed()
|
getDialogEvents().changed()
|
||||||
await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave))
|
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 fireEvent.click(screen.getByRole('button', { name: 'installed-Available' }))
|
||||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||||
await waitForRequestsToFinish()
|
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', () => {
|
describe('PluginCardListView search installation', () => {
|
||||||
@@ -1010,7 +1239,7 @@ describe('PluginCardListView search installation', () => {
|
|||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
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 installRequests = 0
|
||||||
let mode: 'business' | 'http' = 'business'
|
let mode: 'business' | 'http' = 'business'
|
||||||
await renderList()
|
await renderList()
|
||||||
@@ -1033,12 +1262,16 @@ describe('PluginCardListView search installation', () => {
|
|||||||
system_version_message: '版本不兼容',
|
system_version_message: '版本不兼容',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
expect(getDialogProps().plugin).toMatchObject({ id: 'SearchPlugin', plugin_name: '不兼容插件' })
|
||||||
|
expect(installRequests).toBe(0)
|
||||||
|
await getDialogProps().installHandler?.()
|
||||||
expect(mocks.toastError).toHaveBeenLastCalledWith('版本不兼容')
|
expect(mocks.toastError).toHaveBeenLastCalledWith('版本不兼容')
|
||||||
expect(installRequests).toBe(0)
|
expect(installRequests).toBe(0)
|
||||||
|
|
||||||
mocks.toastError.mockClear()
|
mocks.toastError.mockClear()
|
||||||
getDynamicButtonConfig().onClick()
|
getDynamicButtonConfig().onClick()
|
||||||
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: '业务失败插件' }))
|
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: '业务失败插件' }))
|
||||||
|
await getDialogProps().installHandler?.()
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||||
expect(installRequests).toBe(1)
|
expect(installRequests).toBe(1)
|
||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
@@ -1047,6 +1280,7 @@ describe('PluginCardListView search installation', () => {
|
|||||||
mode = 'http'
|
mode = 'http'
|
||||||
getDynamicButtonConfig().onClick()
|
getDynamicButtonConfig().onClick()
|
||||||
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: 'HTTP 失败插件' }))
|
await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: 'HTTP 失败插件' }))
|
||||||
|
await getDialogProps().installHandler?.()
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||||
expect(installRequests).toBe(2)
|
expect(installRequests).toBe(2)
|
||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
@@ -1079,6 +1313,7 @@ describe('PluginCardListView search installation', () => {
|
|||||||
|
|
||||||
getDynamicButtonConfig().onClick()
|
getDynamicButtonConfig().onClick()
|
||||||
await getDialogEvents()['open-plugin'](target)
|
await getDialogEvents()['open-plugin'](target)
|
||||||
|
await getDialogProps().installHandler?.()
|
||||||
|
|
||||||
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
|
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||||
@@ -1087,6 +1322,126 @@ describe('PluginCardListView search installation', () => {
|
|||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
|
||||||
await waitForRequestsToFinish()
|
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', () => {
|
describe('PluginCardListView folders and persistence', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user