From 06c711bb033b5560b853e1210d45eec00c307987 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=A2=9E=E5=8A=A0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=9D=A5=E6=BA=90=E7=BB=91=E5=AE=9A=E4=B8=8E=E6=8D=A2?= =?UTF-8?q?=E6=BA=90=E7=95=8C=E9=9D=A2=20(#722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/__tests__/pluginSource.spec.ts | 80 ++++ src/api/pluginSource.ts | 24 + src/api/types.ts | 81 ++++ src/components/cards/PluginAppCard.vue | 1 - src/components/cards/PluginCard.vue | 19 +- .../__tests__/PluginAppCardRating.spec.ts | 1 - .../cards/__tests__/PluginCard.spec.ts | 22 +- .../cards/__tests__/PluginCardAbout.spec.ts | 26 ++ .../dialog/PluginMarketDetailDialog.vue | 431 +++++++++++++++++- .../dialog/PluginVersionHistoryDialog.vue | 132 +++++- .../PluginMarketDetailDialog.spec.ts | 303 +++++++++++- .../PluginVersionHistoryDialog.spec.ts | 298 +++++++++++- src/locales/en-US.ts | 26 ++ src/locales/zh-CN.ts | 25 + src/locales/zh-TW.ts | 25 + src/views/plugin/PluginCardListView.vue | 85 ++-- .../__tests__/PluginCardListView.spec.ts | 85 +++- 17 files changed, 1562 insertions(+), 102 deletions(-) create mode 100644 src/api/__tests__/pluginSource.spec.ts create mode 100644 src/api/pluginSource.ts diff --git a/src/api/__tests__/pluginSource.spec.ts b/src/api/__tests__/pluginSource.spec.ts new file mode 100644 index 00000000..33bfdf28 --- /dev/null +++ b/src/api/__tests__/pluginSource.spec.ts @@ -0,0 +1,80 @@ +import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource' +import { ApiRequestError } from '@/api/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + post: (...args: unknown[]) => mocks.apiPost(...args), + }), +})) + +describe('plugin source API adapters', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.apiGet.mockResolvedValue({ + plugin_id: 'DemoPlugin', + inventory_complete: true, + selection_status: 'selected', + selection_reason: '', + identity: null, + candidates: [], + }) + mocks.apiPost.mockResolvedValue(undefined) + }) + + it('查询来源候选时编码插件 ID,并按需传递强制刷新参数', async () => { + await getPluginSourceOptions('Demo Plugin', true) + + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/Demo%20Plugin/options', { params: { force: true } }) + }) + + it('默认查询不伪造 force 参数', async () => { + await getPluginSourceOptions('DemoPlugin') + + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options') + }) + + it('按明确来源安装和 CAS 换源分别发送对应请求体', async () => { + const installRequest = { + repo_url: 'https://github.com/example/plugins', + release_version: '1.2.3', + force: true, + } + const changeRequest = { + repo_url: 'https://github.com/other/plugins', + expected_revision: 7, + release_version: null, + } + + await installPluginFromSource('DemoPlugin', installRequest) + await changePluginSource('DemoPlugin', changeRequest) + + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'plugin/source/DemoPlugin/install', installRequest) + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'plugin/source/DemoPlugin', changeRequest) + }) + + it('不吞掉后端业务失败,调用方仍可捕获 ApiRequestError', async () => { + mocks.apiPost.mockResolvedValueOnce({ + success: false, + message: '来源 revision 已变化,请重新确认', + data: null, + }) + + const error = await installPluginFromSource('DemoPlugin', { + repo_url: 'https://github.com/example/plugins', + }).catch(reason => reason) + + expect(error).toBeInstanceOf(ApiRequestError) + expect(error).toMatchObject({ + message: '来源 revision 已变化,请重新确认', + businessFailure: true, + }) + }) +}) diff --git a/src/api/pluginSource.ts b/src/api/pluginSource.ts new file mode 100644 index 00000000..86d659d9 --- /dev/null +++ b/src/api/pluginSource.ts @@ -0,0 +1,24 @@ +import api from './index' +import type { PluginSourceChangeRequest, PluginSourceInstallRequest, PluginSourceOptions } from './types' + +/** 查询插件来源候选、当前绑定身份和来源准入状态。 */ +export function getPluginSourceOptions(pluginId: string, force = false): Promise { + return api.get(`plugin/source/${encodeURIComponent(pluginId)}/options`, { + ...(force ? { params: { force: true } } : {}), + feedback: 'silent', + }) +} + +/** 按管理员明确选择的在线来源安装未绑定插件。 */ +export function installPluginFromSource(pluginId: string, request: PluginSourceInstallRequest): Promise { + return api.post(`plugin/source/${encodeURIComponent(pluginId)}/install`, request, { + feedback: 'silent', + }) +} + +/** 按当前身份 revision 进行 CAS 保护的在线来源切换。 */ +export function changePluginSource(pluginId: string, request: PluginSourceChangeRequest): Promise { + return api.post(`plugin/source/${encodeURIComponent(pluginId)}`, request, { + feedback: 'silent', + }) +} diff --git a/src/api/types.ts b/src/api/types.ts index 80d4e0eb..3b11e775 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -993,6 +993,87 @@ export interface Plugin { instance_mode?: 'virtual' } +/** 已绑定插件可用于自动更新的在线来源类型。 */ +export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party' + +/** 最近一次已应用插件载荷的来源类型,包含本地开发来源。 */ +export type PluginPayloadSourceType = PluginTrustedSourceType | 'local' + +/** 已安装插件来源身份建立时采用的依据。 */ +export type PluginSourceBindingBasis = + 'legacy_unbound' | 'local_only' | 'official_default' | 'tofu' | 'explicit_install' | 'explicit_source_change' + +/** 来源候选的展示类型;本地候选只展示为本地,不暴露路径。 */ +export type PluginSourceCandidateType = 'official' | 'third_party' | 'local' + +/** 显式换源确认所需的当前可信来源身份和并发控制版本。 */ +export interface PluginSourceIdentity { + // 物理插件 ID + plugin_id: string + // 当前可信在线来源类型 + trusted_source_type: PluginTrustedSourceType + // 规范化的可信在线来源键;未绑定时为空 + trusted_source_key?: string | null + // 当前可信来源的建立依据 + binding_basis: PluginSourceBindingBasis + // 最近一次已提交载荷的来源类型 + payload_source_type: PluginPayloadSourceType + // 最近一次在线载荷的来源键;本地或未知载荷为空 + payload_source_key?: string | null + // 显式换源使用的身份 CAS revision + revision: number +} + +/** 一个可供管理员识别的脱敏插件来源候选。 */ +export interface PluginSourceCandidate { + // 来源类型;本地候选不公开路径 + source_type: PluginSourceCandidateType + // 规范化在线来源键;本地候选为空 + source_key?: string | null + // 可明确选择的在线仓库地址;本地候选为空 + repo_url?: string | null + // 当前运行时会采用的插件包代际 + package_generation: 'v1' | 'v2' | 'v3' + // 该来源当前可安装的插件版本 + plugin_version?: string | null +} + +/** 来源选择界面所需的当前身份、候选和准入状态。 */ +export interface PluginSourceOptions { + // 物理插件 ID + plugin_id: string + // 本轮配置市场是否全部得到确定读取结果 + inventory_complete: boolean + // 未指定新来源时的当前准入状态 + selection_status: 'selected' | 'unavailable' | 'conflict' | 'incomplete' + // 当前准入状态的人类可读原因 + selection_reason: string + // 已安装插件的来源身份;未建立身份时为空 + identity?: PluginSourceIdentity | null + // 按来源归并后的在线候选及可选本地候选 + candidates: PluginSourceCandidate[] +} + +/** 管理员为未绑定插件明确选择初始在线来源的请求参数。 */ +export interface PluginSourceInstallRequest { + // 明确选择的目标插件仓库地址 + repo_url: string + // 指定安装的 Release 资产版本;为空时使用当前索引版本 + release_version?: string | null + // 是否强制重新下载并安装所选来源载荷 + force?: boolean +} + +/** 管理员显式切换插件在线来源的请求参数。 */ +export interface PluginSourceChangeRequest { + // 明确选择的目标插件仓库地址 + repo_url: string + // 提交换源时必须匹配的当前身份 revision + expected_revision: number + // 指定安装的 Release 资产版本;为空时使用当前索引版本 + release_version?: string | null +} + export interface PluginRuntimeSummary { // 本轮插件源码、依赖和加载是否已收敛 ready: boolean diff --git a/src/components/cards/PluginAppCard.vue b/src/components/cards/PluginAppCard.vue index 59c70cc9..9cf28813 100644 --- a/src/components/cards/PluginAppCard.vue +++ b/src/components/cards/PluginAppCard.vue @@ -167,7 +167,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) { await api.get(`plugin/install/${props.plugin?.id}`, { feedback: 'silent', params: { - repo_url: repoUrl || props.plugin?.repo_url, release_version: releaseVersion, force: props.plugin?.has_update || Boolean(releaseVersion), }, diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue index 17a09d2a..877975ac 100644 --- a/src/components/cards/PluginCard.vue +++ b/src/components/cards/PluginCard.vue @@ -157,11 +157,18 @@ function showUpdateHistory(showUpdateAction: boolean = false) { versionHistoryDialogController = openSharedDialog( PluginVersionHistoryDialog, { plugin: props.plugin, showUpdateAction }, - { update: updatePlugin }, + { update: updatePlugin, sourceAction: openSourceAction }, { closeOn: ['close', 'update:modelValue'] }, ) } +/** 打开能够完成来源绑定或切换的管理界面。 */ +async function openSourceAction() { + versionHistoryDialogController?.close() + versionHistoryDialogController = null + await showPluginAbout() +} + // 调用API卸载插件 async function uninstallPlugin() { const isConfirmed = await createConfirm({ @@ -267,7 +274,7 @@ async function resetPlugin() { } // 更新插件 -async function updatePlugin(releaseVersion?: string, repoUrl?: string) { +async function updatePlugin(releaseVersion?: string) { if (!releaseVersion && props.plugin?.system_version_compatible === false) { $toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')) return @@ -296,7 +303,6 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) { await api.get(`plugin/install/${props.plugin?.id}`, { feedback: 'silent', params: { - repo_url: repoUrl || props.plugin?.repo_url, release_version: releaseVersion, force: true, }, @@ -476,12 +482,7 @@ function showPluginClone() { } // 执行插件分身 -async function executePluginClone(cloneForm: { - suffix: string - name: string - description: string - icon: string -}) { +async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; icon: string }) { if (!cloneForm.suffix.trim()) { $toast.error(t('plugin.suffixRequired')) return diff --git a/src/components/cards/__tests__/PluginAppCardRating.spec.ts b/src/components/cards/__tests__/PluginAppCardRating.spec.ts index 75e8a5c5..791f6d07 100644 --- a/src/components/cards/__tests__/PluginAppCardRating.spec.ts +++ b/src/components/cards/__tests__/PluginAppCardRating.spec.ts @@ -174,7 +174,6 @@ describe('PluginAppCard rating badge', () => { params: { force: true, release_version: '0.9.0', - repo_url: 'https://github.com/example/releases', }, }) expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!') diff --git a/src/components/cards/__tests__/PluginCard.spec.ts b/src/components/cards/__tests__/PluginCard.spec.ts index 2ac1bb99..9d6dd62e 100644 --- a/src/components/cards/__tests__/PluginCard.spec.ts +++ b/src/components/cards/__tests__/PluginCard.spec.ts @@ -189,7 +189,6 @@ describe('PluginCard lifecycle actions', () => { params: { force: true, release_version: '0.9.0', - repo_url: 'https://github.com/example/releases', }, }) expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') })) @@ -257,12 +256,7 @@ describe('PluginCard lifecycle actions', () => { await fireEvent.click(container.querySelector('.v-card .v-btn')!) await fireEvent.click(await screen.findByText('分身')) const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as { - clone: (form: { - suffix: string - name: string - description: string - icon: string - }) => Promise + clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise } await cloneEvents.clone({ suffix: ' Test ', @@ -289,12 +283,7 @@ describe('PluginCard lifecycle actions', () => { await fireEvent.click(container.querySelector('.v-card .v-btn')!) await fireEvent.click(await screen.findByText('分身')) const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as { - clone: (form: { - suffix: string - name: string - description: string - icon: string - }) => Promise + clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise } await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' }) @@ -308,12 +297,7 @@ describe('PluginCard lifecycle actions', () => { await fireEvent.click(businessFailed.container.querySelector('.v-card .v-btn')!) await fireEvent.click(await screen.findByText('分身')) let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as { - clone: (form: { - suffix: string - name: string - description: string - icon: string - }) => Promise + clone: (form: { suffix: string; name: string; description: string; icon: string }) => Promise } const form = { suffix: 'Test', name: '测试', description: '', icon: '' } await cloneEvents.clone(form) diff --git a/src/components/cards/__tests__/PluginCardAbout.spec.ts b/src/components/cards/__tests__/PluginCardAbout.spec.ts index db3157c5..59a5e822 100644 --- a/src/components/cards/__tests__/PluginCardAbout.spec.ts +++ b/src/components/cards/__tests__/PluginCardAbout.spec.ts @@ -98,6 +98,32 @@ describe('PluginCard about menu', () => { expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true) }) + it('routes a history source action into the source management dialog', async () => { + const { container } = await renderWithProviders(PluginCard, { + props: { plugin: { ...plugin, has_update: true }, count: 24 }, + }) + + await fireEvent.click(container.querySelector('.v-card .v-btn')!) + await fireEvent.click(await screen.findByText('更新')) + + const historyEvents = mocks.openSharedDialog.mock.calls[0][2] as { + sourceAction: () => Promise + } + expect(historyEvents.sourceAction).toBeTypeOf('function') + await historyEvents.sourceAction() + + expect(mocks.closeDialog).toHaveBeenCalled() + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', { + params: { force: false }, + }) + expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2) + expect(mocks.openSharedDialog.mock.calls[1][1].plugin).toMatchObject({ + id: 'DemoPlugin', + installed: true, + repo_url: 'https://github.com/example/plugins', + }) + }) + it('resolves a missing installed repo from market metadata before opening the project page', async () => { mocks.apiGet.mockImplementation((url: string) => { if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' }) diff --git a/src/components/dialog/PluginMarketDetailDialog.vue b/src/components/dialog/PluginMarketDetailDialog.vue index 203a600f..3523b6a8 100644 --- a/src/components/dialog/PluginMarketDetailDialog.vue +++ b/src/components/dialog/PluginMarketDetailDialog.vue @@ -1,7 +1,8 @@