refactor(api): adopt unified responses and restore media config hints

This commit is contained in:
jxxghp
2026-08-12 23:15:13 +08:00
parent 4fce4cd171
commit 0ea12f75f7
164 changed files with 4080 additions and 3629 deletions
+32 -84
View File
@@ -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": {
+35 -14
View File
@@ -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
View File
@@ -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
}
+237
View File
@@ -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)
})
})
+55
View File
@@ -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')
})
})
+348
View File
@@ -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)
}
+59 -104
View File
@@ -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
}
// 声明全局变量类型
declare global {
interface Window {
MoviePilotAPI: typeof api
}
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',
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 初始化请求优化器(必须在其他拦截器之前)
initializeRequestOptimizer(api)
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
const authStore = useAuthStore()
// 在请求头中添加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 { 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]),
})
// 离线状态管理
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'
declare global {
interface Window {
MoviePilotAPI: AxiosInstance
}
return null
}
interface LocalizedApiPayload {
detail?: unknown
detail_i18n?: unknown
message?: unknown
message_i18n?: unknown
/** 为两个客户端安装同一套取消、认证和语言请求头。 */
function initializeClient(instance: AxiosInstance | DataApiClient) {
initializeRequestOptimizer(instance)
instance.interceptors.request.use(config => {
const authStore = useAuthStore()
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
})
}
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
function normalizeLocalizedMessage(payload: any): any {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
// 插件远程组件依赖完整 envelope ABI,内部页面则默认使用已解包的数据客户端。
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
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
View File
@@ -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
}
// 分类规则
+66 -78
View File
@@ -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,11 +2209,13 @@ 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(() => {
// 本地中止优先,停止接口失败不阻塞用户操作。
})
}
abortController?.abort()
}
@@ -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
View File
@@ -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', () => ({
+5 -6
View File
@@ -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 {
+5 -4
View File
@@ -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
+9 -11
View File
@@ -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 }))
}
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
emit('install')
} 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)
+42 -66
View File
@@ -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) {
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
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,
}),
)
}
emit('remove')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} 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) {
$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,
}),
)
}
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)
} 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,27 +266,18 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
},
})
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
emit('save')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} else {
$toast.error(
t('plugin.updateFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
emit('save')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} 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}`, {
suffix: cloneForm.suffix.trim(),
name: cloneForm.name.trim(),
description: cloneForm.description.trim(),
version: cloneForm.version.trim(),
icon: cloneForm.icon.trim(),
})
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 }))
}
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
cloneDialogController?.close()
cloneDialogController = null
emit('remove')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} 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()
+9 -10
View File
@@ -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)
+21 -30
View File
@@ -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) {
// 通知父组件刷新
emit('remove')
}
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) {
$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 }))
}
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')
} 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) {
$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 }))
await api.get(`subscribe/reset/${props.media?.id}`, { feedback: 'silent' })
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
subscribeState.value = 'R'
emit('save')
} 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)
}
}
+2 -3
View File
@@ -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 ''
+2 -3
View File
@@ -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 ''
+2 -3
View File
@@ -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 ''
+2 -3
View File
@@ -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 ''
+3 -7
View File
@@ -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) {
$toast.success(t('user.deleteSuccess'))
emit('remove')
} else {
$toast.error(t('user.deleteFailed'))
}
await api.delete(`user/id/${props.user.id}`, { feedback: 'silent' })
$toast.success(t('user.deleteSuccess'))
emit('remove')
} catch (error) {
$toast.error(t('user.deleteFailed'))
console.log(error)
+15 -36
View File
@@ -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) {
$toast.success(t('workflow.task.deleteSuccess'))
emit('refresh')
} else {
$toast.error(t('workflow.task.deleteFailed', { message: result.message }))
}
await api.delete(`workflow/${item.id}`)
$toast.success(t('workflow.task.deleteSuccess'))
emit('refresh')
} 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) {
$toast.success(t('workflow.task.enableSuccess'))
emit('refresh')
} else {
$toast.error(t('workflow.task.enableFailed', { message: result.message }))
}
await api.post(`workflow/${item.id}/start`)
$toast.success(t('workflow.task.enableSuccess'))
emit('refresh')
} 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) {
$toast.success(t('workflow.task.pauseSuccess'))
emit('refresh')
} else {
$toast.error(t('workflow.task.pauseFailed', { message: result.message }))
}
await api.post(`workflow/${item.id}/pause`)
$toast.success(t('workflow.task.pauseSuccess'))
emit('refresh')
} 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')
}
$toast.success(t('workflow.task.runSuccess'))
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) {
$toast.success(t('workflow.task.resetSuccess'))
emit('refresh')
} else {
$toast.error(t('workflow.task.resetFailed', { message: result.message }))
}
await api.post(`workflow/${item.id}/reset`)
$toast.success(t('workflow.task.resetSuccess'))
emit('refresh')
} 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 }))
+4 -9
View File
@@ -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
+18 -26
View File
@@ -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 {
//
$toast.error(
t('dialog.addDownload.downloadFailed', {
site: props.torrent?.site_name,
title: props.torrent?.title,
message: result?.message,
}),
)
//
emit('error', result?.message)
}
//
$toast.success(
t('dialog.addDownload.downloadSuccess', { site: props.torrent?.site_name, title: props.torrent?.title }),
)
//
emit('done', props.torrent?.enclosure)
} 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,
}),
)
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,29 +153,26 @@ 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,
title: props.subtitle?.title,
}),
)
emit('done', props.subtitle?.enclosure)
} else {
$toast.error(
t('dialog.addSubtitleDownload.downloadFailed', {
site: props.subtitle?.site_name,
title: props.subtitle?.title,
message: result?.message,
}),
)
emit('error', result?.message)
}
$toast.success(
t('dialog.addSubtitleDownload.downloadSuccess', {
site: props.subtitle?.site_name,
title: props.subtitle?.title,
}),
)
emit('done', props.subtitle?.enclosure)
} catch (error) {
console.error(error)
emit('error', String(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,
}),
)
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) {
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
emit('saved', servers)
dialogVisible.value = false
return
}
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
await api.post<null>('message/agent/mcp/servers', { servers }, { feedback: 'silent' })
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
emit('saved', servers)
dialogVisible.value = false
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
+3 -5
View File
@@ -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) {
//
handleDone()
}
await api.get(`/storage/reset/${props.type}`)
//
handleDone()
} catch (e) {
console.error(e)
}
+25 -35
View File
@@ -41,62 +41,52 @@ 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
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
const result = await api.get<{ codeUrl: string }>('/storage/qrcode/alipan', { feedback: 'silent' })
qrCodeUrl.value = result.codeUrl
timeoutTimer = setTimeout(checkQrcode, 3000)
} 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
if (qrCodeStatus == 'LoginSuccess') {
//
alertType.value = 'success'
handleDone()
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
//
alertType.value = 'info'
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
//
alertType.value = 'error'
}
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'
handleDone()
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
//
alertType.value = 'info'
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} 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) {
//
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
await api.get<null>('/storage/reset/alipan', { feedback: 'silent' })
//
alertType.value = 'success'
handleDone()
} catch (e) {
console.error(e)
alertType.value = 'error'
text.value = e instanceof Error ? e.message : t('common.apiRequestFailed')
}
}
+7 -13
View File
@@ -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) {
toast.success(t('setting.category.saveSuccess'))
emit('save')
emit('close')
} else {
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
}
await api.post<null>('media/category/config', payload, { feedback: 'silent' })
toast.success(t('setting.category.saveSuccess'))
emit('save')
emit('close')
} 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) {
historyList.value = historyList.value.filter(history => history.id !== item.id)
return
}
$toast.error(t('dialog.downloadHistory.deleteFailed'))
await api.delete('history/download', { data: item, feedback: 'silent' })
historyList.value = historyList.value.filter(history => history.id !== item.id)
} 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
+352 -352
View File
@@ -187,363 +187,363 @@ onMounted(() => {
<template>
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.incomplete_files_ext"
:label="t('downloader.incomplete_files_ext')"
:hint="t('downloader.incomplete_files_extHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.rename_partial_files"
:label="t('downloader.rename_partial_files')"
:hint="t('downloader.rename_partial_filesHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.incomplete_files_ext"
:label="t('downloader.incomplete_files_ext')"
:hint="t('downloader.incomplete_files_extHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.rename_partial_files"
:label="t('downloader.rename_partial_files')"
:hint="t('downloader.rename_partial_filesHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard
v-for="(row, index) in pathMappingRows"
:key="row.id"
variant="outlined"
class="path-mapping-card my-2"
>
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCard
v-for="(row, index) in pathMappingRows"
:key="row.id"
variant="outlined"
class="path-mapping-card my-2"
>
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="(v: string) => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="d-none d-sm-block" />
<VCol cols="12" sm="8">
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="d-none d-sm-block" />
<VCol cols="12" sm="8">
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
+23 -30
View File
@@ -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) {
queryFollowUsers()
}
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()
}
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) {
$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 }))
}
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.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 }))
}
$toast.success(t('subscribe.cancelSuccess'))
//
emit('delete')
} 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()
+16 -18
View File
@@ -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) {
$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 }))
}
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.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 }))
}
$toast.success(t('workflow.cancelSuccess'))
//
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()
File diff suppressed because it is too large Load Diff
+14 -29
View File
@@ -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) {
$toast.success(t('profile.otpEnableSuccess'))
show.value = false
emit('update:isOtp', true)
} else {
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
}
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)
} 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) {
emit('update:isOtp', false)
$toast.success(t('profile.otpDisableSuccess'))
show.value = false
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
await api.post('mfa/otp/disable', { password }, { feedback: 'silent' })
emit('update:isOtp', false)
$toast.success(t('profile.otpDisableSuccess'))
show.value = false
} catch (error) {
console.error(error)
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
+19 -36
View File
@@ -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 || []
emit('update:passkeyList', passkeyList.value)
} else {
passkeyListFailed.value = true
}
passkeyList.value = result || []
emit('update:passkeyList', passkeyList.value)
} 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'))
}
$toast.success(t('profile.passkeyRegisterSuccess'))
passkeyName.value = ''
await fetchPassKeyList()
} 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) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
await api.post('mfa/passkey/delete', { passkey_id: passkeyId, password }, { feedback: 'silent' })
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} catch (error) {
console.error(error)
$toast.error(t('profile.passkeyDeleteFailed'))
$toast.error(getApiBusinessErrorMessage(error) || t('profile.passkeyDeleteFailed'))
}
},
})
+8 -15
View File
@@ -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) {
$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 }))
}
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))
} 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 -2
View File
@@ -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,32 +142,29 @@ 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 })
: t('plugin.installSuccess', { name: props.plugin?.plugin_name }),
)
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
emit('install')
} else {
$toast.error(t(failureMessageKey, { name: props.plugin?.plugin_name, message: result.message }))
}
$toast.success(
isInstalled.value
? t('plugin.updateSuccess', { name: props.plugin?.plugin_name })
: t('plugin.installSuccess', { name: props.plugin?.plugin_name }),
)
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
emit('install')
} 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)
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
} else {
$toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') }))
}
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 }))
} 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) {
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
emit('save')
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
await api.post('system/setting/PLUGIN_MARKET', repoStringToSave, { feedback: 'silent' })
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
emit('save')
} 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
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,
}),
)
} else {
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
}
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.added_count ?? 0,
total: result.total_count ?? repos.length,
}),
)
} catch (error) {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
+2 -4
View File
@@ -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) {
handleDone()
}
await api.get('/storage/reset/rclone')
handleDone()
} catch (e) {
console.error(e)
}
+45 -100
View File
@@ -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,
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, result)
} catch (err: unknown) {
const errorMessage = getManualTransferErrorMessage(err, t('dialog.reorganize.previewRequestFailed'))
console.warn(`预览请求异常: ${errorMessage}`)
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,
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, 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: `${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,
createFailedPreviewData({
source: `历史记录 ${logid}`,
message: result.message || t('dialog.reorganize.previewRequestFailed'),
}),
)
return
}
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(mergedPreviewData, result)
} catch (err: unknown) {
const errorMessage = getManualTransferErrorMessage(err, t('dialog.reorganize.previewRequestFailed'))
console.warn(`预览请求异常: ${errorMessage}`)
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
}
}
+3 -3
View File
@@ -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) {
+15 -15
View File
@@ -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) {
$toast.success(t('site.messages.addSuccess'))
emit('save')
} else {
$toast.error(`${t('site.messages.addFailed')}${result.message}`)
}
await api.post('site/', siteForm.value, { feedback: 'silent' })
$toast.success(t('site.messages.addSuccess'))
emit('save')
} 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) {
$toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)
emit('save')
} else {
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}${result.message}`)
}
await api.put('site/', siteForm.value, { feedback: 'silent' })
$toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)
emit('save')
} 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}`, {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
})
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'),
}),
)
}
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
emit('done')
} 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
+4 -13
View File
@@ -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) {
//
successCount++
importSuccesses.value.push(site)
} else {
failCount++
//
importErrors.value.push({
site,
error: result.message || t('site.messages.importFailed'),
})
}
await api.post<null>('site/', siteData, { feedback: 'silent' })
//
successCount++
importSuccesses.value.push(site)
} 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)
+8 -16
View File
@@ -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) {
// 使nextTickDOM
await nextTick()
if (activeGeneration !== operationGeneration) return false
// 使nextTickDOM
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'
}
await fetchSiteUserData('refresh', generation)
} catch (error) {
if (generation !== operationGeneration) return
+3 -5
View File
@@ -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) {
//
handleDone()
}
await api.get('/storage/reset/smb')
//
handleDone()
} catch (e) {
console.error(e)
}
+17 -47
View File
@@ -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) {
$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'),
}),
)
}
await api.put<null>('subscribe/', subscribeForm.value, { feedback: 'silent' })
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
//
emit('save', subscribeForm.value)
} 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) {
$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'),
}),
)
}
await api.post<null>(subscribe_config_url, subscribeForm.value, { feedback: 'silent' })
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
//
emit('save', subscribeForm.value)
} 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) {
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
//
emit('remove')
} else {
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
await api.delete<null>(`subscribe/${props.subid}`, { feedback: 'silent' })
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
//
emit('remove')
} 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) {
emit('save')
} else {
$toast.error(t('subscribe.requestFailed'))
}
await api.post('subscribe/', item, { feedback: 'silent' })
emit('save')
} 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) {
historyList.value = historyList.value.filter(i => i.id !== item.id)
}
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
+5 -10
View File
@@ -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) {
$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 }))
}
await api.post('subscribe/share', shareForm.value, { feedback: 'silent' })
$toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))
//
emit('close')
} 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 {
+31 -65
View File
@@ -64,37 +64,24 @@ function handleDone() {
//
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
if (result.success) {
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
}
else {
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
}
}
catch (error) {
await api.get<null>('/storage/reset/u115', { feedback: 'silent' })
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
} 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,29 +120,25 @@ function openAuthWindow() {
//
async function checkAuthStatus() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/u115')
const result = await api.get<{ status: number; tip?: string }>('/storage/check/u115', { feedback: 'silent' })
const { status, tip } = result
if (result.success && result.data) {
const { status, tip } = result.data
if (status === AUTH_STATUS_SUCCESS) {
//
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
return
}
if (status === AUTH_STATUS_FAILED) {
//
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
cleanup()
return
}
// status === 0 1
if (status === AUTH_STATUS_SUCCESS) {
//
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
return
}
}
catch (error) {
if (status === AUTH_STATUS_FAILED) {
//
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
cleanup()
return
}
// status === 0 1
} 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>
+25 -35
View File
@@ -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) {
$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 = ''
}
await api.post<null>('user/', userForm.value, { feedback: 'silent' })
$toast.success(t('dialog.userAddEdit.userCreated', { name: userForm.value.name }))
emit('save')
} 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,37 +374,28 @@ 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}` }))
//
if (isCurrentUser.value) {
userStore.setUserName(currentUserName.value)
}
} else {
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: userForm.value?.name }))
}
//
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
userStore.setAvatar(currentAvatar.value)
}
//
if (oldUserName !== currentUserName.value) {
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: `${oldUserName}${currentUserName.value}` }))
//
if (isCurrentUser.value) {
userStore.setPermissions(userPermissions.value)
userStore.setUserName(currentUserName.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 }))
}
$toast.success(t('dialog.userAddEdit.userUpdateSuccess', { name: userForm.value?.name }))
}
//
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
userStore.setAvatar(currentAvatar.value)
}
//
if (isCurrentUser.value) {
userStore.setPermissions(userPermissions.value)
}
emit('save')
} 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 {
//
+11 -16
View File
@@ -64,12 +64,10 @@ 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
if (ret && !isNullOrEmptyObject(ret.params)) {
authForm.value = ret
}
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) {
$toast.success(t('dialog.userAuth.authSuccess'))
// 1
setTimeout(() => {
emit('done')
}, 1000)
} else {
$toast.error(t('dialog.userAuth.authFailed', { message: result.message }))
}
await api.post<null>(`site/auth`, authForm.value, { feedback: 'silent' })
$toast.success(t('dialog.userAuth.authSuccess'))
// 1
setTimeout(() => {
emit('done')
}, 1000)
} catch (e) {
console.error(e)
$toast.error(t('dialog.userAuth.authFailed', { message: e instanceof Error ? e.message : '' }))
}
loading.value = false
}
+16 -24
View File
@@ -48,10 +48,13 @@ const actionDefinitions = ref<any[]>([])
//
const actionContractMap = computed(() => {
return actionDefinitions.value.reduce((result, action) => {
result[action.type] = action.contract || {}
return result
}, {} as Record<string, any>)
return actionDefinitions.value.reduce(
(result, action) => {
result[action.type] = action.contract || {}
return result
},
{} 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) {
$toast.success(t('dialog.workflowActions.saveSuccess'))
emit('save')
} else {
$toast.error(t('dialog.workflowActions.saveFailed', { message: result.message }))
}
await api.put<null>(`workflow/${workflowForm.value.id}`, workflowForm.value)
$toast.success(t('dialog.workflowActions.saveSuccess'))
emit('save')
} 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) {
$toast.success(t('dialog.workflowAddEdit.addSuccess'))
emit('save')
} else {
$toast.error(t('dialog.workflowAddEdit.addFailed', { message: result.message }))
}
await api.post<null>('workflow/', workflowForm.value)
$toast.success(t('dialog.workflowAddEdit.addSuccess'))
emit('save')
} 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) {
$toast.success(t('dialog.workflowAddEdit.editSuccess'))
emit('save')
} else {
$toast.error(t('dialog.workflowAddEdit.editFailed', { message: result.message }))
}
await api.put<null>(`workflow/${workflowForm.value.id}`, workflowForm.value)
$toast.success(t('dialog.workflowAddEdit.editSuccess'))
emit('save')
} catch (error) {
console.error(error)
}
+12 -10
View File
@@ -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) {
$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 }))
}
await api.post('workflow/share', shareForm.value, { feedback: 'silent' })
$toast.success(t('dialog.workflowShare.shareSuccess', { name: props.workflow?.name }))
//
emit('close')
} 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,28 +30,24 @@ 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',
},
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 },
],
frontend_versions: [
{ version: 'frontend-hidden', count: 0 },
{ version: 'frontend-visible', count: 11 },
],
},
backend_versions: [
{ version: 'backend-hidden', count: 9 },
{ version: 'backend-visible', count: 10 },
],
frontend_versions: [
{ 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(),
ensureSidebarNav: vi.fn(),
loadRemoteComponent: vi.fn(),
nativeSubscribe: vi.fn(),
toast: { error: vi.fn(), success: 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(),
loadRemoteComponent: vi.fn(),
nativeSubscribe: vi.fn(),
toast: { error: vi.fn(), success: 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', () => ({
+2 -2
View File
@@ -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,
+28 -27
View File
@@ -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()
+4 -3
View File
@@ -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')
+6 -7
View File
@@ -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',
})
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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(
+12 -32
View File
@@ -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
+48 -61
View File
@@ -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,37 +297,34 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('subscribe/', {
name: media.title,
type: media.type,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '',
media_source: identity.source,
media_id: identity.mediaId,
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: getMusicSubscribeType(media),
total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined,
season: media.type === '电影' ? null : season,
...payload,
episode_group: episodeGroup.value,
})
const result = await api.post<Subscribe>(
'subscribe/',
{
name: media.title,
type: media.type,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '',
media_source: identity.source,
media_id: identity.mediaId,
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: getMusicSubscribeType(media),
total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined,
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)}`,
{
params: {
media_source: identity.source,
season: media.type === '电影' ? null : season,
music_type: getMusicSubscribeType(media),
},
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') })}`,
)
}
updateSubscribeStatus(media.type === '电影' ? null : season, false)
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
} 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/', {
...subscribe,
...payload,
})
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'),
})}`,
)
}
updateSubscribeStatus(season, true, mode)
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
} catch (error) {
console.error(error)
$toast.error(
+2 -2
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+4 -6
View File
@@ -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',
})
}
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
+113 -148
View File
@@ -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') }
}
return { success: true, message: null }
} 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') }
}
return { success: true, message: null }
} 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') }
}
return { success: true, message: null }
} 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') }
}
return { success: true, message: null }
} 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', {
site: wizardData.value.siteAuth.site,
params: wizardData.value.siteAuth.params,
})
if (!response.success) {
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))
return false
}
await api.post<null>(
'site/auth',
{
site: wizardData.value.siteAuth.site,
params: wizardData.value.siteAuth.params,
},
{ 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) {
// 保存规则组名称到其他设置
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])
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)
// 保存到 SubscribeFilterRuleGroups
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
// 保存到 BestVersionFilterRuleGroups
await api.post('system/setting/BestVersionFilterRuleGroups', ruleGroupNames)
}
// 保存到 BestVersionFilterRuleGroups
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) {
console.log('Setup wizard state saved successfully')
}
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,69 +1509,67 @@ export function useSetupWizard() {
// 加载系统设置
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
// 加载基础设置
if (result.data.APP_DOMAIN) {
wizardData.value.basic.appDomain = result.data.APP_DOMAIN
}
if (result.data.API_TOKEN) {
wizardData.value.basic.apiToken = result.data.API_TOKEN
}
if (result.data.PROXY_HOST) {
wizardData.value.basic.proxyHost = result.data.PROXY_HOST
}
if (result.data.OCR_HOST) {
wizardData.value.basic.ocrHost = result.data.OCR_HOST
}
if (result.data.GITHUB_TOKEN) {
wizardData.value.basic.githubToken = result.data.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.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.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.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
const result: Record<string, any> = await api.get('system/env')
// 加载基础设置
if (result.APP_DOMAIN) {
wizardData.value.basic.appDomain = result.APP_DOMAIN
}
if (result.API_TOKEN) {
wizardData.value.basic.apiToken = result.API_TOKEN
}
if (result.PROXY_HOST) {
wizardData.value.basic.proxyHost = result.PROXY_HOST
}
if (result.OCR_HOST) {
wizardData.value.basic.ocrHost = result.OCR_HOST
}
if (result.GITHUB_TOKEN) {
wizardData.value.basic.githubToken = result.GITHUB_TOKEN
}
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.AUXILIARY_AUTH_ENABLE)
if (result.SUPERUSER) {
wizardData.value.basic.username = result.SUPERUSER
}
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.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.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()
}
// 如果没有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>
+11 -20
View File
@@ -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'))
}
customCssDialogController?.close()
customCssDialogController = null
$toast.success(t('theme.customCssSaveSuccess'))
} catch (e) {
console.error(t('theme.customCssSaveFailed'))
}
+6 -2
View File
@@ -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: &#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 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: &#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 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: &#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 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: &#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 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?',
+6 -2
View File
@@ -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' +
'其中替换词支持格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
'其中替换词支持格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
identifierSaveSuccess: '自定义识别词保存成功',
identifierSaveFailed: '自定义识别词保存失败!',
@@ -3418,7 +3422,7 @@ export default {
customWords: '自定义识别词',
customWordsHint: '只对该订阅使用的识别词',
customWordsPlaceholder:
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:&#123;[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:&#123;[tmdbid/doubanid/bangumiid/anilistid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定媒体数据源ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
cancelSubscribe: '取消订阅',
save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?',

Some files were not shown because too many files have changed in this diff Show More