mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
Propagate plugin rating updates and preserve rating snapshots
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
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 { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
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()
|
||||
@@ -392,6 +392,7 @@ async function showPluginAbout() {
|
||||
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
},
|
||||
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PluginRating } from '@/api/types'
|
||||
import PluginCard from './PluginCard.vue'
|
||||
import PluginFolderCard from './PluginFolderCard.vue'
|
||||
|
||||
@@ -30,6 +31,7 @@ const emit = defineEmits<{
|
||||
renameFolder: [oldName: string, newName: string]
|
||||
updateFolderConfig: [folderName: string, config: any]
|
||||
refreshData: []
|
||||
rating: [pluginRating: PluginRating]
|
||||
actionDone: [pluginId: string]
|
||||
removeFromFolder: [pluginId: string]
|
||||
dropToFolder: [event: DragEvent, folderName: string]
|
||||
@@ -108,6 +110,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@rating="$emit('rating', $event)"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -82,7 +82,17 @@ describe('PluginCard about menu', () => {
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
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()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
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({
|
||||
@@ -220,6 +220,7 @@ async function submitPluginRating() {
|
||||
if (result.success) {
|
||||
rating.value = result.data
|
||||
selectedRating.value = result.data.user_rating || selectedRating.value
|
||||
emit('rating', result.data)
|
||||
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
|
||||
} else {
|
||||
$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">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</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>
|
||||
|
||||
<dl class="plugin-market-detail__metadata">
|
||||
@@ -276,16 +280,6 @@ onUnmounted(() => {
|
||||
</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
|
||||
@@ -392,10 +386,17 @@ onUnmounted(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
margin: 1.125rem auto 0;
|
||||
margin: 1.125rem 0;
|
||||
}
|
||||
|
||||
.plugin-market-detail__metadata-row {
|
||||
@@ -441,12 +442,6 @@ onUnmounted(() => {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.plugin-market-detail__rating-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-market-detail__warning {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ const basePlugin: Plugin = {
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
average_rating: 4.3,
|
||||
rating_count: 12,
|
||||
user_rating: 4.0,
|
||||
}
|
||||
|
||||
const ratingResult: PluginRating = {
|
||||
@@ -102,7 +105,7 @@ describe('PluginMarketDetailDialog', () => {
|
||||
})
|
||||
|
||||
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(screen.queryByText('安装到本地')).not.toBeInTheDocument()
|
||||
@@ -117,6 +120,9 @@ describe('PluginMarketDetailDialog', () => {
|
||||
await waitFor(() => {
|
||||
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('已提交对插件 演示插件 的评分')
|
||||
})
|
||||
|
||||
@@ -128,7 +134,13 @@ describe('PluginMarketDetailDialog', () => {
|
||||
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(screen.queryByText('插件评分:')).not.toBeInTheDocument()
|
||||
@@ -156,12 +168,18 @@ describe('PluginMarketDetailDialog', () => {
|
||||
const metadata = document.querySelector('.plugin-market-detail__metadata')
|
||||
const metadataRows = metadata?.querySelectorAll('.plugin-market-detail__metadata-row')
|
||||
|
||||
expect(metadataRows).toHaveLength(4)
|
||||
expect(metadataRows).toHaveLength(2)
|
||||
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()
|
||||
|
||||
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 () => {
|
||||
@@ -351,11 +369,13 @@ describe('PluginMarketDetailDialog', () => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败')
|
||||
})
|
||||
|
||||
it('keeps plugin actions usable when rating loading fails', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('rating unavailable'))
|
||||
it('refreshes the current plugin rating when the detail opens', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -938,6 +938,7 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||
})
|
||||
if (generation !== installedWriterGeneration) return
|
||||
|
||||
mergeRatingsIntoPlugins(installedPlugins)
|
||||
dataList.value = installedPlugins
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 排序
|
||||
@@ -984,6 +985,7 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
|
||||
})
|
||||
if (generation !== marketWriterGeneration) return
|
||||
|
||||
mergeRatingsIntoPlugins(marketResponse)
|
||||
uninstalledList.value = marketResponse
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 更新插件市场列表
|
||||
@@ -1050,23 +1052,56 @@ async function getPluginRatings() {
|
||||
|
||||
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
|
||||
}
|
||||
mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
|
||||
} catch (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 = {}) {
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||||
await loadPluginFolders()
|
||||
}
|
||||
@@ -1154,7 +1189,7 @@ async function refreshMarket() {
|
||||
isMarketRefreshing.value = true
|
||||
try {
|
||||
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -1167,13 +1202,13 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
||||
|
||||
if (activeTab.value === 'market') {
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
||||
await loadPluginFolders()
|
||||
}
|
||||
@@ -1949,6 +1984,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -1977,6 +2013,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -2008,6 +2045,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -2032,6 +2070,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
|
||||
@@ -145,6 +145,7 @@ const PluginMixedSortCardStub = defineComponent({
|
||||
'delete-folder',
|
||||
'drop-to-folder',
|
||||
'open-folder',
|
||||
'rating',
|
||||
'refresh-data',
|
||||
'rename-folder',
|
||||
'remove-from-folder',
|
||||
@@ -219,6 +220,22 @@ const PluginMixedSortCardStub = defineComponent({
|
||||
type === 'plugin'
|
||||
? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`)
|
||||
: 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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const staleRatings = createDeferred<Record<string, PluginRating>>()
|
||||
const currentMarket = createDeferred<Plugin[]>()
|
||||
|
||||
Reference in New Issue
Block a user