mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
2333 lines
69 KiB
Vue
2333 lines
69 KiB
Vue
<script setup lang="ts">
|
|
import { debounce } from 'lodash-es'
|
|
import { useToast } from 'vue-toastification'
|
|
import api from '@/api'
|
|
import type { StorageConf, TransferHistory } from '@/api/types'
|
|
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
|
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
|
|
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useDisplay } from 'vuetify'
|
|
import { formatFileSize } from '@/@core/utils/formatters'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { usePWA } from '@/composables/usePWA'
|
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
|
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
|
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
|
import { useBackground } from '@/composables/useBackground'
|
|
import { useGlobalSettingsStore, useUserStore } from '@/stores'
|
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
|
import { formatMusicAudioSpecs } from '@/utils/music'
|
|
|
|
const TransferHistoryDeleteDialog = defineAsyncComponent(
|
|
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
|
|
)
|
|
|
|
// i18n
|
|
const { t } = useI18n()
|
|
|
|
// 全局设置
|
|
const globalSettingsStore = useGlobalSettingsStore()
|
|
|
|
// APP
|
|
const display = useDisplay()
|
|
const isDesktop = computed(() => display.mdAndUp.value)
|
|
const isMobile = computed(() => display.smAndDown.value)
|
|
// PWA模式检测
|
|
const { appMode } = usePWA()
|
|
const { useProgressSSE } = useBackground()
|
|
|
|
// 计算列表可用高度
|
|
const { availableHeight } = useAvailableHeight(135, 300)
|
|
|
|
// 提示框
|
|
const $toast = useToast()
|
|
|
|
// 路由
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const userStore = useUserStore()
|
|
const canManage = computed(() =>
|
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
|
)
|
|
let syncingRouteQuery = false
|
|
let fetchDataRequestSeed = 0
|
|
let mobileFetchDataRequestSeed = 0
|
|
let componentUnmounted = false
|
|
|
|
// 组合式输入法状态
|
|
const isComposing = ref(false)
|
|
|
|
// 当前操作记录
|
|
const currentHistory = ref<TransferHistory>()
|
|
|
|
// AI整理中的记录
|
|
const aiRedoIds = ref<number[]>([])
|
|
|
|
// AI整理进度
|
|
const aiRedoProgressActive = ref(false)
|
|
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
|
const aiRedoProgressSSE = ref<any>(null)
|
|
const aiRedoProgressHistoryIds = ref<number[]>([])
|
|
let aiRedoProgressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|
let deleteDialogController: ReturnType<typeof openSharedDialog> | null = null
|
|
|
|
// 重新整理IDS
|
|
const redoIds = ref<number[]>([])
|
|
const redoTargetStorage = ref<string>()
|
|
|
|
// 已选中的数据
|
|
const selected = ref<TransferHistory[]>([])
|
|
|
|
// 移动端批量选择模式
|
|
const mobileBatchMode = ref(false)
|
|
|
|
// 从季集字符串中提取可排序的数字。
|
|
const getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0)
|
|
|
|
// 按媒体类型、标题和剧集序号排序历史记录。
|
|
function sortByTitle(a: TransferHistory, b: TransferHistory) {
|
|
if (a.type !== b.type) {
|
|
return (a.type ?? '').localeCompare(b.type ?? '')
|
|
}
|
|
if (a.title !== b.title) {
|
|
return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase())
|
|
}
|
|
if (a.type === '电视剧') {
|
|
if (a.seasons !== b.seasons) {
|
|
return getNum(a.seasons) - getNum(b.seasons)
|
|
}
|
|
if (a.episodes !== b.episodes) {
|
|
return getNum(a.episodes) - getNum(b.episodes)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// 按源文件体积排序历史记录。
|
|
function sortBySourceSize(a: TransferHistory, b: TransferHistory) {
|
|
return (a.src_fileitem?.size ?? 0) - (b.src_fileitem?.size ?? 0)
|
|
}
|
|
|
|
// 表头
|
|
const headers = [
|
|
{
|
|
title: t('transferHistory.titleColumn'),
|
|
key: 'title',
|
|
sortable: true,
|
|
sortRaw: sortByTitle,
|
|
},
|
|
{
|
|
title: t('transferHistory.pathColumn'),
|
|
key: 'src',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.modeColumn'),
|
|
key: 'mode',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.sizeColumn'),
|
|
key: 'size',
|
|
sortable: true,
|
|
sortRaw: sortBySourceSize,
|
|
},
|
|
{
|
|
title: t('transferHistory.dateColumn'),
|
|
key: 'date',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.statusColumn'),
|
|
key: 'status',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
sortable: false,
|
|
},
|
|
]
|
|
|
|
// 分组表头
|
|
const groupHeaders = [
|
|
{
|
|
title: t('transferHistory.seasonEpisode'),
|
|
key: 'title',
|
|
sortable: true,
|
|
sortRaw: sortByTitle,
|
|
},
|
|
{
|
|
title: t('transferHistory.pathColumn'),
|
|
key: 'src',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.modeColumn'),
|
|
key: 'mode',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.sizeColumn'),
|
|
key: 'size',
|
|
sortable: true,
|
|
sortRaw: sortBySourceSize,
|
|
},
|
|
{
|
|
title: t('transferHistory.dateColumn'),
|
|
key: 'date',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: t('transferHistory.statusColumn'),
|
|
key: 'status',
|
|
sortable: true,
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
sortable: false,
|
|
},
|
|
]
|
|
|
|
const pageRange = [
|
|
{ title: '25', value: 25 },
|
|
{ title: '50', value: 50 },
|
|
{ title: '100', value: 100 },
|
|
{ title: '500', value: 500 },
|
|
{ title: '1000', value: 1000 },
|
|
]
|
|
|
|
const pageRangeValues = pageRange.map(item => item.value)
|
|
|
|
// 数据列表
|
|
const dataList = ref<TransferHistory[]>([])
|
|
|
|
// 移动端历史记录列表,独立于桌面分页表格。
|
|
const mobileDataList = ref<TransferHistory[]>([])
|
|
|
|
// 移动端每次触底加载的记录数。
|
|
const mobilePageSize = 25
|
|
|
|
// 移动端触底加载页码。
|
|
const mobileCurrentPage = ref(1)
|
|
|
|
// 移动端是否还有下一页。
|
|
const mobileHasMore = ref(true)
|
|
|
|
// 移动端无限滚动组件重置键。
|
|
const mobileInfiniteKey = ref(0)
|
|
|
|
// 移动端加载状态。
|
|
const mobileLoading = ref(false)
|
|
|
|
// 移动端已展开完整路径的记录 ID。
|
|
const mobileExpandedPathIds = ref<number[]>([])
|
|
|
|
// 搜索
|
|
const search = ref(getRouteQueryString(route.query.search))
|
|
|
|
// 写入搜索值时统一把空字符串归一为 null,避免 VCombobox 把空字符串当作已有值。
|
|
function setSearchValue(value: unknown) {
|
|
search.value = typeof value === 'string' && value !== '' ? value : null
|
|
}
|
|
|
|
// 搜索提示词列表
|
|
const searchHintList = ref<string[]>([])
|
|
|
|
// 加载状态
|
|
const loading = ref(false)
|
|
|
|
// 总条数
|
|
const totalItems = ref(0)
|
|
|
|
// 是否要分组
|
|
const group = ref<boolean>(route.query.grouped === 'true')
|
|
|
|
// 分组条件
|
|
const groupBy = ref<any>([
|
|
{
|
|
key: 'title',
|
|
},
|
|
])
|
|
|
|
// 每页条数
|
|
const itemsPerPage = ref<number>(ensurePageSize(route.query.itemsPerPage, 50))
|
|
|
|
// 当前页码
|
|
const currentPage = ref<number>(Math.max(1, ensureNumber(route.query.currentPage, 1)))
|
|
|
|
// 进度条
|
|
const progressText = ref(t('transferHistory.progress.pleaseWait'))
|
|
|
|
// 进度值
|
|
const progressValue = ref(0)
|
|
|
|
// 是否已刷新
|
|
const isRefreshed = ref(false)
|
|
|
|
// 是否已完成首次激活
|
|
const hasActivatedOnce = ref(false)
|
|
|
|
// 确认框标题
|
|
const confirmTitle = ref('')
|
|
|
|
// 所有存储
|
|
const storages = ref<StorageConf[]>([])
|
|
|
|
// 查询存储
|
|
async function loadStorages() {
|
|
try {
|
|
const result = await api.get<{ value?: StorageConf[] }>('system/setting/public/Storages')
|
|
|
|
storages.value = result.value ?? []
|
|
} catch (error) {
|
|
console.log(error)
|
|
}
|
|
}
|
|
|
|
// 打开共享进度弹窗,长任务完成后统一关闭。
|
|
function openProgressDialog(text = progressText.value, value = progressValue.value) {
|
|
progressDialogController?.close()
|
|
progressDialogController = openSharedDialog(ProgressDialog, { text, value }, {}, { closeOn: false })
|
|
}
|
|
|
|
// 关闭共享进度弹窗。
|
|
function closeProgressDialog() {
|
|
progressDialogController?.close()
|
|
progressDialogController = null
|
|
}
|
|
|
|
// 打开整理队列进度弹窗。
|
|
function openTransferQueueDialog() {
|
|
openSharedDialog(TransferQueueDialog, {}, {}, { closeOn: ['close'] })
|
|
}
|
|
|
|
// 打开共享删除确认弹窗。
|
|
function openDeleteConfirmDialog() {
|
|
deleteDialogController?.close()
|
|
deleteDialogController = openSharedDialog(
|
|
TransferHistoryDeleteDialog,
|
|
{
|
|
title: confirmTitle.value,
|
|
},
|
|
{
|
|
close: () => {
|
|
deleteDialogController = null
|
|
},
|
|
delete: deleteConfirmHandler,
|
|
'update:modelValue': (value: boolean) => {
|
|
if (!value) deleteDialogController = null
|
|
},
|
|
},
|
|
{ closeOn: ['close', 'delete', 'update:modelValue'] },
|
|
)
|
|
}
|
|
|
|
// 关闭共享删除确认弹窗。
|
|
function closeDeleteConfirmDialog() {
|
|
deleteDialogController?.close()
|
|
deleteDialogController = null
|
|
}
|
|
|
|
// 打开重新整理弹窗,完成后刷新历史列表。
|
|
function openRedoDialog() {
|
|
openSharedDialog(
|
|
ReorganizeDialog,
|
|
{
|
|
logids: redoIds.value,
|
|
target_storage: redoTargetStorage.value,
|
|
},
|
|
{
|
|
done: transferDone,
|
|
},
|
|
{ closeOn: ['close', 'done'] },
|
|
)
|
|
}
|
|
|
|
// 存储字典
|
|
const storageDict = computed(() => {
|
|
return storages.value.reduce(
|
|
(dict, item) => {
|
|
dict[item.type] = item.name
|
|
return dict
|
|
},
|
|
{} as Record<string, string>,
|
|
)
|
|
})
|
|
|
|
// 转移方式字典
|
|
const TransferDict: { [key: string]: string } = {
|
|
copy: t('transferHistory.transferMode.copy'),
|
|
move: t('transferHistory.transferMode.move'),
|
|
link: t('transferHistory.transferMode.link'),
|
|
softlink: t('transferHistory.transferMode.softlink'),
|
|
rclone_copy: t('transferHistory.transferMode.rclone_copy'),
|
|
rclone_move: t('transferHistory.transferMode.rclone_move'),
|
|
}
|
|
|
|
// 分页提示
|
|
const pageTip = computed(() => {
|
|
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
|
|
const end = Math.min(itemsPerPage.value * currentPage.value, totalItems.value)
|
|
return {
|
|
begin,
|
|
end,
|
|
}
|
|
})
|
|
|
|
// 分页总数
|
|
const totalPage = computed(() => {
|
|
const total = Math.ceil(totalItems.value / itemsPerPage.value)
|
|
return Math.max(1, total)
|
|
})
|
|
|
|
// 延迟同步分页参数到地址栏,避免快速翻页时连续触发请求。
|
|
const debouncedReloadPage = debounce(() => {
|
|
void reloadPage()
|
|
}, 1000)
|
|
|
|
// 延迟同步搜索参数到地址栏,输入完成后再重置页码并刷新。
|
|
const debouncedReloadSearchPage = debounce(() => {
|
|
console.log('search: ' + (search.value ?? ''))
|
|
void reloadPage(true)
|
|
}, 1000)
|
|
|
|
// 延迟同步移动端搜索参数,路由监听会按新查询重置无限列表。
|
|
const debouncedReloadMobileSearchPage = debounce(() => {
|
|
void reloadMobileSearchPage()
|
|
}, 600)
|
|
|
|
// 切换页签
|
|
watch([() => currentPage.value, () => itemsPerPage.value], () => {
|
|
if (syncingRouteQuery || !isDesktop.value) return
|
|
|
|
debouncedReloadPage()
|
|
})
|
|
|
|
// 搜索监听
|
|
watch([() => search.value, () => isComposing.value], () => {
|
|
if (syncingRouteQuery || isComposing.value) return
|
|
|
|
if (isMobile.value) {
|
|
debouncedReloadMobileSearchPage()
|
|
return
|
|
}
|
|
|
|
debouncedReloadSearchPage()
|
|
})
|
|
|
|
// 分组模式变化时同步到地址栏,方便返回页面时恢复用户选择。
|
|
watch(
|
|
() => group.value,
|
|
() => {
|
|
if (syncingRouteQuery || !isDesktop.value) return
|
|
|
|
void reloadPage()
|
|
},
|
|
)
|
|
|
|
// 路由参数变化时同步页面状态并重新请求列表数据。
|
|
watch(
|
|
() => route.query,
|
|
() => {
|
|
if (isDesktop.value) {
|
|
void refreshDataFromRouteQuery()
|
|
} else {
|
|
syncMobileSearchFromRouteQuery()
|
|
resetMobileHistory()
|
|
}
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
// 响应桌面与移动端断点切换,进入对应布局后刷新对应数据源。
|
|
watch(isDesktop, desktop => {
|
|
if (desktop) {
|
|
void refreshDataFromRouteQuery()
|
|
} else {
|
|
resetMobileHistory()
|
|
}
|
|
})
|
|
|
|
// 获取历史记录数据,keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
|
|
async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
|
|
const requestSeed = ++fetchDataRequestSeed
|
|
const shouldShowLoading = !options.silent
|
|
if (shouldShowLoading) {
|
|
loading.value = true
|
|
}
|
|
|
|
try {
|
|
const result = await api.get<{ list?: TransferHistory[]; total?: number }>('history/transfer', {
|
|
params: {
|
|
page,
|
|
count,
|
|
title: search.value ?? '',
|
|
},
|
|
})
|
|
if (requestSeed !== fetchDataRequestSeed) return
|
|
|
|
const list = Array.isArray(result.list) ? result.list : []
|
|
|
|
isRefreshed.value = true
|
|
dataList.value = list
|
|
totalItems.value = ensureNumber(result.total, 0)
|
|
updateSearchHintList(list)
|
|
|
|
return {
|
|
list,
|
|
total: totalItems.value,
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
} finally {
|
|
// 静默刷新可能会接管前一个可见请求,也需要负责清掉遗留的表格加载态。
|
|
if (requestSeed === fetchDataRequestSeed && (shouldShowLoading || loading.value)) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// 更新搜索建议,移动端追加加载时会合并已加载记录的标题。
|
|
function updateSearchHintList(list: TransferHistory[]) {
|
|
searchHintList.value = ['失败', '成功', ...new Set(list.map((item: TransferHistory) => item.title || ''))].filter(
|
|
(title): title is string => title !== '',
|
|
)
|
|
}
|
|
|
|
interface MobileHistoryResetOptions {
|
|
/** 是否保持批量模式,通常用于失败项重试。 */
|
|
preserveBatchMode?: boolean
|
|
/** 重置分页时仍需保持选中的记录。 */
|
|
selectedItems?: TransferHistory[]
|
|
}
|
|
|
|
// 重置移动端无限列表,让 VInfiniteScroll 从第一页重新触发加载。
|
|
function resetMobileHistory(options: MobileHistoryResetOptions = {}) {
|
|
mobileFetchDataRequestSeed++
|
|
mobileDataList.value = []
|
|
mobileCurrentPage.value = 1
|
|
mobileHasMore.value = true
|
|
mobileLoading.value = false
|
|
isRefreshed.value = false
|
|
totalItems.value = 0
|
|
mobileExpandedPathIds.value = []
|
|
selected.value = options.selectedItems ?? []
|
|
mobileBatchMode.value = Boolean(options.preserveBatchMode && selected.value.length > 0)
|
|
mobileInfiniteKey.value++
|
|
}
|
|
|
|
// 移动端只从路由同步搜索词,不接收桌面分页和分组状态。
|
|
function syncMobileSearchFromRouteQuery() {
|
|
syncingRouteQuery = true
|
|
try {
|
|
search.value = getRouteQueryString(route.query.search)
|
|
} finally {
|
|
void nextTick(() => {
|
|
syncingRouteQuery = false
|
|
})
|
|
}
|
|
}
|
|
|
|
// 移动端触底加载历史记录,并将新页追加到虚拟列表数据源。
|
|
async function loadMobileHistory({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
|
if (mobileLoading.value) {
|
|
done('ok')
|
|
return
|
|
}
|
|
|
|
if (!mobileHasMore.value) {
|
|
done('empty')
|
|
return
|
|
}
|
|
|
|
const requestSeed = ++mobileFetchDataRequestSeed
|
|
|
|
try {
|
|
mobileLoading.value = true
|
|
const result = await api.get<{ list?: TransferHistory[]; total?: number }>('history/transfer', {
|
|
params: {
|
|
page: mobileCurrentPage.value,
|
|
count: mobilePageSize,
|
|
title: search.value ?? '',
|
|
},
|
|
})
|
|
if (requestSeed !== mobileFetchDataRequestSeed) {
|
|
done('ok')
|
|
return
|
|
}
|
|
|
|
const list = Array.isArray(result.list) ? result.list : []
|
|
const total = ensureNumber(result.total, 0)
|
|
|
|
appendMobileHistory(list, total)
|
|
done(mobileHasMore.value ? 'ok' : 'empty')
|
|
} catch (error) {
|
|
console.error(error)
|
|
done('error')
|
|
} finally {
|
|
if (requestSeed === mobileFetchDataRequestSeed) {
|
|
mobileLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// 将移动端新页数据合并到列表,并维护下一页状态。
|
|
function appendMobileHistory(list: TransferHistory[], total: number) {
|
|
isRefreshed.value = true
|
|
totalItems.value = total
|
|
|
|
if (list.length === 0) {
|
|
mobileHasMore.value = false
|
|
updateSearchHintList(mobileDataList.value)
|
|
return
|
|
}
|
|
|
|
const existingIds = new Set(mobileDataList.value.map(item => item.id))
|
|
const newItems = list.filter(item => !existingIds.has(item.id))
|
|
|
|
mobileDataList.value = [...mobileDataList.value, ...newItems]
|
|
if (selected.value.length > 0) {
|
|
const refreshedItems = new Map(mobileDataList.value.map(item => [item.id, item]))
|
|
selected.value = selected.value.map(item => refreshedItems.get(item.id) ?? item)
|
|
}
|
|
mobileCurrentPage.value++
|
|
mobileHasMore.value = mobileDataList.value.length < total && list.length >= mobilePageSize
|
|
updateSearchHintList(mobileDataList.value)
|
|
}
|
|
|
|
// 从路由查询参数中取出单值字符串,空搜索统一返回 null 以保持输入框 placeholder 可见。
|
|
function getRouteQueryString(value: unknown): string | null {
|
|
if (Array.isArray(value)) {
|
|
return value.find(item => typeof item === 'string' && item !== '') ?? null
|
|
}
|
|
|
|
return typeof value === 'string' && value !== '' ? value : null
|
|
}
|
|
|
|
// 将当前路由查询参数同步回页面状态,并避免触发本地监听器反向写入地址栏。
|
|
async function syncStateFromRouteQuery() {
|
|
syncingRouteQuery = true
|
|
try {
|
|
search.value = getRouteQueryString(route.query.search)
|
|
itemsPerPage.value = ensurePageSize(route.query.itemsPerPage, 50)
|
|
currentPage.value = Math.max(1, ensureNumber(route.query.currentPage, 1))
|
|
group.value = route.query.grouped === 'true'
|
|
} finally {
|
|
await nextTick()
|
|
syncingRouteQuery = false
|
|
}
|
|
}
|
|
|
|
// 根据地址栏中的查询参数刷新历史列表。
|
|
async function refreshDataFromRouteQuery(options: { silent?: boolean } = {}) {
|
|
await syncStateFromRouteQuery()
|
|
await fetchData(currentPage.value, itemsPerPage.value, options)
|
|
}
|
|
|
|
// 操作完成后刷新列表;如果当前页被删空,则跳回最后一个有效页。
|
|
async function refreshDataAfterOperation(mobileSelection: TransferHistory[] = []) {
|
|
if (isMobile.value) {
|
|
resetMobileHistory({
|
|
preserveBatchMode: mobileSelection.length > 0,
|
|
selectedItems: mobileSelection,
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await fetchData()
|
|
if (!result) return
|
|
|
|
const lastAvailablePage = Math.max(1, Math.ceil(result.total / itemsPerPage.value))
|
|
if (currentPage.value <= lastAvailablePage) return
|
|
|
|
await router.replace(createHistoryUrl(false, lastAvailablePage))
|
|
}
|
|
|
|
// 根据 type 返回不同的图标
|
|
function getIcon(type: string) {
|
|
if (type === '电影') return 'mdi-movie'
|
|
else if (type === '电视剧') return 'mdi-television-classic'
|
|
else if (type === '音乐') return 'mdi-album'
|
|
else return 'mdi-help-circle'
|
|
}
|
|
|
|
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
|
|
function getPlaceholderIcon(type: string) {
|
|
if (type === '音乐') return 'mdi-album'
|
|
else if (type === '电视剧') return 'mdi-television-classic'
|
|
else return 'mdi-movie-open-outline'
|
|
}
|
|
|
|
// 计算历史记录海报地址,整理历史的 image 字段由后端写入 Poster 图片。
|
|
function getHistoryPosterUrl(item: TransferHistory) {
|
|
const image = item.image
|
|
if (!image) return ''
|
|
|
|
if (!/^https?:\/\//i.test(image)) {
|
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
|
}
|
|
|
|
return getDisplayImageUrl(image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
|
}
|
|
|
|
// 删除历史记录
|
|
async function removeHistory(item: TransferHistory) {
|
|
currentHistory.value = item
|
|
confirmTitle.value = t('transferHistory.deleteConfirm', {
|
|
title: item.title,
|
|
seasons: item.seasons || '',
|
|
episodes: item.episodes || '',
|
|
})
|
|
openDeleteConfirmDialog()
|
|
}
|
|
|
|
// 调用API删除记录
|
|
async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean, notifyError = true) {
|
|
try {
|
|
// 调用删除API
|
|
await api.delete<null>(`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`, {
|
|
data: item,
|
|
feedback: 'silent',
|
|
})
|
|
return true
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (notifyError) {
|
|
$toast.error(t('transferHistory.deleteRequestFailed'))
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 删除单条记录
|
|
async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
|
|
// 关闭弹窗
|
|
closeDeleteConfirmDialog()
|
|
if (!currentHistory.value) return
|
|
|
|
// 删除
|
|
await remove(currentHistory.value, deleteSrc, deleteDest)
|
|
// 刷新
|
|
await refreshDataAfterOperation()
|
|
}
|
|
|
|
// 批量删除记录
|
|
async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
|
if (hasRunningAiRedo.value) return
|
|
// 关闭弹窗
|
|
closeDeleteConfirmDialog()
|
|
// 总条数
|
|
const total = selected.value.length
|
|
if (total === 0) return
|
|
|
|
// 已处理条数
|
|
let handled = 0
|
|
const failedItems: TransferHistory[] = []
|
|
// 显示进度条
|
|
openProgressDialog()
|
|
// 循环调用removeHistory
|
|
for (const item of selected.value) {
|
|
// 开始删除
|
|
const seasonEpisode = `${item.seasons || ''}${item.episodes || ''}`
|
|
const name = [item.title, seasonEpisode].filter(Boolean).join(' ')
|
|
progressText.value = t('transferHistory.deleting', { name })
|
|
const success = await remove(item, deleteSrc, deleteDest, false)
|
|
if (!success) {
|
|
failedItems.push(item)
|
|
}
|
|
// 删除完成
|
|
handled++
|
|
progressValue.value = (handled / total) * 100
|
|
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
|
}
|
|
// 失败项保持选中,方便用户修正条件后重试。
|
|
selected.value = failedItems
|
|
if (isMobile.value && failedItems.length === 0) {
|
|
mobileBatchMode.value = false
|
|
}
|
|
// 隐藏进度条
|
|
closeProgressDialog()
|
|
if (failedItems.length > 0) {
|
|
$toast.error(t('transferHistory.batchDeleteFailed', { failed: failedItems.length, total }))
|
|
}
|
|
// 重新获取数据
|
|
await refreshDataAfterOperation(failedItems)
|
|
}
|
|
|
|
// 响应删除操作
|
|
async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
|
|
if (currentHistory.value) await removeSingle(deleteSrc, deleteDest)
|
|
else await removeBatch(deleteSrc, deleteDest)
|
|
}
|
|
|
|
// 批量删除历史记录
|
|
async function removeHistoryBatch() {
|
|
if (hasRunningAiRedo.value) return
|
|
if (selected.value.length === 0) return
|
|
|
|
// 清空当前操作记录
|
|
currentHistory.value = undefined
|
|
confirmTitle.value = t('transferHistory.deleteConfirmBatch', {
|
|
count: selected.value.length,
|
|
})
|
|
// 打开确认弹窗
|
|
openDeleteConfirmDialog()
|
|
}
|
|
// 批量重新整理
|
|
async function retransferBatch() {
|
|
if (hasRunningAiRedo.value) return
|
|
if (selected.value.length === 0) return
|
|
|
|
// 清空当前操作记录
|
|
currentHistory.value = undefined
|
|
// 重新整理IDS
|
|
redoIds.value = selected.value.map(item => item.id)
|
|
// 打开识别弹窗
|
|
openRedoDialog()
|
|
}
|
|
|
|
// 整理完成
|
|
async function transferDone() {
|
|
// 清空当前操作记录
|
|
currentHistory.value = undefined
|
|
selected.value = []
|
|
if (isMobile.value) {
|
|
mobileBatchMode.value = false
|
|
}
|
|
// 刷新
|
|
await refreshDataAfterOperation()
|
|
}
|
|
|
|
// AI助手是否启用
|
|
const aiAgentEnabled = computed(() => Boolean(globalSettingsStore.globalSettings.AI_AGENT_ENABLE))
|
|
const hasRunningAiRedo = computed(() => aiRedoIds.value.length > 0)
|
|
|
|
// AI整理中的记录
|
|
function isAiRedoing(historyId: number) {
|
|
return aiRedoIds.value.includes(historyId)
|
|
}
|
|
|
|
// 停止AI整理进度
|
|
function stopAiRedoProgress() {
|
|
aiRedoProgressActive.value = false
|
|
|
|
if (aiRedoProgressSSE.value) {
|
|
aiRedoProgressSSE.value.stop()
|
|
aiRedoProgressSSE.value = null
|
|
}
|
|
}
|
|
|
|
// AI整理完成
|
|
async function finishAiRedo(success: boolean, errorMessage?: string) {
|
|
const historyIds = [...aiRedoProgressHistoryIds.value]
|
|
const historyIdSet = new Set(historyIds)
|
|
|
|
stopAiRedoProgress()
|
|
aiRedoProgressDialogController?.close()
|
|
aiRedoProgressDialogController = null
|
|
aiRedoProgressHistoryIds.value = []
|
|
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIdSet.has(id))
|
|
selected.value = selected.value.filter(item => !historyIdSet.has(item.id))
|
|
|
|
await refreshDataAfterOperation()
|
|
|
|
if (!success && errorMessage) {
|
|
$toast.error(errorMessage)
|
|
}
|
|
}
|
|
|
|
// 处理AI整理进度
|
|
async function handleAiRedoProgressMessage(event: MessageEvent) {
|
|
const progress = JSON.parse(event.data)
|
|
if (!progress) return
|
|
|
|
aiRedoProgressText.value = progress.text_i18n || progress.text || t('transferHistory.actions.aiRedoPending')
|
|
aiRedoProgressDialogController?.updateProps({ text: aiRedoProgressText.value })
|
|
|
|
if (progress.enable === false) {
|
|
await finishAiRedo(progress.data?.success !== false, progress.data?.error_i18n || progress.data?.error)
|
|
}
|
|
}
|
|
|
|
// 开始监听整理进度
|
|
function startAiRedoProgress(historyId: number, progressKey: string) {
|
|
startAiRedoProgressBatch([historyId], progressKey)
|
|
}
|
|
|
|
// 开始监听批量整理进度
|
|
function startAiRedoProgressBatch(historyIds: number[], progressKey: string) {
|
|
stopAiRedoProgress()
|
|
|
|
aiRedoProgressHistoryIds.value = historyIds
|
|
aiRedoProgressActive.value = true
|
|
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
|
|
aiRedoProgressDialogController = openSharedDialog(
|
|
ProgressDialog,
|
|
{ text: aiRedoProgressText.value },
|
|
{},
|
|
{ closeOn: false },
|
|
)
|
|
|
|
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${progressKey}`
|
|
|
|
aiRedoProgressSSE.value = useProgressSSE(
|
|
url,
|
|
handleAiRedoProgressMessage,
|
|
`transfer-history-ai-redo-${progressKey}`,
|
|
aiRedoProgressActive,
|
|
)
|
|
|
|
aiRedoProgressSSE.value.start()
|
|
}
|
|
|
|
// 触发AI整理
|
|
async function triggerAiRedo(item: TransferHistory) {
|
|
if (!aiAgentEnabled.value) {
|
|
$toast.error(t('transferHistory.aiRedoDisabled'))
|
|
return
|
|
}
|
|
if (hasRunningAiRedo.value) return
|
|
|
|
aiRedoIds.value = [...aiRedoIds.value, item.id]
|
|
let progressStarted = false
|
|
try {
|
|
const result = await api.post<{ progress_key?: string }>(`history/transfer/${item.id}/ai-redo`, undefined, {
|
|
feedback: 'silent',
|
|
})
|
|
if (componentUnmounted) return
|
|
|
|
const progressKey = result.progress_key
|
|
|
|
if (!progressKey) {
|
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
|
return
|
|
}
|
|
startAiRedoProgress(item.id, progressKey)
|
|
progressStarted = true
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (!componentUnmounted) {
|
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
|
}
|
|
} finally {
|
|
if (!progressStarted) {
|
|
aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 批量触发AI整理
|
|
async function triggerBatchAiRedo() {
|
|
if (!aiAgentEnabled.value) {
|
|
$toast.error(t('transferHistory.aiRedoDisabled'))
|
|
return
|
|
}
|
|
if (hasRunningAiRedo.value) return
|
|
|
|
const historyIds = [...new Set(selected.value.map(item => item.id))]
|
|
if (historyIds.length === 0) return
|
|
|
|
aiRedoIds.value = [...new Set([...aiRedoIds.value, ...historyIds])]
|
|
let progressStarted = false
|
|
try {
|
|
const result = await api.post<{ history_ids?: number[]; progress_key?: string }>(
|
|
'history/transfer/ai-redo',
|
|
{
|
|
history_ids: historyIds,
|
|
},
|
|
{ feedback: 'silent' },
|
|
)
|
|
if (componentUnmounted) return
|
|
|
|
const progressKey = result.progress_key
|
|
const acceptedIds = result.history_ids ?? historyIds
|
|
|
|
if (!progressKey) {
|
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
|
return
|
|
}
|
|
startAiRedoProgressBatch(acceptedIds, progressKey)
|
|
selected.value = selected.value.filter(item => !acceptedIds.includes(item.id))
|
|
if (isMobile.value && selected.value.length === 0) {
|
|
mobileBatchMode.value = false
|
|
}
|
|
progressStarted = true
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (!componentUnmounted) {
|
|
$toast.error(t('transferHistory.aiRedoFailed'))
|
|
}
|
|
} finally {
|
|
if (!progressStarted) {
|
|
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 计算下拉菜单
|
|
function getDropdownItems(item: TransferHistory) {
|
|
return [
|
|
{
|
|
title: isAiRedoing(item.id) ? t('transferHistory.actions.aiRedoPending') : t('transferHistory.actions.aiRedo'),
|
|
value: 0,
|
|
props: {
|
|
prependIcon: 'mdi-robot-outline',
|
|
disabled: !aiAgentEnabled.value || (hasRunningAiRedo.value && !isAiRedoing(item.id)),
|
|
click: () => {
|
|
triggerAiRedo(item)
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: t('transferHistory.actions.redo'),
|
|
value: 1,
|
|
props: {
|
|
prependIcon: 'mdi-redo-variant',
|
|
click: () => {
|
|
redoIds.value = [item.id]
|
|
redoTargetStorage.value = item.dest_storage
|
|
openRedoDialog()
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: t('transferHistory.actions.delete'),
|
|
value: 2,
|
|
props: {
|
|
prependIcon: 'mdi-trash-can-outline',
|
|
color: 'error',
|
|
click: () => {
|
|
removeHistory(item)
|
|
},
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
// 生成历史记录页地址,确保刷新入口和分页入口使用一致的查询参数。
|
|
function createHistoryUrl(resetPage = false, page = resetPage ? 1 : currentPage.value) {
|
|
const query: Record<string, string> = {}
|
|
|
|
if (search.value) {
|
|
query.search = search.value
|
|
}
|
|
if (itemsPerPage.value) {
|
|
query.itemsPerPage = String(itemsPerPage.value)
|
|
}
|
|
if (page) {
|
|
query.currentPage = String(page)
|
|
}
|
|
if (group.value) {
|
|
query.grouped = 'true'
|
|
}
|
|
|
|
return {
|
|
path: '/history',
|
|
query,
|
|
}
|
|
}
|
|
|
|
// 重载页面,先更新路由,再由路由监听统一拉取列表数据。
|
|
async function reloadPage(resetPage = false) {
|
|
await router.push(createHistoryUrl(resetPage))
|
|
}
|
|
|
|
// 移动端搜索同样以 URL 为持久事实源,刷新和断点切换后可恢复同一查询。
|
|
async function reloadMobileSearchPage() {
|
|
await router.push(createHistoryUrl(true))
|
|
}
|
|
|
|
// 确保值为number类型
|
|
function ensureNumber(value: any, defaultValue: number = 0) {
|
|
value = Number(value)
|
|
// 如果不是数字
|
|
if (Number.isNaN(value)) {
|
|
value = defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
// 校验分页条数,避免地址栏参数超出可选范围。
|
|
function ensurePageSize(value: any, defaultValue: number = 50) {
|
|
const pageSize = ensureNumber(value, defaultValue)
|
|
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
|
|
}
|
|
|
|
// 已选历史记录 ID 集合,供移动端卡片和分组选择状态复用。
|
|
const selectedIdSet = computed(() => new Set(selected.value.map(item => item.id)))
|
|
|
|
// 移动端当前已加载记录数量,用于批量菜单展示选择进度。
|
|
const mobileBatchTotalCount = computed(() => mobileDataList.value.length)
|
|
|
|
// 移动端当前已加载记录中的已选数量。
|
|
const mobileBatchSelectedCount = computed(() => {
|
|
return mobileDataList.value.filter(item => selectedIdSet.value.has(item.id)).length
|
|
})
|
|
|
|
// 移动端当前已加载记录是否已全部选中。
|
|
const isAllMobileHistorySelected = computed(() => {
|
|
return mobileBatchTotalCount.value > 0 && mobileBatchSelectedCount.value === mobileBatchTotalCount.value
|
|
})
|
|
|
|
// 拼接移动端展示用的季集文本。
|
|
function getHistoryEpisodeText(item: TransferHistory) {
|
|
return `${item.seasons || ''}${item.episodes || ''}`
|
|
}
|
|
|
|
// 获取移动端卡片标题,剧集记录会追加季集信息。
|
|
function getHistoryDisplayTitle(item: TransferHistory) {
|
|
const title = item.title || t('common.unknown')
|
|
const episodeText = getHistoryEpisodeText(item)
|
|
|
|
return item.type === '电视剧' && episodeText ? `${title} ${episodeText}` : title
|
|
}
|
|
|
|
// 获取移动端卡片副标题,优先展示二级分类和年份。
|
|
function getHistorySubtitle(item: TransferHistory) {
|
|
return [item.category, item.year, item.type === '音乐' ? formatMusicAudioSpecs(item) : ''].filter(Boolean).join(' / ')
|
|
}
|
|
|
|
// 获取存储展示名称,配置缺失时回退到原始存储标识。
|
|
function getHistoryStorageName(storage?: string) {
|
|
if (!storage) return t('common.unknown')
|
|
|
|
const fallbackNames: Record<string, string> = {
|
|
local: '本地',
|
|
downloads: '下载',
|
|
library: '媒体库',
|
|
smb: 'SMB',
|
|
}
|
|
|
|
return storageDict.value[storage] || fallbackNames[storage] || storage
|
|
}
|
|
|
|
// 获取移动端卡片状态对应的主题色。
|
|
function getHistoryStatusColor(item: TransferHistory) {
|
|
return item.status ? 'success' : 'error'
|
|
}
|
|
|
|
// 获取移动端卡片状态文本。
|
|
function getHistoryStatusText(item: TransferHistory) {
|
|
return item.status ? t('transferHistory.status.success') : t('transferHistory.status.failed')
|
|
}
|
|
|
|
// 将历史记录时间压缩成移动端卡片展示文本。
|
|
function getHistoryDateText(date?: string) {
|
|
if (!date) return ''
|
|
|
|
const match = date.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/)
|
|
if (!match) return date
|
|
|
|
const [, year, month, day, hour, minute] = match
|
|
const recordDate = new Date(Number(year), Number(month) - 1, Number(day))
|
|
const today = new Date()
|
|
const todayDate = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
|
const diffDays = Math.round((todayDate.getTime() - recordDate.getTime()) / 86400000)
|
|
const time = `${hour}:${minute}`
|
|
|
|
if (diffDays === 0) return `今天 ${time}`
|
|
if (diffDays === 1) return `昨天 ${time}`
|
|
|
|
return `${month}-${day} ${time}`
|
|
}
|
|
|
|
// 判断移动端路径是否已展开。
|
|
function isMobilePathExpanded(item: TransferHistory) {
|
|
return mobileExpandedPathIds.value.includes(item.id)
|
|
}
|
|
|
|
// 切换移动端路径完整展示状态。
|
|
function toggleMobilePathExpanded(item: TransferHistory) {
|
|
if (isMobilePathExpanded(item)) {
|
|
mobileExpandedPathIds.value = mobileExpandedPathIds.value.filter(id => id !== item.id)
|
|
return
|
|
}
|
|
|
|
mobileExpandedPathIds.value = [...mobileExpandedPathIds.value, item.id]
|
|
}
|
|
|
|
// 判断指定历史记录是否已被选中。
|
|
function isHistorySelected(item: TransferHistory) {
|
|
return selectedIdSet.value.has(item.id)
|
|
}
|
|
|
|
// 获取移动端历史记录卡片的稳定渲染 key。
|
|
function getMobileHistoryItemKey(item: TransferHistory) {
|
|
return item.id
|
|
}
|
|
|
|
// 批量设置历史记录选中状态,并按 ID 去重。
|
|
function updateHistorySelection(items: readonly TransferHistory[], checked: boolean | null) {
|
|
const itemIds = new Set(items.map(item => item.id))
|
|
|
|
if (checked) {
|
|
const selectedIds = new Set(selected.value.map(item => item.id))
|
|
|
|
selected.value = [...selected.value, ...items.filter(item => !selectedIds.has(item.id))]
|
|
return
|
|
}
|
|
|
|
selected.value = selected.value.filter(item => !itemIds.has(item.id))
|
|
}
|
|
|
|
// 切换单条历史记录的选中状态。
|
|
function toggleHistorySelection(item: TransferHistory, checked: boolean | null) {
|
|
updateHistorySelection([item], checked)
|
|
}
|
|
|
|
// 切换移动端批量选择模式,退出时清空移动端选择状态。
|
|
function toggleMobileBatchMode() {
|
|
if (mobileBatchMode.value) {
|
|
exitMobileBatchMode()
|
|
return
|
|
}
|
|
|
|
selected.value = []
|
|
mobileBatchMode.value = true
|
|
}
|
|
|
|
// 退出移动端批量选择模式并清空选择状态。
|
|
function exitMobileBatchMode() {
|
|
mobileBatchMode.value = false
|
|
selected.value = []
|
|
}
|
|
|
|
// 批量模式下点击移动端记录卡片时切换该记录的选择状态。
|
|
function handleMobileRecordClick(item: TransferHistory) {
|
|
if (!mobileBatchMode.value) return
|
|
|
|
toggleHistorySelection(item, !isHistorySelected(item))
|
|
}
|
|
|
|
// 移动端路径点击在批量模式下转为选择记录,普通模式下展开路径。
|
|
function handleMobilePathClick(item: TransferHistory) {
|
|
if (mobileBatchMode.value) {
|
|
handleMobileRecordClick(item)
|
|
return
|
|
}
|
|
|
|
toggleMobilePathExpanded(item)
|
|
}
|
|
|
|
// 选中移动端当前已加载的全部历史记录。
|
|
function selectAllMobileHistory() {
|
|
updateHistorySelection(mobileDataList.value, true)
|
|
}
|
|
|
|
// 取消移动端历史记录的全部选择。
|
|
function deselectAllMobileHistory() {
|
|
updateHistorySelection(mobileDataList.value, false)
|
|
}
|
|
|
|
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
|
|
const selectedCountsGroupedByTitle = computed(() => {
|
|
return selected.value.reduce(
|
|
(acc, item) => {
|
|
const title = item.title || ''
|
|
acc[title] = (acc[title] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>,
|
|
)
|
|
})
|
|
|
|
// 控制分组内所有子项的选中状态
|
|
const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => {
|
|
const values = items.map(item => item.value)
|
|
updateHistorySelection(values, checked)
|
|
}
|
|
|
|
const historyDynamicIcon = computed(() => 'mdi-timer-sand-paused')
|
|
const historyDynamicMenuItems = computed(() => {
|
|
if (!appMode.value) return undefined
|
|
|
|
if (mobileBatchMode.value) {
|
|
const hasSelectedHistory = mobileBatchSelectedCount.value > 0
|
|
|
|
const items: DynamicButtonMenuItem[] = [
|
|
{
|
|
titleKey: 'transferHistory.selectedCount',
|
|
titleParams: {
|
|
count: mobileBatchSelectedCount.value,
|
|
total: mobileBatchTotalCount.value,
|
|
},
|
|
icon: 'mdi-checkbox-multiple-marked-outline',
|
|
permission: 'manage',
|
|
disabled: true,
|
|
action: () => {},
|
|
},
|
|
{
|
|
titleKey: isAllMobileHistorySelected.value
|
|
? 'transferHistory.actions.deselectAll'
|
|
: 'transferHistory.actions.selectAll',
|
|
icon: isAllMobileHistorySelected.value ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
|
|
permission: 'manage',
|
|
disabled: mobileBatchTotalCount.value === 0,
|
|
action: () => {
|
|
if (isAllMobileHistorySelected.value) {
|
|
deselectAllMobileHistory()
|
|
return
|
|
}
|
|
|
|
selectAllMobileHistory()
|
|
},
|
|
},
|
|
]
|
|
|
|
if (!hasRunningAiRedo.value) {
|
|
items.push(
|
|
{
|
|
titleKey: 'transferHistory.actions.batchAiRedo',
|
|
icon: 'mdi-robot-outline',
|
|
color: 'info',
|
|
permission: 'manage',
|
|
disabled: !hasSelectedHistory,
|
|
action: () => {
|
|
triggerBatchAiRedo()
|
|
},
|
|
},
|
|
{
|
|
titleKey: 'transferHistory.actions.batchRedo',
|
|
icon: 'mdi-redo-variant',
|
|
color: 'success',
|
|
permission: 'manage',
|
|
disabled: !hasSelectedHistory,
|
|
action: () => {
|
|
retransferBatch()
|
|
},
|
|
},
|
|
{
|
|
titleKey: 'transferHistory.actions.batchDelete',
|
|
icon: 'mdi-trash-can-outline',
|
|
color: 'error',
|
|
permission: 'manage',
|
|
disabled: !hasSelectedHistory,
|
|
action: () => {
|
|
removeHistoryBatch()
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
items.push({
|
|
titleKey: 'transferHistory.actions.exitBatchMode',
|
|
icon: 'mdi-close',
|
|
permission: 'manage',
|
|
action: exitMobileBatchMode,
|
|
})
|
|
|
|
return items
|
|
}
|
|
|
|
if (selected.value.length === 0) return undefined
|
|
|
|
const items: DynamicButtonMenuItem[] = [
|
|
{
|
|
titleKey: 'dialog.transferQueue.title',
|
|
icon: 'mdi-timer-sand-paused',
|
|
color: 'primary',
|
|
permission: 'manage',
|
|
action: openTransferQueueDialog,
|
|
},
|
|
]
|
|
|
|
if (!hasRunningAiRedo.value) {
|
|
items.push(
|
|
{
|
|
titleKey: 'transferHistory.actions.batchAiRedo',
|
|
icon: 'mdi-robot-outline',
|
|
color: 'info',
|
|
permission: 'manage',
|
|
action: () => {
|
|
triggerBatchAiRedo()
|
|
},
|
|
},
|
|
{
|
|
titleKey: 'transferHistory.actions.batchRedo',
|
|
icon: 'mdi-redo-variant',
|
|
color: 'success',
|
|
permission: 'manage',
|
|
action: () => {
|
|
retransferBatch()
|
|
},
|
|
},
|
|
{
|
|
titleKey: 'transferHistory.actions.batchDelete',
|
|
icon: 'mdi-trash-can-outline',
|
|
color: 'error',
|
|
permission: 'manage',
|
|
action: () => {
|
|
removeHistoryBatch()
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
return items
|
|
})
|
|
|
|
useDynamicButton({
|
|
icon: historyDynamicIcon,
|
|
onClick: openTransferQueueDialog,
|
|
menuItems: historyDynamicMenuItems,
|
|
permission: 'manage',
|
|
show: computed(() => appMode.value),
|
|
})
|
|
|
|
// 初始加载数据
|
|
onMounted(() => {
|
|
void loadStorages()
|
|
if (isDesktop.value) {
|
|
void refreshDataFromRouteQuery()
|
|
} else {
|
|
resetMobileHistory()
|
|
}
|
|
})
|
|
|
|
onActivated(() => {
|
|
if (!hasActivatedOnce.value) {
|
|
hasActivatedOnce.value = true
|
|
return
|
|
}
|
|
|
|
if (isDesktop.value && !loading.value) {
|
|
void refreshDataFromRouteQuery({ silent: true })
|
|
} else if (isMobile.value && !mobileLoading.value) {
|
|
resetMobileHistory()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
componentUnmounted = true
|
|
debouncedReloadPage.cancel()
|
|
debouncedReloadSearchPage.cancel()
|
|
debouncedReloadMobileSearchPage.cancel()
|
|
stopAiRedoProgress()
|
|
closeProgressDialog()
|
|
aiRedoProgressDialogController?.close()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<VCard v-if="isDesktop">
|
|
<VCardItem>
|
|
<VCardTitle>
|
|
<VRow>
|
|
<VCol cols="8" md="6" class="flex">
|
|
<VCombobox
|
|
key="search_navbar"
|
|
:model-value="search"
|
|
@update:model-value="setSearchValue"
|
|
:items="searchHintList"
|
|
@compositionstart="isComposing = true"
|
|
@compositionend="isComposing = false"
|
|
class="text-disabled"
|
|
density="compact"
|
|
:placeholder="t('transferHistory.searchPlaceholder')"
|
|
:aria-label="t('transferHistory.searchPlaceholder')"
|
|
prepend-inner-icon="mdi-magnify"
|
|
variant="solo-filled"
|
|
max-width="25rem"
|
|
single-line
|
|
hide-details
|
|
flat
|
|
rounded="pill"
|
|
clearable
|
|
/>
|
|
</VCol>
|
|
<VCol cols="4" md="6" class="text-end">
|
|
<VBtnGroup variant="outlined" divided rounded>
|
|
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
|
|
</VBtnGroup>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardTitle>
|
|
</VCardItem>
|
|
<!-- 分组模式 -->
|
|
<VDataTableVirtual
|
|
v-if="group"
|
|
v-model="selected"
|
|
:groupBy="groupBy"
|
|
:headers="groupHeaders"
|
|
:items="dataList"
|
|
:loading="loading"
|
|
density="compact"
|
|
return-object
|
|
fixed-header
|
|
show-select
|
|
:loading-text="t('transferHistory.loading')"
|
|
hover
|
|
:style="{ height: `${availableHeight}px` }"
|
|
>
|
|
<template #header.data-table-group>
|
|
<span>{{ t('transferHistory.titleColumn') }}</span>
|
|
</template>
|
|
<template v-slot:group-header="{ item, columns, toggleGroup, isGroupOpen }">
|
|
<tr>
|
|
<td :colspan="columns.length">
|
|
<div class="d-flex align-center gap-2">
|
|
<VBtn
|
|
:icon="isGroupOpen(item) ? '$expand' : '$next'"
|
|
size="small"
|
|
variant="text"
|
|
@click="toggleGroup(item)"
|
|
/>
|
|
<VCheckbox
|
|
:model-value="selectedCountsGroupedByTitle[item.value] == item.items.length"
|
|
:indeterminate="selectedCountsGroupedByTitle[item.value] < item.items.length"
|
|
@update:modelValue="checked => toggleGroupSelection(checked, item.items)"
|
|
/>
|
|
{{ item.value }}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template #item.title="{ item }">
|
|
<div class="transfer-history-desktop-media-cell">
|
|
<div class="transfer-history-desktop-poster-frame">
|
|
<VImg
|
|
v-if="getHistoryPosterUrl(item)"
|
|
:src="getHistoryPosterUrl(item)"
|
|
:alt="item.title"
|
|
cover
|
|
class="transfer-history-desktop-poster"
|
|
>
|
|
<template #error>
|
|
<div class="transfer-history-desktop-poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="20" color="medium-emphasis" />
|
|
</div>
|
|
</template>
|
|
</VImg>
|
|
<div v-else class="transfer-history-desktop-poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="20" color="medium-emphasis" />
|
|
</div>
|
|
</div>
|
|
<div class="d-flex flex-column">
|
|
<span v-if="item.type === '电视剧'" class="d-block text-high-emphasis min-w-20">
|
|
{{ item?.seasons }}{{ item?.episodes }}
|
|
</span>
|
|
<small>{{ item?.category }}</small>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #item.src="{ item }">
|
|
<div>
|
|
<span>
|
|
<VChip variant="tonal" size="small" label class="my-1">
|
|
{{ getHistoryStorageName(item?.src_storage) }}
|
|
</VChip>
|
|
<small>{{ item?.src }}</small>
|
|
</span>
|
|
<span class="text-high-emphasis text-bold"> => </span>
|
|
<br />
|
|
<span v-if="item?.dest">
|
|
<VChip variant="tonal" size="small" label class="my-1">
|
|
{{ getHistoryStorageName(item?.dest_storage) }}
|
|
</VChip>
|
|
<small>{{ item?.dest }}</small>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template #item.mode="{ item }">
|
|
<VChip variant="outlined" color="primary" size="small">
|
|
{{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}
|
|
</VChip>
|
|
</template>
|
|
<template #item.status="{ item }">
|
|
<VChip v-if="item?.status" color="success" size="small"> {{ t('transferHistory.status.success') }} </VChip>
|
|
<VTooltip v-else :text="item?.errmsg">
|
|
<template #activator="{ props }">
|
|
<VChip v-bind="props" color="error" size="small"> {{ t('transferHistory.status.failed') }} </VChip>
|
|
</template>
|
|
</VTooltip>
|
|
</template>
|
|
<template #item.size="{ item }">
|
|
<small>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</small>
|
|
</template>
|
|
<template #item.date="{ item }">
|
|
<small>{{ item?.date }}</small>
|
|
</template>
|
|
<template #item.actions="{ item }">
|
|
<IconBtn>
|
|
<VIcon icon="mdi-dots-vertical" />
|
|
<VMenu activator="parent" close-on-content-click>
|
|
<VList>
|
|
<VListItem
|
|
v-for="(menu, i) in getDropdownItems(item)"
|
|
:key="i"
|
|
:base-color="menu.props.color"
|
|
:disabled="menu.props.disabled"
|
|
@click="menu.props.click()"
|
|
>
|
|
<template #prepend>
|
|
<VIcon :icon="menu.props.prependIcon" />
|
|
</template>
|
|
<VListItemTitle v-text="menu.title" />
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</IconBtn>
|
|
</template>
|
|
<template #no-data> {{ t('transferHistory.noData') }} </template>
|
|
</VDataTableVirtual>
|
|
<!-- 列表模式 -->
|
|
<VDataTableVirtual
|
|
v-else
|
|
v-model="selected"
|
|
:headers="headers"
|
|
:items="dataList"
|
|
:loading="loading"
|
|
density="compact"
|
|
return-object
|
|
fixed-header
|
|
show-select
|
|
:loading-text="t('transferHistory.loading')"
|
|
hover
|
|
:style="{ height: `${availableHeight}px` }"
|
|
>
|
|
<template #item.title="{ item }">
|
|
<div class="transfer-history-desktop-media-cell">
|
|
<div class="transfer-history-desktop-poster-frame">
|
|
<VImg
|
|
v-if="getHistoryPosterUrl(item)"
|
|
:src="getHistoryPosterUrl(item)"
|
|
:alt="item.title"
|
|
cover
|
|
class="transfer-history-desktop-poster"
|
|
>
|
|
<template #error>
|
|
<div class="transfer-history-desktop-poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="20" color="medium-emphasis" />
|
|
</div>
|
|
</template>
|
|
</VImg>
|
|
<div v-else class="transfer-history-desktop-poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="20" color="medium-emphasis" />
|
|
</div>
|
|
</div>
|
|
<div class="d-flex flex-column">
|
|
<span v-if="item.type === '电视剧'" class="d-block text-high-emphasis min-w-20">
|
|
{{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}
|
|
</span>
|
|
<span v-else class="d-block text-high-emphasis min-w-20">
|
|
{{ item?.title }}
|
|
</span>
|
|
<small>{{ item?.category }}</small>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #item.src="{ item }">
|
|
<div>
|
|
<span>
|
|
<VChip variant="tonal" size="small" label class="my-1">
|
|
{{ getHistoryStorageName(item?.src_storage) }}
|
|
</VChip>
|
|
<small>{{ item?.src }}</small>
|
|
</span>
|
|
<span class="text-high-emphasis text-bold"> => </span>
|
|
<br />
|
|
<span v-if="item?.dest">
|
|
<VChip variant="tonal" size="small" label class="my-1">
|
|
{{ getHistoryStorageName(item?.dest_storage) }}
|
|
</VChip>
|
|
<small>{{ item?.dest }}</small>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template #item.mode="{ item }">
|
|
<VChip variant="outlined" color="primary" size="small">
|
|
{{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}
|
|
</VChip>
|
|
</template>
|
|
<template #item.status="{ item }">
|
|
<VChip v-if="item?.status" color="success" size="small"> {{ t('transferHistory.status.success') }} </VChip>
|
|
<VTooltip v-else :text="item?.errmsg">
|
|
<template #activator="{ props }">
|
|
<VChip v-bind="props" color="error" size="small"> {{ t('transferHistory.status.failed') }} </VChip>
|
|
</template>
|
|
</VTooltip>
|
|
</template>
|
|
<template #item.size="{ item }">
|
|
<small>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</small>
|
|
</template>
|
|
<template #item.date="{ item }">
|
|
<small>{{ item?.date }}</small>
|
|
</template>
|
|
<template #item.actions="{ item }">
|
|
<IconBtn>
|
|
<VIcon icon="mdi-dots-vertical" />
|
|
<VMenu activator="parent" close-on-content-click>
|
|
<VList>
|
|
<VListItem
|
|
v-for="(menu, i) in getDropdownItems(item)"
|
|
:key="i"
|
|
:base-color="menu.props.color"
|
|
:disabled="menu.props.disabled"
|
|
@click="menu.props.click()"
|
|
>
|
|
<template #prepend>
|
|
<VIcon :icon="menu.props.prependIcon" />
|
|
</template>
|
|
<VListItemTitle v-text="menu.title" />
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</IconBtn>
|
|
</template>
|
|
<template #no-data> {{ t('transferHistory.noData') }} </template>
|
|
</VDataTableVirtual>
|
|
<VDivider />
|
|
<div class="flex items-center justify-between">
|
|
<div class="transfer-history-pagination__size w-auto">
|
|
<VSelect v-model="itemsPerPage" :items="pageRange" density="compact" flat class="ms-1" />
|
|
</div>
|
|
<div class="transfer-history-pagination__info w-auto text-sm">
|
|
{{ t('transferHistory.pageInfo', pageTip) }} {{ totalItems }}
|
|
</div>
|
|
<VPagination
|
|
v-model="currentPage"
|
|
show-first-last-page
|
|
:length="totalPage"
|
|
:total-visible="7"
|
|
@next="currentPage + 1"
|
|
@prev="currentPage - 1"
|
|
>
|
|
</VPagination>
|
|
</div>
|
|
</VCard>
|
|
|
|
<section v-else class="transfer-history-mobile-page">
|
|
<div class="transfer-history-mobile-titlebar">
|
|
<VPageContentTitle
|
|
:title="t('navItems.mediaOrganize')"
|
|
class="transfer-history-mobile-title my-0"
|
|
style="margin-block: 0"
|
|
/>
|
|
<VBtn
|
|
v-if="canManage"
|
|
icon="mdi-checkbox-multiple-marked-outline"
|
|
:color="mobileBatchMode ? 'primary' : 'gray'"
|
|
:aria-label="
|
|
mobileBatchMode ? t('transferHistory.actions.exitBatchSelect') : t('transferHistory.actions.batchSelect')
|
|
"
|
|
:title="
|
|
mobileBatchMode ? t('transferHistory.actions.exitBatchSelect') : t('transferHistory.actions.batchSelect')
|
|
"
|
|
variant="text"
|
|
class="settings-icon-button transfer-history-mobile-titlebar__batch"
|
|
@click="toggleMobileBatchMode"
|
|
/>
|
|
</div>
|
|
|
|
<VCombobox
|
|
key="search_mobile"
|
|
:model-value="search"
|
|
@update:model-value="setSearchValue"
|
|
:items="searchHintList"
|
|
@compositionstart="isComposing = true"
|
|
@compositionend="isComposing = false"
|
|
class="transfer-history-mobile-search"
|
|
density="comfortable"
|
|
:placeholder="t('transferHistory.searchPlaceholder')"
|
|
:aria-label="t('transferHistory.searchPlaceholder')"
|
|
prepend-inner-icon="mdi-magnify"
|
|
variant="outlined"
|
|
single-line
|
|
hide-details
|
|
flat
|
|
rounded="pill"
|
|
clearable
|
|
/>
|
|
|
|
<VInfiniteScroll
|
|
:key="mobileInfiniteKey"
|
|
mode="intersect"
|
|
side="end"
|
|
:items="mobileDataList"
|
|
:margin="mobileDataList.length > 0 ? 280 : 0"
|
|
class="transfer-history-mobile-scroll"
|
|
@load="loadMobileHistory"
|
|
>
|
|
<template #loading>
|
|
<div class="transfer-history-mobile-state transfer-history-mobile-state--loading">
|
|
<VProgressCircular indeterminate color="primary" size="26" width="3" />
|
|
</div>
|
|
</template>
|
|
<template #empty />
|
|
<template #error="{ props: retryProps }">
|
|
<div class="transfer-history-mobile-state d-flex flex-column ga-2" role="alert">
|
|
<span class="text-body-2 text-medium-emphasis">{{ t('common.serverConnectionFailed') }}</span>
|
|
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
|
|
{{ t('common.retry') }}
|
|
</VBtn>
|
|
</div>
|
|
</template>
|
|
|
|
<ProgressiveCardGrid
|
|
v-if="mobileDataList.length > 0"
|
|
:items="mobileDataList"
|
|
:columns="1"
|
|
:gap="14"
|
|
:estimated-item-height="296"
|
|
:overscan-rows="5"
|
|
:get-item-key="getMobileHistoryItemKey"
|
|
>
|
|
<template #default="{ item }">
|
|
<article
|
|
class="transfer-history-mobile-record"
|
|
:class="{
|
|
'transfer-history-mobile-record--batch': mobileBatchMode,
|
|
'transfer-history-mobile-record--selected': mobileBatchMode && isHistorySelected(item),
|
|
'transfer-history-mobile-record--failed': !item.status,
|
|
}"
|
|
@click="handleMobileRecordClick(item)"
|
|
>
|
|
<header class="transfer-history-mobile-record__header">
|
|
<div class="transfer-history-mobile-record__poster-wrapper">
|
|
<VImg
|
|
v-if="getHistoryPosterUrl(item)"
|
|
class="transfer-history-mobile-record__poster"
|
|
:src="getHistoryPosterUrl(item)"
|
|
:alt="item.title"
|
|
cover
|
|
>
|
|
<template #placeholder>
|
|
<div class="transfer-history-mobile-record__poster-skeleton">
|
|
<VSkeletonLoader class="h-full" />
|
|
</div>
|
|
</template>
|
|
<template #error>
|
|
<div class="transfer-history-mobile-record__poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="28" color="medium-emphasis" />
|
|
</div>
|
|
</template>
|
|
</VImg>
|
|
<div v-else class="transfer-history-mobile-record__poster-placeholder">
|
|
<VIcon :icon="getPlaceholderIcon(item.type || '')" size="28" color="medium-emphasis" />
|
|
</div>
|
|
<VIcon class="transfer-history-mobile-record__poster-type" :icon="getIcon(item.type || '')" size="14" />
|
|
</div>
|
|
|
|
<div class="transfer-history-mobile-record__heading">
|
|
<div class="transfer-history-mobile-record__title">
|
|
{{ getHistoryDisplayTitle(item) }}
|
|
</div>
|
|
<div class="transfer-history-mobile-record__subtitle">
|
|
{{ getHistorySubtitle(item) || item.type || t('common.unknown') }}
|
|
</div>
|
|
</div>
|
|
|
|
<VChip
|
|
class="transfer-history-mobile-record__status"
|
|
variant="tonal"
|
|
:color="getHistoryStatusColor(item)"
|
|
size="small"
|
|
>
|
|
{{ getHistoryStatusText(item) }}
|
|
</VChip>
|
|
|
|
<VCheckbox
|
|
v-if="mobileBatchMode"
|
|
class="transfer-history-mobile-record__checkbox"
|
|
:model-value="isHistorySelected(item)"
|
|
density="compact"
|
|
hide-details
|
|
@click.stop
|
|
@update:model-value="checked => toggleHistorySelection(item, checked)"
|
|
/>
|
|
|
|
<IconBtn v-else class="transfer-history-mobile-record__menu" size="small">
|
|
<VIcon icon="mdi-dots-vertical" />
|
|
<VMenu activator="parent" close-on-content-click>
|
|
<VList>
|
|
<VListItem
|
|
v-for="(menu, i) in getDropdownItems(item)"
|
|
:key="i"
|
|
:base-color="menu.props.color"
|
|
:disabled="menu.props.disabled"
|
|
@click="menu.props.click()"
|
|
>
|
|
<template #prepend>
|
|
<VIcon :icon="menu.props.prependIcon" />
|
|
</template>
|
|
<VListItemTitle v-text="menu.title" />
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</IconBtn>
|
|
|
|
<div class="transfer-history-mobile-record__meta">
|
|
<VChip class="transfer-history-mobile-record__mode" variant="outlined" color="primary" size="small">
|
|
{{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}
|
|
</VChip>
|
|
<span>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</span>
|
|
<span class="transfer-history-mobile-record__dot">·</span>
|
|
<span v-if="item?.date">{{ getHistoryDateText(item.date) }}</span>
|
|
</div>
|
|
</header>
|
|
|
|
<button
|
|
type="button"
|
|
class="transfer-history-mobile-record__paths"
|
|
:class="{ 'transfer-history-mobile-record__paths--expanded': isMobilePathExpanded(item) }"
|
|
@click.stop="handleMobilePathClick(item)"
|
|
>
|
|
<div class="transfer-history-mobile-record__path-row">
|
|
<span class="transfer-history-mobile-record__storage">
|
|
{{ getHistoryStorageName(item?.src_storage) }}
|
|
</span>
|
|
<p>{{ item?.src || t('common.unknown') }}</p>
|
|
</div>
|
|
<div v-if="item?.dest" class="transfer-history-mobile-record__path-arrow">
|
|
<VIcon icon="mdi-arrow-down" size="18" />
|
|
</div>
|
|
<div v-if="item?.dest" class="transfer-history-mobile-record__path-row">
|
|
<span class="transfer-history-mobile-record__storage">
|
|
{{ getHistoryStorageName(item?.dest_storage) }}
|
|
</span>
|
|
<p>{{ item.dest }}</p>
|
|
</div>
|
|
</button>
|
|
|
|
<div v-if="!item?.status && item?.errmsg" class="transfer-history-mobile-record__error">
|
|
<VIcon icon="mdi-alert-circle" size="18" />
|
|
{{ item.errmsg }}
|
|
</div>
|
|
</article>
|
|
</template>
|
|
</ProgressiveCardGrid>
|
|
</VInfiniteScroll>
|
|
|
|
<div v-if="mobileDataList.length === 0 && isRefreshed && !mobileLoading" class="transfer-history-mobile-empty">
|
|
<VIcon icon="mdi-history" size="32" />
|
|
<span>{{ t('transferHistory.noData') }}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 非 app 模式下的 FAB 按钮 -->
|
|
<Teleport to="body" v-if="!appMode && route.path === '/history'">
|
|
<div v-if="isRefreshed && canManage" class="compact-fab-stack compact-fab-stack--history">
|
|
<VFab
|
|
v-if="selected.length > 0 && !hasRunningAiRedo"
|
|
icon="mdi-trash-can-outline"
|
|
color="warning"
|
|
variant="tonal"
|
|
appear
|
|
class="compact-fab compact-fab--secondary"
|
|
@click="removeHistoryBatch"
|
|
/>
|
|
<VFab
|
|
v-if="selected.length > 0 && !hasRunningAiRedo"
|
|
icon="mdi-redo-variant"
|
|
color="success"
|
|
variant="tonal"
|
|
appear
|
|
class="compact-fab compact-fab--secondary"
|
|
@click="retransferBatch"
|
|
/>
|
|
<VFab
|
|
v-if="selected.length > 0 && !hasRunningAiRedo"
|
|
icon="mdi-robot-outline"
|
|
color="info"
|
|
variant="tonal"
|
|
appear
|
|
class="compact-fab compact-fab--secondary"
|
|
@click="triggerBatchAiRedo"
|
|
/>
|
|
<VFab
|
|
icon="mdi-timer-sand-paused"
|
|
color="primary"
|
|
appear
|
|
class="compact-fab compact-fab--primary"
|
|
@click="openTransferQueueDialog"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
|
|
.v-table th {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.v-table__wrapper {
|
|
border-radius: 0;
|
|
}
|
|
|
|
.transfer-history-desktop-media-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding-block: 6px;
|
|
}
|
|
|
|
.transfer-history-desktop-poster-frame {
|
|
flex: 0 0 36px;
|
|
inline-size: 36px;
|
|
block-size: 54px;
|
|
min-inline-size: 36px;
|
|
min-block-size: 54px;
|
|
max-inline-size: 36px;
|
|
max-block-size: 54px;
|
|
overflow: hidden;
|
|
border-radius: 4px;
|
|
aspect-ratio: 2 / 3;
|
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
|
}
|
|
|
|
.transfer-history-desktop-poster,
|
|
.transfer-history-desktop-poster-placeholder {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
max-inline-size: 100%;
|
|
max-block-size: 100%;
|
|
}
|
|
|
|
.transfer-history-desktop-poster :deep(.v-img__img) {
|
|
object-fit: cover;
|
|
}
|
|
|
|
.transfer-history-desktop-poster-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.transfer-history-mobile-page {
|
|
--transfer-history-mobile-surface-opacity: 0.92;
|
|
--transfer-history-mobile-search-bg: rgba(var(--v-theme-on-surface), 0.045);
|
|
--transfer-history-mobile-muted-bg: rgba(var(--v-theme-on-surface), 0.06);
|
|
--transfer-history-mobile-border: rgba(var(--v-theme-on-surface), 0.1);
|
|
--transfer-history-mobile-storage-width: 4.85rem;
|
|
--transfer-history-mobile-surface-blur: none;
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
min-block-size: 100%;
|
|
padding-block: 0.25rem 1.25rem;
|
|
padding-inline: 0.35rem;
|
|
}
|
|
|
|
.transfer-history-mobile-titlebar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.transfer-history-mobile-title {
|
|
flex: 1;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.transfer-history-mobile-titlebar__batch {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.transfer-history-mobile-title :deep(h2) {
|
|
font-size: 1.875rem;
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.transfer-history-mobile-search {
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.transfer-history-mobile-search :deep(.v-field) {
|
|
background: var(--transfer-history-mobile-search-bg);
|
|
min-block-size: 3.75rem;
|
|
}
|
|
|
|
.transfer-history-mobile-search :deep(.v-field__input) {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.transfer-history-mobile-scroll {
|
|
overflow: visible !important;
|
|
min-block-size: 22rem;
|
|
}
|
|
|
|
.transfer-history-mobile-scroll :deep(.v-infinite-scroll__container),
|
|
.transfer-history-mobile-scroll :deep(.progressive-card-grid),
|
|
.transfer-history-mobile-scroll :deep(.progressive-card-grid__track) {
|
|
overflow: visible !important;
|
|
}
|
|
|
|
.transfer-history-mobile-scroll :deep(.v-infinite-scroll__side) {
|
|
padding-block: 0.75rem;
|
|
}
|
|
|
|
.transfer-history-mobile-state,
|
|
.transfer-history-mobile-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
}
|
|
|
|
.transfer-history-mobile-state {
|
|
min-block-size: 4rem;
|
|
}
|
|
|
|
.transfer-history-mobile-empty {
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
min-block-size: 18rem;
|
|
}
|
|
|
|
.transfer-history-mobile-record {
|
|
overflow: hidden;
|
|
border-radius: var(--app-surface-radius);
|
|
backdrop-filter: var(--transfer-history-mobile-surface-blur);
|
|
background: rgba(var(--v-theme-surface), var(--transfer-history-mobile-surface-opacity));
|
|
box-shadow: var(--app-card-rest-shadow);
|
|
}
|
|
|
|
.transfer-history-mobile-record--batch {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.transfer-history-mobile-record--selected {
|
|
outline: 2px solid rgb(var(--v-theme-primary));
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.transfer-history-mobile-record__header {
|
|
display: grid;
|
|
align-items: start;
|
|
gap: 0.75rem;
|
|
grid-template-columns: 3.5rem minmax(0, 1fr) auto 2rem;
|
|
grid-template-rows: auto auto;
|
|
padding-block: 0.85rem 0.75rem;
|
|
padding-inline: 1rem 0.85rem;
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster-wrapper {
|
|
position: relative;
|
|
grid-row: 1 / span 2;
|
|
inline-size: 3.5rem;
|
|
block-size: 5.25rem;
|
|
border-radius: var(--app-control-radius);
|
|
overflow: hidden;
|
|
background: var(--transfer-history-mobile-muted-bg);
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
border-radius: var(--app-control-radius);
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster :deep(.v-img__img) {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster-skeleton {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster-skeleton :deep(.v-skeleton-loader) {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
|
}
|
|
|
|
.transfer-history-mobile-record__poster-type {
|
|
position: absolute;
|
|
inset-block-end: 4px;
|
|
inset-inline-start: 4px;
|
|
padding: 2px;
|
|
border-radius: 999px;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
color: rgb(255, 255, 255);
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.transfer-history-mobile-record__heading {
|
|
align-self: center;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.transfer-history-mobile-record__title {
|
|
overflow: hidden;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
font-size: 1.125rem;
|
|
font-weight: 650;
|
|
line-height: 1.25;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transfer-history-mobile-record__subtitle {
|
|
overflow: hidden;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
font-size: 0.875rem;
|
|
line-height: 1.45;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transfer-history-mobile-record__status {
|
|
align-self: start;
|
|
border-radius: 999px !important;
|
|
font-weight: 650;
|
|
padding-inline: 0.65rem !important;
|
|
}
|
|
|
|
.transfer-history-mobile-record__menu {
|
|
place-self: start end;
|
|
}
|
|
|
|
.transfer-history-mobile-record__checkbox {
|
|
place-self: start end;
|
|
margin-block-start: -0.35rem;
|
|
margin-inline-end: -0.35rem;
|
|
}
|
|
|
|
.transfer-history-mobile-record__checkbox :deep(.v-selection-control) {
|
|
min-block-size: 2rem;
|
|
}
|
|
|
|
.transfer-history-mobile-record__meta {
|
|
grid-column: 2 / -1;
|
|
grid-row: 2;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 0.65rem;
|
|
margin-block-start: 0;
|
|
min-inline-size: 0;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transfer-history-mobile-record__meta > span {
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
font-size: 0.875rem;
|
|
line-height: 1.4;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.transfer-history-mobile-record__mode {
|
|
border-color: rgb(var(--v-theme-primary)) !important;
|
|
border-radius: 999px !important;
|
|
color: rgb(var(--v-theme-primary)) !important;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.transfer-history-mobile-record__dot {
|
|
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
|
|
}
|
|
|
|
.transfer-history-mobile-record__paths {
|
|
display: grid;
|
|
border: 0;
|
|
background: transparent;
|
|
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
gap: 0.45rem;
|
|
grid-template-columns: 1fr;
|
|
inline-size: 100%;
|
|
padding-block: 0.85rem 0.95rem;
|
|
padding-inline: 1rem;
|
|
text-align: start;
|
|
}
|
|
|
|
.transfer-history-mobile-record__path-row {
|
|
display: grid;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
grid-template-columns: var(--transfer-history-mobile-storage-width) minmax(0, 1fr);
|
|
}
|
|
|
|
.transfer-history-mobile-record__path-arrow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
inline-size: var(--transfer-history-mobile-storage-width);
|
|
padding-inline-start: 0.5rem;
|
|
}
|
|
|
|
.transfer-history-mobile-record__storage {
|
|
display: inline-flex;
|
|
overflow: hidden;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 6px;
|
|
background: var(--transfer-history-mobile-muted-bg);
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
font-size: 0.75rem;
|
|
line-height: 1.4;
|
|
max-inline-size: 100%;
|
|
min-block-size: 1.55rem;
|
|
padding-block: 0.125rem;
|
|
padding-inline: 0.425rem;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transfer-history-mobile-record__path-row p {
|
|
overflow: hidden;
|
|
margin: 0;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
font-size: 0.875rem;
|
|
line-height: 1.45;
|
|
overflow-wrap: anywhere;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.transfer-history-mobile-record__paths--expanded .transfer-history-mobile-record__path-row p {
|
|
white-space: normal;
|
|
}
|
|
|
|
.transfer-history-mobile-record__error {
|
|
display: flex;
|
|
align-items: center;
|
|
border: 1px solid rgba(var(--v-theme-error), 0.2);
|
|
border-radius: 8px;
|
|
background: rgba(var(--v-theme-error), 0.08);
|
|
color: rgb(var(--v-theme-error));
|
|
font-size: 0.875rem;
|
|
font-weight: 650;
|
|
gap: 0.5rem;
|
|
line-height: 1.45;
|
|
margin-block: 0 1rem;
|
|
margin-inline: 1rem;
|
|
overflow-wrap: anywhere;
|
|
padding-block: 0.65rem;
|
|
padding-inline: 0.75rem;
|
|
}
|
|
|
|
html[data-theme='transparent'] .transfer-history-mobile-page,
|
|
.v-theme--transparent .transfer-history-mobile-page {
|
|
--transfer-history-mobile-surface-opacity: var(--transparent-opacity-light, 0.2);
|
|
--transfer-history-mobile-search-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
|
--transfer-history-mobile-muted-bg: rgba(var(--v-theme-surface), var(--transparent-opacity, 0.3));
|
|
--transfer-history-mobile-border: rgba(var(--v-theme-on-surface), 0.14);
|
|
--transfer-history-mobile-surface-blur: blur(var(--transparent-blur, 10px));
|
|
}
|
|
</style>
|