diff --git a/src/components/dialog/PluginMarketDetailDialog.vue b/src/components/dialog/PluginMarketDetailDialog.vue index 0398d484..3f87f133 100644 --- a/src/components/dialog/PluginMarketDetailDialog.vue +++ b/src/components/dialog/PluginMarketDetailDialog.vue @@ -247,190 +247,285 @@ onUnmounted(() => { diff --git a/src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts b/src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts index c978577b..a5a8cf35 100644 --- a/src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts +++ b/src/components/dialog/__tests__/PluginMarketDetailDialog.spec.ts @@ -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 }) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 7df36311..d6eb2dfc 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -3460,6 +3460,7 @@ export default { plugin: { sort: { popular: 'Popular', + rating: 'Rating', name: 'Plugin Name', author: 'Author', repository: 'Plugin Repository', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index e728bbee..7f9be5c7 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -3403,6 +3403,7 @@ export default { plugin: { sort: { popular: '热门', + rating: '评分', name: '插件名称', author: '作者', repository: '插件仓库', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 7d4e77ad..68f82426 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -3402,6 +3402,7 @@ export default { plugin: { sort: { popular: '熱門', + rating: '評分', name: '插件名稱', author: '作者', repository: '插件倉庫', diff --git a/src/views/plugin/PluginCardListView.vue b/src/views/plugin/PluginCardListView.vue index 55e45b81..51820c94 100644 --- a/src/views/plugin/PluginCardListView.vue +++ b/src/views/plugin/PluginCardListView.vue @@ -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 -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([]) // 排序选项 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, 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个 diff --git a/src/views/plugin/__tests__/PluginCardListView.spec.ts b/src/views/plugin/__tests__/PluginCardListView.spec.ts index c41737b1..09a4e63b 100644 --- a/src/views/plugin/__tests__/PluginCardListView.spec.ts +++ b/src/views/plugin/__tests__/PluginCardListView.spec.ts @@ -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>() + 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({