mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(plugin): 收敛插件仓库来源交互 (#725)
This commit is contained in:
+18
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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},并安装该仓库的当前版本。',
|
||||
|
||||
@@ -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},並安裝該倉庫的目前版本。',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user