Propagate plugin rating updates and preserve rating snapshots

This commit is contained in:
jxxghp
2026-08-04 13:39:27 +08:00
parent b4afba99bd
commit 2728896a1e
7 changed files with 175 additions and 38 deletions
+3 -2
View File
@@ -2,7 +2,7 @@
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import api from '@/api' import api from '@/api'
import type { ApiResponse, Plugin } from '@/api/types' import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor' import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import { formatDownloadCount } from '@/@core/utils/formatters' import { formatDownloadCount } from '@/@core/utils/formatters'
@@ -34,7 +34,7 @@ const props = defineProps({
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'actionDone']) const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
// 多语言 // 多语言
const { t } = useI18n() const { t } = useI18n()
@@ -392,6 +392,7 @@ async function showPluginAbout() {
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。 // 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
void pluginSidebarNavStore.ensureSidebarNav(true) void pluginSidebarNavStore.ensureSidebarNav(true)
}, },
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
}, },
{ closeOn: ['close', 'install', 'update:modelValue'] }, { closeOn: ['close', 'install', 'update:modelValue'] },
) )
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PluginRating } from '@/api/types'
import PluginCard from './PluginCard.vue' import PluginCard from './PluginCard.vue'
import PluginFolderCard from './PluginFolderCard.vue' import PluginFolderCard from './PluginFolderCard.vue'
@@ -30,6 +31,7 @@ const emit = defineEmits<{
renameFolder: [oldName: string, newName: string] renameFolder: [oldName: string, newName: string]
updateFolderConfig: [folderName: string, config: any] updateFolderConfig: [folderName: string, config: any]
refreshData: [] refreshData: []
rating: [pluginRating: PluginRating]
actionDone: [pluginId: string] actionDone: [pluginId: string]
removeFromFolder: [pluginId: string] removeFromFolder: [pluginId: string]
dropToFolder: [event: DragEvent, folderName: string] dropToFolder: [event: DragEvent, folderName: string]
@@ -108,6 +110,7 @@ function handleDropToFolder(event: DragEvent) {
:sortable="sortable" :sortable="sortable"
@remove="$emit('refreshData')" @remove="$emit('refreshData')"
@save="$emit('refreshData')" @save="$emit('refreshData')"
@rating="$emit('rating', $event)"
@action-done="$emit('actionDone', item.id)" @action-done="$emit('actionDone', item.id)"
/> />
@@ -82,7 +82,17 @@ describe('PluginCard about menu', () => {
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
closeOn: ['close', 'install', 'update:modelValue'], closeOn: ['close', 'install', 'update:modelValue'],
}) })
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void } const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
install: () => void
rating: (pluginRating: { plugin_id: string; average_rating: number; rating_count: number }) => void
}
dialogEvents.rating({ plugin_id: 'DemoPlugin', average_rating: 4.5, rating_count: 13 })
expect(emitted().rating).toContainEqual([
expect.objectContaining({ plugin_id: 'DemoPlugin', average_rating: 4.5, rating_count: 13 }),
])
expect(emitted()).not.toHaveProperty('save')
expect(sidebarStore.ensureSidebarNav).not.toHaveBeenCalled()
dialogEvents.install() dialogEvents.install()
expect(emitted().save).toHaveLength(1) expect(emitted().save).toHaveLength(1)
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true) expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
@@ -36,7 +36,7 @@ const props = defineProps({
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'install']) const emit = defineEmits(['update:modelValue', 'close', 'install', 'rating'])
// 弹窗显示状态 // 弹窗显示状态
const visible = computed({ const visible = computed({
@@ -220,6 +220,7 @@ async function submitPluginRating() {
if (result.success) { if (result.success) {
rating.value = result.data rating.value = result.data
selectedRating.value = result.data.user_rating || selectedRating.value selectedRating.value = result.data.user_rating || selectedRating.value
emit('rating', result.data)
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
} else { } else {
$toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') })) $toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') }))
@@ -261,6 +262,9 @@ onUnmounted(() => {
<p v-if="props.plugin?.plugin_desc" class="plugin-market-detail__description"> <p v-if="props.plugin?.plugin_desc" class="plugin-market-detail__description">
{{ props.plugin?.plugin_desc }} {{ props.plugin?.plugin_desc }}
</p> </p>
<div v-if="rating.rating_count > 0" class="plugin-market-detail__header-rating">
<PluginRatingDisplay :rating="rating.average_rating" :count="rating.rating_count" :icon-size="18" />
</div>
</header> </header>
<dl class="plugin-market-detail__metadata"> <dl class="plugin-market-detail__metadata">
@@ -276,16 +280,6 @@ onUnmounted(() => {
</button> </button>
</dd> </dd>
</div> </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> </dl>
<VAlert <VAlert
@@ -392,10 +386,17 @@ onUnmounted(() => {
white-space: pre-line; white-space: pre-line;
} }
.plugin-market-detail__header-rating {
display: flex;
align-items: center;
justify-content: center;
margin-block: 0.75rem 0.375rem;
}
.plugin-market-detail__metadata { .plugin-market-detail__metadata {
display: grid; display: grid;
gap: 0.625rem; gap: 0.625rem;
margin: 1.125rem auto 0; margin: 1.125rem 0;
} }
.plugin-market-detail__metadata-row { .plugin-market-detail__metadata-row {
@@ -441,12 +442,6 @@ onUnmounted(() => {
text-decoration: underline; text-decoration: underline;
} }
.plugin-market-detail__rating-summary {
display: flex;
align-items: center;
white-space: nowrap;
}
.plugin-market-detail__warning { .plugin-market-detail__warning {
margin-block-start: 1rem; margin-block-start: 1rem;
} }
@@ -43,6 +43,9 @@ const basePlugin: Plugin = {
plugin_version: '1.0.0', plugin_version: '1.0.0',
plugin_author: 'MoviePilot', plugin_author: 'MoviePilot',
repo_url: 'https://github.com/example/plugins', repo_url: 'https://github.com/example/plugins',
average_rating: 4.3,
rating_count: 12,
user_rating: 4.0,
} }
const ratingResult: PluginRating = { const ratingResult: PluginRating = {
@@ -102,7 +105,7 @@ describe('PluginMarketDetailDialog', () => {
}) })
it('hides install action and submits a half-star rating for an installed plugin', async () => { it('hides install action and submits a half-star rating for an installed plugin', async () => {
await renderDialog({ ...basePlugin, installed: true }) const { emitted } = await renderDialog({ ...basePlugin, installed: true })
expect(await screen.findByText('提交评分')).toBeInTheDocument() expect(await screen.findByText('提交评分')).toBeInTheDocument()
expect(screen.queryByText('安装到本地')).not.toBeInTheDocument() expect(screen.queryByText('安装到本地')).not.toBeInTheDocument()
@@ -117,6 +120,9 @@ describe('PluginMarketDetailDialog', () => {
await waitFor(() => { await waitFor(() => {
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/rating/DemoPlugin', { rating: 4.5 }) expect(mocks.apiPost).toHaveBeenCalledWith('plugin/rating/DemoPlugin', { rating: 4.5 })
}) })
expect(emitted().rating).toContainEqual([
expect.objectContaining({ average_rating: 4.5, plugin_id: 'DemoPlugin', user_rating: 4.5 }),
])
expect(mocks.toastSuccess).toHaveBeenCalledWith('已提交对插件 演示插件 的评分') expect(mocks.toastSuccess).toHaveBeenCalledWith('已提交对插件 演示插件 的评分')
}) })
@@ -128,7 +134,13 @@ describe('PluginMarketDetailDialog', () => {
user_rating: undefined, user_rating: undefined,
}) })
await renderDialog({ ...basePlugin, installed: true }) await renderDialog({
...basePlugin,
installed: true,
average_rating: 0,
rating_count: 0,
user_rating: undefined,
})
expect(await screen.findByText('v1.0.0')).toBeInTheDocument() expect(await screen.findByText('v1.0.0')).toBeInTheDocument()
expect(screen.queryByText('插件评分:')).not.toBeInTheDocument() expect(screen.queryByText('插件评分:')).not.toBeInTheDocument()
@@ -156,12 +168,18 @@ describe('PluginMarketDetailDialog', () => {
const metadata = document.querySelector('.plugin-market-detail__metadata') const metadata = document.querySelector('.plugin-market-detail__metadata')
const metadataRows = metadata?.querySelectorAll('.plugin-market-detail__metadata-row') const metadataRows = metadata?.querySelectorAll('.plugin-market-detail__metadata-row')
expect(metadataRows).toHaveLength(4) expect(metadataRows).toHaveLength(2)
metadataRows?.forEach(row => { metadataRows?.forEach(row => {
expect(row.querySelector(':scope > dt')).not.toBeNull() expect(row.querySelector(':scope > dt')).not.toBeNull()
expect(row.querySelector(':scope > dd')).not.toBeNull() expect(row.querySelector(':scope > dd')).not.toBeNull()
}) })
expect(metadataRows?.[3]?.querySelector('dd > .plugin-rating-display')).not.toBeNull()
const headerRating = document.querySelector('.plugin-market-detail__header-rating')
expect(headerRating?.previousElementSibling).toBe(description)
expect(headerRating?.querySelector('.plugin-rating-display')).not.toBeNull()
expect(screen.queryByText('插件评分:')).not.toBeInTheDocument()
expect(screen.queryByText('v2.15.0 or later')).not.toBeInTheDocument()
}) })
it('emits installation completion only after installation succeeds', async () => { it('emits installation completion only after installation succeeds', async () => {
@@ -351,11 +369,13 @@ describe('PluginMarketDetailDialog', () => {
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败') expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败')
}) })
it('keeps plugin actions usable when rating loading fails', async () => { it('refreshes the current plugin rating when the detail opens', async () => {
mocks.apiGet.mockRejectedValue(new Error('rating unavailable'))
await renderDialog({ ...basePlugin, installed: false }) await renderDialog({ ...basePlugin, installed: false })
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled() expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledOnce()
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/rating/DemoPlugin')
}) })
it('opens installed version history without an update action and closes through the model contract', async () => { it('opens installed version history without an update action and closes through the model contract', async () => {
+50 -11
View File
@@ -938,6 +938,7 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
}) })
if (generation !== installedWriterGeneration) return if (generation !== installedWriterGeneration) return
mergeRatingsIntoPlugins(installedPlugins)
dataList.value = installedPlugins dataList.value = installedPlugins
mergeMarketMetadataIntoInstalled() mergeMarketMetadataIntoInstalled()
// 排序 // 排序
@@ -984,6 +985,7 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
}) })
if (generation !== marketWriterGeneration) return if (generation !== marketWriterGeneration) return
mergeRatingsIntoPlugins(marketResponse)
uninstalledList.value = marketResponse uninstalledList.value = marketResponse
mergeMarketMetadataIntoInstalled() mergeMarketMetadataIntoInstalled()
// 更新插件市场列表 // 更新插件市场列表
@@ -1050,23 +1052,56 @@ async function getPluginRatings() {
PluginRatings.value = ratings PluginRatings.value = ratings
for (const plugin of [...dataList.value, ...uninstalledList.value, ...marketList.value]) { mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
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) { } catch (error) {
console.error(error) console.error(error)
} }
} }
/** 下载量与评分属于同一份市场指标快照,始终在同一刷新时机加载。 */
async function getPluginMarketMetrics() {
await Promise.all([getPluginStatistics(), getPluginRatings()])
}
/** 新列表写入前复用最近一次评分快照,避免静默刷新期间评分闪烁。 */
function mergeRatingsIntoPlugins(
plugins: Plugin[],
ratings: Record<string, PluginRating> = PluginRatings.value,
overwrite = false,
) {
for (const plugin of plugins) {
const pluginRating = ratings[plugin.id]
if (!pluginRating) continue
if (overwrite || plugin.average_rating === undefined) plugin.average_rating = pluginRating.average_rating
if (overwrite || plugin.rating_count === undefined) plugin.rating_count = pluginRating.rating_count
if (overwrite || plugin.user_rating === undefined) plugin.user_rating = pluginRating.user_rating
}
}
/** 评分提交接口已返回新值,只回写当前插件,避免重新加载全部市场指标。 */
function applyPluginRating(pluginRating: PluginRating) {
const pluginId = pluginRating.plugin_id
if (!pluginId) return
PluginRatings.value = {
...PluginRatings.value,
[pluginId]: pluginRating,
}
mergeRatingsIntoPlugins(
[...dataList.value, ...uninstalledList.value, ...marketList.value],
{
[pluginId]: pluginRating,
},
true,
)
}
// 加载所有数据 // 加载所有数据
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 Promise.all([getPluginStatistics(), getPluginRatings()]) await getPluginMarketMetrics()
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中 // 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
await loadPluginFolders() await loadPluginFolders()
} }
@@ -1154,7 +1189,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 Promise.all([getPluginStatistics(), getPluginRatings()]) await getPluginMarketMetrics()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@@ -1167,13 +1202,13 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
if (activeTab.value === 'market') { if (activeTab.value === 'market') {
await fetchUninstalledPlugins(false, context) await fetchUninstalledPlugins(false, context)
await Promise.all([getPluginStatistics(), getPluginRatings()]) await getPluginMarketMetrics()
return return
} }
await fetchInstalledPlugins(context) await fetchInstalledPlugins(context)
await fetchUninstalledPlugins(false, context) await fetchUninstalledPlugins(false, context)
await Promise.all([getPluginStatistics(), getPluginRatings()]) await getPluginMarketMetrics()
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。 // 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
await loadPluginFolders() await loadPluginFolders()
} }
@@ -1949,6 +1984,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)" @rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)" @update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData" @refresh-data="refreshData"
@rating="applyPluginRating"
@action-done=" @action-done="
pluginId => { pluginId => {
pluginActions[pluginId] = false pluginActions[pluginId] = false
@@ -1977,6 +2013,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)" @rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)" @update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData" @refresh-data="refreshData"
@rating="applyPluginRating"
@action-done=" @action-done="
pluginId => { pluginId => {
pluginActions[pluginId] = false pluginActions[pluginId] = false
@@ -2008,6 +2045,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:sortable="true" :sortable="true"
:show-remove-button="true" :show-remove-button="true"
@refresh-data="refreshData" @refresh-data="refreshData"
@rating="applyPluginRating"
@action-done=" @action-done="
pluginId => { pluginId => {
pluginActions[pluginId] = false pluginActions[pluginId] = false
@@ -2032,6 +2070,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:sortable="false" :sortable="false"
:show-remove-button="true" :show-remove-button="true"
@refresh-data="refreshData" @refresh-data="refreshData"
@rating="applyPluginRating"
@action-done=" @action-done="
pluginId => { pluginId => {
pluginActions[pluginId] = false pluginActions[pluginId] = false
@@ -145,6 +145,7 @@ const PluginMixedSortCardStub = defineComponent({
'delete-folder', 'delete-folder',
'drop-to-folder', 'drop-to-folder',
'open-folder', 'open-folder',
'rating',
'refresh-data', 'refresh-data',
'rename-folder', 'rename-folder',
'remove-from-folder', 'remove-from-folder',
@@ -219,6 +220,22 @@ const PluginMixedSortCardStub = defineComponent({
type === 'plugin' type === 'plugin'
? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`) ? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`)
: null, : null,
type === 'plugin'
? h(
'button',
{
onClick: () =>
emit('rating', {
average_rating: 4.7,
plugin_id: id,
rating_count: 10,
user_rating: 5,
} satisfies PluginRating),
type: 'button',
},
`rate-plugin-${id}`,
)
: null,
]) ])
} }
}, },
@@ -542,6 +559,58 @@ describe('PluginCardListView loading and request ownership', () => {
expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99') expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99')
}) })
it('updates only the submitted plugin from the POST result without reloading market metrics', async () => {
let ratingRequest = 0
let statisticRequest = 0
await renderList({
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
rating: () => {
ratingRequest += 1
return { Installed: { average_rating: 3, plugin_id: 'Installed', rating_count: 1 } }
},
statistic: () => {
statisticRequest += 1
return { Installed: 24 }
},
})
await waitForRequestsToFinish()
expect(ratingRequest).toBe(1)
expect(statisticRequest).toBe(1)
await fireEvent.click(screen.getByRole('button', { name: 'rate-plugin-Installed' }))
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.7')
expect(ratingRequest).toBe(1)
expect(statisticRequest).toBe(1)
})
it('keeps the previous rating visible while a tab refresh loads the next rating snapshot', async () => {
const refreshedRatings = createDeferred<Record<string, PluginRating>>()
let ratingRequest = 0
await renderList({
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
rating: () => {
ratingRequest += 1
if (ratingRequest === 1) {
return { Installed: { average_rating: 4.2, plugin_id: 'Installed', rating_count: 3 } }
}
return refreshedRatings.promise
},
})
await waitForRequestsToFinish()
if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调')
const refresh = mocks.keepAliveHandler({ silent: true, source: 'tab' })
await waitFor(() => expect(ratingRequest).toBe(2))
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.2')
refreshedRatings.resolve({
Installed: { average_rating: 4.8, plugin_id: 'Installed', rating_count: 4 },
})
await refresh
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.8')
})
it('rejects a rating snapshot when the current plugin ID set changes before the next rating request', async () => { it('rejects a rating snapshot when the current plugin ID set changes before the next rating request', async () => {
const staleRatings = createDeferred<Record<string, PluginRating>>() const staleRatings = createDeferred<Record<string, PluginRating>>()
const currentMarket = createDeferred<Plugin[]>() const currentMarket = createDeferred<Plugin[]>()