Improve plugin market detail layout and rating sorting

This commit is contained in:
jxxghp
2026-08-04 12:34:02 +08:00
parent 7e46428721
commit df1e72e871
7 changed files with 327 additions and 160 deletions

View File

@@ -247,190 +247,285 @@ onUnmounted(() => {
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VCard>
<VDialog v-if="visible" v-model="visible" width="100%" max-width="32rem" max-height="90dvh" scrollable>
<VCard class="plugin-market-detail">
<VDialogCloseBtn v-model="visible" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one" class="border-0">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="props.plugin?.system_version" class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}</span>
<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"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<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"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ 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>
</div>
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ 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>
<VCardText class="plugin-market-detail__content">
<header class="plugin-market-detail__header">
<VAvatar size="64" class="plugin-market-detail__avatar">
<VImg :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
</VAvatar>
<h2 class="plugin-market-detail__title">
{{ props.plugin?.plugin_name }}
</h2>
<p v-if="props.plugin?.plugin_desc" class="plugin-market-detail__description">
{{ props.plugin?.plugin_desc }}
</p>
</header>
<dl class="plugin-market-detail__metadata">
<div class="plugin-market-detail__metadata-row">
<dt>{{ t('common.version') }}</dt>
<dd>v{{ props.plugin?.plugin_version }}</dd>
</div>
</VCol>
<div class="plugin-market-detail__metadata-row">
<dt>{{ t('common.author') }}</dt>
<dd>
<button type="button" class="plugin-market-detail__author" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</button>
</dd>
</div>
<div v-if="props.plugin?.system_version" class="plugin-market-detail__metadata-row">
<dt>{{ t('plugin.systemVersion') }}</dt>
<dd>{{ props.plugin?.system_version }}</dd>
</div>
<div v-if="rating.rating_count > 0" class="plugin-market-detail__metadata-row">
<dt>{{ t('plugin.rating') }}</dt>
<dd class="plugin-market-detail__rating-summary">
<PluginRatingDisplay :rating="rating.average_rating" :count="rating.rating_count" :icon-size="18" />
</dd>
</div>
</dl>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="plugin-market-detail__warning"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="plugin-market-detail-actions">
<div class="plugin-market-detail-actions__buttons">
<VBtn
v-if="!isInstalled"
color="primary"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
@click="installPlugin()"
>
{{ 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" prepend-icon="mdi-update" @click="showUpdateHistory">
{{ t('plugin.versionHistory') }}
</VBtn>
</div>
<div v-if="props.count" class="plugin-market-detail-actions__downloads">
<VIcon icon="mdi-fire" size="18" />
<span>{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}</span>
</div>
</div>
<section v-if="isInstalled" class="plugin-market-detail-user-rating">
<h3 class="plugin-market-detail-user-rating__title">
{{ t('plugin.yourRating') }}
</h3>
<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>
</section>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-market-detail__content {
padding: 2.25rem 1.5rem 1.5rem;
}
.plugin-market-detail__header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.plugin-market-detail__avatar {
flex: 0 0 auto;
}
.plugin-market-detail__title {
max-inline-size: 100%;
margin: 1rem 0 0;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.plugin-market-detail__description {
max-inline-size: 28rem;
margin: 0.35rem 0 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-line;
}
.plugin-market-detail__metadata {
display: grid;
gap: 0.8rem;
max-inline-size: 26rem;
margin: 1.5rem auto 0;
}
.plugin-market-detail__metadata-row {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
min-inline-size: 0;
}
.plugin-market-detail__metadata dt {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.4;
text-align: end;
white-space: nowrap;
}
.plugin-market-detail__metadata dd {
min-inline-size: 0;
margin: 0;
font-size: 0.9375rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.plugin-market-detail__author {
max-inline-size: 100%;
border: 0;
background: transparent;
padding: 0;
color: inherit;
cursor: pointer;
font: inherit;
overflow-wrap: anywhere;
text-align: start;
}
.plugin-market-detail__author:hover,
.plugin-market-detail__author:focus-visible {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
.plugin-market-detail__rating-summary {
display: flex;
align-items: center;
white-space: nowrap;
}
.plugin-market-detail__warning {
margin-block-start: 1.25rem;
}
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-block-start: 1.25rem;
}
.plugin-market-detail-actions__buttons {
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
inline-size: 100%;
}
.plugin-market-detail-actions__downloads {
flex-basis: 100%;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
text-align: center;
}
.plugin-market-detail-rating-row {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex-wrap: wrap;
white-space: normal;
gap: 0.3rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.4;
}
.plugin-market-detail-user-rating {
padding-block-start: 1rem;
margin-block-start: 1.5rem;
padding-block-start: 1.25rem;
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.plugin-market-detail-user-rating__title {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.4;
text-align: center;
}
.plugin-market-detail-user-rating__controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
min-inline-size: 0;
}
@media (width >= 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
@media (width < 360px) {
.plugin-market-detail__content {
padding-inline: 1rem;
}
.plugin-market-detail-actions__buttons {
justify-content: flex-start;
.plugin-market-detail__metadata-row {
grid-template-columns: minmax(0, 1fr);
gap: 0.2rem;
}
.plugin-market-detail-actions__downloads {
.plugin-market-detail__metadata dt {
text-align: start;
}
.plugin-market-detail-rating-row {
justify-content: flex-start;
.plugin-market-detail-actions__buttons {
flex-direction: column;
}
.plugin-market-detail-actions__buttons :deep(.v-btn) {
inline-size: 100%;
}
}
@media (width >= 480px) {
.plugin-market-detail-user-rating__controls {
flex-direction: row;
justify-content: center;
}
}
</style>

View File

@@ -136,6 +136,32 @@ describe('PluginMarketDetailDialog', () => {
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
})
it('keeps long plugin details in aligned metadata rows', async () => {
const pluginDescription = '支持包含较长说明文字和换行内容的插件详情。\n第二行内容保持完整展示。'
const pluginAuthor = 'MoviePilot-Plugin-Author-With-A-Long-Name'
await renderDialog({
...basePlugin,
installed: true,
plugin_desc: pluginDescription,
plugin_author: pluginAuthor,
system_version: 'v2.15.0 or later',
})
expect(await screen.findByText(pluginDescription)).toHaveClass('plugin-market-detail__description')
expect(screen.getByRole('button', { name: pluginAuthor })).toHaveClass('plugin-market-detail__author')
const metadata = document.querySelector('.plugin-market-detail__metadata')
const metadataRows = metadata?.querySelectorAll('.plugin-market-detail__metadata-row')
expect(metadataRows).toHaveLength(4)
metadataRows?.forEach(row => {
expect(row.querySelector(':scope > dt')).not.toBeNull()
expect(row.querySelector(':scope > dd')).not.toBeNull()
})
expect(metadataRows?.[3]?.querySelector('dd > .plugin-rating-display')).not.toBeNull()
})
it('emits installation completion only after installation succeeds', async () => {
const { emitted } = await renderDialog({ ...basePlugin, installed: false })

View File

@@ -3460,6 +3460,7 @@ export default {
plugin: {
sort: {
popular: 'Popular',
rating: 'Rating',
name: 'Plugin Name',
author: 'Author',
repository: 'Plugin Repository',

View File

@@ -3403,6 +3403,7 @@ export default {
plugin: {
sort: {
popular: '热门',
rating: '评分',
name: '插件名称',
author: '作者',
repository: '插件仓库',

View File

@@ -3402,6 +3402,7 @@ export default {
plugin: {
sort: {
popular: '熱門',
rating: '評分',
name: '插件名稱',
author: '作者',
repository: '插件倉庫',

View File

@@ -3,7 +3,6 @@ import { useToast } from 'vue-toastification'
import api from '@/api'
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getPluginTabs } from '@/router/i18n-menu'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
@@ -43,7 +42,7 @@ interface PluginFolderConfig {
type PluginFolderEntry = PluginFolderConfig | string[]
type PluginFolderMap = Record<string, PluginFolderEntry>
type PluginSortKey = 'count' | 'plugin_name' | 'plugin_author' | 'repo_url' | 'add_time'
type PluginSortKey = 'count' | 'average_rating' | 'plugin_name' | 'plugin_author' | 'repo_url' | 'add_time'
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
@@ -152,6 +151,7 @@ const orderConfig = ref<PluginOrderItem[]>([])
// 排序选项
const sortOptions = computed<{ title: string; value: PluginSortKey }[]>(() => [
{ title: t('plugin.sort.popular'), value: 'count' },
{ title: t('plugin.sort.rating'), value: 'average_rating' },
{ title: t('plugin.sort.name'), value: 'plugin_name' },
{ title: t('plugin.sort.author'), value: 'plugin_author' },
{ title: t('plugin.sort.repository'), value: 'repo_url' },
@@ -1072,7 +1072,7 @@ async function refreshData(context: KeepAliveRefreshContext = {}) {
}
// 对uninstalledList进行排序到sortedUninstalledList
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
watch([marketList, filterForm, activeSort, PluginStatistics, PluginRatings], () => {
// 匹配过滤函数
const match = (filter: Array<string>, value: unknown) => {
const text = normalizeMarketText(value).trim()
@@ -1107,23 +1107,27 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
})
// 排序
if (!isNullOrEmptyObject(PluginStatistics.value)) {
if (!activeSort.value || activeSort.value === 'count') {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
})
} else if (activeSort.value) {
const sortKey = activeSort.value
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
if (sortKey === 'add_time') {
return a.add_time !== undefined && b.add_time !== undefined && a.add_time > b.add_time ? 1 : -1
}
const sortKey = activeSort.value || 'count'
if (sortKey === 'count') {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
})
} else if (sortKey === 'average_rating') {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
const aRating = PluginRatings.value[a.id]?.average_rating ?? a.average_rating ?? 0
const bRating = PluginRatings.value[b.id]?.average_rating ?? b.average_rating ?? 0
return bRating - aRating
})
} else {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
if (sortKey === 'add_time') {
return a.add_time !== undefined && b.add_time !== undefined && a.add_time > b.add_time ? 1 : -1
}
const aValue = a[sortKey]
const bValue = b[sortKey]
return aValue !== undefined && bValue !== undefined && aValue > bValue ? 1 : -1
})
}
const aValue = a[sortKey]
const bValue = b[sortKey]
return aValue !== undefined && bValue !== undefined && aValue > bValue ? 1 : -1
})
}
// 显示前20个

View File

@@ -701,6 +701,45 @@ describe('PluginCardListView market filtering and pagination', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('sorts market entries by rating descending when asynchronous ratings arrive', async () => {
const ratings = createDeferred<Record<string, PluginRating>>()
await renderList({
market: () => [
createPlugin({ id: 'Low', plugin_name: '低评分插件' }),
createPlugin({ id: 'Unrated', plugin_name: '未评分插件' }),
createPlugin({ id: 'High', plugin_name: '高评分插件' }),
createPlugin({ id: 'Medium', plugin_name: '中评分插件' }),
],
rating: () => ratings.promise,
})
expect(await screen.findByText('market:低评分插件')).toBeInTheDocument()
getHeaderConfig().modelValue.value = 'market'
await nextTick()
const marketFilterButton = getHeaderConfig().appendButtons.find(button => button.dataAttr === 'market-filter-btn')
if (!marketFilterButton?.action) throw new Error('未注册市场过滤操作')
marketFilterButton.action()
await nextTick()
await fireEvent.click(screen.getByText('评分'))
ratings.resolve({
High: { average_rating: 4.8, plugin_id: 'High', rating_count: 12 },
Low: { average_rating: 2.1, plugin_id: 'Low', rating_count: 3 },
Medium: { average_rating: 3.6, plugin_id: 'Medium', rating_count: 6 },
})
await waitFor(() => {
const labels = [...document.querySelectorAll('[data-testid^="market-"]')].map(node => node.textContent)
expect(labels).toEqual([
expect.stringContaining('market:高评分插件'),
expect.stringContaining('market:中评分插件'),
expect.stringContaining('market:低评分插件'),
expect.stringContaining('market:未评分插件'),
])
})
await waitForRequestsToFinish()
})
it('filters and sorts market entries, labels local repos, and appends pages of 20', async () => {
const market = Array.from({ length: 25 }, (_, index) =>
createPlugin({