From 775977a3787e1553f935e79b6d013cf36cc781b4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 5 Sep 2026 15:11:02 +0800 Subject: [PATCH] feat(ui): complete structured API capability entries --- eslint-suppressions.json | 36 +- src/api/__tests__/customIdentifiers.spec.ts | 51 ++ src/api/__tests__/history.spec.ts | 26 + src/api/__tests__/mediaServer.spec.ts | 41 ++ src/api/__tests__/pluginCapabilities.spec.ts | 81 ++++ src/api/__tests__/pluginData.spec.ts | 80 ++++ src/api/__tests__/pluginFolders.spec.ts | 71 +++ src/api/__tests__/rule.spec.ts | 102 ++++ src/api/__tests__/site.spec.ts | 24 + src/api/__tests__/storage.spec.ts | 45 ++ src/api/__tests__/subscription.spec.ts | 57 +++ src/api/__tests__/systemSettings.spec.ts | 57 +++ src/api/customIdentifiers.ts | 34 ++ src/api/history.ts | 6 + src/api/mediaServer.ts | 16 + src/api/pluginCapabilities.ts | 117 +++++ src/api/pluginData.ts | 81 ++++ src/api/pluginFolders.ts | 95 ++++ src/api/rule.ts | 153 ++++++ src/api/site.ts | 11 + src/api/storage.ts | 36 ++ src/api/subscription.ts | 47 ++ src/api/systemSettings.ts | 42 ++ src/api/types.ts | 97 ++++ src/components/cards/DownloadingCard.vue | 37 +- src/components/cards/PluginCard.vue | 78 ++- src/components/cards/PluginFolderCard.vue | 43 +- src/components/cards/PluginMixedSortCard.vue | 8 +- src/components/cards/SubscribeCard.vue | 7 +- .../cards/__tests__/DownloadingCard.spec.ts | 15 +- .../cards/__tests__/PluginCard.spec.ts | 92 ++++ .../cards/__tests__/PluginFolderCard.spec.ts | 21 +- src/components/dialog/AddDownloadDialog.vue | 22 +- .../dialog/AddSubtitleDownloadDialog.vue | 21 +- .../dialog/DownloadTaskSettingsDialog.vue | 443 ++++++++++++++++++ src/components/dialog/ForkSubscribeDialog.vue | 15 +- .../dialog/PluginCapabilitiesDialog.vue | 199 ++++++++ .../dialog/PluginDataSummaryDialog.vue | 201 ++++++++ .../dialog/PluginFolderRenameDialog.vue | 16 +- .../dialog/PluginFolderSettingsDialog.vue | 32 +- src/components/dialog/ReorganizeDialog.vue | 177 ++++++- src/components/dialog/SubscribeEditDialog.vue | 14 +- .../__tests__/AddDownloadDialog.spec.ts | 51 +- .../AddSubtitleDownloadDialog.spec.ts | 51 +- .../DownloadTaskSettingsDialog.spec.ts | 158 +++++++ .../__tests__/ForkSubscribeDialog.spec.ts | 42 +- .../PluginCapabilitiesDialog.spec.ts | 77 +++ .../__tests__/PluginDataSummaryDialog.spec.ts | 74 +++ .../dialog/__tests__/ReorganizeDialog.spec.ts | 159 ++++++- .../__tests__/SubscribeEditDialog.spec.ts | 4 +- .../TransferHistoryMaintenancePanel.vue | 95 ++++ .../TransferHistoryMaintenancePanel.spec.ts | 119 +++++ .../workflow/FilterTorrentsAction.vue | 13 +- src/components/workflow/ScanFileAction.vue | 9 +- .../__tests__/FilterTorrentsAction.spec.ts | 15 +- .../workflow/__tests__/ScanFileAction.spec.ts | 16 +- .../__tests__/useSystemUpdateStatus.spec.ts | 28 ++ src/composables/useSystemUpdateStatus.ts | 22 +- src/locales/en-US.ts | 82 ++++ src/locales/zh-CN.ts | 80 ++++ src/locales/zh-TW.ts | 22 + src/pages/__tests__/downloading.spec.ts | 6 +- src/pages/__tests__/subscribe.spec.ts | 107 ++++- src/pages/downloading.vue | 2 +- src/pages/subscribe.vue | 135 +++++- src/views/dashboard/DashboardSystemInfo.vue | 43 +- src/views/dashboard/MediaServerLatest.vue | 15 +- src/views/dashboard/MediaServerLibrary.vue | 15 +- src/views/dashboard/MediaServerPlaying.vue | 15 +- .../__tests__/DashboardSystemInfo.spec.ts | 100 ++++ .../__tests__/MediaServerCards.spec.ts | 54 +-- src/views/plugin/PluginCardListView.vue | 199 +++++--- .../__tests__/PluginCardListView.spec.ts | 113 +++-- src/views/reorganize/DownloadingListView.vue | 3 +- src/views/reorganize/FileBrowserView.vue | 23 +- src/views/reorganize/TransferHistoryView.vue | 9 +- .../__tests__/DownloadingListView.spec.ts | 71 ++- .../__tests__/FileBrowserView.spec.ts | 12 +- .../__tests__/TransferHistoryView.spec.ts | 50 +- .../setting/AccountSettingClassification.vue | 8 +- src/views/setting/AccountSettingDirectory.vue | 21 +- src/views/setting/AccountSettingRule.vue | 263 ++++++++++- src/views/setting/AccountSettingSearch.vue | 4 +- src/views/setting/AccountSettingSite.vue | 41 +- src/views/setting/AccountSettingSubscribe.vue | 4 +- src/views/setting/AccountSettingSystem.vue | 4 + .../AccountSettingClassification.spec.ts | 24 +- .../__tests__/AccountSettingDirectory.spec.ts | 54 ++- .../__tests__/AccountSettingRule.spec.ts | 148 ++++-- .../__tests__/AccountSettingSearch.spec.ts | 4 +- .../__tests__/AccountSettingSite.spec.ts | 74 ++- .../__tests__/AccountSettingSubscribe.spec.ts | 4 +- src/views/system/NameTestView.vue | 6 +- src/views/system/RuleTestView.vue | 4 +- src/views/system/WordsView.vue | 26 +- .../system/__tests__/NameTestView.spec.ts | 17 +- src/views/system/__tests__/WordsView.spec.ts | 24 +- tests/support/factories/subscribe.ts | 14 +- tests/support/msw/handlers/download.ts | 18 +- tests/support/msw/handlers/subscribe.ts | 23 +- 100 files changed, 5032 insertions(+), 689 deletions(-) create mode 100644 src/api/__tests__/customIdentifiers.spec.ts create mode 100644 src/api/__tests__/history.spec.ts create mode 100644 src/api/__tests__/mediaServer.spec.ts create mode 100644 src/api/__tests__/pluginCapabilities.spec.ts create mode 100644 src/api/__tests__/pluginData.spec.ts create mode 100644 src/api/__tests__/pluginFolders.spec.ts create mode 100644 src/api/__tests__/rule.spec.ts create mode 100644 src/api/__tests__/site.spec.ts create mode 100644 src/api/__tests__/storage.spec.ts create mode 100644 src/api/__tests__/subscription.spec.ts create mode 100644 src/api/__tests__/systemSettings.spec.ts create mode 100644 src/api/customIdentifiers.ts create mode 100644 src/api/history.ts create mode 100644 src/api/mediaServer.ts create mode 100644 src/api/pluginCapabilities.ts create mode 100644 src/api/pluginData.ts create mode 100644 src/api/pluginFolders.ts create mode 100644 src/api/rule.ts create mode 100644 src/api/site.ts create mode 100644 src/api/storage.ts create mode 100644 src/api/subscription.ts create mode 100644 src/api/systemSettings.ts create mode 100644 src/components/dialog/DownloadTaskSettingsDialog.vue create mode 100644 src/components/dialog/PluginCapabilitiesDialog.vue create mode 100644 src/components/dialog/PluginDataSummaryDialog.vue create mode 100644 src/components/dialog/__tests__/DownloadTaskSettingsDialog.spec.ts create mode 100644 src/components/dialog/__tests__/PluginCapabilitiesDialog.spec.ts create mode 100644 src/components/dialog/__tests__/PluginDataSummaryDialog.spec.ts create mode 100644 src/components/system/TransferHistoryMaintenancePanel.vue create mode 100644 src/components/system/__tests__/TransferHistoryMaintenancePanel.spec.ts create mode 100644 src/views/dashboard/__tests__/DashboardSystemInfo.spec.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 03a76f38..3446621a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -209,7 +209,7 @@ }, "src/components/dialog/ReorganizeDialog.vue": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 3 }, "sonarjs/super-linear-regex": { "count": 1 @@ -253,7 +253,7 @@ }, "src/components/dialog/SubscribeEditDialog.vue": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "src/components/dialog/SubscribeHistoryDialog.vue": { @@ -441,9 +441,6 @@ } }, "src/components/workflow/FilterTorrentsAction.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "vue/no-mutating-props": { "count": 7 } @@ -459,9 +456,6 @@ } }, "src/components/workflow/ScanFileAction.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "vue/no-mutating-props": { "count": 2 } @@ -736,16 +730,6 @@ "count": 1 } }, - "src/views/dashboard/MediaServerLibrary.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "src/views/dashboard/MediaServerPlaying.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/views/discover/MediaCardSlideView.vue": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -771,7 +755,7 @@ }, "src/views/setting/AccountSettingDirectory.vue": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 }, "sonarjs/super-linear-regex": { "count": 1 @@ -849,19 +833,9 @@ "count": 1 } }, - "src/views/system/NameTestView.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "src/views/system/RuleTestView.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/views/system/WordsView.vue": { "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "src/views/workflow/WorkflowShareView.vue": { @@ -877,4 +851,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/src/api/__tests__/customIdentifiers.spec.ts b/src/api/__tests__/customIdentifiers.spec.ts new file mode 100644 index 00000000..ca5a06fa --- /dev/null +++ b/src/api/__tests__/customIdentifiers.spec.ts @@ -0,0 +1,51 @@ +import { listCustomIdentifiers, replaceCustomIdentifiers } from '@/api/customIdentifiers' +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('custom identifiers API adapter', () => { + beforeEach(() => { + mocks.apiGet.mockReset().mockResolvedValue(null) + mocks.apiPost.mockReset().mockResolvedValue(null) + }) + + it('queries and narrows the dedicated identifier collection', async () => { + mocks.apiGet.mockResolvedValueOnce({ + count: 3, + identifiers: ['旧名 => 新名', null, '季数 <> S02'], + }) + + await expect(listCustomIdentifiers()).resolves.toEqual(['旧名 => 新名', '季数 <> S02']) + expect(mocks.apiGet).toHaveBeenCalledWith('system/identifiers') + }) + + it('normalizes a malformed query collection to an empty list', async () => { + mocks.apiGet.mockResolvedValueOnce({ identifiers: {} }) + + await expect(listCustomIdentifiers()).resolves.toEqual([]) + }) + + it('replaces the list with an optimistic concurrency snapshot', async () => { + mocks.apiPost.mockResolvedValueOnce({ identifiers: ['A', 'B'] }) + + await expect(replaceCustomIdentifiers(['A', 'B'], ['A'])).resolves.toEqual(['A', 'B']) + expect(mocks.apiPost).toHaveBeenCalledWith('system/identifiers', { + identifiers: ['A', 'B'], + expected_identifiers: ['A'], + }) + }) + + it('keeps the submitted list when an older compatible response omits it', async () => { + await expect(replaceCustomIdentifiers(['A'], [])).resolves.toEqual(['A']) + }) +}) diff --git a/src/api/__tests__/history.spec.ts b/src/api/__tests__/history.spec.ts new file mode 100644 index 00000000..f953077c --- /dev/null +++ b/src/api/__tests__/history.spec.ts @@ -0,0 +1,26 @@ +import { clearLegacyTransferHistory } from '@/api/history' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiDelete: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + delete: (...args: unknown[]) => mocks.apiDelete(...args), + }), +})) + +describe('history api', () => { + beforeEach(() => { + mocks.apiDelete.mockReset() + mocks.apiDelete.mockResolvedValue({ data: null, message: '', success: true }) + }) + + it('clears legacy transfer history through the destructive DELETE endpoint', async () => { + await clearLegacyTransferHistory() + + expect(mocks.apiDelete).toHaveBeenCalledOnce() + expect(mocks.apiDelete).toHaveBeenCalledWith('history/transfer/all') + }) +}) diff --git a/src/api/__tests__/mediaServer.spec.ts b/src/api/__tests__/mediaServer.spec.ts new file mode 100644 index 00000000..79bb803b --- /dev/null +++ b/src/api/__tests__/mediaServer.spec.ts @@ -0,0 +1,41 @@ +import { listMediaServerClients } from '@/api/mediaServer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +describe('media server API adapters', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('queries the redacted enabled-client projection', async () => { + mocks.apiGet.mockResolvedValueOnce([{ name: ' Home ', type: ' emby ' }]) + + await expect(listMediaServerClients()).resolves.toEqual([{ name: 'Home', type: 'emby' }]) + expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/clients') + }) + + it('drops malformed clients without leaking arbitrary fields', async () => { + mocks.apiGet.mockResolvedValueOnce([ + { name: 'Home', type: 'emby', config: { token: 'secret' } }, + { name: '', type: 'plex' }, + null, + ]) + + await expect(listMediaServerClients()).resolves.toEqual([{ name: 'Home', type: 'emby' }]) + }) + + it('normalizes non-list responses to an empty collection', async () => { + mocks.apiGet.mockResolvedValueOnce(null) + + await expect(listMediaServerClients()).resolves.toEqual([]) + }) +}) diff --git a/src/api/__tests__/pluginCapabilities.spec.ts b/src/api/__tests__/pluginCapabilities.spec.ts new file mode 100644 index 00000000..479a0ddc --- /dev/null +++ b/src/api/__tests__/pluginCapabilities.spec.ts @@ -0,0 +1,81 @@ +import { getPluginRuntimeCapabilities } from '@/api/pluginCapabilities' +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 runtime capability API adapter', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + }) + + it('queries one plugin and keeps only safe display fields', async () => { + mocks.apiGet.mockResolvedValueOnce({ + actions: [ + { + plugin_id: ' DemoPlugin ', + plugin_name: ' 演示插件 ', + actions: [{ id: ' refresh ', name: ' 刷新 ', kwargs: { token: 'secret' } }], + private: 'ignored', + }, + ], + commands: [{ cmd: ' /demo ', desc: ' 演示命令 ', data: { token: 'secret' } }], + services: [{ id: ' daily ', name: ' 每日任务 ', trigger: ' cron ', kwargs: { token: 'secret' } }], + }) + + await expect(getPluginRuntimeCapabilities('DemoPlugin')).resolves.toEqual({ + actions: [ + { + plugin_id: 'DemoPlugin', + plugin_name: '演示插件', + actions: [{ id: 'refresh', name: '刷新' }], + }, + ], + commands: [{ cmd: '/demo', desc: '演示命令' }], + services: [{ id: 'daily', name: '每日任务', trigger: 'cron' }], + }) + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/runtime/capabilities', { + params: { plugin_id: 'DemoPlugin' }, + }) + }) + + it('drops malformed entries and empty action groups', async () => { + mocks.apiGet.mockResolvedValueOnce({ + actions: [{ actions: [{ name: 'missing id' }] }, null], + commands: [{ desc: 'missing cmd' }, null], + services: [{ name: 'missing id' }, null], + }) + + await expect(getPluginRuntimeCapabilities('DemoPlugin')).resolves.toEqual({ + actions: [], + commands: [], + services: [], + }) + }) + + it('normalizes non-object responses to an empty snapshot', async () => { + mocks.apiGet.mockResolvedValueOnce(null) + + await expect(getPluginRuntimeCapabilities('DemoPlugin')).resolves.toEqual({ + actions: [], + commands: [], + services: [], + }) + }) + + it('reloads a plugin through POST without a request body', async () => { + const { reloadPluginRuntime } = await import('@/api/pluginCapabilities') + mocks.apiPost.mockResolvedValueOnce(undefined) + + await reloadPluginRuntime('Demo Plugin') + + expect(mocks.apiPost).toHaveBeenCalledWith('plugin/reload/Demo%20Plugin') + }) +}) diff --git a/src/api/__tests__/pluginData.spec.ts b/src/api/__tests__/pluginData.spec.ts new file mode 100644 index 00000000..05ea8a79 --- /dev/null +++ b/src/api/__tests__/pluginData.spec.ts @@ -0,0 +1,80 @@ +import { getPluginDataSummary } from '@/api/pluginData' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ apiGet: vi.fn() })) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +describe('plugin data summary API adapter', () => { + beforeEach(() => mocks.apiGet.mockReset()) + + it('keeps only non-value diagnostic fields', async () => { + mocks.apiGet.mockResolvedValueOnce({ + plugin_id: ' DemoPlugin ', + plugin_name: ' 演示插件 ', + plugin_version: ' 1.0.0 ', + state: true, + count: 2, + total_chars: 28, + keys_truncated: false, + keys: [ + { + key: ' api_token ', + value_type: 'string', + serialized_chars: 14, + sensitive: true, + value: 'secret-token', + }, + { key: ' history ', value_type: 'array', serialized_chars: 14, sensitive: false, preview: '[secret]' }, + ], + data: { api_token: 'secret-token' }, + }) + + await expect(getPluginDataSummary('DemoPlugin')).resolves.toEqual({ + plugin_id: 'DemoPlugin', + plugin_name: '演示插件', + plugin_version: '1.0.0', + state: true, + count: 2, + total_chars: 28, + keys_truncated: false, + keys: [ + { key: 'api_token', value_type: 'string', serialized_chars: 14, sensitive: true }, + { key: 'history', value_type: 'array', serialized_chars: 14, sensitive: false }, + ], + }) + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/runtime/DemoPlugin/data/summary') + }) + + it('drops malformed keys and invalid sizes', async () => { + mocks.apiGet.mockResolvedValueOnce({ + keys: [ + { key: '', value_type: 'string' }, + { key: 'custom', value_type: 'unsupported', serialized_chars: 4 }, + { key: 'valid', value_type: 'object', serialized_chars: -1 }, + ], + }) + + await expect(getPluginDataSummary('DemoPlugin')).resolves.toMatchObject({ + plugin_id: 'DemoPlugin', + count: 1, + keys: [{ key: 'valid', value_type: 'object', serialized_chars: null, sensitive: false }], + }) + }) + + it('normalizes an invalid response to an empty summary', async () => { + mocks.apiGet.mockResolvedValueOnce(null) + + await expect(getPluginDataSummary('DemoPlugin')).resolves.toEqual({ + plugin_id: 'DemoPlugin', + count: 0, + total_chars: 0, + keys: [], + keys_truncated: false, + }) + }) +}) diff --git a/src/api/__tests__/pluginFolders.spec.ts b/src/api/__tests__/pluginFolders.spec.ts new file mode 100644 index 00000000..102cba80 --- /dev/null +++ b/src/api/__tests__/pluginFolders.spec.ts @@ -0,0 +1,71 @@ +import { + assignPluginToFolder, + createPluginFolder, + deletePluginFolder, + listPluginFolders, + removePluginFromFolder, + replacePluginFolderMembers, + updatePluginFolder, +} from '@/api/pluginFolders' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + delete: vi.fn(), + get: vi.fn(), + patch: vi.fn(), + post: vi.fn(), + put: vi.fn(), +})) + +vi.mock('@/api', () => ({ default: mocks })) + +describe('pluginFolders api', () => { + beforeEach(() => vi.clearAllMocks()) + + it('normalizes current and legacy folder responses', async () => { + mocks.get.mockResolvedValue({ + Broken: 'invalid', + Legacy: ['Plugin-A', 1], + Tools: { color: '#00ff00', plugins: ['Plugin-B', null], showIcon: false }, + }) + + await expect(listPluginFolders()).resolves.toEqual({ + Legacy: ['Plugin-A'], + Tools: { color: '#00ff00', plugins: ['Plugin-B'], showIcon: false }, + }) + expect(mocks.get).toHaveBeenCalledWith('plugin/folders', { feedback: 'silent' }) + }) + + it('uses encoded incremental folder routes', async () => { + await createPluginFolder('Media Tools') + await updatePluginFolder('Media Tools', { color: '#ff0000', new_name: 'Daily Tools' }) + await deletePluginFolder('Daily Tools') + + expect(mocks.post).toHaveBeenCalledWith('plugin/folders/Media%20Tools', undefined, { feedback: 'silent' }) + expect(mocks.patch).toHaveBeenCalledWith( + 'plugin/folders/Media%20Tools', + { color: '#ff0000', new_name: 'Daily Tools' }, + { feedback: 'silent' }, + ) + expect(mocks.delete).toHaveBeenCalledWith('plugin/folders/Daily%20Tools', { feedback: 'silent' }) + }) + + it('uses conditional member replacement and single-plugin assignment routes', async () => { + await replacePluginFolderMembers('Media Tools', ['Plugin-B'], ['Plugin-A']) + await assignPluginToFolder('Media Tools', 'Plugin/B') + await removePluginFromFolder('Media Tools', 'Plugin/B') + + expect(mocks.put).toHaveBeenNthCalledWith( + 1, + 'plugin/folders/Media%20Tools/plugins', + { expected_plugins: ['Plugin-A'], plugins: ['Plugin-B'] }, + { feedback: 'silent' }, + ) + expect(mocks.put).toHaveBeenNthCalledWith(2, 'plugin/folders/Media%20Tools/plugins/Plugin%2FB', undefined, { + feedback: 'silent', + }) + expect(mocks.delete).toHaveBeenCalledWith('plugin/folders/Media%20Tools/plugins/Plugin%2FB', { + feedback: 'silent', + }) + }) +}) diff --git a/src/api/__tests__/rule.spec.ts b/src/api/__tests__/rule.spec.ts new file mode 100644 index 00000000..e0f4927f --- /dev/null +++ b/src/api/__tests__/rule.spec.ts @@ -0,0 +1,102 @@ +import { + createCustomRule, + createFilterRuleGroup, + deleteCustomRule, + deleteFilterRuleGroup, + listCustomRules, + listFilterRuleGroups, + reorderCustomRules, + reorderFilterRuleGroups, + updateCustomRule, + updateFilterRuleGroup, +} from '@/api/rule' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + delete: (...args: unknown[]) => mocks.apiDelete(...args), + get: (...args: unknown[]) => mocks.apiGet(...args), + post: (...args: unknown[]) => mocks.apiPost(...args), + put: (...args: unknown[]) => mocks.apiPut(...args), + }), +})) + +describe('rule API adapters', () => { + beforeEach(() => { + mocks.apiDelete.mockReset().mockResolvedValue(null) + mocks.apiGet.mockReset().mockResolvedValue(null) + mocks.apiPost.mockReset().mockResolvedValue(null) + mocks.apiPut.mockReset().mockResolvedValue(null) + }) + + it('queries and narrows custom rule analysis fields', async () => { + mocks.apiGet.mockResolvedValueOnce({ + count: 2, + rules: [ + { id: 'A', name: '规则 A', include: 'WEB', source: 'custom', referenced_by_rule_groups: ['默认'] }, + { id: '', name: 'invalid' }, + ], + }) + + await expect(listCustomRules()).resolves.toEqual([{ id: 'A', name: '规则 A', include: 'WEB' }]) + expect(mocks.apiGet).toHaveBeenCalledWith('rule/custom', { params: { include_group_refs: false } }) + }) + + it('queries and narrows rule group analysis fields', async () => { + mocks.apiGet.mockResolvedValueOnce({ + rule_groups: [{ name: '默认', rule_string: '4K', media_type: '电影', syntax_valid: true, usage: {} }, null], + }) + + await expect(listFilterRuleGroups()).resolves.toEqual([ + { name: '默认', rule_string: '4K', media_type: '电影', category: undefined }, + ]) + expect(mocks.apiGet).toHaveBeenCalledWith('rule/groups', { params: { include_usage: false } }) + }) + + it('normalizes malformed query collections to empty lists', async () => { + mocks.apiGet.mockResolvedValueOnce({ rules: null }).mockResolvedValueOnce({ rule_groups: {} }) + + await expect(listCustomRules()).resolves.toEqual([]) + await expect(listFilterRuleGroups()).resolves.toEqual([]) + }) + + it('uses incremental custom rule mutation endpoints', async () => { + await createCustomRule({ rule_id: 'A/B', name: '规则' }) + await updateCustomRule('A/B', { new_rule_id: 'C', include: '' }) + await deleteCustomRule('A/B') + await reorderCustomRules(['C'], ['A/B']) + + expect(mocks.apiPost).toHaveBeenCalledWith('rule/custom', { rule_id: 'A/B', name: '规则' }) + expect(mocks.apiPut).toHaveBeenNthCalledWith(1, 'rule/custom/A%2FB', { new_rule_id: 'C', include: '' }) + expect(mocks.apiDelete).toHaveBeenCalledWith('rule/custom/A%2FB') + expect(mocks.apiPut).toHaveBeenNthCalledWith(2, 'rule/custom/reorder', { + rule_ids: ['C'], + expected_rule_ids: ['A/B'], + }) + }) + + it('uses incremental rule group mutation endpoints', async () => { + await createFilterRuleGroup({ name: '组/一', rule_string: '4K' }) + await updateFilterRuleGroup('组/一', { new_name: '组二', category: '' }) + await deleteFilterRuleGroup('组/一') + await reorderFilterRuleGroups(['组二'], ['组/一']) + + expect(mocks.apiPost).toHaveBeenCalledWith('rule/groups', { name: '组/一', rule_string: '4K' }) + expect(mocks.apiPut).toHaveBeenNthCalledWith(1, 'rule/groups/%E7%BB%84%2F%E4%B8%80', { + new_name: '组二', + category: '', + }) + expect(mocks.apiDelete).toHaveBeenCalledWith('rule/groups/%E7%BB%84%2F%E4%B8%80') + expect(mocks.apiPut).toHaveBeenNthCalledWith(2, 'rule/groups/reorder', { + group_names: ['组二'], + expected_group_names: ['组/一'], + }) + }) +}) diff --git a/src/api/__tests__/site.spec.ts b/src/api/__tests__/site.spec.ts new file mode 100644 index 00000000..ebc632f8 --- /dev/null +++ b/src/api/__tests__/site.spec.ts @@ -0,0 +1,24 @@ +import { requestCookieCloudSync, resetSiteData } from '@/api/site' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiPost: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ post: mocks.apiPost }), +})) + +describe('site API', () => { + beforeEach(() => { + mocks.apiPost.mockReset().mockResolvedValue({ success: true }) + }) + + it('uses POST for CookieCloud sync and site reset commands', async () => { + await requestCookieCloudSync() + await resetSiteData() + + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'site/cookiecloud') + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'site/reset') + }) +}) diff --git a/src/api/__tests__/storage.spec.ts b/src/api/__tests__/storage.spec.ts new file mode 100644 index 00000000..8a7e15b4 --- /dev/null +++ b/src/api/__tests__/storage.spec.ts @@ -0,0 +1,45 @@ +import { listDownloadDirectories, listStorageOptions, listTransferDirectories } from '@/api/storage' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +describe('storage API adapters', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + mocks.apiGet.mockResolvedValue([]) + }) + + it('queries API-ready download paths without reading the complete setting object', async () => { + await expect(listDownloadDirectories()).resolves.toEqual([]) + + expect(mocks.apiGet).toHaveBeenCalledWith('download/paths') + }) + + it('queries redacted storage options', async () => { + await expect(listStorageOptions()).resolves.toEqual([]) + + expect(mocks.apiGet).toHaveBeenCalledWith('storage/options') + }) + + it('normalizes malformed nullable collection responses to empty lists', async () => { + mocks.apiGet.mockResolvedValueOnce(null) + + await expect(listDownloadDirectories()).resolves.toEqual([]) + }) + + it('passes structured directory filters through query parameters', async () => { + await expect(listTransferDirectories({ directory_type: 'library', storage_type: 'remote' })).resolves.toEqual([]) + + expect(mocks.apiGet).toHaveBeenCalledWith('storage/directories', { + params: { directory_type: 'library', storage_type: 'remote' }, + }) + }) +}) diff --git a/src/api/__tests__/subscription.spec.ts b/src/api/__tests__/subscription.spec.ts new file mode 100644 index 00000000..d8895a52 --- /dev/null +++ b/src/api/__tests__/subscription.spec.ts @@ -0,0 +1,57 @@ +import { + followSubscriber, + listFollowedSubscribers, + refreshSubscriptionMetadata, + refreshSubscriptions, + resetSubscription, + searchAllSubscriptions, + searchSubscription, + unfollowSubscriber, +} from '@/api/subscription' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + apiPost: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + delete: mocks.apiDelete, + get: mocks.apiGet, + post: mocks.apiPost, + }), +})) + +describe('subscription API', () => { + beforeEach(() => { + mocks.apiDelete.mockReset().mockResolvedValue({ success: true }) + mocks.apiGet.mockReset().mockResolvedValue({ data: ['followed-user'], success: true }) + mocks.apiPost.mockReset().mockResolvedValue({ success: true }) + }) + + it('uses POST for subscription commands', async () => { + await searchSubscription(7) + await resetSubscription(8) + await searchAllSubscriptions() + await refreshSubscriptions() + await refreshSubscriptionMetadata() + + expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'subscribe/search/7') + expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'subscribe/reset/8') + expect(mocks.apiPost).toHaveBeenNthCalledWith(3, 'subscribe/search') + expect(mocks.apiPost).toHaveBeenNthCalledWith(4, 'subscribe/refresh') + expect(mocks.apiPost).toHaveBeenNthCalledWith(5, 'subscribe/check') + }) + + it('uses the structured follow endpoint for reads and mutations', async () => { + await expect(listFollowedSubscribers()).resolves.toEqual(['followed-user']) + await followSubscriber('new-user') + await unfollowSubscriber('old-user') + + expect(mocks.apiGet).toHaveBeenCalledWith('subscribe/follow') + expect(mocks.apiPost).toHaveBeenCalledWith('subscribe/follow', undefined, { params: { share_uid: 'new-user' } }) + expect(mocks.apiDelete).toHaveBeenCalledWith('subscribe/follow', { params: { share_uid: 'old-user' } }) + }) +}) diff --git a/src/api/__tests__/systemSettings.spec.ts b/src/api/__tests__/systemSettings.spec.ts new file mode 100644 index 00000000..47d3ba9b --- /dev/null +++ b/src/api/__tests__/systemSettings.spec.ts @@ -0,0 +1,57 @@ +import { getSystemSetting } from '@/api/systemSettings' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +describe('system settings API adapters', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('queries one exact setting without requesting secret values', async () => { + const item = { + definition: { + declared_type: 'str | None', + nullable: true, + persistence: 'app.env', + sensitive: false, + update_operations: ['replace'], + value_shape: 'str', + }, + group: 'settings', + has_value: true, + label: 'MOVIE_RENAME_FORMAT', + redacted: false, + setting_key: 'MOVIE_RENAME_FORMAT', + source: 'settings', + value: '{{ title }}', + value_type: 'str', + } + mocks.apiGet.mockResolvedValueOnce({ + include_values: true, + matched_count: 1, + settings: [item], + show_secrets: false, + }) + + await expect(getSystemSetting('MOVIE_RENAME_FORMAT')).resolves.toEqual(item) + expect(mocks.apiGet).toHaveBeenCalledWith('system/settings', { + params: { setting_key: 'MOVIE_RENAME_FORMAT' }, + }) + }) + + it('returns null when the exact item is absent or malformed', async () => { + mocks.apiGet.mockResolvedValueOnce({ settings: [] }).mockResolvedValueOnce(null) + + await expect(getSystemSetting('MOVIE_RENAME_FORMAT')).resolves.toBeNull() + await expect(getSystemSetting('MOVIE_RENAME_FORMAT')).resolves.toBeNull() + }) +}) diff --git a/src/api/customIdentifiers.ts b/src/api/customIdentifiers.ts new file mode 100644 index 00000000..94f9bc4a --- /dev/null +++ b/src/api/customIdentifiers.ts @@ -0,0 +1,34 @@ +import api from '@/api' + +interface CustomIdentifiersResult { + count?: number + identifiers?: unknown +} + +/** 将自定义识别词接口的未知集合收窄为有序字符串列表。 */ +function normalizeCustomIdentifiers(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +/** 查询完整的自定义识别词有序列表。 */ +export async function listCustomIdentifiers(): Promise { + const result = await api.get('system/identifiers', { feedback: 'silent' }) + return normalizeCustomIdentifiers(result?.identifiers) +} + +/** 基于上次读取的完整列表条件替换自定义识别词。 */ +export async function replaceCustomIdentifiers( + identifiers: string[], + expectedIdentifiers: string[], +): Promise { + const result = await api.post( + 'system/identifiers', + { + identifiers, + expected_identifiers: expectedIdentifiers, + }, + { feedback: 'silent' }, + ) + return Array.isArray(result?.identifiers) ? normalizeCustomIdentifiers(result.identifiers) : [...identifiers] +} diff --git a/src/api/history.ts b/src/api/history.ts new file mode 100644 index 00000000..fab89e54 --- /dev/null +++ b/src/api/history.ts @@ -0,0 +1,6 @@ +import api from '@/api' + +/** 清空全部旧整理记录;后端会保留持久失败任务,且不会删除任何文件。 */ +export async function clearLegacyTransferHistory(): Promise { + await api.delete('history/transfer/all', { feedback: 'silent' }) +} diff --git a/src/api/mediaServer.ts b/src/api/mediaServer.ts new file mode 100644 index 00000000..328aaf62 --- /dev/null +++ b/src/api/mediaServer.ts @@ -0,0 +1,16 @@ +import api from '@/api' +import type { MediaServerClient } from '@/api/types' + +/** 查询已启用且不包含连接配置的媒体服务器客户端。 */ +export async function listMediaServerClients(): Promise { + const result = await api.get('mediaserver/clients', { feedback: 'silent' }) + if (!Array.isArray(result)) return [] + + return result.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const name = typeof record.name === 'string' ? record.name.trim() : '' + const type = typeof record.type === 'string' ? record.type.trim() : '' + return name && type ? [{ name, type }] : [] + }) +} diff --git a/src/api/pluginCapabilities.ts b/src/api/pluginCapabilities.ts new file mode 100644 index 00000000..85e6a9a6 --- /dev/null +++ b/src/api/pluginCapabilities.ts @@ -0,0 +1,117 @@ +import api from '@/api' + +/** 插件命令的安全只读元数据。 */ +export interface PluginRuntimeCommandCapability { + cmd: string + desc?: string + plugin_id?: string +} + +/** 插件动作的安全只读元数据。 */ +export interface PluginRuntimeActionCapability { + id: string + name?: string +} + +/** 按插件归组的动作元数据。 */ +export interface PluginRuntimeActionGroup { + plugin_id?: string + plugin_name?: string + actions: PluginRuntimeActionCapability[] +} + +/** 插件定时服务的安全只读元数据。 */ +export interface PluginRuntimeServiceCapability { + id: string + name?: string + trigger?: string +} + +/** 插件运行时公开能力快照。 */ +export interface PluginRuntimeCapabilities { + commands: PluginRuntimeCommandCapability[] + actions: PluginRuntimeActionGroup[] + services: PluginRuntimeServiceCapability[] +} + +/** 读取对象中的非空字符串字段。 */ +function readText(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') return undefined + return value.trim() || undefined +} + +/** 把后端响应收窄为只包含安全展示字段的插件能力快照。 */ +function normalizePluginRuntimeCapabilities(value: unknown): PluginRuntimeCapabilities { + const source = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} + const commands = Array.isArray(source.commands) + ? source.commands.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const cmd = readText(record, 'cmd') + return cmd + ? [ + { + cmd, + ...(readText(record, 'desc') ? { desc: readText(record, 'desc') } : {}), + ...(readText(record, 'plugin_id') ? { plugin_id: readText(record, 'plugin_id') } : {}), + }, + ] + : [] + }) + : [] + const actions = Array.isArray(source.actions) + ? source.actions.flatMap(group => { + if (!group || typeof group !== 'object' || Array.isArray(group)) return [] + const record = group as Record + const items = Array.isArray(record.actions) + ? record.actions.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const action = item as Record + const id = readText(action, 'id') + return id ? [{ id, ...(readText(action, 'name') ? { name: readText(action, 'name') } : {}) }] : [] + }) + : [] + if (!items.length) return [] + return [ + { + actions: items, + ...(readText(record, 'plugin_id') ? { plugin_id: readText(record, 'plugin_id') } : {}), + ...(readText(record, 'plugin_name') ? { plugin_name: readText(record, 'plugin_name') } : {}), + }, + ] + }) + : [] + const services = Array.isArray(source.services) + ? source.services.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const id = readText(record, 'id') + return id + ? [ + { + id, + ...(readText(record, 'name') ? { name: readText(record, 'name') } : {}), + ...(readText(record, 'trigger') ? { trigger: readText(record, 'trigger') } : {}), + }, + ] + : [] + }) + : [] + + return { actions, commands, services } +} + +/** 按插件 ID 查询运行中插件注册的安全能力元数据。 */ +export async function getPluginRuntimeCapabilities(pluginId: string): Promise { + const result = await api.get('plugin/runtime/capabilities', { + feedback: 'silent', + params: { plugin_id: pluginId }, + }) + return normalizePluginRuntimeCapabilities(result) +} + +/** 通过有副作用语义正确的 POST 请求重新加载一个插件。 */ +export async function reloadPluginRuntime(pluginId: string): Promise { + await api.post(`plugin/reload/${encodeURIComponent(pluginId)}`, undefined, { feedback: 'silent' }) +} diff --git a/src/api/pluginData.ts b/src/api/pluginData.ts new file mode 100644 index 00000000..9947e9b3 --- /dev/null +++ b/src/api/pluginData.ts @@ -0,0 +1,81 @@ +import api from '@/api' + +export type PluginDataValueType = 'null' | 'boolean' | 'number' | 'string' | 'array' | 'object' | 'unknown' + +/** 单个插件持久化键的不含值摘要。 */ +export interface PluginDataKeySummary { + key: string + value_type: PluginDataValueType + serialized_chars: number | null + sensitive: boolean +} + +/** 插件持久化数据的不含原值诊断摘要。 */ +export interface PluginDataSummary { + plugin_id: string + plugin_name?: string + plugin_version?: string + state?: boolean + count: number + total_chars: number + keys: PluginDataKeySummary[] + keys_truncated: boolean +} + +const valueTypes = new Set(['null', 'boolean', 'number', 'string', 'array', 'object', 'unknown']) + +/** 读取对象中的非空字符串字段。 */ +function readText(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +/** 读取非负有限数值,否则使用回退值。 */ +function readCount(record: Record, key: string, fallback = 0): number { + const value = record[key] + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback +} + +/** 将后端摘要再次收窄为前端允许展示的字段集合。 */ +function normalizePluginDataSummary(value: unknown, fallbackPluginId: string): PluginDataSummary { + const source = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} + const keys = Array.isArray(source.keys) + ? source.keys.flatMap(item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const key = readText(record, 'key') + const rawType = readText(record, 'value_type') as PluginDataValueType | undefined + if (!key || !rawType || !valueTypes.has(rawType)) return [] + const rawChars = record.serialized_chars + const serializedChars = + typeof rawChars === 'number' && Number.isFinite(rawChars) && rawChars >= 0 ? rawChars : null + return [ + { + key, + value_type: rawType, + serialized_chars: serializedChars, + sensitive: record.sensitive === true, + }, + ] + }) + : [] + + return { + plugin_id: readText(source, 'plugin_id') || fallbackPluginId, + ...(readText(source, 'plugin_name') ? { plugin_name: readText(source, 'plugin_name') } : {}), + ...(readText(source, 'plugin_version') ? { plugin_version: readText(source, 'plugin_version') } : {}), + ...(typeof source.state === 'boolean' ? { state: source.state } : {}), + count: readCount(source, 'count', keys.length), + total_chars: readCount(source, 'total_chars'), + keys, + keys_truncated: source.keys_truncated === true, + } +} + +/** 查询一个插件不包含持久化原值的数据诊断摘要。 */ +export async function getPluginDataSummary(pluginId: string): Promise { + const result = await api.get(`plugin/runtime/${encodeURIComponent(pluginId)}/data/summary`, { + feedback: 'silent', + }) + return normalizePluginDataSummary(result, pluginId) +} diff --git a/src/api/pluginFolders.ts b/src/api/pluginFolders.ts new file mode 100644 index 00000000..6dda9f5d --- /dev/null +++ b/src/api/pluginFolders.ts @@ -0,0 +1,95 @@ +import api from '@/api' + +/** 插件文件夹的成员和可选展示配置。 */ +export interface PluginFolderConfig { + plugins: string[] + order?: number + background?: string + icon?: string + color?: string + gradient?: string + showIcon?: boolean +} + +/** 插件文件夹增量展示配置请求。 */ +export interface PluginFolderUpdateInput { + new_name?: string + background?: string + icon?: string + color?: string + gradient?: string + showIcon?: boolean +} + +export type PluginFolderEntry = PluginFolderConfig | string[] +export type PluginFolderMap = Record + +/** 将插件文件夹响应收窄为兼容的新旧配置映射。 */ +function normalizePluginFolders(value: unknown): PluginFolderMap { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + const folders: PluginFolderMap = {} + Object.entries(value).forEach(([name, entry]) => { + if (Array.isArray(entry)) { + folders[name] = entry.filter((pluginId): pluginId is string => typeof pluginId === 'string') + return + } + if (!entry || typeof entry !== 'object') return + + const config = entry as Partial + folders[name] = { + ...config, + plugins: Array.isArray(config.plugins) + ? config.plugins.filter((pluginId): pluginId is string => typeof pluginId === 'string') + : [], + } + }) + return folders +} + +/** 查询完整的插件文件夹配置。 */ +export async function listPluginFolders(): Promise { + return normalizePluginFolders(await api.get('plugin/folders', { feedback: 'silent' })) +} + +/** 创建一个空插件文件夹。 */ +export async function createPluginFolder(folderName: string): Promise { + await api.post(`plugin/folders/${encodeURIComponent(folderName)}`, undefined, { feedback: 'silent' }) +} + +/** 增量更新插件文件夹名称或展示配置。 */ +export async function updatePluginFolder(folderName: string, payload: PluginFolderUpdateInput): Promise { + await api.patch(`plugin/folders/${encodeURIComponent(folderName)}`, payload, { feedback: 'silent' }) +} + +/** 删除一个插件文件夹但不卸载其中插件。 */ +export async function deletePluginFolder(folderName: string): Promise { + await api.delete(`plugin/folders/${encodeURIComponent(folderName)}`, { feedback: 'silent' }) +} + +/** 基于上次读取的成员顺序条件替换一个文件夹的插件列表。 */ +export async function replacePluginFolderMembers( + folderName: string, + plugins: string[], + expectedPlugins: string[], +): Promise { + await api.put( + `plugin/folders/${encodeURIComponent(folderName)}/plugins`, + { expected_plugins: expectedPlugins, plugins }, + { feedback: 'silent' }, + ) +} + +/** 把一个插件原子迁移到目标文件夹。 */ +export async function assignPluginToFolder(folderName: string, pluginId: string): Promise { + await api.put(`plugin/folders/${encodeURIComponent(folderName)}/plugins/${encodeURIComponent(pluginId)}`, undefined, { + feedback: 'silent', + }) +} + +/** 从指定文件夹移除一个插件。 */ +export async function removePluginFromFolder(folderName: string, pluginId: string): Promise { + await api.delete(`plugin/folders/${encodeURIComponent(folderName)}/plugins/${encodeURIComponent(pluginId)}`, { + feedback: 'silent', + }) +} diff --git a/src/api/rule.ts b/src/api/rule.ts new file mode 100644 index 00000000..e88f100c --- /dev/null +++ b/src/api/rule.ts @@ -0,0 +1,153 @@ +import api from '@/api' +import type { CustomRule, FilterRuleGroup } from '@/api/types' + +interface CustomRuleQueryResult { + count?: number + rules?: unknown +} + +interface FilterRuleGroupQueryResult { + count?: number + rule_groups?: unknown +} + +/** 新增自定义规则的结构化请求。 */ +export interface CustomRuleCreateInput { + rule_id: string + name: string + include?: string + exclude?: string + size_range?: string + seeders?: string + publish_time?: string +} + +/** 更新自定义规则的增量请求。 */ +export interface CustomRuleUpdateInput { + new_rule_id?: string + name?: string + include?: string + exclude?: string + size_range?: string + seeders?: string + publish_time?: string +} + +/** 新增规则组的结构化请求。 */ +export interface FilterRuleGroupCreateInput { + name: string + rule_string: string + media_type?: string + category?: string +} + +/** 更新规则组的增量请求。 */ +export interface FilterRuleGroupUpdateInput { + new_name?: string + rule_string?: string + media_type?: string + category?: string +} + +/** 从结构化查询响应中保留前端规则卡片需要的公开字段。 */ +function normalizeCustomRules(value: unknown): CustomRule[] { + if (!Array.isArray(value)) return [] + return value + .filter(item => item && typeof item === 'object') + .map(item => { + const rule = item as Partial + return { + id: typeof rule.id === 'string' ? rule.id : '', + name: typeof rule.name === 'string' ? rule.name : '', + include: typeof rule.include === 'string' ? rule.include : undefined, + exclude: typeof rule.exclude === 'string' ? rule.exclude : undefined, + size_range: typeof rule.size_range === 'string' ? rule.size_range : undefined, + seeders: typeof rule.seeders === 'string' ? rule.seeders : undefined, + publish_time: typeof rule.publish_time === 'string' ? rule.publish_time : undefined, + } + }) + .filter(rule => rule.id && rule.name) +} + +/** 从结构化查询响应中保留前端规则组卡片需要的公开字段。 */ +function normalizeFilterRuleGroups(value: unknown): FilterRuleGroup[] { + if (!Array.isArray(value)) return [] + return value + .filter(item => item && typeof item === 'object') + .map(item => { + const group = item as Partial + return { + name: typeof group.name === 'string' ? group.name : '', + rule_string: typeof group.rule_string === 'string' ? group.rule_string : undefined, + media_type: typeof group.media_type === 'string' ? group.media_type : undefined, + category: typeof group.category === 'string' ? group.category : undefined, + } + }) + .filter(group => group.name) +} + +/** 查询自定义过滤规则,不加载规则组引用分析。 */ +export async function listCustomRules(): Promise { + const result = await api.get('rule/custom', { + feedback: 'silent', + params: { include_group_refs: false }, + }) + return normalizeCustomRules(result?.rules) +} + +/** 查询规则组,不加载订阅和全局设置引用分析。 */ +export async function listFilterRuleGroups(): Promise { + const result = await api.get('rule/groups', { + feedback: 'silent', + params: { include_usage: false }, + }) + return normalizeFilterRuleGroups(result?.rule_groups) +} + +/** 新增一条自定义过滤规则。 */ +export async function createCustomRule(payload: CustomRuleCreateInput): Promise { + await api.post('rule/custom', payload, { feedback: 'silent' }) +} + +/** 增量更新一条自定义过滤规则。 */ +export async function updateCustomRule(ruleId: string, payload: CustomRuleUpdateInput): Promise { + await api.put(`rule/custom/${encodeURIComponent(ruleId)}`, payload, { feedback: 'silent' }) +} + +/** 删除一条未被规则组引用的自定义过滤规则。 */ +export async function deleteCustomRule(ruleId: string): Promise { + await api.delete(`rule/custom/${encodeURIComponent(ruleId)}`, { feedback: 'silent' }) +} + +/** 按完整 ID 列表调整自定义规则顺序。 */ +export async function reorderCustomRules(ruleIds: string[], expectedRuleIds: string[]): Promise { + await api.put( + 'rule/custom/reorder', + { rule_ids: ruleIds, expected_rule_ids: expectedRuleIds }, + { feedback: 'silent' }, + ) +} + +/** 新增一个过滤规则组。 */ +export async function createFilterRuleGroup(payload: FilterRuleGroupCreateInput): Promise { + await api.post('rule/groups', payload, { feedback: 'silent' }) +} + +/** 增量更新一个过滤规则组。 */ +export async function updateFilterRuleGroup(name: string, payload: FilterRuleGroupUpdateInput): Promise { + await api.put(`rule/groups/${encodeURIComponent(name)}`, payload, { feedback: 'silent' }) +} + +/** 删除一个规则组并由后端清理其引用。 */ +export async function deleteFilterRuleGroup(name: string): Promise { + await api.delete(`rule/groups/${encodeURIComponent(name)}`, { feedback: 'silent' }) +} + +/** 按完整名称列表调整规则组顺序。 */ +export async function reorderFilterRuleGroups(groupNames: string[], expectedGroupNames: string[]): Promise { + await api.put( + 'rule/groups/reorder', + { group_names: groupNames, expected_group_names: expectedGroupNames }, + { feedback: 'silent' }, + ) +} diff --git a/src/api/site.ts b/src/api/site.ts new file mode 100644 index 00000000..d0621dd1 --- /dev/null +++ b/src/api/site.ts @@ -0,0 +1,11 @@ +import api from './index' + +/** 触发一次 CookieCloud 站点同步。 */ +export function requestCookieCloudSync(): Promise { + return api.post('site/cookiecloud', undefined, { feedback: 'silent' }) +} + +/** 清空全部站点并启动一次新的 CookieCloud 同步。 */ +export function resetSiteData(): Promise { + return api.post('site/reset', undefined, { feedback: 'silent' }) +} diff --git a/src/api/storage.ts b/src/api/storage.ts new file mode 100644 index 00000000..f0e67df5 --- /dev/null +++ b/src/api/storage.ts @@ -0,0 +1,36 @@ +import api from '@/api' +import type { DownloadDirectory, StorageOption, TransferDirectoryConf } from '@/api/types' + +/** 目录查询支持的用途筛选。 */ +export type TransferDirectoryType = 'all' | 'download' | 'library' + +/** 目录查询支持的存储位置筛选。 */ +export type TransferDirectoryStorageType = 'all' | 'local' | 'remote' + +/** 结构化目录查询参数。 */ +export interface TransferDirectoryQuery { + directory_type?: TransferDirectoryType + name?: string + storage_type?: TransferDirectoryStorageType +} + +/** 查询可直接提交给下载接口的保存路径。 */ +export async function listDownloadDirectories(): Promise { + const result = await api.get('download/paths', { feedback: 'silent' }) + return Array.isArray(result) ? result : [] +} + +/** 查询不包含连接配置和凭据的存储选项。 */ +export async function listStorageOptions(): Promise { + const result = await api.get('storage/options', { feedback: 'silent' }) + return Array.isArray(result) ? result : [] +} + +/** 按用途和存储位置查询完整目录选择合同。 */ +export async function listTransferDirectories(query: TransferDirectoryQuery = {}): Promise { + const result = await api.get('storage/directories', { + feedback: 'silent', + params: query, + }) + return Array.isArray(result) ? result : [] +} diff --git a/src/api/subscription.ts b/src/api/subscription.ts new file mode 100644 index 00000000..17806b3a --- /dev/null +++ b/src/api/subscription.ts @@ -0,0 +1,47 @@ +import api from '@/api' + +/** 立即搜索一条现有订阅。 */ +export function searchSubscription(subscriptionId: number): Promise { + return api.post(`subscribe/search/${subscriptionId}`, undefined, { feedback: 'silent' }) +} + +/** 重置一条现有订阅,使其重新进入处理状态。 */ +export function resetSubscription(subscriptionId: number): Promise { + return api.post(`subscribe/reset/${subscriptionId}`, undefined, { feedback: 'silent' }) +} + +/** 立即搜索当前用户可访问的全部订阅。 */ +export function searchAllSubscriptions(): Promise { + return api.post('subscribe/search', undefined, { feedback: 'silent' }) +} + +/** 启动全局订阅刷新任务。 */ +export function refreshSubscriptions(): Promise { + return api.post('subscribe/refresh', undefined, { feedback: 'silent' }) +} + +/** 启动全局订阅元数据更新任务。 */ +export function refreshSubscriptionMetadata(): Promise { + return api.post('subscribe/check', undefined, { feedback: 'silent' }) +} + +/** 查询当前用户已关注的订阅分享用户。 */ +export function listFollowedSubscribers(): Promise { + return api.get('subscribe/follow', { feedback: 'silent' }) +} + +/** 关注一个订阅分享用户。 */ +export function followSubscriber(shareUid: string): Promise { + return api.post('subscribe/follow', undefined, { + feedback: 'silent', + params: { share_uid: shareUid }, + }) +} + +/** 取消关注一个订阅分享用户。 */ +export function unfollowSubscriber(shareUid: string): Promise { + return api.delete('subscribe/follow', { + feedback: 'silent', + params: { share_uid: shareUid }, + }) +} diff --git a/src/api/systemSettings.ts b/src/api/systemSettings.ts new file mode 100644 index 00000000..bd0cf3d0 --- /dev/null +++ b/src/api/systemSettings.ts @@ -0,0 +1,42 @@ +import api from '@/api' + +/** 系统设置的稳定定义信息。 */ +export interface SystemSettingDefinition { + declared_type: string + default_match_field?: string | null + nullable: boolean + persistence: string + sensitive: boolean + update_operations: string[] + value_shape: string +} + +/** 单个已登记系统设置及其安全查询元数据。 */ +export interface SystemSettingItem { + definition: SystemSettingDefinition + group: string + has_value: boolean + label: string + redacted: boolean + setting_key: string + source: string + value?: T + value_type: string +} + +interface SystemSettingsQueryResult { + include_values: boolean + matched_count: number + settings: SystemSettingItem[] + show_secrets: boolean +} + +/** 精确查询一个已登记设置,默认接受后端的敏感值脱敏策略。 */ +export async function getSystemSetting(settingKey: string): Promise | null> { + const result = await api.get>('system/settings', { + feedback: 'silent', + params: { setting_key: settingKey }, + }) + if (!result || !Array.isArray(result.settings)) return null + return result.settings.find(item => item?.setting_key === settingKey) ?? null +} diff --git a/src/api/types.ts b/src/api/types.ts index 5e66d2c7..c29c6f5e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -982,6 +982,8 @@ export interface SiteUserData { // 正在下载 export interface DownloadingInfo { + // 下载器实例名称 + downloader?: string // HASH hash?: string // 种子名称 @@ -1002,6 +1004,26 @@ export interface DownloadingInfo { dlspeed?: string // 上传速度 upspeed?: string + // 标签,下载器返回逗号分隔文本 + tags?: string + // 保存目录 + save_path?: string + // 内容目录 + content_path?: string + // 下载器分类 + category?: string + // 下载限速(KB/s) + download_limit?: number + // 上传限速(KB/s) + upload_limit?: number + // 分享率限制 + ratio_limit?: number + // 做种时间限制(分钟) + seeding_time_limit?: number + // Tracker 地址 + trackers?: string[] + // 来源站点 + site_name?: string // 媒体信息 media: { [key: string]: any } // 下载用户ID @@ -1012,6 +1034,33 @@ export interface DownloadingInfo { left_time?: string } +/** 下载任务高级修改请求。 */ +export interface DownloadTaskUpdateRequest { + tags?: string[] + downloader?: string + download_limit?: number + upload_limit?: number + trackers?: string[] + save_path?: string + category?: string + ratio_limit?: number + seeding_time_limit?: number +} + +/** 下载任务单项修改结果。 */ +export interface DownloadTaskMutationResult { + operation: string + success: boolean + message: string +} + +/** 下载任务高级修改聚合结果。 */ +export interface DownloadTaskUpdateData { + hash: string + downloader: string + results: DownloadTaskMutationResult[] +} + // 缺失剧集信息 export interface NotExistMediaInfo { // 季 @@ -1950,6 +1999,14 @@ export interface StorageConf { config?: { [key: string]: any } } +// 不包含连接配置的存储选择项 +export interface StorageOption { + // 名称 + name: string + // 类型 local/alipan/u115/rclone + type: string +} + // 媒体服务器配置 export interface MediaServerConf { // 名称 @@ -1966,6 +2023,14 @@ export interface MediaServerConf { sync_interval?: number | null } +// 不包含连接配置和凭据的媒体服务器选择项 +export interface MediaServerClient { + // 实例名称 + name: string + // 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome + type: string +} + // 文件整理目录配置 export interface TransferDirectoryConf { // 名称 @@ -2010,6 +2075,26 @@ export interface TransferDirectoryConf { notify?: boolean } +// 可直接用于下载保存路径选择的目录摘要 +export interface DownloadDirectory { + // 目录名称 + name?: string + // 存储类型 + storage?: string + // 原始下载目录 + download_path?: string + // 可直接提交给下载接口的保存路径 + save_path?: string + // 优先级 + priority?: number + // 适用媒体类型 + media_type?: string + // 适用媒体分类 + media_category?: string + // 适用媒体分类稳定 ID + media_category_id?: string | null +} + // 自定义规则项 export interface CustomRule { // 规则ID @@ -2168,6 +2253,18 @@ export interface ManualTransferPayload extends Omit { fileitems?: FileItem[] } +// 手动整理目的路径匹配请求 +export interface ManualTransferTargetPathRequest { + // 单个源文件项 + fileitem?: FileItem + // 多个源文件项 + fileitems?: FileItem[] + // 整理历史记录 + logids?: number[] + // 限定目标存储 + target_storage?: string | null +} + // 手动整理目的路径匹配结果 export interface ManualTransferTargetPathData { // 目标存储 diff --git a/src/components/cards/DownloadingCard.vue b/src/components/cards/DownloadingCard.vue index 5cf6a717..16dd5f5f 100644 --- a/src/components/cards/DownloadingCard.vue +++ b/src/components/cards/DownloadingCard.vue @@ -7,18 +7,21 @@ import { useGlobalSettingsStore } from '@/stores' import { getDisplayImageUrl } from '@/utils/imageUtils' import { useI18n } from 'vue-i18n' -/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */ -interface DownloadingCardInfo extends DownloadingInfo { - site_name?: string - trackers?: string[] -} +const DownloadTaskSettingsDialog = defineAsyncComponent( + () => import('@/components/dialog/DownloadTaskSettingsDialog.vue'), +) -/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */ +/** 正在下载任务卡片,负责展示任务状态并提供设置、暂停、继续和删除操作。 */ const props = defineProps({ - info: Object as PropType, + info: Object as PropType, downloaderName: String, + downloaderType: String, }) +const emit = defineEmits<{ + updated: [] +}>() + const { t } = useI18n() const createConfirm = useConfirm() const globalSettingsStore = useGlobalSettingsStore() @@ -28,6 +31,7 @@ const cardState = ref(true) const pendingAction = ref<'delete' | 'toggle' | null>(null) const deleteConfirmationPending = ref(false) const imageLoadError = ref(false) +const settingsDialog = ref(false) const media = computed(() => props.info?.media ?? {}) watch( @@ -288,6 +292,17 @@ async function deleteDownload() { {{ isDownloading ? t('common.pause') : t('common.download') }} + + + {{ t('downloading.settings.title') }} + + diff --git a/src/components/dialog/ForkSubscribeDialog.vue b/src/components/dialog/ForkSubscribeDialog.vue index 45dc9956..7d5d867d 100644 --- a/src/components/dialog/ForkSubscribeDialog.vue +++ b/src/components/dialog/ForkSubscribeDialog.vue @@ -2,6 +2,7 @@ import api from '@/api' import { getApiBusinessErrorMessage } from '@/api/client' import { doneNProgress, startNProgress } from '@/api/nprogress' +import { followSubscriber, listFollowedSubscribers, unfollowSubscriber } from '@/api/subscription' import { SubscribeShare } from '@/api/types' import router from '@/router' import { useToast } from 'vue-toastification' @@ -52,8 +53,7 @@ function toggleExpand() { // 加载follow用户列表 async function queryFollowUsers() { try { - const result = await api.get<{ value?: string[] }>('system/setting/public/FollowSubscribers') - followUsers.value = result.value ?? [] + followUsers.value = await listFollowedSubscribers() } catch (error) { console.error(error) $toast.error(t('subscribe.requestFailed')) @@ -63,7 +63,8 @@ async function queryFollowUsers() { // follow用户 async function followUser() { try { - await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`, undefined, { feedback: 'silent' }) + if (!props.media?.share_uid) return + await followSubscriber(props.media.share_uid) queryFollowUsers() } catch (error) { console.error(error) @@ -74,12 +75,8 @@ async function followUser() { // unfollow用户 async function unfollowUser() { try { - await api.delete('subscribe/follow', { - params: { - share_uid: props.media?.share_uid, - }, - feedback: 'silent', - }) + if (!props.media?.share_uid) return + await unfollowSubscriber(props.media.share_uid) queryFollowUsers() } catch (error) { console.error(error) diff --git a/src/components/dialog/PluginCapabilitiesDialog.vue b/src/components/dialog/PluginCapabilitiesDialog.vue new file mode 100644 index 00000000..0e0bdf54 --- /dev/null +++ b/src/components/dialog/PluginCapabilitiesDialog.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/src/components/dialog/PluginDataSummaryDialog.vue b/src/components/dialog/PluginDataSummaryDialog.vue new file mode 100644 index 00000000..79420b55 --- /dev/null +++ b/src/components/dialog/PluginDataSummaryDialog.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/src/components/dialog/PluginFolderRenameDialog.vue b/src/components/dialog/PluginFolderRenameDialog.vue index b5bc169a..9ffbb074 100644 --- a/src/components/dialog/PluginFolderRenameDialog.vue +++ b/src/components/dialog/PluginFolderRenameDialog.vue @@ -14,6 +14,10 @@ const props = defineProps({ type: String, default: '', }, + saving: { + type: Boolean, + default: false, + }, }) // 定义触发的自定义事件 @@ -59,7 +63,17 @@ function confirmRename() { - 确认 + + 确认 + diff --git a/src/components/dialog/PluginFolderSettingsDialog.vue b/src/components/dialog/PluginFolderSettingsDialog.vue index f7ad6994..6d2b2860 100644 --- a/src/components/dialog/PluginFolderSettingsDialog.vue +++ b/src/components/dialog/PluginFolderSettingsDialog.vue @@ -38,16 +38,7 @@ const iconOptions = [ ] // 预设颜色选项 -const colorOptions = [ - '#2196F3', - '#4CAF50', - '#FF9800', - '#9C27B0', - '#F44336', - '#607D8B', - '#795548', - '#E91E63', -] +const colorOptions = ['#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#F44336', '#607D8B', '#795548', '#E91E63'] // 预设渐变选项 const gradientOptions = [ @@ -71,6 +62,10 @@ const props = defineProps({ type: Object as PropType, default: () => ({}), }, + saving: { + type: Boolean, + default: false, + }, }) // 定义触发的自定义事件 @@ -132,7 +127,12 @@ onMounted(() => { - + @@ -203,7 +203,15 @@ onMounted(() => { - + 保存 diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index d3f65936..0bfb8155 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -5,15 +5,18 @@ import { numberValidator } from '@/@validators' import api from '@/api' import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client' import { transferTypeOptions } from '@/api/constants' +import { listStorageOptions, listTransferDirectories } from '@/api/storage' import { FileItem, ManualTransferHistoryInfo, ManualTransferPayload, ManualTransferPreviewData, ManualTransferPreviewItem, + ManualTransferTargetPathData, + ManualTransferTargetPathRequest, MediaDataSource, MediaInfo, - StorageConf, + StorageOption, TransferDirectoryConf, TransferForm, } from '@/api/types' @@ -119,6 +122,12 @@ const previewData = ref() const manualHistoryLoading = ref(false) const manualHistoryCount = ref(0) +// 自动目的路径匹配状态 +const targetPathMatchLoading = ref(false) +const targetPathMatch = ref() +const targetPathMatchFailed = ref(false) +let targetPathMatchRequestId = 0 + interface EpisodeFormatRecommendData { rule_name?: string rule_index?: number @@ -205,7 +214,7 @@ const previewPage = ref(1) const previewPageSize = ref(20) // 所有存储 -const storages = ref([]) +const storages = ref([]) // 所有剧集组 const episodeGroups = ref<{ [key: string]: any }[]>([]) @@ -219,9 +228,7 @@ let episodeGroupQueryTimer: ReturnType | undefined // 查询存储 async function loadStorages() { try { - const result: { [key: string]: any } = await api.get('system/setting/public/Storages') - - storages.value = result.value ?? [] + storages.value = await listStorageOptions() } catch (error) { console.log(error) } @@ -359,6 +366,16 @@ const transferForm = reactive({ // 历史记录入口和文件浏览器命中的成功历史都属于重新整理。 const isReorganize = computed(() => Boolean(props.logids?.length || transferForm.reorganize)) +// 当前是否保留后端自动匹配目的路径的语义。 +const isAutomaticTargetPath = computed(() => !normalizeTargetPath(transferForm.target_path)) + +// 自动匹配返回的存储使用用户配置名称展示,未知类型回退到稳定标识。 +const matchedTargetStorageLabel = computed(() => { + const storage = targetPathMatch.value?.target_storage + if (!storage) return t('dialog.reorganize.auto') + return storages.value.find(item => item.type === storage)?.name || storage +}) + // 当前手动识别与刮削数据源;自动模式按媒体类型解析实际来源。 const mediaSource = computed(() => { if (transferForm.media_source) return transferForm.media_source @@ -396,8 +413,7 @@ const directories = ref([]) // 查询目录 async function loadDirectories() { try { - const result: { [key: string]: any } = await api.get('system/setting/public/Directories') - directories.value = result.value ?? [] + directories.value = await listTransferDirectories({ directory_type: 'library' }) } catch (error) { console.log(error) } @@ -444,6 +460,58 @@ function resetAutomaticTargetConfig() { transferForm.library_category_folder = null } +/** 构造目的路径匹配所需的最小来源请求。 */ +function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined { + const targetStorage = normalizeOptionalText(transferForm.target_storage) + if (props.logids?.length) { + return { logids: [...props.logids], target_storage: targetStorage } + } + if (!normalizedItems.value.length) return undefined + if (normalizedItems.value.length === 1) { + return { fileitem: normalizedItems.value[0], target_storage: targetStorage } + } + return { fileitems: normalizedItems.value, target_storage: targetStorage } +} + +/** 查询自动目的路径,但不覆盖用户已经明确选择的路径。 */ +async function loadTargetPathMatch() { + if (!isAutomaticTargetPath.value) return + const payload = createTargetPathMatchRequest() + if (!payload) return + + const requestId = ++targetPathMatchRequestId + targetPathMatchLoading.value = true + targetPathMatchFailed.value = false + targetPathMatch.value = undefined + try { + const result = await api.post('transfer/manual/target-path', payload, { + feedback: 'silent', + }) + if (requestId !== targetPathMatchRequestId || !isAutomaticTargetPath.value) return + targetPathMatch.value = result + } catch (error) { + if (requestId !== targetPathMatchRequestId || !isAutomaticTargetPath.value) return + targetPathMatchFailed.value = true + console.error('查询手动整理目的路径失败:', error) + } finally { + if (requestId === targetPathMatchRequestId) targetPathMatchLoading.value = false + } +} + +/** 将用户确认的自动匹配结果应用到本次整理表单。 */ +function applyTargetPathMatch() { + const match = targetPathMatch.value + const targetPath = normalizeTargetPath(match?.target_path) + if (!match || !targetPath) return + + transferForm.target_path = targetPath + transferForm.target_storage = normalizeOptionalText(match.target_storage) || 'local' + transferForm.transfer_type = normalizeOptionalText(match.transfer_type) + transferForm.scrape = match.scrape ?? false + transferForm.library_type_folder = match.library_type_folder ?? false + transferForm.library_category_folder = match.library_category_folder ?? false +} + // 监听目的路径变化,配置默认值 watch( () => transferForm.target_path, @@ -472,6 +540,14 @@ watch( }, ) +// 自动模式下切换目标存储时重新匹配,不在显式路径模式中产生额外请求。 +watch( + () => transferForm.target_storage, + () => { + if (isAutomaticTargetPath.value) void loadTargetPathMatch() + }, +) + // 监听媒体编号变化,仅在TMDB电视剧场景加载剧集组。 watch( () => transferForm.media_id, @@ -1410,12 +1486,13 @@ async function transfer(background: boolean = false) { } onMounted(async () => { - await Promise.all([loadDirectories(), loadManualTransferHistory()]) + await Promise.all([loadDirectories(), loadManualTransferHistory(), loadTargetPathMatch()]) loadStorages() loadEpisodeFormatRuleConfiguration() }) onUnmounted(() => { + targetPathMatchRequestId += 1 stopLoadingProgress() if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer) }) @@ -1431,7 +1508,7 @@ onUnmounted(() => { class="reorganize-dialog-card" :class="{ 'reorganize-dialog-card--split': previewVisible && display.mdAndUp.value }" > - + {{ dialogTitle }} {{ dialogSubtitle }} @@ -1488,6 +1565,54 @@ onUnmounted(() => { persistent-hint prepend-inner-icon="mdi-folder-outline" /> + +
+
+ + + {{ t('dialog.reorganize.targetPathMatchLoading') }} + {{ + t('dialog.reorganize.targetPathMatchFailed') + }} + + {{ + t('dialog.reorganize.targetPathMatchSuccess', { + storage: matchedTargetStorageLabel, + path: targetPathMatch.target_path, + }) + }} + + {{ t('dialog.reorganize.targetPathMatchEmpty') }} +
+ + {{ t('dialog.reorganize.useMatchedTargetPath') }} + +
+
@@ -1881,6 +2006,10 @@ onUnmounted(() => { max-block-size: min(92vh, 64rem); } +.reorganize-dialog-card__header { + padding-inline-end: 4rem !important; +} + .reorganize-dialog-card__body { min-block-size: 0; } @@ -1954,6 +2083,27 @@ onUnmounted(() => { background: rgba(var(--v-theme-info), 0.12); } +.target-path-match { + border-radius: var(--app-control-radius); +} + +.target-path-match__content, +.target-path-match__message { + display: flex; + align-items: center; + min-inline-size: 0; +} + +.target-path-match__content { + justify-content: space-between; + gap: 0.75rem; +} + +.target-path-match__message { + gap: 0.5rem; + overflow-wrap: anywhere; +} + .reorganize-preview-pane { display: flex; overflow: hidden; @@ -2309,6 +2459,15 @@ onUnmounted(() => { } @media (width <= 640px) { + .target-path-match__content { + align-items: stretch; + flex-direction: column; + } + + .target-path-match__content .v-btn { + inline-size: 100%; + } + .reorganize-form-pane__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/components/dialog/SubscribeEditDialog.vue b/src/components/dialog/SubscribeEditDialog.vue index 4a760f86..6ea2a009 100644 --- a/src/components/dialog/SubscribeEditDialog.vue +++ b/src/components/dialog/SubscribeEditDialog.vue @@ -2,7 +2,9 @@ import { useToast } from 'vue-toastification' import { numberValidator } from '@/@validators' import api from '@/api' -import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types' +import { listFilterRuleGroups } from '@/api/rule' +import { listDownloadDirectories } from '@/api/storage' +import type { DownloadDirectory, DownloaderConf, FilterRuleGroup, Site, Subscribe } from '@/api/types' import { useDisplay } from 'vuetify' import { useConfirm } from '@/composables/useConfirm' import { useI18n } from 'vue-i18n' @@ -59,7 +61,7 @@ const activeTab = ref('basic') const siteList = ref([]) // 下载目录列表 -const downloadDirectories = ref([]) +const downloadDirectories = ref([]) // 站点选择下载框 const selectSitesOptions = ref<{ [key: number]: string }[]>([]) @@ -171,11 +173,8 @@ async function loadDownloaderSetting() { // 加载规则组 async function queryFilterRuleGroups() { - if (!canAdmin.value) return - try { - const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups') - filterRuleGroups.value = result.value ?? [] + filterRuleGroups.value = await listFilterRuleGroups() } catch (error) { console.log(error) } @@ -319,8 +318,7 @@ async function removeSubscribe() { // 查询下载目录 async function loadDownloadDirectories() { try { - const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories') - downloadDirectories.value = result.value ?? [] + downloadDirectories.value = await listDownloadDirectories() } catch (error) { console.log(error) } diff --git a/src/components/dialog/__tests__/AddDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts index 710e3f93..eb3b8f75 100644 --- a/src/components/dialog/__tests__/AddDownloadDialog.spec.ts +++ b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts @@ -1,4 +1,4 @@ -import type { MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types' +import type { DownloadDirectory, MediaInfo, TorrentInfo } from '@/api/types' import AddDownloadDialog from '@/components/dialog/AddDownloadDialog.vue' import { screen, waitFor } from '@testing-library/vue' import userEvent from '@testing-library/user-event' @@ -126,13 +126,17 @@ const DialogCloseButtonStub = defineComponent({ }, }) -function createDirectory(overrides: Partial = {}): TransferDirectoryConf { +function createDirectory(overrides: Partial = {}): DownloadDirectory { + const downloadPath = Object.hasOwn(overrides, 'download_path') ? overrides.download_path : '/downloads/default' + const storage = Object.hasOwn(overrides, 'storage') ? overrides.storage : 'local' + const savePath = downloadPath && storage && storage !== 'local' ? `${storage}:${downloadPath}` : downloadPath + return { - download_path: '/downloads/default', + download_path: downloadPath, name: '下载目录', priority: 0, - storage: 'local', - transfer_type: 'link', + save_path: savePath, + storage, ...overrides, } } @@ -184,10 +188,8 @@ function createDeferred() { return { promise, resolve } } -function directoriesHandler(directories: TransferDirectoryConf[]) { - return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => - apiJson({ value: directories }), - ) +function directoriesHandler(directories: DownloadDirectory[]) { + return http.get(new URL('download/paths', API_BASE_URL).href, () => apiJson(directories)) } function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) { @@ -215,7 +217,7 @@ async function renderDialog({ recognizeSource = 'themoviedb', torrent = createTorrent(), }: { - directories?: TransferDirectoryConf[] + directories?: DownloadDirectory[] downloaders?: Array<{ name: string; type: string }> media?: MediaInfo recognizeSource?: string @@ -269,35 +271,24 @@ describe('AddDownloadDialog directories', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) }) - it('normalizes local, remote, missing-storage, and duplicate directories while loading downloaders', async () => { - const missingStorage = createDirectory({ - download_path: '/downloads/legacy', - name: '兼容目录', - storage: undefined as unknown as string, - }) - const nullStorage = createDirectory({ download_path: '/downloads/null-storage', name: '空存储目录' }) - nullStorage.storage = null as unknown as string - + it('uses API-ready paths, removes duplicates, and loads downloaders', async () => { await renderDialog({ directories: [ - createDirectory({ download_path: '/downloads/local' }), - createDirectory({ download_path: '/downloads/remote', name: '远程目录', storage: 'rclone' }), - missingStorage, - nullStorage, - createDirectory({ download_path: '/downloads/empty-storage', name: '空字符串存储', storage: '' }), + createDirectory({ download_path: '/downloads/local', save_path: '/downloads/local' }), + createDirectory({ + download_path: '/downloads/remote', + name: '远程目录', + save_path: 'rclone:/downloads/remote', + storage: 'rclone', + }), createDirectory({ download_path: '/downloads/remote', name: '重复目录', storage: 'rclone' }), - createDirectory({ download_path: undefined, name: '无下载路径' }), + createDirectory({ download_path: undefined, name: '无下载路径', save_path: undefined }), ], downloaders: [{ name: '下载器 A', type: 'qbittorrent' }], }) expect(await screen.findByRole('option', { name: '/downloads/local' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: '/downloads/legacy' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: '/downloads/null-storage' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: ':/downloads/empty-storage' })).toBeInTheDocument() expect(screen.getAllByRole('option', { name: 'rclone:/downloads/remote' })).toHaveLength(1) - expect(screen.queryByText('undefined:/downloads/legacy')).not.toBeInTheDocument() - expect(screen.queryByText('null:/downloads/null-storage')).not.toBeInTheDocument() expect(await screen.findByRole('option', { name: '下载器 A' })).toBeInTheDocument() expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('') expect(screen.getByLabelText('下载器(默认)')).toHaveValue('') diff --git a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts index 25b30f07..08adfeef 100644 --- a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts +++ b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts @@ -1,4 +1,4 @@ -import type { MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types' +import type { DownloadDirectory, MediaDataSource, SubtitleInfo } from '@/api/types' import AddSubtitleDownloadDialog from '@/components/dialog/AddSubtitleDownloadDialog.vue' import { screen, waitFor } from '@testing-library/vue' import userEvent from '@testing-library/user-event' @@ -118,13 +118,17 @@ const DialogCloseButtonStub = defineComponent({ }, }) -function createDirectory(overrides: Partial = {}): TransferDirectoryConf { +function createDirectory(overrides: Partial = {}): DownloadDirectory { + const downloadPath = Object.hasOwn(overrides, 'download_path') ? overrides.download_path : '/subtitles/default' + const storage = Object.hasOwn(overrides, 'storage') ? overrides.storage : 'local' + const savePath = downloadPath && storage && storage !== 'local' ? `${storage}:${downloadPath}` : downloadPath + return { - download_path: '/subtitles/default', + download_path: downloadPath, name: '字幕目录', priority: 0, - storage: 'local', - transfer_type: 'link', + save_path: savePath, + storage, ...overrides, } } @@ -150,10 +154,8 @@ function createDeferred() { return { promise, resolve } } -function directoriesHandler(directories: TransferDirectoryConf[]) { - return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => - apiJson({ value: directories }), - ) +function directoriesHandler(directories: DownloadDirectory[]) { + return http.get(new URL('download/paths', API_BASE_URL).href, () => apiJson(directories)) } function subtitleDownloadHandler( @@ -176,7 +178,7 @@ async function renderDialog({ recognizeSource = 'themoviedb', subtitle = createSubtitle(), }: { - directories?: TransferDirectoryConf[] + directories?: DownloadDirectory[] mediaId?: string | null mediaSource?: MediaDataSource recognizeSource?: string @@ -230,34 +232,23 @@ describe('AddSubtitleDownloadDialog directories', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) }) - it('normalizes local, remote, missing-storage, and duplicate directories while keeping the default empty option', async () => { - const missingStorage = createDirectory({ - download_path: '/subtitles/legacy', - name: '兼容目录', - storage: undefined as unknown as string, - }) - const nullStorage = createDirectory({ download_path: '/subtitles/null-storage', name: '空存储目录' }) - nullStorage.storage = null as unknown as string - + it('uses API-ready paths, removes duplicates, and keeps the default empty option', async () => { await renderDialog({ directories: [ - createDirectory({ download_path: '/subtitles/local' }), - createDirectory({ download_path: '/subtitles/remote', name: '远程目录', storage: 's3' }), - missingStorage, - nullStorage, - createDirectory({ download_path: '/subtitles/empty-storage', name: '空字符串存储', storage: '' }), + createDirectory({ download_path: '/subtitles/local', save_path: '/subtitles/local' }), + createDirectory({ + download_path: '/subtitles/remote', + name: '远程目录', + save_path: 's3:/subtitles/remote', + storage: 's3', + }), createDirectory({ download_path: '/subtitles/remote', name: '重复目录', storage: 's3' }), - createDirectory({ download_path: undefined, name: '无下载路径' }), + createDirectory({ download_path: undefined, name: '无下载路径', save_path: undefined }), ], }) expect(await screen.findByRole('option', { name: '/subtitles/local' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: '/subtitles/legacy' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: '/subtitles/null-storage' })).toBeInTheDocument() - expect(screen.getByRole('option', { name: ':/subtitles/empty-storage' })).toBeInTheDocument() expect(screen.getAllByRole('option', { name: 's3:/subtitles/remote' })).toHaveLength(1) - expect(screen.queryByText('undefined:/subtitles/legacy')).not.toBeInTheDocument() - expect(screen.queryByText('null:/subtitles/null-storage')).not.toBeInTheDocument() expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('') }) }) diff --git a/src/components/dialog/__tests__/DownloadTaskSettingsDialog.spec.ts b/src/components/dialog/__tests__/DownloadTaskSettingsDialog.spec.ts new file mode 100644 index 00000000..cfaae64d --- /dev/null +++ b/src/components/dialog/__tests__/DownloadTaskSettingsDialog.spec.ts @@ -0,0 +1,158 @@ +import type { DownloadingInfo, DownloadTaskUpdateData, DownloadTaskUpdateRequest } from '@/api/types' +import DownloadTaskSettingsDialog from '@/components/dialog/DownloadTaskSettingsDialog.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { updateDownloadTaskHandler } from '@tests/support/msw/handlers/download' +import { server } from '@tests/support/msw/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastWarning: vi.fn(), +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ + error: mocks.toastError, + success: mocks.toastSuccess, + warning: mocks.toastWarning, + }), +})) + +const HASH = '0123456789abcdef0123456789abcdef01234567' + +/** 创建包含后端高级字段的最小下载任务。 */ +function task(overrides: Partial = {}): DownloadingInfo { + return { + category: 'movies', + download_limit: 128, + hash: HASH, + media: {}, + ratio_limit: 2, + save_path: '/downloads', + seeding_time_limit: 60, + title: '测试下载任务', + upload_limit: 64, + ...overrides, + } +} + +/** 创建指定逐项结果的高级修改响应。 */ +function mutationData(results: DownloadTaskUpdateData['results']): DownloadTaskUpdateData { + return { downloader: 'qb-main', hash: HASH, results } +} + +/** 渲染下载任务高级设置弹窗。 */ +async function renderDialog(downloaderType = 'qbittorrent', currentTask = task()) { + return renderWithProviders(DownloadTaskSettingsDialog, { + global: { + stubs: { VDialogCloseBtn: true }, + }, + props: { + downloaderName: 'qb-main', + downloaderType, + modelValue: true, + task: currentTask, + }, + }) +} + +beforeEach(() => { + mocks.toastError.mockReset() + mocks.toastSuccess.mockReset() + mocks.toastWarning.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('DownloadTaskSettingsDialog', () => { + it('submits only changed values and explicit additions', async () => { + const requested = vi.fn<(body: DownloadTaskUpdateRequest) => void>() + server.use( + updateDownloadTaskHandler( + HASH, + { + success: true, + data: mutationData([ + { message: '限速/做种策略修改成功', operation: 'limits', success: true }, + { message: 'Tracker修改成功', operation: 'trackers', success: true }, + { message: '分类修改成功', operation: 'category', success: true }, + ]), + }, + 200, + requested, + ), + ) + const { emitted } = await renderDialog() + + await fireEvent.update(screen.getByLabelText('下载限速'), '256') + await fireEvent.update(screen.getByLabelText('下载器分类'), 'archive') + await fireEvent.update(screen.getByLabelText('更新 Tracker'), 'https://tracker.example.com/announce') + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(requested).toHaveBeenCalledWith({ + category: 'archive', + download_limit: 256, + downloader: 'qb-main', + trackers: ['https://tracker.example.com/announce'], + }) + expect(mocks.toastSuccess).toHaveBeenCalledWith('下载任务设置已保存') + expect(emitted().saved).toHaveLength(1) + expect(emitted()['update:modelValue']).toContainEqual([false]) + }) + + it('keeps partial operation results visible instead of reporting full success', async () => { + const requested = vi.fn<(body: DownloadTaskUpdateRequest) => void>() + server.use( + updateDownloadTaskHandler( + HASH, + { + success: false, + message: '', + data: mutationData([ + { message: '保存目录修改成功', operation: 'save_path', success: true }, + { message: '分类修改失败或下载器不支持', operation: 'category', success: false }, + ]), + }, + 200, + requested, + ), + ) + const { emitted } = await renderDialog() + + await fireEvent.update(screen.getByLabelText('保存目录'), '/new-downloads') + await fireEvent.update(screen.getByLabelText('下载器分类'), 'archive') + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + expect(await screen.findByText('保存目录修改成功')).toBeInTheDocument() + expect(screen.getByText('分类修改失败或下载器不支持')).toBeInTheDocument() + expect(mocks.toastWarning).toHaveBeenCalledWith('部分设置未生效,请查看逐项结果') + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(emitted().saved).toHaveLength(1) + expect(emitted()['update:modelValue']).toBeUndefined() + + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + await waitFor(() => expect(requested).toHaveBeenCalledTimes(2)) + expect(requested.mock.calls[1][0]).toEqual({ category: 'archive', downloader: 'qb-main' }) + }) + + it('hides settings that rTorrent cannot apply while retaining common controls', async () => { + await renderDialog('rtorrent') + + expect(screen.getByLabelText('下载限速')).toBeInTheDocument() + expect(screen.getByLabelText('上传限速')).toBeInTheDocument() + expect(screen.getByLabelText('保存目录')).toBeInTheDocument() + expect(screen.getByLabelText('添加标签')).toBeInTheDocument() + expect(screen.queryByLabelText('分享率限制')).not.toBeInTheDocument() + expect(screen.queryByLabelText('做种时间限制')).not.toBeInTheDocument() + expect(screen.queryByLabelText('下载器分类')).not.toBeInTheDocument() + expect(screen.queryByLabelText('更新 Tracker')).not.toBeInTheDocument() + }) + + it('does not submit an unchanged task', async () => { + await renderDialog() + + expect(screen.getByRole('button', { name: '保存' })).toBeDisabled() + }) +}) diff --git a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts index b14bed21..04b5f06d 100644 --- a/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts +++ b/src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts @@ -6,7 +6,7 @@ import { createSubscribeShare } from '@tests/support/factories/subscribe' import { deleteSubscribeShareHandler, followSubscriberHandler, - followSubscribersSettingHandler, + followedSubscribersHandler, forkSubscribeHandler, unfollowSubscriberHandler, } from '@tests/support/msw/handlers/subscribe' @@ -126,7 +126,7 @@ describe('ForkSubscribeDialog follow behavior', () => { it('loads the followed users and shows the current action', async () => { const media = createSubscribeShare({ share_uid: 'followed-user' }) const requested = vi.fn() - server.use(followSubscribersSettingHandler(['followed-user'], 200, requested)) + server.use(followedSubscribersHandler(['followed-user'], 200, requested)) await renderDialog(media) @@ -136,7 +136,7 @@ describe('ForkSubscribeDialog follow behavior', () => { }) it('places long recognition words in a full-width metadata row', async () => { - server.use(followSubscribersSettingHandler([])) + server.use(followedSubscribersHandler([])) const { media } = await renderDialog( createSubscribeShare({ custom_words: '#九门2026\n【ADWeb】\n^The.Mystic.Nine => 九门.The.Mystic.Nine', @@ -156,7 +156,7 @@ describe('ForkSubscribeDialog follow behavior', () => { const writeRequest = vi.fn((url: URL) => { users.push(url.searchParams.get('share_uid') || '') }) - server.use(followSubscribersSettingHandler(users), followSubscriberHandler({ success: true }, 200, writeRequest)) + server.use(followedSubscribersHandler(users), followSubscriberHandler({ success: true }, 200, writeRequest)) const user = userEvent.setup() await renderDialog(media) @@ -174,7 +174,7 @@ describe('ForkSubscribeDialog follow behavior', () => { const writeRequest = vi.fn((url: URL) => { users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1) }) - server.use(followSubscribersSettingHandler(users), unfollowSubscriberHandler({ success: true }, 200, writeRequest)) + server.use(followedSubscribersHandler(users), unfollowSubscriberHandler({ success: true }, 200, writeRequest)) const user = userEvent.setup() await renderDialog(media) @@ -188,7 +188,7 @@ describe('ForkSubscribeDialog follow behavior', () => { it('does not show follow actions when the share has no UID', async () => { const media = createSubscribeShare({ share_uid: undefined }) - server.use(followSubscribersSettingHandler([])) + server.use(followedSubscribersHandler([])) await renderDialog(media) await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument()) @@ -198,7 +198,7 @@ describe('ForkSubscribeDialog follow behavior', () => { }) it('shows visible feedback when the followed-user list request fails', async () => { - server.use(followSubscribersSettingHandler([], 500)) + server.use(followedSubscribersHandler([], 500)) await renderDialog(createSubscribeShare()) @@ -207,7 +207,7 @@ describe('ForkSubscribeDialog follow behavior', () => { it('keeps the follow action and shows feedback when the follow request fails', async () => { const media = createSubscribeShare({ share_uid: 'failed-follow-user' }) - server.use(followSubscribersSettingHandler([]), followSubscriberHandler({ success: true }, 500)) + server.use(followedSubscribersHandler([]), followSubscriberHandler({ success: true }, 500)) const user = userEvent.setup() await renderDialog(media) @@ -219,10 +219,7 @@ describe('ForkSubscribeDialog follow behavior', () => { it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => { const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' }) - server.use( - followSubscribersSettingHandler(['failed-unfollow-user']), - unfollowSubscriberHandler({ success: true }, 500), - ) + server.use(followedSubscribersHandler(['failed-unfollow-user']), unfollowSubscriberHandler({ success: true }, 500)) const user = userEvent.setup() await renderDialog(media) @@ -245,7 +242,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { const deferred = createDeferred() const forkPayload = vi.fn(() => deferred.promise) server.use( - followSubscribersSettingHandler([]), + followedSubscribersHandler([]), forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload), ) const user = userEvent.setup() @@ -264,7 +261,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { }) it('reports a fork business failure and does not emit', async () => { - server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ message: '订阅已存在', success: false })) + server.use(followedSubscribersHandler([]), forkSubscribeHandler({ message: '订阅已存在', success: false })) const user = userEvent.setup() const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' })) @@ -276,7 +273,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { }) it('reports an HTTP fork failure, restores the action, and does not emit', async () => { - server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ success: true }, 500)) + server.use(followedSubscribersHandler([]), forkSubscribeHandler({ success: true }, 500)) const user = userEvent.setup() const { events } = await renderDialog(createSubscribeShare()) @@ -292,7 +289,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { ['a share manager', 'other-user', true, true], ['another ordinary user', 'other-user', false, false], ])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => { - server.use(followSubscribersSettingHandler([])) + server.use(followedSubscribersHandler([])) await renderDialog(createSubscribeShare({ share_uid: shareUid }), { SUBSCRIBE_SHARE_MANAGE: canManage, @@ -308,10 +305,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { const media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' }) const deferred = createDeferred() const deleteRequest = vi.fn((_url: URL) => deferred.promise) - server.use( - followSubscribersSettingHandler([]), - deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest), - ) + server.use(followedSubscribersHandler([]), deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest)) const user = userEvent.setup() const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' }) const deleteButton = screen.getByRole('button', { name: '取消分享' }) @@ -330,7 +324,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { it('reports a delete business failure and does not emit', async () => { const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' }) server.use( - followSubscribersSettingHandler([]), + followedSubscribersHandler([]), deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }), ) const user = userEvent.setup() @@ -345,7 +339,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { it('reports an HTTP delete failure, restores the action, and does not emit', async () => { const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' }) - server.use(followSubscribersSettingHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500)) + server.use(followedSubscribersHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500)) const user = userEvent.setup() const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' }) @@ -357,7 +351,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { }) it('emits close from the dialog close control', async () => { - server.use(followSubscribersSettingHandler([])) + server.use(followedSubscribersHandler([])) const user = userEvent.setup() const { events } = await renderDialog(createSubscribeShare()) @@ -371,7 +365,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => { media_id: mediaId, media_source: mediaSource as SubscribeShare['media_source'], }) - server.use(followSubscribersSettingHandler([])) + server.use(followedSubscribersHandler([])) const user = userEvent.setup() await renderDialog(media) diff --git a/src/components/dialog/__tests__/PluginCapabilitiesDialog.spec.ts b/src/components/dialog/__tests__/PluginCapabilitiesDialog.spec.ts new file mode 100644 index 00000000..d01f3aa7 --- /dev/null +++ b/src/components/dialog/__tests__/PluginCapabilitiesDialog.spec.ts @@ -0,0 +1,77 @@ +import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' +import type { PluginRuntimeCapabilities } from '@/api/pluginCapabilities' +import type { Plugin } from '@/api/types' +import PluginCapabilitiesDialog from '@/components/dialog/PluginCapabilitiesDialog.vue' +import { renderWithProviders } from '@tests/support/render' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ getCapabilities: vi.fn() })) + +vi.mock('@/api/pluginCapabilities', async importOriginal => ({ + ...(await importOriginal()), + getPluginRuntimeCapabilities: mocks.getCapabilities, +})) + +const plugin: Plugin = { + id: 'DemoPlugin', + plugin_name: '演示插件', + installed: true, +} + +const capabilities: PluginRuntimeCapabilities = { + commands: [{ cmd: '/demo', desc: '执行演示命令' }], + actions: [ + { + plugin_id: 'DemoPlugin', + plugin_name: '演示插件', + actions: [{ id: 'refresh', name: '刷新数据' }], + }, + ], + services: [{ id: 'daily', name: '每日任务', trigger: "cron[hour='1']" }], +} + +/** 渲染插件能力弹窗并注册真实关闭按钮。 */ +async function renderDialog() { + return renderWithProviders(PluginCapabilitiesDialog, { + props: { modelValue: true, plugin }, + global: { components: { VDialogCloseBtn: DialogCloseBtn } }, + }) +} + +describe('PluginCapabilitiesDialog', () => { + beforeEach(() => { + mocks.getCapabilities.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + it('loads and renders commands, actions and scheduled services', async () => { + mocks.getCapabilities.mockResolvedValueOnce(capabilities) + await renderDialog() + + expect(await screen.findByText('演示插件运行能力')).toBeInTheDocument() + expect(screen.getByText('/demo')).toBeInTheDocument() + expect(screen.getByText('执行演示命令')).toBeInTheDocument() + expect(screen.getByText('刷新数据')).toBeInTheDocument() + expect(screen.getByText('每日任务').closest('.v-list-item')).toHaveTextContent("daily · cron[hour='1']") + expect(mocks.getCapabilities).toHaveBeenCalledWith('DemoPlugin') + }) + + it('shows an explicit empty state', async () => { + mocks.getCapabilities.mockResolvedValueOnce({ actions: [], commands: [], services: [] }) + await renderDialog() + + expect(await screen.findByText('该插件当前没有注册命令、动作或定时服务')).toBeInTheDocument() + }) + + it('keeps failure local and retries the same plugin', async () => { + mocks.getCapabilities.mockRejectedValueOnce(new Error('network unavailable')).mockResolvedValueOnce(capabilities) + await renderDialog() + + expect(await screen.findByText('运行能力加载失败,请稍后重试')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '重试' })) + + await waitFor(() => expect(mocks.getCapabilities).toHaveBeenCalledTimes(2)) + expect(await screen.findByText('/demo')).toBeInTheDocument() + }) +}) diff --git a/src/components/dialog/__tests__/PluginDataSummaryDialog.spec.ts b/src/components/dialog/__tests__/PluginDataSummaryDialog.spec.ts new file mode 100644 index 00000000..ed5c3a40 --- /dev/null +++ b/src/components/dialog/__tests__/PluginDataSummaryDialog.spec.ts @@ -0,0 +1,74 @@ +import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' +import type { PluginDataSummary } from '@/api/pluginData' +import type { Plugin } from '@/api/types' +import PluginDataSummaryDialog from '@/components/dialog/PluginDataSummaryDialog.vue' +import { renderWithProviders } from '@tests/support/render' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ getSummary: vi.fn() })) + +vi.mock('@/api/pluginData', async importOriginal => ({ + ...(await importOriginal()), + getPluginDataSummary: mocks.getSummary, +})) + +const plugin: Plugin = { id: 'DemoPlugin', plugin_name: '演示插件', installed: true } +const summary: PluginDataSummary = { + plugin_id: 'DemoPlugin', + plugin_name: '演示插件', + plugin_version: '1.0.0', + state: true, + count: 2, + total_chars: 28, + keys_truncated: false, + keys: [ + { key: 'api_token', value_type: 'string', serialized_chars: 14, sensitive: true }, + { key: 'history', value_type: 'array', serialized_chars: 14, sensitive: false }, + ], +} + +/** 渲染插件数据摘要弹窗并注册真实关闭按钮。 */ +async function renderDialog() { + return renderWithProviders(PluginDataSummaryDialog, { + props: { modelValue: true, plugin }, + global: { components: { VDialogCloseBtn: DialogCloseBtn } }, + }) +} + +describe('PluginDataSummaryDialog', () => { + beforeEach(() => { + mocks.getSummary.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + it('renders key metadata without exposing any persisted values', async () => { + mocks.getSummary.mockResolvedValueOnce(summary) + await renderDialog() + + expect(await screen.findByText('演示插件数据诊断')).toBeInTheDocument() + expect(screen.getByText('api_token')).toBeInTheDocument() + expect(screen.getByText('敏感键')).toBeInTheDocument() + expect(screen.getByText('history')).toBeInTheDocument() + expect(screen.queryByText('secret-token')).not.toBeInTheDocument() + expect(mocks.getSummary).toHaveBeenCalledWith('DemoPlugin') + }) + + it('shows an explicit empty state', async () => { + mocks.getSummary.mockResolvedValueOnce({ ...summary, count: 0, total_chars: 0, keys: [] }) + await renderDialog() + + expect(await screen.findByText('该插件当前没有持久化数据')).toBeInTheDocument() + }) + + it('retries the same plugin after a local load failure', async () => { + mocks.getSummary.mockRejectedValueOnce(new Error('network unavailable')).mockResolvedValueOnce(summary) + await renderDialog() + + expect(await screen.findByText('数据摘要加载失败,请稍后重试')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '重试' })) + + await waitFor(() => expect(mocks.getSummary).toHaveBeenCalledTimes(2)) + expect(await screen.findByText('api_token')).toBeInTheDocument() + }) +}) diff --git a/src/components/dialog/__tests__/ReorganizeDialog.spec.ts b/src/components/dialog/__tests__/ReorganizeDialog.spec.ts index 788c56e9..2d29a9f5 100644 --- a/src/components/dialog/__tests__/ReorganizeDialog.spec.ts +++ b/src/components/dialog/__tests__/ReorganizeDialog.spec.ts @@ -1,4 +1,11 @@ -import type { ApiResponse, FileItem, StorageConf, TransferDirectoryConf } from '@/api/types' +import type { + ApiResponse, + FileItem, + ManualTransferTargetPathData, + ManualTransferTargetPathRequest, + StorageConf, + TransferDirectoryConf, +} from '@/api/types' import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue' import { fireEvent, screen, waitFor } from '@testing-library/vue' import userEvent from '@testing-library/user-event' @@ -252,20 +259,26 @@ function apiEnvelope(data: T | null, success = true, message = ''): ApiRespon function publicSettingHandlers({ directories = [], episodeRules = [], + onTargetPathRequest, storages = [], + targetPathMatch = {}, + targetPathStatus = 200, }: { directories?: TransferDirectoryConf[] episodeRules?: unknown[] + onTargetPathRequest?: (payload: ManualTransferTargetPathRequest) => void storages?: StorageConf[] + targetPathMatch?: ManualTransferTargetPathData + targetPathStatus?: number } = {}) { return [ - http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => { + http.get(new URL('storage/directories', API_BASE_URL).href, () => { initializationRequestCount += 1 - return HttpResponse.json(apiEnvelope({ value: directories })) + return HttpResponse.json(apiEnvelope(directories)) }), - http.get(new URL('system/setting/public/Storages', API_BASE_URL).href, () => { + http.get(new URL('storage/options', API_BASE_URL).href, () => { initializationRequestCount += 1 - return HttpResponse.json(apiEnvelope({ value: storages })) + return HttpResponse.json(apiEnvelope(storages)) }), http.get(new URL('system/setting/public/EpisodeFormatRuleTable', API_BASE_URL).href, () => { initializationRequestCount += 1 @@ -275,6 +288,13 @@ function publicSettingHandlers({ initializationRequestCount += 1 return HttpResponse.json(apiEnvelope({ history_count: 0, reorganize: false })) }), + http.post(new URL('transfer/manual/target-path', API_BASE_URL).href, async ({ request }) => { + onTargetPathRequest?.((await request.json()) as ManualTransferTargetPathRequest) + if (targetPathStatus >= 400) { + return HttpResponse.json({ detail: 'target path unavailable' }, { status: targetPathStatus }) + } + return HttpResponse.json(apiEnvelope(targetPathMatch)) + }), ] } @@ -285,7 +305,12 @@ async function renderDialog({ logids, onClose = vi.fn(), onDone = vi.fn(), + onTargetPathRequest, storages = [], + targetPath, + targetPathMatch = {}, + targetPathStatus = 200, + targetStorage, }: { directories?: TransferDirectoryConf[] episodeRules?: unknown[] @@ -293,10 +318,24 @@ async function renderDialog({ logids?: number[] onClose?: ReturnType onDone?: ReturnType + onTargetPathRequest?: (payload: ManualTransferTargetPathRequest) => void storages?: StorageConf[] + targetPath?: string + targetPathMatch?: ManualTransferTargetPathData + targetPathStatus?: number + targetStorage?: string } = {}) { const resolvedItems = items ?? (logids?.length ? [] : [createFileItem()]) - server.use(...publicSettingHandlers({ directories, episodeRules, storages })) + server.use( + ...publicSettingHandlers({ + directories, + episodeRules, + onTargetPathRequest, + storages, + targetPathMatch, + targetPathStatus, + }), + ) const result = await renderWithProviders(ReorganizeDialog, { global: { stubs: { @@ -325,6 +364,8 @@ async function renderDialog({ modelValue: true, onClose, onDone, + target_path: targetPath, + target_storage: targetStorage, }, }) @@ -718,6 +759,112 @@ describe('ReorganizeDialog payloads and lifecycle', () => { ) }) + it('previews and explicitly applies the backend matched target path', async () => { + const bodies: unknown[] = [] + const targetRequests: ManualTransferTargetPathRequest[] = [] + const directory: TransferDirectoryConf = { + library_category_folder: true, + library_path: '/library/tv', + library_storage: 'rclone', + library_type_folder: true, + name: '电视剧目录', + priority: 1, + scraping: true, + storage: 'local', + transfer_type: 'copy', + } + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(apiEnvelope(null)) + }), + ) + const user = userEvent.setup() + await renderDialog({ + directories: [directory], + onTargetPathRequest: payload => targetRequests.push(payload), + storages: [{ name: '远程存储', type: 'rclone' }], + targetPathMatch: { + library_category_folder: true, + library_type_folder: true, + scrape: true, + target_path: '/library/tv', + target_storage: 'rclone', + transfer_type: 'copy', + }, + }) + + expect(await screen.findByText('自动匹配到 远程存储 · /library/tv')).toBeInTheDocument() + expect(targetRequests).toEqual([ + { + fileitem: expect.objectContaining({ path: '/downloads/Movie.mkv' }), + target_storage: null, + }, + ]) + await user.click(screen.getByRole('button', { name: '使用匹配路径' })) + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(bodies).toHaveLength(1)) + expect(bodies[0]).toEqual( + expect.objectContaining({ + library_category_folder: true, + library_type_folder: true, + scrape: true, + target_path: '/library/tv', + target_storage: 'rclone', + transfer_type: 'copy', + }), + ) + }) + + it('does not request or overwrite an explicit target path', async () => { + const bodies: unknown[] = [] + const targetRequest = vi.fn() + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(apiEnvelope(null)) + }), + ) + const user = userEvent.setup() + await renderDialog({ + onTargetPathRequest: targetRequest, + targetPath: '/custom/library', + targetStorage: 'local', + }) + + expect(screen.queryByText(/自动匹配到/)).not.toBeInTheDocument() + expect(targetRequest).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(bodies).toHaveLength(1)) + expect(bodies[0]).toEqual( + expect.objectContaining({ + target_path: '/custom/library', + target_storage: 'local', + }), + ) + }) + + it('keeps manual organization available when target path matching fails', async () => { + const bodies: unknown[] = [] + server.use( + http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(apiEnvelope(null)) + }), + ) + const user = userEvent.setup() + const { onDone } = await renderDialog({ targetPathStatus: 503 }) + + expect(await screen.findByText('无法预览自动目的路径,不影响手动选择或整理')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '加入整理队列' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)) + expect(bodies).toHaveLength(1) + expect(bodies[0]).toEqual(expect.objectContaining({ target_path: null, target_storage: null })) + }) + it('submits media selection, episode group, episode formatting, and folder options from the form', async () => { const bodies: unknown[] = [] server.use( diff --git a/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts index fe6673eb..c6b875d7 100644 --- a/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeEditDialog.spec.ts @@ -199,7 +199,7 @@ describe('SubscribeEditDialog', () => { expect(await screen.findByText('高优先级')).toBeInTheDocument() }) - it('allows non-admin users to read public defaults but not private rules or save them', async () => { + it('allows non-admin users to read public defaults and rule groups but not save administrator defaults', async () => { const configRequested = vi.fn() const rulesRequested = vi.fn() const saved = vi.fn() @@ -211,7 +211,7 @@ describe('SubscribeEditDialog', () => { const { events } = await renderDialog({ default: true, type: '电视剧' }, false) await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) - expect(rulesRequested).not.toHaveBeenCalled() + await waitFor(() => expect(rulesRequested).toHaveBeenCalledOnce()) await fireEvent.click(screen.getByRole('button', { name: '保存' })) expect(saved).not.toHaveBeenCalled() diff --git a/src/components/system/TransferHistoryMaintenancePanel.vue b/src/components/system/TransferHistoryMaintenancePanel.vue new file mode 100644 index 00000000..80f3c67a --- /dev/null +++ b/src/components/system/TransferHistoryMaintenancePanel.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/system/__tests__/TransferHistoryMaintenancePanel.spec.ts b/src/components/system/__tests__/TransferHistoryMaintenancePanel.spec.ts new file mode 100644 index 00000000..1daae5f8 --- /dev/null +++ b/src/components/system/__tests__/TransferHistoryMaintenancePanel.spec.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { cwd } from 'node:process' +import TransferHistoryMaintenancePanel from '@/components/system/TransferHistoryMaintenancePanel.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const componentSource = readFileSync( + resolve(cwd(), 'src/components/system/TransferHistoryMaintenancePanel.vue'), + 'utf8', +) + +const mocks = vi.hoisted(() => ({ + clearHistory: vi.fn(), + confirm: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@/api/history', () => ({ + clearLegacyTransferHistory: mocks.clearHistory, +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.confirm, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => ({ + error: mocks.toastError, + success: mocks.toastSuccess, + }), +})) + +function renderPanel(superUser = true) { + return renderWithProviders(TransferHistoryMaintenancePanel, { + initialState: { user: { superUser } }, + }) +} + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('TransferHistoryMaintenancePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.confirm.mockResolvedValue(true) + mocks.clearHistory.mockResolvedValue(undefined) + }) + + it('clears transfer history only after explicit confirmation', async () => { + await renderPanel() + + await fireEvent.click(screen.getByRole('button', { name: '清空整理历史' })) + + await waitFor(() => expect(mocks.clearHistory).toHaveBeenCalledOnce()) + expect(mocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + confirmText: '清空整理历史', + content: expect.stringContaining('不会删除源文件或媒体库文件'), + title: '清空整理历史', + }), + ) + expect(mocks.toastSuccess).toHaveBeenCalledWith('旧整理记录已清空,失败任务记录已保留') + }) + + it('does not call the destructive endpoint when confirmation is cancelled', async () => { + mocks.confirm.mockResolvedValue(false) + await renderPanel() + + await fireEvent.click(screen.getByRole('button', { name: '清空整理历史' })) + + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + expect(mocks.clearHistory).not.toHaveBeenCalled() + }) + + it('blocks duplicate submissions while the clear request is pending', async () => { + const request = createDeferred() + mocks.clearHistory.mockReturnValue(request.promise) + await renderPanel() + const button = screen.getByRole('button', { name: '清空整理历史' }) + + await fireEvent.click(button) + await waitFor(() => expect(mocks.clearHistory).toHaveBeenCalledOnce()) + await fireEvent.click(button) + + expect(mocks.confirm).toHaveBeenCalledOnce() + expect(mocks.clearHistory).toHaveBeenCalledOnce() + request.resolve() + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce()) + }) + + it('reports a failed clear and remains available for retry', async () => { + mocks.clearHistory.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValueOnce(undefined) + await renderPanel() + const button = screen.getByRole('button', { name: '清空整理历史' }) + + await fireEvent.click(button) + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('整理历史清空失败,请稍后重试')) + await fireEvent.click(button) + + await waitFor(() => expect(mocks.clearHistory).toHaveBeenCalledTimes(2)) + expect(mocks.toastSuccess).toHaveBeenCalledWith('旧整理记录已清空,失败任务记录已保留') + }) + + it('is hidden for non-superusers and uses a full-width mobile action', async () => { + await renderPanel(false) + + expect(screen.queryByRole('button', { name: '清空整理历史' })).not.toBeInTheDocument() + expect(componentSource).toContain('@media (max-width: 600px)') + expect(componentSource).toContain('inline-size: 100%;') + }) +}) diff --git a/src/components/workflow/FilterTorrentsAction.vue b/src/components/workflow/FilterTorrentsAction.vue index 5b8d11d8..87daade4 100644 --- a/src/components/workflow/FilterTorrentsAction.vue +++ b/src/components/workflow/FilterTorrentsAction.vue @@ -1,17 +1,11 @@