feat(plugin): add ratings and about dialog

This commit is contained in:
jxxghp
2026-07-31 13:41:29 +08:00
parent 3f414afff4
commit 246b8ff54b
15 changed files with 883 additions and 189 deletions

View File

@@ -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 可安装版本

View File

@@ -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;

View File

@@ -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>

View 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()
})
})

View 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)
})
})

View 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>

View 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)')
})
})

View File

@@ -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 ?? {})

View File

@@ -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>

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -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',

View File

@@ -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: '重置',

View File

@@ -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: '重置',

View File

@@ -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()
}