feat(plugin): 增加插件来源绑定与换源界面 (#722)

This commit is contained in:
InfinityPacer
2026-08-26 07:51:38 +08:00
committed by GitHub
parent bb25732cfc
commit 06c711bb03
17 changed files with 1562 additions and 102 deletions
+80
View File
@@ -0,0 +1,80 @@
import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import { ApiRequestError } from '@/api/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
}))
describe('plugin source API adapters', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiGet.mockResolvedValue({
plugin_id: 'DemoPlugin',
inventory_complete: true,
selection_status: 'selected',
selection_reason: '',
identity: null,
candidates: [],
})
mocks.apiPost.mockResolvedValue(undefined)
})
it('查询来源候选时编码插件 ID,并按需传递强制刷新参数', async () => {
await getPluginSourceOptions('Demo Plugin', true)
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/Demo%20Plugin/options', { params: { force: true } })
})
it('默认查询不伪造 force 参数', async () => {
await getPluginSourceOptions('DemoPlugin')
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options')
})
it('按明确来源安装和 CAS 换源分别发送对应请求体', async () => {
const installRequest = {
repo_url: 'https://github.com/example/plugins',
release_version: '1.2.3',
force: true,
}
const changeRequest = {
repo_url: 'https://github.com/other/plugins',
expected_revision: 7,
release_version: null,
}
await installPluginFromSource('DemoPlugin', installRequest)
await changePluginSource('DemoPlugin', changeRequest)
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'plugin/source/DemoPlugin/install', installRequest)
expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'plugin/source/DemoPlugin', changeRequest)
})
it('不吞掉后端业务失败,调用方仍可捕获 ApiRequestError', async () => {
mocks.apiPost.mockResolvedValueOnce({
success: false,
message: '来源 revision 已变化,请重新确认',
data: null,
})
const error = await installPluginFromSource('DemoPlugin', {
repo_url: 'https://github.com/example/plugins',
}).catch(reason => reason)
expect(error).toBeInstanceOf(ApiRequestError)
expect(error).toMatchObject({
message: '来源 revision 已变化,请重新确认',
businessFailure: true,
})
})
})
+24
View File
@@ -0,0 +1,24 @@
import api from './index'
import type { PluginSourceChangeRequest, PluginSourceInstallRequest, PluginSourceOptions } from './types'
/** 查询插件来源候选、当前绑定身份和来源准入状态。 */
export function getPluginSourceOptions(pluginId: string, force = false): Promise<PluginSourceOptions> {
return api.get(`plugin/source/${encodeURIComponent(pluginId)}/options`, {
...(force ? { params: { force: true } } : {}),
feedback: 'silent',
})
}
/** 按管理员明确选择的在线来源安装未绑定插件。 */
export function installPluginFromSource(pluginId: string, request: PluginSourceInstallRequest): Promise<void> {
return api.post(`plugin/source/${encodeURIComponent(pluginId)}/install`, request, {
feedback: 'silent',
})
}
/** 按当前身份 revision 进行 CAS 保护的在线来源切换。 */
export function changePluginSource(pluginId: string, request: PluginSourceChangeRequest): Promise<void> {
return api.post(`plugin/source/${encodeURIComponent(pluginId)}`, request, {
feedback: 'silent',
})
}
+81
View File
@@ -993,6 +993,87 @@ export interface Plugin {
instance_mode?: 'virtual'
}
/** 已绑定插件可用于自动更新的在线来源类型。 */
export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party'
/** 最近一次已应用插件载荷的来源类型,包含本地开发来源。 */
export type PluginPayloadSourceType = PluginTrustedSourceType | 'local'
/** 已安装插件来源身份建立时采用的依据。 */
export type PluginSourceBindingBasis =
'legacy_unbound' | 'local_only' | 'official_default' | 'tofu' | 'explicit_install' | 'explicit_source_change'
/** 来源候选的展示类型;本地候选只展示为本地,不暴露路径。 */
export type PluginSourceCandidateType = 'official' | 'third_party' | 'local'
/** 显式换源确认所需的当前可信来源身份和并发控制版本。 */
export interface PluginSourceIdentity {
// 物理插件 ID
plugin_id: string
// 当前可信在线来源类型
trusted_source_type: PluginTrustedSourceType
// 规范化的可信在线来源键;未绑定时为空
trusted_source_key?: string | null
// 当前可信来源的建立依据
binding_basis: PluginSourceBindingBasis
// 最近一次已提交载荷的来源类型
payload_source_type: PluginPayloadSourceType
// 最近一次在线载荷的来源键;本地或未知载荷为空
payload_source_key?: string | null
// 显式换源使用的身份 CAS revision
revision: number
}
/** 一个可供管理员识别的脱敏插件来源候选。 */
export interface PluginSourceCandidate {
// 来源类型;本地候选不公开路径
source_type: PluginSourceCandidateType
// 规范化在线来源键;本地候选为空
source_key?: string | null
// 可明确选择的在线仓库地址;本地候选为空
repo_url?: string | null
// 当前运行时会采用的插件包代际
package_generation: 'v1' | 'v2' | 'v3'
// 该来源当前可安装的插件版本
plugin_version?: string | null
}
/** 来源选择界面所需的当前身份、候选和准入状态。 */
export interface PluginSourceOptions {
// 物理插件 ID
plugin_id: string
// 本轮配置市场是否全部得到确定读取结果
inventory_complete: boolean
// 未指定新来源时的当前准入状态
selection_status: 'selected' | 'unavailable' | 'conflict' | 'incomplete'
// 当前准入状态的人类可读原因
selection_reason: string
// 已安装插件的来源身份;未建立身份时为空
identity?: PluginSourceIdentity | null
// 按来源归并后的在线候选及可选本地候选
candidates: PluginSourceCandidate[]
}
/** 管理员为未绑定插件明确选择初始在线来源的请求参数。 */
export interface PluginSourceInstallRequest {
// 明确选择的目标插件仓库地址
repo_url: string
// 指定安装的 Release 资产版本;为空时使用当前索引版本
release_version?: string | null
// 是否强制重新下载并安装所选来源载荷
force?: boolean
}
/** 管理员显式切换插件在线来源的请求参数。 */
export interface PluginSourceChangeRequest {
// 明确选择的目标插件仓库地址
repo_url: string
// 提交换源时必须匹配的当前身份 revision
expected_revision: number
// 指定安装的 Release 资产版本;为空时使用当前索引版本
release_version?: string | null
}
export interface PluginRuntimeSummary {
// 本轮插件源码、依赖和加载是否已收敛
ready: boolean
-1
View File
@@ -167,7 +167,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
await api.get(`plugin/install/${props.plugin?.id}`, {
feedback: 'silent',
params: {
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
+10 -9
View File
@@ -157,11 +157,18 @@ function showUpdateHistory(showUpdateAction: boolean = false) {
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ update: updatePlugin, sourceAction: openSourceAction },
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 打开能够完成来源绑定或切换的管理界面。 */
async function openSourceAction() {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
await showPluginAbout()
}
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
@@ -267,7 +274,7 @@ async function resetPlugin() {
}
// 更新插件
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
async function updatePlugin(releaseVersion?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
@@ -296,7 +303,6 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
await api.get(`plugin/install/${props.plugin?.id}`, {
feedback: 'silent',
params: {
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: true,
},
@@ -476,12 +482,7 @@ function showPluginClone() {
}
// 执行插件分身
async function executePluginClone(cloneForm: {
suffix: string
name: string
description: string
icon: string
}) {
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; icon: string }) {
if (!cloneForm.suffix.trim()) {
$toast.error(t('plugin.suffixRequired'))
return
@@ -174,7 +174,6 @@ describe('PluginAppCard rating badge', () => {
params: {
force: true,
release_version: '0.9.0',
repo_url: 'https://github.com/example/releases',
},
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
@@ -189,7 +189,6 @@ describe('PluginCard lifecycle actions', () => {
params: {
force: true,
release_version: '0.9.0',
repo_url: 'https://github.com/example/releases',
},
})
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
@@ -257,12 +256,7 @@ describe('PluginCard lifecycle actions', () => {
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
await fireEvent.click(await screen.findByText('分身'))
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
clone: (form: {
suffix: string
name: string
description: string
icon: string
}) => Promise<void>
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
}
await cloneEvents.clone({
suffix: ' Test ',
@@ -289,12 +283,7 @@ describe('PluginCard lifecycle actions', () => {
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
await fireEvent.click(await screen.findByText('分身'))
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
clone: (form: {
suffix: string
name: string
description: string
icon: string
}) => Promise<void>
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
}
await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' })
@@ -308,12 +297,7 @@ describe('PluginCard lifecycle actions', () => {
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
await fireEvent.click(await screen.findByText('分身'))
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
clone: (form: {
suffix: string
name: string
description: string
icon: string
}) => Promise<void>
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
}
const form = { suffix: 'Test', name: '测试', description: '', icon: '' }
await cloneEvents.clone(form)
@@ -98,6 +98,32 @@ describe('PluginCard about menu', () => {
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
})
it('routes a history source action into the source management dialog', async () => {
const { container } = await renderWithProviders(PluginCard, {
props: { plugin: { ...plugin, has_update: true }, count: 24 },
})
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
await fireEvent.click(await screen.findByText('更新'))
const historyEvents = mocks.openSharedDialog.mock.calls[0][2] as {
sourceAction: () => Promise<void>
}
expect(historyEvents.sourceAction).toBeTypeOf('function')
await historyEvents.sourceAction()
expect(mocks.closeDialog).toHaveBeenCalled()
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
params: { force: false },
})
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
expect(mocks.openSharedDialog.mock.calls[1][1].plugin).toMatchObject({
id: 'DemoPlugin',
installed: true,
repo_url: 'https://github.com/example/plugins',
})
})
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import api from '@/api'
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
import type { Plugin, PluginRating } from '@/api/types'
import { changePluginSource, 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'
import { getLogoUrl } from '@/utils/imageUtils'
@@ -64,10 +65,55 @@ const rating = ref<PluginRating>({
const selectedRating = ref(props.plugin?.user_rating || 0)
const ratingLoading = ref(false)
const ratingSubmitting = ref(false)
const sourceOptions = ref<PluginSourceOptions | null>(null)
const sourceLoading = ref(false)
const sourceError = ref('')
const selectedInstallSourceKey = ref('')
const selectedChangeSourceKey = ref('')
const showSourceChoices = ref(false)
const sourceChanging = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
const onlineSourceCandidates = computed(() =>
(sourceOptions.value?.candidates || []).filter(
(candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } =>
candidate.source_type !== 'local' && Boolean(candidate.repo_url && candidate.source_key),
),
)
const sourceNeedsSelection = computed(() => !isInstalled.value && sourceOptions.value?.selection_status === 'conflict')
const selectedInstallSource = computed(() =>
onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value),
)
const selectedChangeSource = computed(() =>
sourceActionCandidates.value.find(candidate => candidate.source_key === selectedChangeSourceKey.value),
)
const hasTrustedOnlineSource = computed(() => {
const identity = sourceOptions.value?.identity
return Boolean(identity && identity.trusted_source_type !== 'unknown' && identity.trusted_source_key)
})
const sourceNeedsInitialBinding = computed(
() => isInstalled.value && !hasTrustedOnlineSource.value && onlineSourceCandidates.value.length > 0,
)
const changeSourceCandidates = computed(() => {
const trustedSourceKey = sourceOptions.value?.identity?.trusted_source_key
return onlineSourceCandidates.value.filter(candidate => candidate.source_key !== trustedSourceKey)
})
const sourceActionCandidates = computed(() =>
hasTrustedOnlineSource.value ? changeSourceCandidates.value : onlineSourceCandidates.value,
)
const sourceUnavailable = computed(() => {
const unavailable = ['unavailable', 'incomplete'].includes(sourceOptions.value?.selection_status || '')
if (!isInstalled.value) return unavailable
// 未绑定但仍有候选时,优先展示首次绑定流程;已绑定来源失效则必须阻止普通更新。
if (sourceNeedsInitialBinding.value) return false
return unavailable
})
const sourceSectionVisible = computed(
() => isInstalled.value || sourceNeedsSelection.value || sourceUnavailable.value || Boolean(sourceError.value),
)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
@@ -83,6 +129,119 @@ function closeInstallProgress() {
progressDialogController = null
}
/** 把规范化来源键转换为面向用户的仓库标识。 */
function sourceKeyLabel(sourceKey?: string | null) {
if (!sourceKey) return t('plugin.sourceUnknown')
return sourceKey.startsWith('github:') ? sourceKey.slice('github:'.length) : sourceKey
}
/** 返回候选来源的简短名称,本地候选不展示路径。 */
function sourceCandidateLabel(candidate: PluginSourceCandidate) {
if (candidate.source_type === 'local') return t('plugin.local')
return sourceKeyLabel(candidate.source_key)
}
/** 返回当前可信更新来源;本地载荷与在线身份分开展示。 */
function trustedSourceLabel() {
const identity = sourceOptions.value?.identity
if (!identity) return t('plugin.sourceUnbound')
if (identity.trusted_source_type === 'unknown') return t('plugin.sourceUnbound')
return sourceKeyLabel(identity.trusted_source_key)
}
/** 返回最近一次已应用载荷来源。 */
function payloadSourceLabel() {
const identity = sourceOptions.value?.identity
if (!identity) return t('plugin.sourceUnknown')
if (identity.payload_source_type === 'local') return t('plugin.local')
return sourceKeyLabel(identity.payload_source_key)
}
/** 读取当前来源身份和可选候选,失败时保留原安装路径由后端最终准入。 */
async function loadPluginSourceOptions(force = false) {
if (!props.plugin?.id) return
sourceLoading.value = true
sourceError.value = ''
try {
const options = await getPluginSourceOptions(props.plugin.id, force)
sourceOptions.value = options
const installSelectionStillExists = onlineSourceCandidates.value.some(
candidate => candidate.source_key === selectedInstallSourceKey.value,
)
if (!installSelectionStillExists) selectedInstallSourceKey.value = ''
const changeSelectionStillExists = sourceActionCandidates.value.some(
candidate => candidate.source_key === selectedChangeSourceKey.value,
)
if (!changeSelectionStillExists) selectedChangeSourceKey.value = ''
} catch (error) {
console.error(error)
sourceOptions.value = null
sourceError.value = getApiBusinessErrorMessage(error) || t('plugin.sourceLoadFailed')
} finally {
sourceLoading.value = false
}
}
/** 明确绑定或切换自动更新来源,换源时使用打开弹窗时读取的 revision。 */
async function confirmSourceTransition() {
const identity = sourceOptions.value?.identity
const target = selectedChangeSource.value
const bindingSource = !hasTrustedOnlineSource.value
if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return
const confirmed = await createConfirm({
type: 'warn',
title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'),
content: t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
name: props.plugin.plugin_name,
current: trustedSourceLabel(),
target: sourceCandidateLabel(target),
}),
confirmText: t(bindingSource ? 'plugin.bindSource' : 'plugin.changeSource'),
})
if (!confirmed) return
sourceChanging.value = true
showInstallProgress(
t(bindingSource ? 'plugin.bindingSource' : 'plugin.changingSource', { name: props.plugin.plugin_name }),
)
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()
}
}
/** 计算插件图标路径。 */
function pluginIconPath() {
if (imageLoadError.value) return getLogoUrl('plugin')
@@ -135,11 +294,29 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!isConfirmed) return
}
if (sourceUnavailable.value) {
$toast.error(sourceOptions.value?.selection_reason || t('plugin.sourceUnavailable'))
return
}
if (sourceNeedsInitialBinding.value) {
$toast.error(sourceOptions.value?.selection_reason || t('plugin.sourceBindingHint'))
return
}
const explicitSource = sourceNeedsSelection.value ? selectedInstallSource.value : undefined
if (sourceNeedsSelection.value && !explicitSource?.repo_url) {
$toast.error(t('plugin.selectSourceRequired'))
return
}
const selectedRepoUrl = explicitSource?.repo_url || repoUrl
if (props.installHandler) {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
await props.installHandler(releaseVersion, repoUrl)
await props.installHandler(releaseVersion, selectedRepoUrl)
return
}
@@ -155,14 +332,21 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
}),
)
await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: repoUrl || props.plugin?.repo_url,
if (!isInstalled.value && explicitSource?.repo_url) {
await installPluginFromSource(props.plugin.id, {
repo_url: explicitSource.repo_url,
release_version: releaseVersion,
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
},
feedback: 'silent',
})
force: Boolean(props.plugin?.has_update || releaseVersion),
})
} else {
await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
release_version: releaseVersion,
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
},
feedback: 'silent',
})
}
$toast.success(
isInstalled.value
@@ -198,11 +382,19 @@ function showUpdateHistory() {
},
{
update: installPlugin,
sourceAction: openSourceAction,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 展开来源选择,来源未就绪时不执行普通更新。 */
function openSourceAction() {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
showSourceChoices.value = true
}
/** 查询插件平均分和当前安装实例评分。 */
async function loadPluginRating() {
if (!props.plugin?.id) return
@@ -251,7 +443,10 @@ async function submitPluginRating() {
watch(
() => [visible.value, props.plugin?.id],
([isVisible]) => {
if (isVisible) loadPluginRating()
if (isVisible) {
void loadPluginRating()
void loadPluginSourceOptions()
}
},
{ immediate: true },
)
@@ -306,13 +501,131 @@ onUnmounted(() => {
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<section v-if="sourceSectionVisible" class="plugin-market-detail-source" aria-labelledby="plugin-source-title">
<div class="plugin-market-detail-source__heading">
<div>
<h3 id="plugin-source-title" class="plugin-market-detail-source__title">
{{ t('plugin.source') }}
</h3>
<p class="plugin-market-detail-source__hint">
{{
sourceNeedsInitialBinding
? t('plugin.sourceBindingHint')
: isInstalled
? t('plugin.sourceInstalledHint')
: t('plugin.sourceConflictHint')
}}
</p>
</div>
<VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" />
</div>
<VAlert v-if="sourceError" type="warning" variant="tonal" density="compact" :text="sourceError" />
<template v-if="sourceOptions">
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
<div>
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
<dd>{{ trustedSourceLabel() }}</dd>
</div>
<div v-if="sourceOptions.identity.payload_source_type === 'local'">
<dt>{{ t('plugin.currentPayloadSource') }}</dt>
<dd>{{ payloadSourceLabel() }}</dd>
</div>
</dl>
<VAlert
v-if="sourceNeedsSelection || sourceNeedsInitialBinding || sourceUnavailable"
:type="sourceNeedsSelection || sourceNeedsInitialBinding ? 'warning' : 'error'"
variant="tonal"
density="compact"
class="mb-3"
:text="sourceOptions.selection_reason"
/>
<VRadioGroup
v-if="sourceNeedsSelection"
v-model="selectedInstallSourceKey"
class="plugin-market-detail-source__choices"
hide-details
>
<VRadio
v-for="candidate in onlineSourceCandidates"
:key="candidate.source_key"
:value="candidate.source_key"
:label="sourceCandidateLabel(candidate)"
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span
>
</span>
</template>
</VRadio>
</VRadioGroup>
<div v-if="isInstalled && sourceActionCandidates.length > 0" class="plugin-market-detail-source__change">
<VBtn
v-if="!showSourceChoices"
size="small"
variant="text"
prepend-icon="mdi-source-branch"
@click="showSourceChoices = true"
>
{{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }}
</VBtn>
<template v-else>
<VRadioGroup v-model="selectedChangeSourceKey" hide-details>
<VRadio
v-for="candidate in sourceActionCandidates"
:key="candidate.source_key"
:value="candidate.source_key"
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span
>v{{ candidate.plugin_version || '-' }} ·
{{ candidate.package_generation.toUpperCase() }}</span
>
</span>
</template>
</VRadio>
</VRadioGroup>
<div class="plugin-market-detail-source__change-actions">
<VBtn size="small" variant="text" @click="showSourceChoices = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn
size="small"
color="warning"
:loading="sourceChanging"
:disabled="!selectedChangeSource"
@click="confirmSourceTransition"
>
{{ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.bindSource') }}
</VBtn>
</div>
</template>
</div>
</template>
</section>
<div class="plugin-market-detail-actions">
<div class="plugin-market-detail-actions__buttons">
<VBtn
v-if="!isInstalled"
color="primary"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
:loading="sourceLoading"
:disabled="
props.plugin?.system_version_compatible === false ||
sourceLoading ||
sourceUnavailable ||
(sourceNeedsSelection && !selectedInstallSource)
"
@click="installPlugin()"
>
{{ t('plugin.installToLocal') }}
@@ -321,7 +634,13 @@ onUnmounted(() => {
v-else-if="props.plugin?.has_update"
color="primary"
prepend-icon="mdi-arrow-up-circle-outline"
:disabled="props.plugin?.system_version_compatible === false"
:disabled="
props.plugin?.system_version_compatible === false ||
sourceLoading ||
sourceUnavailable ||
sourceNeedsInitialBinding
"
:loading="sourceLoading"
@click="installPlugin()"
>
{{ t('plugin.update') }}
@@ -378,6 +697,94 @@ onUnmounted(() => {
text-align: center;
}
.plugin-market-detail-source {
padding: 0.875rem;
margin-block: 1rem;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.75rem;
}
.plugin-market-detail-source__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-block-end: 0.75rem;
}
.plugin-market-detail-source__title {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.4;
}
.plugin-market-detail-source__hint {
margin: 0.125rem 0 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.45;
}
.plugin-market-detail-source__identity {
display: grid;
gap: 0.5rem;
margin: 0;
}
.plugin-market-detail-source__identity > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.plugin-market-detail-source__identity dt {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.plugin-market-detail-source__identity dd {
min-width: 0;
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
overflow-wrap: anywhere;
text-align: end;
}
.plugin-market-detail-source__choices :deep(.v-selection-control),
.plugin-market-detail-source__change :deep(.v-selection-control) {
min-height: 2.75rem;
}
.plugin-market-detail-source__choice-label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
padding-block: 0.25rem;
}
.plugin-market-detail-source__choice-label strong {
overflow-wrap: anywhere;
}
.plugin-market-detail-source__choice-label span {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.plugin-market-detail-source__change {
margin-block-start: 0.75rem;
}
.plugin-market-detail-source__change-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-block-start: 0.5rem;
}
.plugin-market-detail__avatar {
flex: 0 0 auto;
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
import { getPluginSourceOptions } from '@/api/pluginSource'
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse, PluginSourceOptions } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
@@ -32,6 +33,7 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'close'): void
(event: 'update', releaseVersion?: string, repoUrl?: string): void
(event: 'sourceAction'): void
}>()
const loading = ref(false)
@@ -40,6 +42,8 @@ const pluginDetail = ref<Plugin | null>(null)
const releaseLoading = ref(false)
const releaseError = ref('')
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
const releaseRepoUrl = ref<string | null>(null)
const releaseSourceOptions = ref<PluginSourceOptions | null>(null)
// 弹窗显示状态
const visible = computed({
@@ -63,12 +67,56 @@ const resolvedHistory = computed(() => {
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
const latestActionText = computed(() =>
props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'),
)
const releaseItems = computed(() => releaseDetail.value?.items || [])
type ReleaseSourceAction = 'bind' | 'change' | 'unavailable'
const releaseSourceAction = computed<ReleaseSourceAction | null>(() => {
if (props.actionMode !== 'update' || !releaseSourceOptions.value) return null
const options = releaseSourceOptions.value
const identity = options.identity
const onlineCandidates = options.candidates.filter(
candidate => candidate.source_type !== 'local' && Boolean(candidate.source_key && candidate.repo_url),
)
const hasTrustedOnlineSource = Boolean(
identity && identity.trusted_source_type !== 'unknown' && identity.trusted_source_key,
)
if (!hasTrustedOnlineSource) {
if (onlineCandidates.length > 0) return 'bind'
return ['unavailable', 'incomplete', 'conflict'].includes(options.selection_status) ? 'unavailable' : null
}
if (['unavailable', 'incomplete'].includes(options.selection_status)) {
if (
options.selection_status === 'unavailable' &&
onlineCandidates.some(candidate => candidate.source_key !== identity?.trusted_source_key)
) {
return 'change'
}
return 'unavailable'
}
return null
})
const releaseSourceMessage = computed(() => {
if (releaseSourceAction.value === 'bind') return t('plugin.sourceBindingHint')
if (releaseSourceAction.value) return releaseSourceOptions.value?.selection_reason || t('plugin.sourceUnavailable')
return ''
})
const latestActionText = computed(() =>
releaseSourceAction.value === 'bind'
? t('plugin.bindSource')
: releaseSourceAction.value === 'change'
? t('plugin.changeSource')
: props.actionMode === 'install'
? t('plugin.installReleaseVersion')
: t('plugin.updateToLatest'),
)
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
const releaseByHistoryVersion = computed(() => {
@@ -96,15 +144,39 @@ function releaseItemByHistoryVersion(version: string) {
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
if (!item || item.is_current) return false
if (releaseSourceAction.value === 'unavailable') return false
if (releaseSourceAction.value) return Boolean(item.is_latest && !shouldShowUpdatePanel.value)
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
}
function isOnlineRepoUrl(repoUrl?: string | null): repoUrl is string {
return Boolean(repoUrl && !repoUrl.startsWith('local://'))
}
/** 已安装插件的 Release 只读取可信在线来源,本地载荷路径不进入网络请求。 */
async function resolveReleaseRepoUrl(plugin: Plugin): Promise<string | null> {
if (props.actionMode === 'install') {
return isOnlineRepoUrl(plugin.repo_url) ? plugin.repo_url : null
}
const options = await getPluginSourceOptions(plugin.id)
releaseSourceOptions.value = options
const trustedSourceKey = options.identity?.trusted_source_key
if (!trustedSourceKey) return null
const candidate = options.candidates.find(
item => item.source_type !== 'local' && item.source_key === trustedSourceKey && isOnlineRepoUrl(item.repo_url),
)
return candidate?.repo_url || null
}
async function loadPluginHistory() {
if (!props.plugin?.id) {
pluginDetail.value = null
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
releaseSourceOptions.value = null
return
}
@@ -112,13 +184,15 @@ async function loadPluginHistory() {
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
releaseRepoUrl.value = null
releaseSourceOptions.value = null
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
if (props.actionMode === 'install' && props.plugin?.repo_url) {
if (props.actionMode === 'install') {
pluginDetail.value = null
loading.value = false
loadPluginReleases(props.plugin, false)
await loadPluginReleases(props.plugin, false)
return
}
@@ -128,7 +202,7 @@ async function loadPluginHistory() {
force: true,
},
})
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
await loadPluginReleases(pluginDetail.value ?? props.plugin, true)
} catch (error) {
pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed')
@@ -139,9 +213,11 @@ async function loadPluginHistory() {
}
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
if (!plugin?.id || (props.actionMode === 'install' && !plugin.release)) {
releaseDetail.value = null
releaseError.value = ''
releaseRepoUrl.value = null
releaseSourceOptions.value = null
return
}
@@ -149,9 +225,20 @@ async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPl
releaseError.value = ''
try {
const repoUrl = await resolveReleaseRepoUrl(plugin)
if (!plugin.release) {
releaseDetail.value = null
releaseRepoUrl.value = null
return
}
releaseRepoUrl.value = repoUrl
if (!repoUrl) {
releaseDetail.value = null
return
}
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
params: {
repo_url: plugin.repo_url,
repo_url: repoUrl,
force,
},
})
@@ -164,9 +251,13 @@ async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPl
}
}
/** 触发插件更新操作。 */
/** 根据来源准入状态执行更新,或转交来源绑定和切换。 */
function handleUpdate(releaseItem?: PluginReleaseVersion) {
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
if (releaseSourceAction.value) {
if (releaseSourceAction.value !== 'unavailable') emit('sourceAction')
return
}
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, releaseRepoUrl.value || undefined)
}
watch(
@@ -201,6 +292,14 @@ watch(
:text="releaseError"
/>
</VCardText>
<VCardText v-if="releaseSourceMessage" class="pb-0">
<VAlert
:type="releaseSourceAction === 'unavailable' ? 'error' : 'warning'"
variant="tonal"
density="compact"
:text="releaseSourceMessage"
/>
</VCardText>
<VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText>
@@ -245,6 +344,7 @@ watch(
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
:disabled="
releaseItemByHistoryVersion(version)?.is_current ||
releaseSourceAction === 'unavailable' ||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
"
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
@@ -267,11 +367,15 @@ watch(
class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate()" block :disabled="resolvedPlugin?.system_version_compatible === false">
<VBtn
@click="handleUpdate()"
block
:disabled="resolvedPlugin?.system_version_compatible === false || releaseSourceAction === 'unavailable'"
>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
{{ t('plugin.updateToLatest') }}
{{ latestActionText }}
</VBtn>
</VCardItem>
</template>
@@ -1,5 +1,5 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import type { Plugin, PluginRating } from '@/api/types'
import type { Plugin, PluginRating, PluginSourceOptions } from '@/api/types'
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
@@ -55,6 +55,31 @@ const ratingResult: PluginRating = {
user_rating: 4.0,
}
const defaultSourceOptions: PluginSourceOptions = {
plugin_id: 'DemoPlugin',
inventory_complete: true,
selection_status: 'selected',
selection_reason: '唯一在线来源',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'third_party',
trusted_source_key: 'github:example/plugins',
binding_basis: 'tofu',
payload_source_type: 'third_party',
payload_source_key: 'github:example/plugins',
revision: 3,
},
candidates: [
{
source_type: 'third_party',
source_key: 'github:example/plugins',
repo_url: 'https://github.com/example/plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
],
}
const ImageStub = defineComponent({
name: 'VImg',
emits: ['error'],
@@ -79,6 +104,7 @@ describe('PluginMarketDetailDialog', () => {
beforeEach(() => {
mocks.apiGet.mockReset().mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(defaultSourceOptions)
return Promise.resolve({ success: true })
})
mocks.apiPost.mockReset().mockResolvedValue({
@@ -105,6 +131,274 @@ describe('PluginMarketDetailDialog', () => {
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
})
it('requires an explicit repository when multiple sources publish the same plugin ID', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...defaultSourceOptions,
identity: null,
selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
defaultSourceOptions.candidates[0],
],
} satisfies PluginSourceOptions)
}
return Promise.resolve({ success: true })
})
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument()
const installButton = screen.getByRole('button', { name: '安装到本地' })
expect(installButton).toBeDisabled()
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
expect(installButton).toBeEnabled()
await fireEvent.click(installButton)
await waitFor(() => {
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
release_version: undefined,
force: false,
})
})
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toHaveLength(1)
})
it('shows trusted and local payload sources separately and changes source with the current revision', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...defaultSourceOptions,
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'official',
trusted_source_key: 'github:jxxghp/moviepilot-plugins',
binding_basis: 'official_default',
payload_source_type: 'local',
payload_source_key: null,
revision: 7,
},
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
defaultSourceOptions.candidates[0],
],
} satisfies PluginSourceOptions)
}
return Promise.resolve({ success: true })
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('自动更新来源')).toBeInTheDocument()
expect(screen.getByText('jxxghp/moviepilot-plugins')).toBeInTheDocument()
expect(screen.getByText('当前载荷')).toBeInTheDocument()
expect(screen.getByText('本地')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '更换来源' }))
await fireEvent.click(screen.getByText('example/plugins'))
await fireEvent.click(screen.getByRole('button', { name: '确认换源' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('jxxghp/moviepilot-plugins'),
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))
})
it('blocks ordinary updates when the trusted source has no usable candidate', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...defaultSourceOptions,
selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'official',
trusted_source_key: 'github:jxxghp/moviepilot-plugins',
binding_basis: 'official_default',
payload_source_type: 'official',
payload_source_key: 'github:jxxghp/moviepilot-plugins',
revision: 7,
},
candidates: [defaultSourceOptions.candidates[0]],
} satisfies PluginSourceOptions)
}
return Promise.resolve({ success: true })
})
await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更换来源' })).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新' })
expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton)
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
})
it('binds an installed legacy plugin through the explicit source install endpoint', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...defaultSourceOptions,
selection_status: 'incomplete',
selection_reason: '插件来源身份尚未绑定,不能自动选择在线载荷',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'unknown',
trusted_source_key: null,
binding_basis: 'legacy_unbound',
payload_source_type: 'unknown',
payload_source_key: null,
revision: 2,
},
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
defaultSourceOptions.candidates[0],
],
} satisfies PluginSourceOptions)
}
return Promise.resolve({ success: true })
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更新' })).toBeDisabled()
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: '确认绑定插件来源',
confirmText: '绑定来源',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
force: true,
})
expect(mocks.apiPost).not.toHaveBeenCalledWith('plugin/source/DemoPlugin', expect.anything())
await waitFor(() => expect(emitted().install).toHaveLength(1))
})
it('routes a legacy history update into the binding flow without ordinary install', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...defaultSourceOptions,
selection_status: 'incomplete',
selection_reason: '插件来源身份尚未绑定,不能自动选择在线载荷',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'unknown',
trusted_source_key: null,
binding_basis: 'legacy_unbound',
payload_source_type: 'unknown',
payload_source_key: null,
revision: 2,
},
candidates: [defaultSourceOptions.candidates[0]],
} satisfies PluginSourceOptions)
}
return Promise.resolve({ success: true })
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
const historyEvents = mocks.openSharedDialog.mock.calls.at(-1)?.[2] as {
sourceAction: () => void
update: () => Promise<void>
}
expect(historyEvents.sourceAction).toBeTypeOf('function')
historyEvents.sourceAction()
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
expect(screen.getByRole('button', { name: '绑定来源' })).toBeInTheDocument()
})
it('reloads source evidence after a stale revision failure without retrying the change', async () => {
let sourceReadCount = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
if (url === 'plugin/source/DemoPlugin/options') {
sourceReadCount += 1
return Promise.resolve({
...defaultSourceOptions,
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'official',
trusted_source_key: 'github:jxxghp/moviepilot-plugins',
binding_basis: 'official_default',
payload_source_type: 'official',
payload_source_key: 'github:jxxghp/moviepilot-plugins',
revision: sourceReadCount === 1 ? 7 : 8,
},
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
defaultSourceOptions.candidates[0],
],
} satisfies PluginSourceOptions)
}
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(emitted().install).toBeUndefined()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('hides install action and submits a half-star rating for an installed plugin', async () => {
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
@@ -193,7 +487,6 @@ describe('PluginMarketDetailDialog', () => {
params: {
force: false,
release_version: undefined,
repo_url: 'https://github.com/example/plugins',
},
})
})
@@ -274,7 +567,6 @@ describe('PluginMarketDetailDialog', () => {
params: {
force: true,
release_version: '0.9.0',
repo_url: 'https://github.com/example/releases',
},
})
expect(emitted().install).toHaveLength(1)
@@ -289,7 +581,6 @@ describe('PluginMarketDetailDialog', () => {
params: {
force: true,
release_version: undefined,
repo_url: 'https://github.com/example/plugins',
},
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
@@ -339,7 +630,6 @@ describe('PluginMarketDetailDialog', () => {
params: {
force: true,
release_version: undefined,
repo_url: 'https://github.com/example/releases',
},
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
@@ -387,7 +677,8 @@ describe('PluginMarketDetailDialog', () => {
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledOnce()
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/rating/DemoPlugin')
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options')
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/rating/DemoPlugin')
})
@@ -1,5 +1,5 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import type { Plugin, PluginReleaseVersionsResponse } from '@/api/types'
import type { Plugin, PluginReleaseVersionsResponse, PluginSourceOptions } from '@/api/types'
import PluginVersionHistoryDialog from '@/components/dialog/PluginVersionHistoryDialog.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
@@ -53,6 +53,31 @@ const releases: PluginReleaseVersionsResponse = {
],
}
const sourceOptions: PluginSourceOptions = {
plugin_id: 'DemoPlugin',
inventory_complete: true,
selection_status: 'selected',
selection_reason: '按已绑定来源选择在线载荷',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'third_party',
trusted_source_key: 'github:example/plugins',
binding_basis: 'tofu',
payload_source_type: 'third_party',
payload_source_key: 'github:example/plugins',
revision: 3,
},
candidates: [
{
source_type: 'third_party',
source_key: 'github:example/plugins',
repo_url: 'https://github.com/example/plugins',
package_generation: 'v3',
plugin_version: '2.0.0',
},
],
}
async function renderDialog(props: Record<string, unknown>) {
return renderWithProviders(PluginVersionHistoryDialog, {
props,
@@ -66,6 +91,7 @@ describe('PluginVersionHistoryDialog', () => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(sourceOptions)
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
throw new Error(`Unexpected request: ${url}`)
})
@@ -87,7 +113,10 @@ describe('PluginVersionHistoryDialog', () => {
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
params: { force: true },
})
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'plugin/releases/DemoPlugin', {
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'plugin/source/DemoPlugin/options', {
feedback: 'silent',
})
expect(mocks.apiGet).toHaveBeenNthCalledWith(3, 'plugin/releases/DemoPlugin', {
params: {
force: true,
repo_url: 'https://github.com/example/plugins',
@@ -104,6 +133,227 @@ describe('PluginVersionHistoryDialog', () => {
])
})
it('requires source binding before a legacy plugin can update from history', async () => {
const legacySourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'incomplete',
selection_reason: '插件来源身份尚未绑定,不能自动选择在线载荷',
identity: {
...sourceOptions.identity!,
trusted_source_type: 'unknown',
trusted_source_key: null,
binding_basis: 'legacy_unbound',
payload_source_type: 'unknown',
payload_source_key: null,
revision: 4,
},
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(legacySourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: true,
actionMode: 'update',
})
expect(await screen.findByRole('button', { name: '绑定来源' })).toBeInTheDocument()
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
expect(emitted().sourceAction).toEqual([[]])
expect(emitted().update).toBeUndefined()
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/releases/DemoPlugin', expect.anything())
})
it('keeps local legacy updates available when no online source can be bound', async () => {
const localLegacySourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'selected',
selection_reason: '优先使用本地载荷',
identity: {
...sourceOptions.identity!,
trusted_source_type: 'unknown',
trusted_source_key: null,
binding_basis: 'legacy_unbound',
payload_source_type: 'local',
payload_source_key: null,
},
candidates: [
{
source_type: 'local',
source_key: null,
repo_url: null,
package_generation: 'v3',
plugin_version: '2.0.0-dev',
},
],
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(localLegacySourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: true,
actionMode: 'update',
})
const updateButton = await screen.findByRole('button', { name: '更新到最新版本' })
expect(updateButton).toBeEnabled()
await fireEvent.click(updateButton)
expect(emitted().sourceAction).toBeUndefined()
expect(emitted().update).toEqual([[undefined, undefined]])
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/releases/DemoPlugin', expect.anything())
})
it('routes local-only identities with online candidates into source binding', async () => {
const localOnlySourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'incomplete',
selection_reason: '插件来源身份尚未绑定,不能自动选择在线载荷',
identity: {
...sourceOptions.identity!,
trusted_source_type: 'unknown',
trusted_source_key: null,
binding_basis: 'local_only',
payload_source_type: 'local',
payload_source_key: null,
},
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(localOnlySourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: true,
actionMode: 'update',
})
const bindButton = await screen.findByRole('button', { name: '绑定来源' })
await fireEvent.click(bindButton)
expect(emitted().sourceAction).toEqual([[]])
expect(emitted().update).toBeUndefined()
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/releases/DemoPlugin', expect.anything())
})
it('blocks history updates while source inventory is incomplete', async () => {
const incompleteSourceOptions: PluginSourceOptions = {
...sourceOptions,
inventory_complete: false,
selection_status: 'incomplete',
selection_reason: '本地插件仓库读取失败,不能自动选择在线载荷',
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(incompleteSourceOptions)
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: true,
actionMode: 'update',
})
expect(await screen.findByText('本地插件仓库读取失败,不能自动选择在线载荷')).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新到最新版本' })
expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton)
expect(emitted().update).toBeUndefined()
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/releases/DemoPlugin', {
params: {
force: true,
repo_url: 'https://github.com/example/plugins',
},
})
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
})
it('shows source admission failures while browsing history without update controls', async () => {
const unavailableSourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选',
candidates: [],
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(unavailableSourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: false,
actionMode: 'update',
})
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument()
expect(emitted().update).toBeUndefined()
})
it('loads source admission without requesting Releases for plugins that do not use them', async () => {
const incompleteSourceOptions: PluginSourceOptions = {
...sourceOptions,
inventory_complete: false,
selection_status: 'incomplete',
selection_reason: '插件市场读取不完整,不能自动选择在线载荷',
}
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, release: false, history: { 'v1.0.0': '当前更新说明' } })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(incompleteSourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
const { emitted } = await renderDialog({
modelValue: true,
plugin: { ...installedPlugin, release: false },
showUpdateAction: true,
actionMode: 'update',
})
expect(await screen.findByText('插件市场读取不完整,不能自动选择在线载荷')).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新到最新版本' })
expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton)
expect(emitted().update).toBeUndefined()
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/releases/DemoPlugin', expect.anything())
})
it('uses market metadata directly and emits latest installation without a release version', async () => {
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
const { emitted } = await renderDialog({
@@ -126,6 +376,42 @@ describe('PluginVersionHistoryDialog', () => {
expect(emitted().update).toEqual([[undefined, 'https://github.com/example/plugins']])
})
it('uses the bound online source without sending a local repository path', async () => {
const localRepoUrl = 'local://DemoPlugin?path=%2FUsers%2Fdemo%2Fplugins'
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, repo_url: localRepoUrl, history: {} })
}
if (url === 'plugin/source/DemoPlugin/options') {
return Promise.resolve({
...sourceOptions,
identity: {
...sourceOptions.identity!,
payload_source_type: 'local',
payload_source_key: null,
},
} satisfies PluginSourceOptions)
}
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
throw new Error(`Unexpected request: ${url}`)
})
await renderDialog({
modelValue: true,
plugin: { ...installedPlugin, repo_url: localRepoUrl },
actionMode: 'update',
})
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/releases/DemoPlugin', {
params: {
force: true,
repo_url: 'https://github.com/example/plugins',
},
})
expect(mocks.apiGet.mock.calls.some(([, options]) => JSON.stringify(options).includes(localRepoUrl))).toBe(false)
})
it('distinguishes a Release request failure from an empty history', async () => {
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
await renderDialog({
@@ -147,7 +433,13 @@ describe('PluginVersionHistoryDialog', () => {
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
failed.unmount()
mocks.apiGet.mockReset().mockResolvedValue({ ...installedPlugin, release: false, history: {} })
mocks.apiGet.mockReset().mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({ ...installedPlugin, release: false, history: {} })
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(sourceOptions)
throw new Error(`Unexpected request: ${url}`)
})
await renderDialog({
modelValue: true,
plugin: installedPlugin,
+26
View File
@@ -3843,6 +3843,32 @@ export default {
confirmInstallOldRelease:
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local',
source: 'Plugin Source',
sourceUnknown: 'Unknown source',
sourceUnbound: 'Not bound',
sourceLoadFailed: 'Unable to load plugin sources. Try again later.',
sourceUnavailable: 'No plugin source is currently safe to install',
sourceInstalledHint: 'Automatic updates only use the bound trusted repository.',
sourceConflictHint: 'Multiple plugins share this ID. Choose the repository you trust.',
sourceBindingHint: 'This plugin has no automatic update source yet. Choose a repository you trust.',
trustedUpdateSource: 'Automatic update source',
currentPayloadSource: 'Current payload',
selectSourceRequired: 'Choose a plugin source before installing',
bindSource: 'Bind Source',
confirmSourceBindTitle: 'Confirm Plugin Source',
confirmSourceBind:
'Bind the automatic update source for {name} to {target}. The current version from that source will be installed immediately.',
bindingSource: 'Binding the plugin source for {name}...',
sourceBindSuccess: 'The source for plugin {name} was bound',
sourceBindFailed: 'Failed to bind the source for plugin {name}: {message}',
changeSource: 'Change Source',
confirmSourceChangeTitle: 'Confirm Plugin Source Change',
confirmSourceChange:
'Change the automatic update source for {name} from {current} to {target}. The current version from the target source will be installed immediately.',
confirmSourceChangeAction: 'Confirm Change',
changingSource: 'Changing the plugin source for {name}...',
sourceChangeSuccess: 'The source for plugin {name} was changed',
sourceChangeFailed: 'Failed to change the source for plugin {name}: {message}',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local',
+25
View File
@@ -3781,6 +3781,31 @@ export default {
confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地',
source: '插件来源',
sourceUnknown: '未知来源',
sourceUnbound: '尚未绑定',
sourceLoadFailed: '无法读取插件来源,请稍后重试',
sourceUnavailable: '当前没有可安全安装的插件来源',
sourceInstalledHint: '自动更新只会使用已绑定的可信仓库。',
sourceConflictHint: '检测到多个同名插件,请明确选择要信任的仓库。',
sourceBindingHint: '当前插件尚未绑定自动更新来源,请选择可信仓库。',
trustedUpdateSource: '自动更新来源',
currentPayloadSource: '当前载荷',
selectSourceRequired: '请选择插件来源后再安装',
bindSource: '绑定来源',
confirmSourceBindTitle: '确认绑定插件来源',
confirmSourceBind: '将插件 {name} 的自动更新来源绑定为 {target}。确认后会立即安装该来源的当前版本。',
bindingSource: '正在绑定 {name} 的插件来源...',
sourceBindSuccess: '插件 {name} 的来源已绑定',
sourceBindFailed: '插件 {name} 绑定来源失败:{message}',
changeSource: '更换来源',
confirmSourceChangeTitle: '确认更换插件来源',
confirmSourceChange:
'将插件 {name} 的自动更新来源从 {current} 更换为 {target}。确认后会立即安装目标来源的当前版本。',
confirmSourceChangeAction: '确认换源',
changingSource: '正在更换 {name} 的插件来源...',
sourceChangeSuccess: '插件 {name} 的来源已更换',
sourceChangeFailed: '插件 {name} 换源失败:{message}',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
installToLocal: '安装到本地',
+25
View File
@@ -3780,6 +3780,31 @@ export default {
confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地',
source: '插件來源',
sourceUnknown: '未知來源',
sourceUnbound: '尚未綁定',
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
sourceUnavailable: '目前沒有可安全安裝的插件來源',
sourceInstalledHint: '自動更新只會使用已綁定的可信倉庫。',
sourceConflictHint: '偵測到多個同名插件,請明確選擇要信任的倉庫。',
sourceBindingHint: '目前插件尚未綁定自動更新來源,請選擇可信倉庫。',
trustedUpdateSource: '自動更新來源',
currentPayloadSource: '目前載荷',
selectSourceRequired: '請選擇插件來源後再安裝',
bindSource: '綁定來源',
confirmSourceBindTitle: '確認綁定插件來源',
confirmSourceBind: '將插件 {name} 的自動更新來源綁定為 {target}。確認後會立即安裝該來源的目前版本。',
bindingSource: '正在綁定 {name} 的插件來源...',
sourceBindSuccess: '插件 {name} 的來源已綁定',
sourceBindFailed: '插件 {name} 綁定來源失敗:{message}',
changeSource: '更換來源',
confirmSourceChangeTitle: '確認更換插件來源',
confirmSourceChange:
'將插件 {name} 的自動更新來源從 {current} 更換為 {target}。確認後會立即安裝目標來源的目前版本。',
confirmSourceChangeAction: '確認換源',
changingSource: '正在更換 {name} 的插件來源...',
sourceChangeSuccess: '插件 {name} 的來源已更換',
sourceChangeFailed: '插件 {name} 換源失敗:{message}',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
rating: '插件評分',
+55 -30
View File
@@ -1,6 +1,8 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { getApiBusinessErrorMessage } from '@/api/client'
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import type { Plugin, PluginRating } from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getPluginTabs } from '@/router/i18n-menu'
@@ -822,15 +824,6 @@ function isLocalRepoSource(item: Plugin | string | undefined) {
return Boolean((typeof item !== 'string' && item.is_local) || repoUrl.startsWith('local://'))
}
/** 解码本地插件仓库路径,避免异常路径中断市场列表加载。 */
function decodeLocalRepoPath(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
//
function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
@@ -864,6 +857,43 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
return
}
installingPluginIds.value = new Set([...installingPluginIds.value, pluginId])
const releaseInstallReservation = () => {
const pending = new Set(installingPluginIds.value)
pending.delete(pluginId)
installingPluginIds.value = pending
}
let useExplicitSource = false
try {
const sourceOptions = await getPluginSourceOptions(pluginId)
if (sourceOptions.selection_status === 'conflict') {
if (!repoUrl) {
releaseInstallReservation()
openPluginMarketDetail(item)
return
}
const selectedCandidate = sourceOptions.candidates.find(
candidate => candidate.repo_url === repoUrl && candidate.source_type !== 'local',
)
if (!selectedCandidate) {
releaseInstallReservation()
$toast.error(t('plugin.selectSourceRequired'))
openPluginMarketDetail(item)
return
}
useExplicitSource = true
} else if (['unavailable', 'incomplete'].includes(sourceOptions.selection_status)) {
releaseInstallReservation()
$toast.error(sourceOptions.selection_reason || t('plugin.sourceUnavailable'))
return
}
} catch (error) {
console.error(error)
// Gateway
}
const previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id)
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
sortMode.value = false
@@ -873,7 +903,6 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
enabledFilter.value = false
tabScrollPositions.installed = 0
installScrollPluginId.value = pluginId
installingPluginIds.value = new Set([...installingPluginIds.value, pluginId])
dataList.value = [
...dataList.value.filter(plugin => plugin.id !== pluginId),
{
@@ -888,14 +917,21 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
let installed = false
try {
await api.get(`plugin/install/${pluginId}`, {
params: {
repo_url: repoUrl || item?.repo_url,
if (useExplicitSource && repoUrl) {
await installPluginFromSource(pluginId, {
repo_url: repoUrl,
release_version: releaseVersion,
force: item?.has_update || Boolean(releaseVersion),
},
feedback: 'silent',
})
force: Boolean(item?.has_update || releaseVersion),
})
} else {
await api.get(`plugin/install/${pluginId}`, {
params: {
release_version: releaseVersion,
force: item?.has_update || Boolean(releaseVersion),
},
feedback: 'silent',
})
}
installed = true
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
@@ -916,7 +952,7 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
$toast.error(
t('plugin.installFailed', {
name: item?.plugin_name,
message: error instanceof Error ? error.message : '',
message: getApiBusinessErrorMessage(error) || (error instanceof Error ? error.message : ''),
}),
)
//
@@ -1383,22 +1419,11 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
await loadPluginFolders()
}
function parseLocalRepoPath(repoUrl: string | undefined) {
const text = normalizeMarketText(repoUrl)
if (!text.startsWith('local://')) return ''
try {
return new URL(text).searchParams.get('path') || ''
} catch {
return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
}
}
// github
function handleRepoUrl(item: Plugin | string | undefined) {
const url = typeof item === 'string' ? item : normalizeMarketText(item?.repo_url)
if (!url) return ''
if (isLocalRepoSource(item)) return parseLocalRepoPath(url) || localRepoLabel.value
if (isLocalRepoSource(item)) return localRepoLabel.value
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
}
@@ -1,4 +1,4 @@
import type { Plugin, PluginRating, PluginRuntimeSummary } from '@/api/types'
import type { Plugin, PluginRating, PluginRuntimeSummary, PluginSourceOptions } from '@/api/types'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
@@ -22,6 +22,7 @@ const apiUrls = {
rating: new URL('plugin/rating', API_BASE_URL).href,
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,
statistic: new URL('plugin/statistic', API_BASE_URL).href,
}
@@ -380,6 +381,7 @@ interface ListResponses {
order?: unknown[]
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
runtime?: () => PluginRuntimeSummary | Promise<PluginRuntimeSummary>
sourceOptions?: (pluginId: string) => PluginSourceOptions | Promise<PluginSourceOptions>
statistic?: () => Record<string, number> | Promise<Record<string, number>>
}
@@ -406,6 +408,27 @@ function registerListHandlers(responses: ListResponses = {}) {
),
),
http.get(apiUrls.sidebar, () => apiJson([])),
http.get(apiUrls.sourceOptions, async ({ params }) => {
const pluginId = String(params.pluginId)
return apiJson(
((await responses.sourceOptions?.(pluginId)) ?? {
plugin_id: pluginId,
inventory_complete: true,
selection_status: 'selected',
selection_reason: '唯一在线来源',
identity: null,
candidates: [
{
source_type: 'third_party',
source_key: 'github:example/plugins',
repo_url: 'https://github.com/example/plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
],
}) as unknown as JsonBodyType,
)
}),
http.get(apiUrls.rating, async ({ request }) => {
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType)
@@ -963,8 +986,8 @@ describe('PluginCardListView market filtering and pagination', () => {
await nextTick()
const initialRepository = screen.getByRole('group', { name: '仓库' })
expect(within(initialRepository).getByRole('button', { name: '/tmp/plugins' })).toBeInTheDocument()
expect(within(initialRepository).getByRole('button', { name: '本地' })).toBeInTheDocument()
expect(within(initialRepository).queryByRole('button', { name: '/tmp/plugins' })).not.toBeInTheDocument()
await fireEvent.click(screen.getByText('插件名称'))
await waitFor(() => expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Alpha'))
@@ -993,9 +1016,10 @@ describe('PluginCardListView market filtering and pagination', () => {
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(9))
await fireEvent.click(within(label).getByRole('button', { name: '工具' }))
await fireEvent.click(within(repository).getByRole('button', { name: '/tmp/plugins' }))
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(1))
await fireEvent.click(within(repository).getByRole('button', { name: '本地' }))
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(2))
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
expect(screen.getByText('market:插件 01')).toBeInTheDocument()
await waitForRequestsToFinish()
})
@@ -1244,7 +1268,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
await nextTick()
await fireEvent.click(screen.getByRole('button', { name: 'installed-MarketInstall' }))
expect(getHeaderConfig().modelValue.value).toBe('installed')
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed'))
expect(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument()
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
@@ -1310,7 +1334,7 @@ describe('PluginCardListView search installation', () => {
await waitForRequestsToFinish()
})
it('preserves install query parameters and refreshes the list and sidebar after success', async () => {
it('uses the source-neutral install endpoint and refreshes the list and sidebar after success', async () => {
let installed = false
let installUrl: URL | undefined
const target = createPlugin({
@@ -1340,12 +1364,59 @@ describe('PluginCardListView search installation', () => {
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
expect(installUrl?.searchParams.get('repo_url')).toBe('https://github.com/example/search-plugin')
expect(installUrl?.searchParams.has('repo_url')).toBe(false)
expect(installUrl?.searchParams.get('force')).toBe('true')
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
await waitForRequestsToFinish()
})
it('opens source selection instead of silently installing a conflicting plugin ID', async () => {
let installRequests = 0
const target = createPlugin({ id: 'ConflictPlugin', plugin_name: '重名插件' })
await renderList({
market: () => [target],
sourceOptions: pluginId => ({
plugin_id: pluginId,
inventory_complete: true,
selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
identity: null,
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
{
source_type: 'third_party',
source_key: 'github:example/plugins',
repo_url: 'https://github.com/example/plugins',
package_generation: 'v3',
plugin_version: '2.0.0',
},
],
}),
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('ConflictPlugin'), () => {
installRequests += 1
return apiJson(null)
}),
)
getHeaderConfig().modelValue.value = 'market'
await nextTick()
await fireEvent.click(screen.getByRole('button', { name: 'installed-ConflictPlugin' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalled())
expect(getDialogProps().plugin).toMatchObject({ id: 'ConflictPlugin' })
expect(installRequests).toBe(0)
expect(getHeaderConfig().modelValue.value).toBe('market')
})
it('shows a per-plugin loading card while installation is still running', async () => {
const installGate = createDeferred<void>()
let installed = false