fix(plugin): 收敛插件仓库来源交互 (#725)

This commit is contained in:
InfinityPacer
2026-08-27 06:54:42 +08:00
committed by GitHub
parent dc6c0462bd
commit d8a843d5eb
13 changed files with 408 additions and 98 deletions
+18 -1
View File
@@ -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
+29 -11
View File
@@ -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 {
+3 -1
View File
@@ -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])
+3 -4
View File
@@ -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',
+2 -2
View File
@@ -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},并安装该仓库的当前版本。',
+2 -2
View File
@@ -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},並安裝該倉庫的目前版本。',
+48 -2
View File
@@ -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', () => {