diff --git a/src/api/types.ts b/src/api/types.ts index 6b4a3b1f..e4782402 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 可安装版本 diff --git a/src/components/cards/PluginAppCard.vue b/src/components/cards/PluginAppCard.vue index be9779dc..1f674fa0 100644 --- a/src/components/cards/PluginAppCard.vue +++ b/src/components/cards/PluginAppCard.vue @@ -242,88 +242,113 @@ onUnmounted(() => { }" :style="{ '--plugin-card-accent-rgb': accentRgb }" > -
- - - {{ props.plugin?.plugin_name }} - v{{ props.plugin?.plugin_version }} - - -
-
-
+ + - {{ props.plugin?.plugin_desc }} -
- -
- v{{ props.plugin?.plugin_version }} + + +
+
+
- {{ tag }} - + {{ props.plugin?.plugin_desc }} +
+ +
+ + {{ tag }} + +
+
+
+ + +
-
- - - -
-
- -
-
- - - {{ props.plugin?.plugin_author }} - + +
+ +
+ + {{ formatDownloadCount(props.count) }} +
-
- - {{ formatDownloadCount(props.count) }} +
+ + + + + + + + + + +
+ +
+ + {{ Number(props.plugin?.average_rating || 0).toFixed(1) }}
-
- - - - - - - - - - - -
-
@@ -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; diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue index e2a52f24..c464a948 100644 --- a/src/components/cards/PluginCard.vue +++ b/src/components/cards/PluginCard.vue @@ -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 | null = null let cloneDialogController: ReturnType | null = null +let marketDetailDialogController: ReturnType | null = null let versionHistoryDialogController: ReturnType | 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" > -
- - - - {{ props.plugin?.plugin_name }} - v{{ props.plugin?.plugin_version }} - - -
-
-
- {{ props.plugin?.plugin_desc }} +
+ + + + {{ props.plugin?.plugin_name }} + v{{ props.plugin?.plugin_version }} + + +
+
+
+ {{ props.plugin?.plugin_desc }} +
+
+
+ + +
-
- - - -
-
- -
-
- - - - - {{ props.plugin?.plugin_author }} + +
+
+ + + + + {{ props.plugin?.plugin_author }} + + + {{ props.plugin?.plugin_author }} + +
+ + + {{ formatDownloadCount(props.count) }} - - {{ props.plugin?.plugin_author }} -
- - - {{ formatDownloadCount(props.count) }} - +
+ + + + + + + + + + + +
+
+
+
-
- - - - - - - - - - - -
- -
- -
-
diff --git a/src/components/cards/__tests__/PluginAppCardRating.spec.ts b/src/components/cards/__tests__/PluginAppCardRating.spec.ts new file mode 100644 index 00000000..b1451574 --- /dev/null +++ b/src/components/cards/__tests__/PluginAppCardRating.spec.ts @@ -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() + }) +}) diff --git a/src/components/cards/__tests__/PluginCardAbout.spec.ts b/src/components/cards/__tests__/PluginCardAbout.spec.ts new file mode 100644 index 00000000..f9aaac5e --- /dev/null +++ b/src/components/cards/__tests__/PluginCardAbout.spec.ts @@ -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('.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) + }) +}) diff --git a/src/components/common/PluginRatingDisplay.vue b/src/components/common/PluginRatingDisplay.vue new file mode 100644 index 00000000..817d2888 --- /dev/null +++ b/src/components/common/PluginRatingDisplay.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/components/common/__tests__/PluginRatingDisplay.spec.ts b/src/components/common/__tests__/PluginRatingDisplay.spec.ts new file mode 100644 index 00000000..5c1ecaf5 --- /dev/null +++ b/src/components/common/__tests__/PluginRatingDisplay.spec.ts @@ -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)') + }) +}) diff --git a/src/components/dialog/AboutDialog.vue b/src/components/dialog/AboutDialog.vue index 62226c5a..8f25604e 100644 --- a/src/components/dialog/AboutDialog.vue +++ b/src/components/dialog/AboutDialog.vue @@ -100,11 +100,20 @@ const versionStatisticLoading = ref(false) // 版本统计数据 const versionStatistic = ref({}) +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 ?? {}) diff --git a/src/components/dialog/PluginMarketDetailDialog.vue b/src/components/dialog/PluginMarketDetailDialog.vue index c69d9b78..5a6d9e07 100644 --- a/src/components/dialog/PluginMarketDetailDialog.vue +++ b/src/components/dialog/PluginMarketDetailDialog.vue @@ -1,7 +1,8 @@