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
+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)
}