mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
refactor(api): adopt unified responses and restore media config hints
This commit is contained in:
+32
-84
@@ -72,14 +72,9 @@
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"src/api/index.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/api/types.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 39
|
||||
"count": 38
|
||||
},
|
||||
"@typescript-eslint/no-wrapper-object-types": {
|
||||
"count": 2
|
||||
@@ -127,7 +122,7 @@
|
||||
},
|
||||
"src/components/cards/MediaCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -148,7 +143,7 @@
|
||||
},
|
||||
"src/components/cards/SubscribeCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -168,14 +163,9 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AgentMcpSettingsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AlistConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
@@ -183,7 +173,7 @@
|
||||
},
|
||||
"src/components/dialog/AliyunAuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/CategoryEditDialog.vue": {
|
||||
@@ -191,7 +181,7 @@
|
||||
"count": 16
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/dialog/ContentToggleSettingsDialog.vue": {
|
||||
@@ -210,16 +200,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/ForkSubscribeDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/dialog/ForkWorkflowDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/MediaInfoDialog.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -227,12 +207,12 @@
|
||||
},
|
||||
"src/components/dialog/NotificationChannelInfoDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/RcloneConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
@@ -240,7 +220,7 @@
|
||||
},
|
||||
"src/components/dialog/ReorganizeDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 10
|
||||
"count": 5
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
@@ -253,12 +233,12 @@
|
||||
},
|
||||
"src/components/dialog/SiteAddEditDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteCookieUpdateDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteImportDialog.vue": {
|
||||
@@ -276,7 +256,7 @@
|
||||
},
|
||||
"src/components/dialog/SmbConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 5
|
||||
@@ -284,12 +264,12 @@
|
||||
},
|
||||
"src/components/dialog/SubscribeEditDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 8
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeHistoryDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
@@ -300,11 +280,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeShareDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeShareStatisticsDialog.vue": {
|
||||
"vue/no-side-effects-in-computed-properties": {
|
||||
"count": 1
|
||||
@@ -312,7 +287,7 @@
|
||||
},
|
||||
"src/components/dialog/U115AuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -320,7 +295,7 @@
|
||||
},
|
||||
"src/components/dialog/UserAuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/WorkflowActionsDialog.vue": {
|
||||
@@ -333,11 +308,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/WorkflowShareDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -360,7 +330,7 @@
|
||||
},
|
||||
"src/components/filebrowser/FileList.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -562,14 +532,9 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useLlmProviderDirectory.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/composables/useMediaSubscribe.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -587,7 +552,7 @@
|
||||
},
|
||||
"src/composables/useSetupWizard.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 22
|
||||
"count": 13
|
||||
},
|
||||
"sonarjs/no-duplicated-branches": {
|
||||
"count": 2
|
||||
@@ -629,13 +594,10 @@
|
||||
},
|
||||
"src/layouts/default/components/UserNotification.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/layouts/default/components/UserProfile.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 5
|
||||
},
|
||||
@@ -660,7 +622,7 @@
|
||||
},
|
||||
"src/pages/resource.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
@@ -833,7 +795,7 @@
|
||||
},
|
||||
"src/views/reorganize/TransferHistoryView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
"count": 5
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 3
|
||||
@@ -841,46 +803,40 @@
|
||||
},
|
||||
"src/views/setting/AccountSettingDirectory.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 8
|
||||
"count": 4
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingNotification.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 9
|
||||
},
|
||||
"sonarjs/no-all-duplicated-branches": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingRule.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 14
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSearch.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSite.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSubscribe.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSystem.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 20
|
||||
},
|
||||
"no-prototype-builtins": {
|
||||
"count": 1
|
||||
"count": 11
|
||||
},
|
||||
"no-unsafe-finally": {
|
||||
"count": 1
|
||||
@@ -899,7 +855,7 @@
|
||||
},
|
||||
"src/views/setup/PreferencesSettingsStep.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/FullCalendarView.vue": {
|
||||
@@ -930,7 +886,7 @@
|
||||
},
|
||||
"src/views/system/CacheView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/LoggingView.vue": {
|
||||
@@ -945,21 +901,13 @@
|
||||
}
|
||||
},
|
||||
"src/views/system/ModuleTestView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/NameTestView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/system/NetTestView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/RuleTestView.vue": {
|
||||
@@ -969,7 +917,7 @@
|
||||
},
|
||||
"src/views/system/WordsView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/workflow/WorkflowShareView.vue": {
|
||||
|
||||
@@ -13,7 +13,15 @@ interface WordListMode {
|
||||
}
|
||||
}
|
||||
|
||||
interface JinjaMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
|
||||
const Jinja2Mode = ace.require('ace/mode/jinja2').Mode as new () => JinjaMode
|
||||
const Jinja2JsonMode = ace.require('ace/mode/jinja2_json').Mode as new () => JinjaMode
|
||||
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
|
||||
const plainTokenizer = new WordListMode().getTokenizer()
|
||||
|
||||
@@ -25,6 +33,28 @@ function tokenizePlain(line: string) {
|
||||
return plainTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
function findToken(mode: new () => JinjaMode, line: string, value: string) {
|
||||
return new mode()
|
||||
.getTokenizer()
|
||||
.getLineTokens(line, 'start')
|
||||
.tokens.find(token => token.value === value)
|
||||
}
|
||||
|
||||
describe('Jinja context variables', () => {
|
||||
it('keeps source-specific IDs and excludes unified identity from rename formats', () => {
|
||||
expect(findToken(Jinja2Mode, '{{ tmdbid }}', 'tmdbid')?.type).toBe('variable.language.jinja2')
|
||||
expect(findToken(Jinja2Mode, '{{ media_source }}', 'media_source')?.type).toBe('identifier')
|
||||
expect(findToken(Jinja2Mode, '{{ media_id }}', 'media_id')?.type).toBe('identifier')
|
||||
})
|
||||
|
||||
it('keeps unified identity available in notification JSON templates', () => {
|
||||
const template = '{"source": "{{ media_source }}", "id": "{{ media_id }}"}'
|
||||
|
||||
expect(findToken(Jinja2JsonMode, template, 'media_source')?.type).toBe('variable.language.jinja2')
|
||||
expect(findToken(Jinja2JsonMode, template, 'media_id')?.type).toBe('variable.language.jinja2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('word list syntax mode', () => {
|
||||
it('highlights a block word as one field', () => {
|
||||
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||
@@ -47,21 +77,12 @@ describe('word list syntax mode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts the unified media source and native media ID parameters', () => {
|
||||
const tokens = tokenize('旧名 => 新名 {[media_source=douban;media_id=1295644;type=movie]}')
|
||||
it('rejects unified media identity parameters in custom identifiers', () => {
|
||||
const invalidKeys = tokenize('旧名 => {[media_source=douban;media_id=1295644]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(tokens).toContainEqual({ type: 'word_list_parameter_key', value: 'media_source' })
|
||||
expect(tokens).toContainEqual({ type: 'word_list_parameter_value', value: 'douban' })
|
||||
expect(tokens).toContainEqual({ type: 'word_list_parameter_key', value: 'media_id' })
|
||||
expect(tokens).toContainEqual({ type: 'word_list_parameter_value', value: '1295644' })
|
||||
expect(tokens).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
|
||||
})
|
||||
|
||||
it('rejects an unknown media source in the unified parameters', () => {
|
||||
expect(tokenize('旧名 => {[media_source=custom;media_id=42]}')).toContainEqual({
|
||||
type: 'invalid.word-list',
|
||||
value: 'custom',
|
||||
})
|
||||
expect(invalidKeys).toEqual(['media_source', 'media_id'])
|
||||
})
|
||||
|
||||
it('marks invalid replacement parameter keys and values', () => {
|
||||
|
||||
+2
-8
@@ -70,7 +70,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
@@ -538,15 +538,11 @@ interface WordListToken {
|
||||
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
|
||||
const wordListUnsignedIntegerPattern = /^\d+$/
|
||||
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
|
||||
const wordListMediaSourcePattern =
|
||||
/^(?:themoviedb|douban|bangumi|anilist|imdb|tvdb|musicbrainz|theaudiodb|doubanmusic|bilibili|mangguodiscover|migu|tencentvideodiscover)$/
|
||||
const wordListParameterTypes = {
|
||||
tmdbid: 'uint',
|
||||
doubanid: 'string',
|
||||
doubanid: 'uint',
|
||||
bangumiid: 'uint',
|
||||
anilistid: 'uint',
|
||||
media_source: 'media-source',
|
||||
media_id: 'string',
|
||||
type: 'media-type',
|
||||
g: 'string',
|
||||
s: 'uint-or-range',
|
||||
@@ -569,8 +565,6 @@ function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes,
|
||||
return wordListUnsignedIntegerOrRangePattern.test(value)
|
||||
case 'media-type':
|
||||
return value === 'movie' || value === 'tv'
|
||||
case 'media-source':
|
||||
return wordListMediaSourcePattern.test(value)
|
||||
case 'string':
|
||||
return value.length > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosHeaders,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
type RawAxiosHeaders,
|
||||
} from 'axios'
|
||||
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const notifier: ApiFeedbackNotifier = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}
|
||||
|
||||
/** 构造 Axios 适配器响应,避免客户端单测依赖真实网络。 */
|
||||
function createResponse<T>(
|
||||
config: InternalAxiosRequestConfig,
|
||||
data: T,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {},
|
||||
): AxiosResponse<T> {
|
||||
return {
|
||||
config,
|
||||
data,
|
||||
headers: new AxiosHeaders(headers),
|
||||
status,
|
||||
statusText: status >= 400 ? 'Error' : 'OK',
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建始终成功返回指定数据的 Axios 适配器。 */
|
||||
function resolveWith<T>(data: T, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => createResponse(config, data, 200, headers)
|
||||
}
|
||||
|
||||
/** 创建带 Axios 响应元数据的 HTTP 失败适配器。 */
|
||||
function rejectWith<T>(data: T, status: number, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => {
|
||||
const response = createResponse(config, data, status, headers)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
}
|
||||
|
||||
/** 断言并收窄统一 API 错误,避免测试绕过 unknown 错误边界。 */
|
||||
function requireApiRequestError(reason: unknown): ApiRequestError {
|
||||
expect(reason).toBeInstanceOf(ApiRequestError)
|
||||
if (!(reason instanceof ApiRequestError)) throw reason
|
||||
return reason
|
||||
}
|
||||
|
||||
describe('MoviePilot API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(notifier.error).mockReset()
|
||||
vi.mocked(notifier.success).mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['object', { id: 7 }],
|
||||
['array', ['one', 'two']],
|
||||
['scalar', 42],
|
||||
['null', null],
|
||||
])('仅向内部调用方返回成功 envelope 的 data:%s', async (_label, data) => {
|
||||
const envelope: ApiResponse<typeof data> = { success: true, message: '', data }
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(api.get<typeof data>('/resource')).resolves.toEqual(data)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只在 feedback=all 时展示后端成功消息', async () => {
|
||||
const envelope: ApiResponse<{ saved: boolean }> = {
|
||||
success: true,
|
||||
message: 'Saved',
|
||||
data: { saved: true },
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await api.post('/setting', {}, { feedback: 'default' })
|
||||
await api.post('/setting', {}, { feedback: 'all' })
|
||||
|
||||
expect(notifier.success).toHaveBeenCalledOnce()
|
||||
expect(notifier.success).toHaveBeenCalledWith('Saved')
|
||||
})
|
||||
|
||||
it('将 success=false 转为保留完整响应元数据的 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ reason: string }> = {
|
||||
success: false,
|
||||
message: 'Cannot save',
|
||||
data: { reason: 'locked' },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith(envelope, { 'x-request-id': 'request-1' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/setting', {}).catch(reason => reason))
|
||||
|
||||
expect(axios.isAxiosError(error)).toBe(true)
|
||||
expect(error).toMatchObject({
|
||||
message: 'Cannot save',
|
||||
payload: envelope,
|
||||
status: 200,
|
||||
})
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.headers as RawAxiosHeaders | undefined).get('x-request-id')).toBe('request-1')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||
})
|
||||
|
||||
it('将 HTTP 错误统一转换为 ApiRequestError 并保留 payload、status 和 headers', async () => {
|
||||
const envelope: ApiResponse<{ mfa_methods: string[] }> = {
|
||||
success: false,
|
||||
message: 'Additional verification required',
|
||||
data: { mfa_methods: ['otp'] },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(envelope, 403, { 'x-mfa-required': 'true' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/login/access-token', {}).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Additional verification required')
|
||||
expect(error.status).toBe(403)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.response?.headers as RawAxiosHeaders | undefined).get('x-mfa-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('silent 关闭失败 Toast,但不丢失错误元数据', async () => {
|
||||
const envelope: ApiResponse<null> = { success: false, message: 'Invalid credentials', data: null }
|
||||
const { api } = createApiClients({ adapter: rejectWith(envelope, 401), notifier })
|
||||
|
||||
const error = requireApiRequestError(
|
||||
await api.post('/login/access-token', {}, { feedback: 'silent' }).catch(reason => reason),
|
||||
)
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('插件客户端保留完整 envelope,同时沿用统一失败反馈', async () => {
|
||||
const envelope: ApiResponse<{ plugin: string }> = {
|
||||
success: false,
|
||||
message: 'Plugin rejected request',
|
||||
data: { plugin: 'demo' },
|
||||
}
|
||||
const { pluginApi } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(pluginApi.get<unknown, ApiResponse<{ plugin: string }>>('/plugin/demo')).resolves.toEqual(envelope)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Plugin rejected request')
|
||||
})
|
||||
|
||||
it('Blob 成功响应绕过 envelope 解包', async () => {
|
||||
const blob = new Blob(['moviepilot'], { type: 'application/octet-stream' })
|
||||
const { api } = createApiClients({ adapter: resolveWith(blob), notifier })
|
||||
|
||||
await expect(api.get<Blob>('/download', { responseType: 'blob' })).resolves.toBe(blob)
|
||||
})
|
||||
|
||||
it('解析 Blob 中的 JSON HTTP 错误后再构造 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ file: string }> = {
|
||||
success: false,
|
||||
message: 'File is unavailable',
|
||||
data: { file: 'movie.mkv' },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(envelope)], { type: 'application/json' })
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(blob, 404, { 'content-type': 'application/json' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/download', { responseType: 'blob' }).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('File is unavailable')
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
})
|
||||
|
||||
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async config => {
|
||||
throw new CanceledError('Request cancelled', config)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||
|
||||
const error = await api.get('/slow').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(CanceledError)
|
||||
expect(error).not.toBeInstanceOf(ApiRequestError)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('拒绝带旧顶层 message_i18n 的 envelope', async () => {
|
||||
const legacyEnvelope = {
|
||||
success: false,
|
||||
message: 'Failed',
|
||||
message_i18n: '旧版错误',
|
||||
data: null,
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(legacyEnvelope), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
expect(error.payload).toEqual(legacyEnvelope)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '服务器返回了无效响应'],
|
||||
['en-US', 'The server returned an invalid response'],
|
||||
])('使用应用注入的 %s fallback 文案提示协议异常', async (_locale, translatedMessage) => {
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith({ value: 1 }),
|
||||
notifier,
|
||||
resolveFallbackMessage: key => (key === 'invalid-envelope' ? translatedMessage : key),
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe(translatedMessage)
|
||||
expect(notifier.error).toHaveBeenCalledWith(translatedMessage)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18nT: vi.fn((key: string) => `translated:${key}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({ logout: vi.fn(), token: null }),
|
||||
}))
|
||||
vi.mock('@/utils/requestOptimizer', () => ({
|
||||
initializeRequestOptimizer: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/composables/useOfflineStatus', () => ({
|
||||
useGlobalOfflineStatus: () => ({
|
||||
markServerOnline: vi.fn(),
|
||||
reportNetworkError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
default: { global: { t: mocks.i18nT } },
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('API application wiring', () => {
|
||||
it('向 window 暴露插件 envelope 客户端,而内部默认导出数据客户端', async () => {
|
||||
const module = await import('@/api')
|
||||
|
||||
expect(window.MoviePilotAPI).toBe(module.pluginApi)
|
||||
expect(module.default).not.toBe(module.pluginApi)
|
||||
})
|
||||
|
||||
it('通过 i18n 实例解析请求层 fallback 文案', async () => {
|
||||
const module = await import('@/api')
|
||||
module.default.defaults.adapter = async config => ({
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
data: { legacy: true },
|
||||
headers: new AxiosHeaders(),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const error = await module.default.get('/legacy').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(module.ApiRequestError)
|
||||
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
||||
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type CreateAxiosDefaults,
|
||||
} from 'axios'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
/** 控制请求层是否展示成功或失败反馈。 */
|
||||
export type ApiFeedbackMode = 'silent' | 'default' | 'all'
|
||||
|
||||
/** 请求层无法从后端取得 message 时使用的本地化文案类别。 */
|
||||
export type ApiFallbackMessageKey = 'invalid-envelope' | 'network-error' | 'request-failed' | 'timeout'
|
||||
|
||||
/** 将请求层 fallback 类别解析为当前语言文案,避免基础客户端依赖 Vue i18n。 */
|
||||
export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通 API 客户端在响应拦截器解包后返回业务数据,而不是 AxiosResponse。 */
|
||||
export interface DataApiClient extends Omit<
|
||||
AxiosInstance,
|
||||
'delete' | 'get' | 'head' | 'options' | 'patch' | 'patchForm' | 'post' | 'postForm' | 'put' | 'putForm' | 'request'
|
||||
> {
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
request<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
get<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
delete<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
head<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
options<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
post<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
put<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patch<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
postForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
putForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patchForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
}
|
||||
|
||||
/** 请求层使用的反馈出口,由应用入口接到全局 Toast。 */
|
||||
export interface ApiFeedbackNotifier {
|
||||
error(message: string): void
|
||||
success(message: string): void
|
||||
}
|
||||
|
||||
/** 请求生命周期钩子用于隔离认证和离线状态等应用级副作用。 */
|
||||
export interface ApiClientHooks {
|
||||
markServerOnline?(): void
|
||||
onForbidden?(error: ApiRequestError): void
|
||||
reportConnectionFailure?(reason: 'network-error' | 'timeout'): void
|
||||
}
|
||||
|
||||
/** 创建内部数据客户端与插件原始协议客户端时所需的配置。 */
|
||||
export interface CreateApiClientsOptions extends CreateAxiosDefaults {
|
||||
hooks?: ApiClientHooks
|
||||
notifier?: ApiFeedbackNotifier
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver
|
||||
setupInstance?(instance: AxiosInstance): void
|
||||
}
|
||||
|
||||
const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'Invalid API response envelope',
|
||||
'network-error': 'Network connection failed',
|
||||
'request-failed': 'API request failed',
|
||||
timeout: 'Request timeout',
|
||||
}
|
||||
|
||||
interface ApiRequestErrorOptions<T> {
|
||||
businessFailure?: boolean
|
||||
cause?: unknown
|
||||
code?: string
|
||||
config?: AxiosRequestConfig
|
||||
payload?: T
|
||||
request?: unknown
|
||||
response?: AxiosResponse<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一表示业务失败、HTTP 失败和请求协议错误。
|
||||
*
|
||||
* 该类型继承 AxiosError,确保认证流程和既有调用方仍可读取 response、status、headers 与 config。
|
||||
*/
|
||||
export class ApiRequestError<T = unknown> extends AxiosError<T> {
|
||||
readonly businessFailure: boolean
|
||||
readonly headers?: AxiosResponse<T>['headers']
|
||||
readonly payload?: T
|
||||
|
||||
/** 保留原始 Axios 元数据,并额外暴露已解析的响应载荷。 */
|
||||
constructor(message: string, options: ApiRequestErrorOptions<T> = {}) {
|
||||
super(
|
||||
message,
|
||||
options.code,
|
||||
options.config as AxiosResponse<T>['config'] | undefined,
|
||||
options.request,
|
||||
options.response,
|
||||
)
|
||||
this.name = 'ApiRequestError'
|
||||
this.businessFailure = options.businessFailure ?? false
|
||||
this.headers = options.response?.headers
|
||||
this.payload = options.payload
|
||||
this.status = options.response?.status
|
||||
if (options.cause instanceof Error) this.cause = options.cause
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
|
||||
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
||||
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
||||
const payload = error.payload
|
||||
if (!payload || typeof payload !== 'object') return undefined
|
||||
const message = (payload as { message?: unknown }).message
|
||||
return typeof message === 'string' && message.trim() ? message : undefined
|
||||
}
|
||||
|
||||
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
||||
export function isApiBusinessFailure(error: unknown): error is ApiRequestError {
|
||||
return error instanceof ApiRequestError && error.businessFailure
|
||||
}
|
||||
|
||||
/** 判断未知值是否为 MoviePilot 标准响应 envelope。 */
|
||||
export function isApiResponse<T = unknown>(value: unknown): value is ApiResponse<T> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record)
|
||||
if (keys.length !== 3 || !keys.every(key => key === 'success' || key === 'message' || key === 'data')) return false
|
||||
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
|
||||
}
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
export function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 创建普通数据客户端和保持插件 ABI 的原始 envelope 客户端。 */
|
||||
export function createApiClients(options: CreateApiClientsOptions = {}): {
|
||||
api: DataApiClient
|
||||
pluginApi: AxiosInstance
|
||||
} {
|
||||
const { hooks, notifier, resolveFallbackMessage, setupInstance, ...axiosConfig } = options
|
||||
const api = axios.create(axiosConfig)
|
||||
const pluginApi = axios.create(axiosConfig)
|
||||
|
||||
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
|
||||
setupInstance?.(api)
|
||||
setupInstance?.(pluginApi)
|
||||
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage)
|
||||
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage)
|
||||
|
||||
return {
|
||||
api: api as DataApiClient,
|
||||
pluginApi,
|
||||
}
|
||||
}
|
||||
|
||||
/** 为客户端安装统一的可达性、反馈、解包和错误标准化处理。 */
|
||||
function installResponseInterceptors(
|
||||
instance: AxiosInstance,
|
||||
responseMode: 'data' | 'envelope',
|
||||
hooks?: ApiClientHooks,
|
||||
notifier?: ApiFeedbackNotifier,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
) {
|
||||
instance.interceptors.response.use(
|
||||
response => {
|
||||
hooks?.markServerOnline?.()
|
||||
|
||||
if (isBinarySuccess(response)) return response.data
|
||||
|
||||
const payload: unknown = response.data
|
||||
if (!isApiResponse(payload)) {
|
||||
if (responseMode === 'envelope') return payload
|
||||
const error = new ApiRequestError(resolveFallback('invalid-envelope', resolveFallbackMessage), {
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
})
|
||||
notifyFailure(response.config.feedback, notifier, error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
notifyFailure(response.config.feedback, notifier, payload.message)
|
||||
if (responseMode === 'envelope') return payload
|
||||
return Promise.reject(
|
||||
new ApiRequestError(payload.message || resolveFallback('request-failed', resolveFallbackMessage), {
|
||||
businessFailure: true,
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
notifySuccess(response.config.feedback, notifier, payload.message)
|
||||
return responseMode === 'envelope' ? payload : payload.data
|
||||
},
|
||||
async (reason: unknown) => {
|
||||
if (isCancellation(reason)) return Promise.reject(reason)
|
||||
// fulfilled 分支生成的协议错误已经携带完整上下文,不应再次按响应 message 包装。
|
||||
if (reason instanceof ApiRequestError) return Promise.reject(reason)
|
||||
|
||||
const original = reason instanceof AxiosError ? reason : undefined
|
||||
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
|
||||
if (response) hooks?.markServerOnline?.()
|
||||
|
||||
const payload = response?.data
|
||||
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
|
||||
cause: reason,
|
||||
code: original?.code,
|
||||
config: original?.config,
|
||||
payload,
|
||||
request: original?.request,
|
||||
response,
|
||||
})
|
||||
|
||||
const requestConfig = original?.config
|
||||
const failureReason = original ? resolveConnectionFailureReason(original) : null
|
||||
if (!response && !requestConfig?.skipConnectionTracking && failureReason) {
|
||||
hooks?.reportConnectionFailure?.(failureReason)
|
||||
}
|
||||
if (response?.status === 403) hooks?.onForbidden?.(error)
|
||||
|
||||
notifyFailure(requestConfig?.feedback, notifier, error.message)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 二进制成功响应不参与 JSON envelope 校验和解包。 */
|
||||
function isBinarySuccess(response: AxiosResponse): boolean {
|
||||
if (typeof Blob !== 'undefined' && response.data instanceof Blob) return true
|
||||
return response.config.responseType === 'arraybuffer' || response.config.responseType === 'stream'
|
||||
}
|
||||
|
||||
/** 将 Blob 形式的 JSON 错误响应解析为结构化载荷,便于调用方读取错误元数据。 */
|
||||
async function normalizeErrorResponse(response: AxiosResponse): Promise<AxiosResponse> {
|
||||
if (typeof Blob === 'undefined' || !(response.data instanceof Blob) || !isJsonBlob(response)) return response
|
||||
|
||||
try {
|
||||
const data = JSON.parse(await readBlobText(response.data)) as unknown
|
||||
return { ...response, data }
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先使用现代 Blob API,并为测试环境和旧浏览器保留 FileReader 回退。 */
|
||||
function readBlobText(blob: Blob): Promise<string> {
|
||||
if (typeof blob.text === 'function') return blob.text()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read response Blob'))
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据响应头或 Blob 自身类型识别 JSON 错误载荷。 */
|
||||
function isJsonBlob(response: AxiosResponse): boolean {
|
||||
const blobType = response.data instanceof Blob ? response.data.type : ''
|
||||
const headers = response.headers as AxiosResponse['headers'] & { get?: (name: string) => unknown }
|
||||
const rawContentType = typeof headers.get === 'function' ? headers.get('content-type') : headers['content-type']
|
||||
const contentType = typeof rawContentType === 'string' ? rawContentType : ''
|
||||
return blobType.includes('json') || contentType.includes('json')
|
||||
}
|
||||
|
||||
/** 取消属于正常控制流,保持原始取消对象且不触发 Toast 或离线探测。 */
|
||||
function isCancellation(reason: unknown): boolean {
|
||||
if (axios.isCancel(reason)) return true
|
||||
if (!(reason instanceof Error)) return false
|
||||
return reason.name === 'AbortError' || reason.name === 'CanceledError'
|
||||
}
|
||||
|
||||
/** 优先使用后端 message,并兼容尚未进入统一 envelope 的 detail 错误。 */
|
||||
function resolveErrorMessage(
|
||||
payload: unknown,
|
||||
error?: AxiosError,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
): string {
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const record = payload as Record<string, unknown>
|
||||
if (typeof record.message === 'string' && record.message) return record.message
|
||||
if (typeof record.detail === 'string' && record.detail) return record.detail
|
||||
}
|
||||
if (error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT') {
|
||||
return resolveFallback('timeout', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.code === 'NETWORK_ERROR' || error?.code === 'ERR_NETWORK') {
|
||||
return resolveFallback('network-error', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.message) return error.message
|
||||
return resolveFallback('request-failed', resolveFallbackMessage)
|
||||
}
|
||||
|
||||
/** 调用应用注入的本地化 resolver,并为独立使用保留稳定默认值。 */
|
||||
function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessageResolver): string {
|
||||
return resolver?.(key) || defaultFallbackMessages[key]
|
||||
}
|
||||
|
||||
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
|
||||
function notifyFailure(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
||||
if (mode !== 'silent' && message) notifier?.error(message)
|
||||
}
|
||||
|
||||
/** all 模式用于显式要求请求层展示后端成功消息。 */
|
||||
function notifySuccess(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
||||
if (mode === 'all' && message) notifier?.success(message)
|
||||
}
|
||||
+55
-100
@@ -1,124 +1,79 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
import i18n, { getCurrentLocale } from '@/plugins/i18n'
|
||||
import {
|
||||
ApiRequestError,
|
||||
createApiClients,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiResponse,
|
||||
type ApiFeedbackMode,
|
||||
type ApiFallbackMessageKey,
|
||||
type DataApiClient,
|
||||
} from './client'
|
||||
|
||||
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
const toast = useToast()
|
||||
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'common.invalidApiResponse',
|
||||
'network-error': 'common.networkConnectionFailed',
|
||||
'request-failed': 'common.apiRequestFailed',
|
||||
timeout: 'common.requestTimeout',
|
||||
}
|
||||
const { api, pluginApi } = createApiClients({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
setupInstance: initializeClient,
|
||||
hooks: {
|
||||
markServerOnline: globalOfflineStatus.markServerOnline,
|
||||
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
||||
onForbidden: () => {
|
||||
const authStore = useAuthStore()
|
||||
// 未登录的 403 可能是登录或 MFA 流程的一部分,不应触发全局登出跳转。
|
||||
if (!authStore.token) return
|
||||
authStore.logout()
|
||||
void router.push('/login')
|
||||
},
|
||||
},
|
||||
notifier: {
|
||||
error: message => toast.error(message),
|
||||
success: message => toast.success(message),
|
||||
},
|
||||
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MoviePilotAPI: typeof api
|
||||
MoviePilotAPI: AxiosInstance
|
||||
}
|
||||
}
|
||||
|
||||
// 将 API 实例暴露到全局,供插件使用
|
||||
window.MoviePilotAPI = api
|
||||
|
||||
// 初始化请求优化器(必须在其他拦截器之前)
|
||||
initializeRequestOptimizer(api)
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
/** 为两个客户端安装同一套取消、认证和语言请求头。 */
|
||||
function initializeClient(instance: AxiosInstance | DataApiClient) {
|
||||
initializeRequestOptimizer(instance)
|
||||
instance.interceptors.request.use(config => {
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
if (authStore.token) config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
interface LocalizedApiPayload {
|
||||
detail?: unknown
|
||||
detail_i18n?: unknown
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
}
|
||||
// 插件远程组件依赖完整 envelope ABI,内部页面则默认使用已解包的数据客户端。
|
||||
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
||||
|
||||
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
|
||||
function normalizeLocalizedMessage(payload: any): any {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
|
||||
|
||||
const localizedPayload = payload as LocalizedApiPayload
|
||||
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
|
||||
localizedPayload.message = localizedPayload.message_i18n
|
||||
}
|
||||
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
|
||||
localizedPayload.detail = localizedPayload.detail_i18n
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return normalizeLocalizedMessage(response.data)
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
// 请求被中止(路由切换等)
|
||||
return Promise.reject(new Error('Request cancelled'))
|
||||
}
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
} else {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
export { ApiRequestError, getApiBusinessErrorMessage, isApiResponse, pluginApi }
|
||||
export type { ApiFeedbackMode, DataApiClient }
|
||||
|
||||
export default api
|
||||
|
||||
+4
-5
@@ -2210,12 +2210,11 @@ export interface SubscribeShareStatistics {
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
data: T
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api, { isApiResponse } from '@/api'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
@@ -73,6 +74,20 @@ interface AgentChoiceSelection {
|
||||
selected_description?: string
|
||||
}
|
||||
|
||||
interface AgentChoiceCallbackData {
|
||||
message?: string
|
||||
traditional?: boolean
|
||||
original_message_id?: string
|
||||
original_chat_id?: string
|
||||
choice_selection?: unknown
|
||||
feedback?: {
|
||||
selected_label?: string
|
||||
selected_value?: string
|
||||
selected_description?: string
|
||||
}
|
||||
display_message?: string
|
||||
}
|
||||
|
||||
interface AgentChatMessage {
|
||||
id: string
|
||||
role: AgentMessageRole
|
||||
@@ -712,22 +727,6 @@ function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
|
||||
return deduped.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
// 调用智能助手接口,并统一处理鉴权和标准响应格式。
|
||||
async function fetchAgentApi(path: string, options: RequestInit = {}) {
|
||||
const response = await fetch(resolveApiUrl(path), {
|
||||
...options,
|
||||
headers: buildAgentRequestHeaders(options.headers),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.error'))
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
// 从 localStorage 读取历史会话索引,读取失败时回退为空列表。
|
||||
function restoreHistorySessions() {
|
||||
try {
|
||||
@@ -743,7 +742,9 @@ async function loadServerHistorySessions() {
|
||||
historyHasMore.value = true
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const data = await fetchAgentApi(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`)
|
||||
const data = await api.get<unknown>(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
const sessions = Array.isArray(data)
|
||||
? (data
|
||||
.map(item => normalizeServerSession(item as AgentServerSession))
|
||||
@@ -770,7 +771,9 @@ async function loadMoreServerHistorySessions(options?: { done?: (status: Infinit
|
||||
historyLoadingMore.value = true
|
||||
try {
|
||||
const nextPage = historyPage.value + 1
|
||||
const data = await fetchAgentApi(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`)
|
||||
const data = await api.get<unknown>(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
const sessions = Array.isArray(data)
|
||||
? (data
|
||||
.map(item => normalizeServerSession(item as AgentServerSession))
|
||||
@@ -798,7 +801,7 @@ async function loadSlashCommands() {
|
||||
|
||||
slashCommandsLoading.value = true
|
||||
try {
|
||||
const data = await fetchAgentApi('message/agent/commands')
|
||||
const data = await api.get<unknown>('message/agent/commands', { feedback: 'silent' })
|
||||
slashCommands.value = Array.isArray(data)
|
||||
? data
|
||||
.map(item => ({
|
||||
@@ -845,7 +848,9 @@ async function handleHistoryInfiniteLoad({
|
||||
|
||||
// 加载服务端历史会话详情,并更新本地缓存。
|
||||
async function loadServerHistorySession(targetSessionId: string) {
|
||||
const data = await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`)
|
||||
const data = await api.get<unknown>(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
const session = normalizeServerSession(data as AgentServerSession, true)
|
||||
if (!session) throw new Error(t('agentAssistant.historyLoadFailed'))
|
||||
|
||||
@@ -1105,16 +1110,14 @@ function upsertCurrentSessionHistory() {
|
||||
async function saveCurrentSessionToServer() {
|
||||
if (!sessionId.value || messages.value.length === 0) return
|
||||
|
||||
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/display`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
await api.put<unknown>(
|
||||
`message/agent/sessions/${encodeURIComponent(sessionId.value)}/display`,
|
||||
{
|
||||
title: buildSessionHistoryTitle(messages.value),
|
||||
messages: normalizeStoredMessages(messages.value),
|
||||
}),
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
}
|
||||
|
||||
// 持久化当前会话状态,并按需同步到历史会话列表。
|
||||
@@ -1158,9 +1161,12 @@ function buildAgentRequestHeaders(headers?: HeadersInit) {
|
||||
// 解析智能助手 fetch 失败响应,优先使用后端返回的本地化错误文本。
|
||||
async function resolveAgentResponseErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = await response.clone().json()
|
||||
const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
|
||||
if (typeof message === 'string' && message) return message
|
||||
const payload: unknown = await response.clone().json()
|
||||
if (isApiResponse(payload) && payload.message.trim()) return payload.message
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const detail = (payload as Record<string, unknown>).detail
|
||||
if (typeof detail === 'string' && detail.trim()) return detail
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 错误响应保留 HTTP 状态文本,避免吞掉原始错误。
|
||||
}
|
||||
@@ -1793,19 +1799,9 @@ async function uploadAgentAttachment(file: File) {
|
||||
formData.append('file', file)
|
||||
formData.append('session_id', sessionId.value)
|
||||
|
||||
const response = await fetch(resolveApiUrl('message/agent/upload'), {
|
||||
method: 'POST',
|
||||
headers: buildAgentRequestHeaders(),
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
return await api.post<AgentMessageAttachment & AgentOutgoingFile>('message/agent/upload', formData, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.uploadFailed'))
|
||||
|
||||
return result.data as AgentMessageAttachment & AgentOutgoingFile
|
||||
}
|
||||
|
||||
// 准备本轮发送给 Agent 的图片、文件、音频和展示附件。
|
||||
@@ -2137,27 +2133,19 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
||||
streamError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(resolveApiUrl('message/agent/callback'), {
|
||||
method: 'POST',
|
||||
headers: buildAgentRequestHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
const data = await api.post<AgentChoiceCallbackData>(
|
||||
'message/agent/callback',
|
||||
{
|
||||
session_id: sessionId.value,
|
||||
callback_data: button.callback_data,
|
||||
original_message_id: message.id,
|
||||
original_chat_id: sessionId.value,
|
||||
}),
|
||||
credentials: 'include',
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.choiceExpired'))
|
||||
|
||||
const agentMessage = String(result.data?.message || '')
|
||||
if (result.data?.traditional) {
|
||||
const agentMessage = String(data.message || '')
|
||||
if (data.traditional) {
|
||||
const choiceSelection = buildChoiceSelection(choice, button)
|
||||
choiceSelection.selected_description = getChoiceButtonSelectionText(button)
|
||||
markChoiceSelected(choice, button, choiceSelection)
|
||||
@@ -2167,21 +2155,19 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
||||
echoUser: false,
|
||||
displayText: choiceSelection.selected_label || choiceSelection.selected_description,
|
||||
choiceSelection,
|
||||
originalMessageId: String(result.data?.original_message_id || message.id),
|
||||
originalChatId: String(result.data?.original_chat_id || sessionId.value),
|
||||
originalMessageId: String(data.original_message_id || message.id),
|
||||
originalChatId: String(data.original_chat_id || sessionId.value),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const backendSelection = normalizeChoiceSelection(result.data?.choice_selection)
|
||||
const backendSelection = normalizeChoiceSelection(data.choice_selection)
|
||||
const choiceSelection = backendSelection || buildChoiceSelection(choice, button)
|
||||
choiceSelection.selected_label =
|
||||
result.data?.feedback?.selected_label || choiceSelection.selected_label || button.label
|
||||
choiceSelection.selected_value =
|
||||
result.data?.feedback?.selected_value || choiceSelection.selected_value || agentMessage
|
||||
choiceSelection.selected_label = data.feedback?.selected_label || choiceSelection.selected_label || button.label
|
||||
choiceSelection.selected_value = data.feedback?.selected_value || choiceSelection.selected_value || agentMessage
|
||||
choiceSelection.selected_description =
|
||||
result.data?.display_message ||
|
||||
result.data?.feedback?.selected_description ||
|
||||
data.display_message ||
|
||||
data.feedback?.selected_description ||
|
||||
choiceSelection.selected_description ||
|
||||
getChoiceButtonSelectionText(button)
|
||||
|
||||
@@ -2223,9 +2209,11 @@ function stopGeneration() {
|
||||
}
|
||||
persistState()
|
||||
if (sessionId.value) {
|
||||
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
||||
method: 'POST',
|
||||
}).catch(() => {
|
||||
api
|
||||
.post<unknown>(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, undefined, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
.catch(() => {
|
||||
// 本地中止优先,停止接口失败不阻塞用户操作。
|
||||
})
|
||||
}
|
||||
@@ -2275,8 +2263,8 @@ async function deleteHistorySession(targetSessionId: string) {
|
||||
if (isBusy.value && targetSessionId === sessionId.value) return
|
||||
|
||||
try {
|
||||
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
||||
method: 'DELETE',
|
||||
await api.delete<null>(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
} catch (error) {
|
||||
// 删除接口失败时仍允许清理本地兜底历史,避免坏记录一直挡在列表里。
|
||||
@@ -2502,10 +2490,10 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-history-infinite"
|
||||
@load="handleHistoryInfiniteLoad"
|
||||
>
|
||||
<VVirtualScroll renderless :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
|
||||
<template #default="{ item: historySession, itemRef }">
|
||||
<VVirtualScroll :renderless="true" :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
|
||||
<template #default="{ item: historySession, ...slotProps }">
|
||||
<button
|
||||
:ref="itemRef"
|
||||
:ref="'itemRef' in slotProps ? slotProps.itemRef : undefined"
|
||||
:key="historySession.sessionId"
|
||||
class="agent-assistant-history-item"
|
||||
:class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
|
||||
|
||||
@@ -22,6 +22,48 @@ vi.mock('@/plugins/i18n', () => ({
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => {
|
||||
/** 与生产客户端一致,只接受 success/message/data 三个顶层字段。 */
|
||||
function isApiResponse(payload: unknown) {
|
||||
const keys = payload && typeof payload === 'object' && !Array.isArray(payload) ? Object.keys(payload) : []
|
||||
return (
|
||||
keys.length === 3 &&
|
||||
keys.every(key => key === 'success' || key === 'message' || key === 'data') &&
|
||||
typeof (payload as { success?: unknown }).success === 'boolean' &&
|
||||
typeof (payload as { message?: unknown }).message === 'string' &&
|
||||
Object.hasOwn(payload as object, 'data')
|
||||
)
|
||||
}
|
||||
|
||||
/** 让组件单测继续通过 fetch 控制网络,同时模拟生产 DataApiClient 的严格解包语义。 */
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`/api/v1/${path}`, init)
|
||||
const payload = await response.json()
|
||||
|
||||
if (!isApiResponse(payload)) throw new Error('Invalid API response envelope')
|
||||
if (!response.ok || !payload.success) throw new Error(payload.message || 'API request failed')
|
||||
return payload.data
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
delete: (path: string) => request(path, { method: 'DELETE' }),
|
||||
get: (path: string) => request(path),
|
||||
post: (path: string, data?: unknown) =>
|
||||
request(path, {
|
||||
method: 'POST',
|
||||
body: data instanceof FormData ? data : data === undefined ? undefined : JSON.stringify(data),
|
||||
}),
|
||||
put: (path: string, data?: unknown) =>
|
||||
request(path, {
|
||||
method: 'PUT',
|
||||
body: data === undefined ? undefined : JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
isApiResponse,
|
||||
}
|
||||
})
|
||||
|
||||
interface MockServerSession {
|
||||
session_id: string
|
||||
client_session_id: string
|
||||
@@ -44,11 +86,19 @@ const agentMarkdownContentStub = {
|
||||
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||
function createAgentResponse(data: unknown) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue({ success: true, data }),
|
||||
json: vi.fn().mockResolvedValue({ success: true, message: '', data }),
|
||||
ok: true,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
// 构造业务失败或协议异常响应。
|
||||
function createAgentEnvelopeResponse(payload: Record<string, unknown>, ok = true) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
ok,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function legacySseFrame(data: Record<string, unknown>): SyntheticSseFrame {
|
||||
return { data }
|
||||
}
|
||||
@@ -1052,6 +1102,41 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'uses the localized backend message for a standard failure envelope',
|
||||
{ success: false, message: '附件不受支持', data: null },
|
||||
'附件不受支持',
|
||||
],
|
||||
[
|
||||
'rejects a legacy envelope with an extra localized-message field',
|
||||
{ success: false, message: '标准错误', message_i18n: '旧字段错误', data: null },
|
||||
'Invalid API response envelope',
|
||||
],
|
||||
])('%s', async (_caseName, payload, expectedMessage) => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/upload') && init?.method === 'POST') {
|
||||
return createAgentEnvelopeResponse(payload)
|
||||
}
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = mountPanel()
|
||||
const fileInput = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
configurable: true,
|
||||
value: [new File(['proof'], 'proof.txt', { type: 'text/plain' })],
|
||||
})
|
||||
await fileInput.trigger('change')
|
||||
await wrapper.find('textarea').setValue('检查附件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(expectedMessage)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears protected delivery through Escape and the close button', async () => {
|
||||
const protectedMarker = 'MP-CLOSE-PROTECTED-MARKER'
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
+7
-22
@@ -4,7 +4,6 @@ import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type {
|
||||
ApiResponse,
|
||||
MusicRecognitionCacheData,
|
||||
MusicRecognitionCacheItem,
|
||||
RecognitionCacheData,
|
||||
@@ -148,26 +147,17 @@ async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const [response, musicResponse] = (await Promise.all([
|
||||
api.get(TMDB_CACHE_ENDPOINT),
|
||||
api.get(MUSIC_CACHE_ENDPOINT),
|
||||
])) as unknown as [ApiResponse<RecognitionCacheData>, ApiResponse<MusicRecognitionCacheData>]
|
||||
const [responseData, musicData] = await Promise.all([
|
||||
api.get<RecognitionCacheData>(TMDB_CACHE_ENDPOINT),
|
||||
api.get<MusicRecognitionCacheData>(MUSIC_CACHE_ENDPOINT),
|
||||
])
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
}
|
||||
cacheData.value = {
|
||||
...responseData,
|
||||
shared_recognized: responseData.shared_recognized ?? 0,
|
||||
shared_recognize_enabled: responseData.shared_recognize_enabled ?? false,
|
||||
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
|
||||
}
|
||||
const musicData = musicResponse.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
|
||||
musicCacheData.value = {
|
||||
...musicData,
|
||||
data: (musicData.data ?? []).map(item => ({ ...item })),
|
||||
@@ -207,10 +197,7 @@ async function clearAllCache() {
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const responses = (await Promise.all(
|
||||
clearTargets.map(endpoint => api.delete(endpoint)),
|
||||
)) as unknown as ApiResponse[]
|
||||
if (responses.some(item => !item.success)) throw new Error(responses.find(item => !item.success)?.message)
|
||||
await Promise.all(clearTargets.map(endpoint => api.delete<null>(endpoint, { feedback: 'silent' })))
|
||||
$toast.success(t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
@@ -225,14 +212,12 @@ async function clearAllCache() {
|
||||
|
||||
/** 请求接口删除指定影视识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(`${TMDB_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
await api.delete<null>(`${TMDB_CACHE_ENDPOINT}/${encodeURIComponent(key)}`, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 请求接口删除指定音乐识别缓存。 */
|
||||
async function deleteMusicCacheItem(key: string) {
|
||||
const response = (await api.delete(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
await api.delete<null>(`${MUSIC_CACHE_ENDPOINT}/${encodeURIComponent(key)}`, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 删除两个表格中选中的识别缓存。 */
|
||||
|
||||
@@ -10,9 +10,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: mocks.apiGet,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
@@ -122,13 +122,12 @@ async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
pendingAction.value = 'toggle'
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -142,10 +141,10 @@ async function deleteDownload() {
|
||||
|
||||
pendingAction.value = 'delete'
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
|
||||
await api.delete(`download/${props.info?.hash}`, {
|
||||
params: { name: props.downloaderName },
|
||||
})
|
||||
if (result.success) cardState.value = false
|
||||
cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
|
||||
@@ -176,8 +176,8 @@ async function querySites() {
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
const result = await api.get<{ value?: number[] }>('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -273,7 +273,8 @@ async function handleCheckExists() {
|
||||
try {
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const identity = getMediaSubscribeIdentity(props.media)
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
const result = await api.get<{ item?: { id?: string } }>('mediaserver/exists', {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
...(identity ? { media_source: identity.source, media_id: identity.mediaId } : {}),
|
||||
title: props.media?.title,
|
||||
@@ -283,7 +284,7 @@ async function handleCheckExists() {
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
return Boolean(result.item?.id)
|
||||
})
|
||||
|
||||
isExists.value = exists
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
@@ -153,7 +154,8 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -161,19 +163,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.installFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import type { Plugin, PluginRating } from '@/api/types'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
@@ -136,26 +137,17 @@ async function uninstallPlugin() {
|
||||
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
await api.delete(`plugin/${props.plugin?.id}`, { feedback: 'silent' })
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
@@ -222,25 +214,16 @@ async function resetPlugin() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
await api.get(`plugin/reset/${props.plugin?.id}`, { feedback: 'silent' })
|
||||
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
@@ -274,7 +257,8 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -282,7 +266,6 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
@@ -290,19 +273,11 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: result.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
@@ -479,26 +454,27 @@ async function executePluginClone(cloneForm: {
|
||||
try {
|
||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: ApiResponse<unknown> = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
await api.post(
|
||||
`plugin/clone/${props.plugin?.id}`,
|
||||
{
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
version: cloneForm.version.trim(),
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
const message = getApiBusinessErrorMessage(error)
|
||||
$toast.error(message ? t('plugin.cloneFailed', { message }) : t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isApiBusinessFailure } from '@/api/client'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -65,9 +66,8 @@ async function getSiteIcon() {
|
||||
|
||||
try {
|
||||
const icon = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
const response = await api.get<{ icon?: string }>(`site/icon/${siteId}`)
|
||||
return response?.icon || defaultSiteIcon
|
||||
})
|
||||
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
} catch (error) {
|
||||
@@ -82,13 +82,13 @@ async function testSite() {
|
||||
testButtonText.value = t('site.testing')
|
||||
testButtonDisable.value = true
|
||||
|
||||
const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
|
||||
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
|
||||
await api.get(`site/test/${cardProps.site?.id}`)
|
||||
$toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
|
||||
|
||||
// 测试完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
} catch (error) {
|
||||
if (isApiBusinessFailure(error)) emit('refresh-stats', cardProps.site?.domain)
|
||||
console.error(error)
|
||||
} finally {
|
||||
testButtonText.value = t('site.testConnectivity')
|
||||
@@ -169,9 +169,8 @@ async function deleteSiteInfo() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
|
||||
await api.delete(`site/${cardProps.site?.id}`, { feedback: 'silent' })
|
||||
emit('remove')
|
||||
} catch (error) {
|
||||
$toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: error }))
|
||||
console.error(error)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -226,12 +227,9 @@ function getBufferPercentage() {
|
||||
// 删除订阅
|
||||
async function removeSubscribe() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.media?.id}`)
|
||||
|
||||
if (result.success) {
|
||||
await api.delete(`subscribe/${props.media?.id}`, { feedback: 'silent' })
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
@@ -241,11 +239,8 @@ async function removeSubscribe() {
|
||||
// 搜索订阅
|
||||
async function searchSubscribe() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`subscribe/search/${props.media?.id}`)
|
||||
|
||||
// 提示
|
||||
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||
else $toast.error(t('subscribe.requestFailed'))
|
||||
await api.get(`subscribe/search/${props.media?.id}`, { feedback: 'silent' })
|
||||
$toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
@@ -254,9 +249,9 @@ async function searchSubscribe() {
|
||||
|
||||
// 切换订阅状态
|
||||
async function toggleSubscribeStatus(state: 'R' | 'S') {
|
||||
const action = state === 'S' ? t('common.pause') : t('common.enable')
|
||||
try {
|
||||
// 根据传入的 state 判断对应的操作文字
|
||||
const action = state === 'S' ? t('common.pause') : t('common.enable')
|
||||
// 弹出确认框
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirmAction', { action }),
|
||||
@@ -264,17 +259,13 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
// 调用 API 更新订阅状态
|
||||
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
await api.put(`subscribe/status/${props.media?.id}?state=${state}`, undefined, { feedback: 'silent' })
|
||||
$toast.success(t('subscribe.toggleSuccess', { name: props.media?.name, action }))
|
||||
subscribeState.value = state
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
|
||||
}
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
const message = getApiBusinessErrorMessage(e)
|
||||
$toast.error(message ? t('subscribe.toggleFailed', { action, message }) : t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -289,15 +280,15 @@ async function resetSubscribe() {
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
// 重置
|
||||
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
await api.get(`subscribe/reset/${props.media?.id}`, { feedback: 'silent' })
|
||||
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
|
||||
subscribeState.value = 'R'
|
||||
emit('save')
|
||||
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
const message = getApiBusinessErrorMessage(e)
|
||||
$toast.error(
|
||||
message ? t('subscribe.resetFailed', { name: props.media?.name, message }) : t('subscribe.requestFailed'),
|
||||
)
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,8 @@ async function getSiteIcon() {
|
||||
try {
|
||||
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
const response = await api.get<{ icon?: string }>(`site/icon/${subtitle.value?.site}`)
|
||||
return response?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
|
||||
@@ -43,9 +43,8 @@ async function getSiteIcon() {
|
||||
try {
|
||||
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
const response = await api.get<{ icon?: string }>(`site/icon/${subtitle.value?.site}`)
|
||||
return response?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
|
||||
@@ -56,9 +56,8 @@ async function getSiteIcon(site: number | undefined) {
|
||||
try {
|
||||
const icon = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
const response = await api.get<{ icon?: string }>(`site/icon/${site}`)
|
||||
return response?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
|
||||
@@ -41,9 +41,8 @@ async function getSiteIcon(site: number | undefined) {
|
||||
try {
|
||||
const icon = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
const response = await api.get<{ icon?: string }>(`site/icon/${site}`)
|
||||
return response?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
|
||||
@@ -96,13 +96,9 @@ async function removeUser() {
|
||||
content: t('user.confirmDeleteUser', { username: props.user?.name }),
|
||||
})
|
||||
if (!isConfirmed) return
|
||||
const result: Record<string, unknown> = await api.delete(`user/id/${props.user.id}`)
|
||||
if (result.success) {
|
||||
await api.delete(`user/id/${props.user.id}`, { feedback: 'silent' })
|
||||
$toast.success(t('user.deleteSuccess'))
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(t('user.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('user.deleteFailed'))
|
||||
console.log(error)
|
||||
|
||||
@@ -87,13 +87,9 @@ async function handleDelete(item: Workflow) {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
|
||||
if (result.success) {
|
||||
await api.delete(`workflow/${item.id}`)
|
||||
$toast.success(t('workflow.task.deleteSuccess'))
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(t('workflow.task.deleteFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -103,13 +99,9 @@ async function handleDelete(item: Workflow) {
|
||||
async function handleEnable(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
|
||||
if (result.success) {
|
||||
await api.post(`workflow/${item.id}/start`)
|
||||
$toast.success(t('workflow.task.enableSuccess'))
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(t('workflow.task.enableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -120,13 +112,9 @@ async function handleEnable(item: Workflow) {
|
||||
async function handlePause(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
|
||||
if (result.success) {
|
||||
await api.post(`workflow/${item.id}/pause`)
|
||||
$toast.success(t('workflow.task.pauseSuccess'))
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(t('workflow.task.pauseFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -140,16 +128,11 @@ async function handleRun(item: Workflow, from_begin: boolean) {
|
||||
setTimeout(() => {
|
||||
emit('refresh')
|
||||
}, 500)
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {
|
||||
await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {
|
||||
from_begin,
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success(t('workflow.task.runSuccess'))
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(t('workflow.task.runFailed', { message: result.message }))
|
||||
emit('refresh')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -166,13 +149,9 @@ async function handleReset(item: Workflow) {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
|
||||
if (result.success) {
|
||||
await api.post(`workflow/${item.id}/reset`)
|
||||
$toast.success(t('workflow.task.resetSuccess'))
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(t('workflow.task.resetFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
default: createDataApiMock({ get: mocks.apiGet }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
|
||||
@@ -20,11 +20,11 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
delete: mocks.apiDelete,
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
|
||||
@@ -15,10 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
delete: (...args: unknown[]) => mocks.apiDelete(...args),
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({ useConfirm: () => mocks.confirm }))
|
||||
|
||||
@@ -234,9 +234,8 @@ async function queryVersionStatistic() {
|
||||
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
|
||||
versionStatisticLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/usage/statistic')
|
||||
|
||||
versionStatistic.value = result.data ?? {}
|
||||
const statistic = await api.get<{ [key: string]: any } | null>('system/usage/statistic')
|
||||
versionStatistic.value = statistic ?? {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
versionStatistic.value = {}
|
||||
@@ -254,9 +253,7 @@ async function showVersionStatisticDialog() {
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
systemEnv.value = (await api.get<{ [key: string]: any } | null>('system/env')) ?? {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -276,9 +273,7 @@ async function querySystemUptime() {
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
allRelease.value = (await api.get<any[] | null>('system/versions')) ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DownloaderConf,
|
||||
MediaDataSource,
|
||||
MediaInfo,
|
||||
@@ -144,11 +143,8 @@ const dialogSubtitle = computed(() => {
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
||||
directories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -226,29 +222,25 @@ async function addDownload() {
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(endpoint, payload)
|
||||
await api.post<null>(endpoint, payload, { feedback: 'silent' })
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(
|
||||
t('dialog.addDownload.downloadSuccess', { site: props.torrent?.site_name, title: props.torrent?.title }),
|
||||
)
|
||||
// 下载成功,返回链接
|
||||
emit('done', props.torrent?.enclosure)
|
||||
} else {
|
||||
// 添加下载失败
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$toast.error(
|
||||
t('dialog.addDownload.downloadFailed', {
|
||||
site: props.torrent?.site_name,
|
||||
title: props.torrent?.title,
|
||||
message: result?.message,
|
||||
message,
|
||||
}),
|
||||
)
|
||||
// 下载失败,返回错误原因
|
||||
emit('error', result?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
emit('error', message)
|
||||
}
|
||||
loading.value = false
|
||||
doneNProgress()
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import {
|
||||
MediaSource,
|
||||
type ApiResponse,
|
||||
type MediaDataSource,
|
||||
type SubtitleInfo,
|
||||
type TransferDirectoryConf,
|
||||
} from '@/api/types'
|
||||
import { MediaSource, type MediaDataSource, type SubtitleInfo, type TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
@@ -114,11 +108,8 @@ const buttonText = computed(() =>
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
||||
directories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -162,9 +153,8 @@ async function addSubtitleDownload() {
|
||||
media_id: normalizedMediaId.value,
|
||||
}
|
||||
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('download/subtitle', payload)
|
||||
await api.post<null>('download/subtitle', payload, { feedback: 'silent' })
|
||||
|
||||
if (result && result.success) {
|
||||
$toast.success(
|
||||
t('dialog.addSubtitleDownload.downloadSuccess', {
|
||||
site: props.subtitle?.site_name,
|
||||
@@ -172,19 +162,17 @@ async function addSubtitleDownload() {
|
||||
}),
|
||||
)
|
||||
emit('done', props.subtitle?.enclosure)
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$toast.error(
|
||||
t('dialog.addSubtitleDownload.downloadFailed', {
|
||||
site: props.subtitle?.site_name,
|
||||
title: props.subtitle?.title,
|
||||
message: result?.message,
|
||||
message,
|
||||
}),
|
||||
)
|
||||
emit('error', result?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
emit('error', String(error))
|
||||
emit('error', message)
|
||||
}
|
||||
loading.value = false
|
||||
doneNProgress()
|
||||
|
||||
@@ -41,6 +41,11 @@ interface AgentMcpTestState {
|
||||
tools?: AgentMcpToolInfo[]
|
||||
}
|
||||
|
||||
interface AgentMcpTestResult {
|
||||
message?: string
|
||||
tools?: AgentMcpToolInfo[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
servers: AgentMcpServer[]
|
||||
@@ -169,12 +174,16 @@ async function testServer(server: EditableAgentMcpServer) {
|
||||
const payload = toServerPayload(server)
|
||||
testStates.value[payload.id] = { loading: true }
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers/test', { server: payload })
|
||||
const result = await api.post<AgentMcpTestResult>(
|
||||
'message/agent/mcp/servers/test',
|
||||
{ server: payload },
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
testStates.value[payload.id] = {
|
||||
loading: false,
|
||||
success: Boolean(result.success),
|
||||
message: result.message || result.data?.message || '',
|
||||
tools: result.data?.tools || [],
|
||||
success: true,
|
||||
message: result.message || '',
|
||||
tools: result.tools || [],
|
||||
}
|
||||
} catch (error) {
|
||||
testStates.value[payload.id] = {
|
||||
@@ -190,14 +199,10 @@ async function saveServers() {
|
||||
saving.value = true
|
||||
try {
|
||||
const servers = localServers.value.map(toServerPayload)
|
||||
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers', { servers })
|
||||
if (result.success) {
|
||||
await api.post<null>('message/agent/mcp/servers', { servers }, { feedback: 'silent' })
|
||||
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
|
||||
emit('saved', servers)
|
||||
dialogVisible.value = false
|
||||
return
|
||||
}
|
||||
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
|
||||
@@ -33,11 +33,9 @@ async function handleDone() {
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`/storage/reset/${props.type}`)
|
||||
if (result.success) {
|
||||
await api.get(`/storage/reset/${props.type}`)
|
||||
// 重置成功
|
||||
handleDone()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@@ -41,25 +41,21 @@ async function handleDone() {
|
||||
// 调用/aliyun/qrcode api生成二维码
|
||||
async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
|
||||
if (result.success && result.data) {
|
||||
qrCodeUrl.value = result.data.codeUrl
|
||||
const result = await api.get<{ codeUrl: string }>('/storage/qrcode/alipan', { feedback: 'silent' })
|
||||
qrCodeUrl.value = result.codeUrl
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
text.value = e instanceof Error ? e.message : t('common.apiRequestFailed')
|
||||
}
|
||||
}
|
||||
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
|
||||
if (result.success && result.data) {
|
||||
const qrCodeStatus = result.data.status
|
||||
text.value = result.data.tip
|
||||
const result = await api.get<{ status: string; tip: string }>('/storage/check/alipan', { feedback: 'silent' })
|
||||
const qrCodeStatus = result.status
|
||||
text.value = result.tip
|
||||
if (qrCodeStatus == 'LoginSuccess') {
|
||||
// 登录成功
|
||||
alertType.value = 'success'
|
||||
@@ -73,30 +69,24 @@ async function checkQrcode() {
|
||||
// 二维码过期
|
||||
alertType.value = 'error'
|
||||
}
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alertType.value = 'error'
|
||||
text.value = e instanceof Error ? e.message : t('common.apiRequestFailed')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/alipan')
|
||||
console.log(result.success)
|
||||
if (result.success) {
|
||||
await api.get<null>('/storage/reset/alipan', { feedback: 'silent' })
|
||||
// 重置成功
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alertType.value = 'error'
|
||||
text.value = e instanceof Error ? e.message : t('common.apiRequestFailed')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,10 +143,8 @@ const countryOptions = [
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
const config = await api.get<CategoryConfig | null>('media/category/config', { feedback: 'silent' })
|
||||
if (config) parseConfig(config)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
@@ -334,17 +332,13 @@ const saveConfig = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
await api.post<null>('media/category/config', payload, { feedback: 'silent' })
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
toast.error(t('setting.category.saveFailed', { message: e instanceof Error ? e.message : t('common.error') }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -58,12 +58,8 @@ async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok')
|
||||
/** 删除指定下载历史,并在成功后同步移除当前列表项。 */
|
||||
async function deleteHistory(item: DownloadHistory) {
|
||||
try {
|
||||
const result: { success?: boolean } = await api.delete('history/download', { data: item })
|
||||
if (result.success) {
|
||||
await api.delete('history/download', { data: item, feedback: 'silent' })
|
||||
historyList.value = historyList.value.filter(history => history.id !== item.id)
|
||||
return
|
||||
}
|
||||
$toast.error(t('dialog.downloadHistory.deleteFailed'))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('dialog.downloadHistory.deleteFailed'))
|
||||
@@ -124,9 +120,9 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
<template #empty />
|
||||
|
||||
<VList lines="three" class="download-history-dialog__content py-0">
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="120">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VVirtualScroll v-if="historyList.length > 0" :renderless="true" :items="historyList" :item-height="120">
|
||||
<template #default="{ item, ...slotProps }">
|
||||
<div :ref="'itemRef' in slotProps ? slotProps.itemRef : undefined">
|
||||
<VListItem class="download-history-item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
|
||||
@@ -473,7 +473,7 @@ onMounted(() => {
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
@update:model-value="(v: string) => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -51,8 +52,8 @@ function toggleExpand() {
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
const result = await api.get<{ value?: string[] }>('system/setting/public/FollowSubscribers')
|
||||
followUsers.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
@@ -62,10 +63,8 @@ async function queryFollowUsers() {
|
||||
// follow用户
|
||||
async function followUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
|
||||
if (result.success) {
|
||||
await api.post<null>(`subscribe/follow?share_uid=${props.media?.share_uid}`, undefined, { feedback: 'silent' })
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
@@ -75,14 +74,13 @@ async function followUser() {
|
||||
// unfollow用户
|
||||
async function unfollowUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
|
||||
await api.delete<null>('subscribe/follow', {
|
||||
params: {
|
||||
share_uid: props.media?.share_uid,
|
||||
},
|
||||
feedback: 'silent',
|
||||
})
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
@@ -124,21 +122,16 @@ async function doFork() {
|
||||
try {
|
||||
processing.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
const result = await api.post<{ id: number }>('subscribe/fork', props.media, { feedback: 'silent' })
|
||||
$toast.success(t('subscribe.addSuccess', { name: props.media?.share_title }))
|
||||
// 完成
|
||||
emit('fork', result.data.id)
|
||||
} else {
|
||||
$toast.error(t('subscribe.addFailed', { name: props.media?.share_title, message: result.message }))
|
||||
}
|
||||
emit('fork', result.id)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
t('subscribe.addFailed', {
|
||||
name: props.media?.share_title,
|
||||
message: t('subscribe.requestFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
@@ -154,22 +147,22 @@ async function doDelete() {
|
||||
try {
|
||||
deleting.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/share/${props.media?.id}`, {
|
||||
await api.delete<null>(`subscribe/share/${props.media?.id}`, {
|
||||
params: {
|
||||
share_uid: globalSettings.USER_UNIQUE_ID,
|
||||
},
|
||||
feedback: 'silent',
|
||||
})
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
$toast.success(t('subscribe.cancelSuccess'))
|
||||
// 完成
|
||||
emit('delete')
|
||||
} else {
|
||||
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.cancelFailed', { message: t('subscribe.requestFailed') }))
|
||||
$toast.error(
|
||||
t('subscribe.cancelFailed', {
|
||||
message: getApiBusinessErrorMessage(error) || t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
doneNProgress()
|
||||
|
||||
@@ -78,17 +78,18 @@ async function doFork() {
|
||||
try {
|
||||
processing.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('workflow/fork', props.workflow)
|
||||
// 工作流状态
|
||||
if (result.success) {
|
||||
const result = await api.post<{ id: string }>('workflow/fork', props.workflow, { feedback: 'silent' })
|
||||
$toast.success(t('workflow.addSuccess', { name: props.workflow?.share_title }))
|
||||
// 完成
|
||||
emit('fork', result.data.id)
|
||||
} else {
|
||||
$toast.error(t('workflow.addFailed', { name: props.workflow?.share_title, message: result.message }))
|
||||
}
|
||||
emit('fork', result.id)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
t('workflow.addFailed', {
|
||||
name: props.workflow?.share_title,
|
||||
message: error instanceof Error ? error.message : '',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
processing.value = false
|
||||
doneNProgress()
|
||||
@@ -102,21 +103,18 @@ async function doDelete() {
|
||||
try {
|
||||
deleting.value = true
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.delete(`workflow/share/${props.workflow?.id}`, {
|
||||
const result = await api.delete<{ id: string }>(`workflow/share/${props.workflow?.id}`, {
|
||||
params: {
|
||||
share_uid: globalSettings.USER_UNIQUE_ID,
|
||||
},
|
||||
feedback: 'silent',
|
||||
})
|
||||
// 工作流状态
|
||||
if (result.success) {
|
||||
$toast.success(t('workflow.cancelSuccess'))
|
||||
// 完成
|
||||
emit('delete', result.data.id)
|
||||
} else {
|
||||
$toast.error(t('workflow.cancelFailed', { message: result.message }))
|
||||
}
|
||||
emit('delete', result.id)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('workflow.cancelFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
doneNProgress()
|
||||
|
||||
@@ -258,23 +258,21 @@ function scheduleWechatClawBotRefresh() {
|
||||
const connected = wechatClawBotStatus.value?.connected
|
||||
const pendingStatus = ['waiting', 'scanned'].includes((wechatClawBotStatus.value?.qrcode_status || '').toLowerCase())
|
||||
if (connected || pendingStatus) {
|
||||
wechatClawBotTimer = window.setTimeout(() => {
|
||||
wechatClawBotTimer = window.setTimeout(
|
||||
() => {
|
||||
fetchWechatClawBotStatus({
|
||||
silent: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
}, connected ? 10000 : 3000)
|
||||
},
|
||||
connected ? 10000 : 3000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询微信客服登录状态和二维码信息。 */
|
||||
async function fetchWechatClawBotStatus(options: WechatClawBotStatusFetchOptions = {}) {
|
||||
const {
|
||||
autoGenerateQrcode = false,
|
||||
silent = false,
|
||||
autoRefreshExpired = false,
|
||||
showErrorToast = true,
|
||||
} = options
|
||||
const { autoGenerateQrcode = false, silent = false, autoRefreshExpired = false, showErrorToast = true } = options
|
||||
if (notificationInfo.value.type !== 'wechatclawbot' || !notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
@@ -282,19 +280,19 @@ async function fetchWechatClawBotStatus(options: WechatClawBotStatusFetchOptions
|
||||
wechatClawBotLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('notification/wechatclawbot/status', {
|
||||
const result = await api.get<WechatClawBotStatus>('notification/wechatclawbot/status', {
|
||||
params: getWechatClawBotRequestParams({ auto_generate_qrcode: autoGenerateQrcode }),
|
||||
feedback: 'silent',
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
const status = (result.data?.qrcode_status || '').toLowerCase()
|
||||
wechatClawBotStatus.value = result
|
||||
await updateWechatClawBotQrImage(result)
|
||||
const status = (result.qrcode_status || '').toLowerCase()
|
||||
if (status !== 'expired') {
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
}
|
||||
if (
|
||||
autoRefreshExpired &&
|
||||
!result.data?.connected &&
|
||||
!result.connected &&
|
||||
status === 'expired' &&
|
||||
!wechatClawBotExpiredRefreshAttempted.value
|
||||
) {
|
||||
@@ -306,19 +304,13 @@ async function fetchWechatClawBotStatus(options: WechatClawBotStatusFetchOptions
|
||||
return
|
||||
}
|
||||
scheduleWechatClawBotRefresh()
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
wechatClawBotStatus.value = null
|
||||
wechatClawBotQrImage.value = ''
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(t('notification.wechatclawbot.statusLoadFailed'))
|
||||
$toast.error(error instanceof Error ? error.message : t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
@@ -337,26 +329,21 @@ async function refreshWechatClawBotQrcode(options: WechatClawBotRefreshOptions =
|
||||
wechatClawBotActionLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/refresh', null, {
|
||||
const result = await api.post<WechatClawBotStatus>('notification/wechatclawbot/refresh', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
feedback: 'silent',
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
wechatClawBotStatus.value = result
|
||||
await updateWechatClawBotQrImage(result)
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
scheduleWechatClawBotRefresh()
|
||||
if (showToast) {
|
||||
$toast.success(t('notification.wechatclawbot.qrcodeRefreshSuccess'))
|
||||
}
|
||||
} else {
|
||||
if (showToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (showToast) {
|
||||
$toast.error(t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
$toast.error(error instanceof Error ? error.message : t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
@@ -372,21 +359,18 @@ async function logoutWechatClawBot() {
|
||||
}
|
||||
wechatClawBotActionLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/logout', null, {
|
||||
await api.post<null>('notification/wechatclawbot/logout', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
feedback: 'silent',
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success(result.message || t('notification.wechatclawbot.logoutSuccess'))
|
||||
$toast.success(t('notification.wechatclawbot.logoutSuccess'))
|
||||
await fetchWechatClawBotStatus({
|
||||
autoGenerateQrcode: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
} else {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.logoutFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('notification.wechatclawbot.logoutFailed'))
|
||||
$toast.error(error instanceof Error ? error.message : t('notification.wechatclawbot.logoutFailed'))
|
||||
} finally {
|
||||
wechatClawBotActionLoading.value = false
|
||||
}
|
||||
@@ -425,7 +409,6 @@ watch(notificationInfoDialog, value => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
openNotificationInfoDialog()
|
||||
})
|
||||
@@ -659,7 +642,9 @@ onMounted(() => {
|
||||
<VCard variant="tonal" class="pa-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ t('notification.wechatclawbot.loginStatus') }}</div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ t('notification.wechatclawbot.loginStatus') }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">{{ wechatClawBotStatusText }}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@@ -1173,13 +1158,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveNotificationInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
<VBtn color="primary" variant="flat" @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -4,7 +4,6 @@ import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
@@ -77,14 +76,14 @@ async function getOtpUri() {
|
||||
// 未启用OTP,生成新的二维码
|
||||
otpLoading.value = true
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
const result = await api.post<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
const uri = result.data?.uri?.trim()
|
||||
const otpSecret = result.data?.secret?.trim()
|
||||
}>('mfa/otp/generate', undefined, { feedback: 'silent' })
|
||||
const uri = result.uri?.trim()
|
||||
const otpSecret = result.secret?.trim()
|
||||
|
||||
if (result.success && uri && otpSecret) {
|
||||
if (uri && otpSecret) {
|
||||
const image = await QRCode.toDataURL(uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
@@ -96,7 +95,7 @@ async function getOtpUri() {
|
||||
qrCodeImage.value = image
|
||||
} else {
|
||||
if (generation !== otpGeneration || !props.modelValue) return
|
||||
setOtpGenerateError(result.message || 'empty otp uri')
|
||||
setOtpGenerateError('empty otp uri')
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== otpGeneration || !props.modelValue) return
|
||||
@@ -114,18 +113,10 @@ async function judgeOtpPassword() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
await api.post('mfa/otp/verify', { uri: otpUri.value, otpPassword: otpPassword.value }, { feedback: 'silent' })
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
@@ -139,16 +130,10 @@ function disableOtp() {
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
await api.post('mfa/otp/disable', { password }, { feedback: 'silent' })
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import type { PassKey } from '@/api/types'
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
interface Props {
|
||||
@@ -69,14 +70,10 @@ async function fetchPassKeyList() {
|
||||
passkeyListLoading.value = true
|
||||
passkeyListFailed.value = false
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
const result = await api.get<PassKey[]>('mfa/passkey/list')
|
||||
if (generation !== passkeyListGeneration || !props.modelValue) return
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
passkeyList.value = result || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
} else {
|
||||
passkeyListFailed.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== passkeyListGeneration || !props.modelValue) return
|
||||
passkeyListFailed.value = true
|
||||
@@ -111,24 +108,20 @@ async function registerPassKey() {
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post(
|
||||
const startResult = await api.post<{ options: string; transaction_token: string }>(
|
||||
'mfa/passkey/register/start',
|
||||
{
|
||||
name: registrationName,
|
||||
},
|
||||
{
|
||||
signal: registrationAbortController.signal,
|
||||
feedback: 'silent',
|
||||
},
|
||||
)) as ApiResponse<{ options: string; transaction_token: string }>
|
||||
)
|
||||
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, transaction_token: transactionToken } = startResult.data
|
||||
const { options, transaction_token: transactionToken } = startResult
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
@@ -169,7 +162,7 @@ async function registerPassKey() {
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post(
|
||||
await api.post(
|
||||
'mfa/passkey/register/finish',
|
||||
{
|
||||
credential: credentialJSON,
|
||||
@@ -178,18 +171,15 @@ async function registerPassKey() {
|
||||
},
|
||||
{
|
||||
signal: registrationAbortController.signal,
|
||||
feedback: 'silent',
|
||||
},
|
||||
)) as ApiResponse
|
||||
)
|
||||
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
|
||||
if (finishResult.success) {
|
||||
$toast.success(t('profile.passkeyRegisterSuccess'))
|
||||
passkeyName.value = ''
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== passkeyRegistrationGeneration || !props.modelValue) return
|
||||
console.error('PassKey注册失败:', error)
|
||||
@@ -219,19 +209,12 @@ async function deletePassKey(passkeyId: number) {
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
await api.post('mfa/passkey/delete', { passkey_id: passkeyId, password }, { feedback: 'silent' })
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
$toast.error(getApiBusinessErrorMessage(error) || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin, RenderProps } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import api, { pluginApi } from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
@@ -163,19 +163,12 @@ async function savePluginConf() {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })
|
||||
try {
|
||||
const result = (await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)) as {
|
||||
message?: string
|
||||
success: boolean
|
||||
}
|
||||
if (result.success) {
|
||||
await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 导航声明可能由插件配置控制;刷新失败不改变已经成功的配置保存结果。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true).catch(error => console.error(error))
|
||||
} else {
|
||||
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -238,7 +231,7 @@ onBeforeMount(async () => {
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="api"
|
||||
:api="pluginApi"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin, RenderProps } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
import api, { pluginApi } from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -174,7 +174,7 @@ onMounted(() => {
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:api="api"
|
||||
:api="pluginApi"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
:show_switch="show_switch"
|
||||
@action="handleAction"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
||||
import type { Plugin, PluginRating } from '@/api/types'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
@@ -141,15 +142,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
feedback: 'silent',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(
|
||||
isInstalled.value
|
||||
? t('plugin.updateSuccess', { name: props.plugin?.plugin_name })
|
||||
@@ -159,14 +160,11 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t(failureMessageKey, { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t(failureMessageKey, {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
@@ -214,20 +212,24 @@ async function submitPluginRating() {
|
||||
|
||||
ratingSubmitting.value = true
|
||||
try {
|
||||
const result: ApiResponse<PluginRating> = await api.post(`plugin/rating/${props.plugin.id}`, {
|
||||
rating: selectedRating.value,
|
||||
})
|
||||
if (result.success) {
|
||||
rating.value = result.data
|
||||
selectedRating.value = result.data.user_rating || selectedRating.value
|
||||
emit('rating', result.data)
|
||||
const result = await api.post<PluginRating>(
|
||||
`plugin/rating/${props.plugin.id}`,
|
||||
{ rating: selectedRating.value },
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
rating.value = result
|
||||
selectedRating.value = result.user_rating || selectedRating.value
|
||||
emit('rating', result)
|
||||
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
|
||||
} else {
|
||||
$toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('plugin.ratingFailed', { message: t('common.serverConnectionFailed') }))
|
||||
const businessMessage = getApiBusinessErrorMessage(error)
|
||||
$toast.error(
|
||||
t('plugin.ratingFailed', {
|
||||
message:
|
||||
businessMessage || (isApiBusinessFailure(error) ? t('common.unknown') : t('common.serverConnectionFailed')),
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
ratingSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -136,12 +135,11 @@ async function queryMarketRepoSetting() {
|
||||
loadReposFailed.value = false
|
||||
|
||||
try {
|
||||
const result = (await api.get('system/setting/public/PLUGIN_MARKET')) as unknown as ApiResponse<{
|
||||
const result = await api.get<{
|
||||
value?: string
|
||||
}>
|
||||
if (!result.success) throw new Error(result.message || '')
|
||||
}>('system/setting/public/PLUGIN_MARKET')
|
||||
|
||||
repoList.value = parseRepoInput(result.data?.value || '').repos
|
||||
repoList.value = parseRepoInput(result.value || '').repos
|
||||
syncTextFromList()
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -161,12 +159,9 @@ async function saveHandle() {
|
||||
|
||||
saving.value = true
|
||||
const repoStringToSave = reposToSave.join(',')
|
||||
const result = (await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)) as unknown as ApiResponse<unknown>
|
||||
|
||||
if (result.success) {
|
||||
await api.post('system/setting/PLUGIN_MARKET', repoStringToSave, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
|
||||
emit('save')
|
||||
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
@@ -179,29 +174,23 @@ async function saveHandle() {
|
||||
async function syncPluginSources() {
|
||||
try {
|
||||
syncingSources.value = true
|
||||
const result = (await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})) as unknown as ApiResponse<{
|
||||
const result = await api.post<{
|
||||
added_count?: number
|
||||
repos?: string[]
|
||||
total_count?: number
|
||||
value?: string
|
||||
}>
|
||||
}>('system/setting/PLUGIN_MARKET/sync-wiki', {}, { feedback: 'silent' })
|
||||
|
||||
if (result.success) {
|
||||
const repos = Array.isArray(result.data?.repos)
|
||||
? result.data.repos
|
||||
: parseRepoInput(result.data?.value || '').repos
|
||||
const repos = Array.isArray(result.repos) ? result.repos : parseRepoInput(result.value || '').repos
|
||||
repoList.value = repos
|
||||
syncTextFromList()
|
||||
emit('changed')
|
||||
$toast.success(
|
||||
t('dialog.pluginMarketSetting.syncSuccess', {
|
||||
added: result.data?.added_count ?? 0,
|
||||
total: result.data?.total_count ?? repos.length,
|
||||
added: result.added_count ?? 0,
|
||||
total: result.total_count ?? repos.length,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
|
||||
@@ -47,10 +47,8 @@ async function savaRcloneConfig() {
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/rclone')
|
||||
if (result.success) {
|
||||
await api.get('/storage/reset/rclone')
|
||||
handleDone()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import CryptoJS from 'crypto-js'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage, isApiBusinessFailure } from '@/api/client'
|
||||
import { transferTypeOptions } from '@/api/constants'
|
||||
import {
|
||||
ApiResponse,
|
||||
FileItem,
|
||||
ManualTransferHistoryInfo,
|
||||
ManualTransferPayload,
|
||||
@@ -208,7 +208,7 @@ async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
storages.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -380,7 +380,7 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
directories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -967,8 +967,16 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
||||
async function requestManualTransfer<T = unknown>(
|
||||
payload: ManualTransferPayload,
|
||||
background: boolean = false,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return await api.post<ApiResponse<T>, ApiResponse<T>>(`transfer/manual?background=${background}`, payload)
|
||||
): Promise<T> {
|
||||
return await api.post<T>(`transfer/manual?background=${background}`, payload, { feedback: 'silent' })
|
||||
}
|
||||
|
||||
/** 保留业务失败详情,并在空消息时使用当前操作的本地提示。 */
|
||||
function getManualTransferErrorMessage(error: unknown, fallback: string) {
|
||||
const businessMessage = getApiBusinessErrorMessage(error)
|
||||
if (businessMessage) return businessMessage
|
||||
if (isApiBusinessFailure(error)) return fallback
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
// 查询当前文件或目录是否存在成功整理历史,决定是否展示重新整理语义。
|
||||
@@ -979,14 +987,12 @@ async function loadManualTransferHistory() {
|
||||
try {
|
||||
const payload =
|
||||
normalizedItems.value.length === 1 ? { fileitem: normalizedItems.value[0] } : { fileitems: normalizedItems.value }
|
||||
const result = await api.post<ApiResponse<ManualTransferHistoryInfo>, ApiResponse<ManualTransferHistoryInfo>>(
|
||||
'transfer/manual/history',
|
||||
payload,
|
||||
)
|
||||
if (!result.success) return
|
||||
const result = await api.post<ManualTransferHistoryInfo>('transfer/manual/history', payload, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
|
||||
manualHistoryCount.value = result.data?.history_count ?? 0
|
||||
transferForm.reorganize = Boolean(result.data?.reorganize)
|
||||
manualHistoryCount.value = result.history_count ?? 0
|
||||
transferForm.reorganize = Boolean(result.reorganize)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -997,8 +1003,8 @@ async function loadManualTransferHistory() {
|
||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||
async function loadEpisodeFormatRuleConfiguration() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
|
||||
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||
const result = await api.get<{ value?: unknown[] }>('system/setting/public/EpisodeFormatRuleTable')
|
||||
episodeFormatRuleConfigured.value = Boolean(result.value?.length)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
episodeFormatRuleConfigured.value = undefined
|
||||
@@ -1028,7 +1034,7 @@ async function handleRecommendEpisodeFormat() {
|
||||
|
||||
try {
|
||||
const hasExistingEpisodeFormat = Boolean(transferForm.episode_format?.trim())
|
||||
const result = await api.post<ApiResponse<EpisodeFormatRecommendData>, ApiResponse<EpisodeFormatRecommendData>>(
|
||||
const data = await api.post<EpisodeFormatRecommendData>(
|
||||
'transfer/episode-format/recommend',
|
||||
hasValidSelectedFiles
|
||||
? {
|
||||
@@ -1037,14 +1043,9 @@ async function handleRecommendEpisodeFormat() {
|
||||
: {
|
||||
fileitem: sourceItem,
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('dialog.reorganize.episodeFormatRecommendFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const data = result.data ?? {}
|
||||
if (!data.episode_format) {
|
||||
$toast.error(t('dialog.reorganize.episodeFormatRecommendFailed'))
|
||||
return
|
||||
@@ -1155,19 +1156,6 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
|
||||
}
|
||||
}
|
||||
|
||||
// 从标准响应中提取可展示的整理预览数据,优先保留顶层本地化消息。
|
||||
function resolvePreviewResponseData(result: ApiResponse<ManualTransferPreviewData>) {
|
||||
if (!result.data) return result.data
|
||||
|
||||
const message = result.message_i18n || result.message || result.data.message
|
||||
if (!message || message === result.data.message) return result.data
|
||||
|
||||
return {
|
||||
...result.data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
// 预览整理结果
|
||||
async function previewTransfer() {
|
||||
if (!props.logids?.length && !normalizedItems.value.length) return
|
||||
@@ -1186,24 +1174,15 @@ async function previewTransfer() {
|
||||
const result = await requestManualTransfer<ManualTransferPreviewData>(
|
||||
createTransferPayload({ items: normalizedItems.value, preview: true }),
|
||||
)
|
||||
if (!result.success) {
|
||||
mergePreviewData(mergedPreviewData, result)
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = getManualTransferErrorMessage(err, t('dialog.reorganize.previewRequestFailed'))
|
||||
console.warn(`预览请求异常: ${errorMessage}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: getBatchItemsLabel(normalizedItems.value),
|
||||
message: result.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: getBatchItemsLabel(normalizedItems.value),
|
||||
message: `${getBatchItemsLabel(normalizedItems.value)}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
|
||||
message: errorMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1214,29 +1193,17 @@ async function previewTransfer() {
|
||||
const result = await requestManualTransfer<ManualTransferPreviewData>(
|
||||
createTransferPayload({ item, preview: true }),
|
||||
)
|
||||
if (!result.success) {
|
||||
mergePreviewData(mergedPreviewData, result)
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = getManualTransferErrorMessage(err, t('dialog.reorganize.previewRequestFailed'))
|
||||
console.warn(`预览请求异常: ${errorMessage}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: item.path || item.name,
|
||||
type: item.type,
|
||||
title: item.name,
|
||||
message: result.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: item.path || item.name,
|
||||
type: item.type,
|
||||
title: item.name,
|
||||
message: `${item.name || item.path}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
|
||||
message: errorMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1252,25 +1219,15 @@ async function previewTransfer() {
|
||||
const result = await requestManualTransfer<ManualTransferPreviewData>(
|
||||
createTransferPayload({ logid, preview: true }),
|
||||
)
|
||||
if (!result.success) {
|
||||
mergePreviewData(mergedPreviewData, result)
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = getManualTransferErrorMessage(err, t('dialog.reorganize.previewRequestFailed'))
|
||||
console.warn(`预览请求异常: ${errorMessage}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: `历史记录 ${logid}`,
|
||||
message: result.message || t('dialog.reorganize.previewRequestFailed'),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
mergedPreviewData,
|
||||
createFailedPreviewData({
|
||||
source: `历史记录 ${logid}`,
|
||||
message: `历史记录 ${logid}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
|
||||
message: errorMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1286,10 +1243,10 @@ async function previewTransfer() {
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
previewVisible.value = false
|
||||
resetPreviewState()
|
||||
$toast.error(error?.message || t('dialog.reorganize.previewRequestFailed'))
|
||||
$toast.error(getManualTransferErrorMessage(error, t('dialog.reorganize.previewRequestFailed')))
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
@@ -1311,16 +1268,12 @@ async function togglePreview() {
|
||||
// 整理文件
|
||||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||
try {
|
||||
const result = await requestManualTransfer(createTransferPayload({ item }), background)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('dialog.reorganize.transferRequestFailed'))
|
||||
return false
|
||||
}
|
||||
await requestManualTransfer<null>(createTransferPayload({ item }), background)
|
||||
if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name }))
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
console.log(error)
|
||||
if (error instanceof Error) $toast.error(error.message)
|
||||
$toast.error(getManualTransferErrorMessage(error, t('dialog.reorganize.transferRequestFailed')))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1328,16 +1281,12 @@ async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||
// 批量整理文件并按后台模式决定是否提示入队成功。
|
||||
async function handleTransferBatch(items: FileItem[], background: boolean = false) {
|
||||
try {
|
||||
const result = await requestManualTransfer(createTransferPayload({ items }), background)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('dialog.reorganize.transferRequestFailed'))
|
||||
return false
|
||||
}
|
||||
await requestManualTransfer<null>(createTransferPayload({ items }), background)
|
||||
if (background) $toast.success(t('dialog.reorganize.successMessage', { name: getBatchItemsLabel(items) }))
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
console.log(error)
|
||||
if (error instanceof Error) $toast.error(error.message)
|
||||
$toast.error(getManualTransferErrorMessage(error, t('dialog.reorganize.transferRequestFailed')))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1345,16 +1294,12 @@ async function handleTransferBatch(items: FileItem[], background: boolean = fals
|
||||
// 整理日志
|
||||
async function handleTransferLog(logid: number, background: boolean = false) {
|
||||
try {
|
||||
const result = await requestManualTransfer(createTransferPayload({ logid }), background)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('dialog.reorganize.transferRequestFailed'))
|
||||
return false
|
||||
}
|
||||
await requestManualTransfer<null>(createTransferPayload({ logid }), background)
|
||||
if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
console.log(error)
|
||||
if (error instanceof Error) $toast.error(error.message)
|
||||
$toast.error(getManualTransferErrorMessage(error, t('dialog.reorganize.transferRequestFailed')))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,9 +372,9 @@ async function fetchSubscribes() {
|
||||
/** 从接口加载用户的站点搜索偏好。 */
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
if (result && result.data && result.data.value) {
|
||||
selectedSites.value = result.data.value
|
||||
const result = await api.get<{ value?: number[] }>('system/setting/public/IndexerSites')
|
||||
if (result.value) {
|
||||
selectedSites.value = result.value
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DownloaderConf, Site } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -95,14 +96,12 @@ async function addSite() {
|
||||
if (!siteForm.value?.url) return
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('site/', siteForm.value)
|
||||
if (result.success) {
|
||||
await api.post('site/', siteForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('site.messages.addSuccess'))
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${t('site.messages.addFailed')}:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getApiBusinessErrorMessage(error)
|
||||
$toast.error(message ? `${t('site.messages.addFailed')}:${message}` : t('site.messages.addFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
@@ -121,15 +120,16 @@ async function updateSiteInfo() {
|
||||
siteForm.value.limit_count = 0
|
||||
siteForm.value.limit_seconds = 0
|
||||
}
|
||||
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
|
||||
if (result.success) {
|
||||
await api.put('site/', siteForm.value, { feedback: 'silent' })
|
||||
$toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}!`)
|
||||
const message = getApiBusinessErrorMessage(error)
|
||||
$toast.error(
|
||||
message
|
||||
? `${siteForm.value?.name} ${t('site.messages.updateFailed')}:${message}`
|
||||
: `${siteForm.value?.name} ${t('site.messages.updateFailed')}!`,
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
|
||||
@@ -50,29 +50,21 @@ async function updateSiteCookie() {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
|
||||
await api.post(
|
||||
`site/cookie/${cardProps.site?.id}`,
|
||||
{
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
|
||||
emit('done')
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.siteCookieUpdate.failed', {
|
||||
site: cardProps.site?.name,
|
||||
message: result.message || t('dialog.siteCookieUpdate.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
(typeof error?.response?.data?.detail === 'string' ? error.response.data.detail : error?.message) ||
|
||||
t('dialog.siteCookieUpdate.requestFailed')
|
||||
const message = error?.message || t('dialog.siteCookieUpdate.requestFailed')
|
||||
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
|
||||
} finally {
|
||||
progressDialog.value = false
|
||||
|
||||
@@ -139,19 +139,10 @@ async function importSites() {
|
||||
try {
|
||||
// 移除id字段,避免冲突
|
||||
const { id, ...siteData } = site
|
||||
const result: { success: boolean; message?: string } = await api.post('site/', siteData)
|
||||
if (result.success) {
|
||||
await api.post<null>('site/', siteData, { feedback: 'silent' })
|
||||
// 记录成功的站点
|
||||
successCount++
|
||||
importSuccesses.value.push(site)
|
||||
} else {
|
||||
failCount++
|
||||
// 记录失败信息
|
||||
importErrors.value.push({
|
||||
site,
|
||||
error: result.message || t('site.messages.importFailed'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Import site ${site.name} failed:`, error)
|
||||
failCount++
|
||||
|
||||
@@ -38,8 +38,7 @@ const detailDialog = ref(false)
|
||||
async function fetchSiteStats() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('site/statistic')
|
||||
siteStats.value = Array.isArray(response) ? response : response.data || []
|
||||
siteStats.value = await api.get<SiteStatistic[]>('site/statistic')
|
||||
loading.value = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch site statistics:', error)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ApiResponse, Site, SiteUserData } from '@/api/types'
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
@@ -335,18 +335,14 @@ async function fetchSiteUserData(failureOperation: 'load' | 'refresh' = 'load',
|
||||
const activeGeneration = generation ?? ++operationGeneration
|
||||
|
||||
try {
|
||||
const result = await api.get<ApiResponse<SiteUserData[]>, ApiResponse<SiteUserData[]>>(
|
||||
`site/userdata/${props.site?.id}`,
|
||||
)
|
||||
const result = await api.get<SiteUserData[]>(`site/userdata/${props.site?.id}`, { feedback: 'silent' })
|
||||
if (activeGeneration !== operationGeneration) return false
|
||||
|
||||
if (result.success) {
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
if (activeGeneration !== operationGeneration) return false
|
||||
|
||||
siteDatas.value = result.data.sort((a, b) => (a.updated_day || '').localeCompare(b.updated_day || ''))
|
||||
}
|
||||
siteDatas.value = result.sort((a, b) => (a.updated_day || '').localeCompare(b.updated_day || ''))
|
||||
|
||||
failedOperation.value = undefined
|
||||
return true
|
||||
@@ -365,14 +361,10 @@ async function refreshSiteData() {
|
||||
const generation = ++operationGeneration
|
||||
progressDialog.value = true
|
||||
try {
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(`site/userdata/${props.site?.id}`)
|
||||
await api.post<null>(`site/userdata/${props.site?.id}`, undefined, { feedback: 'silent' })
|
||||
if (generation !== operationGeneration) return
|
||||
|
||||
if (result.success) {
|
||||
await fetchSiteUserData('refresh', generation)
|
||||
} else {
|
||||
failedOperation.value = 'refresh'
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== operationGeneration) return
|
||||
|
||||
|
||||
@@ -29,11 +29,9 @@ async function handleDone() {
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/smb')
|
||||
if (result.success) {
|
||||
await api.get('/storage/reset/smb')
|
||||
// 重置成功
|
||||
handleDone()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ async function queryFilterRuleGroups() {
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
filterRuleGroups.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -193,20 +193,10 @@ const filterRuleGroupOptions = computed(() => {
|
||||
async function updateSubscribeInfo() {
|
||||
const displayName = getSubscribeDisplayName()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
await api.put<null>('subscribe/', subscribeForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
|
||||
// 通知父组件刷新
|
||||
emit('save', subscribeForm.value)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.updateFailed', {
|
||||
name: displayName,
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
$toast.error(
|
||||
@@ -229,19 +219,10 @@ async function saveDefaultSubscribeConfig() {
|
||||
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultMusicSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
||||
if (result.success) {
|
||||
await api.post<null>(subscribe_config_url, subscribeForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
|
||||
// 通知父组件刷新
|
||||
emit('save', subscribeForm.value)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.defaultSaveFailed', {
|
||||
type: typeName,
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(
|
||||
@@ -261,9 +242,9 @@ async function queryDefaultSubscribeConfig() {
|
||||
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultMusicSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
const result = await api.get<{ value?: Record<string, unknown> }>(subscribe_config_url)
|
||||
|
||||
if (result.data.value) subscribeForm.value = result.data?.value ?? ''
|
||||
if (result.value) Object.assign(subscribeForm.value, result.value)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -321,19 +302,10 @@ async function removeSubscribe() {
|
||||
if (!isConfirmed) return
|
||||
const displayName = getSubscribeDisplayName()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
await api.delete<null>(`subscribe/${props.subid}`, { feedback: 'silent' })
|
||||
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(
|
||||
`${displayName} ${t('subscribe.cancelFailed', {
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
$toast.error(
|
||||
@@ -347,10 +319,8 @@ async function removeSubscribe() {
|
||||
// 查询下载目录
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
}
|
||||
const result = await api.get<{ value?: TransferDirectoryConf[] }>('system/setting/public/Directories')
|
||||
downloadDirectories.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
@@ -99,12 +99,8 @@ async function reSubscribe(item: Subscribe) {
|
||||
}
|
||||
progressDialog.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', item)
|
||||
if (result.success) {
|
||||
await api.post('subscribe/', item, { feedback: 'silent' })
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
@@ -115,10 +111,8 @@ async function reSubscribe(item: Subscribe) {
|
||||
// 删除记录
|
||||
async function deleteHistory(item: Subscribe) {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/history/${item.id}`)
|
||||
if (result.success) {
|
||||
await api.delete(`subscribe/history/${item.id}`, { feedback: 'silent' })
|
||||
historyList.value = historyList.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
@@ -176,9 +170,9 @@ function getMediaTypeText(type: string | undefined) {
|
||||
</div>
|
||||
</template>
|
||||
<template #empty />
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VVirtualScroll v-if="historyList.length > 0" :renderless="true" :items="historyList" :item-height="104">
|
||||
<template #default="{ item, ...slotProps }">
|
||||
<div :ref="'itemRef' in slotProps ? slotProps.itemRef : undefined">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
|
||||
@@ -37,21 +37,16 @@ async function doShare() {
|
||||
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
|
||||
try {
|
||||
shareDoing.value = true
|
||||
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
await api.post('subscribe/share', shareForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))
|
||||
// 通知父组件刷新
|
||||
emit('close')
|
||||
} else {
|
||||
$toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
$toast.error(
|
||||
t('dialog.subscribeShare.shareFailed', {
|
||||
name: props.sub?.name,
|
||||
message: t('subscribe.requestFailed'),
|
||||
message: e instanceof Error ? e.message : t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -64,37 +64,24 @@ function handleDone() {
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
|
||||
if (result.success) {
|
||||
await api.get<null>('/storage/reset/u115', { feedback: 'silent' })
|
||||
setMessage('success', t('dialog.u115Auth.authSuccess'))
|
||||
handleDone()
|
||||
}
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error('Reset failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.authFailed'))
|
||||
setMessage('error', error instanceof Error ? error.message : t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权URL
|
||||
async function fetchAuthUrl() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
authUrl.value = result.data.authUrl
|
||||
authState.value = result.data.state
|
||||
}
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const result = await api.get<{ authUrl: string; state: string }>('/storage/auth_url/u115', { feedback: 'silent' })
|
||||
authUrl.value = result.authUrl
|
||||
authState.value = result.state
|
||||
} catch (error) {
|
||||
console.error('Fetch auth URL failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.urlFetchFailed'))
|
||||
setMessage('error', error instanceof Error ? error.message : t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +112,7 @@ function openAuthWindow() {
|
||||
if (authWindow) {
|
||||
setMessage('info', t('dialog.u115Auth.authorizing'))
|
||||
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
setMessage('error', t('dialog.u115Auth.popupBlocked'))
|
||||
}
|
||||
}
|
||||
@@ -134,10 +120,8 @@ function openAuthWindow() {
|
||||
// 检查授权状态
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { status, tip } = result.data
|
||||
const result = await api.get<{ status: number; tip?: string }>('/storage/check/u115', { feedback: 'silent' })
|
||||
const { status, tip } = result
|
||||
|
||||
if (status === AUTH_STATUS_SUCCESS) {
|
||||
// 授权成功
|
||||
@@ -154,9 +138,7 @@ async function checkAuthStatus() {
|
||||
}
|
||||
|
||||
// status === 0 或 1,继续等待
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error('Check auth status failed:', error)
|
||||
}
|
||||
|
||||
@@ -214,36 +196,20 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="text" class="w-full">
|
||||
<VAlert
|
||||
variant="tonal"
|
||||
:type="alertType"
|
||||
:text="text"
|
||||
class="my-4 text-center"
|
||||
>
|
||||
<VAlert variant="tonal" :type="alertType" :text="text" class="my-4 text-center">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-restore"
|
||||
@click="handleReset"
|
||||
>
|
||||
<VBtn color="error" variant="tonal" prepend-icon="mdi-restore" @click="handleReset">
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
</VBtn>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
@click="handleDone"
|
||||
>
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="handleDone">
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useToast } from 'vue-toastification'
|
||||
import type { User } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import api from '@/api'
|
||||
import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useUserStore } from '@/stores'
|
||||
@@ -322,17 +323,15 @@ async function addUser() {
|
||||
isAdding.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('user/', userForm.value)
|
||||
if (result.success) {
|
||||
await api.post<null>('user/', userForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.userAddEdit.userCreated', { name: userForm.value.name }))
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('dialog.userAddEdit.userCreateFailed', { message: result.message }))
|
||||
// 清除用户名
|
||||
userForm.value.name = ''
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('dialog.userAddEdit.userCreateFailed', { message: t('common.serverConnectionFailed') }))
|
||||
$toast.error(
|
||||
t('dialog.userAddEdit.userCreateFailed', {
|
||||
message: getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
@@ -375,9 +374,8 @@ async function updateUser() {
|
||||
// 确保权限数据正确传递
|
||||
userData.permissions = userPermissions.value
|
||||
|
||||
const result: Record<string, unknown> = await api.put('user/', userData)
|
||||
await api.put<null>('user/', userData, { feedback: 'silent' })
|
||||
|
||||
if (result.success) {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: `${oldUserName} → ${currentUserName.value}` }))
|
||||
// 如果是当前登录用户,更新当前用户名称显示
|
||||
@@ -396,16 +394,8 @@ async function updateUser() {
|
||||
userStore.setPermissions(userPermissions.value)
|
||||
}
|
||||
emit('save')
|
||||
} else {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))
|
||||
currentUserName.value = oldUserName
|
||||
} else {
|
||||
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: result.message }))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: '' }))
|
||||
$toast.error(t('dialog.userAddEdit.userUpdateFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
console.error('更新失败:', error)
|
||||
} finally {
|
||||
// 表单中的已保存值用于恢复操作,待提交值只保留在对应的编辑状态中。
|
||||
|
||||
@@ -64,13 +64,11 @@ const formFields = computed(() => {
|
||||
// 查询之前使用的认证参数
|
||||
async function loadLastAuthParams() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`system/setting/UserSiteAuthParams`)
|
||||
if (result.success) {
|
||||
const ret = result.data?.value
|
||||
const result = await api.get<{ value?: typeof authForm.value }>(`system/setting/UserSiteAuthParams`)
|
||||
const ret = result.value
|
||||
if (ret && !isNullOrEmptyObject(ret.params)) {
|
||||
authForm.value = ret
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -110,18 +108,15 @@ async function checkUser() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)
|
||||
if (result.success) {
|
||||
await api.post<null>(`site/auth`, authForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.userAuth.authSuccess'))
|
||||
// 1秒后刷新页面
|
||||
setTimeout(() => {
|
||||
emit('done')
|
||||
}, 1000)
|
||||
} else {
|
||||
$toast.error(t('dialog.userAuth.authFailed', { message: result.message }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
$toast.error(t('dialog.userAuth.authFailed', { message: e instanceof Error ? e.message : '' }))
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -48,10 +48,13 @@ const actionDefinitions = ref<any[]>([])
|
||||
|
||||
// 动作类型到契约的映射
|
||||
const actionContractMap = computed(() => {
|
||||
return actionDefinitions.value.reduce((result, action) => {
|
||||
return actionDefinitions.value.reduce(
|
||||
(result, action) => {
|
||||
result[action.type] = action.contract || {}
|
||||
return result
|
||||
}, {} as Record<string, any>)
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
})
|
||||
|
||||
// 获取指定节点端口的类型(输入/输出)
|
||||
@@ -159,14 +162,10 @@ const getNodeName = (nodeId?: string) => {
|
||||
|
||||
// 获取流程边源节点可用于条件判断的输出字段
|
||||
const getEdgeConditionFields = (edge: any) => {
|
||||
const sourceNode = edge
|
||||
? nodes.value.find(node => node.id === edge.source)
|
||||
: null
|
||||
const sourceNode = edge ? nodes.value.find(node => node.id === edge.source) : null
|
||||
const contract = sourceNode ? actionContractMap.value[sourceNode.type] || {} : {}
|
||||
const fields = contract.condition_fields || contract.outputs || []
|
||||
return Array.isArray(fields)
|
||||
? fields.filter((field: any) => field?.name || field)
|
||||
: []
|
||||
return Array.isArray(fields) ? fields.filter((field: any) => field?.name || field) : []
|
||||
}
|
||||
|
||||
// 判断流程边是否存在可编辑条件
|
||||
@@ -232,15 +231,13 @@ const selectedEdge = computed(() => {
|
||||
})
|
||||
|
||||
// 当前边可用于条件判断的输出字段
|
||||
const selectedEdgeConditionFields = computed(() => (
|
||||
selectedEdge.value ? getEdgeConditionFields(selectedEdge.value) : []
|
||||
))
|
||||
const selectedEdgeConditionFields = computed(() =>
|
||||
selectedEdge.value ? getEdgeConditionFields(selectedEdge.value) : [],
|
||||
)
|
||||
|
||||
// 当前边的条件下拉选项,按源节点固定输出自动生成
|
||||
const edgeConditionOptions = computed(() => {
|
||||
const sourceNode = selectedEdge.value
|
||||
? nodes.value.find(node => node.id === selectedEdge.value?.source)
|
||||
: null
|
||||
const sourceNode = selectedEdge.value ? nodes.value.find(node => node.id === selectedEdge.value?.source) : null
|
||||
const options = [{ title: t('dialog.workflowActions.conditionAlways'), value: '' }]
|
||||
selectedEdgeConditionFields.value.forEach((field: any) => {
|
||||
const fieldName = field.name || field
|
||||
@@ -376,13 +373,9 @@ async function updateWorkflow() {
|
||||
workflowForm.value.flows = edges.value
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
await api.put<null>(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
$toast.success(t('dialog.workflowActions.saveSuccess'))
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('dialog.workflowActions.saveFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -720,6 +713,5 @@ const isMacOS = computed(() => {
|
||||
inset-inline: 16px;
|
||||
max-block-size: min(72vh, calc(100% - 112px));
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -166,13 +166,9 @@ async function addWorkflow() {
|
||||
normalizeWorkflowExecutionConfig()
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
|
||||
if (result.success) {
|
||||
await api.post<null>('workflow/', workflowForm.value)
|
||||
$toast.success(t('dialog.workflowAddEdit.addSuccess'))
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('dialog.workflowAddEdit.addFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -205,13 +201,9 @@ async function editWorkflow() {
|
||||
normalizeWorkflowExecutionConfig()
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
await api.put<null>(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
$toast.success(t('dialog.workflowAddEdit.editSuccess'))
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(t('dialog.workflowAddEdit.editFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -48,18 +48,20 @@ async function doShare() {
|
||||
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
|
||||
try {
|
||||
shareDoing.value = true
|
||||
const result: { [key: string]: any } = await api.post('workflow/share', shareForm.value)
|
||||
shareDoing.value = false
|
||||
// 提示
|
||||
if (result.success) {
|
||||
await api.post('workflow/share', shareForm.value, { feedback: 'silent' })
|
||||
$toast.success(t('dialog.workflowShare.shareSuccess', { name: props.workflow?.name }))
|
||||
// 通知父组件刷新
|
||||
emit('close')
|
||||
} else {
|
||||
$toast.error(t('dialog.workflowShare.shareFailed', { name: props.workflow?.name, message: result.message }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
$toast.error(
|
||||
t('dialog.workflowShare.shareFailed', {
|
||||
name: props.workflow?.name,
|
||||
message: e instanceof Error ? e.message : '',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
shareDoing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,19 +30,16 @@ describe('AboutDialog version statistics', () => {
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path === 'system/env') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
USAGE_STATISTIC_SHARE: true,
|
||||
VERSION: 'v2.0.0',
|
||||
FRONTEND_VERSION: 'v2.0.0',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (path === 'dashboard/processes') return Promise.resolve([])
|
||||
if (path === 'system/versions') return Promise.resolve({ data: [] })
|
||||
if (path === 'system/versions') return Promise.resolve([])
|
||||
if (path === 'site/supporting') return Promise.resolve({})
|
||||
if (path === 'system/usage/statistic') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
backend_versions: [
|
||||
{ version: 'backend-hidden', count: 9 },
|
||||
{ version: 'backend-visible', count: 10 },
|
||||
@@ -51,7 +48,6 @@ describe('AboutDialog version statistics', () => {
|
||||
{ version: 'frontend-hidden', count: 0 },
|
||||
{ version: 'frontend-visible', count: 11 },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API path: ${path}`))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('vuedraggable', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { modelValue: { type: Array, default: () => [] } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
props.modelValue.map((element, index) => slots.item?.({ element, index })),
|
||||
)
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('CategoryEditDialog data client contract', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
})
|
||||
|
||||
it('parses the unwrapped category configuration returned by the default API client', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
movie: {
|
||||
'电影直返分类': { genre_ids: '28', original_language: 'zh', production_countries: 'CN' },
|
||||
},
|
||||
tv: {},
|
||||
})
|
||||
|
||||
await renderWithProviders(CategoryEditDialog, {
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
props: { modelValue: true },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('media/category/config', { feedback: 'silent' }))
|
||||
expect(await screen.findByDisplayValue('电影直返分类')).toBeInTheDocument()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({ default: { post: (...args: unknown[]) => mocks.apiPost(...args) } }))
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({ post: (...args: unknown[]) => mocks.apiPost(...args) }),
|
||||
}))
|
||||
vi.mock('qrcode', () => ({ default: { toDataURL: (...args: unknown[]) => mocks.qrCode(...args) } }))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
|
||||
@@ -15,10 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -5,20 +5,24 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
const mocks = vi.hoisted(() => {
|
||||
const apiGet = vi.fn()
|
||||
const apiPut = vi.fn()
|
||||
|
||||
return {
|
||||
api: { get: apiGet, put: apiPut },
|
||||
apiGet,
|
||||
apiPut,
|
||||
ensureSidebarNav: vi.fn(),
|
||||
loadRemoteComponent: vi.fn(),
|
||||
nativeSubscribe: vi.fn(),
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
put: mocks.apiPut,
|
||||
},
|
||||
pluginApi: mocks.api,
|
||||
default: mocks.api,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/federationLoader', () => ({
|
||||
@@ -174,7 +178,7 @@ describe('PluginConfigDialog', () => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', {}))
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', {}, { feedback: 'silent' }))
|
||||
})
|
||||
|
||||
it('does not expose configuration saving while the form is loading or failed', async () => {
|
||||
@@ -229,7 +233,9 @@ describe('PluginConfigDialog', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: '调整布局' }))
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('data-max-width', '72rem')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '远程保存' }))
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: false }))
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: false }, { feedback: 'silent' }),
|
||||
)
|
||||
await fireEvent.click(screen.getByRole('button', { name: '切换数据' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭远程配置' }))
|
||||
|
||||
@@ -265,7 +271,7 @@ describe('PluginConfigDialog', () => {
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: true })
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: true }, { feedback: 'silent' })
|
||||
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(result.emitted().save).toHaveLength(1)
|
||||
})
|
||||
@@ -275,7 +281,7 @@ describe('PluginConfigDialog', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', () => Promise.resolve({ message: '配置被拒绝', success: false })],
|
||||
['business failure', () => Promise.reject(new Error('配置被拒绝'))],
|
||||
['HTTP failure', () => Promise.reject(new Error('request failed'))],
|
||||
])('keeps the dialog open and reports a %s', async (_case, saveResult) => {
|
||||
mocks.apiGet.mockResolvedValue({ conf: [], model: {}, render_mode: 'vuetify' })
|
||||
|
||||
@@ -5,15 +5,21 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
const mocks = vi.hoisted(() => {
|
||||
const apiGet = vi.fn()
|
||||
|
||||
return {
|
||||
api: { get: apiGet },
|
||||
apiGet,
|
||||
loadRemoteComponent: vi.fn(),
|
||||
nativeSubscribe: vi.fn(),
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
pluginApi: mocks.api,
|
||||
default: mocks.api,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/federationLoader', () => ({
|
||||
|
||||
@@ -18,10 +18,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -17,10 +17,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -244,6 +244,11 @@ function createDeferred<T>() {
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
/** 构造后端网络层使用的严格三段式响应。 */
|
||||
function apiEnvelope<T>(data: T | null, success = true, message = ''): ApiResponse<T> {
|
||||
return { data, message, success }
|
||||
}
|
||||
|
||||
function publicSettingHandlers({
|
||||
directories = [],
|
||||
episodeRules = [],
|
||||
@@ -256,19 +261,19 @@ function publicSettingHandlers({
|
||||
return [
|
||||
http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => {
|
||||
initializationRequestCount += 1
|
||||
return HttpResponse.json({ data: { value: directories }, success: true })
|
||||
return HttpResponse.json(apiEnvelope({ value: directories }))
|
||||
}),
|
||||
http.get(new URL('system/setting/public/Storages', API_BASE_URL).href, () => {
|
||||
initializationRequestCount += 1
|
||||
return HttpResponse.json({ data: { value: storages }, success: true })
|
||||
return HttpResponse.json(apiEnvelope({ value: storages }))
|
||||
}),
|
||||
http.get(new URL('system/setting/public/EpisodeFormatRuleTable', API_BASE_URL).href, () => {
|
||||
initializationRequestCount += 1
|
||||
return HttpResponse.json({ data: { value: episodeRules }, success: true })
|
||||
return HttpResponse.json(apiEnvelope({ value: episodeRules }))
|
||||
}),
|
||||
http.post(new URL('transfer/manual/history', API_BASE_URL).href, () => {
|
||||
initializationRequestCount += 1
|
||||
return HttpResponse.json({ data: { history_count: 0, reorganize: false }, success: true })
|
||||
return HttpResponse.json(apiEnvelope({ history_count: 0, reorganize: false }))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -354,6 +359,7 @@ function previewResponse(
|
||||
total: items.length,
|
||||
},
|
||||
},
|
||||
message: '',
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
@@ -375,7 +381,7 @@ describe('ReorganizeDialog submission safety', () => {
|
||||
it('keeps the dialog open when the backend reports a business failure', async () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ message: '整理失败', success: false }),
|
||||
HttpResponse.json(apiEnvelope(null, false, '整理失败')),
|
||||
),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -388,7 +394,9 @@ describe('ReorganizeDialog submission safety', () => {
|
||||
})
|
||||
|
||||
it('shows a fallback error when a business failure has no message', async () => {
|
||||
server.use(http.post(new URL('transfer/manual', API_BASE_URL).href, () => HttpResponse.json({ success: false })))
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, () => HttpResponse.json(apiEnvelope(null, false))),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const { onDone } = await renderDialog()
|
||||
|
||||
@@ -415,7 +423,7 @@ describe('ReorganizeDialog submission safety', () => {
|
||||
})
|
||||
|
||||
it('coalesces repeated submit clicks while a transfer request is pending', async () => {
|
||||
const response = createDeferred<ApiResponse>()
|
||||
const response = createDeferred<ApiResponse<null>>()
|
||||
let requestCount = 0
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async () => {
|
||||
@@ -430,7 +438,7 @@ describe('ReorganizeDialog submission safety', () => {
|
||||
await fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(requestCount).toBe(1))
|
||||
response.resolve({ data: undefined, success: true })
|
||||
response.resolve(apiEnvelope(null))
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
@@ -439,7 +447,7 @@ describe('ReorganizeDialog submission safety', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, () => {
|
||||
requestCount += 1
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -471,7 +479,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
backgrounds.push(new URL(request.url).searchParams.get('background') ?? '')
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -504,7 +512,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -530,7 +538,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -563,7 +571,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
})
|
||||
|
||||
it('updates synchronous progress from SSE and always stops it after success', async () => {
|
||||
const response = createDeferred<ApiResponse>()
|
||||
const response = createDeferred<ApiResponse<null>>()
|
||||
let payload: unknown
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
@@ -582,7 +590,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('transfer-progress')).toHaveTextContent('正在写入媒体库:65'))
|
||||
|
||||
response.resolve({ data: undefined, success: true })
|
||||
response.resolve(apiEnvelope(null))
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -596,7 +604,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
})
|
||||
|
||||
it('stops the active progress stream when unmounted during a request', async () => {
|
||||
const response = createDeferred<ApiResponse>()
|
||||
const response = createDeferred<ApiResponse<null>>()
|
||||
const requestCompleted = createDeferred<void>()
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async () => {
|
||||
@@ -612,7 +620,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
unmount()
|
||||
|
||||
expect(mocks.progressControllers[0].stop).toHaveBeenCalledTimes(1)
|
||||
response.resolve({ data: undefined, success: true })
|
||||
response.resolve(apiEnvelope(null))
|
||||
await requestCompleted.promise
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
@@ -633,7 +641,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -677,11 +685,11 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
const bodies: unknown[] = []
|
||||
server.use(
|
||||
http.get(new URL('media/groups/600', API_BASE_URL).href, () =>
|
||||
HttpResponse.json([{ episode_count: 12, group_count: 1, id: 'group-1', name: '播出顺序' }]),
|
||||
HttpResponse.json(apiEnvelope([{ episode_count: 12, group_count: 1, id: 'group-1', name: '播出顺序' }])),
|
||||
),
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -739,7 +747,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -780,12 +788,13 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
rule_name: '标准季集',
|
||||
sample_file: 'Movie.mkv',
|
||||
},
|
||||
message: '',
|
||||
success: true,
|
||||
})
|
||||
}),
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
transferBodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -810,7 +819,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json(apiEnvelope(null))
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
@@ -941,7 +950,7 @@ describe('ReorganizeDialog preview', () => {
|
||||
it('summarizes a business-level preview failure without closing the preview', async () => {
|
||||
server.use(
|
||||
http.post(new URL('transfer/manual', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ message: '目标目录不可用', success: false }),
|
||||
HttpResponse.json(apiEnvelope(null, false, '目标目录不可用')),
|
||||
),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('SiteImportDialog', () => {
|
||||
expect(screen.getByText('未支持站点 - 错误详情')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports partial success and preserves the HTTP error message', async () => {
|
||||
it('reports partial success and preserves the backend HTTP error message', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const sites = [createSite({ name: '成功站点' }), createSite({ name: '失败站点' })]
|
||||
let requestIndex = 0
|
||||
@@ -196,7 +196,7 @@ describe('SiteImportDialog', () => {
|
||||
expect(screen.getByText('成功导入 1 个站点')).toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入完成,成功 1 个,失败 1 个')
|
||||
await fireEvent.click(screen.getByText('失败站点 - 错误详情'))
|
||||
expect(await screen.findByText('Request failed with status code 500')).toBeInTheDocument()
|
||||
expect(await screen.findByText('第二站请求失败')).toBeInTheDocument()
|
||||
expect(consoleError).toHaveBeenCalledWith('Import site 失败站点 failed:', expect.any(Error))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SiteUserDataDialog from '@/components/dialog/SiteUserDataDialog.vue'
|
||||
import type { ApiResponse, SiteUserData } from '@/api/types'
|
||||
import type { SiteUserData } from '@/api/types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createSite, createSiteUserData } from '@tests/support/factories/site'
|
||||
import { refreshSiteUserDataHandler, siteUserDataHandler } from '@tests/support/msw/handlers/site'
|
||||
@@ -55,7 +55,7 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
async function renderDialog(
|
||||
initialResult: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'> = { data: [], success: false },
|
||||
initialResult: { data: SiteUserData[]; message?: string; success: boolean } = { data: [], success: true },
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
@@ -390,7 +390,7 @@ describe('SiteUserDataDialog refresh and recovery', () => {
|
||||
it('clears an initial HTTP error when retry returns the legal empty state', async () => {
|
||||
const { site } = await renderDialog({ data: [], success: false }, 500)
|
||||
expect(await screen.findByText(/加载站点数据失败/)).toBeInTheDocument()
|
||||
server.use(siteUserDataHandler(site.id, { data: [], success: false }))
|
||||
server.use(siteUserDataHandler(site.id, { data: [], success: true }))
|
||||
|
||||
await fireEvent.click(getRetryButton())
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ const DialogCloseBtn = defineComponent({
|
||||
})
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
put: (...args: unknown[]) => mocks.apiPut(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import FileToolbar from './FileToolbar.vue'
|
||||
import FileNavigator from './FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useUserStore } from '@/stores'
|
||||
@@ -22,7 +22,7 @@ const props = defineProps({
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||
axios: {
|
||||
type: Function as PropType<AxiosInstance>,
|
||||
type: Function as PropType<DataApiClient>,
|
||||
required: true,
|
||||
},
|
||||
axiosconfig: Object,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { ApiResponse, Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
||||
import type { Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
@@ -38,7 +39,7 @@ const inProps = defineProps({
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||
axios: {
|
||||
type: Function as PropType<AxiosInstance>,
|
||||
type: Function as PropType<DataApiClient>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
@@ -303,8 +304,9 @@ async function requestDeleteItem(item: FileItem) {
|
||||
url: inProps.endpoints?.delete.url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
data: item,
|
||||
feedback: 'silent',
|
||||
}
|
||||
return inProps.axios.request<ApiResponse<unknown>, ApiResponse<unknown>>(config)
|
||||
return inProps.axios.request<null>(config)
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
@@ -324,11 +326,7 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
|
||||
emit('loading', true)
|
||||
|
||||
try {
|
||||
const result = await requestDeleteItem(item)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('common.error'))
|
||||
return false
|
||||
}
|
||||
await requestDeleteItem(item)
|
||||
|
||||
emit('filedeleted')
|
||||
await list_files()
|
||||
@@ -368,8 +366,7 @@ async function batchDelete() {
|
||||
progressValue.value = Math.round(((index + 1) / selectedItems.length) * 100)
|
||||
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||
try {
|
||||
const result = await requestDeleteItem(item)
|
||||
if (!result.success) failedItems.push(item)
|
||||
await requestDeleteItem(item)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
failedItems.push(item)
|
||||
@@ -503,17 +500,13 @@ async function get_recommend_name() {
|
||||
renameLoading.value = true
|
||||
renameDialogController?.updateProps({ loading: true })
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('transfer/name', {
|
||||
const result = await api.get<{ name: string }>('transfer/name', {
|
||||
params: {
|
||||
path: `${inProps.item.path}${currentItem.value?.name}`,
|
||||
filetype: currentItem.value?.type ?? 'file',
|
||||
},
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
newName.value = result.data.name
|
||||
} else {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
newName.value = result.name
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -546,12 +539,9 @@ async function rename() {
|
||||
url,
|
||||
method: inProps.endpoints?.rename.method || 'post',
|
||||
data: currentItem.value,
|
||||
feedback: 'silent',
|
||||
}
|
||||
const result: { [key: string]: any } = await inProps.axios.request<any, { [key: string]: any }>(config)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message || t('common.error'))
|
||||
return
|
||||
}
|
||||
await inProps.axios.request<null>(config)
|
||||
|
||||
newName.value = ''
|
||||
renameAll.value = false
|
||||
@@ -714,19 +704,21 @@ async function recognize(path: string) {
|
||||
}
|
||||
|
||||
// 调用 API 按请求级媒体条件刮削单个文件项。
|
||||
async function scrape(item: FileItem, options: ManualScrapeOptions) {
|
||||
async function scrape(item: FileItem, options: ManualScrapeOptions, silent = false) {
|
||||
try {
|
||||
progressText.value = t('file.scraping', { path: item.path })
|
||||
progressDialogController?.updateProps({ text: progressText.value })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item, {
|
||||
await api.post<null>(`media/scrape/${inProps.item.storage}`, item, {
|
||||
params: options,
|
||||
feedback: 'silent',
|
||||
})
|
||||
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
|
||||
if (!silent) $toast.success(t('file.scrapeCompleted', { path: item.path }))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (!silent) $toast.error(error instanceof Error ? error.message : t('common.error'))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,12 +730,21 @@ async function scrapeItems(itemsToScrape: FileItem[], options: ManualScrapeOptio
|
||||
progressText.value = t('file.scraping', { path: normalizedItems[0].path })
|
||||
progressValue.value = 0
|
||||
openProgressDialog(progressText.value, progressValue.value)
|
||||
const failedItems: FileItem[] = []
|
||||
try {
|
||||
for (const [index, item] of normalizedItems.entries()) {
|
||||
await scrape(item, options)
|
||||
try {
|
||||
await scrape(item, options, true)
|
||||
$toast.success(t('file.scrapeCompleted', { path: item.path }))
|
||||
} catch {
|
||||
failedItems.push(item)
|
||||
}
|
||||
progressValue.value = Math.round(((index + 1) / normalizedItems.length) * 100)
|
||||
progressDialogController?.updateProps({ value: progressValue.value })
|
||||
}
|
||||
if (failedItems.length) {
|
||||
$toast.error(`${t('common.error')}: ${failedItems.map(item => item.name).join(', ')}`)
|
||||
}
|
||||
} finally {
|
||||
closeProgressDialog()
|
||||
if (selectMode.value) exitSelectMode()
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
@@ -36,7 +37,7 @@ const props = defineProps({
|
||||
endpoints: Object,
|
||||
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||
axios: {
|
||||
type: Function as PropType<AxiosInstance>,
|
||||
type: Function as PropType<DataApiClient>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
@@ -113,7 +114,7 @@ async function loadSubdirectories(path: string) {
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = await props.axios?.request(config)
|
||||
const result = await props.axios?.request<FileItem[]>(config)
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import type { ApiResponse, EndPoints, FileItem } from '@/api/types'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
@@ -33,7 +34,7 @@ const inProps = defineProps({
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
// Axios 实例是可调用函数,运行时 prop 类型需与其实际形态一致。
|
||||
axios: {
|
||||
type: Function as PropType<AxiosInstance>,
|
||||
type: Function as PropType<DataApiClient>,
|
||||
required: true,
|
||||
},
|
||||
sort: {
|
||||
@@ -109,12 +110,10 @@ async function mkdir() {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
data: inProps.item,
|
||||
feedback: 'silent',
|
||||
}
|
||||
|
||||
const result = await inProps.axios.request<unknown, ApiResponse<unknown>>(config)
|
||||
if (!result?.success) {
|
||||
return
|
||||
}
|
||||
await inProps.axios.request<null>(config)
|
||||
|
||||
newFolderDialogController?.close()
|
||||
newFolderDialogController = null
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FileBrowser from '@/components/filebrowser/FileBrowser.vue'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -79,7 +79,7 @@ const FileListStub = defineComponent({
|
||||
|
||||
function createBrowserProps() {
|
||||
const request = vi.fn()
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as DataApiClient
|
||||
const endpoint = { method: 'post', url: '/unused' }
|
||||
const endpoints: EndPoints = {
|
||||
delete: endpoint,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import FileList from '@/components/filebrowser/FileList.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -21,10 +22,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
@@ -171,7 +172,7 @@ async function renderList(
|
||||
sort?: string
|
||||
} = {},
|
||||
) {
|
||||
const axios = Object.assign(vi.fn(), { request: vi.fn(request) }) as unknown as AxiosInstance
|
||||
const axios = Object.assign(vi.fn(), { request: vi.fn(request) }) as unknown as DataApiClient
|
||||
const result = await renderWithProviders(FileList, {
|
||||
global: { stubs },
|
||||
props: {
|
||||
@@ -186,7 +187,7 @@ async function renderList(
|
||||
return { ...result, axios }
|
||||
}
|
||||
|
||||
function getRequestConfig(axios: AxiosInstance, index: number) {
|
||||
function getRequestConfig(axios: DataApiClient, index: number) {
|
||||
return vi.mocked(axios.request).mock.calls[index]?.[0] as AxiosRequestConfig
|
||||
}
|
||||
|
||||
@@ -415,7 +416,7 @@ describe('FileList destructive operations', () => {
|
||||
|
||||
it('does not emit deletion success for a business failure and always closes loading', async () => {
|
||||
const request = vi.fn((config: AxiosRequestConfig) => {
|
||||
if (config.url === '/storage/delete') return Promise.resolve({ message: 'delete denied', success: false })
|
||||
if (config.url === '/storage/delete') return Promise.reject(new Error('delete denied'))
|
||||
return Promise.resolve([createItem({ name: 'failed.mkv' })])
|
||||
})
|
||||
const { emitted } = await renderList(request)
|
||||
@@ -451,7 +452,7 @@ describe('FileList destructive operations', () => {
|
||||
listCount += 1
|
||||
return Promise.resolve(listCount === 1 ? [item] : [])
|
||||
}
|
||||
return Promise.resolve({ success: true })
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
const { emitted } = await renderList(request)
|
||||
await screen.findByText('deleted.mkv')
|
||||
@@ -474,9 +475,9 @@ describe('FileList destructive operations', () => {
|
||||
return Promise.resolve(listCount === 1 ? [first, failed] : [failed])
|
||||
}
|
||||
if ((config.data as FileItem).name === 'failed.mkv') {
|
||||
return Promise.resolve({ message: 'permission denied', success: false })
|
||||
return Promise.reject(new Error('permission denied'))
|
||||
}
|
||||
return Promise.resolve({ success: true })
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
const { emitted } = await renderList(request)
|
||||
await screen.findByText('first.mkv')
|
||||
@@ -532,7 +533,7 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
const first = createItem({ name: 'first.mkv', path: '/media/first.mkv' })
|
||||
const second = createItem({ name: 'second.mkv', path: '/media/second.mkv' })
|
||||
const request = vi.fn().mockResolvedValue([first, second])
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
mocks.apiPost.mockResolvedValue(null)
|
||||
await renderList(request)
|
||||
await screen.findByText('first.mkv')
|
||||
|
||||
@@ -665,7 +666,7 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
it('wires recursive rename progress SSE and closes resources on unmount', async () => {
|
||||
const controller = { close: vi.fn(), id: 1, updateProps: vi.fn() }
|
||||
mocks.openSharedDialog.mockReturnValue(controller)
|
||||
const renameResult = deferred<{ success: boolean }>()
|
||||
const renameResult = deferred<null>()
|
||||
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||
config.url?.startsWith('/storage/rename')
|
||||
? renameResult.promise
|
||||
@@ -687,7 +688,7 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
expect(mocks.progressStart).toHaveBeenCalledOnce()
|
||||
mocks.progressHandler?.(new MessageEvent('message', { data: JSON.stringify({ text_i18n: '重命名中', value: 50 }) }))
|
||||
expect(controller.updateProps).toHaveBeenCalledWith(expect.objectContaining({ text: '重命名中', value: 50 }))
|
||||
renameResult.resolve({ success: true })
|
||||
renameResult.resolve(null)
|
||||
await renamePromise
|
||||
|
||||
unmount()
|
||||
@@ -701,7 +702,7 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
mocks.openSharedDialog.mockReturnValueOnce(renameController).mockReturnValueOnce(progressController)
|
||||
const request = vi.fn((config: AxiosRequestConfig) =>
|
||||
config.url?.startsWith('/storage/rename')
|
||||
? Promise.resolve({ message: 'rename denied', success: false })
|
||||
? Promise.reject(new Error('rename denied'))
|
||||
: Promise.resolve([createItem({ name: 'rename.mkv' })]),
|
||||
)
|
||||
const { emitted } = await renderList(request)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FileNavigator from '@/components/filebrowser/FileNavigator.vue'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -43,7 +43,7 @@ function mountNavigator(
|
||||
request = vi.fn().mockResolvedValue([]),
|
||||
overrides: { currentPath?: string; items?: FileItem[] } = {},
|
||||
) {
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as DataApiClient
|
||||
const endpoint = { method: 'post', url: '/unused' }
|
||||
const endpoints: EndPoints = {
|
||||
delete: endpoint,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FileToolbar from '@/components/filebrowser/FileToolbar.vue'
|
||||
import type { DataApiClient } from '@/api'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -41,7 +41,7 @@ function mountToolbar(
|
||||
{ name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
|
||||
],
|
||||
) {
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as AxiosInstance
|
||||
const axios = Object.assign(vi.fn(), { request }) as unknown as DataApiClient
|
||||
const endpoint = { method: 'post', url: '/unused' }
|
||||
const endpoints: EndPoints = {
|
||||
delete: endpoint,
|
||||
@@ -112,7 +112,7 @@ describe('FileToolbar mkdir', () => {
|
||||
})
|
||||
|
||||
it('does not report creation when the API returns a business failure and always finishes loading', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ message: '目录已存在', success: false })
|
||||
const request = vi.fn().mockRejectedValue(new Error('目录已存在'))
|
||||
const wrapper = mountToolbar(request)
|
||||
|
||||
await openDialogAndCreate(wrapper)
|
||||
@@ -137,7 +137,7 @@ describe('FileToolbar mkdir', () => {
|
||||
})
|
||||
|
||||
it('submits the current directory and closes only after a successful creation', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ success: true })
|
||||
const request = vi.fn().mockResolvedValue(null)
|
||||
const wrapper = mountToolbar(request)
|
||||
|
||||
await openDialogAndCreate(wrapper, 'Season 01')
|
||||
@@ -145,6 +145,7 @@ describe('FileToolbar mkdir', () => {
|
||||
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
data: { name: 'downloads', path: '/downloads/', storage: 'local', type: 'dir' },
|
||||
feedback: 'silent',
|
||||
method: 'post',
|
||||
url: '/storage/mkdir?name=Season 01',
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import api from '@/api'
|
||||
import { pluginApi } from '@/api'
|
||||
import { DashboardItem } from '@/api/types'
|
||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -248,7 +248,7 @@ onUnmounted(() => {
|
||||
:is="dynamicPluginComponent"
|
||||
:config="props.config"
|
||||
:allow-refresh="props.allowRefresh"
|
||||
:api="api"
|
||||
:api="pluginApi"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
default: createDataApiMock({ get: mocks.apiGet }),
|
||||
pluginApi: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/federationLoader', () => ({
|
||||
|
||||
@@ -33,7 +33,7 @@ async function queryFilterRuleGroups() {
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
filterRuleGroups.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const storages = ref<StorageConf[]>([])
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
storages.value = result.data?.value ?? []
|
||||
storages.value = result.value ?? []
|
||||
}
|
||||
|
||||
// 存储字典
|
||||
|
||||
@@ -32,7 +32,7 @@ async function loadNotificationSetting() {
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
notifications.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function createProvider(id: string, runtime: string) {
|
||||
|
||||
@@ -24,9 +24,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
@@ -159,7 +159,7 @@ describe('plugin native subscribe flow', () => {
|
||||
|
||||
it('restores movie subscribe and exists state before using the native movie flow', async () => {
|
||||
mocks.checkSubscribe.mockResolvedValue(true)
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
mocks.apiGet.mockResolvedValue({ item: { id: 'library-600' } })
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness()
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -248,12 +248,8 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
async function loadProviders(preserveBaseUrl = true) {
|
||||
loadingProviders.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/providers')
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM providers failed')
|
||||
}
|
||||
|
||||
providers.value = Array.isArray(result.data) ? result.data : []
|
||||
const result = await api.get<LlmProvider[]>('llm/providers')
|
||||
providers.value = Array.isArray(result) ? result : []
|
||||
if (!selectedProvider.value && providers.value.length > 0) {
|
||||
options.provider.value = providers.value[0].id
|
||||
}
|
||||
@@ -270,7 +266,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/models', {
|
||||
const payload = await api.get<{ auth_status?: LlmProviderAuthStatus; models?: LlmModel[] }>('llm/models', {
|
||||
params: {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||
@@ -281,11 +277,6 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
force_refresh: forceRefresh,
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM models failed')
|
||||
}
|
||||
|
||||
const payload = result.data || {}
|
||||
models.value = Array.isArray(payload.models) ? payload.models : []
|
||||
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
|
||||
|
||||
@@ -320,14 +311,15 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
authPolling.value = true
|
||||
clearPollTimer()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`llm/provider-auth/${authSession.value.session_id}/poll`)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Poll LLM auth failed')
|
||||
}
|
||||
const result = await api.post<Partial<LlmProviderAuthSession>>(
|
||||
`llm/provider-auth/${authSession.value.session_id}/poll`,
|
||||
undefined,
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
authSession.value = {
|
||||
...authSession.value,
|
||||
...result.data,
|
||||
...result,
|
||||
}
|
||||
const nextSession = authSession.value
|
||||
if (!nextSession) return null
|
||||
@@ -355,19 +347,12 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
throw new Error('LLM provider is required')
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
|
||||
const result = await api.post<LlmProviderAuthSession>('llm/provider-auth/start', {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
method: methodId,
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Start LLM auth failed')
|
||||
}
|
||||
|
||||
authSession.value = {
|
||||
status: 'pending',
|
||||
provider_id: normalizeValue(options.provider.value),
|
||||
...result.data,
|
||||
}
|
||||
authSession.value = result
|
||||
authDialogVisible.value = true
|
||||
authPopupBlocked.value = false
|
||||
openAuthPage()
|
||||
@@ -378,12 +363,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
async function disconnectAuth() {
|
||||
if (!selectedProvider.value) return false
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Disconnect LLM auth failed')
|
||||
}
|
||||
await api.delete(`llm/provider-auth/${normalizeValue(options.provider.value)}`)
|
||||
|
||||
await loadProviders()
|
||||
return true
|
||||
|
||||
@@ -256,9 +256,10 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
音乐: 'system/setting/public/DefaultMusicSubscribeConfig',
|
||||
}[media?.type || '']
|
||||
if (!subscribeConfigUrl) return undefined
|
||||
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
||||
|
||||
return result.data?.value
|
||||
const result = await api.get<{ value?: SubscribeConfig }>(subscribeConfigUrl, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
return result.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -296,7 +297,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
const result = await api.post<Subscribe>(
|
||||
'subscribe/',
|
||||
{
|
||||
name: media.title,
|
||||
type: media.type,
|
||||
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
|
||||
@@ -309,24 +312,19 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
season: media.type === '电影' ? null : season,
|
||||
...payload,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
const subscribeSeason = media.type === '电影' ? null : season
|
||||
const subscribeMode = getSubscribeMode(payload)
|
||||
if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode)
|
||||
updateSubscribeStatus(subscribeSeason, true, subscribeMode)
|
||||
showSubscribeAddToast(true, media.title ?? '', season, '', payload.best_version ?? 0)
|
||||
|
||||
showSubscribeAddToast(
|
||||
result.success,
|
||||
media.title ?? '',
|
||||
season,
|
||||
result.message ?? t('subscribe.requestFailed'),
|
||||
payload.best_version ?? 0,
|
||||
)
|
||||
|
||||
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||
if (addOptions.openEditDialog ?? true) {
|
||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
||||
if (subscribeConfig?.show_edit_dialog && result.data?.id) {
|
||||
openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode)
|
||||
if (subscribeConfig?.show_edit_dialog && result.id) {
|
||||
openSubscribeEditDialog(result.id, subscribeSeason, subscribeMode)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -362,25 +360,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`subscribe/media/${encodeURIComponent(identity.mediaId)}`,
|
||||
{
|
||||
await api.delete(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
media_source: identity.source,
|
||||
season: media.type === '电影' ? null : season,
|
||||
music_type: getMusicSubscribeType(media),
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(
|
||||
`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
@@ -399,6 +389,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
if (!identity) return false
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
media_source: identity.source,
|
||||
season,
|
||||
@@ -421,6 +412,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
if (!identity) return null
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${encodeURIComponent(identity.mediaId)}`, {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
media_source: identity.source,
|
||||
season,
|
||||
@@ -452,22 +444,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
}
|
||||
|
||||
const payload = getSubscribePayload(mode)
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', {
|
||||
await api.put(
|
||||
'subscribe/',
|
||||
{
|
||||
...subscribe,
|
||||
...payload,
|
||||
})
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(season, true, mode)
|
||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||
} else {
|
||||
$toast.error(
|
||||
`${title} ${t('subscribe.addFailed', {
|
||||
name: getModeName(t, mode),
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
|
||||
@@ -33,8 +33,8 @@ export function useMusicSiteSearch(buildRoute: MusicResourceRouteBuilder) {
|
||||
async function querySelectedSites() {
|
||||
selectedSites.value = []
|
||||
try {
|
||||
const result: { data?: { value?: number[] } } = await api.get('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
const result = await api.get<{ value?: number[] }>('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.value ?? []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ export function usePluginNativeSubscribe(): NativeSubscribe {
|
||||
const [subscribeResult, existsResult] = await Promise.allSettled([
|
||||
subscribeActions.checkSubscribe(null),
|
||||
api.get('mediaserver/exists', {
|
||||
feedback: 'silent',
|
||||
params: {
|
||||
mtype: currentMedia.type,
|
||||
media_source: currentMedia.media_source,
|
||||
@@ -147,13 +148,13 @@ export function usePluginNativeSubscribe(): NativeSubscribe {
|
||||
title: currentMedia.title,
|
||||
year: currentMedia.year,
|
||||
},
|
||||
}) as Promise<{ success?: boolean }>,
|
||||
}) as Promise<{ item?: { id?: string } }>,
|
||||
])
|
||||
|
||||
if (subscribeResult.status === 'fulfilled') isSubscribed.value = subscribeResult.value
|
||||
else console.error(subscribeResult.reason)
|
||||
|
||||
if (existsResult.status === 'fulfilled') isExists.value = Boolean(existsResult.value?.success)
|
||||
if (existsResult.status === 'fulfilled') isExists.value = Boolean(existsResult.value?.item?.id)
|
||||
else console.error(existsResult.reason)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import type { ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -49,11 +49,9 @@ export function useScheduleProgress(schedules: Ref<ScheduleInfo[]>, refreshId: s
|
||||
|
||||
/** 请求指定运行中任务的最新进度。 */
|
||||
async function loadScheduleProgress(schedule: ScheduleInfo) {
|
||||
const response = (await api.get(
|
||||
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
|
||||
)) as ApiResponse<ScheduleProgress>
|
||||
|
||||
return response.success ? response.data : undefined
|
||||
return api.get<ScheduleProgress>(`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import type { ApiResponse, User } from '@/api/types'
|
||||
import type { User } from '@/api/types'
|
||||
|
||||
export interface WizardData {
|
||||
basic: {
|
||||
@@ -988,14 +988,9 @@ export function useSetupWizard() {
|
||||
connectivityTest.value.testMessage = t('setupWizard.checkingStorage')
|
||||
|
||||
// 调用存储测试API - 使用FileManagerModule
|
||||
const result: { [key: string]: any } = await api.get('system/moduletest/FileManagerModule')
|
||||
await api.get<null>('system/moduletest/FileManagerModule', { feedback: 'silent' })
|
||||
connectivityTest.value.testProgress = 100
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: null }
|
||||
} else {
|
||||
return { success: false, message: result.message || t('setupWizard.storageTestFailed') }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Storage test failed:', error)
|
||||
return { success: false, message: (error as Error).message || t('setupWizard.storageTestFailed') }
|
||||
@@ -1025,14 +1020,9 @@ export function useSetupWizard() {
|
||||
return { success: false, message: t('setupWizard.unsupportedDownloaderType', { type: downloaderType }) }
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)
|
||||
await api.get<null>(`system/moduletest/${moduleid}`, { feedback: 'silent' })
|
||||
connectivityTest.value.testProgress = 100
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: null }
|
||||
} else {
|
||||
return { success: false, message: result.message || t('setupWizard.downloaderTestFailed') }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Downloader test failed:', error)
|
||||
return { success: false, message: (error as Error).message || t('setupWizard.downloaderTestFailed') }
|
||||
@@ -1062,14 +1052,9 @@ export function useSetupWizard() {
|
||||
return { success: false, message: t('setupWizard.unsupportedMediaServerType', { type: mediaServerType }) }
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)
|
||||
await api.get<null>(`system/moduletest/${moduleid}`, { feedback: 'silent' })
|
||||
connectivityTest.value.testProgress = 100
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: null }
|
||||
} else {
|
||||
return { success: false, message: result.message || t('setupWizard.mediaServerTestFailed') }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Media server test failed:', error)
|
||||
return { success: false, message: (error as Error).message || t('setupWizard.mediaServerTestFailed') }
|
||||
@@ -1100,14 +1085,9 @@ export function useSetupWizard() {
|
||||
return { success: false, message: t('setupWizard.unsupportedNotificationType', { type: notificationType }) }
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)
|
||||
await api.get<null>(`system/moduletest/${moduleid}`, { feedback: 'silent' })
|
||||
connectivityTest.value.testProgress = 100
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: null }
|
||||
} else {
|
||||
return { success: false, message: result.message || t('setupWizard.notificationTestFailed') }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Notification test failed:', error)
|
||||
return { success: false, message: (error as Error).message || t('setupWizard.notificationTestFailed') }
|
||||
@@ -1254,10 +1234,7 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
// 保存基础设置
|
||||
const response: { [key: string]: any } = await api.post('system/env', basicSettings)
|
||||
if (!response.success) {
|
||||
return false
|
||||
}
|
||||
await api.post<null>('system/env', basicSettings, { feedback: 'silent' })
|
||||
|
||||
// 如果输入了密码,验证密码一致性
|
||||
if (wizardData.value.basic.password) {
|
||||
@@ -1322,27 +1299,24 @@ export function useSetupWizard() {
|
||||
// 保存用户站点认证设置
|
||||
async function saveSiteAuthSettings() {
|
||||
try {
|
||||
const envResponse: { [key: string]: any } = await api.post('system/env', {
|
||||
AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable,
|
||||
})
|
||||
|
||||
if (!envResponse.success) {
|
||||
return false
|
||||
}
|
||||
await api.post<null>(
|
||||
'system/env',
|
||||
{ AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable },
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
if (!wizardData.value.siteAuth.site) {
|
||||
return true
|
||||
}
|
||||
|
||||
const response: { [key: string]: any } = await api.post('site/auth', {
|
||||
await api.post<null>(
|
||||
'site/auth',
|
||||
{
|
||||
site: wizardData.value.siteAuth.site,
|
||||
params: wizardData.value.siteAuth.params,
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ feedback: 'silent' },
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -1482,11 +1456,7 @@ export function useSetupWizard() {
|
||||
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
|
||||
}
|
||||
|
||||
const response: Pick<ApiResponse<unknown>, 'success' | 'message'> = await api.post('system/env', agentSettings)
|
||||
if (!response.success) {
|
||||
$toast.error(response.message || t('setupWizard.saveAgentSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
await api.post<null>('system/env', agentSettings, { feedback: 'silent' })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save agent settings failed:', error)
|
||||
@@ -1502,20 +1472,17 @@ export function useSetupWizard() {
|
||||
if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {
|
||||
try {
|
||||
// 保存当前选中的规则组到 UserFilterRuleGroups
|
||||
const filterResponse: { [key: string]: any } = await api.post(
|
||||
'system/setting/UserFilterRuleGroups',
|
||||
wizardData.value.preferences.ruleSequences,
|
||||
)
|
||||
if (filterResponse.success) {
|
||||
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
// 保存规则组名称到其他设置
|
||||
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])
|
||||
|
||||
// 保存到 SubscribeFilterRuleGroups
|
||||
await api.post('system/setting/SubscribeFilterRuleGroups', ruleGroupNames)
|
||||
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
|
||||
// 保存到 BestVersionFilterRuleGroups
|
||||
await api.post('system/setting/BestVersionFilterRuleGroups', ruleGroupNames)
|
||||
}
|
||||
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
} catch (error) {
|
||||
console.error('Save rule sequences failed:', error)
|
||||
}
|
||||
@@ -1531,10 +1498,8 @@ export function useSetupWizard() {
|
||||
// 保存设置向导完成状态
|
||||
async function saveSetupWizardState() {
|
||||
try {
|
||||
const response: { [key: string]: any } = await api.post('system/setting/SetupWizardState', '1')
|
||||
if (response.success) {
|
||||
await api.post<null>('system/setting/SetupWizardState', '1', { feedback: 'silent' })
|
||||
console.log('Setup wizard state saved successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save setup wizard state failed:', error)
|
||||
// 这里不显示错误提示,因为向导状态保存失败不应该阻止用户完成向导
|
||||
@@ -1544,70 +1509,68 @@ export function useSetupWizard() {
|
||||
// 加载系统设置
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
if (result.success) {
|
||||
const result: Record<string, any> = await api.get('system/env')
|
||||
// 加载基础设置
|
||||
if (result.data.APP_DOMAIN) {
|
||||
wizardData.value.basic.appDomain = result.data.APP_DOMAIN
|
||||
if (result.APP_DOMAIN) {
|
||||
wizardData.value.basic.appDomain = result.APP_DOMAIN
|
||||
}
|
||||
if (result.data.API_TOKEN) {
|
||||
wizardData.value.basic.apiToken = result.data.API_TOKEN
|
||||
if (result.API_TOKEN) {
|
||||
wizardData.value.basic.apiToken = result.API_TOKEN
|
||||
}
|
||||
if (result.data.PROXY_HOST) {
|
||||
wizardData.value.basic.proxyHost = result.data.PROXY_HOST
|
||||
if (result.PROXY_HOST) {
|
||||
wizardData.value.basic.proxyHost = result.PROXY_HOST
|
||||
}
|
||||
if (result.data.OCR_HOST) {
|
||||
wizardData.value.basic.ocrHost = result.data.OCR_HOST
|
||||
if (result.OCR_HOST) {
|
||||
wizardData.value.basic.ocrHost = result.OCR_HOST
|
||||
}
|
||||
if (result.data.GITHUB_TOKEN) {
|
||||
wizardData.value.basic.githubToken = result.data.GITHUB_TOKEN
|
||||
if (result.GITHUB_TOKEN) {
|
||||
wizardData.value.basic.githubToken = result.GITHUB_TOKEN
|
||||
}
|
||||
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.data.AUXILIARY_AUTH_ENABLE)
|
||||
if (result.data.SUPERUSER) {
|
||||
wizardData.value.basic.username = result.data.SUPERUSER
|
||||
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.AUXILIARY_AUTH_ENABLE)
|
||||
if (result.SUPERUSER) {
|
||||
wizardData.value.basic.username = result.SUPERUSER
|
||||
}
|
||||
wizardData.value.agent.enabled = Boolean(result.data.AI_AGENT_ENABLE)
|
||||
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
|
||||
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
|
||||
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
|
||||
wizardData.value.agent.enabled = Boolean(result.AI_AGENT_ENABLE)
|
||||
wizardData.value.agent.global = Boolean(result.AI_AGENT_GLOBAL)
|
||||
wizardData.value.agent.verbose = Boolean(result.AI_AGENT_VERBOSE)
|
||||
wizardData.value.agent.provider = result.LLM_PROVIDER || 'deepseek'
|
||||
wizardData.value.agent.authConnected = false
|
||||
wizardData.value.agent.model = result.data.LLM_MODEL || ''
|
||||
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
|
||||
wizardData.value.agent.apiProtocol = result.data.LLM_API_PROTOCOL || 'auto'
|
||||
wizardData.value.agent.webSearchMode = result.data.LLM_WEB_SEARCH_MODE || 'local'
|
||||
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
|
||||
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
|
||||
wizardData.value.agent.useProxy = result.data.LLM_USE_PROXY ?? true
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||
const agentTemperature = Number(result.data.LLM_TEMPERATURE ?? 0.3)
|
||||
wizardData.value.agent.model = result.LLM_MODEL || ''
|
||||
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result)
|
||||
wizardData.value.agent.apiProtocol = result.LLM_API_PROTOCOL || 'auto'
|
||||
wizardData.value.agent.webSearchMode = result.LLM_WEB_SEARCH_MODE || 'local'
|
||||
wizardData.value.agent.supportImageInput = result.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||
wizardData.value.agent.supportAudioInput = Boolean(result.LLM_SUPPORT_AUDIO_INPUT)
|
||||
wizardData.value.agent.supportAudioOutput = Boolean(result.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.LLM_API_KEY || ''
|
||||
wizardData.value.agent.baseUrl = result.LLM_BASE_URL || ''
|
||||
wizardData.value.agent.useProxy = result.LLM_USE_PROXY ?? true
|
||||
wizardData.value.agent.baseUrlPreset = result.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.LLM_USER_AGENT || ''
|
||||
const agentTemperature = Number(result.LLM_TEMPERATURE ?? 0.3)
|
||||
wizardData.value.agent.temperature = Number.isFinite(agentTemperature) ? agentTemperature : 0.3
|
||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioInputModel = result.data.AUDIO_INPUT_MODEL || 'gpt-4o-mini-transcribe'
|
||||
wizardData.value.agent.audioInputLanguage = result.data.AUDIO_INPUT_LANGUAGE || 'zh'
|
||||
wizardData.value.agent.audioOutputProvider = result.data.AUDIO_OUTPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioOutputApiKey = result.data.AUDIO_OUTPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioOutputBaseUrl = result.data.AUDIO_OUTPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioOutputModel = result.data.AUDIO_OUTPUT_MODEL || 'gpt-4o-mini-tts'
|
||||
wizardData.value.agent.audioOutputVoice = result.data.AUDIO_OUTPUT_VOICE || 'alloy'
|
||||
wizardData.value.agent.audioOutputIncludeText = Boolean(result.data.AUDIO_OUTPUT_INCLUDE_TEXT)
|
||||
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
|
||||
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
|
||||
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)
|
||||
wizardData.value.agent.recommendUserPreference = result.data.AI_RECOMMEND_USER_PREFERENCE || ''
|
||||
wizardData.value.agent.recommendMaxItems = result.data.AI_RECOMMEND_MAX_ITEMS || 50
|
||||
wizardData.value.agent.audioInputProvider = result.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.AUDIO_INPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioInputModel = result.AUDIO_INPUT_MODEL || 'gpt-4o-mini-transcribe'
|
||||
wizardData.value.agent.audioInputLanguage = result.AUDIO_INPUT_LANGUAGE || 'zh'
|
||||
wizardData.value.agent.audioOutputProvider = result.AUDIO_OUTPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioOutputApiKey = result.AUDIO_OUTPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioOutputBaseUrl = result.AUDIO_OUTPUT_BASE_URL || ''
|
||||
wizardData.value.agent.audioOutputModel = result.AUDIO_OUTPUT_MODEL || 'gpt-4o-mini-tts'
|
||||
wizardData.value.agent.audioOutputVoice = result.AUDIO_OUTPUT_VOICE || 'alloy'
|
||||
wizardData.value.agent.audioOutputIncludeText = Boolean(result.AUDIO_OUTPUT_INCLUDE_TEXT)
|
||||
wizardData.value.agent.jobInterval = result.AI_AGENT_JOB_INTERVAL || 0
|
||||
wizardData.value.agent.retryTransfer = Boolean(result.AI_AGENT_RETRY_TRANSFER)
|
||||
wizardData.value.agent.recommendEnabled = Boolean(result.AI_RECOMMEND_ENABLED)
|
||||
wizardData.value.agent.recommendUserPreference = result.AI_RECOMMEND_USER_PREFERENCE || ''
|
||||
wizardData.value.agent.recommendMaxItems = result.AI_RECOMMEND_MAX_ITEMS || 50
|
||||
|
||||
// 如果没有API Token,则创建一个随机的
|
||||
if (!wizardData.value.basic.apiToken) {
|
||||
createRandomString()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Load system settings failed:', error)
|
||||
}
|
||||
@@ -1625,10 +1588,12 @@ export function useSetupWizard() {
|
||||
// 加载用户站点认证设置
|
||||
async function loadSiteAuthSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserSiteAuthParams')
|
||||
if (result.success && result.data?.value) {
|
||||
wizardData.value.siteAuth.site = result.data.value.site || ''
|
||||
wizardData.value.siteAuth.params = result.data.value.params || {}
|
||||
const result = await api.get<{ value?: { site?: string; params?: Record<string, any> } }>(
|
||||
'system/setting/UserSiteAuthParams',
|
||||
)
|
||||
if (result.value) {
|
||||
wizardData.value.siteAuth.site = result.value.site || ''
|
||||
wizardData.value.siteAuth.params = result.value.params || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Load site auth settings failed:', error)
|
||||
@@ -1638,9 +1603,9 @@ export function useSetupWizard() {
|
||||
// 加载存储设置
|
||||
async function loadStorageSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||
const directory = result.data.value[0]
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/public/Directories')
|
||||
if (result.value?.length) {
|
||||
const directory = result.value[0]
|
||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||
wizardData.value.storage.libraryPath = directory.library_path || ''
|
||||
wizardData.value.storage.transferType = directory.transfer_type || 'link'
|
||||
@@ -1654,9 +1619,9 @@ export function useSetupWizard() {
|
||||
// 加载下载器设置
|
||||
async function loadDownloaderSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
|
||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||
const downloader = result.data.value[0]
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Downloaders')
|
||||
if (result.value?.length) {
|
||||
const downloader = result.value[0]
|
||||
wizardData.value.downloader.type = downloader.type
|
||||
wizardData.value.downloader.name = downloader.name
|
||||
wizardData.value.downloader.config = downloader.config || {}
|
||||
@@ -1669,9 +1634,9 @@ export function useSetupWizard() {
|
||||
// 加载媒体服务器设置
|
||||
async function loadMediaServerSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||
const mediaServer = result.data.value[0]
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/MediaServers')
|
||||
if (result.value?.length) {
|
||||
const mediaServer = result.value[0]
|
||||
wizardData.value.mediaServer.type = mediaServer.type
|
||||
wizardData.value.mediaServer.name = mediaServer.name
|
||||
wizardData.value.mediaServer.config = mediaServer.config || {}
|
||||
@@ -1685,9 +1650,9 @@ export function useSetupWizard() {
|
||||
// 加载通知设置
|
||||
async function loadNotificationSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||
const notification = result.data.value[0]
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Notifications')
|
||||
if (result.value?.length) {
|
||||
const notification = result.value[0]
|
||||
wizardData.value.notification.type = notification.type
|
||||
wizardData.value.notification.name = notification.name
|
||||
wizardData.value.notification.enabled = notification.enabled
|
||||
|
||||
@@ -211,9 +211,7 @@ function rebuildNotificationKeys() {
|
||||
/** 清理已移除通知的展开状态,避免虚拟列表复用时保留无效 key。 */
|
||||
function rebuildExpandedNotificationKeys() {
|
||||
const currentKeys = new Set(notificationList.value.map(getNotificationExpansionKey))
|
||||
expandedNotificationKeys.value = new Set(
|
||||
[...expandedNotificationKeys.value].filter(key => currentKeys.has(key)),
|
||||
)
|
||||
expandedNotificationKeys.value = new Set([...expandedNotificationKeys.value].filter(key => currentKeys.has(key)))
|
||||
}
|
||||
|
||||
/** 列表内容变化后同步未读红点和应用角标状态。 */
|
||||
@@ -269,10 +267,11 @@ function getClearSuccessText(scope: NotificationClearScope) {
|
||||
|
||||
/** 调用后端记录清理范围,后续分页查询会直接返回过滤后的通知。 */
|
||||
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
|
||||
const result: { [key: string]: any } = await api.delete('message/notification', {
|
||||
await api.delete('message/notification', {
|
||||
params: { scope },
|
||||
feedback: 'silent',
|
||||
})
|
||||
return result?.success !== false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
|
||||
@@ -604,13 +603,13 @@ watch(appsMenu, handleNotificationMenuVisibleChange)
|
||||
|
||||
<VVirtualScroll
|
||||
v-if="notificationList.length > 0"
|
||||
renderless
|
||||
:renderless="true"
|
||||
:items="notificationDisplayList"
|
||||
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||
>
|
||||
<template #default="{ item, itemRef }">
|
||||
<template #default="{ item, ...slotProps }">
|
||||
<div
|
||||
:ref="itemRef"
|
||||
:ref="'itemRef' in slotProps ? slotProps.itemRef : undefined"
|
||||
:key="item.key"
|
||||
class="notification-virtual-item"
|
||||
:class="{ 'notification-virtual-item--section': item.kind === 'section' }"
|
||||
@@ -662,7 +661,9 @@ watch(appsMenu, handleNotificationMenuVisibleChange)
|
||||
{{ item.notification.text }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span v-if="item.notification.mtype" class="notification-type">{{ item.notification.mtype }}</span>
|
||||
<span v-if="item.notification.mtype" class="notification-type">{{
|
||||
item.notification.mtype
|
||||
}}</span>
|
||||
<span>{{ formatDateDifference(getNotificationTime(item.notification)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,8 +107,8 @@ function closeRestartProgress() {
|
||||
// 检测服务状态
|
||||
async function checkServiceStatus(): Promise<boolean> {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/ping', { timeout: 3000 })
|
||||
return result?.success === true
|
||||
await api.get<null>('system/ping', { timeout: 3000, feedback: 'silent' })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -175,14 +175,7 @@ async function restart() {
|
||||
try {
|
||||
// 显示等待框
|
||||
showRestartProgress()
|
||||
const result: { [key: string]: any } = await api.get('system/restart')
|
||||
if (!result?.success) {
|
||||
// 重启失败,清理状态
|
||||
isRestarting.value = false
|
||||
closeRestartProgress()
|
||||
$toast.error(result.message)
|
||||
return
|
||||
}
|
||||
await api.get<null>('system/restart')
|
||||
} catch (error) {
|
||||
// 重启失败,清理状态
|
||||
isRestarting.value = false
|
||||
@@ -405,12 +398,12 @@ async function getCustomCSS() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
|
||||
if (result && result.success && result.data?.value) {
|
||||
customCSS.value = result.data?.value ?? ''
|
||||
const result = await api.get<{ value?: string }>('system/setting/UserCustomCSS')
|
||||
if (result.value) {
|
||||
customCSS.value = result.value
|
||||
if (customCSS.value) {
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML = result.data?.value ?? ''
|
||||
style.innerHTML = result.value
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}
|
||||
@@ -467,17 +460,15 @@ async function saveCustomCSS(css: string) {
|
||||
|
||||
customCSS.value = css
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
|
||||
await api.post<null>('system/setting/UserCustomCSS', css, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
customCssDialogController?.close()
|
||||
customCssDialogController = null
|
||||
$toast.success(t('theme.customCssSaveSuccess'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('theme.customCssSaveFailed'))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ export default {
|
||||
networkDisconnected: 'Network connection lost',
|
||||
featuresLimited: 'Some features may be limited',
|
||||
serverConnectionFailed: 'Server connection failed',
|
||||
networkConnectionFailed: 'Network connection failed',
|
||||
requestTimeout: 'Request timed out',
|
||||
invalidApiResponse: 'The server returned an invalid response',
|
||||
apiRequestFailed: 'Request failed',
|
||||
troubleshooting: 'Troubleshooting',
|
||||
checking: 'Checking',
|
||||
retry: 'Retry',
|
||||
@@ -2476,7 +2480,7 @@ export default {
|
||||
'Word to replace => Replacement\n' +
|
||||
'Front word <> Back word >> Episode offset (EP)\n' +
|
||||
'Word to replace => Replacement && Front word <> Back word >> Episode offset (EP)\n' +
|
||||
'Replacement format supports: {[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to specify a media source and its native ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
'Replacement format supports: {[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to directly specify a media data source ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
identifierSaveSuccess: 'Custom identifiers saved successfully',
|
||||
identifierSaveFailed: 'Failed to save custom identifiers!',
|
||||
|
||||
@@ -3476,7 +3480,7 @@ export default {
|
||||
customWords: 'Custom Recognition Words',
|
||||
customWordsHint: 'Recognition words only used for this subscription',
|
||||
customWordsPlaceholder:
|
||||
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: {[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to specify a media source and its native ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: {[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to directly specify a media data source ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
cancelSubscribe: 'Cancel Subscription',
|
||||
save: 'Save',
|
||||
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
|
||||
|
||||
@@ -60,6 +60,10 @@ export default {
|
||||
networkDisconnected: '网络连接已断开',
|
||||
featuresLimited: '部分功能可能受限',
|
||||
serverConnectionFailed: '服务器连接失败',
|
||||
networkConnectionFailed: '网络连接失败',
|
||||
requestTimeout: '请求超时',
|
||||
invalidApiResponse: '服务器返回了无效响应',
|
||||
apiRequestFailed: '请求失败',
|
||||
troubleshooting: '疑难解答',
|
||||
checking: '检查中',
|
||||
retry: '重试',
|
||||
@@ -2434,7 +2438,7 @@ export default {
|
||||
'被替换词 => 替换词\n' +
|
||||
'前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'其中替换词支持格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源和ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
'其中替换词支持格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
identifierSaveSuccess: '自定义识别词保存成功',
|
||||
identifierSaveFailed: '自定义识别词保存失败!',
|
||||
|
||||
@@ -3418,7 +3422,7 @@ export default {
|
||||
customWords: '自定义识别词',
|
||||
customWordsHint: '只对该订阅使用的识别词',
|
||||
customWordsPlaceholder:
|
||||
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP)\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源和ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP)\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:{[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
cancelSubscribe: '取消订阅',
|
||||
save: '保存',
|
||||
cancelSubscribeConfirm: '是否确认取消订阅?',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user