diff --git a/src/@core/components/ConfirmDialog.vue b/src/@core/components/ConfirmDialog.vue
index 63d9f353..743f4660 100644
--- a/src/@core/components/ConfirmDialog.vue
+++ b/src/@core/components/ConfirmDialog.vue
@@ -4,6 +4,8 @@ import { computed } from 'vue'
interface Props {
modelValue: boolean
type?: 'info' | 'warn' | 'error'
+ /** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
+ icon?: string
title?: string
content?: string
confirmText?: string
@@ -64,11 +66,11 @@ function handleCancel() {
-
+
{{ title }}
-
{{ content }}
+
{{ content }}
@@ -84,3 +86,9 @@ function handleCancel() {
+
+
diff --git a/src/api/types.ts b/src/api/types.ts
index 3b11e775..5e816122 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -961,6 +961,10 @@ export interface Plugin {
has_page?: boolean
// 是否有新版本
has_update?: boolean
+ // 当前市场发现的最高在线更新候选
+ update_candidate?: PluginUpdateCandidate | null
+ // 在线更新仓库绑定状态
+ source_binding_status?: 'bound' | 'binding_required' | 'local_only'
// 主系统版本是否兼容
system_version_compatible?: boolean
// 主系统版本兼容提示
@@ -993,6 +997,20 @@ export interface Plugin {
instance_mode?: 'virtual'
}
+/** 插件市场为已安装插件发现的当前最高在线更新候选。 */
+export interface PluginUpdateCandidate {
+ // 候选仓库是官方来源还是第三方来源
+ source_type: 'official' | 'third_party'
+ // 候选仓库的规范来源键
+ source_key: string
+ // 候选仓库的公开 GitHub 地址
+ repo_url: string
+ // 候选仓库当前可安装版本
+ version: string
+ // 候选仓库是否为插件当前已绑定仓库
+ is_bound: boolean
+}
+
/** 已绑定插件可用于自动更新的在线来源类型。 */
export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party'
diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue
index 0b15c6c4..44059688 100644
--- a/src/components/cards/PluginCard.vue
+++ b/src/components/cards/PluginCard.vue
@@ -51,7 +51,27 @@ const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
const { t } = useI18n()
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
-const hasCardStatus = computed(() => Boolean(props.plugin?.has_update) || hasCardRating.value)
+const sourceBindingRequired = computed(() => props.plugin?.source_binding_status === 'binding_required')
+const hasCardStatus = computed(
+ () => sourceBindingRequired.value || Boolean(props.plugin?.has_update) || hasCardRating.value,
+)
+const updateCandidate = computed(() => props.plugin?.update_candidate)
+const hasAlternativeUpdate = computed(
+ () => Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound),
+)
+const updateSourceName = computed(() => {
+ const candidate = updateCandidate.value
+ if (!candidate) return ''
+ return candidate.source_key.startsWith('github:') ? candidate.source_key.slice('github:'.length) : candidate.source_key
+})
+const updateBadgeTitle = computed(() => {
+ const candidate = updateCandidate.value
+ if (!candidate) return t('plugin.hasUpdate')
+ return t(candidate.is_bound ? 'plugin.boundUpdateAvailable' : 'plugin.alternativeUpdateAvailable', {
+ source: updateSourceName.value,
+ version: candidate.version,
+ })
+})
const runtimeStatus = computed(() => props.plugin?.runtime_status)
const runtimePending = computed(
() => props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || ''),
@@ -401,7 +421,7 @@ async function fetchInstalledPluginDetail() {
}
/** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */
-async function showPluginAbout() {
+async function showPluginAbout(initialSourceSelectionOpen = false) {
const pluginDetail = await fetchInstalledPluginDetail()
if (!pluginDetail) return
@@ -411,6 +431,7 @@ async function showPluginAbout() {
{
plugin: pluginDetail,
count: props.count,
+ initialSourceSelectionOpen,
},
{
install: () => {
@@ -424,6 +445,15 @@ async function showPluginAbout() {
)
}
+/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */
+function handleUpdateAction() {
+ if (hasAlternativeUpdate.value) {
+ void showPluginAbout(true)
+ return
+ }
+ showUpdateHistory(true)
+}
+
// 访问插件项目主页
async function visitPluginPage() {
const popup = window.open('about:blank', '_blank')
@@ -537,7 +567,7 @@ const dropdownItems = ref([
show: true,
props: {
prependIcon: 'mdi-information-outline',
- click: showPluginAbout,
+ click: () => showPluginAbout(),
},
},
{
@@ -569,13 +599,13 @@ const dropdownItems = ref([
},
},
{
- title: t('plugin.update'),
+ title: hasAlternativeUpdate.value ? t('plugin.viewUpdateSources') : t('plugin.update'),
value: 3,
show: props.plugin?.has_update,
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
- click: () => showUpdateHistory(true),
+ click: handleUpdateAction,
},
},
{
@@ -631,10 +661,15 @@ const dropdownItems = ref([
// 监听插件状态变化
watch(
- () => props.plugin?.has_update,
- newHasUpdate => {
+ () => [props.plugin?.has_update, props.plugin?.update_candidate?.is_bound] as const,
+ ([newHasUpdate]) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
- if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
+ if (updateItemIndex !== -1) {
+ dropdownItems.value[updateItemIndex].show = newHasUpdate
+ dropdownItems.value[updateItemIndex].title = hasAlternativeUpdate.value
+ ? t('plugin.viewUpdateSources')
+ : t('plugin.update')
+ }
const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9)
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
@@ -780,12 +815,21 @@ watch(
+
+ {{ t('plugin.sourceBindingRequired') }}
+ {{ t('plugin.sourceBindingRequiredHint') }}
+
+
+ {{ updateBadgeTitle }}
{
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
})
+ it('shows the repository type and keeps bound updates on the version history flow', async () => {
+ const updatablePlugin: Plugin = {
+ ...plugin,
+ has_update: true,
+ update_candidate: {
+ source_type: 'third_party',
+ source_key: 'github:example/plugins',
+ repo_url: 'https://github.com/example/plugins',
+ version: '2.0.0',
+ is_bound: true,
+ },
+ }
+ const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
+
+ await fireEvent.mouseEnter(screen.getByLabelText('有更新'))
+ expect(await screen.findByText('example/plugins 有可直接安装的新版本 v2.0.0')).toBeInTheDocument()
+ await fireEvent.click(container.querySelector('.v-card .v-btn')!)
+ await fireEvent.click(await screen.findByText('更新'))
+
+ expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
+ expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/history/DemoPlugin', expect.anything())
+ })
+
+ it('opens repository selection for an update published by another repository', async () => {
+ const updatablePlugin: Plugin = {
+ ...plugin,
+ has_update: true,
+ update_candidate: {
+ source_type: 'official',
+ source_key: 'github:jxxghp/moviepilot-plugins',
+ repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
+ version: '2.0.0',
+ is_bound: false,
+ },
+ }
+ mocks.apiGet.mockResolvedValue(updatablePlugin)
+ const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
+
+ await fireEvent.mouseEnter(screen.getByLabelText('有更新'))
+ expect(
+ await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库'),
+ ).toBeInTheDocument()
+ await fireEvent.click(container.querySelector('.v-card .v-btn')!)
+ await fireEvent.click(await screen.findByText('查看更新来源'))
+ await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
+
+ expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
+ params: { force: false },
+ })
+ expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
+ initialSourceSelectionOpen: true,
+ plugin: expect.objectContaining({ id: 'DemoPlugin' }),
+ })
+ expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
+ })
+
+ it('prioritizes repository confirmation over the update marker', async () => {
+ await renderWithProviders(PluginCard, {
+ props: {
+ plugin: {
+ ...plugin,
+ has_update: true,
+ source_binding_status: 'binding_required',
+ },
+ },
+ })
+
+ expect(screen.getByText('需确认仓库')).toBeInTheDocument()
+ expect(screen.queryByLabelText('有更新')).not.toBeInTheDocument()
+ await fireEvent.mouseEnter(screen.getByText('需确认仓库'))
+ expect(await screen.findByText('该插件尚未绑定仓库,请在「关于」中确认')).toBeInTheDocument()
+ })
+
it('blocks an incompatible latest update without sending a request', async () => {
const updatablePlugin = {
...plugin,
diff --git a/src/components/dialog/PluginMarketDetailDialog.vue b/src/components/dialog/PluginMarketDetailDialog.vue
index b797a624..c7e41ffa 100644
--- a/src/components/dialog/PluginMarketDetailDialog.vue
+++ b/src/components/dialog/PluginMarketDetailDialog.vue
@@ -42,6 +42,10 @@ const props = defineProps({
>,
default: undefined,
},
+ initialSourceSelectionOpen: {
+ type: Boolean,
+ default: false,
+ },
})
// 定义触发的自定义事件
@@ -72,7 +76,7 @@ const sourceLoading = ref(false)
const sourceError = ref('')
const selectedInstallSourceKey = ref('')
const selectedChangeSourceKey = ref('')
-const showSourceChoices = ref(false)
+const showSourceChoices = ref(props.initialSourceSelectionOpen)
const sourceChanging = ref(false)
// 图片是否加载失败
@@ -87,7 +91,16 @@ const onlineSourceCandidates = computed(() =>
// 官方仓库是默认可信来源,在所有选源入口中始终置顶。
.sort((left, right) => Number(right.source_type === 'official') - Number(left.source_type === 'official')),
)
-const sourceNeedsSelection = computed(() => !isInstalled.value && sourceOptions.value?.selection_status === 'conflict')
+const sourceNeedsSelection = computed(() => {
+ if (isInstalled.value) return false
+ if (sourceOptions.value?.selection_status === 'conflict') return true
+ return (
+ sourceOptions.value?.selection_status === 'selected' &&
+ onlineSourceCandidates.value.length === 1 &&
+ onlineSourceCandidates.value[0].source_type === 'third_party'
+ )
+})
+const sourceHasConflict = computed(() => sourceOptions.value?.selection_status === 'conflict')
const selectedInstallSource = computed(() =>
onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value),
)
@@ -178,8 +191,13 @@ async function loadPluginSourceOptions(force = false) {
)
if (!installSelectionStillExists) {
const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official')
+ const selectedCandidate = installCandidates.length === 1 ? installCandidates[0] : undefined
selectedInstallSourceKey.value =
- !isInstalled.value && options.selection_status === 'conflict' ? officialCandidate?.source_key || '' : ''
+ !isInstalled.value && options.selection_status === 'conflict'
+ ? officialCandidate?.source_key || ''
+ : !isInstalled.value && selectedCandidate?.source_type === 'third_party'
+ ? selectedCandidate.source_key
+ : ''
}
const changeSelectionStillExists = sourceActionCandidates.value.some(
@@ -202,15 +220,22 @@ async function confirmSourceTransition() {
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', {
+ const confirmationContent = [
+ t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
name: props.plugin.plugin_name,
current: trustedSourceLabel(),
target: sourceCandidateLabel(target),
}),
- confirmText: t(bindingSource ? 'plugin.bindSource' : 'plugin.changeSource'),
+ target.source_type === 'third_party' ? t('plugin.thirdPartySourceRisk') : '',
+ ]
+ .filter(Boolean)
+ .join('\n\n')
+ const confirmed = await createConfirm({
+ type: 'warn',
+ icon: bindingSource ? 'mdi-shield-check-outline' : 'mdi-source-branch',
+ title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'),
+ content: confirmationContent,
+ confirmText: t(bindingSource ? 'plugin.confirmSourceBindAction' : 'plugin.confirmSourceChangeAction'),
})
if (!confirmed) return
@@ -322,6 +347,23 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
const selectedRepoUrl = explicitSource?.repo_url || repoUrl
+ if (explicitSource?.source_type === 'third_party') {
+ const confirmed = await createConfirm({
+ type: 'warn',
+ icon: 'mdi-shield-alert-outline',
+ title: t('plugin.confirmThirdPartyInstallTitle'),
+ content: [
+ t('plugin.confirmThirdPartyInstall', {
+ name: props.plugin?.plugin_name,
+ target: sourceCandidateLabel(explicitSource),
+ }),
+ t('plugin.thirdPartySourceRisk'),
+ ].join('\n\n'),
+ confirmText: t('plugin.confirmThirdPartyInstallAction'),
+ })
+ if (!confirmed) return
+ }
+
if (props.installHandler) {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
@@ -502,20 +544,23 @@ onUnmounted(() => {
-
+ class="plugin-market-detail__compatibility"
+ role="alert"
+ >
+
+ {{ props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}
+
{{ t('plugin.source') }}
+
+ {{ t(sourceHasConflict ? 'plugin.sourceSelectionRequired' : 'plugin.sourceConfirmationRequired') }}
+
{{
@@ -523,20 +568,27 @@ onUnmounted(() => {
? t('plugin.sourceBindingHint')
: isInstalled
? t('plugin.sourceInstalledHint')
- : t('plugin.sourceConflictHint')
+ : sourceHasConflict
+ ? t('plugin.sourceConflictHint')
+ : t('plugin.sourceThirdPartyHint')
}}
-
+
+ {{ sourceError }}
+
- {{ t('plugin.trustedUpdateSource') }}
- -
+
-
{
{{ trustedSourceLabel() }}
+
+
+
+ {{ t(hasTrustedOnlineSource ? 'plugin.changeSourceInline' : 'plugin.bindSourceInline') }}
+
+
@@ -557,14 +623,9 @@ onUnmounted(() => {
-
+
+ {{ sourceOptions.selection_reason }}
+
{
{{ sourceCandidateLabel(candidate) }}
v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}
-
-
- {{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }}
-
-
-
-
-
-
-
-
- {{ t('plugin.sourceOfficial') }}
-
- {{ sourceCandidateLabel(candidate) }}
-
- v{{ candidate.plugin_version || '-' }} ·
- {{ candidate.package_generation.toUpperCase() }} 0 && showSourceChoices"
+ class="plugin-market-detail-source__change"
+ >
+
+
+
+
+
+
+ {{ t('plugin.sourceOfficial') }}
+
+ {{ sourceCandidateLabel(candidate) }}
-
-
-
-
-
- {{ t('common.cancel') }}
-
-
- {{ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.bindSource') }}
-
-
-
+ v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{
+ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.confirmSourceBindAction')
+ }}
+
+
@@ -758,6 +813,9 @@ onUnmounted(() => {
}
.plugin-market-detail-source__title {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.4;
@@ -770,6 +828,13 @@ onUnmounted(() => {
line-height: 1.45;
}
+.plugin-market-detail-source__message--error {
+ margin: 0 0 0.75rem;
+ color: rgb(var(--v-theme-error));
+ font-size: 0.75rem;
+ line-height: 1.45;
+}
+
.plugin-market-detail-source__identity {
display: grid;
gap: 0.5rem;
@@ -797,11 +862,29 @@ onUnmounted(() => {
text-align: end;
}
+.plugin-market-detail-source__identity-content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 1.75rem;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.plugin-market-detail-source__identity-content--no-action {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.plugin-market-detail-source__identity-action {
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+}
+
.plugin-market-detail-source__identity-value,
.plugin-market-detail-source__choice-title {
display: inline-flex;
min-width: 0;
align-items: center;
+ flex-wrap: wrap;
justify-content: flex-end;
gap: 0.375rem;
}
@@ -922,8 +1005,22 @@ onUnmounted(() => {
text-decoration: underline;
}
-.plugin-market-detail__warning {
- margin-block-start: 1rem;
+.plugin-market-detail__compatibility {
+ display: flex;
+ max-inline-size: 24rem;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 0.375rem;
+ margin: 1rem auto 0;
+ color: rgb(var(--v-theme-warning));
+ font-size: 0.8125rem;
+ line-height: 1.45;
+ text-align: center;
+}
+
+.plugin-market-detail__compatibility .v-icon {
+ flex: 0 0 auto;
+ margin-block-start: 0.0625rem;
}
.plugin-market-detail-actions {
diff --git a/src/components/dialog/PluginVersionHistoryDialog.vue b/src/components/dialog/PluginVersionHistoryDialog.vue
index cab4a2bc..80ecab8f 100644
--- a/src/components/dialog/PluginVersionHistoryDialog.vue
+++ b/src/components/dialog/PluginVersionHistoryDialog.vue
@@ -359,14 +359,14 @@ watch(
-
+ class="plugin-version-history-dialog__compatibility"
+ role="alert"
+ >
+
+ {{ resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}
+
{
await renderDialog({ ...basePlugin, installed: false })
expect(await screen.findByText('安装到本地')).toBeInTheDocument()
+ expect(screen.getByText('该插件来自第三方仓库,请确认后安装。')).toBeInTheDocument()
+ expect(screen.getByText('需确认')).toBeInTheDocument()
+ expect(screen.queryByText('需选择')).not.toBeInTheDocument()
+ expect(screen.queryByText('检测到多个同名插件,请选择仓库。')).not.toBeInTheDocument()
expect(screen.queryByText('提交评分')).not.toBeInTheDocument()
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
})
@@ -139,7 +143,7 @@ describe('PluginMarketDetailDialog', () => {
...defaultSourceOptions,
identity: null,
selection_status: 'conflict',
- selection_reason: '未安装插件存在多个在线来源,不能静默选择',
+ selection_reason: '该插件存在多个在线来源,请确认来源后安装。',
candidates: [
defaultSourceOptions.candidates[0],
{
@@ -156,7 +160,9 @@ describe('PluginMarketDetailDialog', () => {
})
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
- expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument()
+ expect(await screen.findByText('检测到多个同名插件,请选择仓库。')).toBeInTheDocument()
+ expect(screen.getByText('需选择')).toBeInTheDocument()
+ expect(screen.queryByText('该插件存在多个在线来源,请确认来源后安装。')).not.toBeInTheDocument()
const installButton = screen.getByRole('button', { name: '安装到本地' })
expect(installButton).toBeEnabled()
@@ -207,20 +213,21 @@ describe('PluginMarketDetailDialog', () => {
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
- expect(await screen.findByText('自动更新来源')).toBeInTheDocument()
+ expect(await screen.findByText('仓库')).toBeInTheDocument()
expect(screen.getByText('jxxghp/moviepilot-plugins')).toBeInTheDocument()
expect(screen.getByText('官方')).toBeInTheDocument()
- expect(screen.getByText('当前载荷')).toBeInTheDocument()
+ expect(screen.getByText('运行来源')).toBeInTheDocument()
expect(screen.getByText('本地')).toBeInTheDocument()
- await fireEvent.click(screen.getByRole('button', { name: '更换来源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '更换' }))
await fireEvent.click(screen.getByText('example/plugins'))
- await fireEvent.click(screen.getByRole('button', { name: '确认换源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
+ icon: 'mdi-source-branch',
content: expect.stringContaining('jxxghp/moviepilot-plugins'),
- confirmText: '更换来源',
+ confirmText: '确认更换',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
@@ -237,7 +244,7 @@ describe('PluginMarketDetailDialog', () => {
return Promise.resolve({
...defaultSourceOptions,
selection_status: 'unavailable',
- selection_reason: '当前来源身份没有可用候选',
+ selection_reason: '已绑定仓库中暂无可用插件包',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'official',
@@ -254,8 +261,8 @@ describe('PluginMarketDetailDialog', () => {
})
await renderDialog({ ...basePlugin, installed: true, has_update: true })
- expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: '更换来源' })).toBeInTheDocument()
+ expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '更换' })).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新' })
expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton)
@@ -295,16 +302,17 @@ describe('PluginMarketDetailDialog', () => {
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
- expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
+ expect(await screen.findByText('当前插件尚未绑定,请选择仓库。')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更新' })).toBeDisabled()
- await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '绑定' }))
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
- await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '确认绑定' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
- title: '确认绑定插件来源',
- confirmText: '绑定来源',
+ icon: 'mdi-shield-check-outline',
+ title: '确认绑定仓库',
+ confirmText: '确认绑定',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
@@ -349,7 +357,7 @@ describe('PluginMarketDetailDialog', () => {
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
- expect(screen.getByRole('button', { name: '绑定来源' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '绑定' })).toBeInTheDocument()
})
it('reloads source evidence after a stale revision failure without retrying the change', async () => {
@@ -390,9 +398,9 @@ describe('PluginMarketDetailDialog', () => {
})
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
- await fireEvent.click(await screen.findByRole('button', { name: '更换来源' }))
+ await fireEvent.click(await screen.findByRole('button', { name: '更换' }))
await fireEvent.click(screen.getByText('example/plugins'))
- await fireEvent.click(screen.getByRole('button', { name: '确认换源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
@@ -485,13 +493,19 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
await waitFor(() => {
- expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
- params: {
- force: false,
- release_version: undefined,
- },
+ expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
+ repo_url: 'https://github.com/example/plugins',
+ release_version: undefined,
+ force: false,
})
})
+ expect(mocks.confirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: '确认安装',
+ confirmText: '安装',
+ content: expect.stringContaining('后续更新也将使用该仓库'),
+ }),
+ )
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
expect(emitted().install).toHaveLength(1)
expect(emitted()['update:modelValue']).toContainEqual([false])
@@ -504,7 +518,11 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
- expect(installHandler).toHaveBeenCalledWith(undefined, undefined, defaultSourceOptions)
+ expect(installHandler).toHaveBeenCalledWith(
+ undefined,
+ 'https://github.com/example/plugins',
+ defaultSourceOptions,
+ )
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
expect(emitted()['update:modelValue']).toContainEqual([false])
@@ -565,11 +583,10 @@ describe('PluginMarketDetailDialog', () => {
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
- expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
- params: {
- force: true,
- release_version: '0.9.0',
- },
+ expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
+ repo_url: 'https://github.com/example/plugins',
+ release_version: '0.9.0',
+ force: true,
})
expect(emitted().install).toHaveLength(1)
})
diff --git a/src/components/dialog/__tests__/PluginVersionHistoryDialog.spec.ts b/src/components/dialog/__tests__/PluginVersionHistoryDialog.spec.ts
index b1210af5..85de7955 100644
--- a/src/components/dialog/__tests__/PluginVersionHistoryDialog.spec.ts
+++ b/src/components/dialog/__tests__/PluginVersionHistoryDialog.spec.ts
@@ -133,6 +133,32 @@ describe('PluginVersionHistoryDialog', () => {
])
})
+ it('shows the version requirement and disables the latest update when the host is incompatible', async () => {
+ mocks.apiGet.mockImplementation((url: string) => {
+ if (url === 'plugin/history/DemoPlugin') {
+ return Promise.resolve({
+ ...installedPlugin,
+ system_version_compatible: false,
+ system_version_message: '该插件要求 MoviePilot >=4.0.0',
+ history: { 'v1.0.0': '当前更新说明' },
+ })
+ }
+ if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(sourceOptions)
+ if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
+ throw new Error(`Unexpected request: ${url}`)
+ })
+
+ await renderDialog({
+ modelValue: true,
+ plugin: installedPlugin,
+ showUpdateAction: true,
+ actionMode: 'update',
+ })
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('该插件要求 MoviePilot >=4.0.0')
+ expect(screen.getByRole('button', { name: '更新到最新版本' })).toBeDisabled()
+ })
+
it('requires source binding before a legacy plugin can update from history', async () => {
const legacySourceOptions: PluginSourceOptions = {
...sourceOptions,
@@ -163,10 +189,10 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update',
})
- expect(await screen.findByRole('button', { name: '绑定来源' })).toBeInTheDocument()
- expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
+ 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: '绑定来源' }))
+ await fireEvent.click(screen.getByRole('button', { name: '绑定仓库' }))
expect(emitted().sourceAction).toEqual([[]])
expect(emitted().update).toBeUndefined()
@@ -249,7 +275,7 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update',
})
- const bindButton = await screen.findByRole('button', { name: '绑定来源' })
+ const bindButton = await screen.findByRole('button', { name: '绑定仓库' })
await fireEvent.click(bindButton)
expect(emitted().sourceAction).toEqual([[]])
@@ -299,7 +325,7 @@ describe('PluginVersionHistoryDialog', () => {
const unavailableSourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'unavailable',
- selection_reason: '当前来源身份没有可用候选',
+ selection_reason: '已绑定仓库中暂无可用插件包',
candidates: [],
}
mocks.apiGet.mockImplementation((url: string) => {
@@ -317,7 +343,7 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update',
})
- expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
+ expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument()
expect(emitted().update).toBeUndefined()
diff --git a/src/composables/useConfirm.ts b/src/composables/useConfirm.ts
index d2faa223..e2c4048f 100644
--- a/src/composables/useConfirm.ts
+++ b/src/composables/useConfirm.ts
@@ -7,6 +7,8 @@ import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
/** 主应用确认弹窗支持的配置项。 */
export interface ConfirmOptions {
type?: 'info' | 'warn' | 'error'
+ /** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
+ icon?: string
title?: string
content?: string
confirmText?: string
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index c6e3da78..c2a17016 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -3841,6 +3841,9 @@ export default {
installed: 'Installed',
notInstalled: 'Not Installed',
hasUpdate: 'Update Available',
+ boundUpdateAvailable: '{source} has an installable update v{version}',
+ alternativeUpdateAvailable: '{source} has update v{version}; changing repositories requires confirmation',
+ viewUpdateSources: 'View Update Sources',
configuring: 'Configuring',
enable: 'Enable',
disable: 'Disable',
@@ -3856,33 +3859,45 @@ export default {
confirmInstallOldRelease:
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local',
- source: 'Plugin Source',
+ source: 'Source',
sourceOfficial: 'Official',
- sourceUnknown: 'Unknown source',
+ sourceUnknown: 'Unknown',
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',
+ sourceLoadFailed: 'Source information is temporarily unavailable. Try again later.',
+ sourceUnavailable: 'No repository is currently available',
+ sourceInstalledHint: 'Plugins are updated only from the bound repository.',
+ sourceBindingRequired: 'Repository confirmation required',
+ sourceBindingRequiredHint: 'This plugin has no bound repository; open About to confirm',
+ sourceConflictHint: 'Multiple plugins share this ID. Choose a repository.',
+ sourceThirdPartyHint: 'This plugin comes from a third-party repository. Confirm before installing.',
+ sourceSelectionRequired: 'Selection required',
+ sourceConfirmationRequired: 'Confirmation required',
+ sourceBindingHint: 'This plugin is not bound. Choose a repository.',
+ trustedUpdateSource: 'Repository',
+ currentPayloadSource: 'Runtime source',
+ selectSourceRequired: 'Choose a repository before installing.',
+ bindSource: 'Bind Repository',
+ bindSourceInline: 'Bind',
+ confirmThirdPartyInstallTitle: 'Confirm Installation',
+ confirmThirdPartyInstall: 'Install “{name}” from {target}. Future updates will also use this repository.',
+ confirmThirdPartyInstallAction: 'Install',
+ confirmSourceBindTitle: 'Confirm Repository Binding',
+ confirmSourceBind: 'Bind “{name}” to {target} and install the current version from this repository.',
+ thirdPartySourceRisk:
+ 'This is a third-party repository. Future updates will come from it. Confirm to continue.',
+ confirmSourceBindAction: 'Confirm Bind',
+ bindingSource: 'Installing {name} from the selected repository...',
+ sourceBindSuccess: 'The repository for {name} was bound',
+ sourceBindFailed: 'Failed to bind the repository for {name}: {message}',
+ changeSource: 'Change Repository',
+ changeSourceInline: 'Change',
+ confirmSourceChangeTitle: 'Confirm Repository Change',
confirmSourceChange:
- 'Change the automatic update source for {name} from {current} to {target}. The current version from the target source will be installed immediately.',
+ 'Change the repository for {name} from {current} to {target}. The current version from the target repository 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}',
+ changingSource: 'Changing the repository for {name}...',
+ sourceChangeSuccess: 'The repository for {name} was changed',
+ sourceChangeFailed: 'Failed to change the repository for {name}: {message}',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 248d400b..13a6c142 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -3778,6 +3778,9 @@ export default {
installed: '已安装',
notInstalled: '未安装',
hasUpdate: '有更新',
+ boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
+ alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
+ viewUpdateSources: '查看更新来源',
configuring: '配置',
enable: '启用',
disable: '禁用',
@@ -3793,32 +3796,43 @@ export default {
confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地',
- source: '插件来源',
+ source: '来源',
sourceOfficial: '官方',
- 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}',
+ sourceUnknown: '未知',
+ sourceUnbound: '未绑定',
+ sourceLoadFailed: '来源信息暂时无法读取,请稍后重试',
+ sourceUnavailable: '当前没有可用仓库',
+ sourceInstalledHint: '插件只会从已绑定仓库更新。',
+ sourceBindingRequired: '需确认仓库',
+ sourceBindingRequiredHint: '该插件尚未绑定仓库,请在「关于」中确认',
+ sourceConflictHint: '检测到多个同名插件,请选择仓库。',
+ sourceThirdPartyHint: '该插件来自第三方仓库,请确认后安装。',
+ sourceSelectionRequired: '需选择',
+ sourceConfirmationRequired: '需确认',
+ sourceBindingHint: '当前插件尚未绑定,请选择仓库。',
+ trustedUpdateSource: '仓库',
+ currentPayloadSource: '运行来源',
+ selectSourceRequired: '请选择仓库后再安装。',
+ bindSource: '绑定仓库',
+ bindSourceInline: '绑定',
+ confirmThirdPartyInstallTitle: '确认安装',
+ confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
+ confirmThirdPartyInstallAction: '安装',
+ confirmSourceBindTitle: '确认绑定仓库',
+ confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
+ thirdPartySourceRisk: '这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
+ confirmSourceBindAction: '确认绑定',
+ bindingSource: '正在从所选仓库安装 {name}...',
+ sourceBindSuccess: '插件 {name} 已绑定仓库',
+ sourceBindFailed: '插件 {name} 绑定仓库失败:{message}',
+ changeSource: '更换仓库',
+ changeSourceInline: '更换',
+ confirmSourceChangeTitle: '确认更换仓库',
+ confirmSourceChange: '将插件「{name}」的仓库从 {current} 更换为 {target},确认后会立即安装目标仓库的当前版本。',
+ confirmSourceChangeAction: '确认更换',
+ changingSource: '正在更换 {name} 的仓库...',
+ sourceChangeSuccess: '插件 {name} 的仓库已更换',
+ sourceChangeFailed: '插件 {name} 更换仓库失败:{message}',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
installToLocal: '安装到本地',
diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts
index 217beaa6..b4c1339d 100644
--- a/src/locales/zh-TW.ts
+++ b/src/locales/zh-TW.ts
@@ -3777,6 +3777,9 @@ export default {
installed: '已安裝',
notInstalled: '未安裝',
hasUpdate: '可更新',
+ boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
+ alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
+ viewUpdateSources: '查看更新來源',
configuring: '配置中',
enable: '啟用',
disable: '禁用',
@@ -3792,32 +3795,43 @@ export default {
confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地',
- source: '插件來源',
+ source: '來源',
sourceOfficial: '官方',
- 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}',
+ sourceUnknown: '未知',
+ sourceUnbound: '未綁定',
+ sourceLoadFailed: '來源資訊暫時無法讀取,請稍後重試',
+ sourceUnavailable: '目前沒有可用倉庫',
+ sourceInstalledHint: '插件只會從已綁定倉庫更新。',
+ sourceBindingRequired: '需確認倉庫',
+ sourceBindingRequiredHint: '此插件尚未綁定倉庫,請在「關於」中確認',
+ sourceConflictHint: '偵測到多個同名插件,請選擇倉庫。',
+ sourceThirdPartyHint: '該插件來自第三方倉庫,請確認後安裝。',
+ sourceSelectionRequired: '需選擇',
+ sourceConfirmationRequired: '需確認',
+ sourceBindingHint: '目前插件尚未綁定,請選擇倉庫。',
+ trustedUpdateSource: '倉庫',
+ currentPayloadSource: '運行來源',
+ selectSourceRequired: '請選擇倉庫後再安裝。',
+ bindSource: '綁定倉庫',
+ bindSourceInline: '綁定',
+ confirmThirdPartyInstallTitle: '確認安裝',
+ confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
+ confirmThirdPartyInstallAction: '安裝',
+ confirmSourceBindTitle: '確認綁定倉庫',
+ confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
+ thirdPartySourceRisk: '這是第三方倉庫,後續更新將來自該倉庫,請確認後繼續。',
+ confirmSourceBindAction: '確認綁定',
+ bindingSource: '正在從所選倉庫安裝 {name}...',
+ sourceBindSuccess: '插件 {name} 已綁定倉庫',
+ sourceBindFailed: '插件 {name} 綁定倉庫失敗:{message}',
+ changeSource: '更換倉庫',
+ changeSourceInline: '更換',
+ confirmSourceChangeTitle: '確認更換倉庫',
+ confirmSourceChange: '將插件「{name}」的倉庫從 {current} 更換為 {target},確認後會立即安裝目標倉庫的目前版本。',
+ confirmSourceChangeAction: '確認更換',
+ changingSource: '正在更換 {name} 的倉庫...',
+ sourceChangeSuccess: '插件 {name} 的倉庫已更換',
+ sourceChangeFailed: '插件 {name} 更換倉庫失敗:{message}',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
rating: '插件評分',
diff --git a/src/views/plugin/PluginCardListView.vue b/src/views/plugin/PluginCardListView.vue
index d28afae6..7c574130 100644
--- a/src/views/plugin/PluginCardListView.vue
+++ b/src/views/plugin/PluginCardListView.vue
@@ -1097,6 +1097,7 @@ function mergeMarketMetadataIntoInstalled() {
dataList.value.forEach(plugin => {
const marketPlugin = marketById.get(plugin.id)
plugin.has_update = Boolean(marketPlugin)
+ plugin.update_candidate = marketPlugin?.update_candidate ?? null
if (!marketPlugin) return
plugin.repo_url = marketPlugin.repo_url
diff --git a/src/views/plugin/__tests__/PluginCardListView.spec.ts b/src/views/plugin/__tests__/PluginCardListView.spec.ts
index a6288309..bf466911 100644
--- a/src/views/plugin/__tests__/PluginCardListView.spec.ts
+++ b/src/views/plugin/__tests__/PluginCardListView.spec.ts
@@ -172,6 +172,7 @@ const PluginMixedSortCardStub = defineComponent({
plugin_name?: string
repo_url?: string
runtime_status?: Plugin['runtime_status']
+ update_candidate?: Plugin['update_candidate']
}
| undefined
const name = type === 'folder' ? id : data?.plugin_name || id
@@ -185,6 +186,9 @@ const PluginMixedSortCardStub = defineComponent({
? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false))
: h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''),
type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null,
+ type === 'plugin'
+ ? h('output', { 'aria-label': `update-source-${id}` }, data?.update_candidate?.source_key || '')
+ : null,
type === 'plugin' ? h('output', { 'aria-label': `runtime-${id}` }, data?.runtime_status || '') : null,
type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : null,
type === 'plugin' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null,
@@ -760,15 +764,31 @@ describe('PluginCardListView loading and request ownership', () => {
market: () => {
marketRequest += 1
return marketRequest === 1
- ? [createPlugin({ has_update: true, id: 'Shared', installed: true, plugin_name: '待更新插件' })]
+ ? [
+ createPlugin({
+ has_update: true,
+ id: 'Shared',
+ installed: true,
+ plugin_name: '待更新插件',
+ update_candidate: {
+ source_type: 'official',
+ source_key: 'github:jxxghp/moviepilot-plugins',
+ repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
+ version: '2.0.0',
+ is_bound: false,
+ },
+ }),
+ ]
: []
},
})
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true'))
+ expect(screen.getByLabelText('update-source-Shared')).toHaveTextContent('github:jxxghp/moviepilot-plugins')
await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' }))
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
+ expect(screen.getByLabelText('update-source-Shared')).toBeEmptyDOMElement()
expect(marketRequest).toBe(2)
await waitForRequestsToFinish()
})
@@ -1458,7 +1478,7 @@ describe('PluginCardListView search installation', () => {
plugin_id: pluginId,
inventory_complete: true,
selection_status: 'conflict',
- selection_reason: '未安装插件存在多个在线来源,不能静默选择',
+ selection_reason: '该插件存在多个在线来源,请确认来源后安装。',
identity: null,
candidates: [
{
diff --git a/vite.config.ts b/vite.config.ts
index 7bc13278..6dc56642 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -264,7 +264,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
server: {
proxy: {
'/api/v1': {
- target: 'http://localhost:3001',
+ target: process.env.MOVIEPILOT_DEV_PROXY_TARGET || 'http://localhost:3001',
changeOrigin: true,
secure: false,
cookieDomainRewrite: 'localhost',