缓存管理页面

This commit is contained in:
jxxghp
2025-05-29 20:49:19 +08:00
parent 61963ea497
commit b886f02043
5 changed files with 553 additions and 219 deletions

View File

@@ -40,6 +40,10 @@ export default {
media: 'Media',
unknown: 'Unknown',
notice: 'Notice',
itemsPerPage: 'Items per page',
pageText: '{0}-{1} of {2}',
noDataText: 'No data',
loadingText: 'Loading...',
},
mediaType: {
movie: 'Movie',
@@ -1109,7 +1113,7 @@ export default {
},
site: {
siteSync: 'Site Synchronization',
siteSyncDesc: 'Quickly sync site data from CookieCloud.',
siteSyncDesc: 'Quickly sync site data from CookieCloud',
enableLocalCookieCloud: 'Enable Local CookieCloud Server',
enableLocalCookieCloudHint:
'Use built-in CookieCloud service to sync site data, service address: http://localhost:3000/cookiecloud',
@@ -1155,7 +1159,7 @@ export default {
},
notification: {
channels: 'Notification Channels',
channelsDesc: 'Set message sending channel parameters.',
channelsDesc: 'Set message sending channel parameters',
organizeSuccess: 'Media Import',
downloadAdded: 'Download Added',
subscribeAdded: 'Subscribe Added',
@@ -1202,7 +1206,7 @@ export default {
},
words: {
customIdentifiers: 'Custom Identifiers',
identifiersDesc: 'Add rules to preprocess torrent names or file names to correct identification.',
identifiersDesc: 'Add rules to preprocess torrent names or file names to correct identification',
identifiersPlaceholder: 'Support regular expressions, special characters need \\ escape, one line for each rule',
identifiersHint: 'Support regular expressions, special characters need \\ escape, one line for each rule',
formatTitle: 'Supported configuration formats (mind the spaces):',
@@ -1242,7 +1246,7 @@ export default {
},
search: {
basicSettings: 'Basic Settings',
basicSettingsDesc: 'Set data sources, rule groups and other basic information.',
basicSettingsDesc: 'Set data sources, rule groups and other basic information',
recognizeSource: 'Recognition Data Source',
recognizeSourceDesc:
'Default is TMDB. Douban is usually more friendly for Chinese works, but some foreign works have incomplete information.',
@@ -1343,8 +1347,7 @@ export default {
},
scheduler: {
title: 'Scheduled Jobs',
subtitle:
"Includes built-in system services and plugin services. Manual execution will not affect the job's normal schedule.",
subtitle: 'Includes built-in system services and plugin services',
provider: 'Provider',
taskName: 'Task Name',
taskStatus: 'Task Status',
@@ -1391,6 +1394,52 @@ export default {
settingsSaveSuccess: 'Subscription basic settings saved successfully',
settingsSaveFailed: 'Failed to save subscription basic settings!',
},
cache: {
title: 'Cache',
description: 'Site cache and media recognition data cache management',
subtitle: 'Manage cached site resources',
refresh: 'Refresh Cache',
deleteSelected: 'Delete Selected',
clearAll: 'Clear All Cache',
refreshSuccess: 'Cache refresh completed',
refreshFailed: 'Failed to refresh cache',
clearSuccess: 'Cache clear completed',
clearFailed: 'Failed to clear cache',
deleteSuccess: 'Cache item deleted successfully',
deleteFailed: 'Failed to delete cache item',
deleteSelectedSuccess: 'Successfully deleted {count} cache items',
deleteSelectedFailed: 'Failed to delete cache items',
loadFailed: 'Failed to load cache data',
selectDeleteWarning: 'Please select cache items to delete',
reidentify: 'Re-identify',
reidentifySuccess: 'Re-identification completed',
reidentifyFailed: 'Re-identification failed',
poster: 'Poster',
torrentTitle: 'Title',
site: 'Site',
size: 'Size',
publishTime: 'Publish Time',
recognitionResult: 'Recognition Result',
actions: 'Actions',
unrecognized: 'Unrecognized',
noData: 'No cache data',
noDataHint: 'Click "Refresh Cache" button to get the latest torrent cache',
reidentifyDialog: {
title: 'Re-identify',
torrentInfo: 'Torrent Info',
tmdbId: 'TMDB ID',
tmdbIdHint: 'Optional, manually specify TMDB ID for recognition',
doubanId: 'Douban ID',
doubanIdHint: 'Optional, manually specify Douban ID for recognition',
autoHint: 'If no ID is specified, the torrent will be automatically re-identified',
cancel: 'Cancel',
confirm: 'Re-identify',
},
mediaType: {
movie: 'Movie',
tv: 'TV Show',
},
},
},
dialog: {
progress: {

View File

@@ -40,6 +40,10 @@ export default {
media: '媒体',
unknown: '未知',
notice: '注意',
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
noDataText: '没有数据',
loadingText: '加载中...',
},
mediaType: {
movie: '电影',
@@ -941,7 +945,7 @@ export default {
system: {
custom: '自定义',
basicSettings: '基础设置',
basicSettingsDesc: '设置服务器的全局功能',
basicSettingsDesc: '设置服务器的全局功能',
appDomain: '访问域名',
appDomainHint: '用于发送通知时,添加快捷跳转地址',
wallpaper: '背景壁纸',
@@ -1099,7 +1103,7 @@ export default {
},
site: {
siteSync: '站点同步',
siteSyncDesc: '从CookieCloud快速同步站点数据',
siteSyncDesc: '从CookieCloud快速同步站点数据',
enableLocalCookieCloud: '启用本地CookieCloud服务器',
enableLocalCookieCloudHint: '使用内建CookieCloud服务同步站点数据服务地址为http://localhost:3000/cookiecloud',
serviceAddress: '服务地址',
@@ -1142,7 +1146,7 @@ export default {
},
notification: {
channels: '通知渠道',
channelsDesc: '设置消息发送渠道参数',
channelsDesc: '设置消息发送渠道参数',
organizeSuccess: '资源入库',
downloadAdded: '资源下载',
subscribeAdded: '添加订阅',
@@ -1189,7 +1193,7 @@ export default {
},
words: {
customIdentifiers: '自定义识别词',
identifiersDesc: '添加规则对种子名或者文件名进行预处理以校正识别',
identifiersDesc: '添加规则对种子名或者文件名进行预处理以校正识别',
identifiersPlaceholder: '支持正则表达式,特殊字符需要\\转义,一行为一组',
identifiersHint: '支持正则表达式,特殊字符需要\\转义,一行为一组',
formatTitle: '支持的配置格式(注意空格):',
@@ -1225,7 +1229,7 @@ export default {
},
search: {
basicSettings: '基础设置',
basicSettingsDesc: '设定数据源、规则组等基础信息',
basicSettingsDesc: '设定数据源、规则组等基础信息',
recognizeSource: '识别数据源',
recognizeSourceDesc: '默认使用TMDB。豆瓣识别中文作品通常更友好但有些国外作品信息不完整。',
themoviedb: 'TheMovieDb',
@@ -1261,7 +1265,7 @@ export default {
},
directory: {
storage: '存储',
storageDesc: '设置本地或网盘存储',
storageDesc: '设置本地或网盘存储',
directory: '目录',
mediaType: '媒体类型',
directoryDesc: '设置媒体文件整理目录结构,按先后顺序依次匹配。',
@@ -1323,7 +1327,7 @@ export default {
},
scheduler: {
title: '定时作业',
subtitle: '包含系统内置服务以及插件提供的服务,手动执行不会影响作业正常的时间表。',
subtitle: '包含系统内置服务以及插件提供的服务',
provider: '提供者',
taskName: '任务名称',
taskStatus: '任务状态',
@@ -1373,6 +1377,48 @@ export default {
cache: {
title: '缓存',
description: '种子缓存、图片文件缓存管理',
subtitle: '管理缓存的站点资源',
refresh: '刷新缓存',
deleteSelected: '删除选中',
clearAll: '清空缓存',
refreshSuccess: '缓存刷新完成',
refreshFailed: '刷新缓存失败',
clearSuccess: '缓存清理完成',
clearFailed: '清理缓存失败',
deleteSuccess: '缓存项删除成功',
deleteFailed: '删除缓存项失败',
deleteSelectedSuccess: '成功删除 {count} 个缓存项',
deleteSelectedFailed: '删除缓存项失败',
loadFailed: '加载缓存数据失败',
selectDeleteWarning: '请选择要删除的缓存项',
reidentify: '重新识别',
reidentifySuccess: '重新识别完成',
reidentifyFailed: '重新识别失败',
poster: '海报',
torrentTitle: '标题',
site: '站点',
size: '大小',
publishTime: '发布时间',
recognitionResult: '识别结果',
actions: '操作',
unrecognized: '未识别',
noData: '暂无缓存数据',
noDataHint: '点击"刷新缓存"按钮获取最新的种子缓存',
reidentifyDialog: {
title: '重新识别',
torrentInfo: '种子信息',
tmdbId: 'TMDB ID',
tmdbIdHint: '可选手动指定TMDB ID进行识别',
doubanId: '豆瓣 ID',
doubanIdHint: '可选手动指定豆瓣ID进行识别',
autoHint: '如果不指定ID将自动重新识别该种子',
cancel: '取消',
confirm: '重新识别',
},
mediaType: {
movie: '电影',
tv: '电视剧',
},
},
},
dialog: {

View File

@@ -40,6 +40,10 @@ export default {
media: '媒體',
unknown: '未知',
notice: '注意',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
noDataText: '沒有數據',
loadingText: '加載中...',
},
mediaType: {
movie: '電影',
@@ -943,7 +947,7 @@ export default {
system: {
custom: '自定義',
basicSettings: '基礎設置',
basicSettingsDesc: '設置服務器的全局功能',
basicSettingsDesc: '設置服務器的全局功能',
appDomain: '訪問域名',
appDomainHint: '用於發送通知時,添加快捷跳轉地址',
wallpaper: '背景壁紙',
@@ -1101,7 +1105,7 @@ export default {
},
site: {
siteSync: '站點同步',
siteSyncDesc: '從CookieCloud快速同步站點數據',
siteSyncDesc: '從CookieCloud快速同步站點數據',
enableLocalCookieCloud: '啟用本地CookieCloud服務器',
enableLocalCookieCloudHint: '使用內建CookieCloud服務同步站點數據服務地址為http://localhost:3000/cookiecloud',
serviceAddress: '服務地址',
@@ -1144,7 +1148,7 @@ export default {
},
notification: {
channels: '通知渠道',
channelsDesc: '設置消息發送渠道參數',
channelsDesc: '設置消息發送渠道參數',
organizeSuccess: '資源入庫',
downloadAdded: '資源下載',
subscribeAdded: '添加訂閱',
@@ -1191,7 +1195,7 @@ export default {
},
words: {
customIdentifiers: '自定義識別詞',
identifiersDesc: '添加規則對種子名或者文件名進行預處理以校正識別',
identifiersDesc: '添加規則對種子名或者文件名進行預處理以校正識別',
identifiersPlaceholder: '支持正則表達式,特殊字符需要\\轉義,一行為一組',
identifiersHint: '支持正則表達式,特殊字符需要\\轉義,一行為一組',
formatTitle: '支持的配置格式(注意空格):',
@@ -1227,7 +1231,7 @@ export default {
},
search: {
basicSettings: '基礎設置',
basicSettingsDesc: '設定數據源、規則組等基礎信息',
basicSettingsDesc: '設定數據源、規則組等基礎信息',
recognizeSource: '識別數據源',
recognizeSourceDesc: '默認使用TMDB。豆瓣識別中文作品通常更友好但有些國外作品信息不完整。',
themoviedb: 'TheMovieDb',
@@ -1263,7 +1267,7 @@ export default {
},
directory: {
storage: '存儲',
storageDesc: '設置本地或網盤存儲',
storageDesc: '設置本地或網盤存儲',
directory: '目錄',
directoryDesc: '設置媒體文件整理目錄結構,按先後順序依次匹配。',
organizeAndScrap: '整理 & 刮削',
@@ -1324,7 +1328,7 @@ export default {
},
scheduler: {
scheduledTasks: '定時作業',
scheduledTasksDesc: '包含系統內置服務以及插件提供的服務,手動執行不會影響作業正常的時間表。',
scheduledTasksDesc: '包含系統內置服務以及插件提供的服務',
provider: '提供者',
taskName: '任務名稱',
taskStatus: '任務狀態',
@@ -1371,6 +1375,52 @@ export default {
settingsSaveSuccess: '訂閱基礎設置保存成功',
settingsSaveFailed: '訂閱基礎設置保存失敗!',
},
cache: {
title: '緩存',
description: '種子緩存、圖片文件緩存管理',
subtitle: '管理緩存的站點資源',
refresh: '刷新緩存',
deleteSelected: '刪除選中',
clearAll: '清空緩存',
refreshSuccess: '緩存刷新完成',
refreshFailed: '刷新緩存失敗',
clearSuccess: '緩存清理完成',
clearFailed: '清理緩存失敗',
deleteSuccess: '緩存項刪除成功',
deleteFailed: '刪除緩存項失敗',
deleteSelectedSuccess: '成功刪除 {count} 個緩存項',
deleteSelectedFailed: '刪除緩存項失敗',
loadFailed: '加載緩存數據失敗',
selectDeleteWarning: '請選擇要刪除的緩存項',
reidentify: '重新識別',
reidentifySuccess: '重新識別完成',
reidentifyFailed: '重新識別失敗',
poster: '海報',
torrentTitle: '標題',
site: '站點',
size: '大小',
publishTime: '發布時間',
recognitionResult: '識別結果',
actions: '操作',
unrecognized: '未識別',
noData: '暫無緩存數據',
noDataHint: '點擊"刷新緩存"按鈕獲取最新的種子緩存',
reidentifyDialog: {
title: '重新識別',
torrentInfo: '種子信息',
tmdbId: 'TMDB ID',
tmdbIdHint: '可選手動指定TMDB ID進行識別',
doubanId: '豆瓣 ID',
doubanIdHint: '可選手動指定豆瓣ID進行識別',
autoHint: '如果不指定ID將自動重新識別該種子',
cancel: '取消',
confirm: '重新識別',
},
mediaType: {
movie: '電影',
tv: '電視劇',
},
},
},
dialog: {
progress: {

View File

@@ -1,199 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.db.models import User
from app.db.user_oper import get_current_active_superuser
from app.utils.crypto import HashUtils
router = APIRouter()
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
def torrents_cache(_: User = Depends(get_current_active_superuser)):
"""
获取当前种子缓存数据
"""
torrents_chain = TorrentsChain()
# 获取spider和rss两种缓存
if settings.SUBSCRIBE_MODE == "rss":
cache_info = torrents_chain.get_torrents("rss")
else:
cache_info = torrents_chain.get_torrents("spider")
# 统计信息
torrent_count = sum(len(torrents) for torrents in cache_info.values())
# 转换为前端需要的格式
torrent_data = []
for domain, contexts in cache_info.items():
for context in contexts:
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
torrent_data.append({
"hash": torrent_hash,
"domain": domain,
"title": context.torrent_info.title,
"description": context.torrent_info.description,
"size": context.torrent_info.size,
"pubdate": context.torrent_info.pubdate,
"site_name": context.torrent_info.site_name,
"media_name": context.media_info.title if context.media_info else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"season_episode": context.meta_info.season_episode if context.meta_info else "",
"resource_term": context.meta_info.resource_term if context.meta_info else "",
"enclosure": context.torrent_info.enclosure,
"page_url": context.torrent_info.page_url,
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
})
return schemas.Response(success=True, data={
"count": torrent_count,
"sites": len(cache_info),
"data": torrent_data
})
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存",
response_model=schemas.Response)
def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser)):
"""
删除指定的种子缓存
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找并删除指定种子
original_count = len(cache_data[domain])
cache_data[domain] = [
context for context in cache_data[domain]
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
]
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, torrents_chain.cache_file)
return schemas.Response(success=True, message="种子删除成功")
except Exception as e:
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
def clear_cache(_: User = Depends(get_current_active_superuser)):
"""
清理所有种子缓存
"""
torrents_chain = TorrentsChain()
try:
torrents_chain.clear_torrents()
return schemas.Response(success=True, message="种子缓存清理完成")
except Exception as e:
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
def refresh_cache(_: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
"""
from app.chain.torrents import TorrentsChain
torrents_chain = TorrentsChain()
try:
result = torrents_chain.refresh()
# 统计刷新结果
total_count = sum(len(torrents) for torrents in result.values())
sites_count = len(result)
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
except Exception as e:
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
def reidentify_cache(domain: str, torrent_hash: str,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
_: User = Depends(get_current_active_superuser)):
"""
重新识别指定的种子
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
media_chain = MediaChain()
try:
# 获取当前缓存
cache_data = torrents_chain.get_torrents()
if domain not in cache_data:
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
# 查找指定种子
target_context = None
for context in cache_data[domain]:
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
target_context = context
break
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
meta = MetaInfo(title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description)
if tmdbid or doubanid:
# 手动指定媒体信息
mediainfo = MediaChain().recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
else:
# 自动重新识别
mediainfo = media_chain.recognize_by_meta(meta)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
else:
# 清理多余数据
mediainfo.clear()
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
torrents_chain.save_cache(cache_data, TorrentsChain().cache_file)
return schemas.Response(success=True, message="重新识别完成", data={
"media_name": mediainfo.title if mediainfo else "",
"media_year": mediainfo.year if mediainfo else "",
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
})
except Exception as e:
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")

View File

@@ -0,0 +1,388 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
const display = useDisplay()
// 提示框
const $toast = useToast()
// 缓存数据
const cacheData = ref<TorrentCacheData>({
count: 0,
sites: 0,
data: [],
})
// 选中的缓存项
const selectedItems = ref<string[]>([])
// 加载状态
const loading = ref(false)
// 重新识别对话框
const reidentifyDialog = ref(false)
const currentReidentifyItem = ref<TorrentCacheItem | null>(null)
const tmdbId = ref<number | undefined>()
const doubanId = ref<string | undefined>()
// 调用API加载缓存数据
async function loadCacheData() {
try {
loading.value = true
const res: any = await api.get('torrent/cache')
cacheData.value = res.data
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.loadFailed'))
} finally {
loading.value = false
}
}
// 清空所有缓存
async function clearAllCache() {
try {
loading.value = true
await api.delete('torrent/cache')
$toast.success(t('setting.cache.clearSuccess'))
await loadCacheData()
selectedItems.value = []
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.clearFailed'))
} finally {
loading.value = false
}
}
// 刷新缓存
async function refreshCache() {
try {
loading.value = true
const res: any = await api.post('torrent/cache/refresh')
$toast.success(res.message || t('setting.cache.refreshSuccess'))
await loadCacheData()
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.refreshFailed'))
} finally {
loading.value = false
}
}
// 删除选中的缓存项
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
return
}
try {
loading.value = true
const deletePromises = selectedItems.value.map(hash => {
const item = cacheData.value.data.find(d => d.hash === hash)
if (item) {
return api.delete(`torrent/cache/${item.domain}/${hash}`)
}
return Promise.resolve()
})
await Promise.all(deletePromises)
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: selectedItems.value.length }))
await loadCacheData()
selectedItems.value = []
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.deleteSelectedFailed'))
} finally {
loading.value = false
}
}
// 删除单个缓存项
async function deleteSingleItem(item: TorrentCacheItem) {
try {
loading.value = true
await api.delete(`torrent/cache/${item.domain}/${item.hash}`)
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
// 从选中列表中移除
const index = selectedItems.value.indexOf(item.hash)
if (index > -1) {
selectedItems.value.splice(index, 1)
}
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.deleteFailed'))
} finally {
loading.value = false
}
}
// 打开重新识别对话框
function openReidentifyDialog(item: TorrentCacheItem) {
currentReidentifyItem.value = item
tmdbId.value = undefined
doubanId.value = undefined
reidentifyDialog.value = true
}
// 重新识别
async function performReidentify() {
if (!currentReidentifyItem.value) return
try {
loading.value = true
const params: any = {}
if (tmdbId.value) params.tmdbid = tmdbId.value
if (doubanId.value) params.doubanid = doubanId.value
const res: any = await api.post(
`torrent/cache/reidentify/${currentReidentifyItem.value.domain}/${currentReidentifyItem.value.hash}`,
null,
{
params,
},
)
$toast.success(res.message || t('setting.cache.reidentifySuccess'))
await loadCacheData()
reidentifyDialog.value = false
} catch (e) {
console.log(e)
$toast.error(t('setting.cache.reidentifyFailed'))
} finally {
loading.value = false
}
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化日期
function formatDate(dateStr: string): string {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
// 获取媒体类型颜色
function getMediaTypeColor(type: string): string {
switch (type) {
case t('setting.cache.mediaType.movie'):
return 'primary'
case t('setting.cache.mediaType.tv'):
return 'success'
default:
return 'default'
}
}
onMounted(() => {
loadCacheData()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ t('setting.cache.title') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.cache.subtitle') }}</VCardSubtitle>
<template #append>
<div class="d-flex gap-2">
<VBtn icon color="primary" :loading="loading" @click="refreshCache">
<VIcon>mdi-refresh</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refresh') }}</VTooltip>
</VBtn>
<VBtn
icon
color="warning"
:loading="loading"
:disabled="selectedItems.length === 0"
@click="deleteSelectedItems"
>
<VIcon>mdi-delete</VIcon>
<VTooltip activator="parent" location="bottom"
>{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})</VTooltip
>
</VBtn>
<VBtn icon color="error" :loading="loading" @click="clearAllCache">
<VIcon>mdi-delete-sweep</VIcon>
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</template>
</VCardItem>
<!-- 缓存列表 -->
<VDataTable
v-model="selectedItems"
:headers="[
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '80px' },
{ title: t('setting.cache.torrentTitle'), key: 'title', sortable: true },
{ title: t('setting.cache.site'), key: 'site_name', sortable: true, width: '120px' },
{ title: t('setting.cache.size'), key: 'size', sortable: true, width: '100px' },
{ title: t('setting.cache.publishTime'), key: 'pubdate', sortable: true, width: '150px' },
{ title: t('setting.cache.recognitionResult'), key: 'media_info', sortable: false, width: '200px' },
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '150px' },
]"
:items="cacheData.data"
:loading="loading"
item-value="hash"
show-select
class="text-no-wrap"
:items-per-page-text="t('common.itemsPerPage')"
:no-data-text="t('common.noDataText')"
:loading-text="t('common.loadingText')"
>
<!-- 全选复选框 -->
<template #header.data-table-select="{ allSelected, selectAll, someSelected }">
<VCheckbox
:indeterminate="someSelected && !allSelected"
:model-value="allSelected"
@update:model-value="(value: boolean | null) => selectAll(value as boolean)"
/>
</template>
<!-- 海报列 -->
<template #item.poster="{ item }">
<VAvatar size="60" rounded="lg" class="ma-1">
<VImg v-if="item.poster_path" :src="item.poster_path" :alt="item.media_name || item.title" cover />
<VIcon v-else size="30" color="grey-lighten-1"> mdi-movie-open </VIcon>
</VAvatar>
</template>
<!-- 标题列 -->
<template #item.title="{ item }">
<div class="d-flex flex-column">
<div class="text-subtitle-2 font-weight-bold">
{{ item.title }}
</div>
<div v-if="item.description" class="text-caption text-grey">
{{ item.description }}
</div>
<div v-if="item.season_episode || item.resource_term" class="text-caption text-primary mt-1">
{{ item.season_episode }} {{ item.resource_term }}
</div>
</div>
</template>
<!-- 大小列 -->
<template #item.size="{ item }">
{{ formatFileSize(item.size) }}
</template>
<!-- 发布时间列 -->
<template #item.pubdate="{ item }">
{{ formatDate(item.pubdate || '') }}
</template>
<!-- 识别结果列 -->
<template #item.media_info="{ item }">
<div v-if="item.media_name" class="d-flex flex-column">
<div class="text-subtitle-2">
{{ item.media_name }}
<VChip v-if="item.media_type" :color="getMediaTypeColor(item.media_type)" size="x-small" class="ml-1">
{{ item.media_type }}
</VChip>
</div>
<div v-if="item.media_year" class="text-caption text-grey">
{{ item.media_year }}
</div>
</div>
<div v-else class="text-caption text-grey">
{{ t('setting.cache.unrecognized') }}
</div>
</template>
<!-- 操作列 -->
<template #item.actions="{ item }">
<div class="d-flex gap-1">
<VBtn size="small" color="primary" variant="text" @click="openReidentifyDialog(item)">
<VIcon size="16">mdi-refresh</VIcon>
</VBtn>
<VBtn size="small" color="error" variant="text" @click="deleteSingleItem(item)">
<VIcon size="16">mdi-delete</VIcon>
</VBtn>
<VBtn v-if="item.page_url" size="small" color="info" variant="text" :href="item.page_url" target="_blank">
<VIcon size="16">mdi-open-in-new</VIcon>
</VBtn>
</div>
</template>
<!-- 空状态 -->
<template #no-data>
<div class="text-center pa-4">
<VIcon size="64" color="grey-lighten-2" class="mb-4"> mdi-database-off </VIcon>
<div class="text-h6 text-grey">{{ t('setting.cache.noData') }}</div>
<div class="text-body-2 text-grey">
{{ t('setting.cache.noDataHint') }}
</div>
</div>
</template>
</VDataTable>
</VCard>
<!-- 重新识别对话框 -->
<VDialog v-model="reidentifyDialog" scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
<VCardText>
<div class="mb-4">
<div class="text-subtitle-2 mb-2">{{ t('setting.cache.reidentifyDialog.torrentInfo') }}</div>
<div class="text-body-2">{{ currentReidentifyItem?.title }}</div>
<div class="text-caption text-grey">{{ currentReidentifyItem?.site_name }}</div>
</div>
<VTextField
v-model="tmdbId"
:label="t('setting.cache.reidentifyDialog.tmdbId')"
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
type="number"
clearable
/>
<VTextField
v-model="doubanId"
:label="t('setting.cache.reidentifyDialog.doubanId')"
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
clearable
class="mt-4"
/>
<VAlert type="info" variant="tonal" class="mt-4">
{{ t('setting.cache.reidentifyDialog.autoHint') }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="grey" variant="text" @click="reidentifyDialog = false">
{{ t('setting.cache.reidentifyDialog.cancel') }}
</VBtn>
<VBtn color="primary" :loading="loading" @click="performReidentify">
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>