mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 10:00:08 +08:00
Add rTorrent as a downloader option in settings, setup wizard, and downloader card with config form (host, username, password) and ruTorrent logo. Include i18n translations for zh-CN, zh-TW, and en-US. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
609 lines
20 KiB
Vue
609 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import api from '@/api'
|
||
import { formatFileSize } from '@/@core/utils/formatters'
|
||
import { DownloaderConf } from '@/api/types'
|
||
import { useToast } from 'vue-toastification'
|
||
import type { DownloaderInfo } from '@/api/types'
|
||
import { getLogoUrl } from '@/utils/imageUtils'
|
||
import { cloneDeep } from 'lodash-es'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||
import { useDisplay } from 'vuetify'
|
||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||
|
||
// 显示器宽度
|
||
const display = useDisplay()
|
||
|
||
// 获取i18n实例
|
||
const { t } = useI18n()
|
||
const { useConditionalDataRefresh } = useBackgroundOptimization()
|
||
|
||
// 定义输入
|
||
const props = defineProps({
|
||
// 单个下载器
|
||
downloader: {
|
||
type: Object as PropType<DownloaderConf>,
|
||
required: true,
|
||
},
|
||
// 是否允许刷新数据
|
||
allowRefresh: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
// 所有下载器
|
||
downloaders: {
|
||
type: Array as PropType<DownloaderConf[]>,
|
||
required: true,
|
||
},
|
||
})
|
||
|
||
// 定义触发的自定义事件
|
||
const emit = defineEmits(['close', 'done', 'change'])
|
||
|
||
// 提示框
|
||
const $toast = useToast()
|
||
|
||
// 上传速率
|
||
const upload_rate = ref(0)
|
||
|
||
// 下载速度
|
||
const download_rate = ref(0)
|
||
|
||
// 下载器详情弹窗
|
||
const downloaderInfoDialog = ref(false)
|
||
|
||
// 表单
|
||
const downloaderForm = ref()
|
||
|
||
// 路径前缀选项
|
||
const prefixOptions = computed(() => {
|
||
return storageAttributes.map(item => ({
|
||
title: t(`storage.${item.type}`),
|
||
value: item.type,
|
||
}))
|
||
})
|
||
|
||
function getStorageType(path: string) {
|
||
if (!path) return 'local'
|
||
// 查找匹配的存储类型
|
||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||
return storage?.type || 'local'
|
||
}
|
||
|
||
function storage2Prefix(storage: string) {
|
||
return storage === 'local' ? '' : storage + ':'
|
||
}
|
||
|
||
// 获取存储路径前后缀
|
||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||
if (!path) return ['', '']
|
||
const storage = getStorageType(path)
|
||
const prefix = storage2Prefix(storage)
|
||
return [prefix, path.slice(prefix.length)]
|
||
}
|
||
|
||
// 更新存储路径前缀
|
||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||
const prefix = storage2Prefix(storage)
|
||
row.storage = prefix + currentSuffix
|
||
}
|
||
|
||
// 更新存储路径后缀
|
||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||
const [currentPrefix] = parseStoragePath(row.storage)
|
||
row.storage = currentPrefix + suffix
|
||
}
|
||
|
||
const pathValidationRules = [
|
||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||
]
|
||
|
||
// 下载器详情
|
||
const downloaderInfo = ref<DownloaderConf>({
|
||
name: '',
|
||
type: '',
|
||
default: false,
|
||
enabled: false,
|
||
config: {},
|
||
path_mapping: [],
|
||
})
|
||
|
||
// 路径映射行定义
|
||
interface PathMappingRow {
|
||
id: string
|
||
storage: string
|
||
download: string
|
||
}
|
||
|
||
// 路径映射行数据
|
||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||
|
||
// 生成随机ID
|
||
function generateId() {
|
||
return Math.random().toString(36).substring(2, 9)
|
||
}
|
||
|
||
// 下载器是否应该刷新数据的计算属性
|
||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||
|
||
// 调用API查询下载器数据
|
||
async function loadDownloaderInfo() {
|
||
if (!shouldRefresh.value) {
|
||
// 当下载器被禁用时,重置速率数据
|
||
upload_rate.value = 0
|
||
download_rate.value = 0
|
||
return
|
||
}
|
||
try {
|
||
const res: DownloaderInfo = await api.get('dashboard/downloader', {
|
||
params: {
|
||
name: props.downloader.name,
|
||
},
|
||
})
|
||
|
||
if (res) {
|
||
upload_rate.value = res.upload_speed
|
||
download_rate.value = res.download_speed
|
||
}
|
||
} catch (e) {
|
||
console.log(e)
|
||
}
|
||
}
|
||
|
||
// 打开详情弹窗
|
||
function openDownloaderInfoDialog() {
|
||
// 深复制
|
||
downloaderInfo.value = cloneDeep(props.downloader)
|
||
// 初始化路径映射行数据
|
||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||
id: generateId(),
|
||
storage: item[0],
|
||
download: item[1],
|
||
}))
|
||
downloaderInfoDialog.value = true
|
||
}
|
||
|
||
// 保存详情数据
|
||
async function saveDownloaderInfo() {
|
||
// 表单校验
|
||
const { valid } = await downloaderForm.value?.validate()
|
||
if (!valid) return
|
||
|
||
// 同步路径映射数据
|
||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||
|
||
// 为空不保存,跳出警告框
|
||
if (!downloaderInfo.value.name) {
|
||
$toast.error(t('downloader.nameRequired'))
|
||
return
|
||
}
|
||
// 重名判断
|
||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||
$toast.error(t('downloader.nameDuplicate'))
|
||
return
|
||
}
|
||
// 默认下载器去重
|
||
if (downloaderInfo.value.default) {
|
||
props.downloaders.forEach(item => {
|
||
if (item.default && item !== props.downloader) {
|
||
item.default = false
|
||
$toast.info(t('downloader.defaultChanged'))
|
||
}
|
||
})
|
||
}
|
||
// 执行保存
|
||
downloaderInfoDialog.value = false
|
||
emit('change', downloaderInfo.value, props.downloader.name)
|
||
emit('done')
|
||
}
|
||
|
||
// 根据存储类型选择图标
|
||
const getIcon = computed(() => {
|
||
switch (props.downloader.type) {
|
||
case 'qbittorrent':
|
||
return getLogoUrl('qbittorrent')
|
||
case 'transmission':
|
||
return getLogoUrl('transmission')
|
||
case 'rtorrent':
|
||
return getLogoUrl('rtorrent')
|
||
default:
|
||
return getLogoUrl('downloader')
|
||
}
|
||
})
|
||
|
||
// 添加路径映射
|
||
function addPathMapping() {
|
||
pathMappingRows.value.push({
|
||
id: generateId(),
|
||
storage: '',
|
||
download: '',
|
||
})
|
||
}
|
||
|
||
// 移除路径映射
|
||
function removePathMapping(index: number) {
|
||
pathMappingRows.value.splice(index, 1)
|
||
}
|
||
|
||
// 按钮点击
|
||
function onClose() {
|
||
emit('close')
|
||
}
|
||
|
||
// 使用条件性数据刷新定时器(只在下载器启用时运行)
|
||
const { stop: stopRefresh } = useConditionalDataRefresh(
|
||
`downloader-${props.downloader.name}`,
|
||
loadDownloaderInfo,
|
||
shouldRefresh, // 响应式条件:只有当allowRefresh为true且downloader启用时才运行
|
||
3000, // 3秒间隔
|
||
true, // 立即执行一次
|
||
)
|
||
|
||
onUnmounted(() => {
|
||
stopRefresh()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<VHover v-slot="hover">
|
||
<VCard
|
||
v-bind="hover.props"
|
||
variant="tonal"
|
||
@click="openDownloaderInfoDialog"
|
||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||
>
|
||
<VDialogCloseBtn @click="onClose" />
|
||
<span class="absolute top-3 right-12">
|
||
<IconBtn>
|
||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||
</IconBtn>
|
||
</span>
|
||
<VCardText class="flex justify-space-between align-center gap-4">
|
||
<div class="align-self-start flex-1">
|
||
<div class="flex items-center">
|
||
<VBadge
|
||
v-if="props.downloader.default && props.downloader.enabled"
|
||
dot
|
||
inline
|
||
color="success"
|
||
class="me-1"
|
||
/>
|
||
<span class="text-h6">{{ downloader.name }}</span>
|
||
</div>
|
||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
|
||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||
</div>
|
||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||
<span class="me-2">自定义下载器</span>
|
||
</div>
|
||
</div>
|
||
<div class="h-20">
|
||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||
</div>
|
||
</VCardText>
|
||
</VCard>
|
||
</VHover>
|
||
|
||
<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" 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.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>
|
||
</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>
|
||
</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>
|
||
|
||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="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="pe-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>
|
||
|
||
<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>
|
||
<VTextField
|
||
v-model="row.download"
|
||
:placeholder="'/path/to/download'"
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details="auto"
|
||
:rules="pathValidationRules"
|
||
/>
|
||
</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>
|
||
|
||
<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="pt-3">
|
||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||
{{ t('common.save') }}
|
||
</VBtn>
|
||
</VCardActions>
|
||
</VCard>
|
||
</VDialog>
|
||
</div>
|
||
</template>
|