mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-14 01:55:57 +08:00
feat(plugin): add ratings and about dialog
This commit is contained in:
@@ -706,6 +706,20 @@ export interface Plugin {
|
||||
add_time?: number
|
||||
// 页面打开状态
|
||||
page_open?: boolean
|
||||
// 平均评分
|
||||
average_rating?: number
|
||||
// 评分人数
|
||||
rating_count?: number
|
||||
// 当前安装实例评分
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件评分结果
|
||||
export interface PluginRating {
|
||||
plugin_id: string
|
||||
average_rating: number
|
||||
rating_count: number
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
|
||||
@@ -242,88 +242,113 @@ onUnmounted(() => {
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="plugin-app-card__title text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
:class="{ 'plugin-app-card__title--with-rating': (props.plugin?.rating_count || 0) > 0 }"
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||
>
|
||||
{{ tag }}
|
||||
</VChip>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
>
|
||||
{{ tag }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div
|
||||
v-if="(props.plugin?.rating_count || 0) > 0"
|
||||
class="plugin-app-card__rating"
|
||||
:aria-label="
|
||||
t('plugin.ratingSummary', {
|
||||
rating: Number(props.plugin?.average_rating || 0).toFixed(1),
|
||||
count: props.plugin?.rating_count || 0,
|
||||
})
|
||||
"
|
||||
:title="
|
||||
t('plugin.ratingSummary', {
|
||||
rating: Number(props.plugin?.average_rating || 0).toFixed(1),
|
||||
count: props.plugin?.rating_count || 0,
|
||||
})
|
||||
"
|
||||
>
|
||||
<VIcon icon="mdi-star" color="warning" size="16" />
|
||||
<span>{{ Number(props.plugin?.average_rating || 0).toFixed(1) }}</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -344,12 +369,31 @@ onUnmounted(() => {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__title--with-rating {
|
||||
padding-inline-end: 4rem !important;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag {
|
||||
flex: 0 0 auto;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-app-card__rating {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
inset-block-start: 0.625rem;
|
||||
inset-inline-end: 0.625rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 65%);
|
||||
}
|
||||
|
||||
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -16,6 +16,7 @@ const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginData
|
||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('../dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
@@ -69,6 +70,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let marketDetailDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
@@ -305,10 +307,7 @@ function hasRemoteRepoUrl(plugin?: Plugin) {
|
||||
function resolvePluginPageUrl(plugin?: Plugin) {
|
||||
if (!plugin) return ''
|
||||
|
||||
const repoUrl =
|
||||
hasRemoteRepoUrl(plugin)
|
||||
? normalizePluginRepoUrl(plugin.repo_url)
|
||||
: plugin.author_url
|
||||
const repoUrl = hasRemoteRepoUrl(plugin) ? normalizePluginRepoUrl(plugin.repo_url) : plugin.author_url
|
||||
|
||||
return repoUrl || plugin.author_url || ''
|
||||
}
|
||||
@@ -332,27 +331,49 @@ async function fetchMarketPlugin(pluginId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 访问插件项目主页
|
||||
async function visitPluginPage() {
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
/** 读取已安装插件的最新市场详情,并保留本地运行状态字段。 */
|
||||
async function fetchInstalledPluginDetail() {
|
||||
let pluginDetail = props.plugin
|
||||
|
||||
if (popup) popup.opener = null
|
||||
if (!props.plugin?.id) return pluginDetail
|
||||
|
||||
try {
|
||||
if (props.plugin?.id) {
|
||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 历史接口可能只返回部分字段,合并原卡片数据避免丢失 author_url 兜底。
|
||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}) } as Plugin
|
||||
}
|
||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}), installed: true } as Plugin
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return pluginDetail
|
||||
}
|
||||
|
||||
/** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */
|
||||
async function showPluginAbout() {
|
||||
const pluginDetail = await fetchInstalledPluginDetail()
|
||||
if (!pluginDetail) return
|
||||
|
||||
marketDetailDialogController?.close()
|
||||
marketDetailDialogController = openSharedDialog(
|
||||
PluginMarketDetailDialog,
|
||||
{
|
||||
plugin: pluginDetail,
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('save'),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 访问插件项目主页
|
||||
async function visitPluginPage() {
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
let pluginDetail = await fetchInstalledPluginDetail()
|
||||
|
||||
if (popup) popup.opener = null
|
||||
|
||||
if (!hasRemoteRepoUrl(pluginDetail)) {
|
||||
const marketPlugin = await fetchMarketPlugin(props.plugin?.id)
|
||||
@@ -409,7 +430,13 @@ function showPluginClone() {
|
||||
}
|
||||
|
||||
// 执行插件分身
|
||||
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
|
||||
async function executePluginClone(cloneForm: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) {
|
||||
if (!cloneForm.suffix.trim()) {
|
||||
$toast.error(t('plugin.suffixRequired'))
|
||||
return
|
||||
@@ -447,10 +474,21 @@ async function executePluginClone(cloneForm: { suffix: string; name: string; des
|
||||
onUnmounted(() => {
|
||||
closePluginProgress()
|
||||
cloneDialogController?.close()
|
||||
marketDetailDialogController?.close()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: t('plugin.about'),
|
||||
value: 10,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: showPluginAbout,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.viewData'),
|
||||
value: 1,
|
||||
@@ -581,97 +619,96 @@ watch(
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<template #default>
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<template #default>
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
35
src/components/cards/__tests__/PluginAppCardRating.spec.ts
Normal file
35
src/components/cards/__tests__/PluginAppCardRating.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试市场卡片评分',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
installed: false,
|
||||
}
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||
const unrated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||
})
|
||||
expect(unrated.container.querySelector('.plugin-app-card__rating')).toBeNull()
|
||||
expect(unrated.container.querySelector('.plugin-app-card__title--with-rating')).toBeNull()
|
||||
unrated.unmount()
|
||||
|
||||
const rated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 4.3, rating_count: 12 } },
|
||||
})
|
||||
const badge = rated.container.querySelector('.plugin-app-card__rating')
|
||||
expect(badge).toHaveTextContent('4.3')
|
||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
80
src/components/cards/__tests__/PluginCardAbout.spec.ts
Normal file
80
src/components/cards/__tests__/PluginCardAbout.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.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(),
|
||||
closeDialog: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
delete: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
}))
|
||||
|
||||
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',
|
||||
author_url: 'https://github.com/MoviePilot',
|
||||
installed: true,
|
||||
state: true,
|
||||
}
|
||||
|
||||
describe('PluginCard about menu', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue({
|
||||
...plugin,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
mocks.closeDialog.mockReset()
|
||||
mocks.openSharedDialog.mockReset().mockReturnValue({ close: mocks.closeDialog })
|
||||
})
|
||||
|
||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, count: 24 },
|
||||
})
|
||||
|
||||
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.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
|
||||
params: { force: false },
|
||||
})
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
const dialogProps = mocks.openSharedDialog.mock.calls[0][1]
|
||||
expect(dialogProps.plugin).toMatchObject({
|
||||
id: 'DemoPlugin',
|
||||
installed: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
expect(dialogProps.count).toBe(24)
|
||||
})
|
||||
})
|
||||
75
src/components/common/PluginRatingDisplay.vue
Normal file
75
src/components/common/PluginRatingDisplay.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
rating?: number
|
||||
count?: number
|
||||
iconSize?: number | string
|
||||
showCount?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rating: 0,
|
||||
count: 0,
|
||||
iconSize: 16,
|
||||
showCount: true,
|
||||
})
|
||||
|
||||
const displayRating = computed(() => {
|
||||
const normalizedRating = Number.isFinite(props.rating) ? props.rating : 0
|
||||
return Math.round(Math.min(5, Math.max(0, normalizedRating)) * 2) / 2
|
||||
})
|
||||
|
||||
const starIcons = computed(() =>
|
||||
Array.from({ length: 5 }, (_, index) => {
|
||||
const starValue = index + 1
|
||||
if (displayRating.value >= starValue) return 'mdi-star'
|
||||
if (displayRating.value >= starValue - 0.5) return 'mdi-star-half-full'
|
||||
return 'mdi-star-outline'
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-rating-display" :aria-label="`${Number(props.rating || 0).toFixed(1)} / 5`">
|
||||
<span class="plugin-rating-display__stars" aria-hidden="true">
|
||||
<VIcon
|
||||
v-for="(icon, index) in starIcons"
|
||||
:key="index"
|
||||
:icon="icon"
|
||||
:size="props.iconSize"
|
||||
:color="icon === 'mdi-star-outline' ? undefined : 'warning'"
|
||||
:data-rating-icon="icon"
|
||||
/>
|
||||
</span>
|
||||
<span class="plugin-rating-display__value">{{ Number(props.rating || 0).toFixed(1) }}</span>
|
||||
<span v-if="props.showCount" class="plugin-rating-display__count">({{ props.count || 0 }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-rating-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-inline-size: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-rating-display__stars {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.plugin-rating-display__value {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugin-rating-display__count {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
18
src/components/common/__tests__/PluginRatingDisplay.spec.ts
Normal file
18
src/components/common/__tests__/PluginRatingDisplay.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('PluginRatingDisplay', () => {
|
||||
it('rounds the visual stars to half increments while keeping the exact score text', async () => {
|
||||
const { container } = await renderWithProviders(PluginRatingDisplay, {
|
||||
props: {
|
||||
rating: 4.3,
|
||||
count: 12,
|
||||
},
|
||||
})
|
||||
|
||||
expect(container.querySelectorAll('[data-rating-icon="mdi-star"]')).toHaveLength(4)
|
||||
expect(container.querySelectorAll('[data-rating-icon="mdi-star-half-full"]')).toHaveLength(1)
|
||||
expect(container.querySelector('[aria-label="4.3 / 5"]')).toHaveTextContent('4.3(12)')
|
||||
})
|
||||
})
|
||||
@@ -100,11 +100,20 @@ const versionStatisticLoading = ref(false)
|
||||
// 版本统计数据
|
||||
const versionStatistic = ref<any>({})
|
||||
|
||||
const MIN_VISIBLE_VERSION_INSTALLS = 2
|
||||
|
||||
/** 过滤安装实例过少的版本,避免在版本统计中展示孤立记录。 */
|
||||
function filterVersionStatistics(items: unknown) {
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items.filter(item => Number(item?.count || 0) >= MIN_VISIBLE_VERSION_INSTALLS)
|
||||
}
|
||||
|
||||
// 后端版本统计
|
||||
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
|
||||
const backendVersionStatistics = computed(() => filterVersionStatistics(versionStatistic.value?.backend_versions))
|
||||
|
||||
// 前端版本统计
|
||||
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
|
||||
const frontendVersionStatistics = computed(() => filterVersionStatistics(versionStatistic.value?.frontend_versions))
|
||||
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -46,6 +47,18 @@ const visible = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const isInstalled = computed(() => Boolean(props.plugin?.installed))
|
||||
|
||||
const rating = ref<PluginRating>({
|
||||
plugin_id: props.plugin?.id,
|
||||
average_rating: props.plugin?.average_rating || 0,
|
||||
rating_count: props.plugin?.rating_count || 0,
|
||||
user_rating: props.plugin?.user_rating,
|
||||
})
|
||||
const selectedRating = ref(props.plugin?.user_rating || 0)
|
||||
const ratingLoading = ref(false)
|
||||
const ratingSubmitting = ref(false)
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
@@ -125,10 +138,12 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion || props?.plugin?.plugin_version,
|
||||
}),
|
||||
isInstalled.value && !releaseVersion
|
||||
? t('plugin.updating', { name: props.plugin?.plugin_name })
|
||||
: t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion || props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
@@ -142,7 +157,11 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
$toast.success(
|
||||
isInstalled.value
|
||||
? t('plugin.updateSuccess', { name: props.plugin?.plugin_name })
|
||||
: t('plugin.installSuccess', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
@@ -161,7 +180,11 @@ function showUpdateHistory() {
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
plugin: props.plugin,
|
||||
actionMode: isInstalled.value ? 'update' : 'install',
|
||||
showUpdateAction: isInstalled.value && Boolean(props.plugin?.has_update),
|
||||
},
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
@@ -169,6 +192,54 @@ function showUpdateHistory() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 查询插件平均分和当前安装实例评分。 */
|
||||
async function loadPluginRating() {
|
||||
if (!props.plugin?.id) return
|
||||
|
||||
ratingLoading.value = true
|
||||
try {
|
||||
const result: PluginRating = await api.get(`plugin/rating/${props.plugin.id}`)
|
||||
rating.value = result
|
||||
selectedRating.value = result.user_rating || 0
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
ratingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交已安装插件的当前安装实例评分。 */
|
||||
async function submitPluginRating() {
|
||||
if (!props.plugin?.id || !isInstalled.value || selectedRating.value <= 0) return
|
||||
|
||||
ratingSubmitting.value = true
|
||||
try {
|
||||
const result: ApiResponse<PluginRating> = await api.post(`plugin/rating/${props.plugin.id}`, {
|
||||
rating: selectedRating.value,
|
||||
})
|
||||
if (result.success) {
|
||||
rating.value = result.data
|
||||
selectedRating.value = result.data.user_rating || selectedRating.value
|
||||
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
|
||||
} else {
|
||||
$toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('plugin.ratingFailed', { message: t('common.serverConnectionFailed') }))
|
||||
} finally {
|
||||
ratingSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.plugin?.id],
|
||||
([isVisible]) => {
|
||||
if (isVisible) loadPluginRating()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
@@ -218,6 +289,16 @@ onUnmounted(() => {
|
||||
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="rating.rating_count > 0" class="ps-0">
|
||||
<VListItemTitle class="plugin-market-detail-rating-row text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('plugin.rating') }}:</span>
|
||||
<PluginRatingDisplay
|
||||
:rating="rating.average_rating"
|
||||
:count="rating.rating_count"
|
||||
:icon-size="18"
|
||||
/>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
@@ -230,6 +311,7 @@ onUnmounted(() => {
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
v-if="!isInstalled"
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
@@ -237,6 +319,15 @@ onUnmounted(() => {
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.plugin?.has_update"
|
||||
color="primary"
|
||||
prepend-icon="mdi-arrow-up-circle-outline"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
@click="installPlugin()"
|
||||
>
|
||||
{{ t('plugin.update') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
@@ -246,6 +337,31 @@ onUnmounted(() => {
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isInstalled" class="plugin-market-detail-user-rating mt-5">
|
||||
<div class="text-body-2 font-weight-medium mb-2">
|
||||
{{ t('plugin.yourRating') }}
|
||||
</div>
|
||||
<div class="plugin-market-detail-user-rating__controls">
|
||||
<VRating
|
||||
v-model="selectedRating"
|
||||
:disabled="ratingLoading || ratingSubmitting"
|
||||
half-increments
|
||||
hover
|
||||
density="compact"
|
||||
active-color="warning"
|
||||
/>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-star-check-outline"
|
||||
:loading="ratingSubmitting"
|
||||
:disabled="ratingLoading || selectedRating <= 0"
|
||||
@click="submitPluginRating"
|
||||
>
|
||||
{{ t('plugin.submitRating') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +395,27 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-market-detail-rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.plugin-market-detail-user-rating {
|
||||
padding-block-start: 1rem;
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.plugin-market-detail-user-rating__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.plugin-market-detail-actions {
|
||||
justify-content: flex-start;
|
||||
@@ -291,5 +428,9 @@ onUnmounted(() => {
|
||||
.plugin-market-detail-actions__downloads {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-rating-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import AboutDialog from '@/components/dialog/AboutDialog.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVersionChecker', () => ({
|
||||
clearCacheAndReload: vi.fn(),
|
||||
}))
|
||||
|
||||
const DialogStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
describe('AboutDialog version statistics', () => {
|
||||
it('hides backend and frontend versions with fewer than two installations', async () => {
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path === 'system/env') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
USAGE_STATISTIC_SHARE: true,
|
||||
VERSION: 'v2.0.0',
|
||||
FRONTEND_VERSION: 'v2.0.0',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (path === 'dashboard/processes') return Promise.resolve([])
|
||||
if (path === 'system/versions') return Promise.resolve({ data: [] })
|
||||
if (path === 'site/supporting') return Promise.resolve({})
|
||||
if (path === 'system/usage/statistic') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
backend_versions: [
|
||||
{ version: 'backend-hidden', count: 1 },
|
||||
{ version: 'backend-visible', count: 2 },
|
||||
],
|
||||
frontend_versions: [
|
||||
{ version: 'frontend-hidden', count: 0 },
|
||||
{ version: 'frontend-visible', count: 3 },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API path: ${path}`))
|
||||
})
|
||||
|
||||
const { container } = await renderWithProviders(AboutDialog, {
|
||||
global: {
|
||||
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||
stubs: { VDialog: DialogStub },
|
||||
},
|
||||
})
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/env'))
|
||||
|
||||
await waitFor(() => expect(container.querySelector('button.flex-shrink-0')).toBeTruthy())
|
||||
const statisticButton = container.querySelector<HTMLButtonElement>('button.flex-shrink-0')
|
||||
await fireEvent.click(statisticButton!)
|
||||
|
||||
expect(await screen.findByText('backend-visible')).toBeInTheDocument()
|
||||
expect(screen.getByText('frontend-visible')).toBeInTheDocument()
|
||||
expect(screen.queryByText('backend-hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('frontend-hidden')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
105
src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts
Normal file
105
src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import type { Plugin, PluginRating } from '@/api/types'
|
||||
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.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(),
|
||||
apiPost: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const basePlugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试插件评分',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
|
||||
const ratingResult: PluginRating = {
|
||||
plugin_id: 'DemoPlugin',
|
||||
average_rating: 4.3,
|
||||
rating_count: 12,
|
||||
user_rating: 4.0,
|
||||
}
|
||||
|
||||
async function renderDialog(plugin: Plugin) {
|
||||
return renderWithProviders(PluginMarketDetailDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
plugin,
|
||||
},
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginMarketDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue(ratingResult)
|
||||
mocks.apiPost.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 },
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('shows install action and readonly rating for a market plugin', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
expect(await screen.findByText('安装到本地')).toBeInTheDocument()
|
||||
expect(screen.queryByText('提交评分')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install action and submits a half-star rating for an installed plugin', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
|
||||
expect(await screen.findByText('提交评分')).toBeInTheDocument()
|
||||
expect(screen.queryByText('安装到本地')).not.toBeInTheDocument()
|
||||
|
||||
const ratingInput = document.querySelector<HTMLInputElement>('.v-rating input[value="4.5"]')
|
||||
expect(ratingInput).not.toBeNull()
|
||||
const ratingLabel = document.querySelector<HTMLLabelElement>(`label[for="${ratingInput?.id}"]`)
|
||||
expect(ratingLabel).not.toBeNull()
|
||||
await fireEvent.click(ratingLabel!)
|
||||
await fireEvent.click(screen.getByRole('button', { name: '提交评分' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/rating/DemoPlugin', { rating: 4.5 })
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已提交对插件 演示插件 的评分')
|
||||
})
|
||||
|
||||
it('keeps plugin metadata and rating input visible when nobody has rated yet', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({
|
||||
plugin_id: 'DemoPlugin',
|
||||
average_rating: 0,
|
||||
rating_count: 0,
|
||||
user_rating: undefined,
|
||||
})
|
||||
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
|
||||
expect(await screen.findByText('v1.0.0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('插件评分:')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('我的评分')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3473,6 +3473,7 @@ export default {
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
settings: 'Settings',
|
||||
about: 'About',
|
||||
projectHome: 'Project Home',
|
||||
updateHistory: 'Update History',
|
||||
versionHistory: 'Version History',
|
||||
@@ -3487,6 +3488,12 @@ export default {
|
||||
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
||||
installToLocal: 'Install to Local',
|
||||
totalDownloads: 'Total {count} downloads',
|
||||
rating: 'Plugin Rating',
|
||||
ratingSummary: '{rating} from {count} ratings',
|
||||
yourRating: 'Your Rating',
|
||||
submitRating: 'Submit Rating',
|
||||
ratingSuccess: 'Your rating for {name} was submitted',
|
||||
ratingFailed: 'Failed to submit rating: {message}',
|
||||
viewData: 'View Data',
|
||||
update: 'Update',
|
||||
reset: 'Reset',
|
||||
|
||||
@@ -3415,6 +3415,7 @@ export default {
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
settings: '设置',
|
||||
about: '关于',
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
versionHistory: '版本历史',
|
||||
@@ -3429,6 +3430,12 @@ export default {
|
||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||
installToLocal: '安装到本地',
|
||||
totalDownloads: '共 {count} 次下载',
|
||||
rating: '插件评分',
|
||||
ratingSummary: '{rating} 分,共 {count} 人评分',
|
||||
yourRating: '我的评分',
|
||||
submitRating: '提交评分',
|
||||
ratingSuccess: '已提交对插件 {name} 的评分',
|
||||
ratingFailed: '评分提交失败:{message}',
|
||||
viewData: '查看数据',
|
||||
update: '更新',
|
||||
reset: '重置',
|
||||
|
||||
@@ -3414,6 +3414,7 @@ export default {
|
||||
enable: '啟用',
|
||||
disable: '禁用',
|
||||
settings: '設置',
|
||||
about: '關於',
|
||||
projectHome: '項目主頁',
|
||||
updateHistory: '更新說明',
|
||||
versionHistory: '版本歷史',
|
||||
@@ -3426,6 +3427,12 @@ export default {
|
||||
local: '本地',
|
||||
installToLocal: '安裝到本地',
|
||||
totalDownloads: '共 {count} 次下載',
|
||||
rating: '插件評分',
|
||||
ratingSummary: '{rating} 分,共 {count} 人評分',
|
||||
yourRating: '我的評分',
|
||||
submitRating: '提交評分',
|
||||
ratingSuccess: '已提交對插件 {name} 的評分',
|
||||
ratingFailed: '評分提交失敗:{message}',
|
||||
viewData: '查看數據',
|
||||
update: '更新',
|
||||
reset: '重置',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { Plugin, PluginRating } from '@/api/types'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -177,6 +177,9 @@ const PluginAppDialog = ref(false)
|
||||
// 插件安装统计
|
||||
const PluginStatistics = ref<{ [key: string]: number }>({})
|
||||
|
||||
// 插件评分
|
||||
const PluginRatings = ref<{ [key: string]: PluginRating }>({})
|
||||
|
||||
// 插件市场刷新状态
|
||||
const isMarketRefreshing = ref(false)
|
||||
|
||||
@@ -905,11 +908,46 @@ async function getPluginStatistics() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量加载插件评分并合并到已安装和市场插件对象。 */
|
||||
async function getPluginRatings() {
|
||||
const pluginIds = Array.from(
|
||||
new Set([...dataList.value, ...marketList.value].map(plugin => plugin.id).filter(Boolean)),
|
||||
)
|
||||
if (pluginIds.length === 0) {
|
||||
PluginRatings.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ratings: { [key: string]: PluginRating } = {}
|
||||
for (let start = 0; start < pluginIds.length; start += 100) {
|
||||
const chunk = pluginIds.slice(start, start + 100)
|
||||
const response: { [key: string]: PluginRating } = await api.get('plugin/rating', {
|
||||
params: {
|
||||
plugin_ids: chunk.join(','),
|
||||
},
|
||||
})
|
||||
Object.assign(ratings, response)
|
||||
}
|
||||
PluginRatings.value = ratings
|
||||
|
||||
for (const plugin of [...dataList.value, ...uninstalledList.value, ...marketList.value]) {
|
||||
const pluginRating = ratings[plugin.id]
|
||||
if (!pluginRating) continue
|
||||
plugin.average_rating = pluginRating.average_rating
|
||||
plugin.rating_count = pluginRating.rating_count
|
||||
plugin.user_rating = pluginRating.user_rating
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
async function refreshData(context: KeepAliveRefreshContext = {}) {
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await getPluginStatistics()
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||||
await loadPluginFolders()
|
||||
}
|
||||
@@ -985,7 +1023,7 @@ async function refreshMarket() {
|
||||
isMarketRefreshing.value = true
|
||||
try {
|
||||
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
||||
await getPluginStatistics()
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -998,13 +1036,13 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
||||
|
||||
if (activeTab.value === 'market') {
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await getPluginStatistics()
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
return
|
||||
}
|
||||
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await getPluginStatistics()
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
||||
await loadPluginFolders()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user