feat(ui): complete structured API capability entries

This commit is contained in:
jxxghp
2026-09-05 15:11:02 +08:00
parent 608e4ae2a0
commit 775977a378
100 changed files with 5032 additions and 689 deletions
+4 -30
View File
@@ -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": {
@@ -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'])
})
})
+26
View File
@@ -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')
})
})
+41
View File
@@ -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')
})
})
+80
View File
@@ -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,
})
})
})
+71
View File
@@ -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',
})
})
})
+102
View File
@@ -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: ['组/一'],
})
})
})
+24
View File
@@ -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')
})
})
+45
View File
@@ -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' },
})
})
})
+57
View File
@@ -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' } })
})
})
+57
View File
@@ -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()
})
})
+34
View File
@@ -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]
}
+6
View File
@@ -0,0 +1,6 @@
import api from '@/api'
/** 清空全部旧整理记录;后端会保留持久失败任务,且不会删除任何文件。 */
export async function clearLegacyTransferHistory(): Promise<void> {
await api.delete<null>('history/transfer/all', { feedback: 'silent' })
}
+16
View File
@@ -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 }] : []
})
}
+117
View File
@@ -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' })
}
+81
View File
@@ -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)
}
+95
View File
@@ -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
View File
@@ -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' },
)
}
+11
View File
@@ -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' })
}
+36
View File
@@ -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 : []
}
+47
View File
@@ -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 },
})
}
+42
View File
@@ -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
}
+97
View File
@@ -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 {
// 目标存储 // 目标存储
+30 -7
View File
@@ -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>
+76 -2
View File
@@ -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>
+36 -7
View File
@@ -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()
}) })
// 弹出菜单 // 弹出菜单
+4 -4
View File
@@ -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 -2
View File
@@ -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 () => {
+5 -17
View File
@@ -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>
+168 -9
View File
@@ -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)
} }
+4 -5
View File
@@ -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)
})
}) })
+21 -1
View File
@@ -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 }
} }
+82
View File
@@ -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',
+80
View File
@@ -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: '卸载',
+22
View File
@@ -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: '服務地址',
+4 -2
View File
@@ -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)
+100 -7
View File
@@ -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([
+1 -1
View File
@@ -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
View File
@@ -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"
+35 -8
View File
@@ -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>
+7 -8
View File
@@ -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
+7 -8
View File
@@ -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)) {
+7 -8
View File
@@ -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
+121 -78
View File
@@ -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')
// arrayobject with config // arrayobject 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?.()
+2 -1
View File
@@ -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
+11 -12
View File
@@ -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 = {
+4 -5
View File
@@ -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 = []
+10 -11
View File
@@ -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)
// APISystemSettings 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
+246 -17
View File
@@ -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 -2
View File
@@ -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)
} }
+39 -2
View File
@@ -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 -3
View File
@@ -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 -2
View File
@@ -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 {
+21 -5
View File
@@ -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('识别词已保存到识别词表末尾')
}) })
+23 -1
View File
@@ -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()
+9 -5
View File
@@ -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,
} }
} }
+17 -1
View File
@@ -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 })
+11 -12
View File
@@ -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()