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'
}
/** 插件市场为已安装插件发现的当前最高在线更新候选。 */
/** 插件市场为已安装插件选择的当前更新候选。 */
export interface PluginUpdateCandidate {
// 候选仓库是官方来源还是第三方来源
source_type: 'official' | 'third_party'
@@ -1092,6 +1092,23 @@ export interface PluginSourceChangeRequest {
release_version?: string | null
}
/** 已安装插件完成来源确认后产生的仓库变更意图。 */
export type PluginSourceTransition =
| {
// 为存量未绑定插件建立初始可信仓库
action: 'bind'
// 管理员明确选择的目标插件仓库地址
repo_url: string
}
| {
// 把已绑定插件切换到另一个可信仓库
action: 'change'
// 管理员明确选择的目标插件仓库地址
repo_url: string
// 提交换仓时必须匹配的当前身份 revision
expected_revision: number
}
export interface PluginRuntimeSummary {
// 本轮插件源码、依赖和加载是否已收敛
ready: boolean
+29 -11
View File
@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
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 { usePluginCardAccent } from '@/composables/usePluginCardAccent'
import { formatDownloadCount } from '@/@core/utils/formatters'
@@ -45,7 +45,13 @@ const props = defineProps({
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()
@@ -56,13 +62,15 @@ const hasCardStatus = computed(
() => sourceBindingRequired.value || Boolean(props.plugin?.has_update) || hasCardRating.value,
)
const updateCandidate = computed(() => props.plugin?.update_candidate)
const hasAlternativeUpdate = computed(
() => Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound),
const hasAlternativeUpdate = computed(() =>
Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound),
)
const updateSourceName = computed(() => {
const candidate = updateCandidate.value
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 candidate = updateCandidate.value
@@ -420,13 +428,13 @@ async function fetchInstalledPluginDetail() {
return pluginDetail
}
/** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */
async function showPluginAbout(initialSourceSelectionOpen = false) {
const pluginDetail = await fetchInstalledPluginDetail()
/** 先展示本地快照,再用市场详情补齐仍处于打开状态的同一个弹窗。 */
function showPluginAbout(initialSourceSelectionOpen = false) {
const pluginDetail = props.plugin
if (!pluginDetail) return
marketDetailDialogController?.close()
marketDetailDialogController = openSharedDialog(
const controller = openSharedDialog(
PluginMarketDetailDialog,
{
plugin: pluginDetail,
@@ -440,9 +448,19 @@ async function showPluginAbout(initialSourceSelectionOpen = false) {
void pluginSidebarNavStore.ensureSidebarNav(true)
},
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
sourceTransition: (transition: PluginSourceTransition) => {
if (props.plugin) emit('sourceTransition', props.plugin, transition)
},
},
{ 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;
gap: 0.5rem;
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-weight: 600;
inset: 0;
@@ -922,7 +940,7 @@ watch(
.plugin-card__runtime-state--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 {
+3 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { PluginRating } from '@/api/types'
import type { Plugin, PluginRating, PluginSourceTransition } from '@/api/types'
import PluginCard from './PluginCard.vue'
import PluginFolderCard from './PluginFolderCard.vue'
@@ -36,6 +36,7 @@ const emit = defineEmits<{
updateFolderConfig: [folderName: string, config: any]
refreshData: []
rating: [pluginRating: PluginRating]
sourceTransition: [plugin: Plugin, transition: PluginSourceTransition]
actionDone: [pluginId: string]
removeFromFolder: [pluginId: string]
dropToFolder: [event: DragEvent, folderName: string]
@@ -117,6 +118,7 @@ function handleDropToFolder(event: DragEvent) {
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@rating="$emit('rating', $event)"
@source-transition="(plugin, transition) => $emit('sourceTransition', plugin, transition)"
@action-done="$emit('actionDone', item.id)"
/>
@@ -238,11 +238,9 @@ describe('PluginCard lifecycle actions', () => {
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
await fireEvent.mouseEnter(screen.getByLabelText('有更新'))
expect(
await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库'),
).toBeInTheDocument()
expect(await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库')).toBeInTheDocument()
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())
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
@@ -272,6 +270,60 @@ describe('PluginCard lifecycle actions', () => {
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 () => {
const updatablePlugin = {
...plugin,
@@ -51,7 +51,10 @@ describe('PluginCard about menu', () => {
repo_url: 'https://github.com/example/plugins',
})
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 () => {
@@ -76,7 +79,6 @@ describe('PluginCard about menu', () => {
expect(dialogProps.plugin).toMatchObject({
id: 'DemoPlugin',
installed: true,
repo_url: 'https://github.com/example/plugins',
})
expect(dialogProps.count).toBe(24)
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({
id: 'DemoPlugin',
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>
import api from '@/api'
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 { formatDownloadCount } from '@/@core/utils/formatters'
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({
@@ -77,7 +77,6 @@ const sourceError = ref('')
const selectedInstallSourceKey = ref('')
const selectedChangeSourceKey = ref('')
const showSourceChoices = ref(props.initialSourceSelectionOpen)
const sourceChanging = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
@@ -213,7 +212,7 @@ async function loadPluginSourceOptions(force = false) {
}
}
/** 明确绑定或切换自动更新来源,换源时使用打开弹窗时读取的 revision。 */
/** 确认仓库后关闭详情,由上层状态所有者执行安装事务。 */
async function confirmSourceTransition() {
const identity = sourceOptions.value?.identity
const target = selectedChangeSource.value
@@ -239,42 +238,17 @@ async function confirmSourceTransition() {
})
if (!confirmed) return
sourceChanging.value = true
showInstallProgress(
t(bindingSource ? 'plugin.bindingSource' : 'plugin.changingSource', { name: props.plugin.plugin_name }),
emit(
'sourceTransition',
bindingSource
? { action: 'bind', repo_url: target.repo_url }
: {
action: 'change',
repo_url: target.repo_url,
expected_revision: identity!.revision,
},
)
try {
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()
}
visible.value = false
}
/** 计算插件图标路径。 */
@@ -587,7 +561,10 @@ onUnmounted(() => {
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
<dd
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">
<VChip
@@ -700,7 +677,6 @@ onUnmounted(() => {
size="small"
color="primary"
variant="text"
:loading="sourceChanging"
:disabled="!selectedChangeSource"
@click="confirmSourceTransition"
>
@@ -230,11 +230,17 @@ describe('PluginMarketDetailDialog', () => {
confirmText: '确认更换',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
repo_url: 'https://github.com/example/plugins',
expected_revision: 7,
})
await waitFor(() => expect(emitted().install).toHaveLength(1))
expect(emitted().sourceTransition).toContainEqual([
{
action: 'change',
repo_url: 'https://github.com/example/plugins',
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 () => {
@@ -269,7 +275,7 @@ describe('PluginMarketDetailDialog', () => {
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) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
@@ -315,12 +321,15 @@ describe('PluginMarketDetailDialog', () => {
confirmText: '确认绑定',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
force: true,
})
expect(emitted().sourceTransition).toContainEqual([
{
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())
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 () => {
@@ -360,7 +369,7 @@ describe('PluginMarketDetailDialog', () => {
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
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
@@ -391,22 +400,23 @@ describe('PluginMarketDetailDialog', () => {
}
return Promise.resolve({ success: true })
})
mocks.apiPost.mockResolvedValueOnce({
success: false,
message: '来源 revision 已变化,请重新确认',
data: null,
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
await fireEvent.click(await screen.findByRole('button', { name: '更换' }))
await fireEvent.click(screen.getByText('example/plugins'))
await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options', { params: { force: true } })
expect(sourceReadCount).toBe(1)
expect(emitted().sourceTransition).toContainEqual([
{
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(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 () => {
@@ -503,7 +513,7 @@ describe('PluginMarketDetailDialog', () => {
expect.objectContaining({
title: '确认安装',
confirmText: '安装',
content: expect.stringContaining('后续更新也将使用该仓库'),
content: '将从 example/plugins 安装「演示插件」。\n\n这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
@@ -518,11 +528,7 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
expect(installHandler).toHaveBeenCalledWith(
undefined,
'https://github.com/example/plugins',
defaultSourceOptions,
)
expect(installHandler).toHaveBeenCalledWith(undefined, 'https://github.com/example/plugins', defaultSourceOptions)
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
expect(emitted()['update:modelValue']).toContainEqual([false])
+3 -4
View File
@@ -3843,7 +3843,7 @@ export default {
hasUpdate: 'Update Available',
boundUpdateAvailable: '{source} has an installable update v{version}',
alternativeUpdateAvailable: '{source} has update v{version}; changing repositories requires confirmation',
viewUpdateSources: 'View Update Sources',
viewUpdateSources: 'View Update',
configuring: 'Configuring',
enable: 'Enable',
disable: 'Disable',
@@ -3879,12 +3879,11 @@ export default {
bindSource: 'Bind Repository',
bindSourceInline: 'Bind',
confirmThirdPartyInstallTitle: 'Confirm Installation',
confirmThirdPartyInstall: 'Install “{name}” from {target}. Future updates will also use this repository.',
confirmThirdPartyInstall: 'Install “{name}” from {target}.',
confirmThirdPartyInstallAction: 'Install',
confirmSourceBindTitle: 'Confirm Repository Binding',
confirmSourceBind: 'Bind “{name}” to {target} and install the current version from this repository.',
thirdPartySourceRisk:
'This is a third-party repository. Future updates will come from it. Confirm to continue.',
thirdPartySourceRisk: 'This is a third-party repository. Future updates will come from it. Confirm to continue.',
confirmSourceBindAction: 'Confirm Bind',
bindingSource: 'Installing {name} from the selected repository...',
sourceBindSuccess: 'The repository for {name} was bound',
+2 -2
View File
@@ -3780,7 +3780,7 @@ export default {
hasUpdate: '有更新',
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
viewUpdateSources: '查看更新来源',
viewUpdateSources: '查看更新',
configuring: '配置',
enable: '启用',
disable: '禁用',
@@ -3816,7 +3816,7 @@ export default {
bindSource: '绑定仓库',
bindSourceInline: '绑定',
confirmThirdPartyInstallTitle: '确认安装',
confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
confirmThirdPartyInstall: '将从 {target} 安装「{name}」。',
confirmThirdPartyInstallAction: '安装',
confirmSourceBindTitle: '确认绑定仓库',
confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
+2 -2
View File
@@ -3779,7 +3779,7 @@ export default {
hasUpdate: '可更新',
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
viewUpdateSources: '查看更新來源',
viewUpdateSources: '查看更新',
configuring: '配置中',
enable: '啟用',
disable: '禁用',
@@ -3815,7 +3815,7 @@ export default {
bindSource: '綁定倉庫',
bindSourceInline: '綁定',
confirmThirdPartyInstallTitle: '確認安裝',
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」。',
confirmThirdPartyInstallAction: '安裝',
confirmSourceBindTitle: '確認綁定倉庫',
confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
+48 -2
View File
@@ -2,8 +2,8 @@
import { useToast } from 'vue-toastification'
import api from '@/api'
import { getApiBusinessErrorMessage } from '@/api/client'
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import type { Plugin, PluginRating, PluginSourceOptions } from '@/api/types'
import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import type { Plugin, PluginRating, PluginSourceOptions, PluginSourceTransition } from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getPluginTabs } from '@/router/i18n-menu'
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) {
openSharedDialog(
@@ -2234,6 +2276,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@rating="applyPluginRating"
@source-transition="transitionPluginSource"
@action-done="
pluginId => {
pluginActions[pluginId] = false
@@ -2265,6 +2308,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
@refresh-data="refreshData"
@rating="applyPluginRating"
@source-transition="transitionPluginSource"
@action-done="
pluginId => {
pluginActions[pluginId] = false
@@ -2299,6 +2343,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:show-remove-button="true"
@refresh-data="refreshData"
@rating="applyPluginRating"
@source-transition="transitionPluginSource"
@action-done="
pluginId => {
pluginActions[pluginId] = false
@@ -2326,6 +2371,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:show-remove-button="true"
@refresh-data="refreshData"
@rating="applyPluginRating"
@source-transition="transitionPluginSource"
@action-done="
pluginId => {
pluginActions[pluginId] = false
@@ -23,6 +23,8 @@ const apiUrls = {
runtime: new URL('plugin/runtime', 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,
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,
}
@@ -157,6 +159,7 @@ const PluginMixedSortCardStub = defineComponent({
'refresh-data',
'rename-folder',
'remove-from-folder',
'source-transition',
'update-folder-config',
],
setup(props, { emit }) {
@@ -236,6 +239,35 @@ 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('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'
? h(
'button',
@@ -1638,6 +1670,99 @@ describe('PluginCardListView search installation', () => {
expect(screen.queryByText('plugin:失败插件')).not.toBeInTheDocument()
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', () => {