mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
fix(plugin): align lifecycle action state handling (#637)
This commit is contained in:
@@ -141,31 +141,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/cards/PluginAppCard.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-ignored-exceptions": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"vue/no-v-text-v-html-on-component": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/cards/PluginCard.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 5
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"vue/no-v-text-v-html-on-component": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/cards/PluginMixedSortCard.vue": {
|
"src/components/cards/PluginMixedSortCard.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -270,17 +245,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/PluginMarketDetailDialog.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-ignored-exceptions": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/PluginMarketSettingDialog.vue": {
|
"src/components/dialog/PluginMarketSettingDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { ApiResponse, Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
@@ -39,7 +39,7 @@ const createConfirm = useConfirm()
|
|||||||
const accentRgb = ref('40, 169, 225')
|
const accentRgb = ref('40, 169, 225')
|
||||||
|
|
||||||
// 图片对象
|
// 图片对象
|
||||||
const imageRef = ref<any>()
|
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
|
||||||
// 获取当前插件的标签
|
// 获取当前插件的标签
|
||||||
const pluginLabels = computed(() => {
|
const pluginLabels = computed(() => {
|
||||||
@@ -51,9 +51,6 @@ const pluginLabels = computed(() => {
|
|||||||
.filter(tag => tag.length > 0)
|
.filter(tag => tag.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 图片是否加载完成
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
@@ -74,7 +71,6 @@ function closeInstallProgress() {
|
|||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
|
||||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||||
@@ -99,20 +95,16 @@ function visitPluginPage() {
|
|||||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||||
repoUrl = props.plugin?.author_url
|
repoUrl = props.plugin?.author_url
|
||||||
}
|
}
|
||||||
if (repoUrl) {
|
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
|
||||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
|
||||||
|
|
||||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
const rawUrl = new URL(repoUrl)
|
||||||
repoUrl = `https://github.com/${user}/${repo}`
|
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||||
} catch (error) {
|
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||||
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if (!repoUrl) {
|
||||||
repoUrl = props.plugin?.author_url
|
repoUrl = props.plugin?.author_url
|
||||||
}
|
}
|
||||||
window.open(repoUrl, '_blank')
|
window.open(repoUrl, '_blank')
|
||||||
@@ -159,7 +151,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
@@ -167,8 +159,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
closeInstallProgress()
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
@@ -178,8 +168,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closeInstallProgress()
|
$toast.error(
|
||||||
|
t('plugin.installFailed', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
closeInstallProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +320,7 @@ onUnmounted(() => {
|
|||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
<VIcon :icon="item.props.prependIcon" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="item.title" />
|
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { ApiResponse, Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
|
||||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||||
@@ -45,11 +46,13 @@ const display = useDisplay()
|
|||||||
const accentRgb = ref('40, 169, 225')
|
const accentRgb = ref('40, 169, 225')
|
||||||
|
|
||||||
// 图片对象
|
// 图片对象
|
||||||
const imageRef = ref<any>()
|
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 确认框
|
// 确认框
|
||||||
const createConfirm = useConfirm()
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
@@ -62,9 +65,6 @@ const menuVisible = ref(false)
|
|||||||
// 用户头像是否加载完成
|
// 用户头像是否加载完成
|
||||||
const isAvatarLoaded = ref(false)
|
const isAvatarLoaded = ref(false)
|
||||||
|
|
||||||
// 图片是否加载完成
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ watch(
|
|||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
|
||||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||||
@@ -124,17 +123,15 @@ async function uninstallPlugin() {
|
|||||||
|
|
||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
|
|
||||||
try {
|
|
||||||
// 显示等待提示框
|
|
||||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
try {
|
||||||
// 隐藏等待提示框
|
const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
|
||||||
closePluginProgress()
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
|
||||||
// 通知父组件刷新
|
|
||||||
emit('remove')
|
emit('remove')
|
||||||
|
// 生命周期成功后刷新动态导航。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(
|
$toast.error(
|
||||||
t('plugin.uninstallFailed', {
|
t('plugin.uninstallFailed', {
|
||||||
@@ -144,8 +141,15 @@ async function uninstallPlugin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closePluginProgress()
|
$toast.error(
|
||||||
|
t('plugin.uninstallFailed', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
closePluginProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +208,12 @@ async function resetPlugin() {
|
|||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
|
const result: ApiResponse<unknown> = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
||||||
// 通知父组件刷新
|
|
||||||
emit('save')
|
emit('save')
|
||||||
|
// 生命周期成功后刷新动态导航。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(
|
$toast.error(
|
||||||
t('plugin.resetFailed', {
|
t('plugin.resetFailed', {
|
||||||
@@ -218,6 +223,12 @@ async function resetPlugin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
$toast.error(
|
||||||
|
t('plugin.resetFailed', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,14 +254,13 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 显示等待提示框
|
|
||||||
showPluginProgress(
|
showPluginProgress(
|
||||||
releaseVersion
|
releaseVersion
|
||||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
@@ -258,16 +268,14 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 隐藏等待提示框
|
|
||||||
closePluginProgress()
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
versionHistoryDialogController = null
|
versionHistoryDialogController = null
|
||||||
|
|
||||||
// 通知父组件刷新
|
|
||||||
emit('save')
|
emit('save')
|
||||||
|
// 生命周期成功后刷新动态导航。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(
|
$toast.error(
|
||||||
t('plugin.updateFailed', {
|
t('plugin.updateFailed', {
|
||||||
@@ -277,8 +285,15 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closePluginProgress()
|
$toast.error(
|
||||||
|
t('plugin.updateFailed', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
closePluginProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +377,11 @@ async function showPluginAbout() {
|
|||||||
count: props.count,
|
count: props.count,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
install: () => emit('save'),
|
install: () => {
|
||||||
|
emit('save')
|
||||||
|
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
@@ -445,7 +464,7 @@ async function executePluginClone(cloneForm: {
|
|||||||
try {
|
try {
|
||||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
const result: ApiResponse<unknown> = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||||
suffix: cloneForm.suffix.trim(),
|
suffix: cloneForm.suffix.trim(),
|
||||||
name: cloneForm.name.trim(),
|
name: cloneForm.name.trim(),
|
||||||
description: cloneForm.description.trim(),
|
description: cloneForm.description.trim(),
|
||||||
@@ -453,21 +472,21 @@ async function executePluginClone(cloneForm: {
|
|||||||
icon: cloneForm.icon.trim(),
|
icon: cloneForm.icon.trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
closePluginProgress()
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||||
cloneDialogController?.close()
|
cloneDialogController?.close()
|
||||||
cloneDialogController = null
|
cloneDialogController = null
|
||||||
// 通知父组件刷新
|
|
||||||
emit('remove')
|
emit('remove')
|
||||||
|
// 生命周期成功后刷新动态导航。
|
||||||
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
} else {
|
} else {
|
||||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closePluginProgress()
|
|
||||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
closePluginProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +600,7 @@ const dropdownItems = ref([
|
|||||||
// 监听插件状态变化
|
// 监听插件状态变化
|
||||||
watch(
|
watch(
|
||||||
() => props.plugin?.has_update,
|
() => props.plugin?.has_update,
|
||||||
(newHasUpdate, _) => {
|
newHasUpdate => {
|
||||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||||
|
|
||||||
@@ -593,7 +612,7 @@ watch(
|
|||||||
// 监听插件窗口状态变化
|
// 监听插件窗口状态变化
|
||||||
watch(
|
watch(
|
||||||
() => props.plugin?.page_open,
|
() => props.plugin?.page_open,
|
||||||
(newOpenState, _) => {
|
newOpenState => {
|
||||||
if (newOpenState) openPluginDetail()
|
if (newOpenState) openPluginDetail()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -695,7 +714,7 @@ watch(
|
|||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon :icon="item.props.prependIcon" />
|
<VIcon :icon="item.props.prependIcon" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle v-text="item.title" />
|
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
accentFromImage: vi.fn(),
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: mocks.apiGet },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const plugin: Plugin = {
|
const plugin: Plugin = {
|
||||||
@@ -16,7 +44,32 @@ const plugin: Plugin = {
|
|||||||
installed: false,
|
installed: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
emits: ['error', 'load'],
|
||||||
|
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||||
|
})
|
||||||
|
|
||||||
describe('PluginAppCard rating badge', () => {
|
describe('PluginAppCard rating badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.dialogCloses.length = 0
|
||||||
|
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||||
|
const close = vi.fn()
|
||||||
|
mocks.dialogCloses.push(close)
|
||||||
|
return {
|
||||||
|
close,
|
||||||
|
id: mocks.dialogCloses.length,
|
||||||
|
updateProps: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||||
const unrated = await renderWithProviders(PluginAppCard, {
|
const unrated = await renderWithProviders(PluginAppCard, {
|
||||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||||
@@ -32,4 +85,175 @@ describe('PluginAppCard rating badge', () => {
|
|||||||
expect(badge).toHaveTextContent('4.3')
|
expect(badge).toHaveTextContent('4.3')
|
||||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports an HTTP failure without emitting install or hiding the card', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValue(new Error('network unavailable'))
|
||||||
|
const lifecyclePlugin = {
|
||||||
|
...plugin,
|
||||||
|
history: { 'v1.0.0': '初始版本' },
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
}
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: { plugin: lifecyclePlugin },
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||||
|
expect(menuButton).not.toBeNull()
|
||||||
|
await fireEvent.click(menuButton!)
|
||||||
|
await fireEvent.click(await screen.findByText('版本历史'))
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await dialogEvents.update()
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
expect(container.querySelector('.plugin-app-card-hover-area')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens market details and forwards only the detail completion event', async () => {
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: { plugin, count: 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector('.v-card')!)
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ plugin, count: 12 })
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||||
|
closeOn: ['close', 'install', 'update:modelValue'],
|
||||||
|
})
|
||||||
|
const detailEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||||
|
detailEvents.install()
|
||||||
|
expect(emitted().install).toHaveLength(1)
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installs a selected release with exact parameters and emits completion', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ success: true })
|
||||||
|
const lifecyclePlugin = {
|
||||||
|
...plugin,
|
||||||
|
history: { 'v1.0.0': '初始版本' },
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
}
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: { plugin: lifecyclePlugin },
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||||
|
await fireEvent.click(menuButton!)
|
||||||
|
await fireEvent.click(await screen.findByText('版本历史'))
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||||
|
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
release_version: '0.9.0',
|
||||||
|
repo_url: 'https://github.com/example/releases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||||
|
expect(emitted().install).toHaveLength(1)
|
||||||
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||||
|
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the version dialog open and emits nothing on a business failure', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ success: false, message: '下载失败' })
|
||||||
|
const lifecyclePlugin = {
|
||||||
|
...plugin,
|
||||||
|
history: { 'v1.0.0': '初始版本' },
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
}
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: { plugin: lifecyclePlugin },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('版本历史'))
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||||
|
await dialogEvents.update()
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:下载失败')
|
||||||
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks incompatible latest installs and honors a cancelled release confirmation', async () => {
|
||||||
|
const lifecyclePlugin = {
|
||||||
|
...plugin,
|
||||||
|
history: { 'v1.0.0': '初始版本' },
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
system_version_compatible: false,
|
||||||
|
system_version_message: '需要更高版本',
|
||||||
|
}
|
||||||
|
const { container } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: { plugin: lifecyclePlugin },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('版本历史'))
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await dialogEvents.update()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false)
|
||||||
|
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders normalized labels, handles image events, and opens a raw GitHub repository', async () => {
|
||||||
|
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
|
const { container } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: {
|
||||||
|
plugin: {
|
||||||
|
...plugin,
|
||||||
|
plugin_icon: 'https://example.com/plugin.png',
|
||||||
|
plugin_label: ' 工具, 自动化, ,工具 ',
|
||||||
|
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getAllByText('工具')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('自动化')).toBeInTheDocument()
|
||||||
|
const image = screen.getByTestId('plugin-image')
|
||||||
|
await fireEvent.click(image)
|
||||||
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||||
|
await fireEvent.contextMenu(image)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('项目主页'))
|
||||||
|
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the author page for a local plugin project link', async () => {
|
||||||
|
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
|
const { container } = await renderWithProviders(PluginAppCard, {
|
||||||
|
props: {
|
||||||
|
plugin: {
|
||||||
|
...plugin,
|
||||||
|
is_local: true,
|
||||||
|
repo_url: 'local://DemoPlugin',
|
||||||
|
author_url: 'https://github.com/example-author',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('项目主页'))
|
||||||
|
expect(open).toHaveBeenCalledWith('https://github.com/example-author', '_blank')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import type { Plugin } from '@/api/types'
|
||||||
|
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||||
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
accentFromImage: vi.fn(),
|
||||||
|
apiDelete: vi.fn(),
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
delete: mocks.apiDelete,
|
||||||
|
get: mocks.apiGet,
|
||||||
|
post: mocks.apiPost,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||||
|
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
plugin_name: '演示插件',
|
||||||
|
plugin_desc: '用于测试插件生命周期',
|
||||||
|
plugin_version: '1.0.0',
|
||||||
|
plugin_author: 'MoviePilot',
|
||||||
|
installed: true,
|
||||||
|
state: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
emits: ['error', 'load'],
|
||||||
|
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PluginCard lifecycle actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||||
|
mocks.apiDelete.mockReset()
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.apiPost.mockReset()
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.dialogCloses.length = 0
|
||||||
|
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||||
|
const close = vi.fn()
|
||||||
|
mocks.dialogCloses.push(close)
|
||||||
|
return {
|
||||||
|
close,
|
||||||
|
id: mocks.dialogCloses.length,
|
||||||
|
updateProps: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes plugin sidebar navigation after uninstall succeeds', async () => {
|
||||||
|
mocks.apiDelete.mockResolvedValue({ success: true })
|
||||||
|
const { container, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||||
|
expect(menuButton).not.toBeNull()
|
||||||
|
await fireEvent.click(menuButton!)
|
||||||
|
await fireEvent.click(await screen.findByText('卸载'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('plugin/DemoPlugin'))
|
||||||
|
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 卸载成功!')
|
||||||
|
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors uninstall cancellation and preserves the card on business failure', async () => {
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false)
|
||||||
|
const cancelled = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(cancelled.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('卸载'))
|
||||||
|
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||||
|
cancelled.unmount()
|
||||||
|
|
||||||
|
mocks.confirm.mockResolvedValueOnce(true)
|
||||||
|
mocks.apiDelete.mockResolvedValueOnce({ success: false, message: '仍有任务运行' })
|
||||||
|
const failed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(failed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('卸载'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 卸载失败:仍有任务运行'))
|
||||||
|
expect(failed.emitted()).not.toHaveProperty('remove')
|
||||||
|
expect(failed.container.querySelector('.plugin-card-hover-area')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports uninstall HTTP failures and always closes progress', async () => {
|
||||||
|
mocks.apiDelete.mockRejectedValue(new Error('network unavailable'))
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('卸载'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('卸载失败')))
|
||||||
|
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||||
|
expect(emitted()).not.toHaveProperty('remove')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets plugin data only after confirmation and refreshes navigation on success', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ success: true })
|
||||||
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('重置'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('plugin/reset/DemoPlugin'))
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: expect.stringContaining('演示插件') }),
|
||||||
|
)
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 数据已重置')
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(emitted().save).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports reset business and HTTP failures without emitting save', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '无法清理数据' })
|
||||||
|
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('重置'))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:无法清理数据'))
|
||||||
|
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||||
|
businessFailed.unmount()
|
||||||
|
|
||||||
|
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('重置'))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:服务器连接失败'))
|
||||||
|
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates from a confirmed Release with exact query parameters', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ success: true })
|
||||||
|
const updatablePlugin = {
|
||||||
|
...plugin,
|
||||||
|
has_update: true,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
}
|
||||||
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||||
|
props: { plugin: updatablePlugin },
|
||||||
|
})
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('更新'))
|
||||||
|
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
release_version: '0.9.0',
|
||||||
|
repo_url: 'https://github.com/example/releases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(emitted().save).toHaveLength(1)
|
||||||
|
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||||
|
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks an incompatible latest update without sending a request', async () => {
|
||||||
|
const updatablePlugin = {
|
||||||
|
...plugin,
|
||||||
|
has_update: true,
|
||||||
|
system_version_compatible: false,
|
||||||
|
system_version_message: '需要更高版本',
|
||||||
|
}
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('更新'))
|
||||||
|
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||||
|
await versionEvents.update()
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps version history open after update business and HTTP failures', async () => {
|
||||||
|
const updatablePlugin = {
|
||||||
|
...plugin,
|
||||||
|
has_update: true,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
}
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '校验失败' })
|
||||||
|
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||||
|
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('更新'))
|
||||||
|
let versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||||
|
await versionEvents.update()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:校验失败')
|
||||||
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||||
|
businessFailed.unmount()
|
||||||
|
|
||||||
|
mocks.openSharedDialog.mockClear()
|
||||||
|
mocks.dialogCloses.length = 0
|
||||||
|
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||||
|
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('更新'))
|
||||||
|
versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||||
|
await versionEvents.update()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||||
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a clone with trimmed form values and refreshes navigation', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
|
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
clone: (form: {
|
||||||
|
suffix: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
version: string
|
||||||
|
icon: string
|
||||||
|
}) => Promise<void>
|
||||||
|
}
|
||||||
|
await cloneEvents.clone({
|
||||||
|
suffix: ' Test ',
|
||||||
|
name: '演示分身',
|
||||||
|
description: ' 独立配置 ',
|
||||||
|
version: ' 1.0.1 ',
|
||||||
|
icon: ' https://example.com/icon.png ',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/clone/DemoPlugin', {
|
||||||
|
suffix: 'Test',
|
||||||
|
name: '演示分身',
|
||||||
|
description: '独立配置',
|
||||||
|
version: '1.0.1',
|
||||||
|
icon: 'https://example.com/icon.png',
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(emitted().remove).toHaveLength(1)
|
||||||
|
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||||
|
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an empty clone suffix before calling the API', async () => {
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
|
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
clone: (form: {
|
||||||
|
suffix: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
version: string
|
||||||
|
icon: string
|
||||||
|
}) => Promise<void>
|
||||||
|
}
|
||||||
|
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps clone dialog open after business and HTTP failures', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ success: false, message: '后缀已存在' })
|
||||||
|
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
|
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
clone: (form: {
|
||||||
|
suffix: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
version: string
|
||||||
|
icon: string
|
||||||
|
}) => Promise<void>
|
||||||
|
}
|
||||||
|
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
|
||||||
|
await cloneEvents.clone(form)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
||||||
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
businessFailed.unmount()
|
||||||
|
|
||||||
|
mocks.openSharedDialog.mockClear()
|
||||||
|
mocks.dialogCloses.length = 0
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
|
cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
clone: (value: typeof form) => Promise<void>
|
||||||
|
}
|
||||||
|
await cloneEvents.clone(form)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败')
|
||||||
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
expect(httpFailed.emitted()).not.toHaveProperty('remove')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens data and config surfaces with reciprocal switch contracts', async () => {
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginCard, {
|
||||||
|
props: { plugin: { ...plugin, has_page: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector('.v-card')!)
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||||
|
plugin: expect.objectContaining({ id: 'DemoPlugin' }),
|
||||||
|
})
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'switch'] })
|
||||||
|
|
||||||
|
const dataEvents = mocks.openSharedDialog.mock.calls[0][2] as { switch: () => void }
|
||||||
|
dataEvents.switch()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[1][3]).toEqual({ closeOn: ['close', 'save', 'switch'] })
|
||||||
|
|
||||||
|
const configEvents = mocks.openSharedDialog.mock.calls[1][2] as { save: () => void; switch: () => void }
|
||||||
|
configEvents.save()
|
||||||
|
expect(emitted().save).toHaveLength(1)
|
||||||
|
configEvents.switch()
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||||
|
const { container } = await renderWithProviders(PluginCard, {
|
||||||
|
props: {
|
||||||
|
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
global: { stubs: { VImg: ImageStub } },
|
||||||
|
})
|
||||||
|
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||||
|
await fireEvent.click(image)
|
||||||
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||||
|
await fireEvent.contextMenu(image)
|
||||||
|
await fireEvent.click(authorImage)
|
||||||
|
await fireEvent.click(container.querySelector('.v-card')!)
|
||||||
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the shared log dialog from the menu', async () => {
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('查看日志'))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ plugin },
|
||||||
|
{},
|
||||||
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens plugin detail from an external action exactly once', async () => {
|
||||||
|
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
||||||
|
props: { plugin, action: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await rerender({ plugin, action: true })
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
expect(emitted().actionDone).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||||
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -54,9 +55,11 @@ describe('PluginCard about menu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||||
const { container } = await renderWithProviders(PluginCard, {
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||||
props: { plugin, count: 24 },
|
props: { plugin, count: 24 },
|
||||||
})
|
})
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||||
expect(menuButton).not.toBeNull()
|
expect(menuButton).not.toBeNull()
|
||||||
@@ -76,5 +79,47 @@ describe('PluginCard about menu', () => {
|
|||||||
repo_url: 'https://github.com/example/plugins',
|
repo_url: 'https://github.com/example/plugins',
|
||||||
})
|
})
|
||||||
expect(dialogProps.count).toBe(24)
|
expect(dialogProps.count).toBe(24)
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||||
|
closeOn: ['close', 'install', 'update:modelValue'],
|
||||||
|
})
|
||||||
|
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||||
|
dialogEvents.install()
|
||||||
|
expect(emitted().save).toHaveLength(1)
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
||||||
|
if (url === 'plugin/') {
|
||||||
|
return Promise.resolve([
|
||||||
|
{
|
||||||
|
...plugin,
|
||||||
|
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${url}`)
|
||||||
|
})
|
||||||
|
const replace = vi.fn()
|
||||||
|
const popup = {
|
||||||
|
close: vi.fn(),
|
||||||
|
location: { replace },
|
||||||
|
opener: window,
|
||||||
|
} as unknown as Window
|
||||||
|
const open = vi.spyOn(window, 'open').mockReturnValue(popup)
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('项目主页'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/', {
|
||||||
|
params: { force: false, state: 'market' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(open).toHaveBeenCalledWith('about:blank', '_blank')
|
||||||
|
await waitFor(() => expect(replace).toHaveBeenCalledWith('https://github.com/example/plugins'))
|
||||||
|
expect(popup.opener).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ const selectedRating = ref(props.plugin?.user_rating || 0)
|
|||||||
const ratingLoading = ref(false)
|
const ratingLoading = ref(false)
|
||||||
const ratingSubmitting = ref(false)
|
const ratingSubmitting = ref(false)
|
||||||
|
|
||||||
// 图片对象
|
|
||||||
const imageRef = ref<any>()
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
@@ -97,20 +94,16 @@ function visitPluginPage() {
|
|||||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||||
repoUrl = props.plugin?.author_url
|
repoUrl = props.plugin?.author_url
|
||||||
}
|
}
|
||||||
if (repoUrl) {
|
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
|
||||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
|
||||||
|
|
||||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
const rawUrl = new URL(repoUrl)
|
||||||
repoUrl = `https://github.com/${user}/${repo}`
|
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||||
} catch (error) {
|
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||||
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if (!repoUrl) {
|
||||||
repoUrl = props.plugin?.author_url
|
repoUrl = props.plugin?.author_url
|
||||||
}
|
}
|
||||||
window.open(repoUrl, '_blank')
|
window.open(repoUrl, '_blank')
|
||||||
@@ -136,6 +129,8 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showInstallProgress(
|
showInstallProgress(
|
||||||
isInstalled.value && !releaseVersion
|
isInstalled.value && !releaseVersion
|
||||||
@@ -146,16 +141,14 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
closeInstallProgress()
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(
|
$toast.success(
|
||||||
isInstalled.value
|
isInstalled.value
|
||||||
@@ -167,11 +160,18 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
visible.value = false
|
visible.value = false
|
||||||
emit('install')
|
emit('install')
|
||||||
} else {
|
} else {
|
||||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
$toast.error(t(failureMessageKey, { name: props.plugin?.plugin_name, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closeInstallProgress()
|
$toast.error(
|
||||||
|
t(failureMessageKey, {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
closeInstallProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ onUnmounted(() => {
|
|||||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||||
<div class="mx-auto mt-5">
|
<div class="mx-auto mt-5">
|
||||||
<VAvatar size="64">
|
<VAvatar size="64">
|
||||||
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
<VImg :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ const resolvedHistory = computed(() => {
|
|||||||
|
|
||||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||||
|
|
||||||
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
const latestActionText = computed(() =>
|
||||||
|
props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'),
|
||||||
|
)
|
||||||
|
|
||||||
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||||
|
|
||||||
@@ -187,29 +189,48 @@ watch(
|
|||||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||||
<VProgressCircular indeterminate color="primary" />
|
<VProgressCircular indeterminate color="primary" />
|
||||||
</div>
|
</div>
|
||||||
<VCardText v-else-if="loadError && !hasHistory">
|
<template v-else>
|
||||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
<VCardText v-if="loadError || releaseError" class="pb-0">
|
||||||
|
<VAlert v-if="loadError" type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||||
|
<VAlert
|
||||||
|
v-if="releaseError"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
:class="{ 'mt-2': loadError }"
|
||||||
|
:text="releaseError"
|
||||||
|
/>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
<VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
|
||||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<template v-else>
|
|
||||||
<VCardText v-if="releaseError" class="pb-0">
|
|
||||||
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
|
||||||
</VCardText>
|
|
||||||
<VersionHistory
|
<VersionHistory
|
||||||
|
v-if="hasHistory"
|
||||||
:history="resolvedHistory"
|
:history="resolvedHistory"
|
||||||
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||||
>
|
>
|
||||||
<template #meta="{ version }">
|
<template #meta="{ version }">
|
||||||
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||||
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
<span
|
||||||
|
v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)"
|
||||||
|
class="plugin-release-meta__date"
|
||||||
|
>
|
||||||
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||||
</span>
|
</span>
|
||||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
<VChip
|
||||||
|
v-if="releaseItemByHistoryVersion(version)?.is_latest"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
{{ t('plugin.latestVersion') }}
|
{{ t('plugin.latestVersion') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
<VChip
|
||||||
|
v-if="releaseItemByHistoryVersion(version)?.is_current"
|
||||||
|
size="x-small"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
{{ t('plugin.currentVersion') }}
|
{{ t('plugin.currentVersion') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,9 +250,7 @@ watch(
|
|||||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
releaseItemByHistoryVersion(version)?.is_latest
|
releaseItemByHistoryVersion(version)?.is_latest ? latestActionText : t('plugin.installReleaseVersion')
|
||||||
? latestActionText
|
|
||||||
: t('plugin.installReleaseVersion')
|
|
||||||
}}
|
}}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
@@ -248,11 +267,7 @@ watch(
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||||
/>
|
/>
|
||||||
<VBtn
|
<VBtn @click="handleUpdate()" block :disabled="resolvedPlugin?.system_version_compatible === false">
|
||||||
@click="handleUpdate()"
|
|
||||||
block
|
|
||||||
:disabled="resolvedPlugin?.system_version_compatible === false"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||||
</template>
|
</template>
|
||||||
@@ -289,5 +304,4 @@ watch(
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import type { Plugin, PluginRating } from '@/api/types'
|
|||||||
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
|
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import type { Stubs } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
apiGet: vi.fn(),
|
apiGet: vi.fn(),
|
||||||
apiPost: vi.fn(),
|
apiPost: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
dialogClose: vi.fn(),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
toastSuccess: vi.fn(),
|
toastSuccess: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -23,6 +28,14 @@ vi.mock('vue-toastification', () => ({
|
|||||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
const basePlugin: Plugin = {
|
const basePlugin: Plugin = {
|
||||||
id: 'DemoPlugin',
|
id: 'DemoPlugin',
|
||||||
plugin_name: '演示插件',
|
plugin_name: '演示插件',
|
||||||
@@ -39,25 +52,45 @@ const ratingResult: PluginRating = {
|
|||||||
user_rating: 4.0,
|
user_rating: 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDialog(plugin: Plugin) {
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
emits: ['error'],
|
||||||
|
template: '<button data-testid="plugin-image" @contextmenu.prevent="$emit(\'error\')" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderDialog(plugin: Plugin, stubs: Stubs = {}) {
|
||||||
return renderWithProviders(PluginMarketDetailDialog, {
|
return renderWithProviders(PluginMarketDetailDialog, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
plugin,
|
plugin,
|
||||||
},
|
},
|
||||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
global: {
|
||||||
|
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||||
|
stubs,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PluginMarketDetailDialog', () => {
|
describe('PluginMarketDetailDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiGet.mockReset().mockResolvedValue(ratingResult)
|
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
})
|
||||||
mocks.apiPost.mockReset().mockResolvedValue({
|
mocks.apiPost.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 },
|
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 },
|
||||||
})
|
})
|
||||||
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
|
mocks.dialogClose.mockReset()
|
||||||
|
mocks.openSharedDialog.mockReset().mockReturnValue({
|
||||||
|
close: mocks.dialogClose,
|
||||||
|
id: 1,
|
||||||
|
updateProps: vi.fn(),
|
||||||
|
})
|
||||||
mocks.toastError.mockReset()
|
mocks.toastError.mockReset()
|
||||||
mocks.toastSuccess.mockReset()
|
mocks.toastSuccess.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows install action and readonly rating for a market plugin', async () => {
|
it('shows install action and readonly rating for a market plugin', async () => {
|
||||||
@@ -102,4 +135,214 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect(screen.getByText('我的评分')).toBeInTheDocument()
|
expect(screen.getByText('我的评分')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits installation completion only after installation succeeds', async () => {
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: false,
|
||||||
|
release_version: undefined,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||||
|
expect(emitted().install).toHaveLength(1)
|
||||||
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the detail open and emits nothing after a business failure', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
return Promise.resolve({ success: false, message: '安装包损坏' })
|
||||||
|
})
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:安装包损坏')
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an HTTP install failure without closing or emitting completion', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
return Promise.reject(new Error('network unavailable'))
|
||||||
|
})
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||||
|
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks an incompatible latest install before sending a request', async () => {
|
||||||
|
await renderDialog({
|
||||||
|
...basePlugin,
|
||||||
|
installed: false,
|
||||||
|
system_version_compatible: false,
|
||||||
|
system_version_message: '需要更高版本',
|
||||||
|
})
|
||||||
|
|
||||||
|
const installButton = await screen.findByRole('button', { name: '安装到本地' })
|
||||||
|
expect(installButton).toBeDisabled()
|
||||||
|
expect(screen.getByText('需要更高版本')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installs a confirmed historical Release with its repo URL', async () => {
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: false, release: true })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||||
|
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||||
|
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
release_version: '0.9.0',
|
||||||
|
repo_url: 'https://github.com/example/releases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(emitted().install).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows update semantics for an installed plugin with a newer version', async () => {
|
||||||
|
await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
release_version: undefined,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an HTTP update failure with update semantics', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
return Promise.reject(new Error('network unavailable'))
|
||||||
|
})
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||||
|
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a business update failure with the backend message', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
return Promise.resolve({ success: false, message: '更新包损坏' })
|
||||||
|
})
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||||
|
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:更新包损坏')
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(emitted()).not.toHaveProperty('install')
|
||||||
|
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forces a latest Release update when the installed plugin snapshot has no update flag', async () => {
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false, release: true })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||||
|
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
await versionEvents.update(undefined, 'https://github.com/example/releases')
|
||||||
|
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
release_version: undefined,
|
||||||
|
repo_url: 'https://github.com/example/releases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||||
|
expect(emitted().install).toHaveLength(1)
|
||||||
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles image fallback and opens a raw GitHub repository from the author row', async () => {
|
||||||
|
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
|
await renderDialog(
|
||||||
|
{
|
||||||
|
...basePlugin,
|
||||||
|
installed: false,
|
||||||
|
plugin_icon: 'https://example.com/plugin.png',
|
||||||
|
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||||
|
},
|
||||||
|
{ VImg: ImageStub },
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.contextMenu(screen.getByTestId('plugin-image'))
|
||||||
|
await fireEvent.click(await screen.findByText('MoviePilot'))
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports rating business and HTTP failures with stable feedback', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
||||||
|
const businessFailed = await renderDialog({ ...basePlugin, installed: true })
|
||||||
|
const businessButton = await screen.findByRole('button', { name: '提交评分' })
|
||||||
|
await waitFor(() => expect(businessButton).toBeEnabled())
|
||||||
|
await fireEvent.click(businessButton)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:未知')
|
||||||
|
businessFailed.unmount()
|
||||||
|
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
await renderDialog({ ...basePlugin, installed: true })
|
||||||
|
const httpButton = await screen.findByRole('button', { name: '提交评分' })
|
||||||
|
await waitFor(() => expect(httpButton).toBeEnabled())
|
||||||
|
await fireEvent.click(httpButton)
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps plugin actions usable when rating loading fails', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValue(new Error('rating unavailable'))
|
||||||
|
await renderDialog({ ...basePlugin, installed: false })
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens installed version history without an update action and closes through the model contract', async () => {
|
||||||
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false })
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||||
|
actionMode: 'update',
|
||||||
|
showUpdateAction: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||||
|
expect(closeButton).not.toBeNull()
|
||||||
|
await fireEvent.click(closeButton!)
|
||||||
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
expect(emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
|
import type { Plugin, PluginReleaseVersionsResponse } from '@/api/types'
|
||||||
|
import PluginVersionHistoryDialog from '@/components/dialog/PluginVersionHistoryDialog.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: mocks.apiGet },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const installedPlugin: Plugin = {
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
plugin_name: '演示插件',
|
||||||
|
plugin_version: '1.0.0',
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
installed: true,
|
||||||
|
release: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases: PluginReleaseVersionsResponse = {
|
||||||
|
release_supported: true,
|
||||||
|
latest_version: '2.0.0',
|
||||||
|
current_version: '1.0.0',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
version: '2.0.0',
|
||||||
|
tag_name: 'v2.0.0',
|
||||||
|
body: '最新说明',
|
||||||
|
published_at: '2026-08-01T00:00:00Z',
|
||||||
|
is_latest: true,
|
||||||
|
is_current: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
tag_name: 'v1.0.0',
|
||||||
|
body: '当前说明',
|
||||||
|
is_latest: false,
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '0.9.0',
|
||||||
|
tag_name: 'v0.9.0',
|
||||||
|
body: '旧版说明',
|
||||||
|
published_at: 'invalid-date',
|
||||||
|
is_latest: false,
|
||||||
|
is_current: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(props: Record<string, unknown>) {
|
||||||
|
return renderWithProviders(PluginVersionHistoryDialog, {
|
||||||
|
props,
|
||||||
|
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginVersionHistoryDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||||
|
if (url === 'plugin/history/DemoPlugin') {
|
||||||
|
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
|
||||||
|
}
|
||||||
|
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
|
||||||
|
throw new Error(`Unexpected request: ${url}`)
|
||||||
|
})
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads installed history and releases, marks versions, and emits exact update arguments', async () => {
|
||||||
|
const { emitted } = await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: installedPlugin,
|
||||||
|
showUpdateAction: true,
|
||||||
|
actionMode: 'update',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('最新')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('当前')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('invalid-date')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
|
||||||
|
params: { force: true },
|
||||||
|
})
|
||||||
|
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'plugin/releases/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: true,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '安装' }))
|
||||||
|
expect(emitted().update).toEqual([['0.9.0', 'https://github.com/example/plugins']])
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '更新到最新版本' }))
|
||||||
|
expect(emitted().update).toEqual([
|
||||||
|
['0.9.0', 'https://github.com/example/plugins'],
|
||||||
|
[undefined, 'https://github.com/example/plugins'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses market metadata directly and emits latest installation without a release version', async () => {
|
||||||
|
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
|
||||||
|
const { emitted } = await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: marketPlugin,
|
||||||
|
actionMode: 'install',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/history/DemoPlugin', expect.anything())
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/releases/DemoPlugin', {
|
||||||
|
params: {
|
||||||
|
force: false,
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const installButtons = screen.getAllByRole('button', { name: '安装' })
|
||||||
|
await fireEvent.click(installButtons[0])
|
||||||
|
expect(emitted().update).toEqual([[undefined, 'https://github.com/example/plugins']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes a Release request failure from an empty history', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
|
||||||
|
await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: { ...installedPlugin, installed: false, history: {} },
|
||||||
|
actionMode: 'install',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('Release 版本加载失败')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('暂未获取到更新说明')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows history request failures and true empty history as different states', async () => {
|
||||||
|
mocks.apiGet.mockRejectedValueOnce(new Error('history unavailable'))
|
||||||
|
const failed = await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: installedPlugin,
|
||||||
|
})
|
||||||
|
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
|
||||||
|
failed.unmount()
|
||||||
|
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue({ ...installedPlugin, release: false, history: {} })
|
||||||
|
await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: installedPlugin,
|
||||||
|
})
|
||||||
|
expect(await screen.findByText('暂未获取到更新说明')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes through the model contract', async () => {
|
||||||
|
const { emitted } = await renderDialog({
|
||||||
|
modelValue: true,
|
||||||
|
plugin: installedPlugin,
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||||
|
expect(closeButton).not.toBeNull()
|
||||||
|
await fireEvent.click(closeButton!)
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
expect(emitted().close).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -370,6 +370,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/cards/SiteCard.vue',
|
'src/components/cards/SiteCard.vue',
|
||||||
'src/components/cards/DownloadingCard.vue',
|
'src/components/cards/DownloadingCard.vue',
|
||||||
'src/components/cards/PluginFolderCard.vue',
|
'src/components/cards/PluginFolderCard.vue',
|
||||||
|
'src/components/cards/PluginCard.vue',
|
||||||
|
'src/components/cards/PluginAppCard.vue',
|
||||||
|
'src/components/dialog/PluginMarketDetailDialog.vue',
|
||||||
|
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
'src/views/reorganize/TransferHistoryView.vue',
|
'src/views/reorganize/TransferHistoryView.vue',
|
||||||
@@ -418,6 +422,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/components/cards/PluginCard.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
'src/components/cards/PluginAppCard.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
'src/components/dialog/PluginMarketDetailDialog.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
'src/components/dialog/PluginVersionHistoryDialog.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
'src/views/reorganize/FileBrowserView.vue': {
|
'src/views/reorganize/FileBrowserView.vue': {
|
||||||
branches: 80,
|
branches: 80,
|
||||||
functions: 85,
|
functions: 85,
|
||||||
|
|||||||
Reference in New Issue
Block a user