mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(plugin): 收敛插件仓库来源交互 (#725)
This commit is contained in:
+18
-1
@@ -997,7 +997,7 @@ export interface Plugin {
|
|||||||
instance_mode?: 'virtual'
|
instance_mode?: 'virtual'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插件市场为已安装插件发现的当前最高在线更新候选。 */
|
/** 插件市场为已安装插件选择的当前更新候选。 */
|
||||||
export interface PluginUpdateCandidate {
|
export interface PluginUpdateCandidate {
|
||||||
// 候选仓库是官方来源还是第三方来源
|
// 候选仓库是官方来源还是第三方来源
|
||||||
source_type: 'official' | 'third_party'
|
source_type: 'official' | 'third_party'
|
||||||
@@ -1092,6 +1092,23 @@ export interface PluginSourceChangeRequest {
|
|||||||
release_version?: string | null
|
release_version?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已安装插件完成来源确认后产生的仓库变更意图。 */
|
||||||
|
export type PluginSourceTransition =
|
||||||
|
| {
|
||||||
|
// 为存量未绑定插件建立初始可信仓库
|
||||||
|
action: 'bind'
|
||||||
|
// 管理员明确选择的目标插件仓库地址
|
||||||
|
repo_url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// 把已绑定插件切换到另一个可信仓库
|
||||||
|
action: 'change'
|
||||||
|
// 管理员明确选择的目标插件仓库地址
|
||||||
|
repo_url: string
|
||||||
|
// 提交换仓时必须匹配的当前身份 revision
|
||||||
|
expected_revision: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginRuntimeSummary {
|
export interface PluginRuntimeSummary {
|
||||||
// 本轮插件源码、依赖和加载是否已收敛
|
// 本轮插件源码、依赖和加载是否已收敛
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
import type { Plugin, PluginRating } from '@/api/types'
|
import type { Plugin, PluginRating, PluginSourceTransition } from '@/api/types'
|
||||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
@@ -45,7 +45,13 @@ const props = defineProps({
|
|||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
const emit = defineEmits<{
|
||||||
|
remove: []
|
||||||
|
save: []
|
||||||
|
actionDone: []
|
||||||
|
rating: [pluginRating: PluginRating]
|
||||||
|
sourceTransition: [plugin: Plugin, transition: PluginSourceTransition]
|
||||||
|
}>()
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -56,13 +62,15 @@ const hasCardStatus = computed(
|
|||||||
() => sourceBindingRequired.value || Boolean(props.plugin?.has_update) || hasCardRating.value,
|
() => sourceBindingRequired.value || Boolean(props.plugin?.has_update) || hasCardRating.value,
|
||||||
)
|
)
|
||||||
const updateCandidate = computed(() => props.plugin?.update_candidate)
|
const updateCandidate = computed(() => props.plugin?.update_candidate)
|
||||||
const hasAlternativeUpdate = computed(
|
const hasAlternativeUpdate = computed(() =>
|
||||||
() => Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound),
|
Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound),
|
||||||
)
|
)
|
||||||
const updateSourceName = computed(() => {
|
const updateSourceName = computed(() => {
|
||||||
const candidate = updateCandidate.value
|
const candidate = updateCandidate.value
|
||||||
if (!candidate) return ''
|
if (!candidate) return ''
|
||||||
return candidate.source_key.startsWith('github:') ? candidate.source_key.slice('github:'.length) : candidate.source_key
|
return candidate.source_key.startsWith('github:')
|
||||||
|
? candidate.source_key.slice('github:'.length)
|
||||||
|
: candidate.source_key
|
||||||
})
|
})
|
||||||
const updateBadgeTitle = computed(() => {
|
const updateBadgeTitle = computed(() => {
|
||||||
const candidate = updateCandidate.value
|
const candidate = updateCandidate.value
|
||||||
@@ -420,13 +428,13 @@ async function fetchInstalledPluginDetail() {
|
|||||||
return pluginDetail
|
return pluginDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */
|
/** 先展示本地快照,再用市场详情补齐仍处于打开状态的同一个弹窗。 */
|
||||||
async function showPluginAbout(initialSourceSelectionOpen = false) {
|
function showPluginAbout(initialSourceSelectionOpen = false) {
|
||||||
const pluginDetail = await fetchInstalledPluginDetail()
|
const pluginDetail = props.plugin
|
||||||
if (!pluginDetail) return
|
if (!pluginDetail) return
|
||||||
|
|
||||||
marketDetailDialogController?.close()
|
marketDetailDialogController?.close()
|
||||||
marketDetailDialogController = openSharedDialog(
|
const controller = openSharedDialog(
|
||||||
PluginMarketDetailDialog,
|
PluginMarketDetailDialog,
|
||||||
{
|
{
|
||||||
plugin: pluginDetail,
|
plugin: pluginDetail,
|
||||||
@@ -440,9 +448,19 @@ async function showPluginAbout(initialSourceSelectionOpen = false) {
|
|||||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
},
|
},
|
||||||
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
|
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
|
||||||
|
sourceTransition: (transition: PluginSourceTransition) => {
|
||||||
|
if (props.plugin) emit('sourceTransition', props.plugin, transition)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
|
marketDetailDialogController = controller
|
||||||
|
|
||||||
|
void fetchInstalledPluginDetail().then(latestPluginDetail => {
|
||||||
|
if (latestPluginDetail && marketDetailDialogController === controller) {
|
||||||
|
controller.updateProps({ plugin: latestPluginDetail })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */
|
/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */
|
||||||
@@ -913,7 +931,7 @@ watch(
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
background: rgba(var(--v-theme-surface), 88%);
|
background: rgba(var(--v-theme-surface), 72%);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -922,7 +940,7 @@ watch(
|
|||||||
|
|
||||||
.plugin-card__runtime-state--error {
|
.plugin-card__runtime-state--error {
|
||||||
color: rgb(var(--v-theme-error));
|
color: rgb(var(--v-theme-error));
|
||||||
background: rgba(var(--v-theme-surface), 94%);
|
background: rgba(var(--v-theme-surface), 80%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-cover-blurred::before {
|
.card-cover-blurred::before {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PluginRating } from '@/api/types'
|
import type { Plugin, PluginRating, PluginSourceTransition } from '@/api/types'
|
||||||
import PluginCard from './PluginCard.vue'
|
import PluginCard from './PluginCard.vue'
|
||||||
import PluginFolderCard from './PluginFolderCard.vue'
|
import PluginFolderCard from './PluginFolderCard.vue'
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ const emit = defineEmits<{
|
|||||||
updateFolderConfig: [folderName: string, config: any]
|
updateFolderConfig: [folderName: string, config: any]
|
||||||
refreshData: []
|
refreshData: []
|
||||||
rating: [pluginRating: PluginRating]
|
rating: [pluginRating: PluginRating]
|
||||||
|
sourceTransition: [plugin: Plugin, transition: PluginSourceTransition]
|
||||||
actionDone: [pluginId: string]
|
actionDone: [pluginId: string]
|
||||||
removeFromFolder: [pluginId: string]
|
removeFromFolder: [pluginId: string]
|
||||||
dropToFolder: [event: DragEvent, folderName: string]
|
dropToFolder: [event: DragEvent, folderName: string]
|
||||||
@@ -117,6 +118,7 @@ function handleDropToFolder(event: DragEvent) {
|
|||||||
@remove="$emit('refreshData')"
|
@remove="$emit('refreshData')"
|
||||||
@save="$emit('refreshData')"
|
@save="$emit('refreshData')"
|
||||||
@rating="$emit('rating', $event)"
|
@rating="$emit('rating', $event)"
|
||||||
|
@source-transition="(plugin, transition) => $emit('sourceTransition', plugin, transition)"
|
||||||
@action-done="$emit('actionDone', item.id)"
|
@action-done="$emit('actionDone', item.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -238,11 +238,9 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||||
|
|
||||||
await fireEvent.mouseEnter(screen.getByLabelText('有更新'))
|
await fireEvent.mouseEnter(screen.getByLabelText('有更新'))
|
||||||
expect(
|
expect(await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库')).toBeInTheDocument()
|
||||||
await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库'),
|
|
||||||
).toBeInTheDocument()
|
|
||||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
await fireEvent.click(await screen.findByText('查看更新来源'))
|
await fireEvent.click(await screen.findByText('查看更新'))
|
||||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
|
||||||
@@ -272,6 +270,60 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
expect(await screen.findByText('该插件尚未绑定仓库,请在「关于」中确认')).toBeInTheDocument()
|
expect(await screen.findByText('该插件尚未绑定仓库,请在「关于」中确认')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forwards a confirmed repository change to the list transaction owner', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue(plugin)
|
||||||
|
const { container, emitted } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('关于'))
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
const detailEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
|
sourceTransition: (transition: { action: 'change'; expected_revision: number; repo_url: string }) => void
|
||||||
|
}
|
||||||
|
detailEvents.sourceTransition({
|
||||||
|
action: 'change',
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(emitted().sourceTransition).toContainEqual([
|
||||||
|
plugin,
|
||||||
|
{
|
||||||
|
action: 'change',
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens about immediately and enriches the same dialog after history loads', async () => {
|
||||||
|
let resolveHistory!: (value: Plugin) => void
|
||||||
|
mocks.apiGet.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Plugin>(resolve => {
|
||||||
|
resolveHistory = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('关于'))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ plugin })
|
||||||
|
const controller = mocks.openSharedDialog.mock.results[0].value as {
|
||||||
|
updateProps: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
expect(controller.updateProps).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
resolveHistory({ ...plugin, plugin_desc: '市场补充详情' })
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(controller.updateProps).toHaveBeenCalledWith({
|
||||||
|
plugin: expect.objectContaining({ plugin_desc: '市场补充详情' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('blocks an incompatible latest update without sending a request', async () => {
|
it('blocks an incompatible latest update without sending a request', async () => {
|
||||||
const updatablePlugin = {
|
const updatablePlugin = {
|
||||||
...plugin,
|
...plugin,
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ describe('PluginCard about menu', () => {
|
|||||||
repo_url: 'https://github.com/example/plugins',
|
repo_url: 'https://github.com/example/plugins',
|
||||||
})
|
})
|
||||||
mocks.closeDialog.mockReset()
|
mocks.closeDialog.mockReset()
|
||||||
mocks.openSharedDialog.mockReset().mockReturnValue({ close: mocks.closeDialog })
|
mocks.openSharedDialog.mockReset().mockReturnValue({
|
||||||
|
close: mocks.closeDialog,
|
||||||
|
updateProps: vi.fn(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||||
@@ -76,7 +79,6 @@ describe('PluginCard about menu', () => {
|
|||||||
expect(dialogProps.plugin).toMatchObject({
|
expect(dialogProps.plugin).toMatchObject({
|
||||||
id: 'DemoPlugin',
|
id: 'DemoPlugin',
|
||||||
installed: true,
|
installed: true,
|
||||||
repo_url: 'https://github.com/example/plugins',
|
|
||||||
})
|
})
|
||||||
expect(dialogProps.count).toBe(24)
|
expect(dialogProps.count).toBe(24)
|
||||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||||
@@ -120,7 +122,6 @@ describe('PluginCard about menu', () => {
|
|||||||
expect(mocks.openSharedDialog.mock.calls[1][1].plugin).toMatchObject({
|
expect(mocks.openSharedDialog.mock.calls[1][1].plugin).toMatchObject({
|
||||||
id: 'DemoPlugin',
|
id: 'DemoPlugin',
|
||||||
installed: true,
|
installed: true,
|
||||||
repo_url: 'https://github.com/example/plugins',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Plugin, PluginSourceTransition } from '@/api/types'
|
||||||
|
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { fireEvent, screen } from '@testing-library/vue'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/components/cards/PluginCard.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'PluginCardStub',
|
||||||
|
props: {
|
||||||
|
plugin: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
emits: ['source-transition'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
onClick: () =>
|
||||||
|
emit('source-transition', props.plugin, {
|
||||||
|
action: 'change',
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
}),
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
'更换仓库',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
id: 'DemoPlugin',
|
||||||
|
installed: true,
|
||||||
|
plugin_author: 'MoviePilot',
|
||||||
|
plugin_name: '演示插件',
|
||||||
|
plugin_version: '1.0.0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition: PluginSourceTransition = {
|
||||||
|
action: 'change',
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginMixedSortCard', () => {
|
||||||
|
it('forwards repository transitions from the plugin card', async () => {
|
||||||
|
const { emitted } = await renderWithProviders(PluginMixedSortCard, {
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
data: plugin,
|
||||||
|
id: plugin.id,
|
||||||
|
order: 0,
|
||||||
|
type: 'plugin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '更换仓库' }))
|
||||||
|
|
||||||
|
expect(emitted().sourceTransition).toContainEqual([plugin, transition])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
||||||
import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
|
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
|
||||||
import type { Plugin, PluginRating, PluginSourceCandidate, PluginSourceOptions } from '@/api/types'
|
import type { Plugin, PluginRating, PluginSourceCandidate, PluginSourceOptions } from '@/api/types'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||||
@@ -49,7 +49,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['update:modelValue', 'close', 'install', 'rating'])
|
const emit = defineEmits(['update:modelValue', 'close', 'install', 'rating', 'sourceTransition'])
|
||||||
|
|
||||||
// 弹窗显示状态
|
// 弹窗显示状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -77,7 +77,6 @@ const sourceError = ref('')
|
|||||||
const selectedInstallSourceKey = ref('')
|
const selectedInstallSourceKey = ref('')
|
||||||
const selectedChangeSourceKey = ref('')
|
const selectedChangeSourceKey = ref('')
|
||||||
const showSourceChoices = ref(props.initialSourceSelectionOpen)
|
const showSourceChoices = ref(props.initialSourceSelectionOpen)
|
||||||
const sourceChanging = ref(false)
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
@@ -213,7 +212,7 @@ async function loadPluginSourceOptions(force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 明确绑定或切换自动更新来源,换源时使用打开弹窗时读取的 revision。 */
|
/** 确认仓库后关闭详情,由上层状态所有者执行安装事务。 */
|
||||||
async function confirmSourceTransition() {
|
async function confirmSourceTransition() {
|
||||||
const identity = sourceOptions.value?.identity
|
const identity = sourceOptions.value?.identity
|
||||||
const target = selectedChangeSource.value
|
const target = selectedChangeSource.value
|
||||||
@@ -239,42 +238,17 @@ async function confirmSourceTransition() {
|
|||||||
})
|
})
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
sourceChanging.value = true
|
emit(
|
||||||
showInstallProgress(
|
'sourceTransition',
|
||||||
t(bindingSource ? 'plugin.bindingSource' : 'plugin.changingSource', { name: props.plugin.plugin_name }),
|
bindingSource
|
||||||
|
? { action: 'bind', repo_url: target.repo_url }
|
||||||
|
: {
|
||||||
|
action: 'change',
|
||||||
|
repo_url: target.repo_url,
|
||||||
|
expected_revision: identity!.revision,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
try {
|
visible.value = false
|
||||||
if (bindingSource) {
|
|
||||||
await installPluginFromSource(props.plugin.id, {
|
|
||||||
repo_url: target.repo_url,
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await changePluginSource(props.plugin.id, {
|
|
||||||
repo_url: target.repo_url,
|
|
||||||
expected_revision: identity!.revision,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
$toast.success(
|
|
||||||
t(bindingSource ? 'plugin.sourceBindSuccess' : 'plugin.sourceChangeSuccess', {
|
|
||||||
name: props.plugin.plugin_name,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
emit('install')
|
|
||||||
visible.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
$toast.error(
|
|
||||||
t(bindingSource ? 'plugin.sourceBindFailed' : 'plugin.sourceChangeFailed', {
|
|
||||||
name: props.plugin.plugin_name,
|
|
||||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await loadPluginSourceOptions(true)
|
|
||||||
} finally {
|
|
||||||
sourceChanging.value = false
|
|
||||||
closeInstallProgress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算插件图标路径。 */
|
/** 计算插件图标路径。 */
|
||||||
@@ -587,7 +561,10 @@ onUnmounted(() => {
|
|||||||
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
|
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
|
||||||
<dd
|
<dd
|
||||||
class="plugin-market-detail-source__identity-content"
|
class="plugin-market-detail-source__identity-content"
|
||||||
:class="{ 'plugin-market-detail-source__identity-content--no-action': !sourceActionCandidates.length || showSourceChoices }"
|
:class="{
|
||||||
|
'plugin-market-detail-source__identity-content--no-action':
|
||||||
|
!sourceActionCandidates.length || showSourceChoices,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<span class="plugin-market-detail-source__identity-value">
|
<span class="plugin-market-detail-source__identity-value">
|
||||||
<VChip
|
<VChip
|
||||||
@@ -700,7 +677,6 @@ onUnmounted(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="text"
|
variant="text"
|
||||||
:loading="sourceChanging"
|
|
||||||
:disabled="!selectedChangeSource"
|
:disabled="!selectedChangeSource"
|
||||||
@click="confirmSourceTransition"
|
@click="confirmSourceTransition"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -230,11 +230,17 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
confirmText: '确认更换',
|
confirmText: '确认更换',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
|
expect(emitted().sourceTransition).toContainEqual([
|
||||||
repo_url: 'https://github.com/example/plugins',
|
{
|
||||||
expected_revision: 7,
|
action: 'change',
|
||||||
})
|
repo_url: 'https://github.com/example/plugins',
|
||||||
await waitFor(() => expect(emitted().install).toHaveLength(1))
|
expected_revision: 7,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin', expect.anything())
|
||||||
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||||
|
expect(emitted().install).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks ordinary updates when the trusted source has no usable candidate', async () => {
|
it('blocks ordinary updates when the trusted source has no usable candidate', async () => {
|
||||||
@@ -269,7 +275,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('binds an installed legacy plugin through the explicit source install endpoint', async () => {
|
it('delegates an installed legacy plugin binding to the list transaction owner', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
if (url === 'plugin/source/DemoPlugin/options') {
|
if (url === 'plugin/source/DemoPlugin/options') {
|
||||||
@@ -315,12 +321,15 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
confirmText: '确认绑定',
|
confirmText: '确认绑定',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
|
expect(emitted().sourceTransition).toContainEqual([
|
||||||
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
|
{
|
||||||
force: true,
|
action: 'bind',
|
||||||
})
|
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin/install', expect.anything())
|
||||||
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin', expect.anything())
|
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin', expect.anything())
|
||||||
await waitFor(() => expect(emitted().install).toHaveLength(1))
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes a legacy history update into the binding flow without ordinary install', async () => {
|
it('routes a legacy history update into the binding flow without ordinary install', async () => {
|
||||||
@@ -360,7 +369,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect(screen.getByRole('button', { name: '绑定' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '绑定' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reloads source evidence after a stale revision failure without retrying the change', async () => {
|
it('delegates the revision observed when the repository change was confirmed', async () => {
|
||||||
let sourceReadCount = 0
|
let sourceReadCount = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
@@ -391,22 +400,23 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
}
|
}
|
||||||
return Promise.resolve({ success: true })
|
return Promise.resolve({ success: true })
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockResolvedValueOnce({
|
|
||||||
success: false,
|
|
||||||
message: '来源 revision 已变化,请重新确认',
|
|
||||||
data: null,
|
|
||||||
})
|
|
||||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
|
||||||
|
|
||||||
await fireEvent.click(await screen.findByRole('button', { name: '更换' }))
|
await fireEvent.click(await screen.findByRole('button', { name: '更换' }))
|
||||||
await fireEvent.click(screen.getByText('example/plugins'))
|
await fireEvent.click(screen.getByText('example/plugins'))
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
|
await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
|
expect(sourceReadCount).toBe(1)
|
||||||
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
expect(emitted().sourceTransition).toContainEqual([
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options', { params: { force: true } })
|
{
|
||||||
|
action: 'change',
|
||||||
|
repo_url: 'https://github.com/example/plugins',
|
||||||
|
expected_revision: 7,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin', expect.anything())
|
||||||
expect(emitted().install).toBeUndefined()
|
expect(emitted().install).toBeUndefined()
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -503,7 +513,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: '确认安装',
|
title: '确认安装',
|
||||||
confirmText: '安装',
|
confirmText: '安装',
|
||||||
content: expect.stringContaining('后续更新也将使用该仓库'),
|
content: '将从 example/plugins 安装「演示插件」。\n\n这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||||
@@ -518,11 +528,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
|
|
||||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||||
|
|
||||||
expect(installHandler).toHaveBeenCalledWith(
|
expect(installHandler).toHaveBeenCalledWith(undefined, 'https://github.com/example/plugins', defaultSourceOptions)
|
||||||
undefined,
|
|
||||||
'https://github.com/example/plugins',
|
|
||||||
defaultSourceOptions,
|
|
||||||
)
|
|
||||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
expect(emitted().install).toBeUndefined()
|
expect(emitted().install).toBeUndefined()
|
||||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
|
|||||||
@@ -3843,7 +3843,7 @@ export default {
|
|||||||
hasUpdate: 'Update Available',
|
hasUpdate: 'Update Available',
|
||||||
boundUpdateAvailable: '{source} has an installable update v{version}',
|
boundUpdateAvailable: '{source} has an installable update v{version}',
|
||||||
alternativeUpdateAvailable: '{source} has update v{version}; changing repositories requires confirmation',
|
alternativeUpdateAvailable: '{source} has update v{version}; changing repositories requires confirmation',
|
||||||
viewUpdateSources: 'View Update Sources',
|
viewUpdateSources: 'View Update',
|
||||||
configuring: 'Configuring',
|
configuring: 'Configuring',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
@@ -3879,12 +3879,11 @@ export default {
|
|||||||
bindSource: 'Bind Repository',
|
bindSource: 'Bind Repository',
|
||||||
bindSourceInline: 'Bind',
|
bindSourceInline: 'Bind',
|
||||||
confirmThirdPartyInstallTitle: 'Confirm Installation',
|
confirmThirdPartyInstallTitle: 'Confirm Installation',
|
||||||
confirmThirdPartyInstall: 'Install “{name}” from {target}. Future updates will also use this repository.',
|
confirmThirdPartyInstall: 'Install “{name}” from {target}.',
|
||||||
confirmThirdPartyInstallAction: 'Install',
|
confirmThirdPartyInstallAction: 'Install',
|
||||||
confirmSourceBindTitle: 'Confirm Repository Binding',
|
confirmSourceBindTitle: 'Confirm Repository Binding',
|
||||||
confirmSourceBind: 'Bind “{name}” to {target} and install the current version from this repository.',
|
confirmSourceBind: 'Bind “{name}” to {target} and install the current version from this repository.',
|
||||||
thirdPartySourceRisk:
|
thirdPartySourceRisk: 'This is a third-party repository. Future updates will come from it. Confirm to continue.',
|
||||||
'This is a third-party repository. Future updates will come from it. Confirm to continue.',
|
|
||||||
confirmSourceBindAction: 'Confirm Bind',
|
confirmSourceBindAction: 'Confirm Bind',
|
||||||
bindingSource: 'Installing {name} from the selected repository...',
|
bindingSource: 'Installing {name} from the selected repository...',
|
||||||
sourceBindSuccess: 'The repository for {name} was bound',
|
sourceBindSuccess: 'The repository for {name} was bound',
|
||||||
|
|||||||
@@ -3780,7 +3780,7 @@ export default {
|
|||||||
hasUpdate: '有更新',
|
hasUpdate: '有更新',
|
||||||
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
|
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
|
||||||
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
|
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
|
||||||
viewUpdateSources: '查看更新来源',
|
viewUpdateSources: '查看更新',
|
||||||
configuring: '配置',
|
configuring: '配置',
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
@@ -3816,7 +3816,7 @@ export default {
|
|||||||
bindSource: '绑定仓库',
|
bindSource: '绑定仓库',
|
||||||
bindSourceInline: '绑定',
|
bindSourceInline: '绑定',
|
||||||
confirmThirdPartyInstallTitle: '确认安装',
|
confirmThirdPartyInstallTitle: '确认安装',
|
||||||
confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
|
confirmThirdPartyInstall: '将从 {target} 安装「{name}」。',
|
||||||
confirmThirdPartyInstallAction: '安装',
|
confirmThirdPartyInstallAction: '安装',
|
||||||
confirmSourceBindTitle: '确认绑定仓库',
|
confirmSourceBindTitle: '确认绑定仓库',
|
||||||
confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
|
confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
|
||||||
|
|||||||
@@ -3779,7 +3779,7 @@ export default {
|
|||||||
hasUpdate: '可更新',
|
hasUpdate: '可更新',
|
||||||
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
|
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
|
||||||
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
|
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
|
||||||
viewUpdateSources: '查看更新來源',
|
viewUpdateSources: '查看更新',
|
||||||
configuring: '配置中',
|
configuring: '配置中',
|
||||||
enable: '啟用',
|
enable: '啟用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
@@ -3815,7 +3815,7 @@ export default {
|
|||||||
bindSource: '綁定倉庫',
|
bindSource: '綁定倉庫',
|
||||||
bindSourceInline: '綁定',
|
bindSourceInline: '綁定',
|
||||||
confirmThirdPartyInstallTitle: '確認安裝',
|
confirmThirdPartyInstallTitle: '確認安裝',
|
||||||
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
|
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」。',
|
||||||
confirmThirdPartyInstallAction: '安裝',
|
confirmThirdPartyInstallAction: '安裝',
|
||||||
confirmSourceBindTitle: '確認綁定倉庫',
|
confirmSourceBindTitle: '確認綁定倉庫',
|
||||||
confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
|
confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
|
import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
|
||||||
import type { Plugin, PluginRating, PluginSourceOptions } from '@/api/types'
|
import type { Plugin, PluginRating, PluginSourceOptions, PluginSourceTransition } from '@/api/types'
|
||||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import { getPluginTabs } from '@/router/i18n-menu'
|
import { getPluginTabs } from '@/router/i18n-menu'
|
||||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
@@ -970,6 +970,48 @@ async function installPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在已安装卡片上执行仓库绑定或换仓,并复用插件级安装状态。 */
|
||||||
|
async function transitionPluginSource(item: Plugin, transition: PluginSourceTransition) {
|
||||||
|
const pluginId = item.id
|
||||||
|
if (!pluginId || installingPluginIds.value.has(pluginId)) return
|
||||||
|
|
||||||
|
installingPluginIds.value = new Set([...installingPluginIds.value, pluginId])
|
||||||
|
try {
|
||||||
|
if (transition.action === 'bind') {
|
||||||
|
await installPluginFromSource(pluginId, {
|
||||||
|
repo_url: transition.repo_url,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await changePluginSource(pluginId, {
|
||||||
|
repo_url: transition.repo_url,
|
||||||
|
expected_revision: transition.expected_revision,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$toast.success(
|
||||||
|
t(transition.action === 'bind' ? 'plugin.sourceBindSuccess' : 'plugin.sourceChangeSuccess', {
|
||||||
|
name: item.plugin_name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await fetchInstalledPlugins({ silent: true })
|
||||||
|
if (userStore.superUser) await pluginRuntimeStore.refresh()
|
||||||
|
await pluginSidebarNavStore.ensureSidebarNav(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
$toast.error(
|
||||||
|
t(transition.action === 'bind' ? 'plugin.sourceBindFailed' : 'plugin.sourceChangeFailed', {
|
||||||
|
name: item.plugin_name,
|
||||||
|
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
void fetchInstalledPlugins({ silent: true })
|
||||||
|
} finally {
|
||||||
|
const pending = new Set(installingPluginIds.value)
|
||||||
|
pending.delete(pluginId)
|
||||||
|
installingPluginIds.value = pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开未安装插件的市场详情,确认后才进入安装流程。 */
|
/** 打开未安装插件的市场详情,确认后才进入安装流程。 */
|
||||||
function openPluginMarketDetail(item: Plugin) {
|
function openPluginMarketDetail(item: Plugin) {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -2234,6 +2276,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
|
@source-transition="transitionPluginSource"
|
||||||
@action-done="
|
@action-done="
|
||||||
pluginId => {
|
pluginId => {
|
||||||
pluginActions[pluginId] = false
|
pluginActions[pluginId] = false
|
||||||
@@ -2265,6 +2308,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
|
@source-transition="transitionPluginSource"
|
||||||
@action-done="
|
@action-done="
|
||||||
pluginId => {
|
pluginId => {
|
||||||
pluginActions[pluginId] = false
|
pluginActions[pluginId] = false
|
||||||
@@ -2299,6 +2343,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:show-remove-button="true"
|
:show-remove-button="true"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
|
@source-transition="transitionPluginSource"
|
||||||
@action-done="
|
@action-done="
|
||||||
pluginId => {
|
pluginId => {
|
||||||
pluginActions[pluginId] = false
|
pluginActions[pluginId] = false
|
||||||
@@ -2326,6 +2371,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:show-remove-button="true"
|
:show-remove-button="true"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
|
@source-transition="transitionPluginSource"
|
||||||
@action-done="
|
@action-done="
|
||||||
pluginId => {
|
pluginId => {
|
||||||
pluginActions[pluginId] = false
|
pluginActions[pluginId] = false
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const apiUrls = {
|
|||||||
runtime: new URL('plugin/runtime', API_BASE_URL).href,
|
runtime: new URL('plugin/runtime', API_BASE_URL).href,
|
||||||
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
|
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
|
||||||
sourceOptions: new URL('plugin/source/:pluginId/options', API_BASE_URL).href,
|
sourceOptions: new URL('plugin/source/:pluginId/options', API_BASE_URL).href,
|
||||||
|
sourceBind: (pluginId: string) => new URL(`plugin/source/${pluginId}/install`, API_BASE_URL).href,
|
||||||
|
sourceChange: (pluginId: string) => new URL(`plugin/source/${pluginId}`, API_BASE_URL).href,
|
||||||
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +159,7 @@ const PluginMixedSortCardStub = defineComponent({
|
|||||||
'refresh-data',
|
'refresh-data',
|
||||||
'rename-folder',
|
'rename-folder',
|
||||||
'remove-from-folder',
|
'remove-from-folder',
|
||||||
|
'source-transition',
|
||||||
'update-folder-config',
|
'update-folder-config',
|
||||||
],
|
],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
@@ -236,6 +239,35 @@ 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('source-transition', data, {
|
||||||
|
action: 'change',
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
}),
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
`change-source-${id}`,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
type === 'plugin'
|
||||||
|
? h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
onClick: () =>
|
||||||
|
emit('source-transition', data, {
|
||||||
|
action: 'bind',
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
}),
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
`bind-source-${id}`,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
type === 'plugin'
|
type === 'plugin'
|
||||||
? h(
|
? h(
|
||||||
'button',
|
'button',
|
||||||
@@ -1638,6 +1670,99 @@ describe('PluginCardListView search installation', () => {
|
|||||||
expect(screen.queryByText('plugin:失败插件')).not.toBeInTheDocument()
|
expect(screen.queryByText('plugin:失败插件')).not.toBeInTheDocument()
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the installed card as busy and deduplicates a repository change', async () => {
|
||||||
|
const sourceGate = createDeferred<void>()
|
||||||
|
const sourceStarted = createDeferred<void>()
|
||||||
|
let sourceRequests = 0
|
||||||
|
let requestBody: unknown
|
||||||
|
const target = createPlugin({ id: 'SourcePlugin', installed: true, plugin_name: '换仓插件' })
|
||||||
|
let installedRequests = 0
|
||||||
|
const { pinia } = await renderList({
|
||||||
|
installed: () => {
|
||||||
|
installedRequests += 1
|
||||||
|
return [target]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
const installedRequestsBeforeChange = installedRequests
|
||||||
|
const runtimeStore = usePluginRuntimeStore(pinia)
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
server.use(
|
||||||
|
http.post(apiUrls.sourceChange('SourcePlugin'), async ({ request }) => {
|
||||||
|
sourceRequests += 1
|
||||||
|
requestBody = await request.json()
|
||||||
|
sourceStarted.resolve()
|
||||||
|
await sourceGate.promise
|
||||||
|
return apiJson(null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const changeButton = screen.getByRole('button', { name: 'change-source-SourcePlugin' })
|
||||||
|
await fireEvent.click(changeButton)
|
||||||
|
await fireEvent.click(changeButton)
|
||||||
|
await sourceStarted.promise
|
||||||
|
|
||||||
|
expect(sourceRequests).toBe(1)
|
||||||
|
expect(requestBody).toEqual({
|
||||||
|
expected_revision: 7,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
})
|
||||||
|
expect(screen.getByLabelText('installing-SourcePlugin')).toHaveTextContent('true')
|
||||||
|
|
||||||
|
sourceGate.resolve()
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 换仓插件 的仓库已更换'))
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('installing-SourcePlugin')).toHaveTextContent('false'))
|
||||||
|
expect(installedRequests).toBeGreaterThan(installedRequestsBeforeChange)
|
||||||
|
expect(runtimeStore.refresh).toHaveBeenCalled()
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs an initial repository binding through the same installed-card transaction', async () => {
|
||||||
|
let requestBody: unknown
|
||||||
|
const target = createPlugin({ id: 'BindingPlugin', installed: true, plugin_name: '待绑定插件' })
|
||||||
|
await renderList({ installed: () => [target] })
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
server.use(
|
||||||
|
http.post(apiUrls.sourceBind('BindingPlugin'), async ({ request }) => {
|
||||||
|
requestBody = await request.json()
|
||||||
|
return apiJson(null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'bind-source-BindingPlugin' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 待绑定插件 已绑定仓库'))
|
||||||
|
expect(requestBody).toEqual({
|
||||||
|
force: true,
|
||||||
|
repo_url: 'https://github.com/example/target',
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('installing-BindingPlugin')).toHaveTextContent('false'))
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the installed card state when a repository change rolls back', async () => {
|
||||||
|
const target = createPlugin({ id: 'FailedSourcePlugin', installed: true, plugin_name: '换仓失败插件' })
|
||||||
|
let installedRequests = 0
|
||||||
|
await renderList({
|
||||||
|
installed: () => {
|
||||||
|
installedRequests += 1
|
||||||
|
return [target]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
const installedRequestsBeforeChange = installedRequests
|
||||||
|
server.use(http.post(apiUrls.sourceChange('FailedSourcePlugin'), () => apiFailureJson('目标仓库不可用')))
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'change-source-FailedSourcePlugin' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 换仓失败插件 更换仓库失败:目标仓库不可用'))
|
||||||
|
expect(screen.getByText('plugin:换仓失败插件')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('installing-FailedSourcePlugin')).toHaveTextContent('false')
|
||||||
|
await waitFor(() => expect(installedRequests).toBeGreaterThan(installedRequestsBeforeChange))
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('PluginCardListView folders and persistence', () => {
|
describe('PluginCardListView folders and persistence', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user