mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(plugin): 明确插件仓库绑定与换仓确认 (#724)
This commit is contained in:
@@ -4,6 +4,8 @@ import { computed } from 'vue'
|
|||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
type?: 'info' | 'warn' | 'error'
|
type?: 'info' | 'warn' | 'error'
|
||||||
|
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
|
||||||
|
icon?: string
|
||||||
title?: string
|
title?: string
|
||||||
content?: string
|
content?: string
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
@@ -64,11 +66,11 @@ function handleCancel() {
|
|||||||
<VCardItem>
|
<VCardItem>
|
||||||
<div class="d-flex align-center justify-start mt-3">
|
<div class="d-flex align-center justify-start mt-3">
|
||||||
<VAvatar :color="currentType.color" variant="text" size="x-large">
|
<VAvatar :color="currentType.color" variant="text" size="x-large">
|
||||||
<VIcon size="x-large" :icon="currentType.icon" />
|
<VIcon size="x-large" :icon="icon || currentType.icon" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
<div class="mx-3">
|
<div class="mx-3">
|
||||||
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
|
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
|
||||||
<p>{{ content }}</p>
|
<p class="app-confirm-dialog-content">{{ content }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
@@ -84,3 +86,9 @@ function handleCancel() {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-confirm-dialog-content {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -961,6 +961,10 @@ export interface Plugin {
|
|||||||
has_page?: boolean
|
has_page?: boolean
|
||||||
// 是否有新版本
|
// 是否有新版本
|
||||||
has_update?: boolean
|
has_update?: boolean
|
||||||
|
// 当前市场发现的最高在线更新候选
|
||||||
|
update_candidate?: PluginUpdateCandidate | null
|
||||||
|
// 在线更新仓库绑定状态
|
||||||
|
source_binding_status?: 'bound' | 'binding_required' | 'local_only'
|
||||||
// 主系统版本是否兼容
|
// 主系统版本是否兼容
|
||||||
system_version_compatible?: boolean
|
system_version_compatible?: boolean
|
||||||
// 主系统版本兼容提示
|
// 主系统版本兼容提示
|
||||||
@@ -993,6 +997,20 @@ export interface Plugin {
|
|||||||
instance_mode?: 'virtual'
|
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'
|
export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party'
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,27 @@ const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
|
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 runtimeStatus = computed(() => props.plugin?.runtime_status)
|
||||||
const runtimePending = computed(
|
const runtimePending = computed(
|
||||||
() => props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || ''),
|
() => 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()
|
const pluginDetail = await fetchInstalledPluginDetail()
|
||||||
if (!pluginDetail) return
|
if (!pluginDetail) return
|
||||||
|
|
||||||
@@ -411,6 +431,7 @@ async function showPluginAbout() {
|
|||||||
{
|
{
|
||||||
plugin: pluginDetail,
|
plugin: pluginDetail,
|
||||||
count: props.count,
|
count: props.count,
|
||||||
|
initialSourceSelectionOpen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
install: () => {
|
install: () => {
|
||||||
@@ -424,6 +445,15 @@ async function showPluginAbout() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */
|
||||||
|
function handleUpdateAction() {
|
||||||
|
if (hasAlternativeUpdate.value) {
|
||||||
|
void showPluginAbout(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showUpdateHistory(true)
|
||||||
|
}
|
||||||
|
|
||||||
// 访问插件项目主页
|
// 访问插件项目主页
|
||||||
async function visitPluginPage() {
|
async function visitPluginPage() {
|
||||||
const popup = window.open('about:blank', '_blank')
|
const popup = window.open('about:blank', '_blank')
|
||||||
@@ -537,7 +567,7 @@ const dropdownItems = ref([
|
|||||||
show: true,
|
show: true,
|
||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-information-outline',
|
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,
|
value: 3,
|
||||||
show: props.plugin?.has_update,
|
show: props.plugin?.has_update,
|
||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-arrow-up-circle-outline',
|
prependIcon: 'mdi-arrow-up-circle-outline',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
click: () => showUpdateHistory(true),
|
click: handleUpdateAction,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -631,10 +661,15 @@ const dropdownItems = ref([
|
|||||||
|
|
||||||
// 监听插件状态变化
|
// 监听插件状态变化
|
||||||
watch(
|
watch(
|
||||||
() => props.plugin?.has_update,
|
() => [props.plugin?.has_update, props.plugin?.update_candidate?.is_bound] as const,
|
||||||
newHasUpdate => {
|
([newHasUpdate]) => {
|
||||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
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)
|
const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9)
|
||||||
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
|
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
|
||||||
@@ -780,12 +815,21 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<div
|
<div
|
||||||
v-if="props.plugin?.has_update"
|
v-if="sourceBindingRequired"
|
||||||
|
class="plugin-card__status plugin-card__status--binding"
|
||||||
|
:aria-label="t('plugin.sourceBindingRequired')"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-shield-alert-outline" size="12" />
|
||||||
|
{{ t('plugin.sourceBindingRequired') }}
|
||||||
|
<VTooltip activator="parent" location="top">{{ t('plugin.sourceBindingRequiredHint') }}</VTooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="props.plugin?.has_update"
|
||||||
class="plugin-card__status plugin-card__status--update"
|
class="plugin-card__status plugin-card__status--update"
|
||||||
:aria-label="t('plugin.hasUpdate')"
|
:aria-label="t('plugin.hasUpdate')"
|
||||||
:title="t('plugin.hasUpdate')"
|
|
||||||
>
|
>
|
||||||
<VIcon icon="mdi-new-box" class="text-white" size="20" />
|
<VIcon icon="mdi-new-box" class="text-white" size="20" />
|
||||||
|
<VTooltip activator="parent" location="top">{{ updateBadgeTitle }}</VTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="hasCardRating"
|
v-else-if="hasCardRating"
|
||||||
@@ -830,6 +874,18 @@ watch(
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-card__status--binding {
|
||||||
|
gap: 0.1875rem;
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
border: 1px solid rgba(var(--v-theme-warning), 45%);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(var(--v-theme-warning), 16%);
|
||||||
|
color: rgb(var(--v-theme-warning));
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-card--runtime-pending {
|
.plugin-card--runtime-pending {
|
||||||
cursor: progress;
|
cursor: progress;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,79 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
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<HTMLButtonElement>('.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<HTMLButtonElement>('.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 () => {
|
it('blocks an incompatible latest update without sending a request', async () => {
|
||||||
const updatablePlugin = {
|
const updatablePlugin = {
|
||||||
...plugin,
|
...plugin,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const props = defineProps({
|
|||||||
>,
|
>,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
|
initialSourceSelectionOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -72,7 +76,7 @@ const sourceLoading = ref(false)
|
|||||||
const sourceError = ref('')
|
const sourceError = ref('')
|
||||||
const selectedInstallSourceKey = ref('')
|
const selectedInstallSourceKey = ref('')
|
||||||
const selectedChangeSourceKey = ref('')
|
const selectedChangeSourceKey = ref('')
|
||||||
const showSourceChoices = ref(false)
|
const showSourceChoices = ref(props.initialSourceSelectionOpen)
|
||||||
const sourceChanging = ref(false)
|
const sourceChanging = ref(false)
|
||||||
|
|
||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
@@ -87,7 +91,16 @@ const onlineSourceCandidates = computed(() =>
|
|||||||
// 官方仓库是默认可信来源,在所有选源入口中始终置顶。
|
// 官方仓库是默认可信来源,在所有选源入口中始终置顶。
|
||||||
.sort((left, right) => Number(right.source_type === 'official') - Number(left.source_type === 'official')),
|
.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(() =>
|
const selectedInstallSource = computed(() =>
|
||||||
onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value),
|
onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value),
|
||||||
)
|
)
|
||||||
@@ -178,8 +191,13 @@ async function loadPluginSourceOptions(force = false) {
|
|||||||
)
|
)
|
||||||
if (!installSelectionStillExists) {
|
if (!installSelectionStillExists) {
|
||||||
const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official')
|
const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official')
|
||||||
|
const selectedCandidate = installCandidates.length === 1 ? installCandidates[0] : undefined
|
||||||
selectedInstallSourceKey.value =
|
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(
|
const changeSelectionStillExists = sourceActionCandidates.value.some(
|
||||||
@@ -202,15 +220,22 @@ async function confirmSourceTransition() {
|
|||||||
const bindingSource = !hasTrustedOnlineSource.value
|
const bindingSource = !hasTrustedOnlineSource.value
|
||||||
if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return
|
if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return
|
||||||
|
|
||||||
const confirmed = await createConfirm({
|
const confirmationContent = [
|
||||||
type: 'warn',
|
t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
|
||||||
title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'),
|
|
||||||
content: t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
|
|
||||||
name: props.plugin.plugin_name,
|
name: props.plugin.plugin_name,
|
||||||
current: trustedSourceLabel(),
|
current: trustedSourceLabel(),
|
||||||
target: sourceCandidateLabel(target),
|
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
|
if (!confirmed) return
|
||||||
|
|
||||||
@@ -322,6 +347,23 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
|||||||
|
|
||||||
const selectedRepoUrl = explicitSource?.repo_url || repoUrl
|
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) {
|
if (props.installHandler) {
|
||||||
versionHistoryDialogController?.close()
|
versionHistoryDialogController?.close()
|
||||||
versionHistoryDialogController = null
|
versionHistoryDialogController = null
|
||||||
@@ -502,20 +544,23 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<VAlert
|
<p
|
||||||
v-if="props.plugin?.system_version_compatible === false"
|
v-if="props.plugin?.system_version_compatible === false"
|
||||||
type="warning"
|
class="plugin-market-detail__compatibility"
|
||||||
variant="tonal"
|
role="alert"
|
||||||
density="compact"
|
>
|
||||||
class="plugin-market-detail__warning"
|
<VIcon icon="mdi-lock-outline" size="16" />
|
||||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
<span>{{ props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}</span>
|
||||||
/>
|
</p>
|
||||||
|
|
||||||
<section v-if="sourceSectionVisible" class="plugin-market-detail-source" aria-labelledby="plugin-source-title">
|
<section v-if="sourceSectionVisible" class="plugin-market-detail-source" aria-labelledby="plugin-source-title">
|
||||||
<div class="plugin-market-detail-source__heading">
|
<div class="plugin-market-detail-source__heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="plugin-source-title" class="plugin-market-detail-source__title">
|
<h3 id="plugin-source-title" class="plugin-market-detail-source__title">
|
||||||
{{ t('plugin.source') }}
|
{{ t('plugin.source') }}
|
||||||
|
<VChip v-if="sourceNeedsSelection" size="x-small" color="warning" variant="tonal">
|
||||||
|
{{ t(sourceHasConflict ? 'plugin.sourceSelectionRequired' : 'plugin.sourceConfirmationRequired') }}
|
||||||
|
</VChip>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="plugin-market-detail-source__hint">
|
<p class="plugin-market-detail-source__hint">
|
||||||
{{
|
{{
|
||||||
@@ -523,20 +568,27 @@ onUnmounted(() => {
|
|||||||
? t('plugin.sourceBindingHint')
|
? t('plugin.sourceBindingHint')
|
||||||
: isInstalled
|
: isInstalled
|
||||||
? t('plugin.sourceInstalledHint')
|
? t('plugin.sourceInstalledHint')
|
||||||
: t('plugin.sourceConflictHint')
|
: sourceHasConflict
|
||||||
|
? t('plugin.sourceConflictHint')
|
||||||
|
: t('plugin.sourceThirdPartyHint')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" />
|
<VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VAlert v-if="sourceError" type="warning" variant="tonal" density="compact" :text="sourceError" />
|
<p v-if="sourceError" class="plugin-market-detail-source__message--error" role="alert">
|
||||||
|
{{ sourceError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<template v-if="sourceOptions">
|
<template v-if="sourceOptions">
|
||||||
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
|
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
|
<dt>{{ t('plugin.trustedUpdateSource') }}</dt>
|
||||||
<dd>
|
<dd
|
||||||
|
class="plugin-market-detail-source__identity-content"
|
||||||
|
:class="{ 'plugin-market-detail-source__identity-content--no-action': !sourceActionCandidates.length || showSourceChoices }"
|
||||||
|
>
|
||||||
<span class="plugin-market-detail-source__identity-value">
|
<span class="plugin-market-detail-source__identity-value">
|
||||||
<VChip
|
<VChip
|
||||||
v-if="sourceOptions.identity.trusted_source_type === 'official'"
|
v-if="sourceOptions.identity.trusted_source_type === 'official'"
|
||||||
@@ -549,6 +601,20 @@ onUnmounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
{{ trustedSourceLabel() }}
|
{{ trustedSourceLabel() }}
|
||||||
</span>
|
</span>
|
||||||
|
<VBtn
|
||||||
|
v-if="sourceActionCandidates.length > 0 && !showSourceChoices"
|
||||||
|
class="plugin-market-detail-source__identity-action"
|
||||||
|
icon
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
:aria-label="t(hasTrustedOnlineSource ? 'plugin.changeSourceInline' : 'plugin.bindSourceInline')"
|
||||||
|
@click="showSourceChoices = true"
|
||||||
|
>
|
||||||
|
<VIcon :icon="hasTrustedOnlineSource ? 'mdi-source-branch' : 'mdi-shield-plus-outline'" size="18" />
|
||||||
|
<VTooltip activator="parent" location="top">
|
||||||
|
{{ t(hasTrustedOnlineSource ? 'plugin.changeSourceInline' : 'plugin.bindSourceInline') }}
|
||||||
|
</VTooltip>
|
||||||
|
</VBtn>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sourceOptions.identity.payload_source_type === 'local'">
|
<div v-if="sourceOptions.identity.payload_source_type === 'local'">
|
||||||
@@ -557,14 +623,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<VAlert
|
<p v-if="sourceUnavailable" class="plugin-market-detail-source__message--error" role="alert">
|
||||||
v-if="sourceNeedsSelection || sourceNeedsInitialBinding || sourceUnavailable"
|
{{ sourceOptions.selection_reason }}
|
||||||
:type="sourceNeedsSelection || sourceNeedsInitialBinding ? 'warning' : 'error'"
|
</p>
|
||||||
variant="tonal"
|
|
||||||
density="compact"
|
|
||||||
class="mb-3"
|
|
||||||
:text="sourceOptions.selection_reason"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VRadioGroup
|
<VRadioGroup
|
||||||
v-if="sourceNeedsSelection"
|
v-if="sourceNeedsSelection"
|
||||||
@@ -593,67 +654,61 @@ onUnmounted(() => {
|
|||||||
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span class="plugin-market-detail-source__choice-meta"
|
<span class="plugin-market-detail-source__choice-meta"
|
||||||
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span
|
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</VRadio>
|
</VRadio>
|
||||||
</VRadioGroup>
|
</VRadioGroup>
|
||||||
|
|
||||||
<div v-if="isInstalled && sourceActionCandidates.length > 0" class="plugin-market-detail-source__change">
|
<div
|
||||||
<VBtn
|
v-if="isInstalled && sourceActionCandidates.length > 0 && showSourceChoices"
|
||||||
v-if="!showSourceChoices"
|
class="plugin-market-detail-source__change"
|
||||||
size="small"
|
>
|
||||||
variant="text"
|
<VRadioGroup v-model="selectedChangeSourceKey" hide-details>
|
||||||
prepend-icon="mdi-source-branch"
|
<VRadio
|
||||||
@click="showSourceChoices = true"
|
v-for="candidate in sourceActionCandidates"
|
||||||
>
|
:key="candidate.source_key"
|
||||||
{{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }}
|
:value="candidate.source_key"
|
||||||
</VBtn>
|
>
|
||||||
<template v-else>
|
<template #label>
|
||||||
<VRadioGroup v-model="selectedChangeSourceKey" hide-details>
|
<span class="plugin-market-detail-source__choice-label">
|
||||||
<VRadio
|
<span class="plugin-market-detail-source__choice-title">
|
||||||
v-for="candidate in sourceActionCandidates"
|
<VChip
|
||||||
:key="candidate.source_key"
|
v-if="candidate.source_type === 'official'"
|
||||||
:value="candidate.source_key"
|
size="x-small"
|
||||||
>
|
color="primary"
|
||||||
<template #label>
|
variant="tonal"
|
||||||
<span class="plugin-market-detail-source__choice-label">
|
prepend-icon="mdi-shield-check"
|
||||||
<span class="plugin-market-detail-source__choice-title">
|
|
||||||
<VChip
|
|
||||||
v-if="candidate.source_type === 'official'"
|
|
||||||
size="x-small"
|
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
prepend-icon="mdi-shield-check"
|
|
||||||
>
|
|
||||||
{{ t('plugin.sourceOfficial') }}
|
|
||||||
</VChip>
|
|
||||||
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
|
||||||
</span>
|
|
||||||
<span class="plugin-market-detail-source__choice-meta"
|
|
||||||
>v{{ candidate.plugin_version || '-' }} ·
|
|
||||||
{{ candidate.package_generation.toUpperCase() }}</span
|
|
||||||
>
|
>
|
||||||
|
{{ t('plugin.sourceOfficial') }}
|
||||||
|
</VChip>
|
||||||
|
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
<span class="plugin-market-detail-source__choice-meta"
|
||||||
</VRadio>
|
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
|
||||||
</VRadioGroup>
|
>
|
||||||
<div class="plugin-market-detail-source__change-actions">
|
</span>
|
||||||
<VBtn size="small" variant="text" @click="showSourceChoices = false">
|
</template>
|
||||||
{{ t('common.cancel') }}
|
</VRadio>
|
||||||
</VBtn>
|
</VRadioGroup>
|
||||||
<VBtn
|
<div class="plugin-market-detail-source__change-actions">
|
||||||
size="small"
|
<VBtn size="small" variant="text" @click="showSourceChoices = false">
|
||||||
color="warning"
|
{{ t('common.cancel') }}
|
||||||
:loading="sourceChanging"
|
</VBtn>
|
||||||
:disabled="!selectedChangeSource"
|
<VBtn
|
||||||
@click="confirmSourceTransition"
|
size="small"
|
||||||
>
|
color="primary"
|
||||||
{{ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.bindSource') }}
|
variant="text"
|
||||||
</VBtn>
|
:loading="sourceChanging"
|
||||||
</div>
|
:disabled="!selectedChangeSource"
|
||||||
</template>
|
@click="confirmSourceTransition"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.confirmSourceBindAction')
|
||||||
|
}}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -758,6 +813,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-detail-source__title {
|
.plugin-market-detail-source__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -770,6 +828,13 @@ onUnmounted(() => {
|
|||||||
line-height: 1.45;
|
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 {
|
.plugin-market-detail-source__identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -797,11 +862,29 @@ onUnmounted(() => {
|
|||||||
text-align: end;
|
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__identity-value,
|
||||||
.plugin-market-detail-source__choice-title {
|
.plugin-market-detail-source__choice-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
@@ -922,8 +1005,22 @@ onUnmounted(() => {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-detail__warning {
|
.plugin-market-detail__compatibility {
|
||||||
margin-block-start: 1rem;
|
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 {
|
.plugin-market-detail-actions {
|
||||||
|
|||||||
@@ -359,14 +359,14 @@ watch(
|
|||||||
<template v-if="shouldShowUpdatePanel">
|
<template v-if="shouldShowUpdatePanel">
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VAlert
|
<p
|
||||||
v-if="resolvedPlugin?.system_version_compatible === false"
|
v-if="resolvedPlugin?.system_version_compatible === false"
|
||||||
type="warning"
|
class="plugin-version-history-dialog__compatibility"
|
||||||
variant="tonal"
|
role="alert"
|
||||||
density="compact"
|
>
|
||||||
class="mb-3"
|
<VIcon icon="mdi-lock-outline" size="16" />
|
||||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
<span>{{ resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}</span>
|
||||||
/>
|
</p>
|
||||||
<VBtn
|
<VBtn
|
||||||
@click="handleUpdate()"
|
@click="handleUpdate()"
|
||||||
block
|
block
|
||||||
@@ -395,6 +395,23 @@ watch(
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-version-history-dialog__compatibility {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: rgb(var(--v-theme-warning));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-version-history-dialog__compatibility .v-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-block-start: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-release-meta {
|
.plugin-release-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
await renderDialog({ ...basePlugin, installed: false })
|
await renderDialog({ ...basePlugin, installed: false })
|
||||||
|
|
||||||
expect(await screen.findByText('安装到本地')).toBeInTheDocument()
|
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.queryByText('提交评分')).not.toBeInTheDocument()
|
||||||
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -139,7 +143,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
...defaultSourceOptions,
|
...defaultSourceOptions,
|
||||||
identity: null,
|
identity: null,
|
||||||
selection_status: 'conflict',
|
selection_status: 'conflict',
|
||||||
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
|
selection_reason: '该插件存在多个在线来源,请确认来源后安装。',
|
||||||
candidates: [
|
candidates: [
|
||||||
defaultSourceOptions.candidates[0],
|
defaultSourceOptions.candidates[0],
|
||||||
{
|
{
|
||||||
@@ -156,7 +160,9 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
})
|
})
|
||||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
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: '安装到本地' })
|
const installButton = screen.getByRole('button', { name: '安装到本地' })
|
||||||
expect(installButton).toBeEnabled()
|
expect(installButton).toBeEnabled()
|
||||||
|
|
||||||
@@ -207,20 +213,21 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
})
|
})
|
||||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
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('jxxghp/moviepilot-plugins')).toBeInTheDocument()
|
||||||
expect(screen.getByText('官方')).toBeInTheDocument()
|
expect(screen.getByText('官方')).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.getByText('example/plugins'))
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '确认换源' }))
|
await fireEvent.click(screen.getByRole('button', { name: '确认更换' }))
|
||||||
|
|
||||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
icon: 'mdi-source-branch',
|
||||||
content: expect.stringContaining('jxxghp/moviepilot-plugins'),
|
content: expect.stringContaining('jxxghp/moviepilot-plugins'),
|
||||||
confirmText: '更换来源',
|
confirmText: '确认更换',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
|
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
|
||||||
@@ -237,7 +244,7 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
...defaultSourceOptions,
|
...defaultSourceOptions,
|
||||||
selection_status: 'unavailable',
|
selection_status: 'unavailable',
|
||||||
selection_reason: '当前来源身份没有可用候选',
|
selection_reason: '已绑定仓库中暂无可用插件包',
|
||||||
identity: {
|
identity: {
|
||||||
plugin_id: 'DemoPlugin',
|
plugin_id: 'DemoPlugin',
|
||||||
trusted_source_type: 'official',
|
trusted_source_type: 'official',
|
||||||
@@ -254,8 +261,8 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
})
|
})
|
||||||
await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||||
|
|
||||||
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
|
expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '更换来源' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '更换' })).toBeInTheDocument()
|
||||||
const updateButton = screen.getByRole('button', { name: '更新' })
|
const updateButton = screen.getByRole('button', { name: '更新' })
|
||||||
expect(updateButton).toBeDisabled()
|
expect(updateButton).toBeDisabled()
|
||||||
await fireEvent.click(updateButton)
|
await fireEvent.click(updateButton)
|
||||||
@@ -295,16 +302,17 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
})
|
})
|
||||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
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()
|
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.getByText('jxxghp/moviepilot-plugins'))
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' }))
|
await fireEvent.click(screen.getByRole('button', { name: '确认绑定' }))
|
||||||
|
|
||||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: '确认绑定插件来源',
|
icon: 'mdi-shield-check-outline',
|
||||||
confirmText: '绑定来源',
|
title: '确认绑定仓库',
|
||||||
|
confirmText: '确认绑定',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
|
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(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
expect(emitted().install).toBeUndefined()
|
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 () => {
|
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 })
|
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.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 已变化')))
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
|
||||||
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||||
@@ -485,13 +493,19 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
|
||||||
params: {
|
repo_url: 'https://github.com/example/plugins',
|
||||||
force: false,
|
release_version: undefined,
|
||||||
release_version: undefined,
|
force: false,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: '确认安装',
|
||||||
|
confirmText: '安装',
|
||||||
|
content: expect.stringContaining('后续更新也将使用该仓库'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||||
expect(emitted().install).toHaveLength(1)
|
expect(emitted().install).toHaveLength(1)
|
||||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
@@ -504,7 +518,11 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
|
|
||||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
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(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||||
expect(emitted().install).toBeUndefined()
|
expect(emitted().install).toBeUndefined()
|
||||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||||
@@ -565,11 +583,10 @@ describe('PluginMarketDetailDialog', () => {
|
|||||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
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.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
|
||||||
params: {
|
repo_url: 'https://github.com/example/plugins',
|
||||||
force: true,
|
release_version: '0.9.0',
|
||||||
release_version: '0.9.0',
|
force: true,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
expect(emitted().install).toHaveLength(1)
|
expect(emitted().install).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
it('requires source binding before a legacy plugin can update from history', async () => {
|
||||||
const legacySourceOptions: PluginSourceOptions = {
|
const legacySourceOptions: PluginSourceOptions = {
|
||||||
...sourceOptions,
|
...sourceOptions,
|
||||||
@@ -163,10 +189,10 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
actionMode: 'update',
|
actionMode: 'update',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: '绑定来源' })).toBeInTheDocument()
|
expect(await screen.findByRole('button', { name: '绑定仓库' })).toBeInTheDocument()
|
||||||
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
|
expect(await screen.findByText('当前插件尚未绑定,请选择仓库。')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: '安装' })).not.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().sourceAction).toEqual([[]])
|
||||||
expect(emitted().update).toBeUndefined()
|
expect(emitted().update).toBeUndefined()
|
||||||
@@ -249,7 +275,7 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
actionMode: 'update',
|
actionMode: 'update',
|
||||||
})
|
})
|
||||||
|
|
||||||
const bindButton = await screen.findByRole('button', { name: '绑定来源' })
|
const bindButton = await screen.findByRole('button', { name: '绑定仓库' })
|
||||||
await fireEvent.click(bindButton)
|
await fireEvent.click(bindButton)
|
||||||
|
|
||||||
expect(emitted().sourceAction).toEqual([[]])
|
expect(emitted().sourceAction).toEqual([[]])
|
||||||
@@ -299,7 +325,7 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
const unavailableSourceOptions: PluginSourceOptions = {
|
const unavailableSourceOptions: PluginSourceOptions = {
|
||||||
...sourceOptions,
|
...sourceOptions,
|
||||||
selection_status: 'unavailable',
|
selection_status: 'unavailable',
|
||||||
selection_reason: '当前来源身份没有可用候选',
|
selection_reason: '已绑定仓库中暂无可用插件包',
|
||||||
candidates: [],
|
candidates: [],
|
||||||
}
|
}
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
@@ -317,7 +343,7 @@ describe('PluginVersionHistoryDialog', () => {
|
|||||||
actionMode: 'update',
|
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(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument()
|
||||||
expect(emitted().update).toBeUndefined()
|
expect(emitted().update).toBeUndefined()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
|||||||
/** 主应用确认弹窗支持的配置项。 */
|
/** 主应用确认弹窗支持的配置项。 */
|
||||||
export interface ConfirmOptions {
|
export interface ConfirmOptions {
|
||||||
type?: 'info' | 'warn' | 'error'
|
type?: 'info' | 'warn' | 'error'
|
||||||
|
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
|
||||||
|
icon?: string
|
||||||
title?: string
|
title?: string
|
||||||
content?: string
|
content?: string
|
||||||
confirmText?: string
|
confirmText?: string
|
||||||
|
|||||||
+38
-23
@@ -3841,6 +3841,9 @@ export default {
|
|||||||
installed: 'Installed',
|
installed: 'Installed',
|
||||||
notInstalled: 'Not Installed',
|
notInstalled: 'Not Installed',
|
||||||
hasUpdate: 'Update Available',
|
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',
|
configuring: 'Configuring',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
@@ -3856,33 +3859,45 @@ 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',
|
source: 'Source',
|
||||||
sourceOfficial: 'Official',
|
sourceOfficial: 'Official',
|
||||||
sourceUnknown: 'Unknown source',
|
sourceUnknown: 'Unknown',
|
||||||
sourceUnbound: 'Not bound',
|
sourceUnbound: 'Not bound',
|
||||||
sourceLoadFailed: 'Unable to load plugin sources. Try again later.',
|
sourceLoadFailed: 'Source information is temporarily unavailable. Try again later.',
|
||||||
sourceUnavailable: 'No plugin source is currently safe to install',
|
sourceUnavailable: 'No repository is currently available',
|
||||||
sourceInstalledHint: 'Automatic updates only use the bound trusted repository.',
|
sourceInstalledHint: 'Plugins are updated only from the bound repository.',
|
||||||
sourceConflictHint: 'Multiple plugins share this ID. Choose the repository you trust.',
|
sourceBindingRequired: 'Repository confirmation required',
|
||||||
sourceBindingHint: 'This plugin has no automatic update source yet. Choose a repository you trust.',
|
sourceBindingRequiredHint: 'This plugin has no bound repository; open About to confirm',
|
||||||
trustedUpdateSource: 'Automatic update source',
|
sourceConflictHint: 'Multiple plugins share this ID. Choose a repository.',
|
||||||
currentPayloadSource: 'Current payload',
|
sourceThirdPartyHint: 'This plugin comes from a third-party repository. Confirm before installing.',
|
||||||
selectSourceRequired: 'Choose a plugin source before installing',
|
sourceSelectionRequired: 'Selection required',
|
||||||
bindSource: 'Bind Source',
|
sourceConfirmationRequired: 'Confirmation required',
|
||||||
confirmSourceBindTitle: 'Confirm Plugin Source',
|
sourceBindingHint: 'This plugin is not bound. Choose a repository.',
|
||||||
confirmSourceBind:
|
trustedUpdateSource: 'Repository',
|
||||||
'Bind the automatic update source for {name} to {target}. The current version from that source will be installed immediately.',
|
currentPayloadSource: 'Runtime source',
|
||||||
bindingSource: 'Binding the plugin source for {name}...',
|
selectSourceRequired: 'Choose a repository before installing.',
|
||||||
sourceBindSuccess: 'The source for plugin {name} was bound',
|
bindSource: 'Bind Repository',
|
||||||
sourceBindFailed: 'Failed to bind the source for plugin {name}: {message}',
|
bindSourceInline: 'Bind',
|
||||||
changeSource: 'Change Source',
|
confirmThirdPartyInstallTitle: 'Confirm Installation',
|
||||||
confirmSourceChangeTitle: 'Confirm Plugin Source Change',
|
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:
|
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',
|
confirmSourceChangeAction: 'Confirm Change',
|
||||||
changingSource: 'Changing the plugin source for {name}...',
|
changingSource: 'Changing the repository for {name}...',
|
||||||
sourceChangeSuccess: 'The source for plugin {name} was changed',
|
sourceChangeSuccess: 'The repository for {name} was changed',
|
||||||
sourceChangeFailed: 'Failed to change the source for plugin {name}: {message}',
|
sourceChangeFailed: 'Failed to change the repository for {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',
|
||||||
|
|||||||
+39
-25
@@ -3778,6 +3778,9 @@ export default {
|
|||||||
installed: '已安装',
|
installed: '已安装',
|
||||||
notInstalled: '未安装',
|
notInstalled: '未安装',
|
||||||
hasUpdate: '有更新',
|
hasUpdate: '有更新',
|
||||||
|
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
|
||||||
|
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
|
||||||
|
viewUpdateSources: '查看更新来源',
|
||||||
configuring: '配置',
|
configuring: '配置',
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
@@ -3793,32 +3796,43 @@ export default {
|
|||||||
confirmInstallOldRelease:
|
confirmInstallOldRelease:
|
||||||
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
source: '插件来源',
|
source: '来源',
|
||||||
sourceOfficial: '官方',
|
sourceOfficial: '官方',
|
||||||
sourceUnknown: '未知来源',
|
sourceUnknown: '未知',
|
||||||
sourceUnbound: '尚未绑定',
|
sourceUnbound: '未绑定',
|
||||||
sourceLoadFailed: '无法读取插件来源,请稍后重试',
|
sourceLoadFailed: '来源信息暂时无法读取,请稍后重试',
|
||||||
sourceUnavailable: '当前没有可安全安装的插件来源',
|
sourceUnavailable: '当前没有可用仓库',
|
||||||
sourceInstalledHint: '自动更新只会使用已绑定的可信仓库。',
|
sourceInstalledHint: '插件只会从已绑定仓库更新。',
|
||||||
sourceConflictHint: '检测到多个同名插件,请明确选择要信任的仓库。',
|
sourceBindingRequired: '需确认仓库',
|
||||||
sourceBindingHint: '当前插件尚未绑定自动更新来源,请选择可信仓库。',
|
sourceBindingRequiredHint: '该插件尚未绑定仓库,请在「关于」中确认',
|
||||||
trustedUpdateSource: '自动更新来源',
|
sourceConflictHint: '检测到多个同名插件,请选择仓库。',
|
||||||
currentPayloadSource: '当前载荷',
|
sourceThirdPartyHint: '该插件来自第三方仓库,请确认后安装。',
|
||||||
selectSourceRequired: '请选择插件来源后再安装',
|
sourceSelectionRequired: '需选择',
|
||||||
bindSource: '绑定来源',
|
sourceConfirmationRequired: '需确认',
|
||||||
confirmSourceBindTitle: '确认绑定插件来源',
|
sourceBindingHint: '当前插件尚未绑定,请选择仓库。',
|
||||||
confirmSourceBind: '将插件 {name} 的自动更新来源绑定为 {target}。确认后会立即安装该来源的当前版本。',
|
trustedUpdateSource: '仓库',
|
||||||
bindingSource: '正在绑定 {name} 的插件来源...',
|
currentPayloadSource: '运行来源',
|
||||||
sourceBindSuccess: '插件 {name} 的来源已绑定',
|
selectSourceRequired: '请选择仓库后再安装。',
|
||||||
sourceBindFailed: '插件 {name} 绑定来源失败:{message}',
|
bindSource: '绑定仓库',
|
||||||
changeSource: '更换来源',
|
bindSourceInline: '绑定',
|
||||||
confirmSourceChangeTitle: '确认更换插件来源',
|
confirmThirdPartyInstallTitle: '确认安装',
|
||||||
confirmSourceChange:
|
confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
|
||||||
'将插件 {name} 的自动更新来源从 {current} 更换为 {target}。确认后会立即安装目标来源的当前版本。',
|
confirmThirdPartyInstallAction: '安装',
|
||||||
confirmSourceChangeAction: '确认换源',
|
confirmSourceBindTitle: '确认绑定仓库',
|
||||||
changingSource: '正在更换 {name} 的插件来源...',
|
confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
|
||||||
sourceChangeSuccess: '插件 {name} 的来源已更换',
|
thirdPartySourceRisk: '这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
|
||||||
sourceChangeFailed: '插件 {name} 换源失败:{message}',
|
confirmSourceBindAction: '确认绑定',
|
||||||
|
bindingSource: '正在从所选仓库安装 {name}...',
|
||||||
|
sourceBindSuccess: '插件 {name} 已绑定仓库',
|
||||||
|
sourceBindFailed: '插件 {name} 绑定仓库失败:{message}',
|
||||||
|
changeSource: '更换仓库',
|
||||||
|
changeSourceInline: '更换',
|
||||||
|
confirmSourceChangeTitle: '确认更换仓库',
|
||||||
|
confirmSourceChange: '将插件「{name}」的仓库从 {current} 更换为 {target},确认后会立即安装目标仓库的当前版本。',
|
||||||
|
confirmSourceChangeAction: '确认更换',
|
||||||
|
changingSource: '正在更换 {name} 的仓库...',
|
||||||
|
sourceChangeSuccess: '插件 {name} 的仓库已更换',
|
||||||
|
sourceChangeFailed: '插件 {name} 更换仓库失败:{message}',
|
||||||
systemVersion: '系统版本',
|
systemVersion: '系统版本',
|
||||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||||
installToLocal: '安装到本地',
|
installToLocal: '安装到本地',
|
||||||
|
|||||||
+39
-25
@@ -3777,6 +3777,9 @@ export default {
|
|||||||
installed: '已安裝',
|
installed: '已安裝',
|
||||||
notInstalled: '未安裝',
|
notInstalled: '未安裝',
|
||||||
hasUpdate: '可更新',
|
hasUpdate: '可更新',
|
||||||
|
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
|
||||||
|
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
|
||||||
|
viewUpdateSources: '查看更新來源',
|
||||||
configuring: '配置中',
|
configuring: '配置中',
|
||||||
enable: '啟用',
|
enable: '啟用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
@@ -3792,32 +3795,43 @@ export default {
|
|||||||
confirmInstallOldRelease:
|
confirmInstallOldRelease:
|
||||||
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
source: '插件來源',
|
source: '來源',
|
||||||
sourceOfficial: '官方',
|
sourceOfficial: '官方',
|
||||||
sourceUnknown: '未知來源',
|
sourceUnknown: '未知',
|
||||||
sourceUnbound: '尚未綁定',
|
sourceUnbound: '未綁定',
|
||||||
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
|
sourceLoadFailed: '來源資訊暫時無法讀取,請稍後重試',
|
||||||
sourceUnavailable: '目前沒有可安全安裝的插件來源',
|
sourceUnavailable: '目前沒有可用倉庫',
|
||||||
sourceInstalledHint: '自動更新只會使用已綁定的可信倉庫。',
|
sourceInstalledHint: '插件只會從已綁定倉庫更新。',
|
||||||
sourceConflictHint: '偵測到多個同名插件,請明確選擇要信任的倉庫。',
|
sourceBindingRequired: '需確認倉庫',
|
||||||
sourceBindingHint: '目前插件尚未綁定自動更新來源,請選擇可信倉庫。',
|
sourceBindingRequiredHint: '此插件尚未綁定倉庫,請在「關於」中確認',
|
||||||
trustedUpdateSource: '自動更新來源',
|
sourceConflictHint: '偵測到多個同名插件,請選擇倉庫。',
|
||||||
currentPayloadSource: '目前載荷',
|
sourceThirdPartyHint: '該插件來自第三方倉庫,請確認後安裝。',
|
||||||
selectSourceRequired: '請選擇插件來源後再安裝',
|
sourceSelectionRequired: '需選擇',
|
||||||
bindSource: '綁定來源',
|
sourceConfirmationRequired: '需確認',
|
||||||
confirmSourceBindTitle: '確認綁定插件來源',
|
sourceBindingHint: '目前插件尚未綁定,請選擇倉庫。',
|
||||||
confirmSourceBind: '將插件 {name} 的自動更新來源綁定為 {target}。確認後會立即安裝該來源的目前版本。',
|
trustedUpdateSource: '倉庫',
|
||||||
bindingSource: '正在綁定 {name} 的插件來源...',
|
currentPayloadSource: '運行來源',
|
||||||
sourceBindSuccess: '插件 {name} 的來源已綁定',
|
selectSourceRequired: '請選擇倉庫後再安裝。',
|
||||||
sourceBindFailed: '插件 {name} 綁定來源失敗:{message}',
|
bindSource: '綁定倉庫',
|
||||||
changeSource: '更換來源',
|
bindSourceInline: '綁定',
|
||||||
confirmSourceChangeTitle: '確認更換插件來源',
|
confirmThirdPartyInstallTitle: '確認安裝',
|
||||||
confirmSourceChange:
|
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
|
||||||
'將插件 {name} 的自動更新來源從 {current} 更換為 {target}。確認後會立即安裝目標來源的目前版本。',
|
confirmThirdPartyInstallAction: '安裝',
|
||||||
confirmSourceChangeAction: '確認換源',
|
confirmSourceBindTitle: '確認綁定倉庫',
|
||||||
changingSource: '正在更換 {name} 的插件來源...',
|
confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
|
||||||
sourceChangeSuccess: '插件 {name} 的來源已更換',
|
thirdPartySourceRisk: '這是第三方倉庫,後續更新將來自該倉庫,請確認後繼續。',
|
||||||
sourceChangeFailed: '插件 {name} 換源失敗:{message}',
|
confirmSourceBindAction: '確認綁定',
|
||||||
|
bindingSource: '正在從所選倉庫安裝 {name}...',
|
||||||
|
sourceBindSuccess: '插件 {name} 已綁定倉庫',
|
||||||
|
sourceBindFailed: '插件 {name} 綁定倉庫失敗:{message}',
|
||||||
|
changeSource: '更換倉庫',
|
||||||
|
changeSourceInline: '更換',
|
||||||
|
confirmSourceChangeTitle: '確認更換倉庫',
|
||||||
|
confirmSourceChange: '將插件「{name}」的倉庫從 {current} 更換為 {target},確認後會立即安裝目標倉庫的目前版本。',
|
||||||
|
confirmSourceChangeAction: '確認更換',
|
||||||
|
changingSource: '正在更換 {name} 的倉庫...',
|
||||||
|
sourceChangeSuccess: '插件 {name} 的倉庫已更換',
|
||||||
|
sourceChangeFailed: '插件 {name} 更換倉庫失敗:{message}',
|
||||||
installToLocal: '安裝到本地',
|
installToLocal: '安裝到本地',
|
||||||
totalDownloads: '共 {count} 次下載',
|
totalDownloads: '共 {count} 次下載',
|
||||||
rating: '插件評分',
|
rating: '插件評分',
|
||||||
|
|||||||
@@ -1097,6 +1097,7 @@ function mergeMarketMetadataIntoInstalled() {
|
|||||||
dataList.value.forEach(plugin => {
|
dataList.value.forEach(plugin => {
|
||||||
const marketPlugin = marketById.get(plugin.id)
|
const marketPlugin = marketById.get(plugin.id)
|
||||||
plugin.has_update = Boolean(marketPlugin)
|
plugin.has_update = Boolean(marketPlugin)
|
||||||
|
plugin.update_candidate = marketPlugin?.update_candidate ?? null
|
||||||
if (!marketPlugin) return
|
if (!marketPlugin) return
|
||||||
|
|
||||||
plugin.repo_url = marketPlugin.repo_url
|
plugin.repo_url = marketPlugin.repo_url
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ const PluginMixedSortCardStub = defineComponent({
|
|||||||
plugin_name?: string
|
plugin_name?: string
|
||||||
repo_url?: string
|
repo_url?: string
|
||||||
runtime_status?: Plugin['runtime_status']
|
runtime_status?: Plugin['runtime_status']
|
||||||
|
update_candidate?: Plugin['update_candidate']
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
const name = type === 'folder' ? id : data?.plugin_name || id
|
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': `update-${id}` }, String(data?.has_update ?? false))
|
||||||
: h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''),
|
: 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': `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': `runtime-${id}` }, data?.runtime_status || '') : null,
|
||||||
type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : 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,
|
type === 'plugin' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null,
|
||||||
@@ -760,15 +764,31 @@ describe('PluginCardListView loading and request ownership', () => {
|
|||||||
market: () => {
|
market: () => {
|
||||||
marketRequest += 1
|
marketRequest += 1
|
||||||
return 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'))
|
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 fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' }))
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
|
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
|
||||||
|
expect(screen.getByLabelText('update-source-Shared')).toBeEmptyDOMElement()
|
||||||
expect(marketRequest).toBe(2)
|
expect(marketRequest).toBe(2)
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
})
|
})
|
||||||
@@ -1458,7 +1478,7 @@ describe('PluginCardListView search installation', () => {
|
|||||||
plugin_id: pluginId,
|
plugin_id: pluginId,
|
||||||
inventory_complete: true,
|
inventory_complete: true,
|
||||||
selection_status: 'conflict',
|
selection_status: 'conflict',
|
||||||
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
|
selection_reason: '该插件存在多个在线来源,请确认来源后安装。',
|
||||||
identity: null,
|
identity: null,
|
||||||
candidates: [
|
candidates: [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -264,7 +264,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:3001',
|
target: process.env.MOVIEPILOT_DEV_PROXY_TARGET || 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: 'localhost',
|
cookieDomainRewrite: 'localhost',
|
||||||
|
|||||||
Reference in New Issue
Block a user