mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
feat(plugin): 增加插件来源绑定与换源界面 (#722)
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -993,6 +993,87 @@ export interface Plugin {
|
|||||||
instance_mode?: 'virtual'
|
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 {
|
export interface PluginRuntimeSummary {
|
||||||
// 本轮插件源码、依赖和加载是否已收敛
|
// 本轮插件源码、依赖和加载是否已收敛
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
feedback: 'silent',
|
feedback: 'silent',
|
||||||
params: {
|
params: {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,11 +157,18 @@ function showUpdateHistory(showUpdateAction: boolean = false) {
|
|||||||
versionHistoryDialogController = openSharedDialog(
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin, showUpdateAction },
|
{ plugin: props.plugin, showUpdateAction },
|
||||||
{ update: updatePlugin },
|
{ update: updatePlugin, sourceAction: openSourceAction },
|
||||||
{ closeOn: ['close', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开能够完成来源绑定或切换的管理界面。 */
|
||||||
|
async function openSourceAction() {
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
await showPluginAbout()
|
||||||
|
}
|
||||||
|
|
||||||
// 调用API卸载插件
|
// 调用API卸载插件
|
||||||
async function uninstallPlugin() {
|
async function uninstallPlugin() {
|
||||||
const isConfirmed = await createConfirm({
|
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) {
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
return
|
||||||
@@ -296,7 +303,6 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
feedback: 'silent',
|
feedback: 'silent',
|
||||||
params: {
|
params: {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
@@ -476,12 +482,7 @@ function showPluginClone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 执行插件分身
|
// 执行插件分身
|
||||||
async function executePluginClone(cloneForm: {
|
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; icon: string }) {
|
||||||
suffix: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}) {
|
|
||||||
if (!cloneForm.suffix.trim()) {
|
if (!cloneForm.suffix.trim()) {
|
||||||
$toast.error(t('plugin.suffixRequired'))
|
$toast.error(t('plugin.suffixRequired'))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ describe('PluginAppCard rating badge', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
release_version: '0.9.0',
|
release_version: '0.9.0',
|
||||||
repo_url: 'https://github.com/example/releases',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
release_version: '0.9.0',
|
release_version: '0.9.0',
|
||||||
repo_url: 'https://github.com/example/releases',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
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(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
await fireEvent.click(await screen.findByText('分身'))
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
clone: (form: {
|
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
|
||||||
suffix: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}) => Promise<void>
|
|
||||||
}
|
}
|
||||||
await cloneEvents.clone({
|
await cloneEvents.clone({
|
||||||
suffix: ' Test ',
|
suffix: ' Test ',
|
||||||
@@ -289,12 +283,7 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
await fireEvent.click(await screen.findByText('分身'))
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
clone: (form: {
|
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
|
||||||
suffix: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}) => Promise<void>
|
|
||||||
}
|
}
|
||||||
await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' })
|
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(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
await fireEvent.click(await screen.findByText('分身'))
|
await fireEvent.click(await screen.findByText('分身'))
|
||||||
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||||
clone: (form: {
|
clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise<void>
|
||||||
suffix: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}) => Promise<void>
|
|
||||||
}
|
}
|
||||||
const form = { suffix: 'Test', name: '测试', description: '', icon: '' }
|
const form = { suffix: 'Test', name: '测试', description: '', icon: '' }
|
||||||
await cloneEvents.clone(form)
|
await cloneEvents.clone(form)
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ describe('PluginCard about menu', () => {
|
|||||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
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 () => {
|
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
||||||
import 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 { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
@@ -64,10 +65,55 @@ const rating = ref<PluginRating>({
|
|||||||
const selectedRating = ref(props.plugin?.user_rating || 0)
|
const selectedRating = ref(props.plugin?.user_rating || 0)
|
||||||
const ratingLoading = ref(false)
|
const ratingLoading = ref(false)
|
||||||
const ratingSubmitting = 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 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 progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
@@ -83,6 +129,119 @@ function closeInstallProgress() {
|
|||||||
progressDialogController = null
|
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() {
|
function pluginIconPath() {
|
||||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||||
@@ -135,11 +294,29 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
if (!isConfirmed) return
|
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) {
|
if (props.installHandler) {
|
||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
versionHistoryDialogController = null
|
versionHistoryDialogController = null
|
||||||
visible.value = false
|
visible.value = false
|
||||||
await props.installHandler(releaseVersion, repoUrl)
|
await props.installHandler(releaseVersion, selectedRepoUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,14 +332,21 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
if (!isInstalled.value && explicitSource?.repo_url) {
|
||||||
params: {
|
await installPluginFromSource(props.plugin.id, {
|
||||||
repo_url: repoUrl || props.plugin?.repo_url,
|
repo_url: explicitSource.repo_url,
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
|
force: Boolean(props.plugin?.has_update || releaseVersion),
|
||||||
},
|
})
|
||||||
feedback: 'silent',
|
} 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(
|
$toast.success(
|
||||||
isInstalled.value
|
isInstalled.value
|
||||||
@@ -198,11 +382,19 @@ function showUpdateHistory() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
update: installPlugin,
|
update: installPlugin,
|
||||||
|
sourceAction: openSourceAction,
|
||||||
},
|
},
|
||||||
{ closeOn: ['close', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 展开来源选择,来源未就绪时不执行普通更新。 */
|
||||||
|
function openSourceAction() {
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
showSourceChoices.value = true
|
||||||
|
}
|
||||||
|
|
||||||
/** 查询插件平均分和当前安装实例评分。 */
|
/** 查询插件平均分和当前安装实例评分。 */
|
||||||
async function loadPluginRating() {
|
async function loadPluginRating() {
|
||||||
if (!props.plugin?.id) return
|
if (!props.plugin?.id) return
|
||||||
@@ -251,7 +443,10 @@ async function submitPluginRating() {
|
|||||||
watch(
|
watch(
|
||||||
() => [visible.value, props.plugin?.id],
|
() => [visible.value, props.plugin?.id],
|
||||||
([isVisible]) => {
|
([isVisible]) => {
|
||||||
if (isVisible) loadPluginRating()
|
if (isVisible) {
|
||||||
|
void loadPluginRating()
|
||||||
|
void loadPluginSourceOptions()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -306,13 +501,131 @@ onUnmounted(() => {
|
|||||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
: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">
|
||||||
<div class="plugin-market-detail-actions__buttons">
|
<div class="plugin-market-detail-actions__buttons">
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="!isInstalled"
|
v-if="!isInstalled"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-download"
|
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()"
|
@click="installPlugin()"
|
||||||
>
|
>
|
||||||
{{ t('plugin.installToLocal') }}
|
{{ t('plugin.installToLocal') }}
|
||||||
@@ -321,7 +634,13 @@ onUnmounted(() => {
|
|||||||
v-else-if="props.plugin?.has_update"
|
v-else-if="props.plugin?.has_update"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-arrow-up-circle-outline"
|
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()"
|
@click="installPlugin()"
|
||||||
>
|
>
|
||||||
{{ t('plugin.update') }}
|
{{ t('plugin.update') }}
|
||||||
@@ -378,6 +697,94 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
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 {
|
.plugin-market-detail__avatar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
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 VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
(event: 'update', releaseVersion?: string, repoUrl?: string): void
|
(event: 'update', releaseVersion?: string, repoUrl?: string): void
|
||||||
|
(event: 'sourceAction'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -40,6 +42,8 @@ const pluginDetail = ref<Plugin | null>(null)
|
|||||||
const releaseLoading = ref(false)
|
const releaseLoading = ref(false)
|
||||||
const releaseError = ref('')
|
const releaseError = ref('')
|
||||||
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||||
|
const releaseRepoUrl = ref<string | null>(null)
|
||||||
|
const releaseSourceOptions = ref<PluginSourceOptions | null>(null)
|
||||||
|
|
||||||
// 弹窗显示状态
|
// 弹窗显示状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -63,12 +67,56 @@ const resolvedHistory = computed(() => {
|
|||||||
|
|
||||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
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 || [])
|
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 shouldShowUpdatePanel = computed(() => props.showUpdateAction)
|
||||||
|
|
||||||
const releaseByHistoryVersion = computed(() => {
|
const releaseByHistoryVersion = computed(() => {
|
||||||
@@ -96,15 +144,39 @@ function releaseItemByHistoryVersion(version: string) {
|
|||||||
|
|
||||||
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
|
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
|
||||||
if (!item || item.is_current) return false
|
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')
|
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() {
|
async function loadPluginHistory() {
|
||||||
if (!props.plugin?.id) {
|
if (!props.plugin?.id) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
releaseDetail.value = null
|
releaseDetail.value = null
|
||||||
releaseError.value = ''
|
releaseError.value = ''
|
||||||
|
releaseSourceOptions.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +184,15 @@ async function loadPluginHistory() {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
releaseDetail.value = null
|
releaseDetail.value = null
|
||||||
releaseError.value = ''
|
releaseError.value = ''
|
||||||
|
releaseRepoUrl.value = null
|
||||||
|
releaseSourceOptions.value = null
|
||||||
|
|
||||||
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
|
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
|
||||||
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
|
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
|
||||||
if (props.actionMode === 'install' && props.plugin?.repo_url) {
|
if (props.actionMode === 'install') {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadPluginReleases(props.plugin, false)
|
await loadPluginReleases(props.plugin, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +202,7 @@ async function loadPluginHistory() {
|
|||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
await loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = t('plugin.updateHistoryLoadFailed')
|
loadError.value = t('plugin.updateHistoryLoadFailed')
|
||||||
@@ -139,9 +213,11 @@ async function loadPluginHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
|
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
|
releaseDetail.value = null
|
||||||
releaseError.value = ''
|
releaseError.value = ''
|
||||||
|
releaseRepoUrl.value = null
|
||||||
|
releaseSourceOptions.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +225,20 @@ async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPl
|
|||||||
releaseError.value = ''
|
releaseError.value = ''
|
||||||
|
|
||||||
try {
|
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}`, {
|
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: plugin.repo_url,
|
repo_url: repoUrl,
|
||||||
force,
|
force,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -164,9 +251,13 @@ async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发插件更新操作。 */
|
/** 根据来源准入状态执行更新,或转交来源绑定和切换。 */
|
||||||
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
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(
|
watch(
|
||||||
@@ -201,6 +292,14 @@ watch(
|
|||||||
:text="releaseError"
|
:text="releaseError"
|
||||||
/>
|
/>
|
||||||
</VCardText>
|
</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">
|
<VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
|
||||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -245,6 +344,7 @@ watch(
|
|||||||
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
|
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
|
||||||
:disabled="
|
:disabled="
|
||||||
releaseItemByHistoryVersion(version)?.is_current ||
|
releaseItemByHistoryVersion(version)?.is_current ||
|
||||||
|
releaseSourceAction === 'unavailable' ||
|
||||||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
|
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
|
||||||
"
|
"
|
||||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||||
@@ -267,11 +367,15 @@ watch(
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
: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>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||||
</template>
|
</template>
|
||||||
{{ t('plugin.updateToLatest') }}
|
{{ latestActionText }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
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 PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
@@ -55,6 +55,31 @@ const ratingResult: PluginRating = {
|
|||||||
user_rating: 4.0,
|
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({
|
const ImageStub = defineComponent({
|
||||||
name: 'VImg',
|
name: 'VImg',
|
||||||
emits: ['error'],
|
emits: ['error'],
|
||||||
@@ -79,6 +104,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||||
|
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(defaultSourceOptions)
|
||||||
return Promise.resolve({ success: true })
|
return Promise.resolve({ success: true })
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockReset().mockResolvedValue({
|
mocks.apiPost.mockReset().mockResolvedValue({
|
||||||
@@ -105,6 +131,274 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
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 () => {
|
it('hides install action and submits a half-star rating for an installed plugin', async () => {
|
||||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
|
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
|
||||||
|
|
||||||
@@ -193,7 +487,6 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: false,
|
force: false,
|
||||||
release_version: undefined,
|
release_version: undefined,
|
||||||
repo_url: 'https://github.com/example/plugins',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -274,7 +567,6 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
release_version: '0.9.0',
|
release_version: '0.9.0',
|
||||||
repo_url: 'https://github.com/example/releases',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(emitted().install).toHaveLength(1)
|
expect(emitted().install).toHaveLength(1)
|
||||||
@@ -289,7 +581,6 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
release_version: undefined,
|
release_version: undefined,
|
||||||
repo_url: 'https://github.com/example/plugins',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||||
@@ -339,7 +630,6 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
params: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
release_version: undefined,
|
release_version: undefined,
|
||||||
repo_url: 'https://github.com/example/releases',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||||
@@ -387,7 +677,8 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
|
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
|
||||||
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
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')
|
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/rating/DemoPlugin')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
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 PluginVersionHistoryDialog from '@/components/dialog/PluginVersionHistoryDialog.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
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>) {
|
async function renderDialog(props: Record<string, unknown>) {
|
||||||
return renderWithProviders(PluginVersionHistoryDialog, {
|
return renderWithProviders(PluginVersionHistoryDialog, {
|
||||||
props,
|
props,
|
||||||
@@ -66,6 +91,7 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
if (url === 'plugin/history/DemoPlugin') {
|
if (url === 'plugin/history/DemoPlugin') {
|
||||||
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
|
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)
|
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
|
||||||
throw new Error(`Unexpected request: ${url}`)
|
throw new Error(`Unexpected request: ${url}`)
|
||||||
})
|
})
|
||||||
@@ -87,7 +113,10 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
|
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
|
||||||
params: { force: true },
|
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: {
|
params: {
|
||||||
force: true,
|
force: true,
|
||||||
repo_url: 'https://github.com/example/plugins',
|
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 () => {
|
it('uses market metadata directly and emits latest installation without a release version', async () => {
|
||||||
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
|
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
|
||||||
const { emitted } = await renderDialog({
|
const { emitted } = await renderDialog({
|
||||||
@@ -126,6 +376,42 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
expect(emitted().update).toEqual([[undefined, 'https://github.com/example/plugins']])
|
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 () => {
|
it('distinguishes a Release request failure from an empty history', async () => {
|
||||||
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
|
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
|
||||||
await renderDialog({
|
await renderDialog({
|
||||||
@@ -147,7 +433,13 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
|
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
|
||||||
failed.unmount()
|
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({
|
await renderDialog({
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
plugin: installedPlugin,
|
plugin: installedPlugin,
|
||||||
|
|||||||
@@ -3843,6 +3843,32 @@ export default {
|
|||||||
confirmInstallOldRelease:
|
confirmInstallOldRelease:
|
||||||
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
|
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
|
||||||
local: 'Local',
|
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',
|
systemVersion: 'System Version',
|
||||||
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
|
||||||
installToLocal: 'Install to Local',
|
installToLocal: 'Install to Local',
|
||||||
|
|||||||
@@ -3781,6 +3781,31 @@ export default {
|
|||||||
confirmInstallOldRelease:
|
confirmInstallOldRelease:
|
||||||
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||||
local: '本地',
|
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: '系统版本',
|
systemVersion: '系统版本',
|
||||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||||
installToLocal: '安装到本地',
|
installToLocal: '安装到本地',
|
||||||
|
|||||||
@@ -3780,6 +3780,31 @@ export default {
|
|||||||
confirmInstallOldRelease:
|
confirmInstallOldRelease:
|
||||||
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||||
local: '本地',
|
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: '安裝到本地',
|
installToLocal: '安裝到本地',
|
||||||
totalDownloads: '共 {count} 次下載',
|
totalDownloads: '共 {count} 次下載',
|
||||||
rating: '插件評分',
|
rating: '插件評分',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
|
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
|
||||||
import type { Plugin, PluginRating } from '@/api/types'
|
import type { Plugin, PluginRating } from '@/api/types'
|
||||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import { getPluginTabs } from '@/router/i18n-menu'
|
import { getPluginTabs } from '@/router/i18n-menu'
|
||||||
@@ -822,15 +824,6 @@ function isLocalRepoSource(item: Plugin | string | undefined) {
|
|||||||
return Boolean((typeof item !== 'string' && item.is_local) || repoUrl.startsWith('local://'))
|
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) {
|
function initOptions(item: Plugin) {
|
||||||
const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
|
const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
|
||||||
@@ -864,6 +857,43 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
|
|||||||
return
|
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 previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id)
|
||||||
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
|
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
|
||||||
sortMode.value = false
|
sortMode.value = false
|
||||||
@@ -873,7 +903,6 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
|
|||||||
enabledFilter.value = false
|
enabledFilter.value = false
|
||||||
tabScrollPositions.installed = 0
|
tabScrollPositions.installed = 0
|
||||||
installScrollPluginId.value = pluginId
|
installScrollPluginId.value = pluginId
|
||||||
installingPluginIds.value = new Set([...installingPluginIds.value, pluginId])
|
|
||||||
dataList.value = [
|
dataList.value = [
|
||||||
...dataList.value.filter(plugin => plugin.id !== pluginId),
|
...dataList.value.filter(plugin => plugin.id !== pluginId),
|
||||||
{
|
{
|
||||||
@@ -888,14 +917,21 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
|
|||||||
|
|
||||||
let installed = false
|
let installed = false
|
||||||
try {
|
try {
|
||||||
await api.get(`plugin/install/${pluginId}`, {
|
if (useExplicitSource && repoUrl) {
|
||||||
params: {
|
await installPluginFromSource(pluginId, {
|
||||||
repo_url: repoUrl || item?.repo_url,
|
repo_url: repoUrl,
|
||||||
release_version: releaseVersion,
|
release_version: releaseVersion,
|
||||||
force: item?.has_update || Boolean(releaseVersion),
|
force: Boolean(item?.has_update || releaseVersion),
|
||||||
},
|
})
|
||||||
feedback: 'silent',
|
} else {
|
||||||
})
|
await api.get(`plugin/install/${pluginId}`, {
|
||||||
|
params: {
|
||||||
|
release_version: releaseVersion,
|
||||||
|
force: item?.has_update || Boolean(releaseVersion),
|
||||||
|
},
|
||||||
|
feedback: 'silent',
|
||||||
|
})
|
||||||
|
}
|
||||||
installed = true
|
installed = true
|
||||||
|
|
||||||
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
|
||||||
@@ -916,7 +952,7 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
|
|||||||
$toast.error(
|
$toast.error(
|
||||||
t('plugin.installFailed', {
|
t('plugin.installFailed', {
|
||||||
name: item?.plugin_name,
|
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()
|
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地址的前缀
|
// 处理掉github地址的前缀
|
||||||
function handleRepoUrl(item: Plugin | string | undefined) {
|
function handleRepoUrl(item: Plugin | string | undefined) {
|
||||||
const url = typeof item === 'string' ? item : normalizeMarketText(item?.repo_url)
|
const url = typeof item === 'string' ? item : normalizeMarketText(item?.repo_url)
|
||||||
if (!url) return ''
|
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/', '')
|
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 type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||||
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
|
import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
@@ -22,6 +22,7 @@ const apiUrls = {
|
|||||||
rating: new URL('plugin/rating', API_BASE_URL).href,
|
rating: new URL('plugin/rating', API_BASE_URL).href,
|
||||||
runtime: new URL('plugin/runtime', API_BASE_URL).href,
|
runtime: new URL('plugin/runtime', API_BASE_URL).href,
|
||||||
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
|
sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href,
|
||||||
|
sourceOptions: new URL('plugin/source/:pluginId/options', API_BASE_URL).href,
|
||||||
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +381,7 @@ interface ListResponses {
|
|||||||
order?: unknown[]
|
order?: unknown[]
|
||||||
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
|
rating?: (ids: string[]) => Record<string, PluginRating> | Promise<Record<string, PluginRating>>
|
||||||
runtime?: () => PluginRuntimeSummary | Promise<PluginRuntimeSummary>
|
runtime?: () => PluginRuntimeSummary | Promise<PluginRuntimeSummary>
|
||||||
|
sourceOptions?: (pluginId: string) => PluginSourceOptions | Promise<PluginSourceOptions>
|
||||||
statistic?: () => Record<string, number> | Promise<Record<string, number>>
|
statistic?: () => Record<string, number> | Promise<Record<string, number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +408,27 @@ function registerListHandlers(responses: ListResponses = {}) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
http.get(apiUrls.sidebar, () => apiJson([])),
|
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 }) => {
|
http.get(apiUrls.rating, async ({ request }) => {
|
||||||
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
|
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
|
||||||
return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType)
|
return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType)
|
||||||
@@ -963,8 +986,8 @@ describe('PluginCardListView market filtering and pagination', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const initialRepository = screen.getByRole('group', { name: '仓库' })
|
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).getByRole('button', { name: '本地' })).toBeInTheDocument()
|
||||||
|
expect(within(initialRepository).queryByRole('button', { name: '/tmp/plugins' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
await fireEvent.click(screen.getByText('插件名称'))
|
await fireEvent.click(screen.getByText('插件名称'))
|
||||||
await waitFor(() => expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Alpha'))
|
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 waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(9))
|
||||||
|
|
||||||
await fireEvent.click(within(label).getByRole('button', { name: '工具' }))
|
await fireEvent.click(within(label).getByRole('button', { name: '工具' }))
|
||||||
await fireEvent.click(within(repository).getByRole('button', { name: '/tmp/plugins' }))
|
await fireEvent.click(within(repository).getByRole('button', { name: '本地' }))
|
||||||
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(1))
|
await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(2))
|
||||||
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
|
expect(screen.getByText('market:Zulu')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('market:插件 01')).toBeInTheDocument()
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1244,7 +1268,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'installed-MarketInstall' }))
|
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(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument()
|
||||||
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
|
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
|
||||||
|
|
||||||
@@ -1310,7 +1334,7 @@ describe('PluginCardListView search installation', () => {
|
|||||||
await waitForRequestsToFinish()
|
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 installed = false
|
||||||
let installUrl: URL | undefined
|
let installUrl: URL | undefined
|
||||||
const target = createPlugin({
|
const target = createPlugin({
|
||||||
@@ -1340,12 +1364,59 @@ describe('PluginCardListView search installation', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
|
expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
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(installUrl?.searchParams.get('force')).toBe('true')
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!')
|
||||||
await waitForRequestsToFinish()
|
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 () => {
|
it('shows a per-plugin loading card while installation is still running', async () => {
|
||||||
const installGate = createDeferred<void>()
|
const installGate = createDeferred<void>()
|
||||||
let installed = false
|
let installed = false
|
||||||
|
|||||||
Reference in New Issue
Block a user