mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
feat(ui): complete structured API capability entries
This commit is contained in:
@@ -209,7 +209,7 @@
|
|||||||
},
|
},
|
||||||
"src/components/dialog/ReorganizeDialog.vue": {
|
"src/components/dialog/ReorganizeDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 5
|
"count": 3
|
||||||
},
|
},
|
||||||
"sonarjs/super-linear-regex": {
|
"sonarjs/super-linear-regex": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"src/components/dialog/SubscribeEditDialog.vue": {
|
"src/components/dialog/SubscribeEditDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 3
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/SubscribeHistoryDialog.vue": {
|
"src/components/dialog/SubscribeHistoryDialog.vue": {
|
||||||
@@ -441,9 +441,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/workflow/FilterTorrentsAction.vue": {
|
"src/components/workflow/FilterTorrentsAction.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"vue/no-mutating-props": {
|
"vue/no-mutating-props": {
|
||||||
"count": 7
|
"count": 7
|
||||||
}
|
}
|
||||||
@@ -459,9 +456,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/workflow/ScanFileAction.vue": {
|
"src/components/workflow/ScanFileAction.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"vue/no-mutating-props": {
|
"vue/no-mutating-props": {
|
||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
@@ -736,16 +730,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/views/discover/MediaCardSlideView.vue": {
|
||||||
"@typescript-eslint/no-unused-vars": {
|
"@typescript-eslint/no-unused-vars": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -771,7 +755,7 @@
|
|||||||
},
|
},
|
||||||
"src/views/setting/AccountSettingDirectory.vue": {
|
"src/views/setting/AccountSettingDirectory.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 3
|
"count": 2
|
||||||
},
|
},
|
||||||
"sonarjs/super-linear-regex": {
|
"sonarjs/super-linear-regex": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -849,19 +833,9 @@
|
|||||||
"count": 1
|
"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": {
|
"src/views/system/WordsView.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/views/workflow/WorkflowShareView.vue": {
|
"src/views/workflow/WorkflowShareView.vue": {
|
||||||
@@ -877,4 +851,4 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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: ['组/一'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string>('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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string[]> {
|
||||||
|
const result = await api.get<CustomIdentifiersResult>('system/identifiers', { feedback: 'silent' })
|
||||||
|
return normalizeCustomIdentifiers(result?.identifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基于上次读取的完整列表条件替换自定义识别词。 */
|
||||||
|
export async function replaceCustomIdentifiers(
|
||||||
|
identifiers: string[],
|
||||||
|
expectedIdentifiers: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
const result = await api.post<CustomIdentifiersResult>(
|
||||||
|
'system/identifiers',
|
||||||
|
{
|
||||||
|
identifiers,
|
||||||
|
expected_identifiers: expectedIdentifiers,
|
||||||
|
},
|
||||||
|
{ feedback: 'silent' },
|
||||||
|
)
|
||||||
|
return Array.isArray(result?.identifiers) ? normalizeCustomIdentifiers(result.identifiers) : [...identifiers]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
/** 清空全部旧整理记录;后端会保留持久失败任务,且不会删除任何文件。 */
|
||||||
|
export async function clearLegacyTransferHistory(): Promise<void> {
|
||||||
|
await api.delete<null>('history/transfer/all', { feedback: 'silent' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
import type { MediaServerClient } from '@/api/types'
|
||||||
|
|
||||||
|
/** 查询已启用且不包含连接配置的媒体服务器客户端。 */
|
||||||
|
export async function listMediaServerClients(): Promise<MediaServerClient[]> {
|
||||||
|
const result = await api.get<unknown>('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<string, unknown>
|
||||||
|
const name = typeof record.name === 'string' ? record.name.trim() : ''
|
||||||
|
const type = typeof record.type === 'string' ? record.type.trim() : ''
|
||||||
|
return name && type ? [{ name, type }] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<string, unknown>, 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<string, unknown>) : {}
|
||||||
|
const commands = Array.isArray(source.commands)
|
||||||
|
? source.commands.flatMap(item => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
const items = Array.isArray(record.actions)
|
||||||
|
? record.actions.flatMap(item => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
||||||
|
const action = item as Record<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<PluginRuntimeCapabilities> {
|
||||||
|
const result = await api.get<unknown>('plugin/runtime/capabilities', {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: { plugin_id: pluginId },
|
||||||
|
})
|
||||||
|
return normalizePluginRuntimeCapabilities(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过有副作用语义正确的 POST 请求重新加载一个插件。 */
|
||||||
|
export async function reloadPluginRuntime(pluginId: string): Promise<void> {
|
||||||
|
await api.post(`plugin/reload/${encodeURIComponent(pluginId)}`, undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
@@ -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<PluginDataValueType>(['null', 'boolean', 'number', 'string', 'array', 'object', 'unknown'])
|
||||||
|
|
||||||
|
/** 读取对象中的非空字符串字段。 */
|
||||||
|
function readText(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取非负有限数值,否则使用回退值。 */
|
||||||
|
function readCount(record: Record<string, unknown>, 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<string, unknown>) : {}
|
||||||
|
const keys = Array.isArray(source.keys)
|
||||||
|
? source.keys.flatMap(item => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
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<PluginDataSummary> {
|
||||||
|
const result = await api.get<unknown>(`plugin/runtime/${encodeURIComponent(pluginId)}/data/summary`, {
|
||||||
|
feedback: 'silent',
|
||||||
|
})
|
||||||
|
return normalizePluginDataSummary(result, pluginId)
|
||||||
|
}
|
||||||
@@ -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<string, PluginFolderEntry>
|
||||||
|
|
||||||
|
/** 将插件文件夹响应收窄为兼容的新旧配置映射。 */
|
||||||
|
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<PluginFolderConfig>
|
||||||
|
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<PluginFolderMap> {
|
||||||
|
return normalizePluginFolders(await api.get('plugin/folders', { feedback: 'silent' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建一个空插件文件夹。 */
|
||||||
|
export async function createPluginFolder(folderName: string): Promise<void> {
|
||||||
|
await api.post(`plugin/folders/${encodeURIComponent(folderName)}`, undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增量更新插件文件夹名称或展示配置。 */
|
||||||
|
export async function updatePluginFolder(folderName: string, payload: PluginFolderUpdateInput): Promise<void> {
|
||||||
|
await api.patch(`plugin/folders/${encodeURIComponent(folderName)}`, payload, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一个插件文件夹但不卸载其中插件。 */
|
||||||
|
export async function deletePluginFolder(folderName: string): Promise<void> {
|
||||||
|
await api.delete(`plugin/folders/${encodeURIComponent(folderName)}`, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基于上次读取的成员顺序条件替换一个文件夹的插件列表。 */
|
||||||
|
export async function replacePluginFolderMembers(
|
||||||
|
folderName: string,
|
||||||
|
plugins: string[],
|
||||||
|
expectedPlugins: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
await api.put(
|
||||||
|
`plugin/folders/${encodeURIComponent(folderName)}/plugins`,
|
||||||
|
{ expected_plugins: expectedPlugins, plugins },
|
||||||
|
{ feedback: 'silent' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一个插件原子迁移到目标文件夹。 */
|
||||||
|
export async function assignPluginToFolder(folderName: string, pluginId: string): Promise<void> {
|
||||||
|
await api.put(`plugin/folders/${encodeURIComponent(folderName)}/plugins/${encodeURIComponent(pluginId)}`, undefined, {
|
||||||
|
feedback: 'silent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从指定文件夹移除一个插件。 */
|
||||||
|
export async function removePluginFromFolder(folderName: string, pluginId: string): Promise<void> {
|
||||||
|
await api.delete(`plugin/folders/${encodeURIComponent(folderName)}/plugins/${encodeURIComponent(pluginId)}`, {
|
||||||
|
feedback: 'silent',
|
||||||
|
})
|
||||||
|
}
|
||||||
+153
@@ -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<CustomRule>
|
||||||
|
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<FilterRuleGroup>
|
||||||
|
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<CustomRule[]> {
|
||||||
|
const result = await api.get<CustomRuleQueryResult>('rule/custom', {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: { include_group_refs: false },
|
||||||
|
})
|
||||||
|
return normalizeCustomRules(result?.rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询规则组,不加载订阅和全局设置引用分析。 */
|
||||||
|
export async function listFilterRuleGroups(): Promise<FilterRuleGroup[]> {
|
||||||
|
const result = await api.get<FilterRuleGroupQueryResult>('rule/groups', {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: { include_usage: false },
|
||||||
|
})
|
||||||
|
return normalizeFilterRuleGroups(result?.rule_groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增一条自定义过滤规则。 */
|
||||||
|
export async function createCustomRule(payload: CustomRuleCreateInput): Promise<void> {
|
||||||
|
await api.post('rule/custom', payload, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增量更新一条自定义过滤规则。 */
|
||||||
|
export async function updateCustomRule(ruleId: string, payload: CustomRuleUpdateInput): Promise<void> {
|
||||||
|
await api.put(`rule/custom/${encodeURIComponent(ruleId)}`, payload, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一条未被规则组引用的自定义过滤规则。 */
|
||||||
|
export async function deleteCustomRule(ruleId: string): Promise<void> {
|
||||||
|
await api.delete(`rule/custom/${encodeURIComponent(ruleId)}`, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按完整 ID 列表调整自定义规则顺序。 */
|
||||||
|
export async function reorderCustomRules(ruleIds: string[], expectedRuleIds: string[]): Promise<void> {
|
||||||
|
await api.put(
|
||||||
|
'rule/custom/reorder',
|
||||||
|
{ rule_ids: ruleIds, expected_rule_ids: expectedRuleIds },
|
||||||
|
{ feedback: 'silent' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增一个过滤规则组。 */
|
||||||
|
export async function createFilterRuleGroup(payload: FilterRuleGroupCreateInput): Promise<void> {
|
||||||
|
await api.post('rule/groups', payload, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增量更新一个过滤规则组。 */
|
||||||
|
export async function updateFilterRuleGroup(name: string, payload: FilterRuleGroupUpdateInput): Promise<void> {
|
||||||
|
await api.put(`rule/groups/${encodeURIComponent(name)}`, payload, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一个规则组并由后端清理其引用。 */
|
||||||
|
export async function deleteFilterRuleGroup(name: string): Promise<void> {
|
||||||
|
await api.delete(`rule/groups/${encodeURIComponent(name)}`, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按完整名称列表调整规则组顺序。 */
|
||||||
|
export async function reorderFilterRuleGroups(groupNames: string[], expectedGroupNames: string[]): Promise<void> {
|
||||||
|
await api.put(
|
||||||
|
'rule/groups/reorder',
|
||||||
|
{ group_names: groupNames, expected_group_names: expectedGroupNames },
|
||||||
|
{ feedback: 'silent' },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import api from './index'
|
||||||
|
|
||||||
|
/** 触发一次 CookieCloud 站点同步。 */
|
||||||
|
export function requestCookieCloudSync(): Promise<null> {
|
||||||
|
return api.post<null>('site/cookiecloud', undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空全部站点并启动一次新的 CookieCloud 同步。 */
|
||||||
|
export function resetSiteData(): Promise<null> {
|
||||||
|
return api.post<null>('site/reset', undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
@@ -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<DownloadDirectory[]> {
|
||||||
|
const result = await api.get<DownloadDirectory[]>('download/paths', { feedback: 'silent' })
|
||||||
|
return Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询不包含连接配置和凭据的存储选项。 */
|
||||||
|
export async function listStorageOptions(): Promise<StorageOption[]> {
|
||||||
|
const result = await api.get<StorageOption[]>('storage/options', { feedback: 'silent' })
|
||||||
|
return Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按用途和存储位置查询完整目录选择合同。 */
|
||||||
|
export async function listTransferDirectories(query: TransferDirectoryQuery = {}): Promise<TransferDirectoryConf[]> {
|
||||||
|
const result = await api.get<TransferDirectoryConf[]>('storage/directories', {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: query,
|
||||||
|
})
|
||||||
|
return Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
/** 立即搜索一条现有订阅。 */
|
||||||
|
export function searchSubscription(subscriptionId: number): Promise<null> {
|
||||||
|
return api.post<null>(`subscribe/search/${subscriptionId}`, undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置一条现有订阅,使其重新进入处理状态。 */
|
||||||
|
export function resetSubscription(subscriptionId: number): Promise<null> {
|
||||||
|
return api.post<null>(`subscribe/reset/${subscriptionId}`, undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即搜索当前用户可访问的全部订阅。 */
|
||||||
|
export function searchAllSubscriptions(): Promise<null> {
|
||||||
|
return api.post<null>('subscribe/search', undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动全局订阅刷新任务。 */
|
||||||
|
export function refreshSubscriptions(): Promise<null> {
|
||||||
|
return api.post<null>('subscribe/refresh', undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动全局订阅元数据更新任务。 */
|
||||||
|
export function refreshSubscriptionMetadata(): Promise<null> {
|
||||||
|
return api.post<null>('subscribe/check', undefined, { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询当前用户已关注的订阅分享用户。 */
|
||||||
|
export function listFollowedSubscribers(): Promise<string[]> {
|
||||||
|
return api.get<string[]>('subscribe/follow', { feedback: 'silent' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关注一个订阅分享用户。 */
|
||||||
|
export function followSubscriber(shareUid: string): Promise<null> {
|
||||||
|
return api.post<null>('subscribe/follow', undefined, {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: { share_uid: shareUid },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消关注一个订阅分享用户。 */
|
||||||
|
export function unfollowSubscriber(shareUid: string): Promise<null> {
|
||||||
|
return api.delete<null>('subscribe/follow', {
|
||||||
|
feedback: 'silent',
|
||||||
|
params: { share_uid: shareUid },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<T = unknown> {
|
||||||
|
definition: SystemSettingDefinition
|
||||||
|
group: string
|
||||||
|
has_value: boolean
|
||||||
|
label: string
|
||||||
|
redacted: boolean
|
||||||
|
setting_key: string
|
||||||
|
source: string
|
||||||
|
value?: T
|
||||||
|
value_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemSettingsQueryResult<T> {
|
||||||
|
include_values: boolean
|
||||||
|
matched_count: number
|
||||||
|
settings: SystemSettingItem<T>[]
|
||||||
|
show_secrets: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 精确查询一个已登记设置,默认接受后端的敏感值脱敏策略。 */
|
||||||
|
export async function getSystemSetting<T = unknown>(settingKey: string): Promise<SystemSettingItem<T> | null> {
|
||||||
|
const result = await api.get<SystemSettingsQueryResult<T>>('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
|
||||||
|
}
|
||||||
@@ -982,6 +982,8 @@ export interface SiteUserData {
|
|||||||
|
|
||||||
// 正在下载
|
// 正在下载
|
||||||
export interface DownloadingInfo {
|
export interface DownloadingInfo {
|
||||||
|
// 下载器实例名称
|
||||||
|
downloader?: string
|
||||||
// HASH
|
// HASH
|
||||||
hash?: string
|
hash?: string
|
||||||
// 种子名称
|
// 种子名称
|
||||||
@@ -1002,6 +1004,26 @@ export interface DownloadingInfo {
|
|||||||
dlspeed?: string
|
dlspeed?: string
|
||||||
// 上传速度
|
// 上传速度
|
||||||
upspeed?: 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 }
|
media: { [key: string]: any }
|
||||||
// 下载用户ID
|
// 下载用户ID
|
||||||
@@ -1012,6 +1034,33 @@ export interface DownloadingInfo {
|
|||||||
left_time?: string
|
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 {
|
export interface NotExistMediaInfo {
|
||||||
// 季
|
// 季
|
||||||
@@ -1950,6 +1999,14 @@ export interface StorageConf {
|
|||||||
config?: { [key: string]: any }
|
config?: { [key: string]: any }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不包含连接配置的存储选择项
|
||||||
|
export interface StorageOption {
|
||||||
|
// 名称
|
||||||
|
name: string
|
||||||
|
// 类型 local/alipan/u115/rclone
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
// 媒体服务器配置
|
// 媒体服务器配置
|
||||||
export interface MediaServerConf {
|
export interface MediaServerConf {
|
||||||
// 名称
|
// 名称
|
||||||
@@ -1966,6 +2023,14 @@ export interface MediaServerConf {
|
|||||||
sync_interval?: number | null
|
sync_interval?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不包含连接配置和凭据的媒体服务器选择项
|
||||||
|
export interface MediaServerClient {
|
||||||
|
// 实例名称
|
||||||
|
name: string
|
||||||
|
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
// 文件整理目录配置
|
// 文件整理目录配置
|
||||||
export interface TransferDirectoryConf {
|
export interface TransferDirectoryConf {
|
||||||
// 名称
|
// 名称
|
||||||
@@ -2010,6 +2075,26 @@ export interface TransferDirectoryConf {
|
|||||||
notify?: boolean
|
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 {
|
export interface CustomRule {
|
||||||
// 规则ID
|
// 规则ID
|
||||||
@@ -2168,6 +2253,18 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
|
|||||||
fileitems?: FileItem[]
|
fileitems?: FileItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动整理目的路径匹配请求
|
||||||
|
export interface ManualTransferTargetPathRequest {
|
||||||
|
// 单个源文件项
|
||||||
|
fileitem?: FileItem
|
||||||
|
// 多个源文件项
|
||||||
|
fileitems?: FileItem[]
|
||||||
|
// 整理历史记录
|
||||||
|
logids?: number[]
|
||||||
|
// 限定目标存储
|
||||||
|
target_storage?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// 手动整理目的路径匹配结果
|
// 手动整理目的路径匹配结果
|
||||||
export interface ManualTransferTargetPathData {
|
export interface ManualTransferTargetPathData {
|
||||||
// 目标存储
|
// 目标存储
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ import { useGlobalSettingsStore } from '@/stores'
|
|||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
const DownloadTaskSettingsDialog = defineAsyncComponent(
|
||||||
interface DownloadingCardInfo extends DownloadingInfo {
|
() => import('@/components/dialog/DownloadTaskSettingsDialog.vue'),
|
||||||
site_name?: string
|
)
|
||||||
trackers?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */
|
/** 正在下载任务卡片,负责展示任务状态并提供设置、暂停、继续和删除操作。 */
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
info: Object as PropType<DownloadingCardInfo>,
|
info: Object as PropType<DownloadingInfo>,
|
||||||
downloaderName: String,
|
downloaderName: String,
|
||||||
|
downloaderType: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const createConfirm = useConfirm()
|
const createConfirm = useConfirm()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -28,6 +31,7 @@ const cardState = ref(true)
|
|||||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||||
const deleteConfirmationPending = ref(false)
|
const deleteConfirmationPending = ref(false)
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
const settingsDialog = ref(false)
|
||||||
const media = computed(() => props.info?.media ?? {})
|
const media = computed(() => props.info?.media ?? {})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -288,6 +292,17 @@ async function deleteDownload() {
|
|||||||
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
:aria-label="t('downloading.settings.title')"
|
||||||
|
:disabled="Boolean(pendingAction) || !props.info?.hash"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="settingsDialog = true"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-tune-variant" />
|
||||||
|
<VTooltip activator="parent" location="top">{{ t('downloading.settings.title') }}</VTooltip>
|
||||||
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
:aria-label="t('common.delete')"
|
:aria-label="t('common.delete')"
|
||||||
class="downloading-card__delete-action"
|
class="downloading-card__delete-action"
|
||||||
@@ -310,6 +325,14 @@ async function deleteDownload() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
<DownloadTaskSettingsDialog
|
||||||
|
v-if="settingsDialog && props.info"
|
||||||
|
v-model="settingsDialog"
|
||||||
|
:task="props.info"
|
||||||
|
:downloader-name="props.downloaderName"
|
||||||
|
:downloader-type="props.downloaderType"
|
||||||
|
@saved="emit('updated')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
import { useGlobalSettingsStore, usePluginRuntimeStore } from '@/stores'
|
import { useGlobalSettingsStore, usePluginRuntimeStore } from '@/stores'
|
||||||
|
import { reloadPluginRuntime } from '@/api/pluginCapabilities'
|
||||||
|
|
||||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||||
|
const PluginCapabilitiesDialog = defineAsyncComponent(() => import('../dialog/PluginCapabilitiesDialog.vue'))
|
||||||
|
const PluginDataSummaryDialog = defineAsyncComponent(() => import('../dialog/PluginDataSummaryDialog.vue'))
|
||||||
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
|
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||||
@@ -48,6 +51,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const pluginRuntimeStore = usePluginRuntimeStore()
|
const pluginRuntimeStore = usePluginRuntimeStore()
|
||||||
|
const reloading = ref(false)
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -99,7 +103,10 @@ const runtimeUnavailable = computed(
|
|||||||
['blocked_by_policy', 'load_failed'].includes(runtimeStatus.value || '') ||
|
['blocked_by_policy', 'load_failed'].includes(runtimeStatus.value || '') ||
|
||||||
(!props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || '')),
|
(!props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || '')),
|
||||||
)
|
)
|
||||||
const runtimeActionsBlocked = computed(() => props.installing || runtimePending.value || runtimeUnavailable.value)
|
const runtimeActionsBlocked = computed(
|
||||||
|
() => props.installing || reloading.value || runtimePending.value || runtimeUnavailable.value,
|
||||||
|
)
|
||||||
|
const reloadBlocked = computed(() => props.installing || reloading.value || runtimePending.value)
|
||||||
const runtimePendingStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = {
|
const runtimePendingStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = {
|
||||||
source_missing: 'plugin.sourceRestoring',
|
source_missing: 'plugin.sourceRestoring',
|
||||||
dependency_pending: 'plugin.dependencyInstalling',
|
dependency_pending: 'plugin.dependencyInstalling',
|
||||||
@@ -256,6 +263,46 @@ async function showPluginInfo() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 显示当前插件注册的安全只读运行能力。 */
|
||||||
|
function showPluginCapabilities() {
|
||||||
|
openSharedDialog(PluginCapabilitiesDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示当前插件不包含持久化原值的数据诊断摘要。 */
|
||||||
|
function showPluginDataSummary() {
|
||||||
|
openSharedDialog(PluginDataSummaryDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新加载当前插件并刷新插件页相关运行事实。 */
|
||||||
|
async function reloadPlugin() {
|
||||||
|
const pluginId = props.plugin?.id
|
||||||
|
if (!pluginId || reloadBlocked.value) return
|
||||||
|
|
||||||
|
reloading.value = true
|
||||||
|
try {
|
||||||
|
await reloadPluginRuntime(pluginId)
|
||||||
|
await Promise.all([pluginRuntimeStore.refreshNow(), pluginSidebarNavStore.ensureSidebarNav(true)])
|
||||||
|
$toast.success(t('plugin.reloadSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
emit('save')
|
||||||
|
} catch (error) {
|
||||||
|
$toast.error(
|
||||||
|
t('plugin.reloadFailed', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
reloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据运行态决定插件菜单项是否可操作。 */
|
||||||
|
function isDropdownItemDisabled(value: number) {
|
||||||
|
if (value === 12) return reloadBlocked.value
|
||||||
|
return runtimeActionsBlocked.value && [1, 2, 4, 8].includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
// 显示插件配置
|
// 显示插件配置
|
||||||
async function showPluginConfig() {
|
async function showPluginConfig() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -579,6 +626,33 @@ const dropdownItems = ref([
|
|||||||
click: showPluginInfo,
|
click: showPluginInfo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('plugin.runtimeCapabilities'),
|
||||||
|
value: 11,
|
||||||
|
show: Boolean(props.plugin?.installed),
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-puzzle-check-outline',
|
||||||
|
click: showPluginCapabilities,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('plugin.dataSummary'),
|
||||||
|
value: 13,
|
||||||
|
show: Boolean(props.plugin?.installed),
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-database-eye-outline',
|
||||||
|
click: showPluginDataSummary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('plugin.reload'),
|
||||||
|
value: 12,
|
||||||
|
show: Boolean(props.plugin?.installed),
|
||||||
|
props: {
|
||||||
|
prependIcon: 'mdi-reload',
|
||||||
|
click: reloadPlugin,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('plugin.settings'),
|
title: t('plugin.settings'),
|
||||||
value: 2,
|
value: 2,
|
||||||
@@ -801,7 +875,7 @@ watch(
|
|||||||
v-show="item.show"
|
v-show="item.show"
|
||||||
:key="i"
|
:key="i"
|
||||||
:base-color="item.props.color"
|
:base-color="item.props.color"
|
||||||
:disabled="runtimeActionsBlocked && [1, 2, 4, 8].includes(item.value)"
|
:disabled="isDropdownItemDisabled(item.value)"
|
||||||
@click="item.props.click"
|
@click="item.props.click"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ interface FolderConfig {
|
|||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
folderName: String,
|
folderName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
pluginCount: Number,
|
pluginCount: Number,
|
||||||
folderConfig: {
|
folderConfig: {
|
||||||
type: Object as PropType<FolderConfig>,
|
type: Object as PropType<FolderConfig>,
|
||||||
@@ -41,7 +44,12 @@ const props = defineProps({
|
|||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
|
const emit = defineEmits<{
|
||||||
|
open: [folderName: string]
|
||||||
|
delete: [folderName: string]
|
||||||
|
rename: [oldName: string, newName: string, onComplete: (success: boolean) => void]
|
||||||
|
'update-config': [folderName: string, config: FolderConfig, onComplete: (success: boolean) => void]
|
||||||
|
}>()
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -58,6 +66,7 @@ const createConfirm = useConfirm()
|
|||||||
// 菜单显示状态
|
// 菜单显示状态
|
||||||
const menuVisible = ref(false)
|
const menuVisible = ref(false)
|
||||||
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let settingsDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
// 默认颜色
|
// 默认颜色
|
||||||
const defaultColor = '#2196F3'
|
const defaultColor = '#2196F3'
|
||||||
@@ -134,11 +143,17 @@ async function confirmRename(newFolderName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit('rename', props.folderName, newFolderName)
|
renameDialogController?.updateProps({ saving: true })
|
||||||
|
const saved = await new Promise<boolean>(resolve => {
|
||||||
|
emit('rename', props.folderName, newFolderName, resolve)
|
||||||
|
})
|
||||||
|
if (!saved) return
|
||||||
renameDialogController?.close()
|
renameDialogController?.close()
|
||||||
renameDialogController = null
|
renameDialogController = null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
renameDialogController?.updateProps({ saving: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,21 +175,35 @@ async function deleteFolder() {
|
|||||||
|
|
||||||
// 显示设置对话框
|
// 显示设置对话框
|
||||||
function showSettingDialog() {
|
function showSettingDialog() {
|
||||||
openSharedDialog(
|
settingsDialogController?.close()
|
||||||
|
settingsDialogController = openSharedDialog(
|
||||||
PluginFolderSettingsDialog,
|
PluginFolderSettingsDialog,
|
||||||
{ folderConfig: props.folderConfig },
|
{ folderConfig: props.folderConfig },
|
||||||
{ save: saveSettings },
|
{ save: saveSettings },
|
||||||
{ closeOn: ['close', 'save', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存设置
|
// 保存设置
|
||||||
function saveSettings(config: FolderConfig) {
|
async function saveSettings(config: FolderConfig) {
|
||||||
emit('update-config', props.folderName, config)
|
try {
|
||||||
|
settingsDialogController?.updateProps({ saving: true })
|
||||||
|
const saved = await new Promise<boolean>(resolve => {
|
||||||
|
emit('update-config', props.folderName, config, resolve)
|
||||||
|
})
|
||||||
|
if (!saved) return
|
||||||
|
settingsDialogController?.close()
|
||||||
|
settingsDialogController = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
settingsDialogController?.updateProps({ saving: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
renameDialogController?.close()
|
renameDialogController?.close()
|
||||||
|
settingsDialogController?.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 弹出菜单
|
// 弹出菜单
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
openFolder: [folderName: string]
|
openFolder: [folderName: string]
|
||||||
deleteFolder: [folderName: string]
|
deleteFolder: [folderName: string]
|
||||||
renameFolder: [oldName: string, newName: string]
|
renameFolder: [oldName: string, newName: string, onComplete?: (success: boolean) => void]
|
||||||
updateFolderConfig: [folderName: string, config: any]
|
updateFolderConfig: [folderName: string, config: any, onComplete?: (success: boolean) => void]
|
||||||
refreshData: []
|
refreshData: []
|
||||||
rating: [pluginRating: PluginRating]
|
rating: [pluginRating: PluginRating]
|
||||||
sourceTransition: [plugin: Plugin, transition: PluginSourceTransition]
|
sourceTransition: [plugin: Plugin, transition: PluginSourceTransition]
|
||||||
@@ -104,8 +104,8 @@ function handleDropToFolder(event: DragEvent) {
|
|||||||
:sortable="sortable"
|
:sortable="sortable"
|
||||||
@open="$emit('openFolder', item.id)"
|
@open="$emit('openFolder', item.id)"
|
||||||
@delete="$emit('deleteFolder', item.id)"
|
@delete="$emit('deleteFolder', item.id)"
|
||||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
@rename="(oldName, newName, onComplete) => $emit('renameFolder', oldName, newName, onComplete)"
|
||||||
@update-config="(folderName, config) => $emit('updateFolderConfig', folderName, config)"
|
@update-config="(folderName, config, onComplete) => $emit('updateFolderConfig', folderName, config, onComplete)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatDateDifference } from '@/@core/utils/formatters'
|
|||||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
|
import { resetSubscription, searchSubscription } from '@/api/subscription'
|
||||||
import type { Subscribe } from '@/api/types'
|
import type { Subscribe } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -308,7 +309,8 @@ async function removeSubscribe() {
|
|||||||
// 搜索订阅
|
// 搜索订阅
|
||||||
async function searchSubscribe() {
|
async function searchSubscribe() {
|
||||||
try {
|
try {
|
||||||
await api.get(`subscribe/search/${props.media?.id}`, { feedback: 'silent' })
|
if (!props.media?.id) return
|
||||||
|
await searchSubscription(props.media.id)
|
||||||
$toast.success(t('subscribe.execution.searchSubmitted', { name: props.media?.name }))
|
$toast.success(t('subscribe.execution.searchSubmitted', { name: props.media?.name }))
|
||||||
emit('save')
|
emit('save')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -350,7 +352,8 @@ async function resetSubscribe() {
|
|||||||
})
|
})
|
||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
// 重置
|
// 重置
|
||||||
await api.get(`subscribe/reset/${props.media?.id}`, { feedback: 'silent' })
|
if (!props.media?.id) return
|
||||||
|
await resetSubscription(props.media.id)
|
||||||
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
|
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
|
||||||
subscribeState.value = 'R'
|
subscribeState.value = 'R'
|
||||||
emit('save')
|
emit('save')
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ async function renderCard(info = downloading(), downloaderName = 'qb-main', glob
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取卡片的继续/暂停和删除操作按钮。 */
|
/** 获取卡片的继续/暂停、设置和删除操作按钮。 */
|
||||||
function actionButtons(container: Element) {
|
function actionButtons(container: Element) {
|
||||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
|
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
|
||||||
expect(buttons).toHaveLength(2)
|
expect(buttons).toHaveLength(3)
|
||||||
return { deleteButton: buttons[1]!, toggleButton: buttons[0]! }
|
return { deleteButton: buttons[2]!, settingsButton: buttons[1]!, toggleButton: buttons[0]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -306,6 +306,15 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(3))
|
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(3))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers advanced settings without displacing pause and delete actions', async () => {
|
||||||
|
const { container } = await renderCard()
|
||||||
|
const { deleteButton, settingsButton, toggleButton } = actionButtons(container)
|
||||||
|
|
||||||
|
expect(toggleButton).toHaveAccessibleName('暂停')
|
||||||
|
expect(settingsButton).toHaveAccessibleName('高级设置')
|
||||||
|
expect(deleteButton).toHaveAccessibleName('删除')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the current state when the pause request fails at the HTTP boundary', async () => {
|
it('keeps the current state when the pause request fails at the HTTP boundary', async () => {
|
||||||
server.use(downloadActionHandler('stop', 'hash-1', { success: false }, 503))
|
server.use(downloadActionHandler('stop', 'hash-1', { success: false }, 503))
|
||||||
const { container } = await renderCard()
|
const { container } = await renderCard()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
confirm: vi.fn(),
|
confirm: vi.fn(),
|
||||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: vi.fn(),
|
||||||
|
reloadPluginRuntime: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
toastSuccess: vi.fn(),
|
toastSuccess: vi.fn(),
|
||||||
toastWarning: vi.fn(),
|
toastWarning: vi.fn(),
|
||||||
@@ -37,6 +38,10 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
|||||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/pluginCapabilities', () => ({
|
||||||
|
reloadPluginRuntime: mocks.reloadPluginRuntime,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/@core/utils/image', () => ({
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
extractDominantColor: mocks.accentFromImage,
|
extractDominantColor: mocks.accentFromImage,
|
||||||
}))
|
}))
|
||||||
@@ -79,6 +84,7 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
updateProps: vi.fn(),
|
updateProps: vi.fn(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
mocks.reloadPluginRuntime.mockReset()
|
||||||
mocks.toastError.mockReset()
|
mocks.toastError.mockReset()
|
||||||
mocks.toastSuccess.mockReset()
|
mocks.toastSuccess.mockReset()
|
||||||
mocks.toastWarning.mockReset()
|
mocks.toastWarning.mockReset()
|
||||||
@@ -500,6 +506,92 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the shared read-only runtime capabilities dialog from the menu', async () => {
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('运行能力'))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ plugin },
|
||||||
|
{},
|
||||||
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the shared redacted data diagnostics dialog from the menu', async () => {
|
||||||
|
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('数据诊断'))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ plugin },
|
||||||
|
{},
|
||||||
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads an installed plugin and refreshes runtime, list and dynamic navigation facts', async () => {
|
||||||
|
mocks.reloadPluginRuntime.mockResolvedValueOnce(undefined)
|
||||||
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
const runtimeStore = usePluginRuntimeStore(pinia)
|
||||||
|
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||||
|
vi.spyOn(runtimeStore, 'refreshNow').mockResolvedValue(undefined)
|
||||||
|
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
|
await fireEvent.click(await screen.findByText('重新加载'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.reloadPluginRuntime).toHaveBeenCalledWith('DemoPlugin'))
|
||||||
|
expect(runtimeStore.refreshNow).toHaveBeenCalledOnce()
|
||||||
|
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 已重新加载')
|
||||||
|
expect(emitted().save).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents a second reload while the first request is still running', async () => {
|
||||||
|
let resolveReload!: () => void
|
||||||
|
mocks.reloadPluginRuntime.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>(resolve => {
|
||||||
|
resolveReload = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { container, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||||
|
const runtimeStore = usePluginRuntimeStore(pinia)
|
||||||
|
vi.spyOn(runtimeStore, 'refreshNow').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const menuButton = container.querySelector<HTMLButtonElement>('.plugin-card__menu')!
|
||||||
|
await fireEvent.click(menuButton)
|
||||||
|
await fireEvent.click(await screen.findByText('重新加载'))
|
||||||
|
await fireEvent.click(menuButton)
|
||||||
|
const pendingReloadItem = (await screen.findByText('重新加载')).closest('.v-list-item')
|
||||||
|
|
||||||
|
expect(pendingReloadItem).toHaveClass('v-list-item--disabled')
|
||||||
|
await fireEvent.click(pendingReloadItem!)
|
||||||
|
expect(mocks.reloadPluginRuntime).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
resolveReload()
|
||||||
|
await waitFor(() => expect(runtimeStore.refreshNow).toHaveBeenCalledOnce())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps terminal load failures reloadable and reports reload errors without refreshing', async () => {
|
||||||
|
mocks.reloadPluginRuntime.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||||
|
props: { plugin: { ...plugin, runtime_status: 'load_failed' } },
|
||||||
|
})
|
||||||
|
const runtimeStore = usePluginRuntimeStore(pinia)
|
||||||
|
vi.spyOn(runtimeStore, 'refreshNow').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.plugin-card__menu')!)
|
||||||
|
await fireEvent.click(await screen.findByText('重新加载'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重新加载失败:服务器连接失败'))
|
||||||
|
expect(runtimeStore.refreshNow).not.toHaveBeenCalled()
|
||||||
|
expect(emitted()).not.toHaveProperty('save')
|
||||||
|
})
|
||||||
|
|
||||||
it('opens plugin detail from an external action exactly once', async () => {
|
it('opens plugin detail from an external action exactly once', async () => {
|
||||||
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
||||||
props: { plugin, action: false },
|
props: { plugin, action: false },
|
||||||
|
|||||||
@@ -168,14 +168,24 @@ describe('PluginFolderCard', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('delegates appearance persistence without announcing success before the owner responds', async () => {
|
it('delegates appearance persistence without announcing success before the owner responds', async () => {
|
||||||
|
const close = vi.fn()
|
||||||
|
const updateProps = vi.fn()
|
||||||
|
mocks.openSharedDialog.mockReturnValueOnce({ close, id: 1, updateProps })
|
||||||
const { emitted } = await renderFolder()
|
const { emitted } = await renderFolder()
|
||||||
|
|
||||||
await fireEvent.click(screen.getByText('设置外观'))
|
await fireEvent.click(screen.getByText('设置外观'))
|
||||||
const config = { color: '#ff0000', icon: 'mdi-folder-heart', showIcon: false }
|
const config = { color: '#ff0000', icon: 'mdi-folder-heart', showIcon: false }
|
||||||
getDialogEvents().save(config)
|
const savePromise = getDialogEvents().save(config)
|
||||||
|
const saveEmission = emitted('update-config')?.[0] as unknown[] | undefined
|
||||||
|
|
||||||
expect(emitted('update-config')).toEqual([['媒体工具', config]])
|
expect(saveEmission?.slice(0, 2)).toEqual(['媒体工具', config])
|
||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
expect(updateProps).toHaveBeenCalledWith({ saving: true })
|
||||||
|
|
||||||
|
;(saveEmission?.[2] as (success: boolean) => void)(false)
|
||||||
|
await savePromise
|
||||||
|
expect(close).not.toHaveBeenCalled()
|
||||||
|
expect(updateProps).toHaveBeenLastCalledWith({ saving: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates rename input and emits a valid rename through the shared dialog', async () => {
|
it('validates rename input and emits a valid rename through the shared dialog', async () => {
|
||||||
@@ -187,8 +197,11 @@ describe('PluginFolderCard', () => {
|
|||||||
await events.rename(' ')
|
await events.rename(' ')
|
||||||
expect(mocks.toastError).toHaveBeenCalledWith('文件夹名称不能为空')
|
expect(mocks.toastError).toHaveBeenCalledWith('文件夹名称不能为空')
|
||||||
|
|
||||||
await events.rename('影音工具')
|
const renamePromise = events.rename('影音工具')
|
||||||
expect(emitted('rename')).toEqual([['媒体工具', '影音工具']])
|
const renameEmission = emitted('rename')?.[0] as unknown[] | undefined
|
||||||
|
expect(renameEmission?.slice(0, 2)).toEqual(['媒体工具', '影音工具'])
|
||||||
|
;(renameEmission?.[2] as (success: boolean) => void)(true)
|
||||||
|
await renamePromise
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes rename without emitting when the name is unchanged', async () => {
|
it('closes rename without emitting when the name is unchanged', async () => {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api, { isApiBusinessFailure, isApiResponse } from '@/api'
|
import api, { isApiBusinessFailure, isApiResponse } from '@/api'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
|
import { listDownloadDirectories } from '@/api/storage'
|
||||||
import type {
|
import type {
|
||||||
|
DownloadDirectory,
|
||||||
DownloaderConf,
|
DownloaderConf,
|
||||||
MediaDataSource,
|
MediaDataSource,
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
MusicEntityType,
|
MusicEntityType,
|
||||||
TorrentInfo,
|
TorrentInfo,
|
||||||
TransferDirectoryConf,
|
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||||
@@ -125,7 +126,7 @@ const selectedDirectory = ref<string | null>(null)
|
|||||||
const downloaders = ref<Array<Pick<DownloaderConf, 'name' | 'type'>>>([])
|
const downloaders = ref<Array<Pick<DownloaderConf, 'name' | 'type'>>>([])
|
||||||
|
|
||||||
// 所有目录设置
|
// 所有目录设置
|
||||||
const directories = ref<TransferDirectoryConf[]>([])
|
const directories = ref<DownloadDirectory[]>([])
|
||||||
|
|
||||||
// 是否正在加载
|
// 是否正在加载
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -232,29 +233,16 @@ const dialogSubtitle = computed(() => {
|
|||||||
// 加载目录设置
|
// 加载目录设置
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
directories.value = await listDownloadDirectories()
|
||||||
directories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将下载目录配置转换为下载器可识别的存储路径。
|
|
||||||
function convertToUri(item: TransferDirectoryConf) {
|
|
||||||
if (!item.download_path) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
|
||||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
|
||||||
return item.download_path
|
|
||||||
}
|
|
||||||
return item.storage + ':' + item.download_path
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取保存目录
|
// 获取保存目录
|
||||||
const targetDirectories = computed(() => {
|
const targetDirectories = computed(() => {
|
||||||
const downloadDirectories = directories.value
|
const downloadDirectories = directories.value
|
||||||
.map(item => convertToUri(item))
|
.map(item => item.save_path?.trim())
|
||||||
.filter((item): item is string => item !== undefined)
|
.filter((item): item is string => item !== undefined)
|
||||||
return [...new Set(downloadDirectories)]
|
return [...new Set(downloadDirectories)]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
import { MediaSource, type MediaDataSource, type SubtitleInfo, type TransferDirectoryConf } from '@/api/types'
|
import { listDownloadDirectories } from '@/api/storage'
|
||||||
|
import { MediaSource, type DownloadDirectory, type MediaDataSource, type SubtitleInfo } from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
@@ -44,7 +45,7 @@ const $toast = useToast()
|
|||||||
const selectedDirectory = ref<string | null>(null)
|
const selectedDirectory = ref<string | null>(null)
|
||||||
|
|
||||||
// 所有目录设置
|
// 所有目录设置
|
||||||
const directories = ref<TransferDirectoryConf[]>([])
|
const directories = ref<DownloadDirectory[]>([])
|
||||||
|
|
||||||
// 是否正在加载
|
// 是否正在加载
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -114,28 +115,16 @@ const buttonText = computed(() =>
|
|||||||
// 加载目录设置
|
// 加载目录设置
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
directories.value = await listDownloadDirectories()
|
||||||
directories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToUri(item: TransferDirectoryConf) {
|
|
||||||
if (!item.download_path) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
|
||||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
|
||||||
return item.download_path
|
|
||||||
}
|
|
||||||
return item.storage + ':' + item.download_path
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取保存目录
|
// 获取保存目录
|
||||||
const targetDirectories = computed(() => {
|
const targetDirectories = computed(() => {
|
||||||
const downloadDirectories = directories.value
|
const downloadDirectories = directories.value
|
||||||
.map(item => convertToUri(item))
|
.map(item => item.save_path?.trim())
|
||||||
.filter((item): item is string => item !== undefined)
|
.filter((item): item is string => item !== undefined)
|
||||||
return [...new Set(downloadDirectories)]
|
return [...new Set(downloadDirectories)]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import api, { ApiRequestError, getApiBusinessErrorMessage, isApiResponse } from '@/api'
|
||||||
|
import type {
|
||||||
|
DownloadingInfo,
|
||||||
|
DownloadTaskMutationResult,
|
||||||
|
DownloadTaskUpdateData,
|
||||||
|
DownloadTaskUpdateRequest,
|
||||||
|
} from '@/api/types'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
interface DownloadTaskSettingsForm {
|
||||||
|
tags: string[]
|
||||||
|
trackers: string
|
||||||
|
save_path: string
|
||||||
|
category: string
|
||||||
|
download_limit: number | string | null
|
||||||
|
upload_limit: number | string | null
|
||||||
|
ratio_limit: number | string | null
|
||||||
|
seeding_time_limit: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
task: DownloadingInfo
|
||||||
|
downloaderName?: string
|
||||||
|
downloaderType?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
close: []
|
||||||
|
saved: [data: DownloadTaskUpdateData]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const display = useDisplay()
|
||||||
|
const formRef = ref()
|
||||||
|
const saving = ref(false)
|
||||||
|
const results = ref<DownloadTaskMutationResult[]>([])
|
||||||
|
const initialValues = ref<DownloadTaskSettingsForm>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
if (!value) emit('close')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedDownloaderType = computed(() =>
|
||||||
|
String(props.downloaderType || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
)
|
||||||
|
const supportsTrackers = computed(() => ['qbittorrent', 'transmission'].includes(normalizedDownloaderType.value))
|
||||||
|
const supportsCategory = computed(() => normalizedDownloaderType.value === 'qbittorrent')
|
||||||
|
const supportsSeedingPolicy = computed(() => ['qbittorrent', 'transmission'].includes(normalizedDownloaderType.value))
|
||||||
|
|
||||||
|
const form = ref<DownloadTaskSettingsForm>(createInitialForm())
|
||||||
|
|
||||||
|
/** 将接口中的可选数字转换为表单可编辑值。 */
|
||||||
|
function editableNumber(value: number | undefined): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据任务快照创建表单,增量标签和 Tracker 默认不重复提交现值。 */
|
||||||
|
function createInitialForm(): DownloadTaskSettingsForm {
|
||||||
|
return {
|
||||||
|
tags: [],
|
||||||
|
trackers: '',
|
||||||
|
save_path: props.task.save_path || '',
|
||||||
|
category: props.task.category || '',
|
||||||
|
download_limit: editableNumber(props.task.download_limit),
|
||||||
|
upload_limit: editableNumber(props.task.upload_limit),
|
||||||
|
ratio_limit: editableNumber(props.task.ratio_limit),
|
||||||
|
seeding_time_limit: editableNumber(props.task.seeding_time_limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置当前任务的编辑状态和上一次逐项执行结果。 */
|
||||||
|
function resetForm() {
|
||||||
|
form.value = createInitialForm()
|
||||||
|
initialValues.value = { ...form.value, tags: [], trackers: '' }
|
||||||
|
results.value = []
|
||||||
|
formRef.value?.resetValidation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理用户输入的字符串列表。 */
|
||||||
|
function normalizeStrings(values: string[]): string[] {
|
||||||
|
return [...new Set(values.map(value => String(value).trim()).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将每行一个的 Tracker 文本转换为接口列表。 */
|
||||||
|
function normalizeTrackers(value: string): string[] {
|
||||||
|
return normalizeStrings(value.split(/\r?\n/))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将可空数字表单值转换为接口数字。 */
|
||||||
|
function normalizeNumber(value: number | string | null): number | undefined {
|
||||||
|
if (value === null || value === '') return undefined
|
||||||
|
const normalized = Number(value)
|
||||||
|
return Number.isFinite(normalized) ? normalized : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验非负限速值,0 表示取消单任务限速。 */
|
||||||
|
function validateLimit(value: number | string | null): true | string {
|
||||||
|
const normalized = normalizeNumber(value)
|
||||||
|
return normalized === undefined || normalized >= 0 || t('downloading.settings.nonNegative')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验普通有限数字字段。 */
|
||||||
|
function validateNumber(value: number | string | null): true | string {
|
||||||
|
if (value === null || value === '') return true
|
||||||
|
return normalizeNumber(value) !== undefined || t('downloading.settings.invalidNumber')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验做种时间为整数分钟。 */
|
||||||
|
function validateInteger(value: number | string | null): true | string {
|
||||||
|
if (value === null || value === '') return true
|
||||||
|
const normalized = normalizeNumber(value)
|
||||||
|
return normalized !== undefined && Number.isInteger(normalized) ? true : t('downloading.settings.integerRequired')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 Tracker 使用下载器支持的 HTTP(S) 或 UDP 地址。 */
|
||||||
|
function validateTrackers(value: string): true | string {
|
||||||
|
const trackers = normalizeTrackers(value)
|
||||||
|
if (!trackers.length) return true
|
||||||
|
const valid = trackers.every(tracker => {
|
||||||
|
try {
|
||||||
|
return ['http:', 'https:', 'udp:'].includes(new URL(tracker).protocol)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return valid || t('downloading.settings.invalidTracker')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只提交相对任务快照真正变化或由用户新增的字段。 */
|
||||||
|
function createPayload(): DownloadTaskUpdateRequest {
|
||||||
|
const initial = initialValues.value || createInitialForm()
|
||||||
|
const payload: DownloadTaskUpdateRequest = {
|
||||||
|
downloader: props.downloaderName || props.task.downloader,
|
||||||
|
}
|
||||||
|
const tags = normalizeStrings(form.value.tags)
|
||||||
|
const trackers = normalizeTrackers(form.value.trackers)
|
||||||
|
if (tags.length) payload.tags = tags
|
||||||
|
if (supportsTrackers.value && trackers.length) payload.trackers = trackers
|
||||||
|
|
||||||
|
for (const key of ['download_limit', 'upload_limit'] as const) {
|
||||||
|
const value = normalizeNumber(form.value[key])
|
||||||
|
const initialValue = normalizeNumber(initial[key])
|
||||||
|
if (value !== undefined && value !== initialValue) payload[key] = value
|
||||||
|
}
|
||||||
|
if (supportsSeedingPolicy.value) {
|
||||||
|
for (const key of ['ratio_limit', 'seeding_time_limit'] as const) {
|
||||||
|
const value = normalizeNumber(form.value[key])
|
||||||
|
const initialValue = normalizeNumber(initial[key])
|
||||||
|
if (value !== undefined && value !== initialValue) payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePath = form.value.save_path.trim()
|
||||||
|
if (savePath && savePath !== initial.save_path.trim()) payload.save_path = savePath
|
||||||
|
const category = form.value.category.trim()
|
||||||
|
if (supportsCategory.value && category && category !== initial.category.trim()) payload.category = category
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanges = computed(() => Object.keys(createPayload()).some(key => key !== 'downloader'))
|
||||||
|
|
||||||
|
/** 从业务失败异常中保留后端返回的逐项修改结果。 */
|
||||||
|
function extractMutationData(error: unknown): DownloadTaskUpdateData | undefined {
|
||||||
|
if (!(error instanceof ApiRequestError)) return undefined
|
||||||
|
const payload = error.payload
|
||||||
|
if (!isApiResponse<DownloadTaskUpdateData>(payload)) return undefined
|
||||||
|
return payload.data || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将部分成功的字段写入本地基线,避免重试时重复执行已经生效的操作。 */
|
||||||
|
function acceptSuccessfulChanges(data: DownloadTaskUpdateData, payload: DownloadTaskUpdateRequest) {
|
||||||
|
const initial = { ...(initialValues.value || createInitialForm()) }
|
||||||
|
for (const result of data.results) {
|
||||||
|
if (!result.success) continue
|
||||||
|
if (result.operation === 'tags') form.value.tags = []
|
||||||
|
if (result.operation === 'trackers') form.value.trackers = ''
|
||||||
|
if (result.operation === 'save_path' && payload.save_path !== undefined) {
|
||||||
|
initial.save_path = payload.save_path
|
||||||
|
}
|
||||||
|
if (result.operation === 'category' && payload.category !== undefined) {
|
||||||
|
initial.category = payload.category
|
||||||
|
}
|
||||||
|
if (result.operation === 'limits') {
|
||||||
|
for (const key of ['download_limit', 'upload_limit', 'ratio_limit', 'seeding_time_limit'] as const) {
|
||||||
|
if (payload[key] !== undefined) initial[key] = payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialValues.value = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交高级设置,并准确呈现下载器不支持导致的部分失败。 */
|
||||||
|
async function saveSettings() {
|
||||||
|
const validation = await formRef.value?.validate?.()
|
||||||
|
if (validation && !validation.valid) return
|
||||||
|
const payload = createPayload()
|
||||||
|
if (!hasChanges.value || !props.task.hash || saving.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
results.value = []
|
||||||
|
try {
|
||||||
|
const data = await api.patch<DownloadTaskUpdateData>(`download/${props.task.hash}`, payload, {
|
||||||
|
feedback: 'silent',
|
||||||
|
})
|
||||||
|
results.value = data.results
|
||||||
|
toast.success(t('downloading.settings.saveSuccess'))
|
||||||
|
emit('saved', data)
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
const partialData = extractMutationData(error)
|
||||||
|
if (partialData?.results.length) {
|
||||||
|
results.value = partialData.results
|
||||||
|
acceptSuccessfulChanges(partialData, payload)
|
||||||
|
emit('saved', partialData)
|
||||||
|
toast.warning(t('downloading.settings.partialFailure'))
|
||||||
|
} else {
|
||||||
|
console.error('保存下载任务高级设置失败:', error)
|
||||||
|
toast.error(getApiBusinessErrorMessage(error) || t('downloading.settings.saveFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
value => {
|
||||||
|
if (value) resetForm()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog v-if="visible" v-model="visible" max-width="48rem" scrollable :fullscreen="display.smAndDown.value">
|
||||||
|
<VCard class="download-task-settings-dialog">
|
||||||
|
<VCardItem class="download-task-settings-dialog__header">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-tune-variant" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VCardTitle>{{ t('downloading.settings.title') }}</VCardTitle>
|
||||||
|
<VCardSubtitle>{{ task.title || task.name || t('common.unknown') }}</VCardSubtitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDialogCloseBtn v-model="visible" />
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText class="download-task-settings-dialog__content">
|
||||||
|
<VForm ref="formRef" @submit.prevent="saveSettings">
|
||||||
|
<section class="download-task-settings-dialog__section">
|
||||||
|
<div class="download-task-settings-dialog__section-title">
|
||||||
|
{{ t('downloading.settings.speedAndSeeding') }}
|
||||||
|
</div>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" sm="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="form.download_limit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
:label="t('downloading.settings.downloadLimit')"
|
||||||
|
:suffix="t('downloading.settings.kilobytesPerSecond')"
|
||||||
|
:rules="[validateLimit]"
|
||||||
|
prepend-inner-icon="mdi-download-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" sm="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="form.upload_limit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
:label="t('downloading.settings.uploadLimit')"
|
||||||
|
:suffix="t('downloading.settings.kilobytesPerSecond')"
|
||||||
|
:rules="[validateLimit]"
|
||||||
|
prepend-inner-icon="mdi-upload-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="supportsSeedingPolicy" cols="12" sm="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="form.ratio_limit"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
:label="t('downloading.settings.ratioLimit')"
|
||||||
|
:rules="[validateNumber]"
|
||||||
|
prepend-inner-icon="mdi-chart-donut"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="supportsSeedingPolicy" cols="12" sm="6">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="form.seeding_time_limit"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
:label="t('downloading.settings.seedingTimeLimit')"
|
||||||
|
:suffix="t('downloading.settings.minutes')"
|
||||||
|
:rules="[validateInteger]"
|
||||||
|
prepend-inner-icon="mdi-timer-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="download-task-settings-dialog__section">
|
||||||
|
<div class="download-task-settings-dialog__section-title">
|
||||||
|
{{ t('downloading.settings.locationAndCategory') }}
|
||||||
|
</div>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" :sm="supportsCategory ? 8 : 12">
|
||||||
|
<VTextField
|
||||||
|
v-model="form.save_path"
|
||||||
|
:label="t('downloading.settings.savePath')"
|
||||||
|
prepend-inner-icon="mdi-folder-move-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="supportsCategory" cols="12" sm="4">
|
||||||
|
<VTextField
|
||||||
|
v-model="form.category"
|
||||||
|
:label="t('downloading.settings.category')"
|
||||||
|
prepend-inner-icon="mdi-shape-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="download-task-settings-dialog__section">
|
||||||
|
<div class="download-task-settings-dialog__section-title">
|
||||||
|
{{ t('downloading.settings.tagsAndTrackers') }}
|
||||||
|
</div>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCombobox
|
||||||
|
v-model="form.tags"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
clearable
|
||||||
|
:label="t('downloading.settings.addTags')"
|
||||||
|
prepend-inner-icon="mdi-tag-plus-outline"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol v-if="supportsTrackers" cols="12">
|
||||||
|
<VTextarea
|
||||||
|
v-model="form.trackers"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
:label="t('downloading.settings.trackers')"
|
||||||
|
:placeholder="t('downloading.settings.trackersPlaceholder')"
|
||||||
|
:rules="[validateTrackers]"
|
||||||
|
prepend-inner-icon="mdi-access-point-network"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</section>
|
||||||
|
</VForm>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="results.length"
|
||||||
|
class="download-task-settings-dialog__results"
|
||||||
|
:type="results.every(item => item.success) ? 'success' : 'warning'"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<div v-for="item in results" :key="item.operation" class="download-task-settings-dialog__result">
|
||||||
|
<VIcon :icon="item.success ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'" size="18" />
|
||||||
|
<span>{{ item.message }}</span>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
<VCardActions class="app-dialog-actions">
|
||||||
|
<VBtn variant="text" :disabled="saving" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-content-save-outline"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!hasChanges || !task.hash"
|
||||||
|
@click="saveSettings"
|
||||||
|
>
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.download-task-settings-dialog__header {
|
||||||
|
padding-inline-end: 4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__content {
|
||||||
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__section + .download-task-settings-dialog__section {
|
||||||
|
margin-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__section-title {
|
||||||
|
margin-block-end: 0.75rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__results {
|
||||||
|
border-radius: var(--app-control-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-task-settings-dialog__result + .download-task-settings-dialog__result {
|
||||||
|
margin-block-start: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.download-task-settings-dialog__content {
|
||||||
|
padding: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
|
import { followSubscriber, listFollowedSubscribers, unfollowSubscriber } from '@/api/subscription'
|
||||||
import { SubscribeShare } from '@/api/types'
|
import { SubscribeShare } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -52,8 +53,7 @@ function toggleExpand() {
|
|||||||
// 加载follow用户列表
|
// 加载follow用户列表
|
||||||
async function queryFollowUsers() {
|
async function queryFollowUsers() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: string[] }>('system/setting/public/FollowSubscribers')
|
followUsers.value = await listFollowedSubscribers()
|
||||||
followUsers.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
$toast.error(t('subscribe.requestFailed'))
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
@@ -63,7 +63,8 @@ async function queryFollowUsers() {
|
|||||||
// follow用户
|
// follow用户
|
||||||
async function followUser() {
|
async function followUser() {
|
||||||
try {
|
try {
|
||||||
await api.post<null>(`subscribe/follow?share_uid=${props.media?.share_uid}`, undefined, { feedback: 'silent' })
|
if (!props.media?.share_uid) return
|
||||||
|
await followSubscriber(props.media.share_uid)
|
||||||
queryFollowUsers()
|
queryFollowUsers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -74,12 +75,8 @@ async function followUser() {
|
|||||||
// unfollow用户
|
// unfollow用户
|
||||||
async function unfollowUser() {
|
async function unfollowUser() {
|
||||||
try {
|
try {
|
||||||
await api.delete<null>('subscribe/follow', {
|
if (!props.media?.share_uid) return
|
||||||
params: {
|
await unfollowSubscriber(props.media.share_uid)
|
||||||
share_uid: props.media?.share_uid,
|
|
||||||
},
|
|
||||||
feedback: 'silent',
|
|
||||||
})
|
|
||||||
queryFollowUsers()
|
queryFollowUsers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Plugin } from '@/api/types'
|
||||||
|
import {
|
||||||
|
getPluginRuntimeCapabilities,
|
||||||
|
type PluginRuntimeActionCapability,
|
||||||
|
type PluginRuntimeCapabilities,
|
||||||
|
} from '@/api/pluginCapabilities'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
type: Object as PropType<Plugin>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'close'])
|
||||||
|
const { mdAndUp } = useDisplay()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
if (!value) emit('close')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const capabilities = ref<PluginRuntimeCapabilities>({ actions: [], commands: [], services: [] })
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
const actionItems = computed(() =>
|
||||||
|
capabilities.value.actions.flatMap(group =>
|
||||||
|
group.actions.map(action => ({
|
||||||
|
...action,
|
||||||
|
pluginName: group.plugin_name || group.plugin_id,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const isEmpty = computed(
|
||||||
|
() => !capabilities.value.commands.length && !actionItems.value.length && !capabilities.value.services.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 读取当前插件的安全运行能力快照。 */
|
||||||
|
async function loadCapabilities() {
|
||||||
|
if (!props.plugin.id || loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
loadFailed.value = false
|
||||||
|
try {
|
||||||
|
capabilities.value = await getPluginRuntimeCapabilities(props.plugin.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
loadFailed.value = true
|
||||||
|
capabilities.value = { actions: [], commands: [], services: [] }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回动作的优先展示名称。 */
|
||||||
|
function actionTitle(action: PluginRuntimeActionCapability) {
|
||||||
|
return action.name || action.id
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.plugin.id, loadCapabilities, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog v-if="visible" v-model="visible" scrollable max-width="52rem" :fullscreen="!mdAndUp">
|
||||||
|
<VCard class="plugin-capabilities-dialog">
|
||||||
|
<VDialogCloseBtn v-model="visible" />
|
||||||
|
<VCardItem>
|
||||||
|
<VCardTitle class="d-flex align-center ga-2 pe-8">
|
||||||
|
<VIcon icon="mdi-puzzle-check-outline" />
|
||||||
|
<span class="plugin-capabilities-dialog__title">
|
||||||
|
{{ t('plugin.runtimeCapabilitiesTitle', { name: props.plugin.plugin_name }) }}
|
||||||
|
</span>
|
||||||
|
</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText class="plugin-capabilities-dialog__content pa-0">
|
||||||
|
<LoadingBanner v-if="loading" class="my-8" />
|
||||||
|
<div v-else-if="loadFailed" class="pa-4 pa-sm-6">
|
||||||
|
<VAlert type="error" variant="tonal" :text="t('plugin.runtimeCapabilitiesLoadFailed')">
|
||||||
|
<template #append>
|
||||||
|
<VBtn variant="text" color="error" @click="loadCapabilities">{{ t('common.retry') }}</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isEmpty" class="pa-4 pa-sm-6">
|
||||||
|
<VAlert type="info" variant="tonal" :text="t('plugin.noRuntimeCapabilities')" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="plugin-capabilities-dialog__sections">
|
||||||
|
<section v-if="capabilities.commands.length" class="plugin-capability-section">
|
||||||
|
<header class="plugin-capability-section__header">
|
||||||
|
<VIcon icon="mdi-console-line" size="20" />
|
||||||
|
<span>{{ t('plugin.capabilityCommands') }}</span>
|
||||||
|
<VChip size="x-small" variant="tonal">{{ capabilities.commands.length }}</VChip>
|
||||||
|
</header>
|
||||||
|
<VList bg-color="transparent" lines="two">
|
||||||
|
<VListItem v-for="command in capabilities.commands" :key="command.cmd">
|
||||||
|
<VListItemTitle class="plugin-capability-section__primary">{{ command.cmd }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle v-if="command.desc" class="plugin-capability-section__secondary">
|
||||||
|
{{ command.desc }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="actionItems.length" class="plugin-capability-section">
|
||||||
|
<header class="plugin-capability-section__header">
|
||||||
|
<VIcon icon="mdi-playlist-play" size="20" />
|
||||||
|
<span>{{ t('plugin.capabilityActions') }}</span>
|
||||||
|
<VChip size="x-small" variant="tonal">{{ actionItems.length }}</VChip>
|
||||||
|
</header>
|
||||||
|
<VList bg-color="transparent" lines="two">
|
||||||
|
<VListItem v-for="action in actionItems" :key="`${action.pluginName || ''}:${action.id}`">
|
||||||
|
<VListItemTitle class="plugin-capability-section__primary">{{ actionTitle(action) }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="plugin-capability-section__secondary">
|
||||||
|
{{ action.id }}<span v-if="action.pluginName"> · {{ action.pluginName }}</span>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="capabilities.services.length" class="plugin-capability-section">
|
||||||
|
<header class="plugin-capability-section__header">
|
||||||
|
<VIcon icon="mdi-calendar-clock-outline" size="20" />
|
||||||
|
<span>{{ t('plugin.capabilityServices') }}</span>
|
||||||
|
<VChip size="x-small" variant="tonal">{{ capabilities.services.length }}</VChip>
|
||||||
|
</header>
|
||||||
|
<VList bg-color="transparent" lines="two">
|
||||||
|
<VListItem v-for="service in capabilities.services" :key="service.id">
|
||||||
|
<VListItemTitle class="plugin-capability-section__primary">
|
||||||
|
{{ service.name || service.id }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="plugin-capability-section__secondary">
|
||||||
|
{{ service.id }}<span v-if="service.trigger"> · {{ service.trigger }}</span>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-capabilities-dialog {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capabilities-dialog__title,
|
||||||
|
.plugin-capability-section__primary,
|
||||||
|
.plugin-capability-section__secondary {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capabilities-dialog__sections {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capability-section + .plugin-capability-section {
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capability-section__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 2.75rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capability-section__secondary {
|
||||||
|
opacity: var(--v-medium-emphasis-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.plugin-capabilities-dialog {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-capabilities-dialog__content {
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getPluginDataSummary, type PluginDataSummary, type PluginDataValueType } from '@/api/pluginData'
|
||||||
|
import type { Plugin } from '@/api/types'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
type: Object as PropType<Plugin>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'close'])
|
||||||
|
const { mdAndUp } = useDisplay()
|
||||||
|
const { n, t } = useI18n()
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
if (!value) emit('close')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const summary = ref<PluginDataSummary | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
const typeLabels: Record<PluginDataValueType, string> = {
|
||||||
|
array: 'plugin.dataTypeArray',
|
||||||
|
boolean: 'plugin.dataTypeBoolean',
|
||||||
|
null: 'plugin.dataTypeNull',
|
||||||
|
number: 'plugin.dataTypeNumber',
|
||||||
|
object: 'plugin.dataTypeObject',
|
||||||
|
string: 'plugin.dataTypeString',
|
||||||
|
unknown: 'plugin.dataTypeUnknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前插件不包含原值的数据诊断摘要。 */
|
||||||
|
async function loadSummary() {
|
||||||
|
if (!props.plugin.id || loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
loadFailed.value = false
|
||||||
|
try {
|
||||||
|
summary.value = await getPluginDataSummary(props.plugin.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
loadFailed.value = true
|
||||||
|
summary.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地化插件数据值类型。 */
|
||||||
|
function typeLabel(valueType: PluginDataValueType) {
|
||||||
|
return t(typeLabels[valueType])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化字符数量,未知大小返回统一占位。 */
|
||||||
|
function formatChars(value: number | null) {
|
||||||
|
return value === null ? t('common.unknown') : t('plugin.dataCharacters', { count: n(value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.plugin.id, loadSummary, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog v-if="visible" v-model="visible" scrollable max-width="48rem" :fullscreen="!mdAndUp">
|
||||||
|
<VCard class="plugin-data-summary-dialog">
|
||||||
|
<VDialogCloseBtn v-model="visible" />
|
||||||
|
<VCardItem>
|
||||||
|
<VCardTitle class="d-flex align-center ga-2 pe-8">
|
||||||
|
<VIcon icon="mdi-database-eye-outline" />
|
||||||
|
<span class="plugin-data-summary-dialog__title">
|
||||||
|
{{ t('plugin.dataSummaryTitle', { name: props.plugin.plugin_name }) }}
|
||||||
|
</span>
|
||||||
|
</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText class="plugin-data-summary-dialog__content pa-0">
|
||||||
|
<LoadingBanner v-if="loading" class="my-8" />
|
||||||
|
<div v-else-if="loadFailed" class="pa-4 pa-sm-6">
|
||||||
|
<VAlert type="error" variant="tonal" :text="t('plugin.dataSummaryLoadFailed')">
|
||||||
|
<template #append>
|
||||||
|
<VBtn variant="text" color="error" @click="loadSummary">{{ t('common.retry') }}</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="summary">
|
||||||
|
<div class="plugin-data-summary-dialog__stats">
|
||||||
|
<div>
|
||||||
|
<span class="plugin-data-summary-dialog__stat-value">{{ n(summary.count) }}</span>
|
||||||
|
<span class="plugin-data-summary-dialog__stat-label">{{ t('plugin.dataItems') }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="plugin-data-summary-dialog__stat-value">{{ n(summary.total_chars) }}</span>
|
||||||
|
<span class="plugin-data-summary-dialog__stat-label">{{ t('plugin.dataTotalCharacters') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VAlert
|
||||||
|
v-if="summary.keys_truncated"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="ma-4 mb-0"
|
||||||
|
:text="t('plugin.dataSummaryTruncated', { count: summary.keys.length })"
|
||||||
|
/>
|
||||||
|
<div v-if="summary.keys.length" class="plugin-data-summary-dialog__list">
|
||||||
|
<VList bg-color="transparent" lines="two">
|
||||||
|
<VListItem v-for="item in summary.keys" :key="item.key">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
:icon="item.sensitive ? 'mdi-lock-outline' : 'mdi-code-json'"
|
||||||
|
:color="item.sensitive ? 'warning' : undefined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="plugin-data-summary-dialog__key">{{ item.key }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="plugin-data-summary-dialog__meta">
|
||||||
|
{{ typeLabel(item.value_type) }} · {{ formatChars(item.serialized_chars) }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
<template v-if="item.sensitive" #append>
|
||||||
|
<VChip size="x-small" color="warning" variant="tonal">
|
||||||
|
{{ t('plugin.sensitiveDataKey') }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</div>
|
||||||
|
<div v-else class="pa-4 pa-sm-6">
|
||||||
|
<VAlert type="info" variant="tonal" :text="t('plugin.noPersistedData')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-data-summary-dialog {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__title,
|
||||||
|
.plugin-data-summary-dialog__key,
|
||||||
|
.plugin-data-summary-dialog__meta {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stats > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stats > div + div {
|
||||||
|
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stat-value {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stat-label,
|
||||||
|
.plugin-data-summary-dialog__meta {
|
||||||
|
opacity: var(--v-medium-emphasis-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__list :deep(.v-list-item:not(:last-child)) {
|
||||||
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.plugin-data-summary-dialog {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__content {
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-data-summary-dialog__stats > div {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -59,7 +63,17 @@ function confirmRename() {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="app-dialog-actions">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
class="px-5"
|
||||||
|
:loading="props.saving"
|
||||||
|
:disabled="props.saving"
|
||||||
|
@click="confirmRename"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -38,16 +38,7 @@ const iconOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// 预设颜色选项
|
// 预设颜色选项
|
||||||
const colorOptions = [
|
const colorOptions = ['#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#F44336', '#607D8B', '#795548', '#E91E63']
|
||||||
'#2196F3',
|
|
||||||
'#4CAF50',
|
|
||||||
'#FF9800',
|
|
||||||
'#9C27B0',
|
|
||||||
'#F44336',
|
|
||||||
'#607D8B',
|
|
||||||
'#795548',
|
|
||||||
'#E91E63',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 预设渐变选项
|
// 预设渐变选项
|
||||||
const gradientOptions = [
|
const gradientOptions = [
|
||||||
@@ -71,6 +62,10 @@ const props = defineProps({
|
|||||||
type: Object as PropType<FolderConfig>,
|
type: Object as PropType<FolderConfig>,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
saving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -132,7 +127,12 @@ onMounted(() => {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VSwitch v-model="folderSettings.showIcon" :label="t('folder.showFolderIcon')" color="primary" hide-details />
|
<VSwitch
|
||||||
|
v-model="folderSettings.showIcon"
|
||||||
|
:label="t('folder.showFolderIcon')"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||||
@@ -203,7 +203,15 @@ onMounted(() => {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="app-dialog-actions">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
:loading="props.saving"
|
||||||
|
:disabled="props.saving"
|
||||||
|
@click="saveSettings"
|
||||||
|
>
|
||||||
保存
|
保存
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import { numberValidator } from '@/@validators'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
||||||
import { transferTypeOptions } from '@/api/constants'
|
import { transferTypeOptions } from '@/api/constants'
|
||||||
|
import { listStorageOptions, listTransferDirectories } from '@/api/storage'
|
||||||
import {
|
import {
|
||||||
FileItem,
|
FileItem,
|
||||||
ManualTransferHistoryInfo,
|
ManualTransferHistoryInfo,
|
||||||
ManualTransferPayload,
|
ManualTransferPayload,
|
||||||
ManualTransferPreviewData,
|
ManualTransferPreviewData,
|
||||||
ManualTransferPreviewItem,
|
ManualTransferPreviewItem,
|
||||||
|
ManualTransferTargetPathData,
|
||||||
|
ManualTransferTargetPathRequest,
|
||||||
MediaDataSource,
|
MediaDataSource,
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
StorageConf,
|
StorageOption,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
@@ -119,6 +122,12 @@ const previewData = ref<ManualTransferPreviewData>()
|
|||||||
const manualHistoryLoading = ref(false)
|
const manualHistoryLoading = ref(false)
|
||||||
const manualHistoryCount = ref(0)
|
const manualHistoryCount = ref(0)
|
||||||
|
|
||||||
|
// 自动目的路径匹配状态
|
||||||
|
const targetPathMatchLoading = ref(false)
|
||||||
|
const targetPathMatch = ref<ManualTransferTargetPathData>()
|
||||||
|
const targetPathMatchFailed = ref(false)
|
||||||
|
let targetPathMatchRequestId = 0
|
||||||
|
|
||||||
interface EpisodeFormatRecommendData {
|
interface EpisodeFormatRecommendData {
|
||||||
rule_name?: string
|
rule_name?: string
|
||||||
rule_index?: number
|
rule_index?: number
|
||||||
@@ -205,7 +214,7 @@ const previewPage = ref(1)
|
|||||||
const previewPageSize = ref(20)
|
const previewPageSize = ref(20)
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageOption[]>([])
|
||||||
|
|
||||||
// 所有剧集组
|
// 所有剧集组
|
||||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||||
@@ -219,9 +228,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
storages.value = await listStorageOptions()
|
||||||
|
|
||||||
storages.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -359,6 +366,16 @@ const transferForm = reactive<TransferForm>({
|
|||||||
// 历史记录入口和文件浏览器命中的成功历史都属于重新整理。
|
// 历史记录入口和文件浏览器命中的成功历史都属于重新整理。
|
||||||
const isReorganize = computed(() => Boolean(props.logids?.length || transferForm.reorganize))
|
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(() => {
|
const mediaSource = computed(() => {
|
||||||
if (transferForm.media_source) return transferForm.media_source
|
if (transferForm.media_source) return transferForm.media_source
|
||||||
@@ -396,8 +413,7 @@ const directories = ref<TransferDirectoryConf[]>([])
|
|||||||
// 查询目录
|
// 查询目录
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
directories.value = await listTransferDirectories({ directory_type: 'library' })
|
||||||
directories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -444,6 +460,58 @@ function resetAutomaticTargetConfig() {
|
|||||||
transferForm.library_category_folder = null
|
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<ManualTransferTargetPathData>('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(
|
watch(
|
||||||
() => transferForm.target_path,
|
() => transferForm.target_path,
|
||||||
@@ -472,6 +540,14 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 自动模式下切换目标存储时重新匹配,不在显式路径模式中产生额外请求。
|
||||||
|
watch(
|
||||||
|
() => transferForm.target_storage,
|
||||||
|
() => {
|
||||||
|
if (isAutomaticTargetPath.value) void loadTargetPathMatch()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 监听媒体编号变化,仅在TMDB电视剧场景加载剧集组。
|
// 监听媒体编号变化,仅在TMDB电视剧场景加载剧集组。
|
||||||
watch(
|
watch(
|
||||||
() => transferForm.media_id,
|
() => transferForm.media_id,
|
||||||
@@ -1410,12 +1486,13 @@ async function transfer(background: boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadDirectories(), loadManualTransferHistory()])
|
await Promise.all([loadDirectories(), loadManualTransferHistory(), loadTargetPathMatch()])
|
||||||
loadStorages()
|
loadStorages()
|
||||||
loadEpisodeFormatRuleConfiguration()
|
loadEpisodeFormatRuleConfiguration()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
targetPathMatchRequestId += 1
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||||
})
|
})
|
||||||
@@ -1431,7 +1508,7 @@ onUnmounted(() => {
|
|||||||
class="reorganize-dialog-card"
|
class="reorganize-dialog-card"
|
||||||
:class="{ 'reorganize-dialog-card--split': previewVisible && display.mdAndUp.value }"
|
:class="{ 'reorganize-dialog-card--split': previewVisible && display.mdAndUp.value }"
|
||||||
>
|
>
|
||||||
<VCardItem class="py-2">
|
<VCardItem class="py-2 reorganize-dialog-card__header">
|
||||||
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
||||||
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
||||||
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||||
@@ -1488,6 +1565,54 @@ onUnmounted(() => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-folder-outline"
|
prepend-inner-icon="mdi-folder-outline"
|
||||||
/>
|
/>
|
||||||
|
<VAlert
|
||||||
|
v-if="isAutomaticTargetPath"
|
||||||
|
class="target-path-match mt-3"
|
||||||
|
:type="targetPathMatchFailed ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
:icon="false"
|
||||||
|
>
|
||||||
|
<div class="target-path-match__content">
|
||||||
|
<div class="target-path-match__message">
|
||||||
|
<VProgressCircular
|
||||||
|
v-if="targetPathMatchLoading"
|
||||||
|
color="info"
|
||||||
|
indeterminate
|
||||||
|
size="18"
|
||||||
|
width="2"
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-else
|
||||||
|
:icon="targetPathMatchFailed ? 'mdi-alert-outline' : 'mdi-source-branch-check'"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
<span v-if="targetPathMatchLoading">{{ t('dialog.reorganize.targetPathMatchLoading') }}</span>
|
||||||
|
<span v-else-if="targetPathMatchFailed">{{
|
||||||
|
t('dialog.reorganize.targetPathMatchFailed')
|
||||||
|
}}</span>
|
||||||
|
<span v-else-if="targetPathMatch?.target_path">
|
||||||
|
{{
|
||||||
|
t('dialog.reorganize.targetPathMatchSuccess', {
|
||||||
|
storage: matchedTargetStorageLabel,
|
||||||
|
path: targetPathMatch.target_path,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ t('dialog.reorganize.targetPathMatchEmpty') }}</span>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
v-if="targetPathMatch?.target_path && !targetPathMatchLoading"
|
||||||
|
color="info"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
@click="applyTargetPathMatch"
|
||||||
|
>
|
||||||
|
{{ t('dialog.reorganize.useMatchedTargetPath') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
@@ -1881,6 +2006,10 @@ onUnmounted(() => {
|
|||||||
max-block-size: min(92vh, 64rem);
|
max-block-size: min(92vh, 64rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reorganize-dialog-card__header {
|
||||||
|
padding-inline-end: 4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.reorganize-dialog-card__body {
|
.reorganize-dialog-card__body {
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
@@ -1954,6 +2083,27 @@ onUnmounted(() => {
|
|||||||
background: rgba(var(--v-theme-info), 0.12);
|
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 {
|
.reorganize-preview-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2309,6 +2459,15 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 640px) {
|
@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 {
|
.reorganize-form-pane__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { numberValidator } from '@/@validators'
|
import { numberValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
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 { useDisplay } from 'vuetify'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -59,7 +61,7 @@ const activeTab = ref('basic')
|
|||||||
const siteList = ref<Site[]>([])
|
const siteList = ref<Site[]>([])
|
||||||
|
|
||||||
// 下载目录列表
|
// 下载目录列表
|
||||||
const downloadDirectories = ref<TransferDirectoryConf[]>([])
|
const downloadDirectories = ref<DownloadDirectory[]>([])
|
||||||
|
|
||||||
// 站点选择下载框
|
// 站点选择下载框
|
||||||
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
|
||||||
@@ -171,11 +173,8 @@ async function loadDownloaderSetting() {
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
if (!canAdmin.value) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
filterRuleGroups.value = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -319,8 +318,7 @@ async function removeSubscribe() {
|
|||||||
// 查询下载目录
|
// 查询下载目录
|
||||||
async function loadDownloadDirectories() {
|
async function loadDownloadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
downloadDirectories.value = await listDownloadDirectories()
|
||||||
downloadDirectories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 AddDownloadDialog from '@/components/dialog/AddDownloadDialog.vue'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
@@ -126,13 +126,17 @@ const DialogCloseButtonStub = defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function createDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
function createDirectory(overrides: Partial<DownloadDirectory> = {}): 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 {
|
return {
|
||||||
download_path: '/downloads/default',
|
download_path: downloadPath,
|
||||||
name: '下载目录',
|
name: '下载目录',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
storage: 'local',
|
save_path: savePath,
|
||||||
transfer_type: 'link',
|
storage,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,10 +188,8 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve }
|
return { promise, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
function directoriesHandler(directories: TransferDirectoryConf[]) {
|
function directoriesHandler(directories: DownloadDirectory[]) {
|
||||||
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
|
return http.get(new URL('download/paths', API_BASE_URL).href, () => apiJson(directories))
|
||||||
apiJson({ value: directories }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) {
|
function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) {
|
||||||
@@ -215,7 +217,7 @@ async function renderDialog({
|
|||||||
recognizeSource = 'themoviedb',
|
recognizeSource = 'themoviedb',
|
||||||
torrent = createTorrent(),
|
torrent = createTorrent(),
|
||||||
}: {
|
}: {
|
||||||
directories?: TransferDirectoryConf[]
|
directories?: DownloadDirectory[]
|
||||||
downloaders?: Array<{ name: string; type: string }>
|
downloaders?: Array<{ name: string; type: string }>
|
||||||
media?: MediaInfo
|
media?: MediaInfo
|
||||||
recognizeSource?: string
|
recognizeSource?: string
|
||||||
@@ -269,35 +271,24 @@ describe('AddDownloadDialog directories', () => {
|
|||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes local, remote, missing-storage, and duplicate directories while loading downloaders', async () => {
|
it('uses API-ready paths, removes duplicates, and loads 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
|
|
||||||
|
|
||||||
await renderDialog({
|
await renderDialog({
|
||||||
directories: [
|
directories: [
|
||||||
createDirectory({ download_path: '/downloads/local' }),
|
createDirectory({ download_path: '/downloads/local', save_path: '/downloads/local' }),
|
||||||
createDirectory({ download_path: '/downloads/remote', name: '远程目录', storage: 'rclone' }),
|
createDirectory({
|
||||||
missingStorage,
|
download_path: '/downloads/remote',
|
||||||
nullStorage,
|
name: '远程目录',
|
||||||
createDirectory({ download_path: '/downloads/empty-storage', name: '空字符串存储', storage: '' }),
|
save_path: 'rclone:/downloads/remote',
|
||||||
|
storage: 'rclone',
|
||||||
|
}),
|
||||||
createDirectory({ download_path: '/downloads/remote', name: '重复目录', 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' }],
|
downloaders: [{ name: '下载器 A', type: 'qbittorrent' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByRole('option', { name: '/downloads/local' })).toBeInTheDocument()
|
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.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(await screen.findByRole('option', { name: '下载器 A' })).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('')
|
expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('')
|
||||||
expect(screen.getByLabelText('下载器(默认)')).toHaveValue('')
|
expect(screen.getByLabelText('下载器(默认)')).toHaveValue('')
|
||||||
|
|||||||
@@ -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 AddSubtitleDownloadDialog from '@/components/dialog/AddSubtitleDownloadDialog.vue'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
@@ -118,13 +118,17 @@ const DialogCloseButtonStub = defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function createDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
function createDirectory(overrides: Partial<DownloadDirectory> = {}): 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 {
|
return {
|
||||||
download_path: '/subtitles/default',
|
download_path: downloadPath,
|
||||||
name: '字幕目录',
|
name: '字幕目录',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
storage: 'local',
|
save_path: savePath,
|
||||||
transfer_type: 'link',
|
storage,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,10 +154,8 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve }
|
return { promise, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
function directoriesHandler(directories: TransferDirectoryConf[]) {
|
function directoriesHandler(directories: DownloadDirectory[]) {
|
||||||
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
|
return http.get(new URL('download/paths', API_BASE_URL).href, () => apiJson(directories))
|
||||||
apiJson({ value: directories }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtitleDownloadHandler(
|
function subtitleDownloadHandler(
|
||||||
@@ -176,7 +178,7 @@ async function renderDialog({
|
|||||||
recognizeSource = 'themoviedb',
|
recognizeSource = 'themoviedb',
|
||||||
subtitle = createSubtitle(),
|
subtitle = createSubtitle(),
|
||||||
}: {
|
}: {
|
||||||
directories?: TransferDirectoryConf[]
|
directories?: DownloadDirectory[]
|
||||||
mediaId?: string | null
|
mediaId?: string | null
|
||||||
mediaSource?: MediaDataSource
|
mediaSource?: MediaDataSource
|
||||||
recognizeSource?: string
|
recognizeSource?: string
|
||||||
@@ -230,34 +232,23 @@ describe('AddSubtitleDownloadDialog directories', () => {
|
|||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes local, remote, missing-storage, and duplicate directories while keeping the default empty option', async () => {
|
it('uses API-ready paths, removes duplicates, and keeps 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
|
|
||||||
|
|
||||||
await renderDialog({
|
await renderDialog({
|
||||||
directories: [
|
directories: [
|
||||||
createDirectory({ download_path: '/subtitles/local' }),
|
createDirectory({ download_path: '/subtitles/local', save_path: '/subtitles/local' }),
|
||||||
createDirectory({ download_path: '/subtitles/remote', name: '远程目录', storage: 's3' }),
|
createDirectory({
|
||||||
missingStorage,
|
download_path: '/subtitles/remote',
|
||||||
nullStorage,
|
name: '远程目录',
|
||||||
createDirectory({ download_path: '/subtitles/empty-storage', name: '空字符串存储', storage: '' }),
|
save_path: 's3:/subtitles/remote',
|
||||||
|
storage: 's3',
|
||||||
|
}),
|
||||||
createDirectory({ download_path: '/subtitles/remote', name: '重复目录', 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(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.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('')
|
expect(screen.getByLabelText('保存目录(自动)')).toHaveValue('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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> = {}): 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ import { createSubscribeShare } from '@tests/support/factories/subscribe'
|
|||||||
import {
|
import {
|
||||||
deleteSubscribeShareHandler,
|
deleteSubscribeShareHandler,
|
||||||
followSubscriberHandler,
|
followSubscriberHandler,
|
||||||
followSubscribersSettingHandler,
|
followedSubscribersHandler,
|
||||||
forkSubscribeHandler,
|
forkSubscribeHandler,
|
||||||
unfollowSubscriberHandler,
|
unfollowSubscriberHandler,
|
||||||
} from '@tests/support/msw/handlers/subscribe'
|
} 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 () => {
|
it('loads the followed users and shows the current action', async () => {
|
||||||
const media = createSubscribeShare({ share_uid: 'followed-user' })
|
const media = createSubscribeShare({ share_uid: 'followed-user' })
|
||||||
const requested = vi.fn()
|
const requested = vi.fn()
|
||||||
server.use(followSubscribersSettingHandler(['followed-user'], 200, requested))
|
server.use(followedSubscribersHandler(['followed-user'], 200, requested))
|
||||||
|
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('places long recognition words in a full-width metadata row', async () => {
|
it('places long recognition words in a full-width metadata row', async () => {
|
||||||
server.use(followSubscribersSettingHandler([]))
|
server.use(followedSubscribersHandler([]))
|
||||||
const { media } = await renderDialog(
|
const { media } = await renderDialog(
|
||||||
createSubscribeShare({
|
createSubscribeShare({
|
||||||
custom_words: '#九门2026\n【ADWeb】\n^The.Mystic.Nine => 九门.The.Mystic.Nine',
|
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) => {
|
const writeRequest = vi.fn((url: URL) => {
|
||||||
users.push(url.searchParams.get('share_uid') || '')
|
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()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
const writeRequest = vi.fn((url: URL) => {
|
const writeRequest = vi.fn((url: URL) => {
|
||||||
users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
|
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()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
|||||||
|
|
||||||
it('does not show follow actions when the share has no UID', async () => {
|
it('does not show follow actions when the share has no UID', async () => {
|
||||||
const media = createSubscribeShare({ share_uid: undefined })
|
const media = createSubscribeShare({ share_uid: undefined })
|
||||||
server.use(followSubscribersSettingHandler([]))
|
server.use(followedSubscribersHandler([]))
|
||||||
|
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
|
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 () => {
|
it('shows visible feedback when the followed-user list request fails', async () => {
|
||||||
server.use(followSubscribersSettingHandler([], 500))
|
server.use(followedSubscribersHandler([], 500))
|
||||||
|
|
||||||
await renderDialog(createSubscribeShare())
|
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 () => {
|
it('keeps the follow action and shows feedback when the follow request fails', async () => {
|
||||||
const media = createSubscribeShare({ share_uid: 'failed-follow-user' })
|
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()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
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 () => {
|
it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => {
|
||||||
const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' })
|
const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' })
|
||||||
server.use(
|
server.use(followedSubscribersHandler(['failed-unfollow-user']), unfollowSubscriberHandler({ success: true }, 500))
|
||||||
followSubscribersSettingHandler(['failed-unfollow-user']),
|
|
||||||
unfollowSubscriberHandler({ success: true }, 500),
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
@@ -245,7 +242,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
|
|||||||
const deferred = createDeferred()
|
const deferred = createDeferred()
|
||||||
const forkPayload = vi.fn(() => deferred.promise)
|
const forkPayload = vi.fn(() => deferred.promise)
|
||||||
server.use(
|
server.use(
|
||||||
followSubscribersSettingHandler([]),
|
followedSubscribersHandler([]),
|
||||||
forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload),
|
forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload),
|
||||||
)
|
)
|
||||||
const user = userEvent.setup()
|
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 () => {
|
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 user = userEvent.setup()
|
||||||
const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
|
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 () => {
|
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 user = userEvent.setup()
|
||||||
const { events } = await renderDialog(createSubscribeShare())
|
const { events } = await renderDialog(createSubscribeShare())
|
||||||
|
|
||||||
@@ -292,7 +289,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
|
|||||||
['a share manager', 'other-user', true, true],
|
['a share manager', 'other-user', true, true],
|
||||||
['another ordinary user', 'other-user', false, false],
|
['another ordinary user', 'other-user', false, false],
|
||||||
])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
|
])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
|
||||||
server.use(followSubscribersSettingHandler([]))
|
server.use(followedSubscribersHandler([]))
|
||||||
|
|
||||||
await renderDialog(createSubscribeShare({ share_uid: shareUid }), {
|
await renderDialog(createSubscribeShare({ share_uid: shareUid }), {
|
||||||
SUBSCRIBE_SHARE_MANAGE: canManage,
|
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 media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' })
|
||||||
const deferred = createDeferred()
|
const deferred = createDeferred()
|
||||||
const deleteRequest = vi.fn((_url: URL) => deferred.promise)
|
const deleteRequest = vi.fn((_url: URL) => deferred.promise)
|
||||||
server.use(
|
server.use(followedSubscribersHandler([]), deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest))
|
||||||
followSubscribersSettingHandler([]),
|
|
||||||
deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest),
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
|
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
|
||||||
const deleteButton = screen.getByRole('button', { name: '取消分享' })
|
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 () => {
|
it('reports a delete business failure and does not emit', async () => {
|
||||||
const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' })
|
const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' })
|
||||||
server.use(
|
server.use(
|
||||||
followSubscribersSettingHandler([]),
|
followedSubscribersHandler([]),
|
||||||
deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }),
|
deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }),
|
||||||
)
|
)
|
||||||
const user = userEvent.setup()
|
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 () => {
|
it('reports an HTTP delete failure, restores the action, and does not emit', async () => {
|
||||||
const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' })
|
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 user = userEvent.setup()
|
||||||
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
|
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 () => {
|
it('emits close from the dialog close control', async () => {
|
||||||
server.use(followSubscribersSettingHandler([]))
|
server.use(followedSubscribersHandler([]))
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { events } = await renderDialog(createSubscribeShare())
|
const { events } = await renderDialog(createSubscribeShare())
|
||||||
|
|
||||||
@@ -371,7 +365,7 @@ describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
|
|||||||
media_id: mediaId,
|
media_id: mediaId,
|
||||||
media_source: mediaSource as SubscribeShare['media_source'],
|
media_source: mediaSource as SubscribeShare['media_source'],
|
||||||
})
|
})
|
||||||
server.use(followSubscribersSettingHandler([]))
|
server.use(followedSubscribersHandler([]))
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderDialog(media)
|
await renderDialog(media)
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof import('@/api/pluginCapabilities')>()),
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof import('@/api/pluginData')>()),
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
@@ -252,20 +259,26 @@ function apiEnvelope<T>(data: T | null, success = true, message = ''): ApiRespon
|
|||||||
function publicSettingHandlers({
|
function publicSettingHandlers({
|
||||||
directories = [],
|
directories = [],
|
||||||
episodeRules = [],
|
episodeRules = [],
|
||||||
|
onTargetPathRequest,
|
||||||
storages = [],
|
storages = [],
|
||||||
|
targetPathMatch = {},
|
||||||
|
targetPathStatus = 200,
|
||||||
}: {
|
}: {
|
||||||
directories?: TransferDirectoryConf[]
|
directories?: TransferDirectoryConf[]
|
||||||
episodeRules?: unknown[]
|
episodeRules?: unknown[]
|
||||||
|
onTargetPathRequest?: (payload: ManualTransferTargetPathRequest) => void
|
||||||
storages?: StorageConf[]
|
storages?: StorageConf[]
|
||||||
|
targetPathMatch?: ManualTransferTargetPathData
|
||||||
|
targetPathStatus?: number
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return [
|
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
|
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
|
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, () => {
|
http.get(new URL('system/setting/public/EpisodeFormatRuleTable', API_BASE_URL).href, () => {
|
||||||
initializationRequestCount += 1
|
initializationRequestCount += 1
|
||||||
@@ -275,6 +288,13 @@ function publicSettingHandlers({
|
|||||||
initializationRequestCount += 1
|
initializationRequestCount += 1
|
||||||
return HttpResponse.json(apiEnvelope({ history_count: 0, reorganize: false }))
|
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,
|
logids,
|
||||||
onClose = vi.fn(),
|
onClose = vi.fn(),
|
||||||
onDone = vi.fn(),
|
onDone = vi.fn(),
|
||||||
|
onTargetPathRequest,
|
||||||
storages = [],
|
storages = [],
|
||||||
|
targetPath,
|
||||||
|
targetPathMatch = {},
|
||||||
|
targetPathStatus = 200,
|
||||||
|
targetStorage,
|
||||||
}: {
|
}: {
|
||||||
directories?: TransferDirectoryConf[]
|
directories?: TransferDirectoryConf[]
|
||||||
episodeRules?: unknown[]
|
episodeRules?: unknown[]
|
||||||
@@ -293,10 +318,24 @@ async function renderDialog({
|
|||||||
logids?: number[]
|
logids?: number[]
|
||||||
onClose?: ReturnType<typeof vi.fn>
|
onClose?: ReturnType<typeof vi.fn>
|
||||||
onDone?: ReturnType<typeof vi.fn>
|
onDone?: ReturnType<typeof vi.fn>
|
||||||
|
onTargetPathRequest?: (payload: ManualTransferTargetPathRequest) => void
|
||||||
storages?: StorageConf[]
|
storages?: StorageConf[]
|
||||||
|
targetPath?: string
|
||||||
|
targetPathMatch?: ManualTransferTargetPathData
|
||||||
|
targetPathStatus?: number
|
||||||
|
targetStorage?: string
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const resolvedItems = items ?? (logids?.length ? [] : [createFileItem()])
|
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, {
|
const result = await renderWithProviders(ReorganizeDialog, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
@@ -325,6 +364,8 @@ async function renderDialog({
|
|||||||
modelValue: true,
|
modelValue: true,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
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 () => {
|
it('submits media selection, episode group, episode formatting, and folder options from the form', async () => {
|
||||||
const bodies: unknown[] = []
|
const bodies: unknown[] = []
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('SubscribeEditDialog', () => {
|
|||||||
expect(await screen.findByText('高优先级')).toBeInTheDocument()
|
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 configRequested = vi.fn()
|
||||||
const rulesRequested = vi.fn()
|
const rulesRequested = vi.fn()
|
||||||
const saved = vi.fn()
|
const saved = vi.fn()
|
||||||
@@ -211,7 +211,7 @@ describe('SubscribeEditDialog', () => {
|
|||||||
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
|
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
|
||||||
|
|
||||||
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
||||||
expect(rulesRequested).not.toHaveBeenCalled()
|
await waitFor(() => expect(rulesRequested).toHaveBeenCalledOnce())
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
expect(saved).not.toHaveBeenCalled()
|
expect(saved).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { clearLegacyTransferHistory } from '@/api/history'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const clearing = ref(false)
|
||||||
|
|
||||||
|
/** 确认后清空可安全移除的旧整理历史,并阻止重复提交。 */
|
||||||
|
async function clearTransferHistory() {
|
||||||
|
if (!userStore.superUser || clearing.value) return
|
||||||
|
|
||||||
|
clearing.value = true
|
||||||
|
try {
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
type: 'warn',
|
||||||
|
icon: 'mdi-delete-sweep-outline',
|
||||||
|
title: t('setting.system.transferHistoryClearTitle'),
|
||||||
|
content: t('setting.system.transferHistoryClearConfirm'),
|
||||||
|
confirmText: t('setting.system.transferHistoryClear'),
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
await clearLegacyTransferHistory()
|
||||||
|
toast.success(t('setting.system.transferHistoryClearSuccess'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('setting.system.transferHistoryClearFailed'))
|
||||||
|
} finally {
|
||||||
|
clearing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="userStore.superUser"
|
||||||
|
class="transfer-history-maintenance-panel"
|
||||||
|
:aria-label="t('setting.system.transferHistoryMaintenance')"
|
||||||
|
>
|
||||||
|
<VDivider class="mb-5" />
|
||||||
|
|
||||||
|
<div class="transfer-history-maintenance-panel__content">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="d-flex align-center text-subtitle-1 font-weight-medium">
|
||||||
|
<VIcon icon="mdi-database-remove-outline" class="me-2" />
|
||||||
|
{{ t('setting.system.transferHistoryMaintenance') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||||
|
{{ t('setting.system.transferHistoryMaintenanceHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
class="transfer-history-maintenance-panel__action"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-delete-sweep-outline"
|
||||||
|
:loading="clearing"
|
||||||
|
:disabled="clearing"
|
||||||
|
@click="clearTransferHistory"
|
||||||
|
>
|
||||||
|
{{ t('setting.system.transferHistoryClear') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-history-maintenance-panel__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-history-maintenance-panel__action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.transfer-history-maintenance-panel__content {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-history-maintenance-panel__action {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(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<void>()
|
||||||
|
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%;')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import { listFilterRuleGroups } from '@/api/rule'
|
||||||
import { FilterRuleGroup } from '@/api/types'
|
import { FilterRuleGroup } from '@/api/types'
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||||
import { useUserStore } from '@/stores'
|
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
|
||||||
const canAdmin = computed(() =>
|
|
||||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
|
||||||
)
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -29,11 +23,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
if (!canAdmin.value) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
filterRuleGroups.value = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import { listStorageOptions } from '@/api/storage'
|
||||||
import { StorageConf } from '@/api/types'
|
import type { StorageOption } from '@/api/types'
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -18,12 +18,11 @@ defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageOption[]>([])
|
||||||
|
|
||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
storages.value = await listStorageOptions()
|
||||||
storages.value = result.value ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储字典
|
// 存储字典
|
||||||
|
|||||||
@@ -30,23 +30,22 @@ describe('FilterTorrentsAction', () => {
|
|||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not request admin filter groups for regular users', async () => {
|
it('loads filter groups for regular users through the active-user endpoint', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ rule_groups: [{ name: '普通用户规则' }] })
|
||||||
const { container } = await renderAction()
|
const { container } = await renderAction()
|
||||||
|
|
||||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('rule/groups', { params: { include_usage: false } }))
|
||||||
expect(getSelectItems(container, '过滤规则组')).toEqual([])
|
expect(getSelectItems(container, '过滤规则组')).toEqual([{ title: '普通用户规则', value: '普通用户规则' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps unwrapped admin filter groups to name options', async () => {
|
it('maps structured filter groups to name options', async () => {
|
||||||
mocks.apiGet.mockResolvedValue({
|
mocks.apiGet.mockResolvedValue({
|
||||||
success: true,
|
rule_groups: [{ name: '高清规则' }, { name: '字幕规则' }],
|
||||||
message: '',
|
|
||||||
data: { value: [{ name: '高清规则' }, { name: '字幕规则' }] },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { container } = await renderAction({ user: { superUser: true } })
|
const { container } = await renderAction({ user: { superUser: true } })
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/UserFilterRuleGroups'))
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('rule/groups', { params: { include_usage: false } }))
|
||||||
expect(getSelectItems(container, '过滤规则组')).toEqual([
|
expect(getSelectItems(container, '过滤规则组')).toEqual([
|
||||||
{ title: '高清规则', value: '高清规则' },
|
{ title: '高清规则', value: '高清规则' },
|
||||||
{ title: '字幕规则', value: '字幕规则' },
|
{ title: '字幕规则', value: '字幕规则' },
|
||||||
|
|||||||
@@ -30,20 +30,14 @@ describe('ScanFileAction', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('maps unwrapped storage names and types to options', async () => {
|
it('maps unwrapped storage names and types to options', async () => {
|
||||||
mocks.apiGet.mockResolvedValue({
|
mocks.apiGet.mockResolvedValue([
|
||||||
success: true,
|
{ name: '本地存储', type: 'local' },
|
||||||
message: '',
|
{ name: '阿里云盘', type: 'alipan' },
|
||||||
data: {
|
])
|
||||||
value: [
|
|
||||||
{ name: '本地存储', type: 'local' },
|
|
||||||
{ name: '阿里云盘', type: 'alipan' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { container } = await renderAction()
|
const { container } = await renderAction()
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/public/Storages'))
|
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('storage/options'))
|
||||||
expect(getSelectItems(container, '存储')).toEqual([
|
expect(getSelectItems(container, '存储')).toEqual([
|
||||||
{ title: '本地存储', value: 'local' },
|
{ title: '本地存储', value: 'local' },
|
||||||
{ title: '阿里云盘', value: 'alipan' },
|
{ title: '阿里云盘', value: 'alipan' },
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
default: {
|
default: {
|
||||||
get: (...args: unknown[]) => mocks.get(...args),
|
get: (...args: unknown[]) => mocks.get(...args),
|
||||||
|
post: (...args: unknown[]) => mocks.post(...args),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ describe('useSystemUpdateStatus', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
mocks.get.mockReset()
|
mocks.get.mockReset()
|
||||||
|
mocks.post.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -69,4 +72,29 @@ describe('useSystemUpdateStatus', () => {
|
|||||||
expect(updates.status.value?.updates?.[0].state).toBe('ready')
|
expect(updates.status.value?.updates?.[0].state).toBe('ready')
|
||||||
updates.stopPolling()
|
updates.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('coalesces concurrent manual checks and writes the returned shared status', async () => {
|
||||||
|
let resolveCheck!: (status: SystemUpdateStatus) => void
|
||||||
|
mocks.post.mockReturnValue(
|
||||||
|
new Promise<SystemUpdateStatus>(resolve => {
|
||||||
|
resolveCheck = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { useSystemUpdateStatus } = await import('@/composables/useSystemUpdateStatus')
|
||||||
|
const updates = useSystemUpdateStatus()
|
||||||
|
|
||||||
|
const first = updates.checkStatus()
|
||||||
|
const second = updates.checkStatus()
|
||||||
|
|
||||||
|
expect(first).toBe(second)
|
||||||
|
expect(updates.checking.value).toBe(true)
|
||||||
|
expect(mocks.post).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.post).toHaveBeenCalledWith('system/update/check', undefined, { feedback: 'silent' })
|
||||||
|
|
||||||
|
const checkedStatus = updateStatus('available')
|
||||||
|
resolveCheck(checkedStatus)
|
||||||
|
await expect(first).resolves.toEqual(checkedStatus)
|
||||||
|
expect(updates.status.value).toEqual(checkedStatus)
|
||||||
|
expect(updates.checking.value).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import api from '@/api'
|
|||||||
export const SYSTEM_UPDATE_MENU_EVENT = 'moviepilot:system-update-menu'
|
export const SYSTEM_UPDATE_MENU_EVENT = 'moviepilot:system-update-menu'
|
||||||
|
|
||||||
const status = ref<SystemUpdateStatus | null>(null)
|
const status = ref<SystemUpdateStatus | null>(null)
|
||||||
|
const checking = ref(false)
|
||||||
let pollingConsumers = 0
|
let pollingConsumers = 0
|
||||||
let pollingTimer: ReturnType<typeof setTimeout> | null = null
|
let pollingTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let checkRequest: Promise<SystemUpdateStatus> | null = null
|
||||||
|
|
||||||
/** 共享后台更新状态,避免升级提示和头像菜单各自维护过期快照。 */
|
/** 共享后台更新状态,避免升级提示和头像菜单各自维护过期快照。 */
|
||||||
export function useSystemUpdateStatus() {
|
export function useSystemUpdateStatus() {
|
||||||
@@ -26,6 +28,24 @@ export function useSystemUpdateStatus() {
|
|||||||
schedulePolling()
|
schedulePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 立即检查更新并合并并发请求,成功后写入共享状态。 */
|
||||||
|
function checkStatus(): Promise<SystemUpdateStatus> {
|
||||||
|
if (checkRequest) return checkRequest
|
||||||
|
|
||||||
|
checking.value = true
|
||||||
|
checkRequest = api
|
||||||
|
.post<SystemUpdateStatus>('system/update/check', undefined, { feedback: 'silent' })
|
||||||
|
.then(nextStatus => {
|
||||||
|
setStatus(nextStatus)
|
||||||
|
return nextStatus
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
checking.value = false
|
||||||
|
checkRequest = null
|
||||||
|
})
|
||||||
|
return checkRequest
|
||||||
|
}
|
||||||
|
|
||||||
function clearPollingTimer() {
|
function clearPollingTimer() {
|
||||||
if (pollingTimer) clearTimeout(pollingTimer)
|
if (pollingTimer) clearTimeout(pollingTimer)
|
||||||
pollingTimer = null
|
pollingTimer = null
|
||||||
@@ -59,5 +79,5 @@ export function useSystemUpdateStatus() {
|
|||||||
window.dispatchEvent(new CustomEvent(SYSTEM_UPDATE_MENU_EVENT, { detail: { target } }))
|
window.dispatchEvent(new CustomEvent(SYSTEM_UPDATE_MENU_EVENT, { detail: { target } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status, loadStatus, setStatus, startPolling, stopPolling, requestMenuUpdate }
|
return { status, checking, checkStatus, loadStatus, setStatus, startPolling, stopPolling, requestMenuUpdate }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ export default {
|
|||||||
restartDescription: 'The system will stop current tasks and switch to the new version after restarting.',
|
restartDescription: 'The system will stop current tasks and switch to the new version after restarting.',
|
||||||
downloadFailed: 'Failed to download the update',
|
downloadFailed: 'Failed to download the update',
|
||||||
installFailed: 'Failed to start update installation',
|
installFailed: 'Failed to start update installation',
|
||||||
|
checkFound: 'An update is available',
|
||||||
|
upToDate: 'MoviePilot is up to date',
|
||||||
|
checkFailed: 'Failed to check for updates. Try again later.',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: 'Movie',
|
movie: 'Movie',
|
||||||
@@ -1427,6 +1430,18 @@ export default {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
maintenance: {
|
||||||
|
title: 'Subscription Maintenance',
|
||||||
|
searchAll: 'Search All Subscriptions Now',
|
||||||
|
searchAllConfirmTitle: 'Search All Subscriptions?',
|
||||||
|
searchAllConfirm:
|
||||||
|
'This will immediately search every subscription available to your account and may send many site requests. Continue?',
|
||||||
|
searchAllStarted: 'The all-subscription search has started!',
|
||||||
|
refresh: 'Refresh Subscription Resources',
|
||||||
|
refreshStarted: 'The subscription refresh has started!',
|
||||||
|
refreshMetadata: 'Update Subscription Metadata',
|
||||||
|
metadataRefreshStarted: 'The subscription metadata update has started!',
|
||||||
|
},
|
||||||
mediaDetail: 'Media Details',
|
mediaDetail: 'Media Details',
|
||||||
fileStatistics: 'File Statistics',
|
fileStatistics: 'File Statistics',
|
||||||
sortTitle: 'Sort',
|
sortTitle: 'Sort',
|
||||||
@@ -1503,6 +1518,30 @@ export default {
|
|||||||
noTask: 'No Task',
|
noTask: 'No Task',
|
||||||
noTaskDescription: 'Downloading tasks will be displayed here.',
|
noTaskDescription: 'Downloading tasks will be displayed here.',
|
||||||
confirmDelete: 'Delete task "{name}" and its associated download files from the downloader?',
|
confirmDelete: 'Delete task "{name}" and its associated download files from the downloader?',
|
||||||
|
settings: {
|
||||||
|
title: 'Advanced Settings',
|
||||||
|
speedAndSeeding: 'Speed and Seeding',
|
||||||
|
locationAndCategory: 'Location and Category',
|
||||||
|
tagsAndTrackers: 'Tags and Trackers',
|
||||||
|
downloadLimit: 'Download Limit',
|
||||||
|
uploadLimit: 'Upload Limit',
|
||||||
|
kilobytesPerSecond: 'KB/s',
|
||||||
|
ratioLimit: 'Ratio Limit',
|
||||||
|
seedingTimeLimit: 'Seeding Time Limit',
|
||||||
|
minutes: 'minutes',
|
||||||
|
savePath: 'Save Path',
|
||||||
|
category: 'Downloader Category',
|
||||||
|
addTags: 'Add Tags',
|
||||||
|
trackers: 'Update Trackers',
|
||||||
|
trackersPlaceholder: 'One tracker URL per line',
|
||||||
|
nonNegative: 'The speed limit cannot be negative',
|
||||||
|
invalidNumber: 'Enter a valid number',
|
||||||
|
integerRequired: 'Enter a whole number of minutes',
|
||||||
|
invalidTracker: 'Trackers must use HTTP, HTTPS, or UDP URLs',
|
||||||
|
saveSuccess: 'Download task settings saved',
|
||||||
|
saveFailed: 'Failed to save download task settings',
|
||||||
|
partialFailure: 'Some settings were not applied. Review the operation results.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: 'Resource Search Results',
|
searchResults: 'Resource Search Results',
|
||||||
@@ -2809,6 +2848,15 @@ export default {
|
|||||||
dataCleanupOutboxDeadDaysHint: 'Unit: days. Set to 0 to keep all durable events that exhausted their retries',
|
dataCleanupOutboxDeadDaysHint: 'Unit: days. Set to 0 to keep all durable events that exhausted their retries',
|
||||||
downloadFilesCleanupNotice:
|
downloadFilesCleanupNotice:
|
||||||
'The download files table has no independent timestamp field. Its orphan record cleanup follows the retention period of download history.',
|
'The download files table has no independent timestamp field. Its orphan record cleanup follows the retention period of download history.',
|
||||||
|
transferHistoryMaintenance: 'Transfer History Maintenance',
|
||||||
|
transferHistoryMaintenanceHint:
|
||||||
|
'Manually clear all legacy transfer records without deleting source files, library files, or failed-task records.',
|
||||||
|
transferHistoryClear: 'Clear Transfer History',
|
||||||
|
transferHistoryClearTitle: 'Clear Transfer History',
|
||||||
|
transferHistoryClearConfirm:
|
||||||
|
'Clear all legacy transfer records?\nSource and library files will not be deleted, and failed-task records will be preserved. This cannot be undone.',
|
||||||
|
transferHistoryClearSuccess: 'Legacy transfer records cleared; failed-task records were preserved',
|
||||||
|
transferHistoryClearFailed: 'Failed to clear transfer history. Please try again',
|
||||||
pluginAutoReload: 'Plugin Hot Reload',
|
pluginAutoReload: 'Plugin Hot Reload',
|
||||||
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
|
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
|
||||||
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
|
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
|
||||||
@@ -2962,6 +3010,9 @@ export default {
|
|||||||
site: {
|
site: {
|
||||||
siteSync: 'Site Synchronization',
|
siteSync: 'Site Synchronization',
|
||||||
siteSyncDesc: 'Quickly sync site data from CookieCloud',
|
siteSyncDesc: 'Quickly sync site data from CookieCloud',
|
||||||
|
syncNow: 'Sync Now',
|
||||||
|
syncSuccess: 'CookieCloud sync has started.',
|
||||||
|
syncFailed: 'Failed to start CookieCloud sync.',
|
||||||
enableLocalCookieCloud: 'Enable Local CookieCloud Server',
|
enableLocalCookieCloud: 'Enable Local CookieCloud Server',
|
||||||
enableLocalCookieCloudHint:
|
enableLocalCookieCloudHint:
|
||||||
'Use built-in CookieCloud service to sync site data, service address: http://localhost:3000/cookiecloud',
|
'Use built-in CookieCloud service to sync site data, service address: http://localhost:3000/cookiecloud',
|
||||||
@@ -3959,6 +4010,11 @@ export default {
|
|||||||
targetPath: 'Target Path',
|
targetPath: 'Target Path',
|
||||||
targetPathHint: 'Organization target path. Choose Auto to match by source path.',
|
targetPathHint: 'Organization target path. Choose Auto to match by source path.',
|
||||||
targetPathPlaceholder: 'Choose Auto or enter a path',
|
targetPathPlaceholder: 'Choose Auto or enter a path',
|
||||||
|
targetPathMatchLoading: 'Matching the automatic target path…',
|
||||||
|
targetPathMatchSuccess: 'Matched {storage} · {path}',
|
||||||
|
targetPathMatchEmpty: 'No common target path matched. Keep Auto to let the backend resolve it during execution.',
|
||||||
|
targetPathMatchFailed: 'The automatic target path could not be previewed. Manual selection still works.',
|
||||||
|
useMatchedTargetPath: 'Use matched path',
|
||||||
mediaType: 'Type',
|
mediaType: 'Type',
|
||||||
mediaTypeHint: 'File media type',
|
mediaTypeHint: 'File media type',
|
||||||
musicEntity: 'Music Entity',
|
musicEntity: 'Music Entity',
|
||||||
@@ -4430,6 +4486,32 @@ export default {
|
|||||||
ratingSuccess: 'Your rating for {name} was submitted',
|
ratingSuccess: 'Your rating for {name} was submitted',
|
||||||
ratingFailed: 'Failed to submit rating: {message}',
|
ratingFailed: 'Failed to submit rating: {message}',
|
||||||
viewData: 'View Data',
|
viewData: 'View Data',
|
||||||
|
runtimeCapabilities: 'Runtime Capabilities',
|
||||||
|
runtimeCapabilitiesTitle: '{name} Runtime Capabilities',
|
||||||
|
capabilityCommands: 'Commands',
|
||||||
|
capabilityActions: 'Actions',
|
||||||
|
capabilityServices: 'Scheduled Services',
|
||||||
|
noRuntimeCapabilities: 'This plugin has no registered commands, actions, or scheduled services.',
|
||||||
|
runtimeCapabilitiesLoadFailed: 'Failed to load runtime capabilities. Please try again later.',
|
||||||
|
dataSummary: 'Data Diagnostics',
|
||||||
|
dataSummaryTitle: '{name} Data Diagnostics',
|
||||||
|
dataSummaryLoadFailed: 'Failed to load the data summary. Please try again later.',
|
||||||
|
dataItems: 'Data Items',
|
||||||
|
dataTotalCharacters: 'Total Characters',
|
||||||
|
dataCharacters: '{count} characters',
|
||||||
|
dataSummaryTruncated: 'This plugin has many data items. Showing the first {count} keys only.',
|
||||||
|
sensitiveDataKey: 'Sensitive Key',
|
||||||
|
noPersistedData: 'This plugin has no persisted data.',
|
||||||
|
dataTypeNull: 'Null',
|
||||||
|
dataTypeBoolean: 'Boolean',
|
||||||
|
dataTypeNumber: 'Number',
|
||||||
|
dataTypeString: 'String',
|
||||||
|
dataTypeArray: 'Array',
|
||||||
|
dataTypeObject: 'Object',
|
||||||
|
dataTypeUnknown: 'Unknown Type',
|
||||||
|
reload: 'Reload',
|
||||||
|
reloadSuccess: 'Plugin {name} was reloaded',
|
||||||
|
reloadFailed: 'Failed to reload plugin {name}: {message}',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
uninstall: 'Uninstall',
|
uninstall: 'Uninstall',
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export default {
|
|||||||
restartDescription: '系统将停止当前任务并重启,重启完成后正式切换到新版本。',
|
restartDescription: '系统将停止当前任务并重启,重启完成后正式切换到新版本。',
|
||||||
downloadFailed: '更新包下载失败',
|
downloadFailed: '更新包下载失败',
|
||||||
installFailed: '无法启动更新安装',
|
installFailed: '无法启动更新安装',
|
||||||
|
checkFound: '已发现可用更新',
|
||||||
|
upToDate: '当前已是最新版本',
|
||||||
|
checkFailed: '检查更新失败,请稍后重试',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '电影',
|
movie: '电影',
|
||||||
@@ -1407,6 +1410,17 @@ export default {
|
|||||||
completed: '执行完成',
|
completed: '执行完成',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
maintenance: {
|
||||||
|
title: '订阅维护',
|
||||||
|
searchAll: '立即搜索全部订阅',
|
||||||
|
searchAllConfirmTitle: '确认搜索全部订阅',
|
||||||
|
searchAllConfirm: '将立即搜索当前账号可访问的全部订阅,可能产生较多站点请求,是否继续?',
|
||||||
|
searchAllStarted: '全部订阅搜索任务已启动!',
|
||||||
|
refresh: '刷新订阅资源',
|
||||||
|
refreshStarted: '订阅刷新任务已启动!',
|
||||||
|
refreshMetadata: '更新订阅元数据',
|
||||||
|
metadataRefreshStarted: '订阅元数据更新任务已启动!',
|
||||||
|
},
|
||||||
mediaDetail: '媒体详情',
|
mediaDetail: '媒体详情',
|
||||||
fileStatistics: '文件统计',
|
fileStatistics: '文件统计',
|
||||||
sortTitle: '排序',
|
sortTitle: '排序',
|
||||||
@@ -1483,6 +1497,30 @@ export default {
|
|||||||
noTask: '没有任务',
|
noTask: '没有任务',
|
||||||
noTaskDescription: '正在下载的任务将会显示在这里。',
|
noTaskDescription: '正在下载的任务将会显示在这里。',
|
||||||
confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?',
|
confirmDelete: '确认从下载器删除任务“{name}”及对应下载文件吗?',
|
||||||
|
settings: {
|
||||||
|
title: '高级设置',
|
||||||
|
speedAndSeeding: '速度与做种',
|
||||||
|
locationAndCategory: '位置与分类',
|
||||||
|
tagsAndTrackers: '标签与 Tracker',
|
||||||
|
downloadLimit: '下载限速',
|
||||||
|
uploadLimit: '上传限速',
|
||||||
|
kilobytesPerSecond: 'KB/s',
|
||||||
|
ratioLimit: '分享率限制',
|
||||||
|
seedingTimeLimit: '做种时间限制',
|
||||||
|
minutes: '分钟',
|
||||||
|
savePath: '保存目录',
|
||||||
|
category: '下载器分类',
|
||||||
|
addTags: '添加标签',
|
||||||
|
trackers: '更新 Tracker',
|
||||||
|
trackersPlaceholder: '每行一个 Tracker 地址',
|
||||||
|
nonNegative: '限速不能小于 0',
|
||||||
|
invalidNumber: '请输入有效数字',
|
||||||
|
integerRequired: '请输入整数分钟',
|
||||||
|
invalidTracker: 'Tracker 仅支持 HTTP、HTTPS 或 UDP 地址',
|
||||||
|
saveSuccess: '下载任务设置已保存',
|
||||||
|
saveFailed: '下载任务设置保存失败',
|
||||||
|
partialFailure: '部分设置未生效,请查看逐项结果',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: '资源搜索结果',
|
searchResults: '资源搜索结果',
|
||||||
@@ -2753,6 +2791,14 @@ export default {
|
|||||||
dataCleanupOutboxDeadDays: 'Outbox 死信记录保留天数',
|
dataCleanupOutboxDeadDays: 'Outbox 死信记录保留天数',
|
||||||
dataCleanupOutboxDeadDaysHint: '单位:天,0 表示不清理重试耗尽的可靠事件记录',
|
dataCleanupOutboxDeadDaysHint: '单位:天,0 表示不清理重试耗尽的可靠事件记录',
|
||||||
downloadFilesCleanupNotice: '下载文件表没有独立时间字段,会跟随下载历史表的保留周期清理其孤儿记录。',
|
downloadFilesCleanupNotice: '下载文件表没有独立时间字段,会跟随下载历史表的保留周期清理其孤儿记录。',
|
||||||
|
transferHistoryMaintenance: '整理历史维护',
|
||||||
|
transferHistoryMaintenanceHint: '手动清空全部旧整理记录,不会删除源文件、媒体库文件或失败任务记录。',
|
||||||
|
transferHistoryClear: '清空整理历史',
|
||||||
|
transferHistoryClearTitle: '清空整理历史',
|
||||||
|
transferHistoryClearConfirm:
|
||||||
|
'确定清空全部旧整理记录吗?\n不会删除源文件或媒体库文件,失败任务记录会保留。此操作无法撤销。',
|
||||||
|
transferHistoryClearSuccess: '旧整理记录已清空,失败任务记录已保留',
|
||||||
|
transferHistoryClearFailed: '整理历史清空失败,请稍后重试',
|
||||||
pluginAutoReload: '插件热加载',
|
pluginAutoReload: '插件热加载',
|
||||||
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
||||||
pluginLocalRepoPaths: '本地插件仓库路径',
|
pluginLocalRepoPaths: '本地插件仓库路径',
|
||||||
@@ -2898,6 +2944,9 @@ export default {
|
|||||||
site: {
|
site: {
|
||||||
siteSync: '站点同步',
|
siteSync: '站点同步',
|
||||||
siteSyncDesc: '从CookieCloud快速同步站点数据',
|
siteSyncDesc: '从CookieCloud快速同步站点数据',
|
||||||
|
syncNow: '立即同步',
|
||||||
|
syncSuccess: 'CookieCloud同步任务已启动!',
|
||||||
|
syncFailed: 'CookieCloud同步启动失败!',
|
||||||
enableLocalCookieCloud: '启用本地CookieCloud服务器',
|
enableLocalCookieCloud: '启用本地CookieCloud服务器',
|
||||||
enableLocalCookieCloudHint: '使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud',
|
enableLocalCookieCloudHint: '使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud',
|
||||||
serviceAddress: '服务地址',
|
serviceAddress: '服务地址',
|
||||||
@@ -3874,6 +3923,11 @@ export default {
|
|||||||
targetPath: '目的路径',
|
targetPath: '目的路径',
|
||||||
targetPathHint: '整理目的路径,选择自动将由后端按源路径匹配',
|
targetPathHint: '整理目的路径,选择自动将由后端按源路径匹配',
|
||||||
targetPathPlaceholder: '选择自动或输入路径',
|
targetPathPlaceholder: '选择自动或输入路径',
|
||||||
|
targetPathMatchLoading: '正在匹配自动目的路径…',
|
||||||
|
targetPathMatchSuccess: '自动匹配到 {storage} · {path}',
|
||||||
|
targetPathMatchEmpty: '当前来源未匹配到统一目的路径,保持自动后将由后端在执行时处理',
|
||||||
|
targetPathMatchFailed: '无法预览自动目的路径,不影响手动选择或整理',
|
||||||
|
useMatchedTargetPath: '使用匹配路径',
|
||||||
mediaType: '类型',
|
mediaType: '类型',
|
||||||
mediaTypeHint: '文件的媒体类型',
|
mediaTypeHint: '文件的媒体类型',
|
||||||
musicEntity: '音乐实体',
|
musicEntity: '音乐实体',
|
||||||
@@ -4340,6 +4394,32 @@ export default {
|
|||||||
ratingSuccess: '已提交对插件 {name} 的评分',
|
ratingSuccess: '已提交对插件 {name} 的评分',
|
||||||
ratingFailed: '评分提交失败:{message}',
|
ratingFailed: '评分提交失败:{message}',
|
||||||
viewData: '查看数据',
|
viewData: '查看数据',
|
||||||
|
runtimeCapabilities: '运行能力',
|
||||||
|
runtimeCapabilitiesTitle: '{name}运行能力',
|
||||||
|
capabilityCommands: '命令',
|
||||||
|
capabilityActions: '动作',
|
||||||
|
capabilityServices: '定时服务',
|
||||||
|
noRuntimeCapabilities: '该插件当前没有注册命令、动作或定时服务',
|
||||||
|
runtimeCapabilitiesLoadFailed: '运行能力加载失败,请稍后重试',
|
||||||
|
dataSummary: '数据诊断',
|
||||||
|
dataSummaryTitle: '{name}数据诊断',
|
||||||
|
dataSummaryLoadFailed: '数据摘要加载失败,请稍后重试',
|
||||||
|
dataItems: '数据项',
|
||||||
|
dataTotalCharacters: '总字符数',
|
||||||
|
dataCharacters: '{count} 个字符',
|
||||||
|
dataSummaryTruncated: '数据项较多,仅显示前 {count} 个键',
|
||||||
|
sensitiveDataKey: '敏感键',
|
||||||
|
noPersistedData: '该插件当前没有持久化数据',
|
||||||
|
dataTypeNull: '空值',
|
||||||
|
dataTypeBoolean: '布尔值',
|
||||||
|
dataTypeNumber: '数字',
|
||||||
|
dataTypeString: '字符串',
|
||||||
|
dataTypeArray: '数组',
|
||||||
|
dataTypeObject: '对象',
|
||||||
|
dataTypeUnknown: '未知类型',
|
||||||
|
reload: '重新加载',
|
||||||
|
reloadSuccess: '插件 {name} 已重新加载',
|
||||||
|
reloadFailed: '插件 {name} 重新加载失败:{message}',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
uninstall: '卸载',
|
uninstall: '卸载',
|
||||||
|
|||||||
@@ -1408,6 +1408,17 @@ export default {
|
|||||||
completed: '執行完成',
|
completed: '執行完成',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
maintenance: {
|
||||||
|
title: '訂閱維護',
|
||||||
|
searchAll: '立即搜索全部訂閱',
|
||||||
|
searchAllConfirmTitle: '確認搜索全部訂閱',
|
||||||
|
searchAllConfirm: '將立即搜索目前帳號可存取的全部訂閱,可能產生較多站點請求,是否繼續?',
|
||||||
|
searchAllStarted: '全部訂閱搜索任務已啟動!',
|
||||||
|
refresh: '刷新訂閱資源',
|
||||||
|
refreshStarted: '訂閱刷新任務已啟動!',
|
||||||
|
refreshMetadata: '更新訂閱元資料',
|
||||||
|
metadataRefreshStarted: '訂閱元資料更新任務已啟動!',
|
||||||
|
},
|
||||||
sortTitle: '排序',
|
sortTitle: '排序',
|
||||||
sort: {
|
sort: {
|
||||||
custom: '自定義',
|
custom: '自定義',
|
||||||
@@ -2753,6 +2764,14 @@ export default {
|
|||||||
dataCleanupOutboxDeadDays: 'Outbox 死信記錄保留天數',
|
dataCleanupOutboxDeadDays: 'Outbox 死信記錄保留天數',
|
||||||
dataCleanupOutboxDeadDaysHint: '單位:天,0 表示不清理重試耗盡的可靠事件記錄',
|
dataCleanupOutboxDeadDaysHint: '單位:天,0 表示不清理重試耗盡的可靠事件記錄',
|
||||||
downloadFilesCleanupNotice: '下載文件表沒有獨立時間欄位,會跟隨下載歷史表的保留週期清理其孤兒記錄。',
|
downloadFilesCleanupNotice: '下載文件表沒有獨立時間欄位,會跟隨下載歷史表的保留週期清理其孤兒記錄。',
|
||||||
|
transferHistoryMaintenance: '整理歷史維護',
|
||||||
|
transferHistoryMaintenanceHint: '手動清空全部舊整理記錄,不會刪除來源檔案、媒體庫檔案或失敗任務記錄。',
|
||||||
|
transferHistoryClear: '清空整理歷史',
|
||||||
|
transferHistoryClearTitle: '清空整理歷史',
|
||||||
|
transferHistoryClearConfirm:
|
||||||
|
'確定清空全部舊整理記錄嗎?\n不會刪除來源檔案或媒體庫檔案,失敗任務記錄會保留。此操作無法復原。',
|
||||||
|
transferHistoryClearSuccess: '舊整理記錄已清空,失敗任務記錄已保留',
|
||||||
|
transferHistoryClearFailed: '整理歷史清空失敗,請稍後重試',
|
||||||
pluginAutoReload: '插件熱加載',
|
pluginAutoReload: '插件熱加載',
|
||||||
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
|
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
|
||||||
pluginLocalRepoPaths: '本地插件倉庫路徑',
|
pluginLocalRepoPaths: '本地插件倉庫路徑',
|
||||||
@@ -2898,6 +2917,9 @@ export default {
|
|||||||
site: {
|
site: {
|
||||||
siteSync: '站點同步',
|
siteSync: '站點同步',
|
||||||
siteSyncDesc: '從CookieCloud快速同步站點數據',
|
siteSyncDesc: '從CookieCloud快速同步站點數據',
|
||||||
|
syncNow: '立即同步',
|
||||||
|
syncSuccess: 'CookieCloud同步任務已啟動!',
|
||||||
|
syncFailed: 'CookieCloud同步啟動失敗!',
|
||||||
enableLocalCookieCloud: '啟用本地CookieCloud服務器',
|
enableLocalCookieCloud: '啟用本地CookieCloud服務器',
|
||||||
enableLocalCookieCloudHint: '使用內建CookieCloud服務同步站點數據,服務地址為:http://localhost:3000/cookiecloud',
|
enableLocalCookieCloudHint: '使用內建CookieCloud服務同步站點數據,服務地址為:http://localhost:3000/cookiecloud',
|
||||||
serviceAddress: '服務地址',
|
serviceAddress: '服務地址',
|
||||||
|
|||||||
@@ -43,15 +43,16 @@ const DownloadingListViewStub = defineComponent({
|
|||||||
props: {
|
props: {
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
name: String,
|
name: String,
|
||||||
|
type: String,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
return () => h('div', `${props.name}:${props.active}`)
|
return () => h('div', `${props.name}:${props.type}:${props.active}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function renderPage(appMode: boolean) {
|
async function renderPage(appMode: boolean) {
|
||||||
mocks.appMode = appMode
|
mocks.appMode = appMode
|
||||||
mocks.apiGet.mockResolvedValue([{ name: 'qb-main' }])
|
mocks.apiGet.mockResolvedValue([{ name: 'qb-main', type: 'qbittorrent' }])
|
||||||
return renderWithProviders(DownloadingPage, {
|
return renderWithProviders(DownloadingPage, {
|
||||||
initialRoute: '/downloading',
|
initialRoute: '/downloading',
|
||||||
global: {
|
global: {
|
||||||
@@ -87,6 +88,7 @@ describe('Downloading page history action', () => {
|
|||||||
it('renders a compact desktop FAB that opens download history', async () => {
|
it('renders a compact desktop FAB that opens download history', async () => {
|
||||||
await renderPage(false)
|
await renderPage(false)
|
||||||
|
|
||||||
|
await waitFor(() => expect(document.body).toHaveTextContent('qb-main:qbittorrent:true'))
|
||||||
await waitFor(() => expect(document.querySelector('.compact-fab button')).toBeInTheDocument())
|
await waitFor(() => expect(document.querySelector('.compact-fab button')).toBeInTheDocument())
|
||||||
expect(document.querySelector('.compact-fab--primary')).toBeInTheDocument()
|
expect(document.querySelector('.compact-fab--primary')).toBeInTheDocument()
|
||||||
await fireEvent.click(document.querySelector('.compact-fab button') as HTMLButtonElement)
|
await fireEvent.click(document.querySelector('.compact-fab button') as HTMLButtonElement)
|
||||||
|
|||||||
@@ -8,11 +8,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
appMode: false,
|
appMode: false,
|
||||||
|
confirm: vi.fn(),
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: vi.fn(),
|
||||||
|
refreshSubscriptionMetadata: vi.fn(),
|
||||||
|
refreshSubscriptions: vi.fn(),
|
||||||
registerHeaderTab: vi.fn(),
|
registerHeaderTab: vi.fn(),
|
||||||
|
searchAllSubscriptions: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
useDynamicButton: vi.fn(),
|
useDynamicButton: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/subscription', () => ({
|
||||||
|
refreshSubscriptionMetadata: mocks.refreshSubscriptionMetadata,
|
||||||
|
refreshSubscriptions: mocks.refreshSubscriptions,
|
||||||
|
searchAllSubscriptions: mocks.searchAllSubscriptions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useDynamicHeaderTab', () => ({
|
vi.mock('@/composables/useDynamicHeaderTab', () => ({
|
||||||
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
|
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
|
||||||
}))
|
}))
|
||||||
@@ -222,6 +242,10 @@ describe('subscribe page', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mocks.appMode = false
|
mocks.appMode = false
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.refreshSubscriptionMetadata.mockResolvedValue(null)
|
||||||
|
mocks.refreshSubscriptions.mockResolvedValue(null)
|
||||||
|
mocks.searchAllSubscriptions.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses movie route meta and query values to register the movie page contract', async () => {
|
it('uses movie route meta and query values to register the movie page contract', async () => {
|
||||||
@@ -323,7 +347,8 @@ describe('subscribe page', () => {
|
|||||||
expect(getListOutput('list sort mode')).toHaveTextContent('true')
|
expect(getListOutput('list sort mode')).toHaveTextContent('true')
|
||||||
expect(getListOutput('list sort by')).toHaveTextContent('custom')
|
expect(getListOutput('list sort by')).toHaveTextContent('custom')
|
||||||
expect(unref(batchButton.color)).toBe('gray')
|
expect(unref(batchButton.color)).toBe('gray')
|
||||||
expect(unref(getDynamicButtonConfig().show)).toBe(false)
|
expect(unref(getDynamicButtonConfig().show)).toBe(true)
|
||||||
|
expect(unref(getDynamicButtonConfig().icon)).toBe('mdi-magnify-scan')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('delegates PWA batch actions to the list public API', async () => {
|
it('delegates PWA batch actions to the list public API', async () => {
|
||||||
@@ -373,8 +398,11 @@ describe('subscribe page', () => {
|
|||||||
it('exposes administrator history and default-rule actions on desktop and PWA', async () => {
|
it('exposes administrator history and default-rule actions on desktop and PWA', async () => {
|
||||||
const { unmount } = await renderSubscribe({ superUser: true })
|
const { unmount } = await renderSubscribe({ superUser: true })
|
||||||
|
|
||||||
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(2))
|
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(3))
|
||||||
const [historyButton, defaultRuleButton] = document.querySelectorAll<HTMLButtonElement>('.compact-fab button')
|
const [maintenanceButton, historyButton, defaultRuleButton] =
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('.compact-fab button')
|
||||||
|
|
||||||
|
expect(maintenanceButton.closest('[aria-label="订阅维护"]')).not.toBeNull()
|
||||||
|
|
||||||
await fireEvent.click(historyButton)
|
await fireEvent.click(historyButton)
|
||||||
expect(getListOutput('last list command')).toHaveTextContent('open-history')
|
expect(getListOutput('last list command')).toHaveTextContent('open-history')
|
||||||
@@ -394,14 +422,19 @@ describe('subscribe page', () => {
|
|||||||
expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([
|
expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([
|
||||||
'dialog.subscribeHistory.title',
|
'dialog.subscribeHistory.title',
|
||||||
'dialog.subscribeEdit.titleDefault',
|
'dialog.subscribeEdit.titleDefault',
|
||||||
|
'subscribe.maintenance.searchAll',
|
||||||
|
'subscribe.maintenance.refresh',
|
||||||
|
'subscribe.maintenance.refreshMetadata',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes only subscription history for music on desktop and PWA', async () => {
|
it('exposes only subscription history for music on desktop and PWA', async () => {
|
||||||
const { unmount } = await renderSubscribe({ subType: '音乐', superUser: true })
|
const { unmount } = await renderSubscribe({ subType: '音乐', superUser: true })
|
||||||
|
|
||||||
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(1))
|
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(2))
|
||||||
await fireEvent.click(document.querySelector<HTMLButtonElement>('.compact-fab button')!)
|
const [maintenanceButton, historyButton] = document.querySelectorAll<HTMLButtonElement>('.compact-fab button')
|
||||||
|
expect(maintenanceButton.closest('[aria-label="订阅维护"]')).not.toBeNull()
|
||||||
|
await fireEvent.click(historyButton)
|
||||||
expect(getListOutput('last list command')).toHaveTextContent('open-history')
|
expect(getListOutput('last list command')).toHaveTextContent('open-history')
|
||||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||||
unmount()
|
unmount()
|
||||||
@@ -410,10 +443,70 @@ describe('subscribe page', () => {
|
|||||||
const dynamicButton = getDynamicButtonConfig()
|
const dynamicButton = getDynamicButtonConfig()
|
||||||
expect(unref(dynamicButton.show)).toBe(true)
|
expect(unref(dynamicButton.show)).toBe(true)
|
||||||
expect(unref(dynamicButton.icon)).toBe('mdi-history')
|
expect(unref(dynamicButton.icon)).toBe('mdi-history')
|
||||||
|
expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([
|
||||||
|
'dialog.subscribeHistory.title',
|
||||||
|
'subscribe.maintenance.searchAll',
|
||||||
|
'subscribe.maintenance.refresh',
|
||||||
|
'subscribe.maintenance.refreshMetadata',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms and starts an all-subscription search for a regular subscriber', async () => {
|
||||||
|
await renderSubscribe({ appMode: true })
|
||||||
|
const dynamicButton = getDynamicButtonConfig()
|
||||||
|
|
||||||
|
expect(unref(dynamicButton.show)).toBe(true)
|
||||||
|
expect(unref(dynamicButton.icon)).toBe('mdi-magnify-scan')
|
||||||
expect(unref(dynamicButton.menuItems)).toBeUndefined()
|
expect(unref(dynamicButton.menuItems)).toBeUndefined()
|
||||||
|
|
||||||
dynamicButton.onClick?.()
|
dynamicButton.onClick?.()
|
||||||
await nextTick()
|
|
||||||
expect(getListOutput('last list command')).toHaveTextContent('open-history')
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(mocks.searchAllSubscriptions).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('全部订阅搜索任务已启动!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start an all-subscription search after confirmation cancellation', async () => {
|
||||||
|
mocks.confirm.mockResolvedValue(false)
|
||||||
|
await renderSubscribe({ appMode: true })
|
||||||
|
|
||||||
|
getDynamicButtonConfig().onClick?.()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.searchAllSubscriptions).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs administrator maintenance commands from the existing dynamic menu', async () => {
|
||||||
|
await renderSubscribe({ appMode: true, superUser: true })
|
||||||
|
const items = unref(getDynamicButtonConfig().menuItems) ?? []
|
||||||
|
|
||||||
|
items.find(item => item.titleKey === 'subscribe.maintenance.refresh')?.action()
|
||||||
|
items.find(item => item.titleKey === 'subscribe.maintenance.refreshMetadata')?.action()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.refreshSubscriptions).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(mocks.refreshSubscriptionMetadata).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('订阅刷新任务已启动!')
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('订阅元数据更新任务已启动!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks duplicate all-subscription search submissions while the first request is pending', async () => {
|
||||||
|
let resolveSearch: ((value: null) => void) | undefined
|
||||||
|
mocks.searchAllSubscriptions.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<null>(resolve => {
|
||||||
|
resolveSearch = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await renderSubscribe({ appMode: true })
|
||||||
|
const dynamicButton = getDynamicButtonConfig()
|
||||||
|
|
||||||
|
dynamicButton.onClick?.()
|
||||||
|
dynamicButton.onClick?.()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(mocks.searchAllSubscriptions).toHaveBeenCalledOnce())
|
||||||
|
resolveSearch?.(null)
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('全部订阅搜索任务已启动!'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ useKeepAliveRefresh(async () => {
|
|||||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||||
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
||||||
<div>
|
<div>
|
||||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
<DownloadingListView :name="item.name" :type="item.type" :active="activeTab === item.name" />
|
||||||
</div>
|
</div>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
|
|||||||
+134
-1
@@ -7,7 +7,10 @@ import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useD
|
|||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
import { refreshSubscriptionMetadata, refreshSubscriptions, searchAllSubscriptions } from '@/api/subscription'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
import { getSubscribeMovieTabs, getSubscribeMusicTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
import { getSubscribeMovieTabs, getSubscribeMusicTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ const { t } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
// 非默认标签页和弹窗按需加载,避免进入订阅列表时同步下载分享/统计相关代码。
|
// 非默认标签页和弹窗按需加载,避免进入订阅列表时同步下载分享/统计相关代码。
|
||||||
const SubscribePopularView = defineAsyncComponent(() => import('@/views/subscribe/SubscribePopularView.vue'))
|
const SubscribePopularView = defineAsyncComponent(() => import('@/views/subscribe/SubscribePopularView.vue'))
|
||||||
@@ -61,6 +66,20 @@ const filterSubscribeDialog = ref(false)
|
|||||||
// 搜索订阅分享弹窗
|
// 搜索订阅分享弹窗
|
||||||
const searchShareDialog = ref(false)
|
const searchShareDialog = ref(false)
|
||||||
|
|
||||||
|
type SubscriptionMaintenanceAction = 'search' | 'refresh' | 'metadata'
|
||||||
|
|
||||||
|
interface SubscriptionMaintenanceMenuItem extends DynamicButtonMenuItem {
|
||||||
|
id: SubscriptionMaintenanceAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅维护菜单和各命令的独立忙碌状态。
|
||||||
|
const subscriptionMaintenanceMenu = ref(false)
|
||||||
|
const subscriptionMaintenanceBusy = reactive<Record<SubscriptionMaintenanceAction, boolean>>({
|
||||||
|
search: false,
|
||||||
|
refresh: false,
|
||||||
|
metadata: false,
|
||||||
|
})
|
||||||
|
|
||||||
// 排序模式
|
// 排序模式
|
||||||
const subscribeSortMode = ref(false)
|
const subscribeSortMode = ref(false)
|
||||||
|
|
||||||
@@ -236,6 +255,7 @@ const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscr
|
|||||||
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value && subType !== '音乐')
|
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value && subType !== '音乐')
|
||||||
const showSubscribeHistoryAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
|
const showSubscribeHistoryAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
|
||||||
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
|
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
|
||||||
|
const showSubscriptionMaintenanceAction = computed(() => activeTab.value === 'mysub' && canSubscribe.value)
|
||||||
const subscribeRoutePath = computed(() => {
|
const subscribeRoutePath = computed(() => {
|
||||||
if (subType === '电影') return '/subscribe/movie'
|
if (subType === '电影') return '/subscribe/movie'
|
||||||
if (subType === '音乐') return '/subscribe/music'
|
if (subType === '音乐') return '/subscribe/music'
|
||||||
@@ -262,6 +282,72 @@ function openShareStatisticsDialog() {
|
|||||||
openSharedDialog(SubscribeShareStatisticsDialog, {}, {}, { closeOn: ['close'] })
|
openSharedDialog(SubscribeShareStatisticsDialog, {}, {}, { closeOn: ['close'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 触发一项订阅维护命令,并只锁定当前命令入口。 */
|
||||||
|
async function runSubscriptionMaintenance(action: SubscriptionMaintenanceAction) {
|
||||||
|
if (subscriptionMaintenanceBusy[action]) return
|
||||||
|
subscriptionMaintenanceBusy[action] = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'search') {
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
title: t('subscribe.maintenance.searchAllConfirmTitle'),
|
||||||
|
content: t('subscribe.maintenance.searchAllConfirm'),
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
await searchAllSubscriptions()
|
||||||
|
$toast.success(t('subscribe.maintenance.searchAllStarted'))
|
||||||
|
} else if (action === 'refresh') {
|
||||||
|
await refreshSubscriptions()
|
||||||
|
$toast.success(t('subscribe.maintenance.refreshStarted'))
|
||||||
|
} else {
|
||||||
|
await refreshSubscriptionMetadata()
|
||||||
|
$toast.success(t('subscribe.maintenance.metadataRefreshStarted'))
|
||||||
|
}
|
||||||
|
subscriptionMaintenanceMenu.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
|
} finally {
|
||||||
|
subscriptionMaintenanceBusy[action] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionMaintenanceItems = computed<SubscriptionMaintenanceMenuItem[]>(() => {
|
||||||
|
const items: SubscriptionMaintenanceMenuItem[] = [
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
titleKey: 'subscribe.maintenance.searchAll',
|
||||||
|
icon: subscriptionMaintenanceBusy.search ? 'mdi-loading' : 'mdi-magnify-scan',
|
||||||
|
permission: 'subscribe',
|
||||||
|
disabled: subscriptionMaintenanceBusy.search,
|
||||||
|
action: () => void runSubscriptionMaintenance('search'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (canAdmin.value) {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: 'refresh',
|
||||||
|
titleKey: 'subscribe.maintenance.refresh',
|
||||||
|
icon: subscriptionMaintenanceBusy.refresh ? 'mdi-loading' : 'mdi-refresh',
|
||||||
|
permission: 'admin',
|
||||||
|
disabled: subscriptionMaintenanceBusy.refresh,
|
||||||
|
action: () => void runSubscriptionMaintenance('refresh'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metadata',
|
||||||
|
titleKey: 'subscribe.maintenance.refreshMetadata',
|
||||||
|
icon: subscriptionMaintenanceBusy.metadata ? 'mdi-loading' : 'mdi-database-refresh-outline',
|
||||||
|
permission: 'admin',
|
||||||
|
disabled: subscriptionMaintenanceBusy.metadata,
|
||||||
|
action: () => void runSubscriptionMaintenance('metadata'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
// 订阅列表批量状态变化响应,用于驱动移动端 Footer 和桌面 FAB 操作按钮。
|
// 订阅列表批量状态变化响应,用于驱动移动端 Footer 和桌面 FAB 操作按钮。
|
||||||
function handleSubscribeBatchStateChange(state: SubscribeBatchState) {
|
function handleSubscribeBatchStateChange(state: SubscribeBatchState) {
|
||||||
subscribeBatchState.value = state
|
subscribeBatchState.value = state
|
||||||
@@ -428,6 +514,8 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push(...subscriptionMaintenanceItems.value)
|
||||||
|
|
||||||
return items.length > 1 ? items : undefined
|
return items.length > 1 ? items : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +526,7 @@ const subscribeDynamicIcon = computed(() => {
|
|||||||
if (subscribeBatchState.value.enabled) return 'mdi-checkbox-multiple-marked-outline'
|
if (subscribeBatchState.value.enabled) return 'mdi-checkbox-multiple-marked-outline'
|
||||||
if (showShareStatisticsAction.value) return 'mdi-chart-line'
|
if (showShareStatisticsAction.value) return 'mdi-chart-line'
|
||||||
if (showSubscribeHistoryAction.value) return 'mdi-history'
|
if (showSubscribeHistoryAction.value) return 'mdi-history'
|
||||||
|
if (showSubscriptionMaintenanceAction.value) return 'mdi-magnify-scan'
|
||||||
return 'mdi-clipboard-edit-outline'
|
return 'mdi-clipboard-edit-outline'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,6 +548,11 @@ function handleSubscribeDynamicAction() {
|
|||||||
|
|
||||||
if (showDefaultRuleAction.value) {
|
if (showDefaultRuleAction.value) {
|
||||||
openDefaultRuleDialog()
|
openDefaultRuleDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSubscriptionMaintenanceAction.value) {
|
||||||
|
void runSubscriptionMaintenance('search')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +567,8 @@ useDynamicButton({
|
|||||||
(subscribeBatchState.value.enabled ||
|
(subscribeBatchState.value.enabled ||
|
||||||
showDefaultRuleAction.value ||
|
showDefaultRuleAction.value ||
|
||||||
showSubscribeHistoryAction.value ||
|
showSubscribeHistoryAction.value ||
|
||||||
showShareStatisticsAction.value),
|
showShareStatisticsAction.value ||
|
||||||
|
showSubscriptionMaintenanceAction.value),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -671,6 +766,44 @@ onMounted(() => {
|
|||||||
|
|
||||||
<Teleport to="body" v-if="!appMode && route.path.startsWith(subscribeRoutePath)">
|
<Teleport to="body" v-if="!appMode && route.path.startsWith(subscribeRoutePath)">
|
||||||
<div class="compact-fab-stack">
|
<div class="compact-fab-stack">
|
||||||
|
<VMenu
|
||||||
|
v-if="!subscribeBatchState.enabled && showSubscriptionMaintenanceAction"
|
||||||
|
v-model="subscriptionMaintenanceMenu"
|
||||||
|
location="top end"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<VFab
|
||||||
|
v-bind="props"
|
||||||
|
icon="mdi-tools"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
appear
|
||||||
|
class="compact-fab compact-fab--secondary"
|
||||||
|
:aria-label="t('subscribe.maintenance.title')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VList min-width="240" density="comfortable">
|
||||||
|
<VListItem
|
||||||
|
v-for="item in subscriptionMaintenanceItems"
|
||||||
|
:key="item.id"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="item.action"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VProgressCircular
|
||||||
|
v-if="subscriptionMaintenanceBusy[item.id]"
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
size="20"
|
||||||
|
width="2"
|
||||||
|
/>
|
||||||
|
<VIcon v-else :icon="item.icon" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ item.titleKey ? t(item.titleKey) : item.title }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
<VFab
|
<VFab
|
||||||
v-if="subscribeBatchState.enabled"
|
v-if="subscribeBatchState.enabled"
|
||||||
icon="mdi-close"
|
icon="mdi-close"
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { DashboardSystemInfo } from '@/api/types'
|
import type { DashboardSystemInfo, SystemUpdateItemStatus, SystemUpdateStatus } from '@/api/types'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
|
||||||
import { useBackground } from '@/composables/useBackground'
|
import { useBackground } from '@/composables/useBackground'
|
||||||
|
import { useSystemUpdateStatus } from '@/composables/useSystemUpdateStatus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// 是否允许刷新数据
|
// 是否允许刷新数据
|
||||||
@@ -16,7 +15,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
const { useDataRefresh } = useBackground()
|
const { useDataRefresh } = useBackground()
|
||||||
|
const { checking: updateChecking, checkStatus: checkSystemUpdateStatus } = useSystemUpdateStatus()
|
||||||
|
|
||||||
// 系统摘要与本地运行时间校准点。
|
// 系统摘要与本地运行时间校准点。
|
||||||
const systemInfo = ref<DashboardSystemInfo | null>(null)
|
const systemInfo = ref<DashboardSystemInfo | null>(null)
|
||||||
@@ -52,9 +53,26 @@ function formatRuntime(totalSeconds: number) {
|
|||||||
return t('dashboard.systemInfo.runtimeValue', { days, hours, minutes })
|
return t('dashboard.systemInfo.runtimeValue', { days, hours, minutes })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开关于页复用现有版本检查能力。 */
|
/** 判断聚合更新状态中是否存在需要用户处理的更新。 */
|
||||||
function openVersionDetails() {
|
function hasAvailableUpdate(status: SystemUpdateStatus) {
|
||||||
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
const updates: Array<Partial<SystemUpdateItemStatus>> = status.updates?.length ? status.updates : [status]
|
||||||
|
return updates.some(
|
||||||
|
item =>
|
||||||
|
item.can_update ||
|
||||||
|
item.can_install ||
|
||||||
|
['available', 'downloading', 'ready', 'installing'].includes(item.state || 'idle'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即检查更新,并通过共享状态触发现有更新提示。 */
|
||||||
|
async function checkSystemUpdate() {
|
||||||
|
try {
|
||||||
|
const status = await checkSystemUpdateStatus()
|
||||||
|
toast.success(t(hasAvailableUpdate(status) ? 'systemUpdate.checkFound' : 'systemUpdate.upToDate'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SystemUpdate] 检查更新失败', error)
|
||||||
|
toast.error(t('systemUpdate.checkFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useDataRefresh('dashboard-system-info', loadSystemInfo, 60000, true)
|
useDataRefresh('dashboard-system-info', loadSystemInfo, 60000, true)
|
||||||
@@ -93,7 +111,16 @@ onBeforeUnmount(() => {
|
|||||||
<div class="dashboard-system-footer">
|
<div class="dashboard-system-footer">
|
||||||
<span>{{ t('dashboard.systemInfo.version') }}</span>
|
<span>{{ t('dashboard.systemInfo.version') }}</span>
|
||||||
<strong>{{ systemInfo?.version || '—' }}</strong>
|
<strong>{{ systemInfo?.version || '—' }}</strong>
|
||||||
<VBtn size="small" variant="text" color="primary" class="dashboard-grid-no-drag" @click="openVersionDetails">
|
<VBtn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="dashboard-grid-no-drag"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
:loading="updateChecking"
|
||||||
|
:disabled="updateChecking"
|
||||||
|
@click="checkSystemUpdate"
|
||||||
|
>
|
||||||
{{ t('dashboard.systemInfo.checkUpdate') }}
|
{{ t('dashboard.systemInfo.checkUpdate') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import { listMediaServerClients } from '@/api/mediaServer'
|
||||||
|
import type { MediaServerClient, MediaServerPlayItem } from '@/api/types'
|
||||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||||
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
||||||
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
||||||
@@ -29,8 +30,8 @@ const hasSnapshot = ref(currentSnapshot !== undefined)
|
|||||||
const isLoading = ref(!currentSnapshot)
|
const isLoading = ref(!currentSnapshot)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 所有媒体服务器设置
|
// 已启用且不包含连接配置的媒体服务器客户端
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerClient[]>([])
|
||||||
|
|
||||||
// 小屏幕纵向空间更紧凑,展示三行;桌面端保持两行横向铺满。
|
// 小屏幕纵向空间更紧凑,展示三行;桌面端保持两行横向铺满。
|
||||||
const mediaGridRows = computed(() => (display.smAndDown.value ? 3 : 2))
|
const mediaGridRows = computed(() => (display.smAndDown.value ? 3 : 2))
|
||||||
@@ -50,12 +51,11 @@ const {
|
|||||||
let latestLoadId = 0
|
let latestLoadId = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询媒体服务器设置。
|
* 查询已启用的媒体服务器客户端。
|
||||||
*/
|
*/
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<{ value?: MediaServerConf[] }>('system/setting/MediaServers')
|
mediaServers.value = await listMediaServerClients()
|
||||||
mediaServers.value = response.value ?? []
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(t('dashboard.errors.loadMediaServer'), error)
|
console.log(t('dashboard.errors.loadMediaServer'), error)
|
||||||
@@ -101,9 +101,8 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
if (loadId !== latestLoadId) return
|
if (loadId !== latestLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
enabledServers.map(async server => [server.name, await loadLatest(server.name, count)] as const),
|
mediaServers.value.map(async server => [server.name, await loadLatest(server.name, count)] as const),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (loadId !== latestLoadId) return
|
if (loadId !== latestLoadId) return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
import { listMediaServerClients } from '@/api/mediaServer'
|
||||||
|
import type { MediaServerClient, MediaServerLibrary } from '@/api/types'
|
||||||
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
||||||
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
||||||
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
||||||
@@ -26,18 +27,17 @@ const hasSnapshot = ref(currentSnapshot !== undefined)
|
|||||||
const isLoading = ref(!currentSnapshot)
|
const isLoading = ref(!currentSnapshot)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 所有媒体服务器设置
|
// 已启用且不包含连接配置的媒体服务器客户端
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerClient[]>([])
|
||||||
let libraryLoadId = 0
|
let libraryLoadId = 0
|
||||||
let canRefreshOnActivated = false
|
let canRefreshOnActivated = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询媒体服务器设置。
|
* 查询已启用的媒体服务器客户端。
|
||||||
*/
|
*/
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
mediaServers.value = await listMediaServerClients()
|
||||||
mediaServers.value = result.value ?? []
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -76,8 +76,7 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
if (loadId !== libraryLoadId) return
|
if (loadId !== libraryLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const serverLibraries = await Promise.all(mediaServers.value.map(server => loadLibrary(server.name)))
|
||||||
const serverLibraries = await Promise.all(enabledServers.map(server => loadLibrary(server.name)))
|
|
||||||
|
|
||||||
if (loadId !== libraryLoadId) return
|
if (loadId !== libraryLoadId) return
|
||||||
if (serverLibraries.some(libraries => libraries === undefined)) {
|
if (serverLibraries.some(libraries => libraries === undefined)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import { listMediaServerClients } from '@/api/mediaServer'
|
||||||
|
import type { MediaServerClient, MediaServerPlayItem } from '@/api/types'
|
||||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||||
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
||||||
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
|
||||||
@@ -32,8 +33,8 @@ const hasSnapshot = ref(currentSnapshot !== undefined)
|
|||||||
const isLoading = ref(!currentSnapshot)
|
const isLoading = ref(!currentSnapshot)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 所有媒体服务器设置
|
// 已启用且不包含连接配置的媒体服务器客户端
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerClient[]>([])
|
||||||
|
|
||||||
// 小屏幕纵向空间更紧凑,展示三行;桌面端保持两行横向铺满。
|
// 小屏幕纵向空间更紧凑,展示三行;桌面端保持两行横向铺满。
|
||||||
const mediaGridRows = computed(() => (display.smAndDown.value ? 3 : 2))
|
const mediaGridRows = computed(() => (display.smAndDown.value ? 3 : 2))
|
||||||
@@ -55,12 +56,11 @@ const displayedPlayingList = computed(() => playingList.value.slice(0, playingIt
|
|||||||
let playingLoadId = 0
|
let playingLoadId = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询媒体服务器设置。
|
* 查询已启用的媒体服务器客户端。
|
||||||
*/
|
*/
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
mediaServers.value = await listMediaServerClients()
|
||||||
mediaServers.value = result.value ?? []
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -106,8 +106,7 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
if (loadId !== playingLoadId) return
|
if (loadId !== playingLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const serverItems = await Promise.all(mediaServers.value.map(server => loadPlayingList(server.name, count)))
|
||||||
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
|
|
||||||
|
|
||||||
if (loadId !== playingLoadId) return
|
if (loadId !== playingLoadId) return
|
||||||
if (serverItems.some(items => items === undefined)) {
|
if (serverItems.some(items => items === undefined)) {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { DashboardSystemInfo as DashboardSystemInfoData, SystemUpdateStatus } from '@/api/types'
|
||||||
|
import DashboardSystemInfo from '@/views/dashboard/DashboardSystemInfo.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
checkStatus: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const checking = ref(false)
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useBackground', () => ({
|
||||||
|
useBackground: () => ({
|
||||||
|
useDataRefresh: (_id: string, callback: () => Promise<void>) => {
|
||||||
|
void callback()
|
||||||
|
return { loading: ref(false) }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSystemUpdateStatus', () => ({
|
||||||
|
useSystemUpdateStatus: () => ({ checking, checkStatus: mocks.checkStatus }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const systemInfo: DashboardSystemInfoData = {
|
||||||
|
hostname: 'moviepilot',
|
||||||
|
operating_system: 'Linux',
|
||||||
|
runtime: 3600,
|
||||||
|
version: 'v3.0.0',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造主动检查接口的最小聚合状态。 */
|
||||||
|
function updateStatus(state: SystemUpdateStatus['state']): SystemUpdateStatus {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
current_version: 'v3.0.0',
|
||||||
|
version: state === 'available' ? 'v3.1.0' : null,
|
||||||
|
frontend_version: null,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
progress: 0,
|
||||||
|
can_update: state === 'available',
|
||||||
|
can_install: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardSystemInfo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
checking.value = false
|
||||||
|
mocks.apiGet.mockResolvedValue(systemInfo)
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks updates through the structured endpoint and reports an available version', async () => {
|
||||||
|
mocks.checkStatus.mockResolvedValue(updateStatus('available'))
|
||||||
|
await renderWithProviders(DashboardSystemInfo)
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '检查更新' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.checkStatus).toHaveBeenCalledOnce())
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('已发现可用更新')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the up-to-date state without opening a second update surface', async () => {
|
||||||
|
mocks.checkStatus.mockResolvedValue(updateStatus('idle'))
|
||||||
|
await renderWithProviders(DashboardSystemInfo)
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole('button', { name: '检查更新' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('当前已是最新版本'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a failed check and remains retryable', async () => {
|
||||||
|
mocks.checkStatus.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(updateStatus('idle'))
|
||||||
|
await renderWithProviders(DashboardSystemInfo)
|
||||||
|
const button = await screen.findByRole('button', { name: '检查更新' })
|
||||||
|
|
||||||
|
await fireEvent.click(button)
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('检查更新失败,请稍后重试'))
|
||||||
|
await fireEvent.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.checkStatus).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -118,7 +118,7 @@ describe('dashboard media server cards', () => {
|
|||||||
|
|
||||||
it('loads media libraries on an ordinary initial mount', async () => {
|
it('loads media libraries on an ordinary initial mount', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === 'mediaserver/library') return [{ id: 'movies', name: '电影库' }]
|
if (url === 'mediaserver/library') return [{ id: 'movies', name: '电影库' }]
|
||||||
throw new Error(`Unexpected GET ${url}`)
|
throw new Error(`Unexpected GET ${url}`)
|
||||||
})
|
})
|
||||||
@@ -137,7 +137,7 @@ describe('dashboard media server cards', () => {
|
|||||||
[MediaServerLibrary, 'mediaserver/library', '暂无媒体库数据'],
|
[MediaServerLibrary, 'mediaserver/library', '暂无媒体库数据'],
|
||||||
])('shows the explicit empty state for %s', async (component, endpoint, emptyText) => {
|
])('shows the explicit empty state for %s', async (component, endpoint, emptyText) => {
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === endpoint) return []
|
if (url === endpoint) return []
|
||||||
throw new Error(`Unexpected GET ${url}`)
|
throw new Error(`Unexpected GET ${url}`)
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,7 @@ describe('dashboard media server cards', () => {
|
|||||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
let endpointReads = 0
|
let endpointReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === endpoint) {
|
if (url === endpoint) {
|
||||||
endpointReads += 1
|
endpointReads += 1
|
||||||
if (endpointReads === 1) return []
|
if (endpointReads === 1) return []
|
||||||
@@ -185,7 +185,7 @@ describe('dashboard media server cards', () => {
|
|||||||
let endpointReads = 0
|
let endpointReads = 0
|
||||||
let shouldFail = true
|
let shouldFail = true
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === endpoint) {
|
if (url === endpoint) {
|
||||||
endpointReads += 1
|
endpointReads += 1
|
||||||
if (shouldFail) throw new Error('remote unavailable')
|
if (shouldFail) throw new Error('remote unavailable')
|
||||||
@@ -209,11 +209,11 @@ describe('dashboard media server cards', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('restores the last successful library snapshot before F5 revalidation completes', async () => {
|
it('restores the last successful library snapshot before F5 revalidation completes', async () => {
|
||||||
const pendingSettings = deferred<{ data: { value: Array<{ enabled: boolean; name: string }> } }>()
|
const pendingSettings = deferred<Array<{ name: string; type: string }>>()
|
||||||
let reload = false
|
let reload = false
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') {
|
if (url === 'mediaserver/clients') {
|
||||||
return reload ? pendingSettings.promise : { data: { value: [{ enabled: true, name: 'home' }] } }
|
return reload ? pendingSettings.promise : [{ name: 'home', type: 'emby' }]
|
||||||
}
|
}
|
||||||
if (url === 'mediaserver/library') return [{ id: 'movies', name: '已缓存媒体库' }]
|
if (url === 'mediaserver/library') return [{ id: 'movies', name: '已缓存媒体库' }]
|
||||||
throw new Error(`Unexpected GET ${url}`)
|
throw new Error(`Unexpected GET ${url}`)
|
||||||
@@ -234,9 +234,9 @@ describe('dashboard media server cards', () => {
|
|||||||
let settingReads = 0
|
let settingReads = 0
|
||||||
let playingReads = 0
|
let playingReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') {
|
if (url === 'mediaserver/clients') {
|
||||||
settingReads += 1
|
settingReads += 1
|
||||||
return { data: { value: [{ enabled: true, name: 'home' }] } }
|
return [{ name: 'home', type: 'emby' }]
|
||||||
}
|
}
|
||||||
if (url === 'mediaserver/playing') {
|
if (url === 'mediaserver/playing') {
|
||||||
playingReads += 1
|
playingReads += 1
|
||||||
@@ -264,7 +264,7 @@ describe('dashboard media server cards', () => {
|
|||||||
const refresh = deferred<Array<{ id: string; title: string }>>()
|
const refresh = deferred<Array<{ id: string; title: string }>>()
|
||||||
let playingReads = 0
|
let playingReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === 'mediaserver/playing') {
|
if (url === 'mediaserver/playing') {
|
||||||
playingReads += 1
|
playingReads += 1
|
||||||
return playingReads === 1 ? [{ id: 'old', title: '旧继续观看' }] : refresh.promise
|
return playingReads === 1 ? [{ id: 'old', title: '旧继续观看' }] : refresh.promise
|
||||||
@@ -294,7 +294,7 @@ describe('dashboard media server cards', () => {
|
|||||||
const refresh = deferred<Array<{ id: string; title: string }>>()
|
const refresh = deferred<Array<{ id: string; title: string }>>()
|
||||||
let latestReads = 0
|
let latestReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === 'mediaserver/latest') {
|
if (url === 'mediaserver/latest') {
|
||||||
latestReads += 1
|
latestReads += 1
|
||||||
return latestReads === 1 ? [{ id: 'old', title: '旧最近入库' }] : refresh.promise
|
return latestReads === 1 ? [{ id: 'old', title: '旧最近入库' }] : refresh.promise
|
||||||
@@ -322,7 +322,7 @@ describe('dashboard media server cards', () => {
|
|||||||
const refresh = deferred<Array<{ id: string; name: string }>>()
|
const refresh = deferred<Array<{ id: string; name: string }>>()
|
||||||
let libraryReads = 0
|
let libraryReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === 'mediaserver/library') {
|
if (url === 'mediaserver/library') {
|
||||||
libraryReads += 1
|
libraryReads += 1
|
||||||
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
|
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
|
||||||
@@ -346,7 +346,7 @@ describe('dashboard media server cards', () => {
|
|||||||
const refresh = deferred<Array<{ id: string; name: string }>>()
|
const refresh = deferred<Array<{ id: string; name: string }>>()
|
||||||
let libraryReads = 0
|
let libraryReads = 0
|
||||||
mocks.apiGet.mockImplementation((url: string) => {
|
mocks.apiGet.mockImplementation((url: string) => {
|
||||||
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
|
if (url === 'mediaserver/clients') return [{ name: 'home', type: 'emby' }]
|
||||||
if (url === 'mediaserver/library') {
|
if (url === 'mediaserver/library') {
|
||||||
libraryReads += 1
|
libraryReads += 1
|
||||||
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
|
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
|
||||||
@@ -372,15 +372,11 @@ describe('dashboard media server cards', () => {
|
|||||||
|
|
||||||
it('keeps same-id libraries from different media servers', async () => {
|
it('keeps same-id libraries from different media servers', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
|
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
|
||||||
if (url === 'system/setting/MediaServers') {
|
if (url === 'mediaserver/clients') {
|
||||||
return {
|
return [
|
||||||
data: {
|
{ name: 'home-a', type: 'emby' },
|
||||||
value: [
|
{ name: 'home-b', type: 'plex' },
|
||||||
{ enabled: true, name: 'home-a' },
|
]
|
||||||
{ enabled: true, name: 'home-b' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (url === 'mediaserver/library') {
|
if (url === 'mediaserver/library') {
|
||||||
const server = options?.params?.server
|
const server = options?.params?.server
|
||||||
@@ -403,15 +399,11 @@ describe('dashboard media server cards', () => {
|
|||||||
|
|
||||||
it('keeps same-id continue-watching items from different media servers', async () => {
|
it('keeps same-id continue-watching items from different media servers', async () => {
|
||||||
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
|
mocks.apiGet.mockImplementation((url: string, options?: { params?: { server?: string } }) => {
|
||||||
if (url === 'system/setting/MediaServers') {
|
if (url === 'mediaserver/clients') {
|
||||||
return {
|
return [
|
||||||
data: {
|
{ name: 'home-a', type: 'emby' },
|
||||||
value: [
|
{ name: 'home-b', type: 'plex' },
|
||||||
{ enabled: true, name: 'home-a' },
|
]
|
||||||
{ enabled: true, name: 'home-b' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (url === 'mediaserver/playing') {
|
if (url === 'mediaserver/playing') {
|
||||||
const server = options?.params?.server
|
const server = options?.params?.server
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import {
|
|||||||
installPluginFromSource,
|
installPluginFromSource,
|
||||||
requiresExplicitPluginSourceInstall,
|
requiresExplicitPluginSourceInstall,
|
||||||
} from '@/api/pluginSource'
|
} from '@/api/pluginSource'
|
||||||
|
import {
|
||||||
|
assignPluginToFolder,
|
||||||
|
createPluginFolder,
|
||||||
|
deletePluginFolder,
|
||||||
|
listPluginFolders,
|
||||||
|
removePluginFromFolder,
|
||||||
|
replacePluginFolderMembers,
|
||||||
|
updatePluginFolder,
|
||||||
|
} from '@/api/pluginFolders'
|
||||||
import type {
|
import type {
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginInstallOutcome,
|
PluginInstallOutcome,
|
||||||
@@ -635,13 +644,20 @@ function restoreFolderState(snapshot: FolderStateSnapshot) {
|
|||||||
currentFolder.value = snapshot.currentFolder
|
currentFolder.value = snapshot.currentFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 双写发生部分提交时,按服务端已持久化的两个事实源重建排序状态。 */
|
/** 多个持久化事实发生部分提交时,按服务端快照重建排序状态。 */
|
||||||
async function reloadPersistedOrderingState() {
|
async function reloadPersistedOrderingState() {
|
||||||
await loadPluginOrderConfig()
|
await loadPluginOrderConfig()
|
||||||
await loadPluginFolders()
|
await loadPluginFolders()
|
||||||
sortPluginOrder()
|
sortPluginOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文件夹增量写失败后优先读取服务端,校准失败时才恢复本地快照。 */
|
||||||
|
async function reloadPluginFoldersAfterFailure(snapshot: FolderStateSnapshot) {
|
||||||
|
if (!(await loadPluginFolders(true))) {
|
||||||
|
restoreFolderState(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 按order的顺序对插件进行排序
|
// 按order的顺序对插件进行排序
|
||||||
function sortPluginOrder() {
|
function sortPluginOrder() {
|
||||||
if (!orderConfig.value) {
|
if (!orderConfig.value) {
|
||||||
@@ -664,7 +680,6 @@ async function saveMixedSortOrder() {
|
|||||||
const folderSnapshot = captureFolderState()
|
const folderSnapshot = captureFolderState()
|
||||||
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||||
const previousFilteredData = [...filteredDataList.value]
|
const previousFilteredData = [...filteredDataList.value]
|
||||||
let orderPersisted = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 分离文件夹和插件,并记录它们的全局排序位置
|
// 分离文件夹和插件,并记录它们的全局排序位置
|
||||||
@@ -723,19 +738,11 @@ async function saveMixedSortOrder() {
|
|||||||
|
|
||||||
// 保存到服务端
|
// 保存到服务端
|
||||||
await savePluginOrderConfig(orderObj)
|
await savePluginOrderConfig(orderObj)
|
||||||
orderPersisted = true
|
|
||||||
|
|
||||||
// 保存文件夹排序
|
|
||||||
await savePluginFolders()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
if (orderPersisted) {
|
restoreFolderState(folderSnapshot)
|
||||||
await reloadPersistedOrderingState()
|
orderConfig.value = previousOrder
|
||||||
} else {
|
filteredDataList.value = previousFilteredData
|
||||||
restoreFolderState(folderSnapshot)
|
|
||||||
orderConfig.value = previousOrder
|
|
||||||
filteredDataList.value = previousFilteredData
|
|
||||||
}
|
|
||||||
$toast.error(t('plugin.operationFailed'))
|
$toast.error(t('plugin.operationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
// 清除拖拽标志
|
// 清除拖拽标志
|
||||||
@@ -759,6 +766,10 @@ async function saveFolderPluginOrder() {
|
|||||||
const folderData = pluginFolders.value[currentFolder.value]
|
const folderData = pluginFolders.value[currentFolder.value]
|
||||||
if (folderData) {
|
if (folderData) {
|
||||||
const newPluginIds = draggableFolderPlugins.value.map(plugin => plugin.id)
|
const newPluginIds = draggableFolderPlugins.value.map(plugin => plugin.id)
|
||||||
|
const previousFolderData = folderSnapshot.folders[currentFolder.value]
|
||||||
|
const expectedPluginIds = Array.isArray(previousFolderData)
|
||||||
|
? [...previousFolderData]
|
||||||
|
: [...(previousFolderData?.plugins || [])]
|
||||||
|
|
||||||
if (Array.isArray(folderData)) {
|
if (Array.isArray(folderData)) {
|
||||||
// 旧格式,直接替换数组
|
// 旧格式,直接替换数组
|
||||||
@@ -790,8 +801,8 @@ async function saveFolderPluginOrder() {
|
|||||||
await savePluginOrderConfig(orderConfig.value)
|
await savePluginOrderConfig(orderConfig.value)
|
||||||
orderPersisted = true
|
orderPersisted = true
|
||||||
|
|
||||||
// 保存到后端
|
// 只条件替换当前文件夹成员,避免覆盖其他文件夹和展示配置。
|
||||||
await savePluginFolders()
|
await replacePluginFolderMembers(currentFolder.value, newPluginIds, expectedPluginIds)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -1712,12 +1723,9 @@ useDynamicButton({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 获取插件文件夹配置
|
// 获取插件文件夹配置
|
||||||
async function loadPluginFolders() {
|
async function loadPluginFolders(preserveOnError = false): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const foldersData = await api.get<
|
const foldersData = await listPluginFolders()
|
||||||
Record<string, string[] | Partial<PluginFolderConfig>>,
|
|
||||||
Record<string, string[] | Partial<PluginFolderConfig>>
|
|
||||||
>('plugin/folders')
|
|
||||||
|
|
||||||
// 处理旧格式兼容性(array)和新格式(object with config)
|
// 处理旧格式兼容性(array)和新格式(object with config)
|
||||||
const processedFolders: Record<string, PluginFolderConfig> = {}
|
const processedFolders: Record<string, PluginFolderConfig> = {}
|
||||||
@@ -1763,38 +1771,15 @@ async function loadPluginFolders() {
|
|||||||
|
|
||||||
return aOrder - bOrder
|
return aOrder - bOrder
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
pluginFolders.value = {}
|
if (!preserveOnError) {
|
||||||
folderOrder.value = []
|
pluginFolders.value = {}
|
||||||
}
|
folderOrder.value = []
|
||||||
}
|
|
||||||
|
|
||||||
// 保存插件文件夹配置
|
|
||||||
async function savePluginFolders() {
|
|
||||||
const foldersToSave: Record<string, PluginFolderConfig> = {}
|
|
||||||
Object.keys(pluginFolders.value).forEach(folderName => {
|
|
||||||
const folderData = pluginFolders.value[folderName]
|
|
||||||
const orderIndex = folderOrder.value.indexOf(folderName)
|
|
||||||
const normalizedFolder = Array.isArray(folderData)
|
|
||||||
? {
|
|
||||||
plugins: [...folderData],
|
|
||||||
order: orderIndex,
|
|
||||||
icon: defaultIcon,
|
|
||||||
color: defaultColor,
|
|
||||||
gradient: defaultGradient,
|
|
||||||
background: '',
|
|
||||||
showIcon: true,
|
|
||||||
}
|
|
||||||
: folderData
|
|
||||||
|
|
||||||
foldersToSave[folderName] = {
|
|
||||||
...normalizedFolder,
|
|
||||||
order: orderIndex >= 0 ? orderIndex : 999,
|
|
||||||
}
|
}
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
await api.post('plugin/folders', foldersToSave, { feedback: 'silent' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新文件夹
|
// 创建新文件夹
|
||||||
@@ -1827,8 +1812,8 @@ async function createNewFolder() {
|
|||||||
// 添加到排序列表
|
// 添加到排序列表
|
||||||
folderOrder.value.push(folderName)
|
folderOrder.value.push(folderName)
|
||||||
|
|
||||||
// 保存到后端
|
// 只创建目标文件夹,展示默认值由前端兼容层负责。
|
||||||
await savePluginFolders()
|
await createPluginFolder(folderName)
|
||||||
|
|
||||||
folderCreateDialogController?.close()
|
folderCreateDialogController?.close()
|
||||||
folderCreateDialogController = null
|
folderCreateDialogController = null
|
||||||
@@ -1836,7 +1821,7 @@ async function createNewFolder() {
|
|||||||
$toast.success(t('plugin.folderCreateSuccess'))
|
$toast.success(t('plugin.folderCreateSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
$toast.error(t('plugin.operationFailed'))
|
$toast.error(t('plugin.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1852,44 +1837,76 @@ function backToMain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重命名文件夹
|
// 重命名文件夹
|
||||||
async function renameFolder(oldName: string, newName: string) {
|
async function renameFolder(
|
||||||
if (pluginFolders.value[newName]) {
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
onComplete?: (success: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedName = newName.trim()
|
||||||
|
if (!normalizedName) {
|
||||||
|
$toast.error(t('plugin.folderNameEmpty'))
|
||||||
|
onComplete?.(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (pluginFolders.value[normalizedName]) {
|
||||||
$toast.error(t('plugin.folderExists'))
|
$toast.error(t('plugin.folderExists'))
|
||||||
return
|
onComplete?.(false)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = captureFolderState()
|
const snapshot = captureFolderState()
|
||||||
|
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||||
|
let folderPersisted = false
|
||||||
try {
|
try {
|
||||||
// 更新本地状态
|
// 更新本地状态
|
||||||
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
const folderData = pluginFolders.value[oldName] || { plugins: [] }
|
||||||
pluginFolders.value[newName] = folderData
|
pluginFolders.value[normalizedName] = folderData
|
||||||
delete pluginFolders.value[oldName]
|
delete pluginFolders.value[oldName]
|
||||||
|
|
||||||
// 更新排序列表
|
// 更新排序列表
|
||||||
const orderIndex = folderOrder.value.indexOf(oldName)
|
const orderIndex = folderOrder.value.indexOf(oldName)
|
||||||
if (orderIndex >= 0) {
|
if (orderIndex >= 0) {
|
||||||
folderOrder.value[orderIndex] = newName
|
folderOrder.value[orderIndex] = normalizedName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在查看该文件夹,更新当前文件夹名
|
// 如果正在查看该文件夹,更新当前文件夹名
|
||||||
if (currentFolder.value === oldName) {
|
if (currentFolder.value === oldName) {
|
||||||
currentFolder.value = newName
|
currentFolder.value = normalizedName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到后端
|
const nextOrder = orderConfig.value.map(item =>
|
||||||
await savePluginFolders()
|
item.type === 'folder' && item.id === oldName ? { ...item, id: normalizedName } : item,
|
||||||
|
)
|
||||||
|
orderConfig.value = nextOrder
|
||||||
|
|
||||||
|
await updatePluginFolder(oldName, { new_name: normalizedName })
|
||||||
|
folderPersisted = true
|
||||||
|
if (nextOrder.some((item, index) => item.id !== previousOrder[index]?.id)) {
|
||||||
|
await savePluginOrderConfig(nextOrder)
|
||||||
|
}
|
||||||
|
|
||||||
$toast.success(t('plugin.folderRenameSuccess'))
|
$toast.success(t('plugin.folderRenameSuccess'))
|
||||||
|
onComplete?.(true)
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
if (folderPersisted) {
|
||||||
|
await reloadPersistedOrderingState()
|
||||||
|
} else {
|
||||||
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
|
orderConfig.value = previousOrder
|
||||||
|
}
|
||||||
$toast.error(t('plugin.folderRenameFailed'))
|
$toast.error(t('plugin.folderRenameFailed'))
|
||||||
|
onComplete?.(false)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除文件夹
|
// 删除文件夹
|
||||||
async function deleteFolder(folderName: string) {
|
async function deleteFolder(folderName: string) {
|
||||||
const snapshot = captureFolderState()
|
const snapshot = captureFolderState()
|
||||||
|
const previousOrder = orderConfig.value.map(item => ({ ...item }))
|
||||||
|
let folderPersisted = false
|
||||||
try {
|
try {
|
||||||
delete pluginFolders.value[folderName]
|
delete pluginFolders.value[folderName]
|
||||||
|
|
||||||
@@ -1901,13 +1918,24 @@ async function deleteFolder(folderName: string) {
|
|||||||
currentFolder.value = ''
|
currentFolder.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到后端
|
const nextOrder = orderConfig.value.filter(item => !(item.type === 'folder' && item.id === folderName))
|
||||||
await savePluginFolders()
|
orderConfig.value = nextOrder
|
||||||
|
|
||||||
|
await deletePluginFolder(folderName)
|
||||||
|
folderPersisted = true
|
||||||
|
if (nextOrder.length !== previousOrder.length) {
|
||||||
|
await savePluginOrderConfig(nextOrder)
|
||||||
|
}
|
||||||
|
|
||||||
$toast.success(t('plugin.folderDeleteSuccess'))
|
$toast.success(t('plugin.folderDeleteSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
if (folderPersisted) {
|
||||||
|
await reloadPersistedOrderingState()
|
||||||
|
} else {
|
||||||
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
|
orderConfig.value = previousOrder
|
||||||
|
}
|
||||||
$toast.error(t('plugin.folderDeleteFailed'))
|
$toast.error(t('plugin.folderDeleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1945,20 +1973,23 @@ async function removeFromFolder(pluginId: string) {
|
|||||||
folderData.plugins = plugins
|
folderData.plugins = plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存配置
|
await removePluginFromFolder(currentFolder.value, pluginId)
|
||||||
await savePluginFolders()
|
|
||||||
|
|
||||||
$toast.success(t('plugin.removeFromFolderSuccess'))
|
$toast.success(t('plugin.removeFromFolderSuccess'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
$toast.error(t('plugin.operationFailed'))
|
$toast.error(t('plugin.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新文件夹配置
|
// 更新文件夹配置
|
||||||
async function updateFolderConfig(folderName: string, config: Partial<PluginFolderConfig>) {
|
async function updateFolderConfig(
|
||||||
|
folderName: string,
|
||||||
|
config: Partial<PluginFolderConfig>,
|
||||||
|
onComplete?: (success: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
const snapshot = captureFolderState()
|
const snapshot = captureFolderState()
|
||||||
try {
|
try {
|
||||||
// 更新本地配置
|
// 更新本地配置
|
||||||
@@ -1968,14 +1999,23 @@ async function updateFolderConfig(folderName: string, config: Partial<PluginFold
|
|||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到后端
|
await updatePluginFolder(folderName, {
|
||||||
await savePluginFolders()
|
background: config.background,
|
||||||
|
color: config.color,
|
||||||
|
gradient: config.gradient,
|
||||||
|
icon: config.icon,
|
||||||
|
showIcon: config.showIcon,
|
||||||
|
})
|
||||||
$toast.success(t('folder.folderSettingsSaved'))
|
$toast.success(t('folder.folderSettingsSaved'))
|
||||||
}
|
}
|
||||||
|
onComplete?.(true)
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
$toast.error(t('plugin.saveFolderConfigFailed'))
|
$toast.error(t('plugin.saveFolderConfigFailed'))
|
||||||
|
onComplete?.(false)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2062,8 +2102,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
|||||||
targetFolder.plugins.push(pluginId)
|
targetFolder.plugins.push(pluginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存配置
|
await assignPluginToFolder(folderName, pluginId)
|
||||||
await savePluginFolders()
|
|
||||||
|
|
||||||
// 更新混合排序列表
|
// 更新混合排序列表
|
||||||
updateMixedSortList()
|
updateMixedSortList()
|
||||||
@@ -2071,7 +2110,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
|||||||
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
$toast.success(`插件已移动到文件夹 "${folderName}"`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
restoreFolderState(snapshot)
|
await reloadPluginFoldersAfterFailure(snapshot)
|
||||||
isDraggingSortMode.value = false
|
isDraggingSortMode.value = false
|
||||||
currentDraggedPluginId.value = ''
|
currentDraggedPluginId.value = ''
|
||||||
updateMixedSortList()
|
updateMixedSortList()
|
||||||
@@ -2324,8 +2363,10 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:sortable="true"
|
:sortable="true"
|
||||||
@open-folder="openFolder"
|
@open-folder="openFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
@rename-folder="(oldName, newName, onComplete) => renameFolder(oldName, newName, onComplete)"
|
||||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
@update-folder-config="
|
||||||
|
(folderName, config, onComplete) => updateFolderConfig(folderName, config, onComplete)
|
||||||
|
"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
@source-transition="transitionPluginSource"
|
@source-transition="transitionPluginSource"
|
||||||
@@ -2358,8 +2399,10 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
|||||||
:sortable="false"
|
:sortable="false"
|
||||||
@open-folder="openFolder"
|
@open-folder="openFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
@rename-folder="(oldName, newName, onComplete) => renameFolder(oldName, newName, onComplete)"
|
||||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
@update-folder-config="
|
||||||
|
(folderName, config, onComplete) => updateFolderConfig(folderName, config, onComplete)
|
||||||
|
"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@rating="applyPluginRating"
|
@rating="applyPluginRating"
|
||||||
@source-transition="transitionPluginSource"
|
@source-transition="transitionPluginSource"
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
const apiUrls = {
|
const apiUrls = {
|
||||||
|
assignFolderPlugin: (folderName: string, pluginId: string) =>
|
||||||
|
new URL(`plugin/folders/${folderName}/plugins/${pluginId}`, API_BASE_URL).href,
|
||||||
|
createFolder: (folderName: string) => new URL(`plugin/folders/${folderName}`, API_BASE_URL).href,
|
||||||
|
deleteFolder: (folderName: string) => new URL(`plugin/folders/${folderName}`, API_BASE_URL).href,
|
||||||
folders: new URL('plugin/folders', API_BASE_URL).href,
|
folders: new URL('plugin/folders', API_BASE_URL).href,
|
||||||
|
folderPlugins: (folderName: string) => new URL(`plugin/folders/${folderName}/plugins`, API_BASE_URL).href,
|
||||||
install: (pluginId: string) => new URL(`plugin/install/${pluginId}`, API_BASE_URL).href,
|
install: (pluginId: string) => new URL(`plugin/install/${pluginId}`, API_BASE_URL).href,
|
||||||
list: new URL('plugin/', API_BASE_URL).href,
|
list: new URL('plugin/', API_BASE_URL).href,
|
||||||
order: new URL('user/config/PluginOrder', API_BASE_URL).href,
|
order: new URL('user/config/PluginOrder', API_BASE_URL).href,
|
||||||
@@ -26,6 +31,7 @@ const apiUrls = {
|
|||||||
sourceBind: (pluginId: string) => new URL(`plugin/source/${pluginId}/install`, API_BASE_URL).href,
|
sourceBind: (pluginId: string) => new URL(`plugin/source/${pluginId}/install`, API_BASE_URL).href,
|
||||||
sourceChange: (pluginId: string) => new URL(`plugin/source/${pluginId}`, API_BASE_URL).href,
|
sourceChange: (pluginId: string) => new URL(`plugin/source/${pluginId}`, API_BASE_URL).href,
|
||||||
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
statistic: new URL('plugin/statistic', API_BASE_URL).href,
|
||||||
|
updateFolder: (folderName: string) => new URL(`plugin/folders/${folderName}`, API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -435,6 +441,12 @@ function registerListHandlers(responses: ListResponses = {}) {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(apiUrls.order, () => apiJson({ value: responses.order ?? [] })),
|
http.get(apiUrls.order, () => apiJson({ value: responses.order ?? [] })),
|
||||||
http.get(apiUrls.folders, async () => apiJson((await responses.folders?.()) ?? {})),
|
http.get(apiUrls.folders, async () => apiJson((await responses.folders?.()) ?? {})),
|
||||||
|
http.post(new URL('plugin/folders/:folderName', API_BASE_URL).href, () => apiJson(null)),
|
||||||
|
http.patch(new URL('plugin/folders/:folderName', API_BASE_URL).href, () => apiJson(null)),
|
||||||
|
http.delete(new URL('plugin/folders/:folderName', API_BASE_URL).href, () => apiJson(null)),
|
||||||
|
http.put(new URL('plugin/folders/:folderName/plugins', API_BASE_URL).href, () => apiJson(null)),
|
||||||
|
http.put(new URL('plugin/folders/:folderName/plugins/:pluginId', API_BASE_URL).href, () => apiJson(null)),
|
||||||
|
http.delete(new URL('plugin/folders/:folderName/plugins/:pluginId', API_BASE_URL).href, () => apiJson(null)),
|
||||||
http.get(apiUrls.list, async ({ request }) => {
|
http.get(apiUrls.list, async ({ request }) => {
|
||||||
const state = new URL(request.url).searchParams.get('state')
|
const state = new URL(request.url).searchParams.get('state')
|
||||||
const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.()
|
const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.()
|
||||||
@@ -2014,7 +2026,7 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
})
|
})
|
||||||
await screen.findByText('plugin:已安装插件')
|
await screen.findByText('plugin:已安装插件')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(http.post(apiUrls.folders, () => apiFailureJson('保存被拒绝')))
|
server.use(http.post(apiUrls.createFolder('失败文件夹'), () => apiFailureJson('保存被拒绝')))
|
||||||
|
|
||||||
getDynamicMenuItem('plugin.newFolder').action()
|
getDynamicMenuItem('plugin.newFolder').action()
|
||||||
const events = getDialogEvents()
|
const events = getDialogEvents()
|
||||||
@@ -2032,8 +2044,6 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await renderList({ folders: () => ({ Existing: [] }) })
|
await renderList({ folders: () => ({ Existing: [] }) })
|
||||||
await screen.findByText('folder:Existing')
|
await screen.findByText('folder:Existing')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(http.post(apiUrls.folders, () => apiJson(null)))
|
|
||||||
|
|
||||||
getDynamicMenuItem('plugin.newFolder').action()
|
getDynamicMenuItem('plugin.newFolder').action()
|
||||||
const events = getDialogEvents()
|
const events = getDialogEvents()
|
||||||
events['update:name'](' ')
|
events['update:name'](' ')
|
||||||
@@ -2057,8 +2067,6 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
})
|
})
|
||||||
await screen.findByText('folder:Tools')
|
await screen.findByText('folder:Tools')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(http.post(apiUrls.folders, () => apiJson(null)))
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' }))
|
||||||
await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000'))
|
await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000'))
|
||||||
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹设置已保存'))
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹设置已保存'))
|
||||||
@@ -2080,14 +2088,9 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
})
|
})
|
||||||
await screen.findByText('folder:Tools')
|
await screen.findByText('folder:Tools')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
let saveAttempt = 0
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post(apiUrls.folders, () => {
|
http.patch(apiUrls.updateFolder('Tools'), () => apiFailureJson('Rejected')),
|
||||||
saveAttempt += 1
|
http.delete(apiUrls.deleteFolder('Tools'), () => HttpResponse.json({ message: 'HTTP failure' }, { status: 500 })),
|
||||||
return saveAttempt === 2
|
|
||||||
? HttpResponse.json({ message: 'HTTP failure' }, { status: 500 })
|
|
||||||
: apiFailureJson('Rejected')
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' }))
|
||||||
@@ -2105,6 +2108,33 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reloads folder and user-order facts when rename succeeds before order persistence fails', async () => {
|
||||||
|
let foldersState: JsonBodyType = { Tools: { plugins: [], color: '#00ff00' } }
|
||||||
|
const persistedOrder = [{ id: 'Tools', order: 0, type: 'folder' }]
|
||||||
|
await renderList({
|
||||||
|
folders: () => foldersState,
|
||||||
|
order: persistedOrder,
|
||||||
|
})
|
||||||
|
await screen.findByText('folder:Tools')
|
||||||
|
await waitForRequestsToFinish()
|
||||||
|
server.use(
|
||||||
|
http.get(apiUrls.order, () => apiJson({ value: persistedOrder })),
|
||||||
|
http.patch(apiUrls.updateFolder('Tools'), async ({ request }) => {
|
||||||
|
const body = (await request.json()) as { new_name?: string }
|
||||||
|
foldersState = { 'Tools-renamed': { plugins: [], color: '#00ff00' } }
|
||||||
|
expect(body).toEqual({ new_name: 'Tools-renamed' })
|
||||||
|
return apiJson(null)
|
||||||
|
}),
|
||||||
|
http.post(apiUrls.order, () => apiFailureJson('Order rejected')),
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'rename-folder-Tools' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('重命名文件夹失败'))
|
||||||
|
expect(await screen.findByText('folder:Tools-renamed')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('folder:Tools')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('rolls back a failed removal and persists a later removal from a folder', async () => {
|
it('rolls back a failed removal and persists a later removal from a folder', async () => {
|
||||||
let saveSucceeds = false
|
let saveSucceeds = false
|
||||||
await renderList({
|
await renderList({
|
||||||
@@ -2114,7 +2144,7 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await screen.findByText('folder:Tools')
|
await screen.findByText('folder:Tools')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(
|
server.use(
|
||||||
http.post(apiUrls.folders, () =>
|
http.delete(apiUrls.assignFolderPlugin('Tools', 'Installed'), () =>
|
||||||
saveSucceeds ? apiJson(null) : HttpResponse.json({ message: 'Rejected' }, { status: 500 }),
|
saveSucceeds ? apiJson(null) : HttpResponse.json({ message: 'Rejected' }, { status: 500 }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2153,7 +2183,6 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
savedOrder = await request.json()
|
savedOrder = await request.json()
|
||||||
return orderSucceeds ? apiJson(null) : apiFailureJson('Rejected')
|
return orderSucceeds ? apiJson(null) : apiFailureJson('Rejected')
|
||||||
}),
|
}),
|
||||||
http.post(apiUrls.folders, () => apiJson(null)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
getHeaderButton('mdi-sort-variant').action?.()
|
getHeaderButton('mdi-sort-variant').action?.()
|
||||||
@@ -2176,14 +2205,13 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reloads server ordering when folders fail after PluginOrder is persisted', async () => {
|
it('persists mixed ordering without rewriting the folder snapshot', async () => {
|
||||||
let persistedOrder: unknown[] = [
|
let persistedOrder: unknown[] = [
|
||||||
{ id: 'Plugin-A', order: 0, type: 'plugin' },
|
{ id: 'Plugin-A', order: 0, type: 'plugin' },
|
||||||
{ id: 'Tools', order: 1, type: 'folder' },
|
{ id: 'Tools', order: 1, type: 'folder' },
|
||||||
{ id: 'Plugin-B', order: 2, type: 'plugin' },
|
{ id: 'Plugin-B', order: 2, type: 'plugin' },
|
||||||
]
|
]
|
||||||
let folderReads = 0
|
let folderWrites = 0
|
||||||
let orderReads = 0
|
|
||||||
await renderList({
|
await renderList({
|
||||||
folders: () => ({ Tools: [] }),
|
folders: () => ({ Tools: [] }),
|
||||||
installed: () => [
|
installed: () => [
|
||||||
@@ -2195,34 +2223,33 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await screen.findByText('folder:Tools')
|
await screen.findByText('folder:Tools')
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(
|
server.use(
|
||||||
http.get(apiUrls.order, () => {
|
|
||||||
orderReads += 1
|
|
||||||
return apiJson({ value: persistedOrder })
|
|
||||||
}),
|
|
||||||
http.get(apiUrls.folders, () => {
|
|
||||||
folderReads += 1
|
|
||||||
return apiJson({ Tools: [] })
|
|
||||||
}),
|
|
||||||
http.post(apiUrls.order, async ({ request }) => {
|
http.post(apiUrls.order, async ({ request }) => {
|
||||||
persistedOrder = (await request.json()) as unknown[]
|
persistedOrder = (await request.json()) as unknown[]
|
||||||
return apiJson(null)
|
return apiJson(null)
|
||||||
}),
|
}),
|
||||||
http.post(apiUrls.folders, () => apiFailureJson('Rejected')),
|
http.post(apiUrls.folders, () => {
|
||||||
|
folderWrites += 1
|
||||||
|
return apiFailureJson('整表保存不应发生')
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
getHeaderButton('mdi-sort-variant').action?.()
|
getHeaderButton('mdi-sort-variant').action?.()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
await waitFor(() =>
|
||||||
expect(orderReads).toBe(1)
|
expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A']),
|
||||||
expect(folderReads).toBe(1)
|
)
|
||||||
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
expect(folderWrites).toBe(0)
|
||||||
expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A'])
|
expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A'])
|
||||||
expect(persistedOrder).toEqual([
|
await waitFor(() =>
|
||||||
{ id: 'Plugin-B', order: 0, type: 'plugin' },
|
expect(persistedOrder).toEqual([
|
||||||
{ id: 'Tools', order: 1, type: 'folder' },
|
{ id: 'Plugin-B', order: 0, type: 'plugin' },
|
||||||
{ id: 'Plugin-A', order: 2, type: 'plugin' },
|
{ id: 'Tools', order: 1, type: 'folder' },
|
||||||
])
|
{ id: 'Plugin-A', order: 2, type: 'plugin' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2257,7 +2284,7 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
persistedOrder = (await request.json()) as unknown[]
|
persistedOrder = (await request.json()) as unknown[]
|
||||||
return apiJson(null)
|
return apiJson(null)
|
||||||
}),
|
}),
|
||||||
http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })),
|
http.put(apiUrls.folderPlugins('Tools'), () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })),
|
||||||
)
|
)
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' }))
|
||||||
@@ -2286,7 +2313,9 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(
|
server.use(
|
||||||
http.post(apiUrls.order, () => apiJson(null)),
|
http.post(apiUrls.order, () => apiJson(null)),
|
||||||
http.post(apiUrls.folders, () => (folderSaveSucceeds ? apiJson(null) : apiFailureJson('Rejected'))),
|
http.put(apiUrls.assignFolderPlugin('Tools', 'Plugin-A'), () =>
|
||||||
|
folderSaveSucceeds ? apiJson(null) : apiFailureJson('Rejected'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
getHeaderButton('mdi-sort-variant').action?.()
|
getHeaderButton('mdi-sort-variant').action?.()
|
||||||
@@ -2311,6 +2340,7 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
|
|
||||||
it('rolls back folder-internal order and persists a later successful order', async () => {
|
it('rolls back folder-internal order and persists a later successful order', async () => {
|
||||||
let orderSucceeds = false
|
let orderSucceeds = false
|
||||||
|
let folderUpdateBody: unknown
|
||||||
await renderList({
|
await renderList({
|
||||||
folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }),
|
folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }),
|
||||||
installed: () => [
|
installed: () => [
|
||||||
@@ -2327,7 +2357,10 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
server.use(
|
server.use(
|
||||||
http.post(apiUrls.order, () => (orderSucceeds ? apiJson(null) : apiFailureJson('Rejected'))),
|
http.post(apiUrls.order, () => (orderSucceeds ? apiJson(null) : apiFailureJson('Rejected'))),
|
||||||
http.post(apiUrls.folders, () => apiJson(null)),
|
http.put(apiUrls.folderPlugins('Tools'), async ({ request }) => {
|
||||||
|
folderUpdateBody = await request.json()
|
||||||
|
return apiJson(null)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' }))
|
||||||
@@ -2341,6 +2374,12 @@ describe('PluginCardListView folders and persistence', () => {
|
|||||||
orderSucceeds = true
|
orderSucceeds = true
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' }))
|
||||||
await waitFor(() => expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A']))
|
await waitFor(() => expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A']))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(folderUpdateBody).toEqual({
|
||||||
|
expected_plugins: ['Plugin-A', 'Plugin-B'],
|
||||||
|
plugins: ['Plugin-B', 'Plugin-A'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
|
|
||||||
getHeaderButton('mdi-arrow-left').action?.()
|
getHeaderButton('mdi-arrow-left').action?.()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { useConditionalDataRefresh } = useBackground()
|
|||||||
// 定义输入参数
|
// 定义输入参数
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
|
type?: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ useKeepAliveRefresh(fetchData, {
|
|||||||
:estimated-item-height="230"
|
:estimated-item-height="230"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<DownloadingCard :info="item" :downloader-name="props.name" />
|
<DownloadingCard :info="item" :downloader-name="props.name" :downloader-type="props.type" @updated="fetchData" />
|
||||||
</template>
|
</template>
|
||||||
</ProgressiveCardGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { FileItem, StorageConf, TransferDirectoryConf } from '@/api/types'
|
import { listDownloadDirectories, listStorageOptions } from '@/api/storage'
|
||||||
|
import type { DownloadDirectory, FileItem, StorageOption } from '@/api/types'
|
||||||
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
|
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
|
||||||
|
|
||||||
const endpoints = {
|
const endpoints = {
|
||||||
@@ -31,7 +32,7 @@ const endpoints = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageOption[]>([])
|
||||||
const storageTypes = computed(() => storages.value.map(s => s.type))
|
const storageTypes = computed(() => storages.value.map(s => s.type))
|
||||||
|
|
||||||
// 当前文件项
|
// 当前文件项
|
||||||
@@ -83,20 +84,21 @@ interface BrowserInitialParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从可用存储和下载目录中选择初始入口,未配置有效目录时回退到存储根路径。 */
|
/** 从可用存储和下载目录中选择初始入口,未配置有效目录时回退到存储根路径。 */
|
||||||
function determineBrowserInitialParams(downloadDirectories: TransferDirectoryConf[]): BrowserInitialParams {
|
function determineBrowserInitialParams(downloadDirectories: DownloadDirectory[]): BrowserInitialParams {
|
||||||
const isAvailable = (storage: string) => storageTypes.value.includes(storage)
|
const isAvailable = (storage: string) => storageTypes.value.includes(storage)
|
||||||
const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => {
|
const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => {
|
||||||
|
const storage = item.storage || 'local'
|
||||||
// filter out directories whose storage is not available
|
// filter out directories whose storage is not available
|
||||||
if (!isAvailable(item.storage)) {
|
if (!isAvailable(storage)) {
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
if (item.download_path == undefined) {
|
if (item.download_path == undefined) {
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
if (!dict.has(item.storage)) {
|
if (!dict.has(storage)) {
|
||||||
dict.set(item.storage, [item.download_path])
|
dict.set(storage, [item.download_path])
|
||||||
} else {
|
} else {
|
||||||
dict.get(item.storage)!.push(item.download_path)
|
dict.get(storage)!.push(item.download_path)
|
||||||
}
|
}
|
||||||
return dict
|
return dict
|
||||||
}, new Map())
|
}, new Map())
|
||||||
@@ -132,11 +134,8 @@ function determineBrowserInitialParams(downloadDirectories: TransferDirectoryCon
|
|||||||
async function loadDownloadDirectories() {
|
async function loadDownloadDirectories() {
|
||||||
try {
|
try {
|
||||||
// fetch available storages
|
// fetch available storages
|
||||||
const storageResult = await api.get<{ value?: StorageConf[] | null }>('system/setting/public/Storages')
|
storages.value = await listStorageOptions()
|
||||||
storages.value = storageResult.value ?? []
|
const directories = await listDownloadDirectories()
|
||||||
|
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] | null }>('system/setting/public/Directories')
|
|
||||||
const directories = Array.isArray(result.value) ? result.value : []
|
|
||||||
const { storage, path, name } = determineBrowserInitialParams(directories)
|
const { storage, path, name } = determineBrowserInitialParams(directories)
|
||||||
// operItem初始化
|
// operItem初始化
|
||||||
operItem.value = {
|
operItem.value = {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { debounce } from 'lodash-es'
|
import { debounce } from 'lodash-es'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api, { isApiBusinessFailure } from '@/api'
|
import api, { isApiBusinessFailure } from '@/api'
|
||||||
|
import { listStorageOptions } from '@/api/storage'
|
||||||
import type {
|
import type {
|
||||||
StorageConf,
|
StorageOption,
|
||||||
TransferHistory,
|
TransferHistory,
|
||||||
TransferHistoryDeleteResult,
|
TransferHistoryDeleteResult,
|
||||||
TransferHistoryDeleteStepStatus,
|
TransferHistoryDeleteStepStatus,
|
||||||
@@ -327,14 +328,12 @@ const hasActivatedOnce = ref(false)
|
|||||||
const confirmTitle = ref('')
|
const confirmTitle = ref('')
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageOption[]>([])
|
||||||
|
|
||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: StorageConf[] }>('system/setting/public/Storages')
|
storages.value = await listStorageOptions()
|
||||||
|
|
||||||
storages.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,16 @@ const NoDataFoundStub = defineComponent({
|
|||||||
const DownloadingCardStub = defineComponent({
|
const DownloadingCardStub = defineComponent({
|
||||||
props: {
|
props: {
|
||||||
downloaderName: String,
|
downloaderName: String,
|
||||||
|
downloaderType: String,
|
||||||
info: Object,
|
info: Object,
|
||||||
},
|
},
|
||||||
template: '<article :data-testid="`download-${info.hash}`">{{ info.title }}|{{ downloaderName }}</article>',
|
emits: ['updated'],
|
||||||
|
template: `
|
||||||
|
<article :data-testid="\`download-\${info.hash}\`">
|
||||||
|
{{ info.title }}|{{ downloaderName }}|{{ downloaderType }}
|
||||||
|
<button type="button" @click="$emit('updated')">refresh {{ info.hash }}</button>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProgressiveCardGridStub = defineComponent({
|
const ProgressiveCardGridStub = defineComponent({
|
||||||
@@ -124,7 +131,7 @@ function downloading(hash: string, title: string, overrides: Partial<Downloading
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderList(
|
async function renderList(
|
||||||
props: { active?: boolean; name?: string } = {},
|
props: { active?: boolean; name?: string; type?: string } = {},
|
||||||
options: {
|
options: {
|
||||||
onRequest?: (url: URL) => void
|
onRequest?: (url: URL) => void
|
||||||
response?: DownloadingInfo[] | ((url: URL) => DownloadingInfo[] | Promise<DownloadingInfo[]>)
|
response?: DownloadingInfo[] | ((url: URL) => DownloadingInfo[] | Promise<DownloadingInfo[]>)
|
||||||
@@ -138,6 +145,7 @@ async function renderList(
|
|||||||
props: {
|
props: {
|
||||||
active: props.active ?? true,
|
active: props.active ?? true,
|
||||||
name: props.name ?? 'primary',
|
name: props.name ?? 'primary',
|
||||||
|
type: props.type ?? 'qbittorrent',
|
||||||
},
|
},
|
||||||
initialState: {
|
initialState: {
|
||||||
user: {
|
user: {
|
||||||
@@ -174,7 +182,7 @@ describe('DownloadingListView loading and ownership', () => {
|
|||||||
it('queries the selected downloader and filters a normal user by either owner field', async () => {
|
it('queries the selected downloader and filters a normal user by either owner field', async () => {
|
||||||
const requested = vi.fn()
|
const requested = vi.fn()
|
||||||
await renderList(
|
await renderList(
|
||||||
{ name: 'qb-main' },
|
{ name: 'qb-main', type: 'qbittorrent' },
|
||||||
{
|
{
|
||||||
onRequest: requested,
|
onRequest: requested,
|
||||||
response: [
|
response: [
|
||||||
@@ -185,11 +193,14 @@ describe('DownloadingListView loading and ownership', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await screen.findByText('Own by id|qb-main')).toBeInTheDocument()
|
expect(await screen.findByText(/Own by id\|qb-main\|qbittorrent/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('Own by name|qb-main')).toBeInTheDocument()
|
expect(screen.getByText(/Own by name\|qb-main\|qbittorrent/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('Own by id|qb-main').parentElement).toHaveAttribute('data-item-key', 'own-id')
|
expect(screen.getByText(/Own by id\|qb-main\|qbittorrent/).parentElement).toHaveAttribute('data-item-key', 'own-id')
|
||||||
expect(screen.getByText('Own by name|qb-main').parentElement).toHaveAttribute('data-item-key', 'Own by name')
|
expect(screen.getByText(/Own by name\|qb-main\|qbittorrent/).parentElement).toHaveAttribute(
|
||||||
expect(screen.queryByText('Other task|qb-main')).not.toBeInTheDocument()
|
'data-item-key',
|
||||||
|
'Own by name',
|
||||||
|
)
|
||||||
|
expect(screen.queryByText(/Other task\|qb-main\|qbittorrent/)).not.toBeInTheDocument()
|
||||||
expect(requested).toHaveBeenCalledOnce()
|
expect(requested).toHaveBeenCalledOnce()
|
||||||
expect(requested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
|
expect(requested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
|
||||||
})
|
})
|
||||||
@@ -206,8 +217,8 @@ describe('DownloadingListView loading and ownership', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await screen.findByText('Own task|transmission')).toBeInTheDocument()
|
expect(await screen.findByText(/Own task\|transmission\|qbittorrent/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('Other task|transmission')).toBeInTheDocument()
|
expect(screen.getByText(/Other task\|transmission\|qbittorrent/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replaces the loading state with the successful empty state', async () => {
|
it('replaces the loading state with the successful empty state', async () => {
|
||||||
@@ -238,6 +249,26 @@ describe('DownloadingListView loading and ownership', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('DownloadingListView refresh ownership', () => {
|
describe('DownloadingListView refresh ownership', () => {
|
||||||
|
it('refreshes the current downloader after a task settings update', async () => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
let snapshot = [downloading('task', 'Before update')]
|
||||||
|
await renderList(
|
||||||
|
{ name: 'qb-main' },
|
||||||
|
{
|
||||||
|
onRequest: requested,
|
||||||
|
response: () => snapshot,
|
||||||
|
superUser: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Before update\|qb-main\|qbittorrent/)).toBeInTheDocument()
|
||||||
|
snapshot = [downloading('task', 'After update')]
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'refresh task' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText(/After update\|qb-main\|qbittorrent/)).toBeInTheDocument()
|
||||||
|
expect(requested).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses downloader-scoped identities and refreshes only the active downloader snapshot', async () => {
|
it('uses downloader-scoped identities and refreshes only the active downloader snapshot', async () => {
|
||||||
const requested = vi.fn()
|
const requested = vi.fn()
|
||||||
const snapshots: Record<string, DownloadingInfo[]> = {
|
const snapshots: Record<string, DownloadingInfo[]> = {
|
||||||
@@ -270,8 +301,8 @@ describe('DownloadingListView refresh ownership', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByText('Alpha old|alpha')).toBeInTheDocument()
|
expect(await screen.findByText(/Alpha old\|alpha\|/)).toBeInTheDocument()
|
||||||
expect(await screen.findByText('Beta old|beta')).toBeInTheDocument()
|
expect(await screen.findByText(/Beta old\|beta\|/)).toBeInTheDocument()
|
||||||
expect(requested).toHaveBeenCalledTimes(2)
|
expect(requested).toHaveBeenCalledTimes(2)
|
||||||
expect(requested.mock.calls.map(call => call[0].searchParams.get('name')).sort()).toEqual(['alpha', 'beta'])
|
expect(requested.mock.calls.map(call => call[0].searchParams.get('name')).sort()).toEqual(['alpha', 'beta'])
|
||||||
snapshots.alpha = [downloading('alpha-new', 'Alpha new')]
|
snapshots.alpha = [downloading('alpha-new', 'Alpha new')]
|
||||||
@@ -279,10 +310,10 @@ describe('DownloadingListView refresh ownership', () => {
|
|||||||
|
|
||||||
await runRegisteredRefreshes()
|
await runRegisteredRefreshes()
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Alpha new|alpha')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText(/Alpha new\|alpha\|/)).toBeInTheDocument())
|
||||||
expect(screen.queryByText('Alpha old|alpha')).not.toBeInTheDocument()
|
expect(screen.queryByText(/Alpha old\|alpha\|/)).not.toBeInTheDocument()
|
||||||
expect(screen.getByText('Beta old|beta')).toBeInTheDocument()
|
expect(screen.getByText(/Beta old\|beta\|/)).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Beta new|beta')).not.toBeInTheDocument()
|
expect(screen.queryByText(/Beta new\|beta\|/)).not.toBeInTheDocument()
|
||||||
expect(requested).toHaveBeenCalledTimes(3)
|
expect(requested).toHaveBeenCalledTimes(3)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(1)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(1)
|
||||||
@@ -291,7 +322,7 @@ describe('DownloadingListView refresh ownership', () => {
|
|||||||
snapshots.beta = [downloading('beta-activated', 'Beta activated')]
|
snapshots.beta = [downloading('beta-activated', 'Beta activated')]
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'activate beta' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'activate beta' }))
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Beta activated|beta')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText(/Beta activated\|beta\|/)).toBeInTheDocument())
|
||||||
expect(requested).toHaveBeenCalledTimes(4)
|
expect(requested).toHaveBeenCalledTimes(4)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(2)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(2)
|
||||||
@@ -299,9 +330,9 @@ describe('DownloadingListView refresh ownership', () => {
|
|||||||
snapshots.beta = [downloading('beta-new', 'Beta new')]
|
snapshots.beta = [downloading('beta-new', 'Beta new')]
|
||||||
await runRegisteredRefreshes()
|
await runRegisteredRefreshes()
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Beta new|beta')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText(/Beta new\|beta\|/)).toBeInTheDocument())
|
||||||
expect(screen.getByText('Alpha new|alpha')).toBeInTheDocument()
|
expect(screen.getByText(/Alpha new\|alpha\|/)).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Alpha later|alpha')).not.toBeInTheDocument()
|
expect(screen.queryByText(/Alpha later\|alpha\|/)).not.toBeInTheDocument()
|
||||||
expect(requested).toHaveBeenCalledTimes(5)
|
expect(requested).toHaveBeenCalledTimes(5)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'alpha')).toHaveLength(2)
|
||||||
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(3)
|
expect(requested.mock.calls.filter(call => call[0].searchParams.get('name') === 'beta')).toHaveLength(3)
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ function mockSettings(
|
|||||||
directories: Array<{ download_path?: string; storage: string }> | null,
|
directories: Array<{ download_path?: string; storage: string }> | null,
|
||||||
) {
|
) {
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
if (endpoint === 'system/setting/public/Storages') {
|
if (endpoint === 'storage/options') return storages
|
||||||
return { data: { value: storages }, success: true }
|
if (endpoint === 'download/paths') return directories
|
||||||
}
|
|
||||||
if (endpoint === 'system/setting/public/Directories') {
|
|
||||||
return { data: { value: directories }, success: true }
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected GET ${endpoint}`)
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -52,7 +48,7 @@ describe('FileBrowserView initialization', () => {
|
|||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the storage root when Directories is null', async () => {
|
it('falls back to the storage root when download paths are null', async () => {
|
||||||
mockSettings([{ name: '本地', type: 'local' }], null)
|
mockSettings([{ name: '本地', type: 'local' }], null)
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
|
|
||||||
@@ -74,7 +70,7 @@ describe('FileBrowserView initialization', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to local root when Storages and Directories are null', async () => {
|
it('falls back to local root when storage options and download paths are null', async () => {
|
||||||
mockSettings(null, null)
|
mockSettings(null, null)
|
||||||
const browser = (await mountView()).getComponent(FileBrowserStub)
|
const browser = (await mountView()).getComponent(FileBrowserStub)
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ function deleteResultResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function storageResponse() {
|
function storageResponse() {
|
||||||
return { data: { value: [] }, success: true }
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeferred<T>() {
|
function createDeferred<T>() {
|
||||||
@@ -407,7 +407,7 @@ describe('TransferHistoryView', () => {
|
|||||||
mocks.progressCallback = undefined
|
mocks.progressCallback = undefined
|
||||||
mocks.apiDelete.mockResolvedValue(deleteResultResponse())
|
mocks.apiDelete.mockResolvedValue(deleteResultResponse())
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([]))
|
return Promise.resolve(historyResponse([]))
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockResolvedValue({ data: { history_ids: [1], progress_key: 'progress-1' }, success: true })
|
mocks.apiPost.mockResolvedValue({ data: { history_ids: [1], progress_key: 'progress-1' }, success: true })
|
||||||
@@ -422,7 +422,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('uses the desktop URL query as the request source and falls back from invalid pagination values', async () => {
|
it('uses the desktop URL query as the request source and falls back from invalid pagination values', async () => {
|
||||||
const requests: Array<Record<string, unknown>> = []
|
const requests: Array<Record<string, unknown>> = []
|
||||||
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
requests.push(config?.params ?? {})
|
requests.push(config?.params ?? {})
|
||||||
return Promise.resolve(historyResponse([createHistory(1, '桌面结果')], 51))
|
return Promise.resolve(historyResponse([createHistory(1, '桌面结果')], 51))
|
||||||
})
|
})
|
||||||
@@ -436,7 +436,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('sends status as an explicit query while preserving the title search', async () => {
|
it('sends status as an explicit query while preserving the title search', async () => {
|
||||||
const requests: Array<Record<string, unknown>> = []
|
const requests: Array<Record<string, unknown>> = []
|
||||||
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
requests.push(config?.params ?? {})
|
requests.push(config?.params ?? {})
|
||||||
return Promise.resolve(historyResponse([]))
|
return Promise.resolve(historyResponse([]))
|
||||||
})
|
})
|
||||||
@@ -471,7 +471,7 @@ describe('TransferHistoryView', () => {
|
|||||||
mocks.desktop = false
|
mocks.desktop = false
|
||||||
const requests: Array<Record<string, unknown>> = []
|
const requests: Array<Record<string, unknown>> = []
|
||||||
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
requests.push(config?.params ?? {})
|
requests.push(config?.params ?? {})
|
||||||
return Promise.resolve(historyResponse([]))
|
return Promise.resolve(historyResponse([]))
|
||||||
})
|
})
|
||||||
@@ -490,7 +490,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
||||||
})
|
})
|
||||||
@@ -509,7 +509,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const hidden = createHistory(1, '筛选前记录')
|
const hidden = createHistory(1, '筛选前记录')
|
||||||
const visible = createHistory(2, '筛选后记录')
|
const visible = createHistory(2, '筛选后记录')
|
||||||
mocks.apiGet.mockImplementation((path: string, config?: { params?: { title?: string } }) => {
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: { title?: string } }) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse(config?.params?.title === 'new' ? [visible] : [hidden]))
|
return Promise.resolve(historyResponse(config?.params?.title === 'new' ? [visible] : [hidden]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -539,7 +539,7 @@ describe('TransferHistoryView', () => {
|
|||||||
image: '/poster.jpg',
|
image: '/poster.jpg',
|
||||||
})
|
})
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ describe('TransferHistoryView', () => {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse(histories))
|
return Promise.resolve(historyResponse(histories))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -618,7 +618,7 @@ describe('TransferHistoryView', () => {
|
|||||||
]
|
]
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return Promise.resolve(historyResponse(historyCalls === 1 ? firstPage : secondPage, 29))
|
return Promise.resolve(historyResponse(historyCalls === 1 ? firstPage : secondPage, 29))
|
||||||
})
|
})
|
||||||
@@ -639,7 +639,7 @@ describe('TransferHistoryView', () => {
|
|||||||
mocks.desktop = false
|
mocks.desktop = false
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
if (historyCalls === 1) return Promise.reject(new Error('temporary failure'))
|
if (historyCalls === 1) return Promise.reject(new Error('temporary failure'))
|
||||||
return Promise.resolve(historyResponse([createHistory(1, '重试结果')]))
|
return Promise.resolve(historyResponse([createHistory(1, '重试结果')]))
|
||||||
@@ -667,7 +667,7 @@ describe('TransferHistoryView', () => {
|
|||||||
year: '2025',
|
year: '2025',
|
||||||
})
|
})
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -714,7 +714,7 @@ describe('TransferHistoryView', () => {
|
|||||||
year: '2003',
|
year: '2003',
|
||||||
})
|
})
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -730,7 +730,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
||||||
})
|
})
|
||||||
@@ -765,7 +765,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('summarizes batch deletion failures, retains failed selections, and never renders undefined progress text', async () => {
|
it('summarizes batch deletion failures, retains failed selections, and never renders undefined progress text', async () => {
|
||||||
const histories = [createHistory(1, '成功项'), createHistory(2, '业务失败项'), createHistory(3, '异常失败项')]
|
const histories = [createHistory(1, '成功项'), createHistory(2, '业务失败项'), createHistory(3, '异常失败项')]
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse(histories))
|
return Promise.resolve(historyResponse(histories))
|
||||||
})
|
})
|
||||||
mocks.apiDelete
|
mocks.apiDelete
|
||||||
@@ -810,7 +810,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('shows the existing toast-style feedback when a single deletion request throws', async () => {
|
it('shows the existing toast-style feedback when a single deletion request throws', async () => {
|
||||||
const item = createHistory(1, '异常删除')
|
const item = createHistory(1, '异常删除')
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
mocks.apiDelete.mockRejectedValueOnce(new Error('delete unavailable'))
|
mocks.apiDelete.mockRejectedValueOnce(new Error('delete unavailable'))
|
||||||
@@ -827,7 +827,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('retries only the unfinished file step after a partial deletion', async () => {
|
it('retries only the unfinished file step after a partial deletion', async () => {
|
||||||
const item = createHistory(1, '部分删除')
|
const item = createHistory(1, '部分删除')
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
mocks.apiDelete
|
mocks.apiDelete
|
||||||
@@ -859,7 +859,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('releases delete-dialog ownership when either close contract fires', async () => {
|
it('releases delete-dialog ownership when either close contract fires', async () => {
|
||||||
const item = createHistory(1, '删除弹窗生命周期')
|
const item = createHistory(1, '删除弹窗生命周期')
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -881,7 +881,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const item = createHistory(26, '第二页唯一记录')
|
const item = createHistory(26, '第二页唯一记录')
|
||||||
const requestedPages: number[] = []
|
const requestedPages: number[] = []
|
||||||
mocks.apiGet.mockImplementation((path: string, config?: { params?: { page?: number } }) => {
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: { page?: number } }) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
const page = config?.params?.page ?? 1
|
const page = config?.params?.page ?? 1
|
||||||
requestedPages.push(page)
|
requestedPages.push(page)
|
||||||
if (requestedPages.length === 1) return Promise.resolve(historyResponse([item], 26))
|
if (requestedPages.length === 1) return Promise.resolve(historyResponse([item], 26))
|
||||||
@@ -901,7 +901,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const histories = [createHistory(1, '删除成功'), createHistory(2, '保留甲'), createHistory(3, '保留乙')]
|
const histories = [createHistory(1, '删除成功'), createHistory(2, '保留甲'), createHistory(3, '保留乙')]
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1)))
|
return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1)))
|
||||||
})
|
})
|
||||||
@@ -956,7 +956,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const histories = [createHistory(10, '重整甲'), createHistory(11, '重整乙')]
|
const histories = [createHistory(10, '重整甲'), createHistory(11, '重整乙')]
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return Promise.resolve(historyResponse(histories))
|
return Promise.resolve(historyResponse(histories))
|
||||||
})
|
})
|
||||||
@@ -979,7 +979,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const histories = [createHistory(1, 'AI 重整')]
|
const histories = [createHistory(1, 'AI 重整')]
|
||||||
let historyCalls = 0
|
let historyCalls = 0
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
historyCalls += 1
|
historyCalls += 1
|
||||||
return Promise.resolve(historyResponse(histories))
|
return Promise.resolve(historyResponse(histories))
|
||||||
})
|
})
|
||||||
@@ -1023,7 +1023,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('starts the single AI redo progress boundary with the accepted progress key', async () => {
|
it('starts the single AI redo progress boundary with the accepted progress key', async () => {
|
||||||
const item = createHistory(7, '单条 AI')
|
const item = createHistory(7, '单条 AI')
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockResolvedValueOnce({ data: { progress_key: 'single-progress' }, success: true })
|
mocks.apiPost.mockResolvedValueOnce({ data: { progress_key: 'single-progress' }, success: true })
|
||||||
@@ -1042,7 +1042,7 @@ describe('TransferHistoryView', () => {
|
|||||||
const item = createHistory(1, '卸载中的单条 AI')
|
const item = createHistory(1, '卸载中的单条 AI')
|
||||||
const pending = createDeferred<{ data: { progress_key: string }; success: boolean }>()
|
const pending = createDeferred<{ data: { progress_key: string }; success: boolean }>()
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
||||||
@@ -1065,7 +1065,7 @@ describe('TransferHistoryView', () => {
|
|||||||
success: boolean
|
success: boolean
|
||||||
}>()
|
}>()
|
||||||
mocks.apiGet.mockImplementation((path: string) => {
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
if (path === 'storage/options') return Promise.resolve(storageResponse())
|
||||||
return Promise.resolve(historyResponse([item]))
|
return Promise.resolve(historyResponse([item]))
|
||||||
})
|
})
|
||||||
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api, { getApiErrorMessage } from '@/api'
|
import { getApiErrorMessage } from '@/api'
|
||||||
|
import { listTransferDirectories } from '@/api/storage'
|
||||||
import type { TransferDirectoryConf } from '@/api/types'
|
import type { TransferDirectoryConf } from '@/api/types'
|
||||||
import type {
|
import type {
|
||||||
ClassificationCategory,
|
ClassificationCategory,
|
||||||
@@ -267,10 +268,7 @@ async function ensureInitialized(force = false): Promise<void> {
|
|||||||
async function loadDirectoryReferences(): Promise<void> {
|
async function loadDirectoryReferences(): Promise<void> {
|
||||||
directoryReferencesUnavailable.value = false
|
directoryReferencesUnavailable.value = false
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories', {
|
directories.value = await listTransferDirectories()
|
||||||
feedback: 'silent',
|
|
||||||
})
|
|
||||||
directories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
directories.value = []
|
directories.value = []
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api, { getApiErrorMessage } from '@/api'
|
import api, { getApiErrorMessage } from '@/api'
|
||||||
|
import { listTransferDirectories } from '@/api/storage'
|
||||||
|
import { getSystemSetting } from '@/api/systemSettings'
|
||||||
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
|
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
|
||||||
import type { ClassificationCategory } from '@/api/mediaClassification'
|
import type { ClassificationCategory } from '@/api/mediaClassification'
|
||||||
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||||
@@ -130,14 +132,12 @@ const musicRenameFormat = computed({
|
|||||||
// 加载系统设置
|
// 加载系统设置
|
||||||
async function loadSystemSettings() {
|
async function loadSystemSettings() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/env')
|
const keys = Object.keys(SystemSettings.value.Basic)
|
||||||
// 将API返回的值赋值给SystemSettings
|
const settings = await Promise.all(keys.map(key => getSystemSetting(key)))
|
||||||
for (const sectionKey of Object.keys(SystemSettings.value) as Array<keyof typeof SystemSettings.value>) {
|
for (const setting of settings) {
|
||||||
Object.keys(SystemSettings.value[sectionKey]).forEach((key: string) => {
|
if (setting && Object.prototype.hasOwnProperty.call(SystemSettings.value.Basic, setting.setting_key)) {
|
||||||
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
Reflect.set(SystemSettings.value.Basic, setting.setting_key, setting.value)
|
||||||
Reflect.set(SystemSettings.value[sectionKey], key, result[key])
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -165,7 +165,7 @@ function orderDirectoryCards() {
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: StorageConf[] }>('system/setting/public/Storages')
|
const result = await api.get<{ value?: StorageConf[] }>('system/setting/Storages')
|
||||||
storages.value = result.value ?? []
|
storages.value = result.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -186,8 +186,7 @@ async function saveStorages() {
|
|||||||
// 查询目录
|
// 查询目录
|
||||||
async function loadDirectories(options: { rethrow?: boolean } = {}) {
|
async function loadDirectories(options: { rethrow?: boolean } = {}) {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
directories.value = await listTransferDirectories()
|
||||||
directories.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
if (options.rethrow) throw error
|
if (options.rethrow) throw error
|
||||||
|
|||||||
@@ -3,7 +3,21 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
import {
|
||||||
|
createCustomRule,
|
||||||
|
createFilterRuleGroup,
|
||||||
|
deleteCustomRule,
|
||||||
|
deleteFilterRuleGroup,
|
||||||
|
listCustomRules,
|
||||||
|
listFilterRuleGroups,
|
||||||
|
reorderCustomRules,
|
||||||
|
reorderFilterRuleGroups,
|
||||||
|
updateCustomRule,
|
||||||
|
updateFilterRuleGroup,
|
||||||
|
type CustomRuleUpdateInput,
|
||||||
|
type FilterRuleGroupUpdateInput,
|
||||||
|
} from '@/api/rule'
|
||||||
|
import type { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||||
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
|
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
|
||||||
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
|
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -24,11 +38,21 @@ const props = defineProps({
|
|||||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||||
|
|
||||||
|
const originalCustomRuleId = Symbol('originalCustomRuleId')
|
||||||
|
const originalRuleGroupName = Symbol('originalRuleGroupName')
|
||||||
|
|
||||||
|
type CustomRuleDraft = CustomRule & { [originalCustomRuleId]?: string }
|
||||||
|
type FilterRuleGroupDraft = FilterRuleGroup & { [originalRuleGroupName]?: string }
|
||||||
|
|
||||||
// 自定义规则列表
|
// 自定义规则列表
|
||||||
const customRules = ref<CustomRule[]>([])
|
const customRules = ref<CustomRuleDraft[]>([])
|
||||||
|
const customRuleBaseline = ref<CustomRule[]>([])
|
||||||
|
const savingCustomRules = ref(false)
|
||||||
|
|
||||||
// 所有规则组列表
|
// 所有规则组列表
|
||||||
const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
const filterRuleGroups = ref<FilterRuleGroupDraft[]>([])
|
||||||
|
const filterRuleGroupBaseline = ref<FilterRuleGroup[]>([])
|
||||||
|
const savingFilterRuleGroups = ref(false)
|
||||||
|
|
||||||
// 种子优先规则
|
// 种子优先规则
|
||||||
const selectedTorrentPriority = ref<string[]>(['seeder'])
|
const selectedTorrentPriority = ref<string[]>(['seeder'])
|
||||||
@@ -56,6 +80,95 @@ async function loadMediaCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复制自定义规则的公开字段,排除页面草稿身份。 */
|
||||||
|
function copyCustomRule(rule: CustomRule): CustomRule {
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
name: rule.name,
|
||||||
|
include: rule.include,
|
||||||
|
exclude: rule.exclude,
|
||||||
|
size_range: rule.size_range,
|
||||||
|
seeders: rule.seeders,
|
||||||
|
publish_time: rule.publish_time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制规则组的公开字段,排除页面草稿身份。 */
|
||||||
|
function copyFilterRuleGroup(group: FilterRuleGroup): FilterRuleGroup {
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
rule_string: group.rule_string,
|
||||||
|
media_type: group.media_type,
|
||||||
|
category: group.category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为服务端已有自定义规则附加不可序列化的原始身份。 */
|
||||||
|
function createCustomRuleDraft(rule: CustomRule, originalId?: string): CustomRuleDraft {
|
||||||
|
const draft = copyCustomRule(rule) as CustomRuleDraft
|
||||||
|
if (originalId) draft[originalCustomRuleId] = originalId
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为服务端已有规则组附加不可序列化的原始身份。 */
|
||||||
|
function createFilterRuleGroupDraft(group: FilterRuleGroup, originalName?: string): FilterRuleGroupDraft {
|
||||||
|
const draft = copyFilterRuleGroup(group) as FilterRuleGroupDraft
|
||||||
|
if (originalName) draft[originalRuleGroupName] = originalName
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比较可选文本字段,空值和缺省值视为同一状态。 */
|
||||||
|
function optionalTextChanged(current: string | undefined, baseline: string | undefined): boolean {
|
||||||
|
return (current ?? '') !== (baseline ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一条已有自定义规则的最小更新载荷。 */
|
||||||
|
function buildCustomRuleUpdate(draft: CustomRuleDraft, baseline: CustomRule): CustomRuleUpdateInput {
|
||||||
|
const payload: CustomRuleUpdateInput = {}
|
||||||
|
if (draft.id !== baseline.id) payload.new_rule_id = draft.id
|
||||||
|
if (draft.name !== baseline.name) payload.name = draft.name
|
||||||
|
if (optionalTextChanged(draft.include, baseline.include)) payload.include = draft.include ?? ''
|
||||||
|
if (optionalTextChanged(draft.exclude, baseline.exclude)) payload.exclude = draft.exclude ?? ''
|
||||||
|
if (optionalTextChanged(draft.size_range, baseline.size_range)) payload.size_range = draft.size_range ?? ''
|
||||||
|
if (optionalTextChanged(draft.seeders, baseline.seeders)) payload.seeders = draft.seeders ?? ''
|
||||||
|
if (optionalTextChanged(draft.publish_time, baseline.publish_time)) payload.publish_time = draft.publish_time ?? ''
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一个已有规则组的最小更新载荷。 */
|
||||||
|
function buildFilterRuleGroupUpdate(
|
||||||
|
draft: FilterRuleGroupDraft,
|
||||||
|
baseline: FilterRuleGroup,
|
||||||
|
): FilterRuleGroupUpdateInput {
|
||||||
|
const payload: FilterRuleGroupUpdateInput = {}
|
||||||
|
if (draft.name !== baseline.name) payload.new_name = draft.name
|
||||||
|
if (optionalTextChanged(draft.rule_string, baseline.rule_string)) payload.rule_string = draft.rule_string ?? ''
|
||||||
|
if (optionalTextChanged(draft.media_type, baseline.media_type)) payload.media_type = draft.media_type ?? ''
|
||||||
|
if (optionalTextChanged(draft.category, baseline.category)) payload.category = draft.category ?? ''
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断增量更新载荷是否包含实际变化。 */
|
||||||
|
function hasUpdates(payload: CustomRuleUpdateInput | FilterRuleGroupUpdateInput): boolean {
|
||||||
|
return Object.keys(payload).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在本地规则组草稿和基线中同步后端完成的规则 ID 改名。 */
|
||||||
|
function replaceCustomRuleReferences(previousId: string, currentId: string) {
|
||||||
|
const escaped = previousId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const pattern = new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`, 'g')
|
||||||
|
for (const groups of [filterRuleGroups.value, filterRuleGroupBaseline.value]) {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.rule_string) group.rule_string = group.rule_string.replace(pattern, currentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败后重新读取规则与规则组,丢弃可能只完成一部分的页面草稿。 */
|
||||||
|
async function reloadRuleCollections() {
|
||||||
|
await Promise.allSettled([queryCustomRules(), queryFilterRuleGroups()])
|
||||||
|
}
|
||||||
|
|
||||||
// 保存自定义规则
|
// 保存自定义规则
|
||||||
async function saveCustomRules() {
|
async function saveCustomRules() {
|
||||||
// 检查是否存在空id规则
|
// 检查是否存在空id规则
|
||||||
@@ -81,12 +194,62 @@ async function saveCustomRules() {
|
|||||||
$toast.error(t('setting.rule.duplicateNameError'))
|
$toast.error(t('setting.rule.duplicateNameError'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (savingCustomRules.value) return
|
||||||
|
savingCustomRules.value = true
|
||||||
try {
|
try {
|
||||||
await api.post('system/setting/CustomFilterRules', customRules.value, { feedback: 'silent' })
|
const baselineById = new Map(customRuleBaseline.value.map(rule => [rule.id, rule]))
|
||||||
|
const retainedIds = new Set(
|
||||||
|
customRules.value.map(rule => rule[originalCustomRuleId]).filter((id): id is string => Boolean(id)),
|
||||||
|
)
|
||||||
|
const expectedOrder = customRuleBaseline.value.map(rule => rule.id)
|
||||||
|
|
||||||
|
for (const rule of customRuleBaseline.value) {
|
||||||
|
if (retainedIds.has(rule.id)) continue
|
||||||
|
await deleteCustomRule(rule.id)
|
||||||
|
expectedOrder.splice(expectedOrder.indexOf(rule.id), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const draft of customRules.value) {
|
||||||
|
const originalId = draft[originalCustomRuleId]
|
||||||
|
if (!originalId) continue
|
||||||
|
const baseline = baselineById.get(originalId)
|
||||||
|
if (!baseline) throw new Error(`Missing baseline for custom rule ${originalId}`)
|
||||||
|
const payload = buildCustomRuleUpdate(draft, baseline)
|
||||||
|
if (!hasUpdates(payload)) continue
|
||||||
|
await updateCustomRule(originalId, payload)
|
||||||
|
if (draft.id !== originalId) {
|
||||||
|
const index = expectedOrder.indexOf(originalId)
|
||||||
|
if (index !== -1) expectedOrder[index] = draft.id
|
||||||
|
replaceCustomRuleReferences(originalId, draft.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const draft of customRules.value) {
|
||||||
|
if (draft[originalCustomRuleId]) continue
|
||||||
|
await createCustomRule({
|
||||||
|
rule_id: draft.id,
|
||||||
|
name: draft.name,
|
||||||
|
include: draft.include,
|
||||||
|
exclude: draft.exclude,
|
||||||
|
size_range: draft.size_range,
|
||||||
|
seeders: draft.seeders,
|
||||||
|
publish_time: draft.publish_time,
|
||||||
|
})
|
||||||
|
expectedOrder.push(draft.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredOrder = customRules.value.map(rule => rule.id)
|
||||||
|
if (desiredOrder.some((ruleId, index) => expectedOrder[index] !== ruleId)) {
|
||||||
|
await reorderCustomRules(desiredOrder, expectedOrder)
|
||||||
|
}
|
||||||
|
await queryCustomRules()
|
||||||
$toast.success(t('setting.rule.customRuleSaveSuccess'))
|
$toast.success(t('setting.rule.customRuleSaveSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
await reloadRuleCollections()
|
||||||
$toast.error(t('setting.rule.customRuleSaveFailed'))
|
$toast.error(t('setting.rule.customRuleSaveFailed'))
|
||||||
|
} finally {
|
||||||
|
savingCustomRules.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,16 +270,17 @@ async function addCustomRule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 移除自定义规则
|
// 移除自定义规则
|
||||||
function removeCustomRule(rule: CustomRule) {
|
function removeCustomRule(rule: CustomRuleDraft) {
|
||||||
const index = customRules.value.findIndex(item => item.id === rule.id)
|
const index = customRules.value.indexOf(rule)
|
||||||
if (index !== -1) customRules.value.splice(index, 1)
|
if (index !== -1) customRules.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: FilterRuleGroup[] }>('system/setting/UserFilterRuleGroups')
|
const groups = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
filterRuleGroupBaseline.value = groups.map(copyFilterRuleGroup)
|
||||||
|
filterRuleGroups.value = groups.map(group => createFilterRuleGroupDraft(group, group.name))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -135,12 +299,58 @@ async function saveFilterRuleGroups() {
|
|||||||
$toast.error(t('setting.rule.duplicateGroupNameError'))
|
$toast.error(t('setting.rule.duplicateGroupNameError'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (savingFilterRuleGroups.value) return
|
||||||
|
savingFilterRuleGroups.value = true
|
||||||
try {
|
try {
|
||||||
await api.post('system/setting/UserFilterRuleGroups', filterRuleGroups.value, { feedback: 'silent' })
|
const baselineByName = new Map(filterRuleGroupBaseline.value.map(group => [group.name, group]))
|
||||||
|
const retainedNames = new Set(
|
||||||
|
filterRuleGroups.value.map(group => group[originalRuleGroupName]).filter((name): name is string => Boolean(name)),
|
||||||
|
)
|
||||||
|
const expectedOrder = filterRuleGroupBaseline.value.map(group => group.name)
|
||||||
|
|
||||||
|
for (const group of filterRuleGroupBaseline.value) {
|
||||||
|
if (retainedNames.has(group.name)) continue
|
||||||
|
await deleteFilterRuleGroup(group.name)
|
||||||
|
expectedOrder.splice(expectedOrder.indexOf(group.name), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const draft of filterRuleGroups.value) {
|
||||||
|
const originalName = draft[originalRuleGroupName]
|
||||||
|
if (!originalName) continue
|
||||||
|
const baseline = baselineByName.get(originalName)
|
||||||
|
if (!baseline) throw new Error(`Missing baseline for rule group ${originalName}`)
|
||||||
|
const payload = buildFilterRuleGroupUpdate(draft, baseline)
|
||||||
|
if (!hasUpdates(payload)) continue
|
||||||
|
await updateFilterRuleGroup(originalName, payload)
|
||||||
|
if (draft.name !== originalName) {
|
||||||
|
const index = expectedOrder.indexOf(originalName)
|
||||||
|
if (index !== -1) expectedOrder[index] = draft.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const draft of filterRuleGroups.value) {
|
||||||
|
if (draft[originalRuleGroupName]) continue
|
||||||
|
await createFilterRuleGroup({
|
||||||
|
name: draft.name,
|
||||||
|
rule_string: draft.rule_string ?? '',
|
||||||
|
media_type: draft.media_type,
|
||||||
|
category: draft.category,
|
||||||
|
})
|
||||||
|
expectedOrder.push(draft.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredOrder = filterRuleGroups.value.map(group => group.name)
|
||||||
|
if (desiredOrder.some((name, index) => expectedOrder[index] !== name)) {
|
||||||
|
await reorderFilterRuleGroups(desiredOrder, expectedOrder)
|
||||||
|
}
|
||||||
|
await queryFilterRuleGroups()
|
||||||
$toast.success(t('setting.rule.ruleGroupSaveSuccess'))
|
$toast.success(t('setting.rule.ruleGroupSaveSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
await queryFilterRuleGroups()
|
||||||
$toast.error(t('setting.rule.ruleGroupSaveFailed'))
|
$toast.error(t('setting.rule.ruleGroupSaveFailed'))
|
||||||
|
} finally {
|
||||||
|
savingFilterRuleGroups.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,19 +536,23 @@ function deleteAllRules(dateType: string) {
|
|||||||
// 规则变化时赋值
|
// 规则变化时赋值
|
||||||
function onRuleChange(rule: CustomRule, id: string) {
|
function onRuleChange(rule: CustomRule, id: string) {
|
||||||
const index = customRules.value.findIndex(item => item.id === id)
|
const index = customRules.value.findIndex(item => item.id === id)
|
||||||
if (index !== -1) customRules.value[index] = rule
|
if (index === -1) return
|
||||||
|
const draft = createCustomRuleDraft(rule, customRules.value[index][originalCustomRuleId])
|
||||||
|
customRules.value[index] = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除规则组
|
// 移除规则组
|
||||||
function removeFilterRuleGroup(rule: FilterRuleGroup) {
|
function removeFilterRuleGroup(rule: FilterRuleGroupDraft) {
|
||||||
const index = filterRuleGroups.value.findIndex(item => item.name === rule.name)
|
const index = filterRuleGroups.value.indexOf(rule)
|
||||||
if (index !== -1) filterRuleGroups.value.splice(index, 1)
|
if (index !== -1) filterRuleGroups.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规则组变化时赋值
|
// 规则组变化时赋值
|
||||||
function changeRuleGroup(group: FilterRuleGroup, name: string) {
|
function changeRuleGroup(group: FilterRuleGroup, name: string) {
|
||||||
const index = filterRuleGroups.value.findIndex(item => item.name === name)
|
const index = filterRuleGroups.value.findIndex(item => item.name === name)
|
||||||
if (index !== -1) filterRuleGroups.value[index] = group
|
if (index === -1) return
|
||||||
|
const draft = createFilterRuleGroupDraft(group, filterRuleGroups.value[index][originalRuleGroupName])
|
||||||
|
filterRuleGroups.value[index] = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询种子优先规则
|
// 查询种子优先规则
|
||||||
@@ -354,8 +568,9 @@ async function queryTorrentPriority() {
|
|||||||
// 查询自定义规则项
|
// 查询自定义规则项
|
||||||
async function queryCustomRules() {
|
async function queryCustomRules() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: CustomRule[] }>('system/setting/CustomFilterRules')
|
const rules = await listCustomRules()
|
||||||
customRules.value = result.value ?? []
|
customRuleBaseline.value = rules.map(copyCustomRule)
|
||||||
|
customRules.value = rules.map(rule => createCustomRuleDraft(rule, rule.id))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -415,7 +630,14 @@ useSilentSettingRefresh(loadPageData, {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<div class="d-flex flex-wrap gap-4 mt-4">
|
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||||
<VBtn type="submit" class="me-2" @click="saveCustomRules" prepend-icon="mdi-content-save">
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
class="me-2"
|
||||||
|
:loading="savingCustomRules"
|
||||||
|
:disabled="savingCustomRules"
|
||||||
|
@click="saveCustomRules"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
>
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtnGroup density="comfortable">
|
<VBtnGroup density="comfortable">
|
||||||
@@ -468,7 +690,14 @@ useSilentSettingRefresh(loadPageData, {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<div class="d-flex flex-wrap gap-4 mt-4">
|
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||||
<VBtn type="submit" class="me-2" @click="saveFilterRuleGroups" prepend-icon="mdi-content-save">
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
class="me-2"
|
||||||
|
:loading="savingFilterRuleGroups"
|
||||||
|
:disabled="savingFilterRuleGroups"
|
||||||
|
@click="saveFilterRuleGroups"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
>
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtnGroup density="comfortable">
|
<VBtnGroup density="comfortable">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||||
|
import { listFilterRuleGroups } from '@/api/rule'
|
||||||
import type { FilterRuleGroup, Site } from '@/api/types'
|
import type { FilterRuleGroup, Site } from '@/api/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||||
@@ -105,8 +106,7 @@ async function querySites() {
|
|||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: FilterRuleGroup[] }>('system/setting/UserFilterRuleGroups')
|
filterRuleGroups.value = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { requestCookieCloudSync, resetSiteData } from '@/api/site'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ const resetSitesText = ref(t('setting.site.resetSites'))
|
|||||||
// 站点重置按钮可用状态
|
// 站点重置按钮可用状态
|
||||||
const resetSitesDisabled = ref(false)
|
const resetSitesDisabled = ref(false)
|
||||||
|
|
||||||
|
// CookieCloud 手工同步状态
|
||||||
|
const syncingCookieCloud = ref(false)
|
||||||
|
|
||||||
const isPasswordVisible = ref(false)
|
const isPasswordVisible = ref(false)
|
||||||
|
|
||||||
const isCookieCloudAuthHeaderVisible = ref(false)
|
const isCookieCloudAuthHeaderVisible = ref(false)
|
||||||
@@ -80,11 +84,12 @@ const BrowserEmulationItems = [
|
|||||||
|
|
||||||
// 重置站点
|
// 重置站点
|
||||||
async function resetSites() {
|
async function resetSites() {
|
||||||
|
if (resetSitesDisabled.value) return
|
||||||
try {
|
try {
|
||||||
resetSitesDisabled.value = true
|
resetSitesDisabled.value = true
|
||||||
resetSitesText.value = t('setting.site.resettingSites')
|
resetSitesText.value = t('setting.site.resettingSites')
|
||||||
|
|
||||||
await api.get('site/reset', { feedback: 'silent' })
|
await resetSiteData()
|
||||||
$toast.success(t('setting.site.resetSuccess'))
|
$toast.success(t('setting.site.resetSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -95,6 +100,21 @@ async function resetSites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 使用已保存的 CookieCloud 配置触发一次手工同步。 */
|
||||||
|
async function syncCookieCloud() {
|
||||||
|
if (syncingCookieCloud.value) return
|
||||||
|
syncingCookieCloud.value = true
|
||||||
|
try {
|
||||||
|
await requestCookieCloudSync()
|
||||||
|
$toast.success(t('setting.site.syncSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
$toast.error(t('setting.site.syncFailed'))
|
||||||
|
} finally {
|
||||||
|
syncingCookieCloud.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载站点设置
|
// 加载站点设置
|
||||||
async function loadSiteSettings() {
|
async function loadSiteSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -223,10 +243,19 @@ useSilentSettingRefresh(loadSiteSettings, {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VForm @submit.prevent="() => {}">
|
<VForm @submit.prevent="() => {}">
|
||||||
<div class="d-flex flex-wrap gap-4 mt-4">
|
<div class="site-setting-actions d-flex flex-wrap gap-4 mt-4">
|
||||||
<VBtn type="submit" @click="saveSiteSetting(siteSetting.CookieCloud)" prepend-icon="mdi-content-save">
|
<VBtn type="submit" @click="saveSiteSetting(siteSetting.CookieCloud)" prepend-icon="mdi-content-save">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-cloud-sync-outline"
|
||||||
|
:loading="syncingCookieCloud"
|
||||||
|
:disabled="syncingCookieCloud"
|
||||||
|
@click="syncCookieCloud"
|
||||||
|
>
|
||||||
|
{{ t('setting.site.syncNow') }}
|
||||||
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -340,3 +369,11 @@ useSilentSettingRefresh(loadSiteSettings, {
|
|||||||
</VRow>
|
</VRow>
|
||||||
<!-- 进度框 -->
|
<!-- 进度框 -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-setting-actions :deep(.v-btn) {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { listFilterRuleGroups } from '@/api/rule'
|
||||||
import type { FilterRuleGroup, Site } from '@/api/types'
|
import type { FilterRuleGroup, Site } from '@/api/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||||
@@ -92,8 +93,7 @@ async function querySites() {
|
|||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
try {
|
try {
|
||||||
const result = await api.get<{ value?: FilterRuleGroup[] }>('system/setting/UserFilterRuleGroups')
|
filterRuleGroups.value = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DownloaderConf, MediaServerConf } from '@/api/types'
|
|||||||
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
|
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
|
||||||
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
|
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
|
||||||
import DatabaseBackupPanel from '@/components/system/DatabaseBackupPanel.vue'
|
import DatabaseBackupPanel from '@/components/system/DatabaseBackupPanel.vue'
|
||||||
|
import TransferHistoryMaintenancePanel from '@/components/system/TransferHistoryMaintenancePanel.vue'
|
||||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
||||||
@@ -2816,6 +2817,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</template>
|
</template>
|
||||||
|
<VCol cols="12">
|
||||||
|
<TransferHistoryMaintenancePanel />
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</div>
|
</div>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|||||||
@@ -231,19 +231,17 @@ function createImpact(): ClassificationImpactAnalysis {
|
|||||||
|
|
||||||
describe('AccountSettingClassification', () => {
|
describe('AccountSettingClassification', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiGet.mockReset().mockResolvedValue({
|
mocks.apiGet.mockReset().mockResolvedValue([
|
||||||
value: [
|
{
|
||||||
{
|
name: '电影目录',
|
||||||
name: '电影目录',
|
priority: 0,
|
||||||
priority: 0,
|
storage: 'local',
|
||||||
storage: 'local',
|
transfer_type: 'copy',
|
||||||
transfer_type: 'copy',
|
media_type: '电影',
|
||||||
media_type: '电影',
|
media_category_id: 'movie.base',
|
||||||
media_category_id: 'movie.base',
|
media_category: '电影',
|
||||||
media_category: '电影',
|
},
|
||||||
},
|
])
|
||||||
],
|
|
||||||
})
|
|
||||||
mocks.apiErrorMessage.mockReset().mockReturnValue(undefined)
|
mocks.apiErrorMessage.mockReset().mockReturnValue(undefined)
|
||||||
mocks.analyzeImpact.mockReset()
|
mocks.analyzeImpact.mockReset()
|
||||||
mocks.initialize.mockReset().mockResolvedValue(undefined)
|
mocks.initialize.mockReset().mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -190,30 +190,58 @@ function mockLoadedSettings(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
let directoryReadCount = 0
|
let directoryReadCount = 0
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.apiGet.mockImplementation((endpoint: string, config?: { params?: { setting_key?: string } }) => {
|
||||||
if (endpoint === 'system/setting/public/Directories') {
|
if (endpoint === 'storage/directories') {
|
||||||
directoryReadCount += 1
|
directoryReadCount += 1
|
||||||
const value =
|
const value =
|
||||||
directoryReadCount > 1 && options.reloadedDirectories
|
directoryReadCount > 1 && options.reloadedDirectories
|
||||||
? options.reloadedDirectories
|
? options.reloadedDirectories
|
||||||
: (options.directories ?? directoriesFixture)
|
: (options.directories ?? directoriesFixture)
|
||||||
return { data: { value: structuredClone(value) } }
|
return { data: structuredClone(value) }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/public/Storages') return { data: { value: structuredClone(storagesFixture) } }
|
if (endpoint === 'system/setting/Storages') return { data: { value: structuredClone(storagesFixture) } }
|
||||||
if (endpoint === 'media/classification/policy') {
|
if (endpoint === 'media/classification/policy') {
|
||||||
return {
|
return {
|
||||||
...structuredClone(classificationPolicyFixture),
|
...structuredClone(classificationPolicyFixture),
|
||||||
categories: structuredClone(options.categories ?? classificationCategories),
|
categories: structuredClone(options.categories ?? classificationCategories),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/env') {
|
if (endpoint === 'system/settings') {
|
||||||
|
const settingKey = config?.params?.setting_key
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
SCRAP_SOURCE: 'themoviedb',
|
||||||
|
MOVIE_RENAME_FORMAT: '{{ title }}',
|
||||||
|
TV_RENAME_FORMAT: '{{ name }}',
|
||||||
|
MUSIC_RENAME_FORMAT: '{{ artist }}',
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
|
||||||
data: {
|
data: {
|
||||||
MOVIE_RENAME_FORMAT: '{{ title }}',
|
include_values: true,
|
||||||
TV_RENAME_FORMAT: '{{ name }}',
|
matched_count: settingKey && Object.hasOwn(values, settingKey) ? 1 : 0,
|
||||||
MUSIC_RENAME_FORMAT: '{{ artist }}',
|
settings:
|
||||||
UNRELATED: 'ignored',
|
settingKey && Object.hasOwn(values, settingKey)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
definition: {
|
||||||
|
declared_type: 'unknown',
|
||||||
|
nullable: values[settingKey] == null,
|
||||||
|
persistence: 'app.env',
|
||||||
|
sensitive: false,
|
||||||
|
update_operations: ['replace'],
|
||||||
|
value_shape: typeof values[settingKey],
|
||||||
|
},
|
||||||
|
group: 'settings',
|
||||||
|
has_value: values[settingKey] != null,
|
||||||
|
label: settingKey,
|
||||||
|
redacted: false,
|
||||||
|
setting_key: settingKey,
|
||||||
|
source: 'settings',
|
||||||
|
value: values[settingKey],
|
||||||
|
value_type: typeof values[settingKey],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
show_secrets: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +288,12 @@ describe('AccountSettingDirectory', () => {
|
|||||||
expect(screen.getByText('目录3')).toBeInTheDocument()
|
expect(screen.getByText('目录3')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByTestId('category-count-目录1')).toHaveTextContent('2'))
|
await waitFor(() => expect(screen.getByTestId('category-count-目录1')).toHaveTextContent('2'))
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/classification/policy')
|
expect(mocks.apiGet).toHaveBeenCalledWith('media/classification/policy')
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('storage/directories', { params: {} })
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/Storages')
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('system/settings', {
|
||||||
|
params: { setting_key: 'MOVIE_RENAME_FORMAT' },
|
||||||
|
})
|
||||||
|
expect(mocks.apiGet).not.toHaveBeenCalledWith('system/env')
|
||||||
expect(screen.getByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
expect(screen.getByRole('checkbox', { name: '挂载盘删除空目录' })).toBeChecked()
|
||||||
expect(screen.getByRole('button', { name: '自动分类策略' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '自动分类策略' })).toBeInTheDocument()
|
||||||
expect(getRenameEditors().map(input => (input as HTMLTextAreaElement).value)).toEqual([
|
expect(getRenameEditors().map(input => (input as HTMLTextAreaElement).value)).toEqual([
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { renderWithProviders } from '@tests/support/render'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiDelete: vi.fn(),
|
||||||
apiGet: vi.fn(),
|
apiGet: vi.fn(),
|
||||||
apiPost: vi.fn(),
|
apiPost: vi.fn(),
|
||||||
|
apiPut: vi.fn(),
|
||||||
copyToClipboard: vi.fn(),
|
copyToClipboard: vi.fn(),
|
||||||
openSharedDialog: vi.fn(),
|
openSharedDialog: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
@@ -16,7 +18,12 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }),
|
default: createDataApiMock({
|
||||||
|
delete: mocks.apiDelete,
|
||||||
|
get: mocks.apiGet,
|
||||||
|
post: mocks.apiPost,
|
||||||
|
put: mocks.apiPut,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
@@ -118,18 +125,20 @@ const groupsFixture = [
|
|||||||
function mockLoadedRules() {
|
function mockLoadedRules() {
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
if (endpoint === 'media/category') return { 电影: ['华语'] }
|
if (endpoint === 'media/category') return { 电影: ['华语'] }
|
||||||
if (endpoint === 'system/setting/CustomFilterRules') {
|
if (endpoint === 'rule/custom') {
|
||||||
return { success: true, data: { value: structuredClone(customRulesFixture) } }
|
return { count: customRulesFixture.length, rules: structuredClone(customRulesFixture) }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/UserFilterRuleGroups') {
|
if (endpoint === 'rule/groups') {
|
||||||
return { success: true, data: { value: structuredClone(groupsFixture) } }
|
return { count: groupsFixture.length, rule_groups: structuredClone(groupsFixture) }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/TorrentsPriority') {
|
if (endpoint === 'system/setting/TorrentsPriority') {
|
||||||
return { success: true, data: { value: ['site', 'seeder'] } }
|
return { success: true, data: { value: ['site', 'seeder'] } }
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected GET ${endpoint}`)
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
})
|
})
|
||||||
|
mocks.apiDelete.mockResolvedValue({ success: true })
|
||||||
mocks.apiPost.mockResolvedValue({ success: true })
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
|
mocks.apiPut.mockResolvedValue({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderRuleSettings() {
|
async function renderRuleSettings() {
|
||||||
@@ -156,8 +165,10 @@ describe('AccountSettingRule', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mocks.apiDelete.mockReset()
|
||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
mocks.apiPost.mockReset()
|
mocks.apiPost.mockReset()
|
||||||
|
mocks.apiPut.mockReset()
|
||||||
mocks.copyToClipboard.mockReset()
|
mocks.copyToClipboard.mockReset()
|
||||||
mocks.openSharedDialog.mockReset()
|
mocks.openSharedDialog.mockReset()
|
||||||
mocks.toastError.mockReset()
|
mocks.toastError.mockReset()
|
||||||
@@ -188,17 +199,17 @@ describe('AccountSettingRule', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'reverse-规则1' }))
|
await user.click(screen.getByRole('button', { name: 'reverse-规则1' }))
|
||||||
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/CustomFilterRules', [
|
expect(mocks.apiPut).toHaveBeenCalledWith('rule/custom/reorder', {
|
||||||
expect.objectContaining({ id: 'RULE3' }),
|
rule_ids: ['RULE3', 'RULE1'],
|
||||||
expect.objectContaining({ id: 'RULE1' }),
|
expected_rule_ids: ['RULE1', 'RULE3'],
|
||||||
])
|
})
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'reverse-规则组1' }))
|
await user.click(screen.getByRole('button', { name: 'reverse-规则组1' }))
|
||||||
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/UserFilterRuleGroups', [
|
expect(mocks.apiPut).toHaveBeenCalledWith('rule/groups/reorder', {
|
||||||
expect.objectContaining({ name: '规则组3' }),
|
group_names: ['规则组3', '规则组1'],
|
||||||
expect.objectContaining({ name: '规则组1' }),
|
expected_group_names: ['规则组1', '规则组3'],
|
||||||
])
|
})
|
||||||
|
|
||||||
await user.click(getCard('下载规则').getByRole('button', { name: '保存' }))
|
await user.click(getCard('下载规则').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/TorrentsPriority', ['site', 'seeder'])
|
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/TorrentsPriority', ['site', 'seeder'])
|
||||||
@@ -220,6 +231,35 @@ describe('AccountSettingRule', () => {
|
|||||||
expect(screen.queryByText('规则组1')).not.toBeInTheDocument()
|
expect(screen.queryByText('规则组1')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reconciles custom rule deletions, edits, and additions through incremental endpoints', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderRuleSettings()
|
||||||
|
await screen.findByText('RULE1 / 规则1')
|
||||||
|
|
||||||
|
const idInput = screen.getByLabelText('custom-id-RULE1')
|
||||||
|
const nameInput = screen.getByLabelText('custom-name-RULE1')
|
||||||
|
await fireEvent.update(idInput, 'RULE2')
|
||||||
|
await fireEvent.update(nameInput, '规则2')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'remove-custom-RULE3' }))
|
||||||
|
await user.click(getCommandButtons('自定义规则')[1])
|
||||||
|
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
expect(mocks.apiDelete).toHaveBeenCalledWith('rule/custom/RULE3')
|
||||||
|
expect(mocks.apiPut).toHaveBeenCalledWith('rule/custom/RULE1', {
|
||||||
|
new_rule_id: 'RULE2',
|
||||||
|
name: '规则2',
|
||||||
|
})
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('rule/custom', {
|
||||||
|
rule_id: 'RULE3',
|
||||||
|
name: '规则3',
|
||||||
|
include: undefined,
|
||||||
|
exclude: undefined,
|
||||||
|
size_range: undefined,
|
||||||
|
seeders: undefined,
|
||||||
|
publish_time: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('blocks empty and duplicate custom rule identifiers or names', async () => {
|
it('blocks empty and duplicate custom rule identifiers or names', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderRuleSettings()
|
await renderRuleSettings()
|
||||||
@@ -275,20 +315,15 @@ describe('AccountSettingRule', () => {
|
|||||||
})
|
})
|
||||||
expect(await screen.findByText('RULE9 / 规则9')).toBeInTheDocument()
|
expect(await screen.findByText('RULE9 / 规则9')).toBeInTheDocument()
|
||||||
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.apiPost).toHaveBeenLastCalledWith(
|
expect(mocks.apiPost).toHaveBeenLastCalledWith('rule/custom', {
|
||||||
'system/setting/CustomFilterRules',
|
rule_id: 'RULE9',
|
||||||
expect.arrayContaining([
|
name: '规则9',
|
||||||
{
|
include: 'REMUX',
|
||||||
id: 'RULE9',
|
exclude: undefined,
|
||||||
name: '规则9',
|
size_range: undefined,
|
||||||
include: 'REMUX',
|
seeders: undefined,
|
||||||
exclude: undefined,
|
publish_time: undefined,
|
||||||
size_range: undefined,
|
})
|
||||||
seeders: undefined,
|
|
||||||
publish_time: undefined,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
await user.click(getCommandButtons('优先级规则组')[2])
|
await user.click(getCommandButtons('优先级规则组')[2])
|
||||||
getImportSave(1)('group', {
|
getImportSave(1)('group', {
|
||||||
@@ -296,10 +331,12 @@ describe('AccountSettingRule', () => {
|
|||||||
})
|
})
|
||||||
expect(await screen.findByText('规则组9')).toBeInTheDocument()
|
expect(await screen.findByText('规则组9')).toBeInTheDocument()
|
||||||
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.apiPost).toHaveBeenLastCalledWith(
|
expect(mocks.apiPost).toHaveBeenLastCalledWith('rule/groups', {
|
||||||
'system/setting/UserFilterRuleGroups',
|
name: '规则组9',
|
||||||
expect.arrayContaining([{ name: '规则组9', rule_string: 'RULE9', media_type: '电影', category: undefined }]),
|
rule_string: 'RULE9',
|
||||||
)
|
media_type: '电影',
|
||||||
|
category: undefined,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects malformed or structurally invalid imports without mutating rules', async () => {
|
it('rejects malformed or structurally invalid imports without mutating rules', async () => {
|
||||||
@@ -350,24 +387,39 @@ describe('AccountSettingRule', () => {
|
|||||||
await renderRuleSettings()
|
await renderRuleSettings()
|
||||||
await screen.findByText('RULE1 / 规则1')
|
await screen.findByText('RULE1 / 规则1')
|
||||||
|
|
||||||
const responsibilities = [
|
await fireEvent.update(screen.getByLabelText('custom-name-RULE1'), '规则一')
|
||||||
{ card: '自定义规则', failure: '自定义规则保存失败!' },
|
mocks.apiPut.mockResolvedValueOnce({ success: false })
|
||||||
{ card: '优先级规则组', failure: '优先级规则组保存失败!' },
|
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||||
{ card: '下载规则', failure: '优先规则保存失败!' },
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('自定义规则保存失败!'))
|
||||||
]
|
expect(await screen.findByText('RULE1 / 规则1')).toBeInTheDocument()
|
||||||
|
|
||||||
for (const responsibility of responsibilities) {
|
mocks.toastError.mockClear()
|
||||||
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
await fireEvent.update(screen.getByLabelText('custom-name-RULE1'), '规则一')
|
||||||
await user.click(getCard(responsibility.card).getByRole('button', { name: '保存' }))
|
mocks.apiPut.mockRejectedValueOnce(new Error('offline'))
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(responsibility.failure))
|
await user.click(getCard('自定义规则').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('自定义规则保存失败!'))
|
||||||
|
|
||||||
mocks.toastError.mockClear()
|
mocks.toastError.mockClear()
|
||||||
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
await fireEvent.update(screen.getByLabelText('group-name-规则组1'), '规则组一')
|
||||||
await user.click(getCard(responsibility.card).getByRole('button', { name: '保存' }))
|
mocks.apiPut.mockResolvedValueOnce({ success: false })
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(responsibility.failure))
|
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('优先级规则组保存失败!'))
|
||||||
mocks.toastError.mockClear()
|
expect(await screen.findByText('规则组1')).toBeInTheDocument()
|
||||||
}
|
|
||||||
|
mocks.toastError.mockClear()
|
||||||
|
await fireEvent.update(screen.getByLabelText('group-name-规则组1'), '规则组一')
|
||||||
|
mocks.apiPut.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
await user.click(getCard('优先级规则组').getByRole('button', { name: '保存' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('优先级规则组保存失败!'))
|
||||||
|
|
||||||
|
mocks.toastError.mockClear()
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
||||||
|
await user.click(getCard('下载规则').getByRole('button', { name: '保存' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('优先规则保存失败!'))
|
||||||
|
|
||||||
|
mocks.toastError.mockClear()
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
await user.click(getCard('下载规则').getByRole('button', { name: '保存' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('优先规则保存失败!'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ function mockLoadedSettings() {
|
|||||||
{ id: 2, name: 'Disabled', is_active: false },
|
{ id: 2, name: 'Disabled', is_active: false },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/UserFilterRuleGroups') {
|
if (endpoint === 'rule/groups') {
|
||||||
return { success: true, data: { value: [{ name: 'HDR' }, { name: 'Remux' }] } }
|
return { count: 2, rule_groups: [{ name: 'HDR' }, { name: 'Remux' }] }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/public/IndexerSites') {
|
if (endpoint === 'system/setting/public/IndexerSites') {
|
||||||
return { success: true, data: { value: [1] } }
|
return { success: true, data: { value: [1] } }
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
apiGet: vi.fn(),
|
apiGet: vi.fn(),
|
||||||
apiPost: vi.fn(),
|
apiPost: vi.fn(),
|
||||||
|
requestCookieCloudSync: vi.fn(),
|
||||||
|
resetSiteData: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
toastSuccess: vi.fn(),
|
toastSuccess: vi.fn(),
|
||||||
useSilentSettingRefresh: vi.fn(),
|
useSilentSettingRefresh: vi.fn(),
|
||||||
@@ -16,6 +18,11 @@ vi.mock('@/api', () => ({
|
|||||||
default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }),
|
default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/site', () => ({
|
||||||
|
requestCookieCloudSync: mocks.requestCookieCloudSync,
|
||||||
|
resetSiteData: mocks.resetSiteData,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
}))
|
}))
|
||||||
@@ -46,7 +53,6 @@ function mockLoadedSettings() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (endpoint === 'site/reset') return { success: true }
|
|
||||||
throw new Error(`Unexpected GET ${endpoint}`)
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,8 @@ describe('AccountSettingSite', () => {
|
|||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
mocks.apiPost.mockReset()
|
mocks.apiPost.mockReset()
|
||||||
|
mocks.requestCookieCloudSync.mockReset().mockResolvedValue(null)
|
||||||
|
mocks.resetSiteData.mockReset().mockResolvedValue(null)
|
||||||
mocks.toastError.mockReset()
|
mocks.toastError.mockReset()
|
||||||
mocks.toastSuccess.mockReset()
|
mocks.toastSuccess.mockReset()
|
||||||
mocks.useSilentSettingRefresh.mockReset()
|
mocks.useSilentSettingRefresh.mockReset()
|
||||||
@@ -155,38 +163,67 @@ describe('AccountSettingSite', () => {
|
|||||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts one CookieCloud sync at a time and restores the action after success', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let resolveSync: (() => void) | undefined
|
||||||
|
mocks.requestCookieCloudSync.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<void>(resolve => {
|
||||||
|
resolveSync = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await renderSettings()
|
||||||
|
const syncButton = getCard('站点同步').getByRole('button', { name: '立即同步' })
|
||||||
|
|
||||||
|
await user.click(syncButton)
|
||||||
|
await waitFor(() => expect(mocks.requestCookieCloudSync).toHaveBeenCalledTimes(1))
|
||||||
|
expect(syncButton).toBeDisabled()
|
||||||
|
await user.click(syncButton)
|
||||||
|
expect(mocks.requestCookieCloudSync).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveSync?.()
|
||||||
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('CookieCloud同步任务已启动!'))
|
||||||
|
expect(syncButton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores CookieCloud sync after an API failure', async () => {
|
||||||
|
mocks.requestCookieCloudSync.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
await renderSettings()
|
||||||
|
const syncButton = getCard('站点同步').getByRole('button', { name: '立即同步' })
|
||||||
|
|
||||||
|
await fireEvent.click(syncButton)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('CookieCloud同步启动失败!'))
|
||||||
|
expect(syncButton).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
it('requires confirmation and restores the reset action after success or business failure', async () => {
|
it('requires confirmation and restores the reset action after success or business failure', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderSettings()
|
await renderSettings()
|
||||||
const resetCard = getCard('站点重置')
|
const resetCard = getCard('站点重置')
|
||||||
const resetButton = resetCard.getByRole('button', { name: '重置站点数据' })
|
const resetButton = resetCard.getByRole('button', { name: '重置站点数据' })
|
||||||
let resolveReset: ((value: { success: boolean }) => void) | undefined
|
let resolveReset: (() => void) | undefined
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.resetSiteData.mockImplementation(
|
||||||
if (endpoint === 'site/reset') {
|
() =>
|
||||||
return new Promise(resolve => {
|
new Promise<void>(resolve => {
|
||||||
resolveReset = resolve
|
resolveReset = resolve
|
||||||
})
|
}),
|
||||||
}
|
)
|
||||||
throw new Error(`Unexpected GET ${endpoint}`)
|
|
||||||
})
|
|
||||||
expect(resetButton).toBeDisabled()
|
expect(resetButton).toBeDisabled()
|
||||||
|
|
||||||
await user.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' }))
|
await user.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' }))
|
||||||
expect(resetButton).toBeEnabled()
|
expect(resetButton).toBeEnabled()
|
||||||
await user.click(resetButton)
|
await user.click(resetButton)
|
||||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('site/reset'))
|
await waitFor(() => expect(mocks.resetSiteData).toHaveBeenCalledTimes(1))
|
||||||
expect(resetCard.getByRole('button', { name: '正在重置...' })).toBeDisabled()
|
expect(resetCard.getByRole('button', { name: '正在重置...' })).toBeDisabled()
|
||||||
await user.click(resetCard.getByRole('button', { name: '正在重置...' }))
|
await user.click(resetCard.getByRole('button', { name: '正在重置...' }))
|
||||||
expect(mocks.apiGet.mock.calls.filter(([endpoint]) => endpoint === 'site/reset')).toHaveLength(1)
|
expect(mocks.resetSiteData).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
resolveReset?.({ success: true })
|
resolveReset?.()
|
||||||
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('站点重置成功,请等待CookieCloud同步完成!'))
|
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('站点重置成功,请等待CookieCloud同步完成!'))
|
||||||
expect(resetCard.getByRole('button', { name: '重置站点数据' })).toBeEnabled()
|
expect(resetCard.getByRole('button', { name: '重置站点数据' })).toBeEnabled()
|
||||||
|
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.resetSiteData.mockRejectedValueOnce(new Error('business failure'))
|
||||||
if (endpoint === 'site/reset') return { success: false }
|
|
||||||
return mockLoadedSettings()
|
|
||||||
})
|
|
||||||
mocks.toastSuccess.mockReset()
|
mocks.toastSuccess.mockReset()
|
||||||
await user.click(resetCard.getByRole('button', { name: '重置站点数据' }))
|
await user.click(resetCard.getByRole('button', { name: '重置站点数据' }))
|
||||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点重置失败!'))
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点重置失败!'))
|
||||||
@@ -197,10 +234,7 @@ describe('AccountSettingSite', () => {
|
|||||||
await renderSettings()
|
await renderSettings()
|
||||||
const resetCard = getCard('站点重置')
|
const resetCard = getCard('站点重置')
|
||||||
await fireEvent.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' }))
|
await fireEvent.click(resetCard.getByRole('checkbox', { name: '确认删除所有站点数据并重新同步。' }))
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.resetSiteData.mockRejectedValueOnce(new Error('offline'))
|
||||||
if (endpoint === 'site/reset') throw new Error('offline')
|
|
||||||
throw new Error(`Unexpected GET ${endpoint}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
await fireEvent.click(resetCard.getByRole('button', { name: '重置站点数据' }))
|
await fireEvent.click(resetCard.getByRole('button', { name: '重置站点数据' }))
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ function mockLoadedSettings() {
|
|||||||
{ id: 4, name: 'RSS Disabled', is_active: false },
|
{ id: 4, name: 'RSS Disabled', is_active: false },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/UserFilterRuleGroups') {
|
if (endpoint === 'rule/groups') {
|
||||||
return { success: true, data: { value: [{ name: 'HDR' }, { name: 'Remux' }] } }
|
return { count: 2, rule_groups: [{ name: 'HDR' }, { name: 'Remux' }] }
|
||||||
}
|
}
|
||||||
if (endpoint === 'system/setting/RssSites') return { success: true, data: { value: [3] } }
|
if (endpoint === 'system/setting/RssSites') return { success: true, data: { value: [3] } }
|
||||||
if (endpoint === 'system/setting/SubscribeFilterRuleGroups') {
|
if (endpoint === 'system/setting/SubscribeFilterRuleGroups') {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, reactive, ref } from 'vue'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { requiredValidator } from '@/@validators'
|
import { requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { listCustomIdentifiers, replaceCustomIdentifiers } from '@/api/customIdentifiers'
|
||||||
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
|
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
|
||||||
import { getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
|
import { getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
@@ -334,8 +335,7 @@ async function saveCustomWords() {
|
|||||||
|
|
||||||
savingCustomWords.value = true
|
savingCustomWords.value = true
|
||||||
try {
|
try {
|
||||||
const queryResult: { [key: string]: any } = await api.get('system/setting/CustomIdentifiers')
|
const existingLines = await listCustomIdentifiers()
|
||||||
const existingLines: string[] = Array.isArray(queryResult?.value) ? queryResult.value : []
|
|
||||||
const appendLines = newLines.filter(line => !existingLines.includes(line))
|
const appendLines = newLines.filter(line => !existingLines.includes(line))
|
||||||
|
|
||||||
if (!appendLines.length) {
|
if (!appendLines.length) {
|
||||||
@@ -343,7 +343,7 @@ async function saveCustomWords() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.post<null>('system/setting/CustomIdentifiers', [...existingLines, ...appendLines], { feedback: 'silent' })
|
await replaceCustomIdentifiers([...existingLines, ...appendLines], existingLines)
|
||||||
$toast.success(t('nameTest.saveWordsSuccess'))
|
$toast.success(t('nameTest.saveWordsSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { requiredValidator } from '@/@validators'
|
import { requiredValidator } from '@/@validators'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { listFilterRuleGroups } from '@/api/rule'
|
||||||
import type { FilterRuleGroup, RuleTestData } from '@/api/types'
|
import type { FilterRuleGroup, RuleTestData } from '@/api/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -136,8 +137,7 @@ function countRules(ruleString = '') {
|
|||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
try {
|
try {
|
||||||
filterRuleGroupLoading.value = true
|
filterRuleGroupLoading.value = true
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
filterRuleGroups.value = await listFilterRuleGroups()
|
||||||
filterRuleGroups.value = result.value ?? []
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { configureAceEditorPadding } from '@/utils/aceEditor'
|
import { configureAceEditorPadding } from '@/utils/aceEditor'
|
||||||
import type { Ace } from 'ace-builds'
|
import type { Ace } from 'ace-builds'
|
||||||
|
import { listCustomIdentifiers, replaceCustomIdentifiers } from '@/api/customIdentifiers'
|
||||||
|
|
||||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ const savedTextValues = reactive<Record<TextSectionKey, string>>({
|
|||||||
customization: '',
|
customization: '',
|
||||||
excludeWords: '',
|
excludeWords: '',
|
||||||
})
|
})
|
||||||
|
const savedCustomIdentifierLines = ref<string[]>([])
|
||||||
const savedEpisodeRules = ref('[]')
|
const savedEpisodeRules = ref('[]')
|
||||||
|
|
||||||
const textSectionModels: Record<TextSectionKey, typeof customIdentifiers> = {
|
const textSectionModels: Record<TextSectionKey, typeof customIdentifiers> = {
|
||||||
@@ -113,7 +115,7 @@ const textSectionModels: Record<TextSectionKey, typeof customIdentifiers> = {
|
|||||||
|
|
||||||
const textSectionSettings = computed<Record<TextSectionKey, TextSectionSetting>>(() => ({
|
const textSectionSettings = computed<Record<TextSectionKey, TextSectionSetting>>(() => ({
|
||||||
identifiers: {
|
identifiers: {
|
||||||
endpoint: 'system/setting/CustomIdentifiers',
|
endpoint: 'system/identifiers',
|
||||||
failedMessage: t('setting.words.identifierSaveFailed'),
|
failedMessage: t('setting.words.identifierSaveFailed'),
|
||||||
successMessage: t('setting.words.identifierSaveSuccess'),
|
successMessage: t('setting.words.identifierSaveSuccess'),
|
||||||
},
|
},
|
||||||
@@ -324,10 +326,16 @@ function deleteEpisodeRule(index: number) {
|
|||||||
/** 查询一个多行词表配置,并同步其已保存快照。 */
|
/** 查询一个多行词表配置,并同步其已保存快照。 */
|
||||||
async function queryTextSection(section: TextSectionKey) {
|
async function queryTextSection(section: TextSectionKey) {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(textSectionSettings.value[section].endpoint)
|
const lines =
|
||||||
const value = Array.isArray(result?.value) ? result.value.join('\n') : ''
|
section === 'identifiers'
|
||||||
|
? await listCustomIdentifiers()
|
||||||
|
: await api
|
||||||
|
.get<{ value?: unknown }>(textSectionSettings.value[section].endpoint)
|
||||||
|
.then(result => (Array.isArray(result?.value) ? result.value.filter(item => typeof item === 'string') : []))
|
||||||
|
const value = lines.join('\n')
|
||||||
textSectionModels[section].value = value
|
textSectionModels[section].value = value
|
||||||
savedTextValues[section] = value
|
savedTextValues[section] = value
|
||||||
|
if (section === 'identifiers') savedCustomIdentifierLines.value = [...lines]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
@@ -339,8 +347,16 @@ async function saveTextSection(section: TextSectionKey) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const value = textSectionModels[section].value
|
const value = textSectionModels[section].value
|
||||||
await api.post<null>(setting.endpoint, value.split('\n'), { feedback: 'silent' })
|
if (section === 'identifiers') {
|
||||||
savedTextValues[section] = value
|
const savedLines = await replaceCustomIdentifiers(value.split('\n'), savedCustomIdentifierLines.value)
|
||||||
|
const savedValue = savedLines.join('\n')
|
||||||
|
customIdentifiers.value = savedValue
|
||||||
|
savedCustomIdentifierLines.value = [...savedLines]
|
||||||
|
savedTextValues[section] = savedValue
|
||||||
|
} else {
|
||||||
|
await api.post<null>(setting.endpoint, value.split('\n'), { feedback: 'silent' })
|
||||||
|
savedTextValues[section] = value
|
||||||
|
}
|
||||||
$toast.success(setting.successMessage)
|
$toast.success(setting.successMessage)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -240,8 +240,8 @@ describe('NameTestView media identity', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('treats a resolved custom-word save as success after the data client unwraps the response', async () => {
|
it('treats a resolved custom-word save as success after the data client unwraps the response', async () => {
|
||||||
mocks.apiGet.mockResolvedValueOnce({ value: ['已存在规则'] })
|
mocks.apiGet.mockResolvedValueOnce({ identifiers: ['已存在规则'] })
|
||||||
mocks.apiPost.mockResolvedValueOnce(null)
|
mocks.apiPost.mockResolvedValueOnce({ identifiers: ['已存在规则', '新增规则'] })
|
||||||
await renderWithProviders(NameTestView, {
|
await renderWithProviders(NameTestView, {
|
||||||
initialState: {
|
initialState: {
|
||||||
globalSettings: {
|
globalSettings: {
|
||||||
@@ -254,10 +254,15 @@ describe('NameTestView media identity', () => {
|
|||||||
await user.type(screen.getByLabelText('识别词'), '新增规则')
|
await user.type(screen.getByLabelText('识别词'), '新增规则')
|
||||||
await user.click(screen.getByRole('button', { name: '保存识别词' }))
|
await user.click(screen.getByRole('button', { name: '保存识别词' }))
|
||||||
|
|
||||||
expect(mocks.apiGet).toHaveBeenCalledWith('system/setting/CustomIdentifiers')
|
expect(mocks.apiGet).toHaveBeenCalledWith('system/identifiers', { feedback: 'silent' })
|
||||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/CustomIdentifiers', ['已存在规则', '新增规则'], {
|
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||||
feedback: 'silent',
|
'system/identifiers',
|
||||||
})
|
{
|
||||||
|
identifiers: ['已存在规则', '新增规则'],
|
||||||
|
expected_identifiers: ['已存在规则'],
|
||||||
|
},
|
||||||
|
{ feedback: 'silent' },
|
||||||
|
)
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('识别词已保存到识别词表末尾')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('识别词已保存到识别词表末尾')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import WordsView from '@/views/system/WordsView.vue'
|
import WordsView from '@/views/system/WordsView.vue'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
@@ -50,6 +50,7 @@ const AceEditorStub = defineComponent({
|
|||||||
:data-show-gutter="String(Boolean(options.showGutter))"
|
:data-show-gutter="String(Boolean(options.showGutter))"
|
||||||
:data-show-line-numbers="String(Boolean(options.showLineNumbers))"
|
:data-show-line-numbers="String(Boolean(options.showLineNumbers))"
|
||||||
:data-value="value"
|
:data-value="value"
|
||||||
|
@click="$emit('update:value', value + '\\ngamma')"
|
||||||
/>
|
/>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -67,6 +68,7 @@ async function renderWordsView() {
|
|||||||
describe('WordsView editor preferences', () => {
|
describe('WordsView editor preferences', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiGet.mockImplementation((endpoint: string) => {
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'system/identifiers') return Promise.resolve({ data: { identifiers: ['alpha', 'beta'] } })
|
||||||
if (endpoint.includes('EpisodeFormatRuleTable')) return Promise.resolve({ data: { value: [] } })
|
if (endpoint.includes('EpisodeFormatRuleTable')) return Promise.resolve({ data: { value: [] } })
|
||||||
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
|
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
|
||||||
})
|
})
|
||||||
@@ -154,6 +156,26 @@ describe('WordsView editor preferences', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads and conditionally replaces identifiers through the dedicated API', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
await renderWordsView()
|
||||||
|
|
||||||
|
const editor = await screen.findByTestId('words-ace-editor')
|
||||||
|
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('system/identifiers')
|
||||||
|
|
||||||
|
await fireEvent.click(editor)
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存更改' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('system/identifiers', {
|
||||||
|
identifiers: ['alpha', 'beta', 'gamma'],
|
||||||
|
expected_identifiers: ['alpha', 'beta'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('自定义识别词保存成功')
|
||||||
|
})
|
||||||
|
|
||||||
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
|
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await renderWordsView()
|
await renderWordsView()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
DownloadDirectory,
|
||||||
DownloaderConf,
|
DownloaderConf,
|
||||||
FilterRuleGroup,
|
FilterRuleGroup,
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
@@ -6,7 +7,6 @@ import type {
|
|||||||
Subscribe,
|
Subscribe,
|
||||||
SubscribeShare,
|
SubscribeShare,
|
||||||
SubscribeShareStatistics,
|
SubscribeShareStatistics,
|
||||||
TransferDirectoryConf,
|
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
import { createMediaInfo } from './media'
|
import { createMediaInfo } from './media'
|
||||||
|
|
||||||
@@ -121,13 +121,17 @@ export function createSubscribeDownloader(overrides: Partial<DownloaderConf> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 构造下载目录配置。 */
|
/** 构造下载目录配置。 */
|
||||||
export function createSubscribeDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
|
export function createSubscribeDirectory(overrides: Partial<DownloadDirectory> = {}): DownloadDirectory {
|
||||||
|
const downloadPath = Object.hasOwn(overrides, 'download_path') ? overrides.download_path : '/downloads'
|
||||||
|
const storage = Object.hasOwn(overrides, 'storage') ? overrides.storage : 'local'
|
||||||
|
const savePath = downloadPath && storage && storage !== 'local' ? `${storage}:${downloadPath}` : downloadPath
|
||||||
|
|
||||||
return {
|
return {
|
||||||
download_path: '/downloads',
|
download_path: downloadPath,
|
||||||
name: '测试目录',
|
name: '测试目录',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
storage: 'local',
|
save_path: savePath,
|
||||||
transfer_type: 'link',
|
storage,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DownloadHistory, DownloadingInfo } from '@/api/types'
|
import type { DownloadHistory, DownloadingInfo, DownloadTaskUpdateData, DownloadTaskUpdateRequest } from '@/api/types'
|
||||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
import { apiFailureJson, apiJson } from '../response'
|
import { apiFailureJson, apiJson } from '../response'
|
||||||
|
|
||||||
@@ -12,10 +12,26 @@ export interface DownloadMutationResponse {
|
|||||||
export const downloadApiUrls = {
|
export const downloadApiUrls = {
|
||||||
action: (operation: 'start' | 'stop', hash: string) => new URL(`download/${operation}/${hash}`, API_BASE_URL).href,
|
action: (operation: 'start' | 'stop', hash: string) => new URL(`download/${operation}/${hash}`, API_BASE_URL).href,
|
||||||
delete: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href,
|
delete: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href,
|
||||||
|
update: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href,
|
||||||
list: new URL('download/', API_BASE_URL).href,
|
list: new URL('download/', API_BASE_URL).href,
|
||||||
history: new URL('history/download', API_BASE_URL).href,
|
history: new URL('history/download', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拦截下载任务高级修改并保留请求体供断言。 */
|
||||||
|
export function updateDownloadTaskHandler(
|
||||||
|
hash: string,
|
||||||
|
response: { success: boolean; message?: string; data: DownloadTaskUpdateData },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (body: DownloadTaskUpdateRequest) => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.patch(downloadApiUrls.update(hash), async ({ request }) => {
|
||||||
|
await onRequest((await request.json()) as DownloadTaskUpdateRequest)
|
||||||
|
if (status >= 400) return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
if (!response.success) return apiFailureJson(response.message ?? '', response.data, { status })
|
||||||
|
return apiJson(response.data, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function dataResponse(body: JsonBodyType, status: number) {
|
function dataResponse(body: JsonBodyType, status: number) {
|
||||||
if (status >= 400) return HttpResponse.json(body, { status })
|
if (status >= 400) return HttpResponse.json(body, { status })
|
||||||
return apiJson(body, { status })
|
return apiJson(body, { status })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
DownloadDirectory,
|
||||||
DownloaderConf,
|
DownloaderConf,
|
||||||
FilterRuleGroup,
|
FilterRuleGroup,
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
@@ -7,7 +8,6 @@ import type {
|
|||||||
SubscriptionBatchStatus,
|
SubscriptionBatchStatus,
|
||||||
SubscribeShare,
|
SubscribeShare,
|
||||||
SubscribeShareStatistics,
|
SubscribeShareStatistics,
|
||||||
TransferDirectoryConf,
|
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
|
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
|
||||||
import { apiFailureJson, apiJson } from '../response'
|
import { apiFailureJson, apiJson } from '../response'
|
||||||
@@ -41,15 +41,14 @@ export const subscribeApiUrls = {
|
|||||||
executionBatches: new URL('subscribe/execution/batches', API_BASE_URL).href,
|
executionBatches: new URL('subscribe/execution/batches', API_BASE_URL).href,
|
||||||
executionBatchCancel: (batchId: string) =>
|
executionBatchCancel: (batchId: string) =>
|
||||||
new URL(`subscribe/execution/batches/${batchId}/cancel`, API_BASE_URL).href,
|
new URL(`subscribe/execution/batches/${batchId}/cancel`, API_BASE_URL).href,
|
||||||
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
|
directories: new URL('download/paths', API_BASE_URL).href,
|
||||||
downloaders: new URL('download/clients', API_BASE_URL).href,
|
downloaders: new URL('download/clients', API_BASE_URL).href,
|
||||||
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
|
||||||
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
|
filterRuleGroups: new URL('rule/groups', API_BASE_URL).href,
|
||||||
filesById: (id: number) => new URL(`subscribe/files/${id}`, API_BASE_URL).href,
|
filesById: (id: number) => new URL(`subscribe/files/${id}`, API_BASE_URL).href,
|
||||||
historyById: (id: number) => new URL(`subscribe/history/${id}`, API_BASE_URL).href,
|
historyById: (id: number) => new URL(`subscribe/history/${id}`, API_BASE_URL).href,
|
||||||
historyByType: (type: SubscribeMediaType) => new URL(`subscribe/history/${type}`, API_BASE_URL).href,
|
historyByType: (type: SubscribeMediaType) => new URL(`subscribe/history/${type}`, API_BASE_URL).href,
|
||||||
follow: new URL('subscribe/follow', API_BASE_URL).href,
|
follow: new URL('subscribe/follow', API_BASE_URL).href,
|
||||||
followSubscribers: new URL('system/setting/public/FollowSubscribers', API_BASE_URL).href,
|
|
||||||
fork: new URL('subscribe/fork', API_BASE_URL).href,
|
fork: new URL('subscribe/fork', API_BASE_URL).href,
|
||||||
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
|
||||||
list: new URL('subscribe/', API_BASE_URL).href,
|
list: new URL('subscribe/', API_BASE_URL).href,
|
||||||
@@ -166,15 +165,15 @@ export function forkSubscribeHandler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function followSubscribersSettingHandler(
|
export function followedSubscribersHandler(
|
||||||
users: string[] = [],
|
users: string[] = [],
|
||||||
status = 200,
|
status = 200,
|
||||||
onRequest: (url: URL) => void | Promise<void> = () => {},
|
onRequest: (url: URL) => void | Promise<void> = () => {},
|
||||||
) {
|
) {
|
||||||
return http.get(subscribeApiUrls.followSubscribers, async ({ request }) => {
|
return http.get(subscribeApiUrls.follow, async ({ request }) => {
|
||||||
await onRequest(new URL(request.url))
|
await onRequest(new URL(request.url))
|
||||||
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
|
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
|
||||||
return apiJson({ value: users }, { status })
|
return apiJson(users, { status })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +302,7 @@ export function searchSubscribeByIdHandler(
|
|||||||
status = 200,
|
status = 200,
|
||||||
onRequest: (url: URL) => void = () => {},
|
onRequest: (url: URL) => void = () => {},
|
||||||
) {
|
) {
|
||||||
return http.get(subscribeApiUrls.searchById(id), ({ request }) => {
|
return http.post(subscribeApiUrls.searchById(id), ({ request }) => {
|
||||||
onRequest(new URL(request.url))
|
onRequest(new URL(request.url))
|
||||||
return mutationResponse(response, status)
|
return mutationResponse(response, status)
|
||||||
})
|
})
|
||||||
@@ -315,7 +314,7 @@ export function resetSubscribeByIdHandler(
|
|||||||
status = 200,
|
status = 200,
|
||||||
onRequest: (url: URL) => void = () => {},
|
onRequest: (url: URL) => void = () => {},
|
||||||
) {
|
) {
|
||||||
return http.get(subscribeApiUrls.resetById(id), ({ request }) => {
|
return http.post(subscribeApiUrls.resetById(id), ({ request }) => {
|
||||||
onRequest(new URL(request.url))
|
onRequest(new URL(request.url))
|
||||||
return mutationResponse(response, status)
|
return mutationResponse(response, status)
|
||||||
})
|
})
|
||||||
@@ -420,7 +419,7 @@ export function saveDefaultSubscribeConfigHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscribeDialogOptions {
|
export interface SubscribeDialogOptions {
|
||||||
directories?: TransferDirectoryConf[]
|
directories?: DownloadDirectory[]
|
||||||
downloaders?: DownloaderConf[]
|
downloaders?: DownloaderConf[]
|
||||||
episodeGroups?: Record<string, unknown>[]
|
episodeGroups?: Record<string, unknown>[]
|
||||||
filterRuleGroups?: FilterRuleGroup[]
|
filterRuleGroups?: FilterRuleGroup[]
|
||||||
@@ -460,11 +459,11 @@ export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions =
|
|||||||
}),
|
}),
|
||||||
http.get(subscribeApiUrls.directories, () => {
|
http.get(subscribeApiUrls.directories, () => {
|
||||||
onDirectories()
|
onDirectories()
|
||||||
return apiJson({ value: directories })
|
return apiJson(directories)
|
||||||
}),
|
}),
|
||||||
http.get(subscribeApiUrls.filterRuleGroups, () => {
|
http.get(subscribeApiUrls.filterRuleGroups, () => {
|
||||||
onFilterRuleGroups()
|
onFilterRuleGroups()
|
||||||
return apiJson({ value: filterRuleGroups })
|
return apiJson({ count: filterRuleGroups.length, rule_groups: filterRuleGroups })
|
||||||
}),
|
}),
|
||||||
http.get(subscribeApiUrls.episodeGroups(tmdbId), () => {
|
http.get(subscribeApiUrls.episodeGroups(tmdbId), () => {
|
||||||
onEpisodeGroups()
|
onEpisodeGroups()
|
||||||
|
|||||||
Reference in New Issue
Block a user