mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
feat(plugin): add ratings and about dialog
This commit is contained in:
@@ -706,6 +706,20 @@ export interface Plugin {
|
|||||||
add_time?: number
|
add_time?: number
|
||||||
// 页面打开状态
|
// 页面打开状态
|
||||||
page_open?: boolean
|
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 可安装版本
|
// 插件 Release 可安装版本
|
||||||
|
|||||||
@@ -242,88 +242,113 @@ onUnmounted(() => {
|
|||||||
}"
|
}"
|
||||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||||
>
|
>
|
||||||
<div class="plugin-card__banner flex-grow">
|
<div class="plugin-card__banner flex-grow">
|
||||||
<VCardText class="px-2 pt-2 pb-0">
|
<VCardText class="px-2 pt-2 pb-0">
|
||||||
<VCardTitle
|
<VCardTitle
|
||||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
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_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 }"
|
|
||||||
>
|
>
|
||||||
{{ props.plugin?.plugin_desc }}
|
{{ props.plugin?.plugin_name }}
|
||||||
</div>
|
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||||
<!-- 插件标签 -->
|
</VCardTitle>
|
||||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
</VCardText>
|
||||||
<VChip
|
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||||
v-for="tag in pluginLabels"
|
<div class="relative flex-1 min-w-0">
|
||||||
:key="tag"
|
<div
|
||||||
size="x-small"
|
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||||
variant="tonal"
|
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||||
color="info"
|
|
||||||
class="plugin-app-card__tag"
|
|
||||||
tile
|
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ props.plugin?.plugin_desc }}
|
||||||
</VChip>
|
</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>
|
</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>
|
||||||
</div>
|
<VCardText
|
||||||
<VCardText
|
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
>
|
||||||
>
|
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
<VIcon icon="mdi-github" class="me-1" />
|
||||||
<VIcon icon="mdi-github" class="me-1" />
|
<a
|
||||||
<a
|
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
:href="props.plugin?.author_url"
|
||||||
:href="props.plugin?.author_url"
|
target="_blank"
|
||||||
target="_blank"
|
@click.stop
|
||||||
@click.stop
|
>
|
||||||
>
|
{{ props.plugin?.plugin_author }}
|
||||||
{{ props.plugin?.plugin_author }}
|
</a>
|
||||||
</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>
|
||||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
<div class="absolute bottom-0 right-0">
|
||||||
<VIcon size="small" icon="mdi-download" />
|
<IconBtn @click.stop>
|
||||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
<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>
|
</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>
|
||||||
<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>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -344,12 +369,31 @@ onUnmounted(() => {
|
|||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-app-card__title--with-rating {
|
||||||
|
padding-inline-end: 4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-app-card__tag {
|
.plugin-app-card__tag {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
min-inline-size: 0;
|
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) {
|
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
+138
-101
@@ -16,6 +16,7 @@ const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginData
|
|||||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||||
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
||||||
|
const PluginMarketDetailDialog = defineAsyncComponent(() => import('../dialog/PluginMarketDetailDialog.vue'))
|
||||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
@@ -69,6 +70,7 @@ const imageLoadError = ref(false)
|
|||||||
|
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let cloneDialogController: 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
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||||
@@ -305,10 +307,7 @@ function hasRemoteRepoUrl(plugin?: Plugin) {
|
|||||||
function resolvePluginPageUrl(plugin?: Plugin) {
|
function resolvePluginPageUrl(plugin?: Plugin) {
|
||||||
if (!plugin) return ''
|
if (!plugin) return ''
|
||||||
|
|
||||||
const repoUrl =
|
const repoUrl = hasRemoteRepoUrl(plugin) ? normalizePluginRepoUrl(plugin.repo_url) : plugin.author_url
|
||||||
hasRemoteRepoUrl(plugin)
|
|
||||||
? normalizePluginRepoUrl(plugin.repo_url)
|
|
||||||
: plugin.author_url
|
|
||||||
|
|
||||||
return repoUrl || plugin.author_url || ''
|
return repoUrl || plugin.author_url || ''
|
||||||
}
|
}
|
||||||
@@ -332,27 +331,49 @@ async function fetchMarketPlugin(pluginId?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 访问插件项目主页
|
/** 读取已安装插件的最新市场详情,并保留本地运行状态字段。 */
|
||||||
async function visitPluginPage() {
|
async function fetchInstalledPluginDetail() {
|
||||||
const popup = window.open('about:blank', '_blank')
|
|
||||||
let pluginDetail = props.plugin
|
let pluginDetail = props.plugin
|
||||||
|
if (!props.plugin?.id) return pluginDetail
|
||||||
if (popup) popup.opener = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (props.plugin?.id) {
|
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
params: {
|
||||||
params: {
|
force: false,
|
||||||
force: false,
|
},
|
||||||
},
|
})
|
||||||
})
|
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}), installed: true } as Plugin
|
||||||
|
|
||||||
// 历史接口可能只返回部分字段,合并原卡片数据避免丢失 author_url 兜底。
|
|
||||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}) } as Plugin
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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)) {
|
if (!hasRemoteRepoUrl(pluginDetail)) {
|
||||||
const marketPlugin = await fetchMarketPlugin(props.plugin?.id)
|
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()) {
|
if (!cloneForm.suffix.trim()) {
|
||||||
$toast.error(t('plugin.suffixRequired'))
|
$toast.error(t('plugin.suffixRequired'))
|
||||||
return
|
return
|
||||||
@@ -447,10 +474,21 @@ async function executePluginClone(cloneForm: { suffix: string; name: string; des
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
closePluginProgress()
|
closePluginProgress()
|
||||||
cloneDialogController?.close()
|
cloneDialogController?.close()
|
||||||
|
marketDetailDialogController?.close()
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 弹出菜单
|
// 弹出菜单
|
||||||
const dropdownItems = ref([
|
const dropdownItems = ref([
|
||||||
|
{
|
||||||
|
title: t('plugin.about'),
|
||||||
|
value: 10,
|
||||||
|
show: true,
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-information-outline',
|
||||||
|
click: showPluginAbout,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('plugin.viewData'),
|
title: t('plugin.viewData'),
|
||||||
value: 1,
|
value: 1,
|
||||||
@@ -581,97 +619,96 @@ watch(
|
|||||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||||
:ripple="!props.sortable"
|
:ripple="!props.sortable"
|
||||||
>
|
>
|
||||||
<div class="plugin-card__banner flex-grow">
|
<div class="plugin-card__banner flex-grow">
|
||||||
<VCardText class="px-2 pt-2 pb-0">
|
<VCardText class="px-2 pt-2 pb-0">
|
||||||
<VCardTitle
|
<VCardTitle
|
||||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
>
|
>
|
||||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||||
{{ props.plugin?.plugin_name }}
|
{{ props.plugin?.plugin_name }}
|
||||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||||
<div class="relative flex-1 min-w-0">
|
<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 ...">
|
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||||
{{ props.plugin?.plugin_desc }}
|
{{ 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>
|
</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>
|
||||||
</div>
|
<VCardText
|
||||||
<VCardText
|
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
>
|
||||||
>
|
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
<template #default>
|
||||||
<template #default>
|
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
</template>
|
||||||
</template>
|
</VImg>
|
||||||
</VImg>
|
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
{{ props.plugin?.plugin_author }}
|
||||||
{{ 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>
|
</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>
|
</div>
|
||||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||||
<VIcon size="small" icon="mdi-download" />
|
<IconBtn @click.stop>
|
||||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
</span>
|
<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>
|
||||||
<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>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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 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 ?? {})
|
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<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, PluginRating } from '@/api/types'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
|
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
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>()
|
const imageRef = ref<any>()
|
||||||
|
|
||||||
@@ -125,10 +138,12 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
showInstallProgress(
|
showInstallProgress(
|
||||||
t('plugin.installing', {
|
isInstalled.value && !releaseVersion
|
||||||
name: props.plugin?.plugin_name,
|
? t('plugin.updating', { name: props.plugin?.plugin_name })
|
||||||
version: releaseVersion || props?.plugin?.plugin_version,
|
: 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}`, {
|
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()
|
closeInstallProgress()
|
||||||
|
|
||||||
if (result.success) {
|
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?.close()
|
||||||
versionHistoryDialogController = null
|
versionHistoryDialogController = null
|
||||||
visible.value = false
|
visible.value = false
|
||||||
@@ -161,7 +180,11 @@ function showUpdateHistory() {
|
|||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
versionHistoryDialogController = openSharedDialog(
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin, actionMode: 'install' },
|
{
|
||||||
|
plugin: props.plugin,
|
||||||
|
actionMode: isInstalled.value ? 'update' : 'install',
|
||||||
|
showUpdateAction: isInstalled.value && Boolean(props.plugin?.has_update),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
update: installPlugin,
|
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(() => {
|
onUnmounted(() => {
|
||||||
closeInstallProgress()
|
closeInstallProgress()
|
||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
@@ -218,6 +289,16 @@ onUnmounted(() => {
|
|||||||
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
|
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
</VListItem>
|
</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>
|
</VList>
|
||||||
<VAlert
|
<VAlert
|
||||||
v-if="props.plugin?.system_version_compatible === false"
|
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">
|
||||||
<div class="plugin-market-detail-actions__buttons">
|
<div class="plugin-market-detail-actions__buttons">
|
||||||
<VBtn
|
<VBtn
|
||||||
|
v-if="!isInstalled"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="installPlugin()"
|
@click="installPlugin()"
|
||||||
prepend-icon="mdi-download"
|
prepend-icon="mdi-download"
|
||||||
@@ -237,6 +319,15 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ t('plugin.installToLocal') }}
|
{{ t('plugin.installToLocal') }}
|
||||||
</VBtn>
|
</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">
|
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||||
{{ t('plugin.versionHistory') }}
|
{{ t('plugin.versionHistory') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -246,6 +337,31 @@ onUnmounted(() => {
|
|||||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</VCardItem>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,6 +395,27 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
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) {
|
@media (width >= 960px) {
|
||||||
.plugin-market-detail-actions {
|
.plugin-market-detail-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -291,5 +428,9 @@ onUnmounted(() => {
|
|||||||
.plugin-market-detail-actions__downloads {
|
.plugin-market-detail-actions__downloads {
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-market-detail-rating-row {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
about: 'About',
|
||||||
projectHome: 'Project Home',
|
projectHome: 'Project Home',
|
||||||
updateHistory: 'Update History',
|
updateHistory: 'Update History',
|
||||||
versionHistory: 'Version History',
|
versionHistory: 'Version History',
|
||||||
@@ -3487,6 +3488,12 @@ export default {
|
|||||||
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
||||||
installToLocal: 'Install to Local',
|
installToLocal: 'Install to Local',
|
||||||
totalDownloads: 'Total {count} downloads',
|
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',
|
viewData: 'View Data',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
|
|||||||
@@ -3415,6 +3415,7 @@ export default {
|
|||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
|
about: '关于',
|
||||||
projectHome: '项目主页',
|
projectHome: '项目主页',
|
||||||
updateHistory: '更新说明',
|
updateHistory: '更新说明',
|
||||||
versionHistory: '版本历史',
|
versionHistory: '版本历史',
|
||||||
@@ -3429,6 +3430,12 @@ export default {
|
|||||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||||
installToLocal: '安装到本地',
|
installToLocal: '安装到本地',
|
||||||
totalDownloads: '共 {count} 次下载',
|
totalDownloads: '共 {count} 次下载',
|
||||||
|
rating: '插件评分',
|
||||||
|
ratingSummary: '{rating} 分,共 {count} 人评分',
|
||||||
|
yourRating: '我的评分',
|
||||||
|
submitRating: '提交评分',
|
||||||
|
ratingSuccess: '已提交对插件 {name} 的评分',
|
||||||
|
ratingFailed: '评分提交失败:{message}',
|
||||||
viewData: '查看数据',
|
viewData: '查看数据',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
|
|||||||
@@ -3414,6 +3414,7 @@ export default {
|
|||||||
enable: '啟用',
|
enable: '啟用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
settings: '設置',
|
settings: '設置',
|
||||||
|
about: '關於',
|
||||||
projectHome: '項目主頁',
|
projectHome: '項目主頁',
|
||||||
updateHistory: '更新說明',
|
updateHistory: '更新說明',
|
||||||
versionHistory: '版本歷史',
|
versionHistory: '版本歷史',
|
||||||
@@ -3426,6 +3427,12 @@ export default {
|
|||||||
local: '本地',
|
local: '本地',
|
||||||
installToLocal: '安裝到本地',
|
installToLocal: '安裝到本地',
|
||||||
totalDownloads: '共 {count} 次下載',
|
totalDownloads: '共 {count} 次下載',
|
||||||
|
rating: '插件評分',
|
||||||
|
ratingSummary: '{rating} 分,共 {count} 人評分',
|
||||||
|
yourRating: '我的評分',
|
||||||
|
submitRating: '提交評分',
|
||||||
|
ratingSuccess: '已提交對插件 {name} 的評分',
|
||||||
|
ratingFailed: '評分提交失敗:{message}',
|
||||||
viewData: '查看數據',
|
viewData: '查看數據',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
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 NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
@@ -177,6 +177,9 @@ const PluginAppDialog = ref(false)
|
|||||||
// 插件安装统计
|
// 插件安装统计
|
||||||
const PluginStatistics = ref<{ [key: string]: number }>({})
|
const PluginStatistics = ref<{ [key: string]: number }>({})
|
||||||
|
|
||||||
|
// 插件评分
|
||||||
|
const PluginRatings = ref<{ [key: string]: PluginRating }>({})
|
||||||
|
|
||||||
// 插件市场刷新状态
|
// 插件市场刷新状态
|
||||||
const isMarketRefreshing = ref(false)
|
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 = {}) {
|
async function refreshData(context: KeepAliveRefreshContext = {}) {
|
||||||
await fetchInstalledPlugins(context)
|
await fetchInstalledPlugins(context)
|
||||||
await fetchUninstalledPlugins(false, context)
|
await fetchUninstalledPlugins(false, context)
|
||||||
await getPluginStatistics()
|
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||||||
await loadPluginFolders()
|
await loadPluginFolders()
|
||||||
}
|
}
|
||||||
@@ -985,7 +1023,7 @@ async function refreshMarket() {
|
|||||||
isMarketRefreshing.value = true
|
isMarketRefreshing.value = true
|
||||||
try {
|
try {
|
||||||
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
||||||
await getPluginStatistics()
|
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -998,13 +1036,13 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
|||||||
|
|
||||||
if (activeTab.value === 'market') {
|
if (activeTab.value === 'market') {
|
||||||
await fetchUninstalledPlugins(false, context)
|
await fetchUninstalledPlugins(false, context)
|
||||||
await getPluginStatistics()
|
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchInstalledPlugins(context)
|
await fetchInstalledPlugins(context)
|
||||||
await fetchUninstalledPlugins(false, context)
|
await fetchUninstalledPlugins(false, context)
|
||||||
await getPluginStatistics()
|
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||||
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
||||||
await loadPluginFolders()
|
await loadPluginFolders()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user